mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-30 07:33:59 +08:00
Merge remote-tracking branch 'origin/main' into feat/111-editor-flag
# Conflicts: # deploy/docker/sandbox/Dockerfile.base
This commit is contained in:
@@ -132,6 +132,21 @@ If `/readyz` fails, k3s is still starting or has crashed. Check container logs (
|
||||
|
||||
If pods are in `CrashLoopBackOff`, `ImagePullBackOff`, or `Pending`, investigate those pods specifically.
|
||||
|
||||
Also check for node pressure conditions that cause the kubelet to evict pods and reject scheduling:
|
||||
|
||||
```bash
|
||||
# Check node conditions (DiskPressure, MemoryPressure, PIDPressure)
|
||||
docker exec openshell-cluster-<name> sh -lc 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get nodes -o jsonpath="{range .items[*]}{.metadata.name}{range .status.conditions[*]} {.type}={.status}{end}{\"\n\"}{end}"'
|
||||
|
||||
# Check disk usage inside the container
|
||||
docker exec openshell-cluster-<name> df -h /
|
||||
|
||||
# Check memory usage
|
||||
docker exec openshell-cluster-<name> free -h
|
||||
```
|
||||
|
||||
If any pressure condition is `True`, pods will be evicted and new ones rejected. The bootstrap now detects `HEALTHCHECK_NODE_PRESSURE` markers from the health-check script and aborts early with a clear diagnosis. To fix: free disk/memory on the host, then recreate the gateway.
|
||||
|
||||
### Step 4: Check OpenShell Server StatefulSet
|
||||
|
||||
The OpenShell server is deployed via a HelmChart CR as a StatefulSet with persistent storage. Check its status:
|
||||
@@ -215,7 +230,7 @@ docker save <image-ref> | docker exec -i openshell-cluster-<name> ctr -a /run/k3
|
||||
docker exec openshell-cluster-<name> cat /etc/rancher/k3s/registries.yaml
|
||||
|
||||
# Test pulling an image manually from inside the cluster
|
||||
docker exec openshell-cluster-<name> sh -lc 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml crictl pull ghcr.io/nvidia/openshell/server:latest'
|
||||
docker exec openshell-cluster-<name> sh -lc 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml crictl pull ghcr.io/nvidia/openshell/gateway:latest'
|
||||
```
|
||||
|
||||
If `registries.yaml` is missing or has wrong values, verify env wiring (`OPENSHELL_REGISTRY_HOST`, `OPENSHELL_REGISTRY_INSECURE`, username/password for authenticated registries).
|
||||
@@ -305,6 +320,8 @@ If DNS is broken, all image pulls from the distribution registry will fail, as w
|
||||
| Port conflict | Another service on 6443 or the configured gateway host port (default 8080) | Stop conflicting service or use `--port` on `openshell gateway start` to pick a different host port |
|
||||
| gRPC connect refused to `127.0.0.1:443` in CI | Docker daemon is remote (`DOCKER_HOST=tcp://...`) but metadata still points to loopback | Verify metadata endpoint host matches `DOCKER_HOST` and includes non-loopback host |
|
||||
| DNS failures inside container | Entrypoint DNS detection failed | Check `/etc/rancher/k3s/resolv.conf` and container startup logs |
|
||||
| Node DiskPressure / MemoryPressure / PIDPressure | Insufficient disk, memory, or PIDs on host | Free disk (`docker system prune -a --volumes`), increase memory, or expand host resources. Bootstrap auto-detects via `HEALTHCHECK_NODE_PRESSURE` marker |
|
||||
| Pods evicted with "The node had condition: [DiskPressure]" | Host disk full, kubelet evicting pods | Free disk space on host, then `openshell gateway destroy <name> && openshell gateway start` |
|
||||
| `metrics-server` errors in logs | Normal k3s noise, not the root cause | These errors are benign — look for the actual failing health check component |
|
||||
| Stale NotReady nodes from previous deploys | Volume reused across container recreations | The deploy flow now auto-cleans stale nodes; if it still fails, manually delete NotReady nodes (see Step 3) or choose "Recreate" when prompted |
|
||||
| gRPC `UNIMPLEMENTED` for newer RPCs in push mode | Helm values still point at older pulled images instead of the pushed refs | Verify rendered `navigator-helmchart.yaml` uses the expected push refs (`server`, `sandbox`, `pki-job`) and not `:latest` |
|
||||
@@ -363,6 +380,12 @@ run docker exec "${CONTAINER}" sh -lc "${KCFG} kubectl get --raw='/readyz'" 2>&1
|
||||
echo "=== Nodes ==="
|
||||
run docker exec "${CONTAINER}" sh -lc "${KCFG} kubectl get nodes -o wide" 2>&1
|
||||
|
||||
echo "=== Node Conditions ==="
|
||||
run docker exec "${CONTAINER}" sh -lc "${KCFG} kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{range .status.conditions[*]} {.type}={.status}{end}{\"\n\"}{end}'" 2>&1
|
||||
|
||||
echo "=== Disk Usage ==="
|
||||
run docker exec "${CONTAINER}" df -h / 2>&1
|
||||
|
||||
echo "=== All Pods ==="
|
||||
run docker exec "${CONTAINER}" sh -lc "${KCFG} kubectl get pods -A -o wide" 2>&1
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: sbom
|
||||
description: Generate and manage Software Bill of Materials (SBOMs) for the OpenShell project. Covers SBOM generation with Syft, license resolution via public registries, and CSV export for compliance review. Trigger keywords - SBOM, sbom, bill of materials, license audit, license resolution, generate sbom, sbom csv, dependency license, supply chain, license scan.
|
||||
---
|
||||
|
||||
# SBOM Generation and License Resolution
|
||||
|
||||
Generate CycloneDX SBOMs, resolve missing licenses, and export to CSV for compliance review.
|
||||
|
||||
## Overview
|
||||
|
||||
The OpenShell SBOM tooling produces CycloneDX JSON SBOMs using Syft, resolves missing or hash-based licenses by querying public registries (crates.io, npm, PyPI), and exports the results to CSV for stakeholder review.
|
||||
|
||||
SBOMs are **release artifacts only** -- they are generated on demand and not committed to the repository. Output lands in `deploy/sbom/output/` (gitignored).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `mise install` has been run (installs Syft and other tools)
|
||||
- The repository is checked out at the root
|
||||
|
||||
## Workflow 1: Full SBOM Generation (One Command)
|
||||
|
||||
```bash
|
||||
mise run sbom
|
||||
```
|
||||
|
||||
This single command chains three stages:
|
||||
|
||||
1. **Generate** (`sbom:generate`): Syft scans the workspace source tree and produces a CycloneDX JSON SBOM
|
||||
2. **Resolve** (`sbom:resolve`): Public registry APIs fill in missing or hash-based licenses in the JSON
|
||||
3. **CSV** (`sbom:csv`): JSON SBOMs are converted to CSV for review
|
||||
|
||||
Output directory: `deploy/sbom/output/`
|
||||
|
||||
After running, the user can find:
|
||||
- `deploy/sbom/output/*.cdx.json` -- full CycloneDX SBOMs
|
||||
- `deploy/sbom/output/*.csv` -- CSV exports ready for spreadsheet review
|
||||
|
||||
## Workflow 2: Individual Stages
|
||||
|
||||
Run stages independently when debugging or iterating:
|
||||
|
||||
```bash
|
||||
mise run sbom:generate # Generate JSON SBOMs only (requires Syft)
|
||||
mise run sbom:resolve # Resolve licenses in existing JSONs (queries APIs)
|
||||
mise run sbom:csv # Convert existing JSONs to CSV
|
||||
```
|
||||
|
||||
## Workflow 3: License Check (CI Advisory)
|
||||
|
||||
```bash
|
||||
mise run sbom:check
|
||||
```
|
||||
|
||||
Reports unresolved licenses without failing. Intended for PR CI as a non-blocking advisory check. Requires that SBOMs have already been generated (`mise run sbom:generate`).
|
||||
|
||||
## Workflow 4: Processing External SBOMs
|
||||
|
||||
The Python scripts accept explicit file paths, so they can process SBOMs from any source (e.g., NVIDIA nSpect pipeline output):
|
||||
|
||||
```bash
|
||||
uv run python deploy/sbom/resolve_licenses.py /path/to/external-sbom.json
|
||||
uv run python deploy/sbom/sbom_to_csv.py /path/to/external-sbom.json
|
||||
```
|
||||
|
||||
## License Resolution Details
|
||||
|
||||
The resolver queries these public registries:
|
||||
|
||||
| Registry | Package URL prefix | Method |
|
||||
|----------|-------------------|--------|
|
||||
| crates.io | `pkg:cargo/*` | REST API |
|
||||
| npm | `pkg:npm/*` | Registry API |
|
||||
| PyPI | `pkg:pypi/*` | JSON API |
|
||||
| Go modules | `pkg:golang/*` | Known license map (no API) |
|
||||
| Debian/Ubuntu | `pkg:deb/*` | Known license map |
|
||||
|
||||
Components from private registries (e.g., `@openclaw/*` npm packages) are not resolved and will appear in the "unresolved" report.
|
||||
|
||||
## Output Files
|
||||
|
||||
| Pattern | Description |
|
||||
|---------|-------------|
|
||||
| `deploy/sbom/output/openshell-source-{version}.cdx.json` | CycloneDX JSON SBOM |
|
||||
| `deploy/sbom/output/openshell-source-{version}.csv` | CSV export (name, version, type, purl, licenses, bom-ref) |
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `deploy/sbom/resolve_licenses.py` | License resolution script |
|
||||
| `deploy/sbom/sbom_to_csv.py` | JSON-to-CSV converter |
|
||||
| `tasks/sbom.toml` | Mise task definitions |
|
||||
| `mise.toml` | Syft tool definition (under `[tools]`) |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Full pipeline | `mise run sbom` |
|
||||
| Generate only | `mise run sbom:generate` |
|
||||
| Resolve licenses | `mise run sbom:resolve` |
|
||||
| Export CSV | `mise run sbom:csv` |
|
||||
| CI license check | `mise run sbom:check` |
|
||||
| Process external SBOM | `uv run python deploy/sbom/resolve_licenses.py <file>` |
|
||||
@@ -59,16 +59,16 @@
|
||||
- Proto files also include: `proto/inference.proto` (navigator.inference.v1)
|
||||
|
||||
## Container/Build Details
|
||||
- Four runtime images: sandbox (5 stages), server (2 stages), cluster (k3s base), pki-job (Alpine)
|
||||
- Four runtime images: sandbox (5 stages), gateway (2 stages), cluster (k3s base), pki-job (Alpine)
|
||||
- Two build-only images: python-wheels (Linux multi-arch), python-wheels-macos (osxcross cross-compile)
|
||||
- CI image: Dockerfile.ci (Ubuntu 24.04, pre-installs docker/buildx/aws/kubectl/helm/mise/uv/sccache/socat)
|
||||
- Cross-compilation: `deploy/docker/cross-build.sh` shared by sandbox + server Dockerfiles
|
||||
- Cross-compilation: `deploy/docker/cross-build.sh` shared by sandbox + gateway Dockerfiles
|
||||
- Sandbox image has coding-agents stage: Claude CLI (native installer), OpenCode, Codex (npm)
|
||||
- Helm chart deploys a StatefulSet (NOT Deployment), PVC 1Gi at /var/navigator
|
||||
- Cluster image does NOT bundle image tarballs -- components pulled at runtime from distribution registry
|
||||
- PKI job generates CA + server cert + client cert for mTLS (RSA 2048, 10yr, Helm pre-install hook)
|
||||
- Build tasks in `tasks/*.toml`; scripts in `tasks/scripts/`
|
||||
- `cluster-deploy-fast.sh` supports both auto mode (git diff) and explicit targets (server/sandbox/pki-job/chart/all)
|
||||
- `cluster-deploy-fast.sh` supports both auto mode (git diff) and explicit targets (gateway/sandbox/chart/all)
|
||||
- `cluster-bootstrap.sh` ensures local Docker registry on port 5000, pushes all components, then deploys
|
||||
- Default values.yaml: repository is CloudFront-backed CDN, tag: "latest", pullPolicy: Always
|
||||
- Envoy Gateway version: v1.5.8 (set in mise.toml)
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
component:
|
||||
description: "Component to build (server, sandbox, cluster)"
|
||||
description: "Component to build (gateway, sandbox, cluster)"
|
||||
required: true
|
||||
type: string
|
||||
timeout-minutes:
|
||||
|
||||
@@ -9,11 +9,11 @@ permissions:
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-server:
|
||||
build-gateway:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'e2e')
|
||||
uses: ./.github/workflows/docker-build.yml
|
||||
with:
|
||||
component: server
|
||||
component: gateway
|
||||
|
||||
build-sandbox:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'e2e')
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
component: cluster
|
||||
|
||||
e2e:
|
||||
needs: [build-server, build-sandbox, build-cluster]
|
||||
needs: [build-gateway, build-sandbox, build-cluster]
|
||||
uses: ./.github/workflows/e2e-test.yml
|
||||
with:
|
||||
image-tag: ${{ github.sha }}
|
||||
|
||||
@@ -14,10 +14,10 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
build-server:
|
||||
build-gateway:
|
||||
uses: ./.github/workflows/docker-build.yml
|
||||
with:
|
||||
component: server
|
||||
component: gateway
|
||||
|
||||
build-sandbox:
|
||||
uses: ./.github/workflows/docker-build.yml
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
tag-ghcr-latest:
|
||||
name: Tag GHCR Images as Latest
|
||||
needs: [build-server, build-sandbox, build-cluster]
|
||||
needs: [build-gateway, build-sandbox, build-cluster]
|
||||
runs-on: build-amd64
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REGISTRY="ghcr.io/nvidia/openshell"
|
||||
for component in server sandbox cluster; do
|
||||
for component in gateway sandbox cluster; do
|
||||
echo "Tagging ${REGISTRY}/${component}:${{ github.sha }} as latest..."
|
||||
docker buildx imagetools create \
|
||||
--prefer-index=false \
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
build-python-wheels:
|
||||
name: Stage Python Wheels
|
||||
needs: [build-server, build-sandbox, build-cluster]
|
||||
needs: [build-gateway, build-sandbox, build-cluster]
|
||||
runs-on: build-amd64
|
||||
timeout-minutes: 120
|
||||
outputs:
|
||||
|
||||
@@ -184,6 +184,9 @@ _build/
|
||||
# Docker build artifacts (image tarballs, packaged helm charts)
|
||||
deploy/docker/.build/
|
||||
|
||||
# SBOM generated output (JSON, CSV) — release artifacts, not committed
|
||||
deploy/sbom/output/
|
||||
|
||||
# Local mise settings
|
||||
mise.local.toml
|
||||
|
||||
|
||||
+15969
-2262
File diff suppressed because it is too large
Load Diff
+27
@@ -0,0 +1,27 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# cargo-about configuration for generating third-party license notices.
|
||||
# See https://embarkstudios.github.io/cargo-about/
|
||||
|
||||
# Accepted licenses (SPDX expressions). All permissive/weak-copyleft licenses
|
||||
# found in this workspace's dependency tree.
|
||||
accepted = [
|
||||
"0BSD",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-1-Clause",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BSL-1.0",
|
||||
"CC0-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"ISC",
|
||||
"LGPL-2.1-or-later",
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
"OpenSSL",
|
||||
"Unicode-3.0",
|
||||
"Unlicense",
|
||||
"Zlib",
|
||||
]
|
||||
@@ -238,7 +238,7 @@ The CLI is the primary way users interact with the platform. It provides command
|
||||
- **Provider management** (`openshell provider`): Create, update, list, and delete external service credentials.
|
||||
- **Inference management** (`openshell cluster inference`): Configure cluster-level inference by specifying a provider and model. The gateway resolves endpoint and credential details from the named provider record.
|
||||
|
||||
The CLI resolves which gateway to operate on through a priority chain: explicit `--gateway` flag, then the `OPENSHELL_GATEWAY` environment variable, then the active gateway set by `openshell gateway select`. It supports TLS client certificates for mutual authentication with the gateway.
|
||||
The CLI resolves which gateway to operate on through a priority chain: explicit `--gateway` flag, then the `OPENSHELL_GATEWAY` environment variable, then the active gateway set by `openshell gateway select`. Gateway names are exposed to shell completion from local metadata, and `openshell gateway select` opens an interactive chooser on a TTY while falling back to a printed list in non-interactive use. The CLI supports TLS client certificates for mutual authentication with the gateway.
|
||||
|
||||
## How Users Get Started
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ deploy/
|
||||
docker/
|
||||
.dockerignore
|
||||
Dockerfile.sandbox # Sandbox container (runs agent code in isolation)
|
||||
Dockerfile.server # Gateway container (orchestration / control plane)
|
||||
Dockerfile.gateway # Gateway container (orchestration / control plane)
|
||||
Dockerfile.cluster # Airgapped k3s cluster with Helm charts and manifests
|
||||
Dockerfile.ci # CI runner image with pre-installed toolchain
|
||||
Dockerfile.python-wheels # Multi-arch Linux wheel builder for the Python CLI package
|
||||
@@ -74,11 +74,11 @@ The sandbox container runs inside each sandbox pod. It contains the sandbox supe
|
||||
- Policy files are mounted at `/var/navigator/policy.rego` (rules) and `/var/navigator/data.yaml` (data) when running in file-based policy mode.
|
||||
- The Python SDK is copied directly into the venv's site-packages at `/app/.venv/lib/python3.12/site-packages/openshell/`.
|
||||
|
||||
### Gateway Image (`openshell/server`)
|
||||
### Gateway Image (`openshell/gateway`)
|
||||
|
||||
The gateway container runs the control plane / orchestration service.
|
||||
|
||||
**Build stages** (2 stages in `deploy/docker/Dockerfile.server`):
|
||||
**Build stages** (2 stages in `deploy/docker/Dockerfile.gateway`):
|
||||
|
||||
1. **builder** -- Two-pass Rust compilation with dependency caching:
|
||||
- First pass copies only `Cargo.toml`/`Cargo.lock` files and creates dummy source files (`fn main() {}` / empty `lib.rs`) to build dependencies in isolation. This layer is cached unless dependency manifests change.
|
||||
@@ -195,7 +195,7 @@ Modifies the HelmChart manifest at `/var/lib/rancher/k3s/server/manifests/naviga
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `IMAGE_REPO_BASE` | Rewrites `repository:` and `sandboxImage:` to use the specified base path |
|
||||
| `PUSH_IMAGE_REFS` | Parses comma-separated image refs and rewrites exact server and sandbox references (matching on path component `/server:`, `/sandbox:`) |
|
||||
| `PUSH_IMAGE_REFS` | Parses comma-separated image refs and rewrites exact gateway and sandbox references (matching on path component `/gateway:`, `/sandbox:`) |
|
||||
| `IMAGE_TAG` | Replaces `:latest` tags with the specified tag (handles both quoted and unquoted `tag: latest` formats) |
|
||||
| `IMAGE_PULL_POLICY` | Replaces `pullPolicy: Always` with the specified policy (e.g., `IfNotPresent`) |
|
||||
| `SSH_GATEWAY_HOST` / `SSH_GATEWAY_PORT` | Replaces `__SSH_GATEWAY_HOST__` and `__SSH_GATEWAY_PORT__` placeholders; clears to defaults if unset |
|
||||
@@ -228,7 +228,7 @@ The Helm chart at `deploy/helm/navigator/` deploys the gateway to Kubernetes as
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/nvidia/openshell/server
|
||||
repository: ghcr.io/nvidia/openshell/gateway
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
@@ -268,7 +268,7 @@ The chart deploys a **StatefulSet** (not a Deployment) with a `volumeClaimTempla
|
||||
|
||||
The StatefulSet always mounts TLS secrets as volumes and sets environment variables for the server to use `rustls` with mTLS:
|
||||
|
||||
- **Volumes**: `tls-cert` (from `navigator-server-tls` secret, mounted at `/etc/navigator-tls/server/`) and `tls-client-ca` (from `navigator-server-client-ca` secret, mounted at `/etc/navigator-tls/client-ca/`).
|
||||
- **Volumes**: `tls-cert` (from `navigator-server-tls` secret, mounted at `/etc/openshell-tls/server/`) and `tls-client-ca` (from `navigator-server-client-ca` secret, mounted at `/etc/openshell-tls/client-ca/`).
|
||||
- **Environment**: `OPENSHELL_TLS_CERT`, `OPENSHELL_TLS_KEY`, `OPENSHELL_TLS_CLIENT_CA` pointing to the mounted files.
|
||||
- **Client TLS secret name**: `OPENSHELL_CLIENT_TLS_SECRET_NAME` set to the `clientTlsSecretName` value, used by the server to inject TLS volume mounts into sandbox pod specs.
|
||||
- **gRPC endpoint**: `https://navigator.navigator.svc.cluster.local:8080` so sandbox pods connect over mTLS.
|
||||
@@ -306,7 +306,7 @@ All builds use mise tasks defined in `tasks/*.toml` (included from `mise.toml`).
|
||||
|---|---|
|
||||
| `mise run docker:build` | Build all runtime images (sandbox, gateway, cluster) |
|
||||
| `mise run docker:build:sandbox` | Build sandbox image |
|
||||
| `mise run docker:build:server` | Build gateway image |
|
||||
| `mise run docker:build:gateway` | Build gateway image |
|
||||
| `mise run docker:build:cluster` | Build k3s cluster image (packages Helm charts first) |
|
||||
| `mise run docker:build:ci` | Build CI runner image |
|
||||
| `mise run docker:build:cluster:multiarch` | Build multi-arch cluster image and push to a registry |
|
||||
@@ -336,11 +336,11 @@ All builds use mise tasks defined in `tasks/*.toml` (included from `mise.toml`).
|
||||
| `Cargo.toml`, `Cargo.lock`, `proto/*`, `deploy/docker/cross-build.sh` | Gateway + sandbox rebuild |
|
||||
| `crates/navigator-core/*`, `crates/navigator-providers/*` | Gateway + sandbox rebuild |
|
||||
| `crates/navigator-router/*` | Gateway rebuild |
|
||||
| `crates/navigator-server/*`, `deploy/docker/Dockerfile.server` | Gateway rebuild |
|
||||
| `crates/navigator-server/*`, `deploy/docker/Dockerfile.gateway` | Gateway rebuild |
|
||||
| `crates/navigator-sandbox/*`, `deploy/docker/sandbox/*`, `python/*`, `pyproject.toml`, `uv.lock`, `crates/navigator-sandbox/data/sandbox-policy.rego` | Sandbox rebuild |
|
||||
| `deploy/helm/navigator/*` | Helm upgrade |
|
||||
|
||||
**Explicit target mode** (arguments: `server`, `sandbox`, `chart`, `all`): Rebuilds only the specified components.
|
||||
**Explicit target mode** (arguments: `gateway`, `sandbox`, `chart`, `all`): Rebuilds only the specified components.
|
||||
|
||||
Auto mode persists the last deployed fingerprints in `.cache/cluster-deploy-fast.state` (or `$DEPLOY_FAST_STATE_FILE`). Re-running `mise run cluster` without new local changes prints `No new local changes since last deploy.` and skips rebuild/upgrade work.
|
||||
|
||||
@@ -461,7 +461,7 @@ When the cluster container starts, k3s automatically deploys these HelmChart CRs
|
||||
## Implementation References
|
||||
|
||||
- `deploy/docker/Dockerfile.sandbox` -- Sandbox image (5-stage multi-arch build)
|
||||
- `deploy/docker/Dockerfile.server` -- Gateway image (2-stage with dependency caching)
|
||||
- `deploy/docker/Dockerfile.gateway` -- Gateway image (2-stage with dependency caching)
|
||||
- `deploy/docker/Dockerfile.cluster` -- Cluster image (k3s base + charts + manifests)
|
||||
- `deploy/docker/Dockerfile.ci` -- CI runner image (Ubuntu + full toolchain)
|
||||
- `deploy/docker/Dockerfile.python-wheels` -- Linux wheel builder
|
||||
|
||||
@@ -85,19 +85,19 @@ Secret names are defined as constants in `crates/navigator-bootstrap/src/constan
|
||||
|
||||
### Gateway Mounts
|
||||
|
||||
The Helm StatefulSet (`deploy/helm/navigator/templates/statefulset.yaml`) mounts:
|
||||
The Helm StatefulSet (`deploy/helm/openshell/templates/statefulset.yaml`) mounts:
|
||||
|
||||
| Volume | Mount Path | Source Secret |
|
||||
|---|---|---|
|
||||
| `tls-cert` | `/etc/navigator-tls/server/` (read-only) | `navigator-server-tls` |
|
||||
| `tls-client-ca` | `/etc/navigator-tls/client-ca/` (read-only) | `navigator-server-client-ca` |
|
||||
| `tls-cert` | `/etc/openshell-tls/server/` (read-only) | `navigator-server-tls` |
|
||||
| `tls-client-ca` | `/etc/openshell-tls/client-ca/` (read-only) | `navigator-server-client-ca` |
|
||||
|
||||
Environment variables point the gateway binary to these paths:
|
||||
|
||||
```
|
||||
OPENSHELL_TLS_CERT=/etc/navigator-tls/server/tls.crt
|
||||
OPENSHELL_TLS_KEY=/etc/navigator-tls/server/tls.key
|
||||
OPENSHELL_TLS_CLIENT_CA=/etc/navigator-tls/client-ca/ca.crt
|
||||
OPENSHELL_TLS_CERT=/etc/openshell-tls/server/tls.crt
|
||||
OPENSHELL_TLS_KEY=/etc/openshell-tls/server/tls.key
|
||||
OPENSHELL_TLS_CLIENT_CA=/etc/openshell-tls/client-ca/ca.crt
|
||||
```
|
||||
|
||||
### Sandbox Pod Mounts
|
||||
@@ -105,13 +105,13 @@ OPENSHELL_TLS_CLIENT_CA=/etc/navigator-tls/client-ca/ca.crt
|
||||
When the gateway creates a sandbox pod (`crates/navigator-server/src/sandbox/mod.rs:681`), it injects:
|
||||
|
||||
- A volume backed by the `navigator-client-tls` secret.
|
||||
- A read-only mount at `/etc/navigator-tls/client/` on the agent container.
|
||||
- A read-only mount at `/etc/openshell-tls/client/` on the agent container.
|
||||
- Environment variables for the sandbox gRPC client:
|
||||
|
||||
```
|
||||
OPENSHELL_TLS_CA=/etc/navigator-tls/client/ca.crt
|
||||
OPENSHELL_TLS_CERT=/etc/navigator-tls/client/tls.crt
|
||||
OPENSHELL_TLS_KEY=/etc/navigator-tls/client/tls.key
|
||||
OPENSHELL_TLS_CA=/etc/openshell-tls/client/ca.crt
|
||||
OPENSHELL_TLS_CERT=/etc/openshell-tls/client/tls.crt
|
||||
OPENSHELL_TLS_KEY=/etc/openshell-tls/client/tls.key
|
||||
OPENSHELL_ENDPOINT=https://navigator.navigator.svc.cluster.local:8080
|
||||
```
|
||||
|
||||
@@ -120,7 +120,7 @@ OPENSHELL_ENDPOINT=https://navigator.navigator.svc.cluster.local:8080
|
||||
The CLI's copy of the client certificate bundle is written to:
|
||||
|
||||
```
|
||||
$XDG_CONFIG_HOME/openshell/clusters/<cluster-name>/mtls/
|
||||
$XDG_CONFIG_HOME/openshell/gateways/<gateway-name>/mtls/
|
||||
├── ca.crt
|
||||
├── tls.crt
|
||||
└── tls.key
|
||||
@@ -221,9 +221,9 @@ Sandbox pods connect back to the gateway at startup to fetch their policy and pr
|
||||
|
||||
| Env Var | Value |
|
||||
|---|---|
|
||||
| `OPENSHELL_TLS_CA` | `/etc/navigator-tls/client/ca.crt` |
|
||||
| `OPENSHELL_TLS_CERT` | `/etc/navigator-tls/client/tls.crt` |
|
||||
| `OPENSHELL_TLS_KEY` | `/etc/navigator-tls/client/tls.key` |
|
||||
| `OPENSHELL_TLS_CA` | `/etc/openshell-tls/client/ca.crt` |
|
||||
| `OPENSHELL_TLS_CERT` | `/etc/openshell-tls/client/tls.crt` |
|
||||
| `OPENSHELL_TLS_KEY` | `/etc/openshell-tls/client/tls.key` |
|
||||
|
||||
These are used to build a `tonic::transport::ClientTlsConfig` with:
|
||||
- `ca_certificate()` -- verifies the server's certificate against the cluster CA.
|
||||
|
||||
@@ -50,7 +50,7 @@ All gateway lifecycle commands live under `openshell gateway`:
|
||||
| `openshell gateway tunnel [--name NAME] [--remote user@host] [--print-command]` | Start or print SSH tunnel for kubectl access |
|
||||
| `openshell status` | Show gateway health via gRPC/HTTP |
|
||||
| `openshell gateway select <name>` | Set the active gateway |
|
||||
| `openshell gateway select` | List all gateways with metadata |
|
||||
| `openshell gateway select` | Open an interactive chooser on a TTY, or list all gateways in non-interactive mode |
|
||||
|
||||
The `--name` flag defaults to `"openshell"`. When omitted on commands that accept it, the CLI resolves the active gateway via: `--gateway` flag, then `OPENSHELL_GATEWAY` env, then `~/.config/openshell/active_gateway` file.
|
||||
|
||||
@@ -289,7 +289,7 @@ Copies bundled manifests from `/opt/navigator/manifests/` to `/var/lib/rancher/k
|
||||
When environment variables are set, the entrypoint modifies the HelmChart manifest at `/var/lib/rancher/k3s/server/manifests/navigator-helmchart.yaml`:
|
||||
|
||||
- `IMAGE_REPO_BASE`: Rewrites `repository:`, `sandboxImage:`, and `jobImage:` in the HelmChart.
|
||||
- `PUSH_IMAGE_REFS`: In push mode, parses comma-separated image refs and rewrites the exact gateway, sandbox, and pki-job image references (matching on path component `/server:`, `/sandbox:`, `/pki-job:`).
|
||||
- `PUSH_IMAGE_REFS`: In push mode, parses comma-separated image refs and rewrites the exact gateway, sandbox, and pki-job image references (matching on path component `/gateway:`, `/sandbox:`, `/pki-job:`).
|
||||
- `IMAGE_TAG`: Replaces `:latest` tags with the specified tag on gateway, sandbox, and pki-job images. Handles both quoted and unquoted `tag: latest` formats.
|
||||
- `IMAGE_PULL_POLICY`: Replaces `pullPolicy: Always` with the specified policy (e.g., `IfNotPresent`).
|
||||
- `SSH_GATEWAY_HOST` / `SSH_GATEWAY_PORT`: Replaces `__SSH_GATEWAY_HOST__` and `__SSH_GATEWAY_PORT__` placeholders.
|
||||
@@ -304,6 +304,29 @@ When environment variables are set, the entrypoint modifies the HelmChart manife
|
||||
3. **Gateway**: Checks that `gateway/navigator-gateway` in namespace `navigator` has the `Programmed` condition.
|
||||
4. **mTLS secret** (conditional): If `NAV_GATEWAY_TLS_ENABLED` is true (or inferred from the HelmChart manifest using the same two-path detection logic as the bootstrap code), checks that secret `navigator-cli-client` exists with non-empty `ca.crt`, `tls.crt`, and `tls.key` data.
|
||||
|
||||
## GPU Enablement
|
||||
|
||||
GPU support is part of the single-node gateway bootstrap path rather than a separate architecture.
|
||||
|
||||
- `openshell gateway start --gpu` threads a boolean deploy option through `crates/navigator-cli`, `crates/navigator-bootstrap`, and `crates/navigator-bootstrap/src/docker.rs`.
|
||||
- When enabled, the cluster container is created with Docker `DeviceRequests`, which is the API equivalent of `docker run --gpus all`.
|
||||
- `deploy/docker/Dockerfile.cluster` installs NVIDIA Container Toolkit packages in a dedicated Ubuntu stage and copies the runtime binaries, config, and `libnvidia-container` shared libraries into the final Ubuntu-based cluster image.
|
||||
- `deploy/docker/cluster-entrypoint.sh` checks `GPU_ENABLED=true` and copies GPU-only manifests from `/opt/openshell/gpu-manifests/` into k3s's manifests directory.
|
||||
- `deploy/kube/gpu-manifests/nvidia-device-plugin-helmchart.yaml` installs the NVIDIA device plugin chart, currently pinned to `0.18.2`, along with GPU Feature Discovery and Node Feature Discovery.
|
||||
- k3s auto-detects `nvidia-container-runtime` on `PATH`, registers the `nvidia` containerd runtime, and creates the `nvidia` `RuntimeClass` automatically.
|
||||
|
||||
The runtime chain is:
|
||||
|
||||
```text
|
||||
Host GPU drivers & NVIDIA Container Toolkit
|
||||
└─ Docker: --gpus all (DeviceRequests in bollard API)
|
||||
└─ k3s/containerd: nvidia-container-runtime on PATH -> auto-detected
|
||||
└─ k8s: nvidia-device-plugin DaemonSet advertises nvidia.com/gpu
|
||||
└─ Pods: request nvidia.com/gpu in resource limits
|
||||
```
|
||||
|
||||
The expected smoke test is a plain pod requesting `nvidia.com/gpu: 1` with `runtimeClassName: nvidia` and running `nvidia-smi`.
|
||||
|
||||
## Remote Image Transfer
|
||||
|
||||
```mermaid
|
||||
|
||||
@@ -8,7 +8,7 @@ The `--from` flag accepts four kinds of input:
|
||||
|
||||
| Input | Example | Behavior |
|
||||
|-------|---------|----------|
|
||||
| **Community sandbox name** | `--from openclaw` | Resolves to `d1i0nduu2f6qxk.cloudfront.net/openshell-community/sandboxes/openclaw:latest` |
|
||||
| **Community sandbox name** | `--from openclaw` | Resolves to `ghcr.io/nvidia/openshell-community/sandboxes/openclaw:latest` |
|
||||
| **Dockerfile path** | `--from ./Dockerfile` | Builds the image, pushes it into the cluster, then creates the sandbox |
|
||||
| **Directory with Dockerfile** | `--from ./my-sandbox/` | Uses the directory as the build context |
|
||||
| **Full image reference** | `--from myregistry.com/img:tag` | Uses the image directly |
|
||||
@@ -22,7 +22,7 @@ The CLI classifies the value in this order:
|
||||
3. **Contains `/`, `:`, or `.`** — treated as a full container image reference.
|
||||
4. **Otherwise** — treated as a community sandbox name, expanded to `{OPENSHELL_COMMUNITY_REGISTRY}/{name}:latest`.
|
||||
|
||||
The community registry prefix defaults to `d1i0nduu2f6qxk.cloudfront.net/openshell-community/sandboxes` and can be overridden with the `OPENSHELL_COMMUNITY_REGISTRY` environment variable.
|
||||
The community registry prefix defaults to `ghcr.io/nvidia/openshell-community/sandboxes` and can be overridden with the `OPENSHELL_COMMUNITY_REGISTRY` environment variable.
|
||||
|
||||
### Dockerfile build flow
|
||||
|
||||
|
||||
@@ -10,12 +10,13 @@ use bollard::API_DEFAULT_VERSION;
|
||||
use bollard::Docker;
|
||||
use bollard::errors::Error as BollardError;
|
||||
use bollard::models::{
|
||||
ContainerCreateBody, HostConfig, NetworkCreateRequest, NetworkDisconnectRequest, PortBinding,
|
||||
VolumeCreateRequest,
|
||||
ContainerCreateBody, DeviceRequest, HostConfig, NetworkCreateRequest, NetworkDisconnectRequest,
|
||||
PortBinding, VolumeCreateRequest,
|
||||
};
|
||||
use bollard::query_parameters::{
|
||||
CreateContainerOptions, CreateImageOptions, InspectContainerOptions, InspectNetworkOptions,
|
||||
RemoveContainerOptions, RemoveImageOptions, RemoveVolumeOptions, StartContainerOptions,
|
||||
ListContainersOptionsBuilder, RemoveContainerOptions, RemoveImageOptions, RemoveVolumeOptions,
|
||||
StartContainerOptions,
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use miette::{IntoDiagnostic, Result, WrapErr};
|
||||
@@ -241,6 +242,7 @@ pub async fn ensure_container(
|
||||
disable_tls: bool,
|
||||
disable_gateway_auth: bool,
|
||||
registry_token: Option<&str>,
|
||||
gpu: bool,
|
||||
) -> Result<()> {
|
||||
let container_name = container_name(name);
|
||||
|
||||
@@ -320,7 +322,7 @@ pub async fn ensure_container(
|
||||
exposed_ports.push("6443/tcp".to_string());
|
||||
}
|
||||
|
||||
let host_config = HostConfig {
|
||||
let mut host_config = HostConfig {
|
||||
privileged: Some(true),
|
||||
port_bindings: Some(port_bindings),
|
||||
binds: Some(vec![format!("{}:/var/lib/rancher/k3s", volume_name(name))]),
|
||||
@@ -331,6 +333,23 @@ pub async fn ensure_container(
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// When GPU support is requested, add NVIDIA device requests.
|
||||
// This is the programmatic equivalent of `docker run --gpus all`.
|
||||
// The NVIDIA Container Toolkit runtime hook injects /dev/nvidia* devices
|
||||
// and GPU driver libraries from the host into the container.
|
||||
if gpu {
|
||||
host_config.device_requests = Some(vec![DeviceRequest {
|
||||
driver: Some("nvidia".to_string()),
|
||||
count: Some(-1), // all GPUs
|
||||
capabilities: Some(vec![vec![
|
||||
"gpu".to_string(),
|
||||
"utility".to_string(),
|
||||
"compute".to_string(),
|
||||
]]),
|
||||
..Default::default()
|
||||
}]);
|
||||
}
|
||||
|
||||
let mut cmd = vec![
|
||||
"server".to_string(),
|
||||
"--disable=traefik".to_string(),
|
||||
@@ -396,10 +415,9 @@ pub async fn ensure_container(
|
||||
// runtime. Pass community registry credentials as a separate set of
|
||||
// env vars so the entrypoint can add a second block to registries.yaml.
|
||||
if registry_host != DEFAULT_REGISTRY {
|
||||
env_vars.push(format!("COMMUNITY_REGISTRY_HOST={}", DEFAULT_REGISTRY));
|
||||
env_vars.push(format!("COMMUNITY_REGISTRY_HOST={DEFAULT_REGISTRY}"));
|
||||
env_vars.push(format!(
|
||||
"COMMUNITY_REGISTRY_USERNAME={}",
|
||||
DEFAULT_REGISTRY_USERNAME
|
||||
"COMMUNITY_REGISTRY_USERNAME={DEFAULT_REGISTRY_USERNAME}"
|
||||
));
|
||||
env_vars.push(format!(
|
||||
"COMMUNITY_REGISTRY_PASSWORD={}",
|
||||
@@ -455,6 +473,12 @@ pub async fn ensure_container(
|
||||
env_vars.push("DISABLE_GATEWAY_AUTH=true".to_string());
|
||||
}
|
||||
|
||||
// GPU support: tell the entrypoint to deploy the NVIDIA device plugin
|
||||
// HelmChart CR so k8s workloads can request nvidia.com/gpu resources.
|
||||
if gpu {
|
||||
env_vars.push("GPU_ENABLED=true".to_string());
|
||||
}
|
||||
|
||||
let env = Some(env_vars);
|
||||
|
||||
let config = ContainerCreateBody {
|
||||
@@ -480,6 +504,77 @@ pub async fn ensure_container(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Information about a container that is holding a port we need.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortConflict {
|
||||
/// Name of the container holding the port (without leading `/`).
|
||||
pub container_name: String,
|
||||
/// The host port that conflicts.
|
||||
pub host_port: u16,
|
||||
}
|
||||
|
||||
/// Check whether any *other* running container already binds the host ports
|
||||
/// that the gateway needs. Returns a list of conflicts (empty if none).
|
||||
///
|
||||
/// Docker silently fails to attach networking when a port is already bound,
|
||||
/// leaving the new container with only a loopback interface. Detecting this
|
||||
/// up-front lets us give a clear error instead of a cryptic "no default route"
|
||||
/// failure 30 seconds later.
|
||||
pub async fn check_port_conflicts(
|
||||
docker: &Docker,
|
||||
name: &str,
|
||||
gateway_port: u16,
|
||||
kube_port: Option<u16>,
|
||||
) -> Result<Vec<PortConflict>> {
|
||||
let our_container = container_name(name);
|
||||
let needed_ports: Vec<u16> = std::iter::once(gateway_port).chain(kube_port).collect();
|
||||
|
||||
let containers = docker
|
||||
.list_containers(Some(
|
||||
ListContainersOptionsBuilder::new()
|
||||
// Only running containers can hold port bindings.
|
||||
.all(false)
|
||||
.build(),
|
||||
))
|
||||
.await
|
||||
.into_diagnostic()
|
||||
.wrap_err("failed to list containers for port conflict check")?;
|
||||
|
||||
let mut conflicts = Vec::new();
|
||||
for container in &containers {
|
||||
// Skip our own container (it may already exist from a previous run).
|
||||
let names = container.names.as_deref().unwrap_or_default();
|
||||
let is_ours = names
|
||||
.iter()
|
||||
.any(|n| n.trim_start_matches('/') == our_container);
|
||||
if is_ours {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ports = container.ports.as_deref().unwrap_or_default();
|
||||
for port in ports {
|
||||
if let Some(public) = port.public_port {
|
||||
if needed_ports.contains(&public) {
|
||||
let cname = names
|
||||
.first()
|
||||
.map(|n| n.trim_start_matches('/').to_string())
|
||||
.unwrap_or_else(|| {
|
||||
container
|
||||
.id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "<unknown>".to_string())
|
||||
});
|
||||
conflicts.push(PortConflict {
|
||||
container_name: cname,
|
||||
host_port: public,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(conflicts)
|
||||
}
|
||||
|
||||
pub async fn start_container(docker: &Docker, name: &str) -> Result<()> {
|
||||
let container_name = container_name(name);
|
||||
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Gateway error detection and user-friendly guidance.
|
||||
//!
|
||||
//! This module analyzes error messages and container logs to detect known
|
||||
//! failure patterns and provide actionable recovery guidance.
|
||||
|
||||
/// A diagnosed gateway failure with user-friendly guidance.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GatewayFailureDiagnosis {
|
||||
/// Short summary of what went wrong.
|
||||
pub summary: String,
|
||||
/// Detailed explanation of the issue.
|
||||
pub explanation: String,
|
||||
/// Commands or steps the user can take to fix the issue.
|
||||
pub recovery_steps: Vec<RecoveryStep>,
|
||||
/// Whether the issue might be auto-recoverable by retrying.
|
||||
pub retryable: bool,
|
||||
}
|
||||
|
||||
/// A recovery step with a command and description.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecoveryStep {
|
||||
/// Description of what this step does.
|
||||
pub description: String,
|
||||
/// Command to run (if applicable).
|
||||
pub command: Option<String>,
|
||||
}
|
||||
|
||||
impl RecoveryStep {
|
||||
fn new(description: impl Into<String>) -> Self {
|
||||
Self {
|
||||
description: description.into(),
|
||||
command: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_command(description: impl Into<String>, command: impl Into<String>) -> Self {
|
||||
Self {
|
||||
description: description.into(),
|
||||
command: Some(command.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How multiple matchers should be combined.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
enum MatchMode {
|
||||
/// Match if ANY of the matchers is found (default).
|
||||
#[default]
|
||||
Any,
|
||||
/// Match only if ALL of the matchers are found.
|
||||
All,
|
||||
}
|
||||
|
||||
/// Known failure patterns and their detection logic.
|
||||
struct FailurePattern {
|
||||
/// Patterns to match in error message or logs.
|
||||
matchers: &'static [&'static str],
|
||||
/// How to combine multiple matchers (default: Any).
|
||||
match_mode: MatchMode,
|
||||
/// Function to generate diagnosis.
|
||||
diagnose: fn(gateway_name: &str) -> GatewayFailureDiagnosis,
|
||||
}
|
||||
|
||||
const FAILURE_PATTERNS: &[FailurePattern] = &[
|
||||
// Corrupted cluster state / RBAC issues
|
||||
FailurePattern {
|
||||
matchers: &[
|
||||
"extension-apiserver-authentication",
|
||||
"cannot get resource",
|
||||
"is forbidden",
|
||||
],
|
||||
match_mode: MatchMode::Any,
|
||||
diagnose: diagnose_corrupted_state,
|
||||
},
|
||||
// No default route (Docker networking)
|
||||
FailurePattern {
|
||||
matchers: &["no default route present"],
|
||||
match_mode: MatchMode::Any,
|
||||
diagnose: diagnose_no_default_route,
|
||||
},
|
||||
// Port already in use
|
||||
FailurePattern {
|
||||
matchers: &["port is already allocated", "address already in use"],
|
||||
match_mode: MatchMode::Any,
|
||||
diagnose: diagnose_port_conflict,
|
||||
},
|
||||
// Image pull failures (auth/registry issues)
|
||||
FailurePattern {
|
||||
matchers: &[
|
||||
"pull access denied",
|
||||
"image not found",
|
||||
"manifest unknown",
|
||||
"unauthorized to access repository",
|
||||
"denied: access forbidden",
|
||||
],
|
||||
match_mode: MatchMode::Any,
|
||||
diagnose: diagnose_image_pull_auth_failure,
|
||||
},
|
||||
// k3s internal DNS proxy failure (must be before general network connectivity)
|
||||
// This happens when the k3s cluster starts but its internal DNS proxy can't resolve
|
||||
// external names, causing all image pulls to fail with "Try again" DNS errors.
|
||||
// The pattern "Try again" is a DNS EAGAIN error indicating temporary failure.
|
||||
// IMPORTANT: Both patterns must match to distinguish from other network issues.
|
||||
FailurePattern {
|
||||
matchers: &["dial tcp: lookup", "Try again"],
|
||||
match_mode: MatchMode::All,
|
||||
diagnose: diagnose_k3s_dns_proxy_failure,
|
||||
},
|
||||
// Network connectivity issues (DNS, timeouts, unreachable)
|
||||
FailurePattern {
|
||||
matchers: &[
|
||||
"no such host",
|
||||
"i/o timeout",
|
||||
"network is unreachable",
|
||||
"connection refused",
|
||||
"connection reset by peer",
|
||||
"TLS handshake timeout",
|
||||
"dial tcp",
|
||||
"lookup ghcr.io",
|
||||
"lookup registry",
|
||||
"no route to host",
|
||||
"temporary failure in name resolution",
|
||||
],
|
||||
match_mode: MatchMode::Any,
|
||||
diagnose: diagnose_network_connectivity,
|
||||
},
|
||||
// OOM killed
|
||||
FailurePattern {
|
||||
matchers: &["exit_code=137", "OOMKilled"],
|
||||
match_mode: MatchMode::Any,
|
||||
diagnose: diagnose_oom_killed,
|
||||
},
|
||||
// Node resource pressure (DiskPressure, MemoryPressure, PIDPressure)
|
||||
FailurePattern {
|
||||
matchers: &["HEALTHCHECK_NODE_PRESSURE"],
|
||||
match_mode: MatchMode::Any,
|
||||
diagnose: diagnose_node_pressure,
|
||||
},
|
||||
// TLS/certificate issues
|
||||
FailurePattern {
|
||||
matchers: &[
|
||||
"certificate has expired",
|
||||
"x509: certificate",
|
||||
"tls: failed to verify",
|
||||
],
|
||||
match_mode: MatchMode::Any,
|
||||
diagnose: diagnose_certificate_issue,
|
||||
},
|
||||
// Docker daemon not running
|
||||
FailurePattern {
|
||||
matchers: &[
|
||||
"Cannot connect to the Docker daemon",
|
||||
"docker daemon is not running",
|
||||
"Is the docker daemon running",
|
||||
],
|
||||
match_mode: MatchMode::Any,
|
||||
diagnose: diagnose_docker_not_running,
|
||||
},
|
||||
];
|
||||
|
||||
fn diagnose_corrupted_state(gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Corrupted cluster state".to_string(),
|
||||
explanation: "The gateway cluster has corrupted internal state, likely from a previous \
|
||||
interrupted startup or unclean shutdown."
|
||||
.to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::with_command(
|
||||
"Destroy and recreate the gateway",
|
||||
format!("openshell gateway destroy {gateway_name} && openshell gateway start"),
|
||||
),
|
||||
RecoveryStep::with_command(
|
||||
"If that fails, remove the volume for a clean slate",
|
||||
format!("docker volume rm openshell-cluster-{gateway_name}"),
|
||||
),
|
||||
],
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnose_no_default_route(_gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Docker networking issue".to_string(),
|
||||
explanation: "The gateway container has no network route. This can happen when \
|
||||
another container is already bound to the same host port (Docker silently \
|
||||
skips network attachment), or due to stale Docker networks."
|
||||
.to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::with_command(
|
||||
"Check for containers using the same port",
|
||||
"docker ps --format '{{.Names}}\\t{{.Ports}}'",
|
||||
),
|
||||
RecoveryStep::new(
|
||||
"Stop any container holding the gateway port (default 8080), then retry",
|
||||
),
|
||||
RecoveryStep::with_command("Prune unused Docker networks", "docker network prune -f"),
|
||||
RecoveryStep::new("Restart Docker Desktop (if on Mac/Windows)"),
|
||||
RecoveryStep::new("Then retry: openshell gateway start"),
|
||||
],
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnose_port_conflict(_gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Port already in use".to_string(),
|
||||
explanation: "The gateway port is already in use by another process or container."
|
||||
.to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::with_command(
|
||||
"Check what's using the port",
|
||||
"lsof -i :8080 || netstat -an | grep 8080",
|
||||
),
|
||||
RecoveryStep::with_command(
|
||||
"Use a different port",
|
||||
"openshell gateway start --port 8081",
|
||||
),
|
||||
RecoveryStep::with_command(
|
||||
"Or stop other openshell gateways",
|
||||
"openshell gateway list && openshell gateway destroy <name>",
|
||||
),
|
||||
],
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnose_image_pull_auth_failure(_gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Registry authentication failed".to_string(),
|
||||
explanation: "Could not authenticate with the container registry. The image may not \
|
||||
exist, or you may not have permission to access it."
|
||||
.to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::with_command(
|
||||
"Verify the image exists and you have access",
|
||||
"docker pull ghcr.io/nvidia/openshell/cluster:latest",
|
||||
),
|
||||
RecoveryStep::new(
|
||||
"If using a private registry, ensure OPENSHELL_REGISTRY_TOKEN is set",
|
||||
),
|
||||
RecoveryStep::with_command("Check your Docker login", "docker login ghcr.io"),
|
||||
],
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnose_k3s_dns_proxy_failure(gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Cluster DNS resolution failed".to_string(),
|
||||
explanation: "The gateway cluster started but its internal DNS proxy cannot resolve \
|
||||
external hostnames. Docker's embedded DNS inside the container cannot reach \
|
||||
an upstream resolver. This is typically caused by Docker not being configured \
|
||||
with the host's DNS servers, stale Docker networking state, or (on Desktop) \
|
||||
DNS configuration issues."
|
||||
.to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::with_command(
|
||||
"Check your host's DNS servers",
|
||||
"resolvectl status | grep 'DNS Servers' -A2",
|
||||
),
|
||||
RecoveryStep::with_command(
|
||||
"Configure Docker to use those DNS servers \
|
||||
(add to /etc/docker/daemon.json, then restart Docker)",
|
||||
"echo '{\"dns\": [\"<your-dns-server-ip>\"]}' | sudo tee /etc/docker/daemon.json \
|
||||
&& sudo systemctl restart docker",
|
||||
),
|
||||
RecoveryStep::with_command("Prune Docker networks", "docker network prune -f"),
|
||||
RecoveryStep::with_command(
|
||||
"Destroy and recreate the gateway",
|
||||
format!("openshell gateway destroy {gateway_name} && openshell gateway start"),
|
||||
),
|
||||
],
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnose_network_connectivity(_gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Network connectivity issue".to_string(),
|
||||
explanation: "Could not reach the container registry. This could be a DNS resolution \
|
||||
failure, firewall blocking the connection, or general internet connectivity issue."
|
||||
.to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::new("Check your internet connection"),
|
||||
RecoveryStep::with_command("Test DNS resolution", "nslookup ghcr.io"),
|
||||
RecoveryStep::with_command("Test registry connectivity", "curl -I https://ghcr.io/v2/"),
|
||||
RecoveryStep::new(
|
||||
"If behind a corporate firewall/proxy, ensure Docker is configured to use it",
|
||||
),
|
||||
RecoveryStep::new("Restart Docker and try again"),
|
||||
],
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnose_oom_killed(_gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Container killed due to memory limits".to_string(),
|
||||
explanation: "The gateway container was killed because it exceeded memory limits. \
|
||||
The gateway requires at least 4GB of memory."
|
||||
.to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::new(
|
||||
"Increase Docker memory allocation to at least 4GB \
|
||||
(Docker Desktop → Settings → Resources)",
|
||||
),
|
||||
RecoveryStep::new("Close other memory-intensive applications"),
|
||||
RecoveryStep::new("Then retry: openshell gateway start"),
|
||||
],
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnose_node_pressure(gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Node under resource pressure".to_string(),
|
||||
explanation: "The cluster node is reporting a resource pressure condition \
|
||||
(DiskPressure, MemoryPressure, or PIDPressure). When a node is under \
|
||||
pressure the kubelet evicts running pods and rejects new pod scheduling, \
|
||||
so the gateway will never become healthy until the pressure is resolved."
|
||||
.to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::with_command("Check available disk space on the host", "df -h /"),
|
||||
RecoveryStep::with_command(
|
||||
"Free disk space by pruning unused Docker resources",
|
||||
"docker system prune -a --volumes",
|
||||
),
|
||||
RecoveryStep::with_command("Check available memory on the host", "free -h"),
|
||||
RecoveryStep::new(
|
||||
"Increase Docker resource allocation \
|
||||
(Docker Desktop → Settings → Resources), or free resources on the host",
|
||||
),
|
||||
RecoveryStep::with_command(
|
||||
"Destroy and recreate the gateway after freeing resources",
|
||||
format!("openshell gateway destroy {gateway_name} && openshell gateway start"),
|
||||
),
|
||||
],
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnose_certificate_issue(gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "TLS certificate issue".to_string(),
|
||||
explanation: "There's a problem with the gateway's TLS certificates, possibly expired \
|
||||
or mismatched certificates from a previous installation."
|
||||
.to_string(),
|
||||
recovery_steps: vec![RecoveryStep::with_command(
|
||||
"Destroy and recreate the gateway to regenerate certificates",
|
||||
format!("openshell gateway destroy {gateway_name} && openshell gateway start"),
|
||||
)],
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnose_docker_not_running(_gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Docker is not running".to_string(),
|
||||
explanation: "The Docker daemon is not running or not accessible.".to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::new("Start Docker Desktop (Mac/Windows) or the Docker service (Linux)"),
|
||||
RecoveryStep::with_command("Verify Docker is running", "docker info"),
|
||||
RecoveryStep::new("Then retry: openshell gateway start"),
|
||||
],
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Analyze an error message and container logs to diagnose the failure.
|
||||
///
|
||||
/// Returns `Some(diagnosis)` if a known pattern is detected, `None` otherwise.
|
||||
pub fn diagnose_failure(
|
||||
gateway_name: &str,
|
||||
error_message: &str,
|
||||
container_logs: Option<&str>,
|
||||
) -> Option<GatewayFailureDiagnosis> {
|
||||
let combined = container_logs.map_or_else(
|
||||
|| error_message.to_string(),
|
||||
|logs| format!("{error_message}\n{logs}"),
|
||||
);
|
||||
|
||||
for pattern in FAILURE_PATTERNS {
|
||||
let matches = match pattern.match_mode {
|
||||
MatchMode::Any => pattern.matchers.iter().any(|m| combined.contains(m)),
|
||||
MatchMode::All => pattern.matchers.iter().all(|m| combined.contains(m)),
|
||||
};
|
||||
if matches {
|
||||
return Some((pattern.diagnose)(gateway_name));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Create a generic diagnosis when no specific pattern is matched.
|
||||
pub fn generic_failure_diagnosis(gateway_name: &str) -> GatewayFailureDiagnosis {
|
||||
GatewayFailureDiagnosis {
|
||||
summary: "Gateway failed to start".to_string(),
|
||||
explanation: "The gateway encountered an unexpected error during startup.".to_string(),
|
||||
recovery_steps: vec![
|
||||
RecoveryStep::with_command(
|
||||
"Try destroying and recreating the gateway",
|
||||
format!("openshell gateway destroy {gateway_name} && openshell gateway start"),
|
||||
),
|
||||
RecoveryStep::with_command(
|
||||
"Check container logs for details",
|
||||
format!("docker logs openshell-cluster-{gateway_name}"),
|
||||
),
|
||||
RecoveryStep::new(
|
||||
"If the issue persists, report it at https://github.com/nvidia/openshell/issues",
|
||||
),
|
||||
],
|
||||
retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_diagnose_corrupted_state() {
|
||||
let diagnosis = diagnose_failure(
|
||||
"test",
|
||||
"K8s namespace not ready",
|
||||
Some("configmaps \"extension-apiserver-authentication\" is forbidden"),
|
||||
);
|
||||
assert!(diagnosis.is_some());
|
||||
let d = diagnosis.unwrap();
|
||||
assert!(d.summary.contains("Corrupted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagnose_no_default_route() {
|
||||
let diagnosis = diagnose_failure(
|
||||
"test",
|
||||
"container exited with code 1",
|
||||
Some("Error: no default route present before starting k3s"),
|
||||
);
|
||||
assert!(diagnosis.is_some());
|
||||
let d = diagnosis.unwrap();
|
||||
assert!(d.summary.contains("networking"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagnose_port_conflict() {
|
||||
let diagnosis = diagnose_failure("test", "port is already allocated", None);
|
||||
assert!(diagnosis.is_some());
|
||||
let d = diagnosis.unwrap();
|
||||
assert!(d.summary.contains("Port"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_match_returns_none() {
|
||||
let diagnosis = diagnose_failure("test", "some random error", Some("random logs"));
|
||||
assert!(diagnosis.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagnose_k3s_dns_proxy_failure_both_patterns() {
|
||||
// Should match when BOTH patterns are present
|
||||
let diagnosis = diagnose_failure(
|
||||
"test",
|
||||
"failed to pull image",
|
||||
Some("dial tcp: lookup registry-1.docker.io: Try again"),
|
||||
);
|
||||
assert!(diagnosis.is_some());
|
||||
let d = diagnosis.unwrap();
|
||||
assert!(d.summary.contains("DNS"));
|
||||
assert!(d.retryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagnose_k3s_dns_proxy_failure_requires_both_patterns() {
|
||||
// Should NOT match with only "dial tcp: lookup" (falls through to network connectivity)
|
||||
let diagnosis = diagnose_failure(
|
||||
"test",
|
||||
"failed to pull image",
|
||||
Some("dial tcp: lookup registry-1.docker.io: connection refused"),
|
||||
);
|
||||
assert!(diagnosis.is_some());
|
||||
let d = diagnosis.unwrap();
|
||||
// Should match the general network connectivity pattern, not k3s DNS
|
||||
assert!(d.summary.contains("Network connectivity"));
|
||||
|
||||
// Should NOT match with only "Try again" (no match at all since it's too generic)
|
||||
let diagnosis = diagnose_failure("test", "Try again later", None);
|
||||
assert!(diagnosis.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagnose_node_pressure_disk() {
|
||||
let diagnosis = diagnose_failure(
|
||||
"test",
|
||||
"HEALTHCHECK_NODE_PRESSURE: DiskPressure\n\
|
||||
The cluster node is under resource pressure.",
|
||||
None,
|
||||
);
|
||||
assert!(diagnosis.is_some());
|
||||
let d = diagnosis.unwrap();
|
||||
assert!(
|
||||
d.summary.contains("pressure"),
|
||||
"expected pressure diagnosis, got: {}",
|
||||
d.summary
|
||||
);
|
||||
assert!(!d.retryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagnose_node_pressure_from_container_logs() {
|
||||
let diagnosis = diagnose_failure(
|
||||
"test",
|
||||
"gateway health check reported unhealthy",
|
||||
Some("HEALTHCHECK_NODE_PRESSURE: MemoryPressure"),
|
||||
);
|
||||
assert!(diagnosis.is_some());
|
||||
let d = diagnosis.unwrap();
|
||||
assert!(
|
||||
d.summary.contains("pressure"),
|
||||
"expected pressure diagnosis, got: {}",
|
||||
d.summary
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diagnose_dns_failure_from_namespace_timeout() {
|
||||
// When wait_for_namespace detects DNS failure, the error message itself
|
||||
// (not container logs) contains the DNS markers. The diagnose_failure
|
||||
// function must match these from the error_message parameter alone,
|
||||
// since container_logs may be None in this path.
|
||||
let diagnosis = diagnose_failure(
|
||||
"test",
|
||||
"K8s namespace not ready\n\nCaused by:\n dial tcp: lookup registry: Try again\n DNS resolution is failing inside the gateway container.",
|
||||
None,
|
||||
);
|
||||
assert!(diagnosis.is_some());
|
||||
let d = diagnosis.unwrap();
|
||||
assert!(
|
||||
d.summary.contains("DNS"),
|
||||
"expected DNS diagnosis, got: {}",
|
||||
d.summary
|
||||
);
|
||||
assert!(d.retryable);
|
||||
}
|
||||
}
|
||||
@@ -200,9 +200,7 @@ pub async fn pull_remote_image(
|
||||
"Pulling image {} on remote host from {}",
|
||||
registry_image, DEFAULT_REGISTRY
|
||||
);
|
||||
on_progress(format!(
|
||||
"[status] Pulling openshell/cluster:{tag} ({platform_str}) on remote host"
|
||||
));
|
||||
on_progress(format!("[progress] Pulling {platform_str} image"));
|
||||
|
||||
let credentials = ghcr_credentials(registry_token);
|
||||
|
||||
@@ -228,7 +226,7 @@ pub async fn pull_remote_image(
|
||||
{
|
||||
let current_mb = current / (1024 * 1024);
|
||||
let total_mb = total / (1024 * 1024);
|
||||
on_progress(format!("[status] {status}: {current_mb}/{total_mb} MB"));
|
||||
on_progress(format!("[progress] {status}: {current_mb}/{total_mb} MB"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +277,7 @@ pub async fn pull_remote_image(
|
||||
actual_arch
|
||||
);
|
||||
|
||||
on_progress(format!("[status] Image {image_ref} ready on remote host"));
|
||||
on_progress("[progress] Image ready".to_string());
|
||||
info!("Remote image pull and tag complete: {}", image_ref);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
pub mod build;
|
||||
pub mod edge_token;
|
||||
pub mod errors;
|
||||
pub mod image;
|
||||
|
||||
mod constants;
|
||||
@@ -31,8 +32,9 @@ use crate::constants::{
|
||||
volume_name,
|
||||
};
|
||||
use crate::docker::{
|
||||
check_existing_gateway, create_ssh_docker_client, destroy_gateway_resources, ensure_container,
|
||||
ensure_image, ensure_network, ensure_volume, start_container, stop_container,
|
||||
check_existing_gateway, check_port_conflicts, create_ssh_docker_client,
|
||||
destroy_gateway_resources, ensure_container, ensure_image, ensure_network, ensure_volume,
|
||||
start_container, stop_container,
|
||||
};
|
||||
use crate::kubeconfig::{rewrite_kubeconfig, rewrite_kubeconfig_remote, store_kubeconfig};
|
||||
use crate::metadata::{
|
||||
@@ -122,6 +124,10 @@ pub struct DeployOptions {
|
||||
/// scope) used to pull images from ghcr.io both during the initial
|
||||
/// bootstrap pull and inside the k3s cluster at runtime.
|
||||
pub registry_token: Option<String>,
|
||||
/// Enable NVIDIA GPU passthrough. When true, the Docker container is
|
||||
/// created with GPU device requests (`--gpus all`) and the NVIDIA
|
||||
/// k8s-device-plugin is deployed inside the k3s cluster.
|
||||
pub gpu: bool,
|
||||
}
|
||||
|
||||
impl DeployOptions {
|
||||
@@ -136,6 +142,7 @@ impl DeployOptions {
|
||||
disable_tls: false,
|
||||
disable_gateway_auth: false,
|
||||
registry_token: None,
|
||||
gpu: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +195,13 @@ impl DeployOptions {
|
||||
self.registry_token = Some(token.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable NVIDIA GPU passthrough for the cluster container.
|
||||
#[must_use]
|
||||
pub fn with_gpu(mut self, gpu: bool) -> Self {
|
||||
self.gpu = gpu;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -253,6 +267,7 @@ where
|
||||
let disable_tls = options.disable_tls;
|
||||
let disable_gateway_auth = options.disable_gateway_auth;
|
||||
let registry_token = options.registry_token;
|
||||
let gpu = options.gpu;
|
||||
let kubeconfig_path = stored_kubeconfig_path(&name)?;
|
||||
|
||||
// Wrap on_log in Arc<Mutex<>> so we can share it with pull_remote_image
|
||||
@@ -280,7 +295,7 @@ where
|
||||
|
||||
// Ensure the image is available on the target Docker daemon
|
||||
if remote_opts.is_some() {
|
||||
log("[status] Pulling gateway image".to_string());
|
||||
log("[status] Downloading gateway".to_string());
|
||||
let on_log_clone = Arc::clone(&on_log);
|
||||
let progress_cb = move |msg: String| {
|
||||
if let Ok(mut f) = on_log_clone.lock() {
|
||||
@@ -296,15 +311,13 @@ where
|
||||
.await?;
|
||||
} else {
|
||||
// Local deployment: ensure image exists (pull if needed)
|
||||
log("[status] Pulling gateway image".to_string());
|
||||
log("[status] Downloading gateway".to_string());
|
||||
ensure_image(&target_docker, &image_ref, registry_token.as_deref()).await?;
|
||||
}
|
||||
|
||||
// All subsequent operations use the target Docker (remote or local)
|
||||
log("[status] Preparing gateway".to_string());
|
||||
log("[progress] Creating gateway network".to_string());
|
||||
log("[status] Initializing environment".to_string());
|
||||
ensure_network(&target_docker).await?;
|
||||
log("[progress] Preparing gateway volume".to_string());
|
||||
ensure_volume(&target_docker, &volume_name(&name)).await?;
|
||||
|
||||
// Compute extra TLS SANs for remote deployments so the gateway and k3s
|
||||
@@ -341,7 +354,34 @@ where
|
||||
(sans, gateway_host)
|
||||
};
|
||||
|
||||
log("[progress] Creating gateway container".to_string());
|
||||
// Check for port conflicts before creating/starting the container.
|
||||
// Docker silently fails to attach networking when a host port is already
|
||||
// bound by another container, leaving the new container with only loopback
|
||||
// and no default route. Detecting this up-front avoids a confusing 30s
|
||||
// timeout followed by a misleading "Docker networking issue" diagnostic.
|
||||
let conflicts = check_port_conflicts(&target_docker, &name, port, kube_port).await?;
|
||||
if !conflicts.is_empty() {
|
||||
let details: Vec<String> = conflicts
|
||||
.iter()
|
||||
.map(|c| {
|
||||
format!(
|
||||
"port {} is held by container \"{}\"",
|
||||
c.host_port, c.container_name
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
return Err(miette::miette!(
|
||||
"cannot start gateway: {}\n\nStop or remove the conflicting container(s) first, \
|
||||
then retry:\n{}",
|
||||
details.join(", "),
|
||||
conflicts
|
||||
.iter()
|
||||
.map(|c| format!(" docker stop {}", c.container_name))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
));
|
||||
}
|
||||
|
||||
ensure_container(
|
||||
&target_docker,
|
||||
&name,
|
||||
@@ -353,12 +393,10 @@ where
|
||||
disable_tls,
|
||||
disable_gateway_auth,
|
||||
registry_token.as_deref(),
|
||||
gpu,
|
||||
)
|
||||
.await?;
|
||||
log("[status] Starting gateway".to_string());
|
||||
start_container(&target_docker, &name).await?;
|
||||
|
||||
log("[progress] Waiting for kubeconfig".to_string());
|
||||
let raw_kubeconfig = wait_for_kubeconfig(&target_docker, &name).await?;
|
||||
|
||||
// Rewrite kubeconfig based on deployment mode
|
||||
@@ -366,15 +404,13 @@ where
|
||||
|| rewrite_kubeconfig(&raw_kubeconfig, &name, kube_port),
|
||||
|opts| rewrite_kubeconfig_remote(&raw_kubeconfig, &name, &opts.destination, kube_port),
|
||||
);
|
||||
log("[progress] Writing kubeconfig".to_string());
|
||||
store_kubeconfig(&kubeconfig_path, &rewritten)?;
|
||||
// Clean up stale k3s nodes left over from previous container instances that
|
||||
// used the same persistent volume. Without this, pods remain scheduled on
|
||||
// NotReady ghost nodes and the health check will time out.
|
||||
log("[progress] Cleaning stale nodes".to_string());
|
||||
match clean_stale_nodes(&target_docker, &name).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => log(format!("[progress] Removed {n} stale node(s)")),
|
||||
Ok(n) => tracing::debug!("removed {n} stale node(s)"),
|
||||
Err(err) => {
|
||||
tracing::debug!("stale node cleanup failed (non-fatal): {err}");
|
||||
}
|
||||
@@ -391,7 +427,6 @@ where
|
||||
// cluster, secrets are always newly generated and a restart is unnecessary.
|
||||
// Restarting only when workload pre-existed avoids extra rollout latency.
|
||||
let workload_existed_before_pki = openshell_workload_exists(&target_docker, &name).await?;
|
||||
log("[progress] Reconciling TLS certificates".to_string());
|
||||
let (pki_bundle, rotated) = reconcile_pki(&target_docker, &name, &extra_sans, &log).await?;
|
||||
|
||||
if rotated && workload_existed_before_pki {
|
||||
@@ -399,11 +434,9 @@ where
|
||||
// it picks up the new TLS secrets before we write CLI-side certs.
|
||||
// A failed rollout is a hard error — CLI certs must not be persisted
|
||||
// if the server cannot come up with the new PKI.
|
||||
log("[progress] PKI rotated — restarting openshell workload".to_string());
|
||||
restart_openshell_deployment(&target_docker, &name).await?;
|
||||
}
|
||||
|
||||
log("[progress] Storing CLI mTLS credentials".to_string());
|
||||
store_pki_bundle(&name, &pki_bundle)?;
|
||||
|
||||
// Push locally-built component images into the k3s containerd runtime.
|
||||
@@ -419,10 +452,7 @@ where
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if !images.is_empty() {
|
||||
log(format!(
|
||||
"[progress] Importing {} local image(s) into gateway",
|
||||
images.len()
|
||||
));
|
||||
log("[status] Deploying components".to_string());
|
||||
let local_docker = Docker::connect_with_local_defaults().into_diagnostic()?;
|
||||
let container = container_name(&name);
|
||||
let on_log_ref = Arc::clone(&on_log);
|
||||
@@ -440,12 +470,11 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
log("[progress] Restarting openshell deployment".to_string());
|
||||
restart_openshell_deployment(&target_docker, &name).await?;
|
||||
}
|
||||
}
|
||||
|
||||
log("[status] Waiting for gateway".to_string());
|
||||
log("[status] Starting gateway".to_string());
|
||||
{
|
||||
// Create a short-lived closure that locks on each call rather than holding
|
||||
// the MutexGuard across await points.
|
||||
@@ -459,7 +488,6 @@ where
|
||||
}
|
||||
|
||||
// Create and store gateway metadata.
|
||||
log("[progress] Persisting gateway metadata".to_string());
|
||||
let metadata = create_gateway_metadata_with_host(
|
||||
&name,
|
||||
remote_opts.as_ref(),
|
||||
@@ -741,9 +769,41 @@ async fn load_existing_pki_bundle(
|
||||
|
||||
/// Wait for a K8s namespace to exist inside the cluster container.
|
||||
///
|
||||
/// The Helm controller creates the `navigator` namespace when it processes
|
||||
/// The Helm controller creates the `openshell` namespace when it processes
|
||||
/// the `HelmChart` manifest, but there's a race between kubeconfig being ready
|
||||
/// and the namespace being created. We poll briefly.
|
||||
/// Check whether DNS resolution is working inside the container.
|
||||
///
|
||||
/// Probes the configured `REGISTRY_HOST` (falling back to `ghcr.io`) since
|
||||
/// that is the primary registry the cluster needs to reach for image pulls.
|
||||
///
|
||||
/// Returns `Ok(true)` if DNS is functional, `Ok(false)` if the probe ran but
|
||||
/// resolution failed, and `Err` if the exec itself failed.
|
||||
async fn probe_container_dns(docker: &Docker, container_name: &str) -> Result<bool> {
|
||||
// The probe must handle IP-literal registry hosts (e.g. 127.0.0.1:5000)
|
||||
// which don't need DNS resolution. Strip the port suffix since nslookup
|
||||
// doesn't understand host:port, and skip the probe entirely for IP
|
||||
// literals.
|
||||
let (output, exit_code) = exec_capture_with_exit(
|
||||
docker,
|
||||
container_name,
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
concat!(
|
||||
"host=\"${REGISTRY_HOST:-ghcr.io}\"; ",
|
||||
"host=\"${host%%:*}\"; ",
|
||||
"echo \"$host\" | grep -qE '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$' && { echo DNS_OK; exit 0; }; ",
|
||||
"echo \"$host\" | grep -qE '^\\[?[0-9a-fA-F:]+\\]?$' && { echo DNS_OK; exit 0; }; ",
|
||||
"nslookup \"$host\" >/dev/null 2>&1 && echo DNS_OK || echo DNS_FAIL",
|
||||
)
|
||||
.to_string(),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
Ok(exit_code == 0 && output.contains("DNS_OK"))
|
||||
}
|
||||
|
||||
async fn wait_for_namespace(
|
||||
docker: &Docker,
|
||||
container_name: &str,
|
||||
@@ -756,7 +816,42 @@ async fn wait_for_namespace(
|
||||
let max_backoff = std::time::Duration::from_secs(2);
|
||||
let mut backoff = std::time::Duration::from_millis(200);
|
||||
|
||||
// Track consecutive DNS failures. We start probing early (iteration 3,
|
||||
// giving k3s a few seconds to boot) and probe every 3 iterations after
|
||||
// that. Two consecutive failures are enough to abort — the nslookup
|
||||
// timeout already provides a built-in retry window.
|
||||
let dns_probe_start = 3; // skip the first few iterations while k3s boots
|
||||
let dns_probe_interval = 3; // probe every N iterations after start
|
||||
let dns_failure_threshold: u32 = 2; // consecutive probe failures to abort
|
||||
let mut dns_consecutive_failures: u32 = 0;
|
||||
|
||||
for attempt in 0..attempts {
|
||||
// --- Periodic DNS health probe ---
|
||||
if attempt >= dns_probe_start && (attempt - dns_probe_start) % dns_probe_interval == 0 {
|
||||
match probe_container_dns(docker, container_name).await {
|
||||
Ok(true) => {
|
||||
dns_consecutive_failures = 0;
|
||||
}
|
||||
Ok(false) => {
|
||||
dns_consecutive_failures += 1;
|
||||
if dns_consecutive_failures >= dns_failure_threshold {
|
||||
let logs = fetch_recent_logs(docker, container_name, 40).await;
|
||||
return Err(miette::miette!(
|
||||
"dial tcp: lookup registry: Try again\n\
|
||||
DNS resolution is failing inside the gateway container. \
|
||||
The cluster cannot pull images or create the '{namespace}' namespace \
|
||||
until DNS is fixed.\n{logs}"
|
||||
))
|
||||
.wrap_err("K8s namespace not ready");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Exec failed — container may be restarting; don't count
|
||||
// as a DNS failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exec_result = exec_capture_with_exit(
|
||||
docker,
|
||||
container_name,
|
||||
|
||||
@@ -450,7 +450,7 @@ mod tests {
|
||||
is_remote: true,
|
||||
gateway_port: 8080,
|
||||
kube_port: Some(7443),
|
||||
remote_host: Some("user@navigator-dev".to_string()),
|
||||
remote_host: Some("user@openshell-dev".to_string()),
|
||||
resolved_host: Some("10.0.0.5".to_string()),
|
||||
auth_mode: None,
|
||||
edge_team_domain: None,
|
||||
@@ -472,7 +472,7 @@ mod tests {
|
||||
is_remote: true,
|
||||
gateway_port: 8080,
|
||||
kube_port: None,
|
||||
remote_host: Some("user@navigator-dev".to_string()),
|
||||
remote_host: Some("user@openshell-dev".to_string()),
|
||||
resolved_host: Some("10.0.0.5".to_string()),
|
||||
auth_mode: None,
|
||||
edge_team_domain: None,
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn generate_pki(extra_sans: &[String]) -> Result<PkiBundle> {
|
||||
.wrap_err("failed to create client cert params")?;
|
||||
client_params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, "navigator-client");
|
||||
.push(DnType::CommonName, "openshell-client");
|
||||
|
||||
let client_cert = client_params
|
||||
.signed_by(&client_key, &ca_cert, &ca_key)
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::runtime::exec_capture_with_exit;
|
||||
const CONTAINERD_SOCK: &str = "/run/k3s/containerd/containerd.sock";
|
||||
|
||||
/// Path inside the container where the image tar is staged.
|
||||
const IMPORT_TAR_PATH: &str = "/tmp/navigator-images.tar";
|
||||
const IMPORT_TAR_PATH: &str = "/tmp/openshell-images.tar";
|
||||
|
||||
/// Push a list of images from the local Docker daemon into a k3s gateway's
|
||||
/// containerd runtime.
|
||||
@@ -42,30 +42,20 @@ pub async fn push_local_images(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
on_log(format!(
|
||||
"[status] Importing {} component image(s) into gateway",
|
||||
images.len()
|
||||
));
|
||||
for img in images {
|
||||
on_log(format!("[status] {img}"));
|
||||
}
|
||||
|
||||
// 1. Export all images from the local Docker daemon as a single tar.
|
||||
on_log("[status] Exporting images from Docker".to_string());
|
||||
let image_tar = collect_export(local_docker, images).await?;
|
||||
on_log(format!(
|
||||
"[status] Exported {} MiB of image data",
|
||||
"[progress] Exported {} MiB",
|
||||
image_tar.len() / (1024 * 1024)
|
||||
));
|
||||
|
||||
// 2. Wrap the image tar as a file inside an outer tar archive and upload
|
||||
// it into the container filesystem via the Docker put_archive API.
|
||||
on_log("[status] Uploading images into gateway container".to_string());
|
||||
let outer_tar = wrap_in_tar(IMPORT_TAR_PATH, &image_tar)?;
|
||||
upload_archive(gateway_docker, container_name, &outer_tar).await?;
|
||||
on_log("[progress] Uploaded to gateway".to_string());
|
||||
|
||||
// 3. Import the tar into containerd via ctr.
|
||||
on_log("[status] Running ctr images import".to_string());
|
||||
let (output, exit_code) = exec_capture_with_exit(
|
||||
gateway_docker,
|
||||
container_name,
|
||||
@@ -100,7 +90,6 @@ pub async fn push_local_images(
|
||||
)
|
||||
.await;
|
||||
|
||||
on_log("[status] All component images imported into gateway".to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,27 @@ pub async fn wait_for_kubeconfig(docker: &Docker, name: &str) -> Result<String>
|
||||
Err(miette::miette!("timed out waiting for kubeconfig\n{logs}"))
|
||||
}
|
||||
|
||||
/// Log markers emitted by the entrypoint and health-check scripts when DNS
|
||||
/// resolution fails inside the container. Detecting these early lets us
|
||||
/// short-circuit the 6-minute polling loop and surface a clear diagnosis.
|
||||
const DNS_FAILURE_MARKERS: &[&str] = &["DNS_PROBE_FAILED", "HEALTHCHECK_DNS_FAILURE"];
|
||||
|
||||
/// Log marker emitted by the health-check script when a Kubernetes node is
|
||||
/// under resource pressure (`DiskPressure`, `MemoryPressure`, `PIDPressure`).
|
||||
/// When a node has pressure conditions the kubelet evicts pods and rejects
|
||||
/// new scheduling, so the cluster will never become healthy on its own.
|
||||
const NODE_PRESSURE_MARKER: &str = "HEALTHCHECK_NODE_PRESSURE";
|
||||
|
||||
/// Number of consecutive polling iterations that must observe DNS failure
|
||||
/// markers before we treat the failure as persistent and abort. A small
|
||||
/// grace period avoids false positives on transient hiccups during startup.
|
||||
const DNS_FAILURE_GRACE_ITERATIONS: u32 = 5;
|
||||
|
||||
/// Number of consecutive polling iterations that must observe node pressure
|
||||
/// markers before aborting. Slightly longer grace period than DNS since
|
||||
/// transient pressure can occur during image extraction on startup.
|
||||
const NODE_PRESSURE_GRACE_ITERATIONS: u32 = 8;
|
||||
|
||||
pub async fn wait_for_gateway_ready<F>(docker: &Docker, name: &str, mut on_log: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(String) + Send,
|
||||
@@ -66,10 +87,73 @@ where
|
||||
let mut recent_logs = VecDeque::with_capacity(15);
|
||||
let attempts = 180;
|
||||
let mut result = None;
|
||||
let mut dns_failure_seen_count: u32 = 0;
|
||||
let mut node_pressure_seen_count: u32 = 0;
|
||||
|
||||
for attempt in 0..attempts {
|
||||
drain_logs(&mut log_rx, &mut recent_logs, &mut on_log);
|
||||
|
||||
// -- Early DNS failure detection ---------------------------------
|
||||
// Check recent logs for DNS failure markers emitted by the
|
||||
// entrypoint or health-check scripts. If the marker persists for
|
||||
// several consecutive iterations the DNS proxy is broken and
|
||||
// waiting longer won't help.
|
||||
let dns_failing = recent_logs
|
||||
.iter()
|
||||
.any(|line| DNS_FAILURE_MARKERS.iter().any(|m| line.contains(m)));
|
||||
if dns_failing {
|
||||
dns_failure_seen_count += 1;
|
||||
if dns_failure_seen_count >= DNS_FAILURE_GRACE_ITERATIONS {
|
||||
result = Some(Err(miette::miette!(
|
||||
"dial tcp: lookup registry: Try again\n\
|
||||
DNS resolution is failing inside the gateway container.\n{}",
|
||||
format_recent_logs(&recent_logs)
|
||||
)));
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
dns_failure_seen_count = 0;
|
||||
}
|
||||
|
||||
// -- Early node pressure detection -------------------------------
|
||||
// Check for DiskPressure / MemoryPressure / PIDPressure markers
|
||||
// emitted by the health-check script. Under pressure the kubelet
|
||||
// evicts pods and blocks new scheduling, so waiting won't help.
|
||||
let pressure_lines: Vec<&str> = recent_logs
|
||||
.iter()
|
||||
.filter(|line| line.contains(NODE_PRESSURE_MARKER))
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
if pressure_lines.is_empty() {
|
||||
node_pressure_seen_count = 0;
|
||||
} else {
|
||||
node_pressure_seen_count += 1;
|
||||
if node_pressure_seen_count >= NODE_PRESSURE_GRACE_ITERATIONS {
|
||||
// Extract the specific pressure type(s) from the marker lines
|
||||
let conditions: Vec<String> = pressure_lines
|
||||
.iter()
|
||||
.filter_map(|line| {
|
||||
line.find(NODE_PRESSURE_MARKER)
|
||||
.map(|pos| &line[pos + NODE_PRESSURE_MARKER.len()..])
|
||||
.map(|rest| rest.trim_start_matches(':').trim().to_string())
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let condition_list = if conditions.is_empty() {
|
||||
"unknown pressure condition".to_string()
|
||||
} else {
|
||||
conditions.join(", ")
|
||||
};
|
||||
result = Some(Err(miette::miette!(
|
||||
"HEALTHCHECK_NODE_PRESSURE: {condition_list}\n\
|
||||
The cluster node is under resource pressure. \
|
||||
The kubelet is evicting pods and rejecting new scheduling.\n{}",
|
||||
format_recent_logs(&recent_logs)
|
||||
)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let inspect = docker
|
||||
.inspect_container(&container_name, None::<InspectContainerOptions>)
|
||||
.await
|
||||
|
||||
@@ -132,7 +132,7 @@ pub async fn browser_auth_flow(gateway_endpoint: &str) -> Result<String> {
|
||||
result = rx => {
|
||||
result.map_err(|_| miette::miette!("auth callback channel closed unexpectedly"))?
|
||||
}
|
||||
_ = tokio::time::sleep(AUTH_TIMEOUT) => {
|
||||
() = tokio::time::sleep(AUTH_TIMEOUT) => {
|
||||
return Err(miette::miette!(
|
||||
"authentication timed out after {} seconds.\n\
|
||||
Try again with: openshell gateway login",
|
||||
|
||||
@@ -121,6 +121,7 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::TEST_ENV_LOCK;
|
||||
use navigator_bootstrap::{GatewayMetadata, store_gateway_metadata};
|
||||
use temp_env::with_vars;
|
||||
|
||||
fn with_isolated_cli_env<F: FnOnce()>(tmp: &std::path::Path, f: F) {
|
||||
@@ -163,4 +164,34 @@ mod tests {
|
||||
assert!(result.is_empty());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_completer_returns_registered_gateways() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
with_isolated_cli_env(temp.path(), || {
|
||||
store_gateway_metadata(
|
||||
"alpha",
|
||||
&GatewayMetadata {
|
||||
name: "alpha".to_string(),
|
||||
gateway_endpoint: "https://alpha.example.com".to_string(),
|
||||
is_remote: true,
|
||||
gateway_port: 0,
|
||||
kube_port: None,
|
||||
remote_host: None,
|
||||
resolved_host: None,
|
||||
auth_mode: Some("cloudflare_jwt".to_string()),
|
||||
edge_team_domain: None,
|
||||
edge_auth_url: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = complete_gateway_names(OsStr::new("a"));
|
||||
let names: Vec<String> = result
|
||||
.iter()
|
||||
.map(|candidate| candidate.get_value().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(names.contains(&"alpha".to_string()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! OpenShell CLI library.
|
||||
//! `OpenShell` CLI library.
|
||||
//!
|
||||
//! This crate provides the CLI implementation for OpenShell.
|
||||
//! This crate provides the CLI implementation for `OpenShell`.
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! OpenShell CLI - command-line interface for OpenShell.
|
||||
//! `OpenShell` CLI - command-line interface for `OpenShell`.
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand, ValueEnum, ValueHint};
|
||||
use clap_complete::engine::ArgValueCompleter;
|
||||
@@ -124,12 +124,11 @@ fn resolve_gateway_name(gateway_flag: &Option<String>) -> Option<String> {
|
||||
/// stored edge token from disk and sets it on the `TlsOptions`. The token is
|
||||
/// always read from gateway metadata rather than supplied via a CLI flag.
|
||||
fn apply_edge_auth(tls: &mut TlsOptions, gateway_name: &str) {
|
||||
if let Some(meta) = get_gateway_metadata(gateway_name) {
|
||||
if meta.auth_mode.as_deref() == Some("cloudflare_jwt") {
|
||||
if let Some(token) = load_edge_token(gateway_name) {
|
||||
tls.edge_token = Some(token);
|
||||
}
|
||||
}
|
||||
if let Some(meta) = get_gateway_metadata(gateway_name)
|
||||
&& meta.auth_mode.as_deref() == Some("cloudflare_jwt")
|
||||
&& let Some(token) = load_edge_token(gateway_name)
|
||||
{
|
||||
tls.edge_token = Some(token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +151,9 @@ fn resolve_sandbox_name(name: Option<String>, gateway: &str) -> Result<String> {
|
||||
Ok(last)
|
||||
}
|
||||
|
||||
// Custom help template organized like `gh` CLI
|
||||
// Custom root help stays hand-authored so commands can be grouped into product
|
||||
// areas without relying on clap's default subcommand listing. User-facing
|
||||
// commands remain visible so shell completion can suggest them at the root.
|
||||
const HELP_TEMPLATE: &str = "\
|
||||
{about-with-newline}
|
||||
\x1b[1mUSAGE\x1b[0m
|
||||
@@ -275,7 +276,7 @@ const INFERENCE_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m
|
||||
$ openshell inference update --model gpt-4-turbo
|
||||
";
|
||||
|
||||
/// OpenShell CLI - agent execution and management.
|
||||
/// `OpenShell` CLI - agent execution and management.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "openshell")]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
@@ -290,11 +291,12 @@ struct Cli {
|
||||
short = 'g',
|
||||
global = true,
|
||||
env = "OPENSHELL_GATEWAY",
|
||||
help_heading = "GATEWAY FLAGS"
|
||||
help_heading = "GATEWAY FLAGS",
|
||||
add = ArgValueCompleter::new(completers::complete_gateway_names)
|
||||
)]
|
||||
gateway: Option<String>,
|
||||
|
||||
/// Gateway endpoint URL (e.g. https://gateway.example.com).
|
||||
/// Gateway endpoint URL (e.g. <https://gateway.example.com>).
|
||||
/// Connects directly without looking up gateway metadata.
|
||||
#[arg(
|
||||
long,
|
||||
@@ -326,23 +328,24 @@ enum Commands {
|
||||
// SANDBOX COMMANDS
|
||||
// ===================================================================
|
||||
/// Manage sandboxes.
|
||||
#[command(visible_alias = "sb", hide = true, after_help = SANDBOX_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
#[command(alias = "sb", after_help = SANDBOX_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
Sandbox {
|
||||
#[command(subcommand)]
|
||||
command: Option<SandboxCommands>,
|
||||
},
|
||||
|
||||
/// Manage port forwarding to a sandbox.
|
||||
#[command(visible_alias = "fwd", hide = true, after_help = FORWARD_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
#[command(alias = "fwd", after_help = FORWARD_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
Forward {
|
||||
#[command(subcommand)]
|
||||
command: Option<ForwardCommands>,
|
||||
},
|
||||
|
||||
/// View sandbox logs.
|
||||
#[command(visible_alias = "lg", hide = true, after_help = LOGS_EXAMPLES, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
#[command(alias = "lg", after_help = LOGS_EXAMPLES, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Logs {
|
||||
/// Sandbox name (defaults to last-used sandbox).
|
||||
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
|
||||
name: Option<String>,
|
||||
|
||||
/// Number of log lines to return.
|
||||
@@ -368,14 +371,14 @@ enum Commands {
|
||||
},
|
||||
|
||||
/// Manage sandbox policy.
|
||||
#[command(visible_alias = "pol", hide = true, after_help = POLICY_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
#[command(alias = "pol", after_help = POLICY_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
Policy {
|
||||
#[command(subcommand)]
|
||||
command: Option<PolicyCommands>,
|
||||
},
|
||||
|
||||
/// Manage provider configuration.
|
||||
#[command(hide = true, after_help = PROVIDER_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
#[command(after_help = PROVIDER_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
Provider {
|
||||
#[command(subcommand)]
|
||||
command: Option<ProviderCommands>,
|
||||
@@ -385,18 +388,18 @@ enum Commands {
|
||||
// GATEWAY COMMANDS
|
||||
// ===================================================================
|
||||
/// Manage the gateway lifecycle.
|
||||
#[command(visible_alias = "gw", hide = true, after_help = GATEWAY_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
#[command(alias = "gw", after_help = GATEWAY_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
Gateway {
|
||||
#[command(subcommand)]
|
||||
command: Option<GatewayCommands>,
|
||||
},
|
||||
|
||||
/// Show gateway status and information.
|
||||
#[command(hide = true, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Status,
|
||||
|
||||
/// Manage inference configuration.
|
||||
#[command(hide = true, after_help = INFERENCE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
#[command(after_help = INFERENCE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
|
||||
Inference {
|
||||
#[command(subcommand)]
|
||||
command: Option<InferenceCommands>,
|
||||
@@ -405,12 +408,12 @@ enum Commands {
|
||||
// ===================================================================
|
||||
// ADDITIONAL COMMANDS
|
||||
// ===================================================================
|
||||
/// Launch the OpenShell interactive TUI.
|
||||
#[command(hide = true, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
/// Launch the `OpenShell` interactive TUI.
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Term,
|
||||
|
||||
/// Generate shell completions.
|
||||
#[command(hide = true, after_long_help = COMPLETIONS_HELP, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
#[command(after_long_help = COMPLETIONS_HELP, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Completions {
|
||||
/// Shell to generate completions for.
|
||||
shell: CompletionShell,
|
||||
@@ -483,7 +486,7 @@ The script is output on stdout, allowing you to redirect the output to the file
|
||||
The exact config file locations might vary based on your system. Make sure to restart your
|
||||
shell before testing whether completions are working.
|
||||
|
||||
## bash
|
||||
\x1b[1mBASH\x1b[0m
|
||||
|
||||
First, ensure that you install `bash-completion` using your package manager.
|
||||
|
||||
@@ -495,12 +498,12 @@ On macOS with Homebrew (install bash-completion first):
|
||||
mkdir -p $(brew --prefix)/etc/bash_completion.d
|
||||
openshell completions bash > $(brew --prefix)/etc/bash_completion.d/openshell.bash-completion
|
||||
|
||||
## fish
|
||||
\x1b[1mFISH\x1b[0m
|
||||
|
||||
mkdir -p ~/.config/fish/completions
|
||||
openshell completions fish > ~/.config/fish/completions/openshell.fish
|
||||
|
||||
## zsh
|
||||
\x1b[1mZSH\x1b[0m
|
||||
|
||||
mkdir -p ~/.zfunc
|
||||
openshell completions zsh > ~/.zfunc/_openshell
|
||||
@@ -509,7 +512,7 @@ Then add the following to your .zshrc before compinit:
|
||||
|
||||
fpath+=~/.zfunc
|
||||
|
||||
## powershell
|
||||
\x1b[1mPOWERSHELL\x1b[0m
|
||||
|
||||
openshell completions powershell >> $PROFILE
|
||||
|
||||
@@ -518,6 +521,12 @@ If no profile exists yet, create one first:
|
||||
New-Item -Path $PROFILE -Type File -Force
|
||||
";
|
||||
|
||||
fn normalize_completion_script(output: Vec<u8>, executable: &std::path::Path) -> Result<String> {
|
||||
let script = String::from_utf8(output)
|
||||
.map_err(|e| miette::miette!("generated completions were not valid UTF-8: {e}"))?;
|
||||
Ok(script.replace(executable.to_string_lossy().as_ref(), "openshell"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ValueEnum)]
|
||||
enum CliProviderType {
|
||||
Claude,
|
||||
@@ -731,13 +740,22 @@ enum GatewayCommands {
|
||||
/// runtime.
|
||||
#[arg(long, env = "OPENSHELL_REGISTRY_TOKEN")]
|
||||
registry_token: Option<String>,
|
||||
|
||||
/// Enable NVIDIA GPU passthrough.
|
||||
///
|
||||
/// Passes all host GPUs into the cluster container and deploys the
|
||||
/// NVIDIA k8s-device-plugin so Kubernetes workloads can request
|
||||
/// `nvidia.com/gpu` resources. Requires NVIDIA drivers and the
|
||||
/// NVIDIA Container Toolkit on the host.
|
||||
#[arg(long)]
|
||||
gpu: bool,
|
||||
},
|
||||
|
||||
/// Stop the gateway (preserves state).
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Stop {
|
||||
/// Gateway name (defaults to active gateway).
|
||||
#[arg(long, env = "OPENSHELL_GATEWAY")]
|
||||
#[arg(long, env = "OPENSHELL_GATEWAY", add = ArgValueCompleter::new(completers::complete_gateway_names))]
|
||||
name: Option<String>,
|
||||
|
||||
/// Override SSH destination (auto-resolved from gateway metadata).
|
||||
@@ -753,7 +771,7 @@ enum GatewayCommands {
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Destroy {
|
||||
/// Gateway name (defaults to active gateway).
|
||||
#[arg(long, env = "OPENSHELL_GATEWAY")]
|
||||
#[arg(long, env = "OPENSHELL_GATEWAY", add = ArgValueCompleter::new(completers::complete_gateway_names))]
|
||||
name: Option<String>,
|
||||
|
||||
/// Override SSH destination (auto-resolved from gateway metadata).
|
||||
@@ -799,10 +817,11 @@ enum GatewayCommands {
|
||||
|
||||
/// Select the active gateway.
|
||||
///
|
||||
/// When called without a name, lists available gateways to choose from.
|
||||
/// When called without a name, opens an interactive chooser on a TTY and
|
||||
/// lists available gateways in non-interactive mode.
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Select {
|
||||
/// Gateway name (omit to list available gateways).
|
||||
/// Gateway name (omit to choose interactively or list in non-interactive mode).
|
||||
#[arg(add = ArgValueCompleter::new(completers::complete_gateway_names))]
|
||||
name: Option<String>,
|
||||
},
|
||||
@@ -811,7 +830,7 @@ enum GatewayCommands {
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Info {
|
||||
/// Gateway name (defaults to active gateway).
|
||||
#[arg(long, env = "OPENSHELL_GATEWAY")]
|
||||
#[arg(long, env = "OPENSHELL_GATEWAY", add = ArgValueCompleter::new(completers::complete_gateway_names))]
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
@@ -819,7 +838,7 @@ enum GatewayCommands {
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Tunnel {
|
||||
/// Gateway name (defaults to active gateway).
|
||||
#[arg(long, env = "OPENSHELL_GATEWAY")]
|
||||
#[arg(long, env = "OPENSHELL_GATEWAY", add = ArgValueCompleter::new(completers::complete_gateway_names))]
|
||||
name: Option<String>,
|
||||
|
||||
/// Override SSH destination (auto-resolved from gateway metadata).
|
||||
@@ -905,7 +924,7 @@ enum SandboxCommands {
|
||||
///
|
||||
/// When given a Dockerfile or directory, the image is built and pushed
|
||||
/// into the cluster automatically before creating the sandbox.
|
||||
#[arg(long)]
|
||||
#[arg(long, value_hint = ValueHint::AnyPath)]
|
||||
from: Option<String>,
|
||||
|
||||
/// Upload local files into the sandbox before running.
|
||||
@@ -1029,8 +1048,12 @@ enum SandboxCommands {
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Delete {
|
||||
/// Sandbox names.
|
||||
#[arg(required = true, num_args = 1.., value_name = "NAME", add = ArgValueCompleter::new(completers::complete_sandbox_names))]
|
||||
#[arg(required_unless_present = "all", num_args = 1.., value_name = "NAME", add = ArgValueCompleter::new(completers::complete_sandbox_names))]
|
||||
names: Vec<String>,
|
||||
|
||||
/// Delete all sandboxes.
|
||||
#[arg(long, conflicts_with = "names")]
|
||||
all: bool,
|
||||
},
|
||||
|
||||
/// Connect to a sandbox.
|
||||
@@ -1078,6 +1101,7 @@ enum SandboxCommands {
|
||||
sandbox_path: String,
|
||||
|
||||
/// Local destination (defaults to `.`).
|
||||
#[arg(value_hint = ValueHint::AnyPath)]
|
||||
dest: Option<String>,
|
||||
},
|
||||
|
||||
@@ -1088,6 +1112,7 @@ enum SandboxCommands {
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
SshConfig {
|
||||
/// Sandbox name (defaults to last-used sandbox).
|
||||
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
|
||||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -1098,6 +1123,7 @@ enum PolicyCommands {
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Set {
|
||||
/// Sandbox name (defaults to last-used sandbox).
|
||||
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
|
||||
name: Option<String>,
|
||||
|
||||
/// Path to the policy YAML file.
|
||||
@@ -1117,6 +1143,7 @@ enum PolicyCommands {
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
Get {
|
||||
/// Sandbox name (defaults to last-used sandbox).
|
||||
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
|
||||
name: Option<String>,
|
||||
|
||||
/// Show a specific policy revision (default: latest).
|
||||
@@ -1132,6 +1159,7 @@ enum PolicyCommands {
|
||||
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
|
||||
List {
|
||||
/// Sandbox name (defaults to last-used sandbox).
|
||||
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
|
||||
name: Option<String>,
|
||||
|
||||
/// Maximum number of revisions to return.
|
||||
@@ -1221,6 +1249,7 @@ async fn main() -> Result<()> {
|
||||
plaintext,
|
||||
disable_gateway_auth,
|
||||
registry_token,
|
||||
gpu,
|
||||
} => {
|
||||
run::gateway_admin_deploy(
|
||||
&name,
|
||||
@@ -1235,6 +1264,7 @@ async fn main() -> Result<()> {
|
||||
plaintext,
|
||||
disable_gateway_auth,
|
||||
registry_token.as_deref(),
|
||||
gpu,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -1278,17 +1308,7 @@ async fn main() -> Result<()> {
|
||||
run::gateway_login(&name).await?;
|
||||
}
|
||||
GatewayCommands::Select { name } => {
|
||||
if let Some(name) = name {
|
||||
run::gateway_use(&name)?;
|
||||
} else {
|
||||
// No name provided — show available gateways.
|
||||
run::gateway_list(&cli.gateway)?;
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"Select a gateway with: {}",
|
||||
"openshell gateway select <name>".dimmed()
|
||||
);
|
||||
}
|
||||
run::gateway_select(name.as_deref(), &cli.gateway)?;
|
||||
}
|
||||
GatewayCommands::Info { name } => {
|
||||
let name = name
|
||||
@@ -1710,8 +1730,8 @@ async fn main() -> Result<()> {
|
||||
} => {
|
||||
run::sandbox_list(endpoint, limit, offset, ids, names, &tls).await?;
|
||||
}
|
||||
SandboxCommands::Delete { names } => {
|
||||
run::sandbox_delete(endpoint, &names, &tls).await?;
|
||||
SandboxCommands::Delete { names, all } => {
|
||||
run::sandbox_delete(endpoint, &names, all, &tls).await?;
|
||||
}
|
||||
SandboxCommands::Connect { name, editor } => {
|
||||
let name = resolve_sandbox_name(name, &ctx.name)?;
|
||||
@@ -1801,12 +1821,13 @@ async fn main() -> Result<()> {
|
||||
Some(Commands::Completions { shell }) => {
|
||||
let exe = std::env::current_exe()
|
||||
.map_err(|e| miette::miette!("failed to find current executable: {e}"))?;
|
||||
let output = std::process::Command::new(exe)
|
||||
let output = std::process::Command::new(&exe)
|
||||
.env("COMPLETE", shell.to_string())
|
||||
.output()
|
||||
.map_err(|e| miette::miette!("failed to generate completions: {e}"))?;
|
||||
let script = normalize_completion_script(output.stdout, &exe)?;
|
||||
std::io::stdout()
|
||||
.write_all(&output.stdout)
|
||||
.write_all(script.as_bytes())
|
||||
.map_err(|e| miette::miette!("failed to write completions: {e}"))?;
|
||||
}
|
||||
Some(Commands::SshProxy {
|
||||
@@ -1911,7 +1932,7 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse an upload spec like `<local>[:<remote>]` into (local_path, optional_sandbox_path).
|
||||
/// Parse an upload spec like `<local>[:<remote>]` into (`local_path`, `optional_sandbox_path`).
|
||||
fn parse_upload_spec(spec: &str) -> (String, Option<String>) {
|
||||
if let Some((local, remote)) = spec.split_once(':') {
|
||||
(
|
||||
@@ -1987,9 +2008,26 @@ mod tests {
|
||||
let args: Vec<OsString> = vec!["openshell".into(), "".into()];
|
||||
let candidates = clap_complete::engine::complete(&mut cmd, args, 1, None)
|
||||
.expect("completion engine failed");
|
||||
let names: Vec<String> = candidates
|
||||
.iter()
|
||||
.map(|c| c.get_value().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!candidates.is_empty(),
|
||||
"expected subcommand completions for empty input"
|
||||
names.contains(&"sandbox".to_string()),
|
||||
"expected 'sandbox' in root candidates, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&"--gateway".to_string()),
|
||||
"expected '--gateway' in root candidates, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"lg".to_string()),
|
||||
"expected root candidates to prefer canonical command names, got: {names:?}"
|
||||
);
|
||||
assert!(
|
||||
!names.contains(&"pol".to_string()),
|
||||
"expected root candidates to prefer canonical command names, got: {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2060,6 +2098,23 @@ mod tests {
|
||||
4,
|
||||
"Dockerfile",
|
||||
),
|
||||
(
|
||||
vec!["openshell", "sandbox", "create", "--from", "Do"],
|
||||
4,
|
||||
"Dockerfile",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"openshell",
|
||||
"sandbox",
|
||||
"download",
|
||||
"demo",
|
||||
"/sandbox/file",
|
||||
"Do",
|
||||
],
|
||||
5,
|
||||
"Dockerfile",
|
||||
),
|
||||
];
|
||||
|
||||
for (raw_args, index, expected) in cases {
|
||||
@@ -2125,6 +2180,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_completion_suggests_registered_gateways() {
|
||||
let tmp = tempfile::tempdir().expect("failed to create tempdir");
|
||||
with_tmp_xdg(tmp.path(), || {
|
||||
store_gateway_metadata(
|
||||
"alpha",
|
||||
&edge_metadata("alpha", "https://alpha.example.com"),
|
||||
)
|
||||
.expect("store gateway alpha");
|
||||
store_gateway_metadata("beta", &edge_metadata("beta", "https://beta.example.com"))
|
||||
.expect("store gateway beta");
|
||||
|
||||
for (raw_args, index) in [
|
||||
(vec!["openshell", "--gateway", "a"], 2),
|
||||
(vec!["openshell", "gateway", "select", "a"], 3),
|
||||
(vec!["openshell", "gateway", "info", "--name", "a"], 4),
|
||||
] {
|
||||
let mut cmd = Cli::command();
|
||||
let args: Vec<OsString> = raw_args.iter().copied().map(Into::into).collect();
|
||||
let candidates = clap_complete::engine::complete(&mut cmd, args, index, None)
|
||||
.expect("completion engine failed");
|
||||
let names: Vec<String> = candidates
|
||||
.iter()
|
||||
.map(|c| c.get_value().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
names.contains(&"alpha".to_string()),
|
||||
"expected gateway completion for args {raw_args:?}, got: {names:?}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_gateway_flag_still_parses_with_subcommands() {
|
||||
let cli = Cli::try_parse_from(["openshell", "--gateway", "demo", "status"])
|
||||
.expect("global gateway flag should parse with subcommands");
|
||||
|
||||
assert_eq!(cli.gateway.as_deref(), Some("demo"));
|
||||
assert!(matches!(cli.command, Some(Commands::Status)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_aliases_still_parse() {
|
||||
let cli = Cli::try_parse_from(["openshell", "lg", "sandbox-1"])
|
||||
.expect("hidden aliases should still parse");
|
||||
|
||||
assert!(matches!(
|
||||
cli.command,
|
||||
Some(Commands::Logs { name: Some(ref name), .. }) if name == "sandbox-1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_script_uses_openshell_command_name() {
|
||||
let script = normalize_completion_script(
|
||||
b"/tmp/custom/openshell -- \"${words[@]}\"\n#compdef openshell\n".to_vec(),
|
||||
std::path::Path::new("/tmp/custom/openshell"),
|
||||
)
|
||||
.expect("normalize completion script");
|
||||
|
||||
assert!(script.contains("openshell -- \"${words[@]}\""));
|
||||
assert!(!script.contains("/tmp/custom/openshell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_create_and_download_use_path_value_hints() {
|
||||
let cmd = Cli::command();
|
||||
let sandbox = cmd
|
||||
.get_subcommands()
|
||||
.find(|c| c.get_name() == "sandbox")
|
||||
.expect("missing sandbox subcommand");
|
||||
let create = sandbox
|
||||
.get_subcommands()
|
||||
.find(|c| c.get_name() == "create")
|
||||
.expect("missing create subcommand");
|
||||
let from = create
|
||||
.get_arguments()
|
||||
.find(|arg| arg.get_id() == "from")
|
||||
.expect("missing from argument");
|
||||
let download = sandbox
|
||||
.get_subcommands()
|
||||
.find(|c| c.get_name() == "download")
|
||||
.expect("missing download subcommand");
|
||||
let dest = download
|
||||
.get_arguments()
|
||||
.find(|arg| arg.get_id() == "dest")
|
||||
.expect("missing dest argument");
|
||||
|
||||
assert_eq!(from.get_value_hint(), ValueHint::AnyPath);
|
||||
assert_eq!(dest.get_value_hint(), ValueHint::AnyPath);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_upload_spec_without_remote() {
|
||||
let (local, remote) = parse_upload_spec("./src");
|
||||
|
||||
+505
-159
@@ -7,7 +7,7 @@ use crate::tls::{
|
||||
TlsOptions, build_rustls_config, grpc_client, grpc_inference_client, require_tls_materials,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use dialoguer::Confirm;
|
||||
use dialoguer::{Confirm, Select, theme::ColorfulTheme};
|
||||
use futures::StreamExt;
|
||||
use http_body_util::Full;
|
||||
use hyper::{Request, StatusCode};
|
||||
@@ -108,58 +108,50 @@ fn civil_from_days(days: u64) -> (i64, u64, u64) {
|
||||
/// Known provisioning steps derived from Kubernetes events and sandbox lifecycle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum ProvisioningStep {
|
||||
/// Sandbox CRD created, waiting for compute resources (node scheduling).
|
||||
RequestingCompute,
|
||||
/// Pod scheduled on a node.
|
||||
Scheduled,
|
||||
/// Pulling container image.
|
||||
Pulling,
|
||||
/// Container image pulled.
|
||||
Pulled,
|
||||
/// Container created (not yet started).
|
||||
ContainerCreated,
|
||||
/// Container started, waiting for sandbox to become ready.
|
||||
ContainerStarted,
|
||||
/// Sandbox is ready to accept connections.
|
||||
SandboxReady,
|
||||
/// Sandbox CRD created, waiting for pod to be scheduled.
|
||||
RequestingSandbox,
|
||||
/// Pulling the sandbox container image (the user-facing image, not init containers).
|
||||
PullingSandboxImage,
|
||||
/// Container is starting up.
|
||||
StartingSandbox,
|
||||
}
|
||||
|
||||
impl ProvisioningStep {
|
||||
/// Human-readable label for a completed step.
|
||||
fn completed_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::RequestingCompute => "Compute allocated",
|
||||
Self::Scheduled => "Scheduled on node",
|
||||
Self::Pulling => "Image pulled",
|
||||
Self::Pulled => "Image pulled",
|
||||
Self::ContainerCreated => "Container created",
|
||||
Self::ContainerStarted => "Container started",
|
||||
Self::SandboxReady => "Sandbox ready",
|
||||
Self::RequestingSandbox => "Sandbox allocated",
|
||||
Self::PullingSandboxImage => "Image pulled",
|
||||
Self::StartingSandbox => "Sandbox ready",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable label for an in-progress step (shown on the spinner).
|
||||
fn active_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::RequestingCompute => "Requesting compute...",
|
||||
Self::Scheduled => "Scheduling on node...",
|
||||
Self::Pulling => "Pulling image...",
|
||||
Self::Pulled => "Pulling image...",
|
||||
Self::ContainerCreated => "Creating container...",
|
||||
Self::ContainerStarted => "Starting container...",
|
||||
Self::SandboxReady => "Waiting for sandbox...",
|
||||
Self::RequestingSandbox => "Requesting sandbox...",
|
||||
Self::PullingSandboxImage => "Pulling image...",
|
||||
Self::StartingSandbox => "Starting sandbox...",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a Kubernetes event reason to a provisioning step.
|
||||
fn kube_event_to_step(reason: &str) -> Option<ProvisioningStep> {
|
||||
/// Kubernetes event reason codes we care about.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum KubeEventReason {
|
||||
Scheduled,
|
||||
Pulling,
|
||||
Pulled,
|
||||
Started,
|
||||
}
|
||||
|
||||
/// Map a Kubernetes event reason string to an enum.
|
||||
fn parse_kube_event_reason(reason: &str) -> Option<KubeEventReason> {
|
||||
match reason {
|
||||
"Scheduled" => Some(ProvisioningStep::Scheduled),
|
||||
"Pulling" => Some(ProvisioningStep::Pulling),
|
||||
"Pulled" => Some(ProvisioningStep::Pulled),
|
||||
"Created" => Some(ProvisioningStep::ContainerCreated),
|
||||
"Started" => Some(ProvisioningStep::ContainerStarted),
|
||||
"Scheduled" => Some(KubeEventReason::Scheduled),
|
||||
"Pulling" => Some(KubeEventReason::Pulling),
|
||||
"Pulled" => Some(KubeEventReason::Pulled),
|
||||
"Started" => Some(KubeEventReason::Started),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -171,8 +163,13 @@ fn kube_event_to_step(reason: &str) -> Option<ProvisioningStep> {
|
||||
struct ProvisioningDisplay {
|
||||
mp: MultiProgress,
|
||||
spinner: ProgressBar,
|
||||
/// Blank line below the spinner so progress doesn't sit flush against
|
||||
/// the bottom of the terminal.
|
||||
spacer: ProgressBar,
|
||||
/// Steps that have been completed, in order.
|
||||
completed_steps: Vec<ProvisioningStep>,
|
||||
/// Progress bars for completed steps (so they can be cleared).
|
||||
completed_bars: Vec<ProgressBar>,
|
||||
/// The currently active step label (shown on the spinner).
|
||||
active_label: String,
|
||||
/// Detail text shown next to the active step (e.g. image name).
|
||||
@@ -192,12 +189,22 @@ impl ProvisioningDisplay {
|
||||
);
|
||||
spinner.enable_steady_tick(Duration::from_millis(120));
|
||||
|
||||
// Always keep a blank line below the spinner so the progress area
|
||||
// doesn't sit flush against the bottom of the terminal.
|
||||
let spacer = mp.add(ProgressBar::new(0));
|
||||
spacer.set_style(
|
||||
ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_bar()),
|
||||
);
|
||||
spacer.set_message("");
|
||||
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
mp,
|
||||
spinner,
|
||||
spacer,
|
||||
completed_steps: Vec::new(),
|
||||
active_label: ProvisioningStep::RequestingCompute
|
||||
completed_bars: Vec::new(),
|
||||
active_label: ProvisioningStep::RequestingSandbox
|
||||
.active_label()
|
||||
.to_string(),
|
||||
active_detail: String::new(),
|
||||
@@ -223,12 +230,20 @@ impl ProvisioningDisplay {
|
||||
|
||||
let elapsed = self.step_start.elapsed();
|
||||
let elapsed_str = format_elapsed(elapsed);
|
||||
let _ = self.mp.println(format!(
|
||||
" {} {} {}",
|
||||
|
||||
// Use a progress bar instead of println so we can clear it later.
|
||||
let bar = self.mp.insert_before(&self.spinner, ProgressBar::new(0));
|
||||
bar.set_style(
|
||||
ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_bar()),
|
||||
);
|
||||
bar.set_message(format!(
|
||||
"{} {} {}",
|
||||
"\u{2713}".green().bold(),
|
||||
label,
|
||||
elapsed_str.dimmed()
|
||||
));
|
||||
bar.finish();
|
||||
self.completed_bars.push(bar);
|
||||
|
||||
// Reset step timer for the next step.
|
||||
self.step_start = Instant::now();
|
||||
@@ -267,10 +282,10 @@ impl ProvisioningDisplay {
|
||||
}
|
||||
|
||||
/// Finish with an error message shown on the last step line.
|
||||
fn finish_error(&mut self, msg: &str) {
|
||||
fn finish_error(&self, msg: &str) {
|
||||
let _ = self
|
||||
.mp
|
||||
.println(format!(" {} {}", "\u{2717}".red().bold(), msg.red()));
|
||||
.println(format!("{} {}", "\u{2717}".red().bold(), msg.red()));
|
||||
self.spinner.finish_and_clear();
|
||||
}
|
||||
|
||||
@@ -283,6 +298,15 @@ impl ProvisioningDisplay {
|
||||
fn current_step_description(&self) -> &str {
|
||||
&self.active_label
|
||||
}
|
||||
|
||||
/// Clear all progress output (spinner, spacer, and completed step lines).
|
||||
fn clear(&mut self) {
|
||||
self.spacer.finish_and_clear();
|
||||
self.spinner.finish_and_clear();
|
||||
for bar in &self.completed_bars {
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a duration as a compact elapsed time string, e.g. `(3s)` or `(1m 12s)`.
|
||||
@@ -303,6 +327,33 @@ fn format_timestamp(d: Duration) -> String {
|
||||
format!("[{secs:.1}s]")
|
||||
}
|
||||
|
||||
/// Extract image size in bytes from a Kubernetes Pulled event message.
|
||||
/// Example: "Successfully pulled image ... Image size: 620405524 bytes."
|
||||
fn extract_image_size(message: &str) -> Option<u64> {
|
||||
let size_prefix = "Image size: ";
|
||||
let start = message.find(size_prefix)? + size_prefix.len();
|
||||
let rest = &message[start..];
|
||||
let end = rest.find(' ')?;
|
||||
rest[..end].parse().ok()
|
||||
}
|
||||
|
||||
/// Format bytes as a human-readable string (e.g., "620 MB").
|
||||
fn format_bytes(bytes: u64) -> String {
|
||||
const KB: u64 = 1024;
|
||||
const MB: u64 = 1024 * KB;
|
||||
const GB: u64 = 1024 * MB;
|
||||
|
||||
if bytes >= GB {
|
||||
format!("{:.1} GB", bytes as f64 / GB as f64)
|
||||
} else if bytes >= MB {
|
||||
format!("{} MB", bytes / MB)
|
||||
} else if bytes >= KB {
|
||||
format!("{} KB", bytes / KB)
|
||||
} else {
|
||||
format!("{} B", bytes)
|
||||
}
|
||||
}
|
||||
|
||||
fn print_sandbox_header(sandbox: &Sandbox, display: Option<&ProvisioningDisplay>) {
|
||||
let lines = [
|
||||
String::new(),
|
||||
@@ -384,6 +435,9 @@ struct GatewayDeployLogPanel {
|
||||
progress: Option<String>,
|
||||
current_step: Option<String>,
|
||||
spinner: ProgressBar,
|
||||
/// Blank line below the spinner so progress doesn't sit flush against the
|
||||
/// bottom of the terminal.
|
||||
spacer: ProgressBar,
|
||||
completed_steps: Vec<ProgressBar>,
|
||||
top_border: Option<ProgressBar>,
|
||||
log_lines: Vec<ProgressBar>,
|
||||
@@ -402,12 +456,21 @@ impl GatewayDeployLogPanel {
|
||||
);
|
||||
spinner.enable_steady_tick(Duration::from_millis(120));
|
||||
|
||||
// Keep a blank line below the spinner so it doesn't sit flush
|
||||
// against the bottom of the terminal.
|
||||
let spacer = mp.add(ProgressBar::new(0));
|
||||
spacer.set_style(
|
||||
ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_bar()),
|
||||
);
|
||||
spacer.set_message("");
|
||||
|
||||
let panel = Self {
|
||||
mp,
|
||||
status: "Starting".to_string(),
|
||||
progress: None,
|
||||
current_step: None,
|
||||
spinner,
|
||||
spacer,
|
||||
completed_steps: Vec::new(),
|
||||
top_border: None,
|
||||
log_lines: Vec::with_capacity(CLUSTER_DEPLOY_LOG_LINES),
|
||||
@@ -476,7 +539,7 @@ impl GatewayDeployLogPanel {
|
||||
let top_border = self.mp.add(ProgressBar::new(0));
|
||||
top_border.set_style(line_style.clone());
|
||||
top_border.set_message(
|
||||
horizontal_rule(Some("Container Logs"), width)
|
||||
horizontal_rule(Some("Gateway Logs"), width)
|
||||
.cyan()
|
||||
.to_string(),
|
||||
);
|
||||
@@ -534,6 +597,7 @@ impl GatewayDeployLogPanel {
|
||||
}
|
||||
self.clear_log_panel();
|
||||
self.spinner.finish_and_clear();
|
||||
self.spacer.finish_and_clear();
|
||||
}
|
||||
|
||||
fn finish_failure(&mut self) {
|
||||
@@ -554,6 +618,7 @@ impl GatewayDeployLogPanel {
|
||||
bottom_border.finish();
|
||||
}
|
||||
self.spinner.finish_and_clear();
|
||||
self.spacer.finish_and_clear();
|
||||
}
|
||||
|
||||
/// Clear the container log panel from the terminal output.
|
||||
@@ -592,7 +657,7 @@ pub async fn gateway_status(gateway_name: &str, server: &str, tls: &TlsOptions)
|
||||
println!(" {} {}", "Gateway:".dimmed(), gateway_name);
|
||||
println!(" {} {}", "Server:".dimmed(), server);
|
||||
if tls.is_bearer_auth() {
|
||||
println!(" {} {}", "Auth:".dimmed(), "Edge (bearer token)");
|
||||
println!(" {} Edge (bearer token)", "Auth:".dimmed());
|
||||
}
|
||||
|
||||
// Try to connect and get health
|
||||
@@ -657,6 +722,117 @@ pub fn gateway_use(name: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn gateway_select(name: Option<&str>, gateway_flag: &Option<String>) -> Result<()> {
|
||||
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
|
||||
gateway_select_with(name, gateway_flag, interactive, |gateways, default| {
|
||||
let prompt = format!(
|
||||
"Select a gateway\n{}",
|
||||
format_gateway_select_header(gateways)
|
||||
);
|
||||
let items = format_gateway_select_items(gateways);
|
||||
Select::with_theme(&ColorfulTheme::default())
|
||||
.with_prompt(prompt)
|
||||
.items(&items)
|
||||
.default(default)
|
||||
.report(false)
|
||||
.interact_opt()
|
||||
.into_diagnostic()
|
||||
.map(|selection| selection.map(|index| gateways[index].name.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
fn format_gateway_select_header(gateways: &[GatewayMetadata]) -> String {
|
||||
let (name_width, endpoint_width) = gateway_select_column_widths(gateways);
|
||||
format!(
|
||||
" {:<name_width$} {:<endpoint_width$} {}",
|
||||
"NAME".bold(),
|
||||
"ENDPOINT".bold(),
|
||||
"TYPE".bold(),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_gateway_select_items(gateways: &[GatewayMetadata]) -> Vec<String> {
|
||||
let (name_width, endpoint_width) = gateway_select_column_widths(gateways);
|
||||
|
||||
gateways
|
||||
.iter()
|
||||
.map(|gateway| {
|
||||
format!(
|
||||
"{:<name_width$} {:<endpoint_width$} {}",
|
||||
gateway.name,
|
||||
gateway.gateway_endpoint,
|
||||
gateway_type_label(gateway),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn gateway_select_column_widths(gateways: &[GatewayMetadata]) -> (usize, usize) {
|
||||
let name_width = gateways
|
||||
.iter()
|
||||
.map(|gateway| gateway.name.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
let endpoint_width = gateways
|
||||
.iter()
|
||||
.map(|gateway| gateway.gateway_endpoint.len())
|
||||
.max()
|
||||
.unwrap_or(8)
|
||||
.max(8);
|
||||
|
||||
(name_width, endpoint_width)
|
||||
}
|
||||
|
||||
fn gateway_type_label(gateway: &GatewayMetadata) -> &'static str {
|
||||
match gateway.auth_mode.as_deref() {
|
||||
Some("cloudflare_jwt") => "edge",
|
||||
_ if gateway.is_remote => "remote",
|
||||
_ => "local",
|
||||
}
|
||||
}
|
||||
|
||||
fn gateway_select_with<F>(
|
||||
name: Option<&str>,
|
||||
gateway_flag: &Option<String>,
|
||||
interactive: bool,
|
||||
choose_gateway: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnOnce(&[GatewayMetadata], usize) -> Result<Option<String>>,
|
||||
{
|
||||
if let Some(name) = name {
|
||||
return gateway_use(name);
|
||||
}
|
||||
|
||||
let gateways = list_gateways()?;
|
||||
if gateways.is_empty() || !interactive {
|
||||
gateway_list(gateway_flag)?;
|
||||
if !gateways.is_empty() {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"Select a gateway with: {}",
|
||||
"openshell gateway select <name>".dimmed()
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let active = gateway_flag.clone().or_else(load_active_gateway);
|
||||
let default = active
|
||||
.as_deref()
|
||||
.and_then(|name| gateways.iter().position(|gateway| gateway.name == name))
|
||||
.unwrap_or(0);
|
||||
|
||||
if let Some(name) = choose_gateway(&gateways, default)? {
|
||||
gateway_use(&name)?;
|
||||
} else {
|
||||
eprintln!("{} Gateway selection cancelled", "!".yellow());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register an external edge-authenticated gateway.
|
||||
///
|
||||
/// Creates local metadata for the given endpoint so it appears in
|
||||
@@ -664,24 +840,23 @@ pub fn gateway_use(name: &str) -> Result<()> {
|
||||
/// authentication and stores the bearer token locally.
|
||||
pub async fn gateway_add(endpoint: &str, name: Option<&str>, no_auth: bool) -> Result<()> {
|
||||
// Normalise the endpoint: ensure it has a scheme.
|
||||
let endpoint = if !endpoint.contains("://") {
|
||||
format!("https://{endpoint}")
|
||||
} else {
|
||||
let endpoint = if endpoint.contains("://") {
|
||||
endpoint.to_string()
|
||||
} else {
|
||||
format!("https://{endpoint}")
|
||||
};
|
||||
|
||||
// Derive a gateway name from the hostname when none is provided.
|
||||
let derived_name;
|
||||
let name = match name {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
// Parse out just the host portion of the URL.
|
||||
derived_name = url::Url::parse(&endpoint)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(String::from))
|
||||
.unwrap_or_else(|| endpoint.clone());
|
||||
&derived_name
|
||||
}
|
||||
let name = if let Some(n) = name {
|
||||
n
|
||||
} else {
|
||||
// Parse out just the host portion of the URL.
|
||||
derived_name = url::Url::parse(&endpoint)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(String::from))
|
||||
.unwrap_or_else(|| endpoint.clone());
|
||||
&derived_name
|
||||
};
|
||||
|
||||
// Build metadata for an edge-authenticated remote gateway.
|
||||
@@ -806,11 +981,7 @@ pub fn gateway_list(gateway_flag: &Option<String>) -> Result<()> {
|
||||
for gateway in &gateways {
|
||||
let is_active = active.as_deref() == Some(&gateway.name);
|
||||
let marker = if is_active { "*" } else { " " };
|
||||
let gateway_type = match gateway.auth_mode.as_deref() {
|
||||
Some("cloudflare_jwt") => "edge",
|
||||
_ if gateway.is_remote => "remote",
|
||||
_ => "local",
|
||||
};
|
||||
let gateway_type = gateway_type_label(gateway);
|
||||
let line = format!(
|
||||
"{marker} {:<name_width$} {:<endpoint_width$} {gateway_type}",
|
||||
gateway.name, gateway.gateway_endpoint,
|
||||
@@ -831,13 +1002,12 @@ async fn http_health_check(server: &str, tls: &TlsOptions) -> Result<Option<Stat
|
||||
|
||||
let scheme = uri.scheme_str().unwrap_or("https");
|
||||
let https = if scheme.eq_ignore_ascii_case("http") || tls.is_bearer_auth() {
|
||||
let https = HttpsConnectorBuilder::new()
|
||||
HttpsConnectorBuilder::new()
|
||||
.with_native_roots()
|
||||
.into_diagnostic()?
|
||||
.https_or_http()
|
||||
.enable_http1()
|
||||
.build();
|
||||
https
|
||||
.build()
|
||||
} else {
|
||||
let materials = require_tls_materials(server, tls)?;
|
||||
let tls_config = build_rustls_config(&materials)?;
|
||||
@@ -937,6 +1107,13 @@ pub(crate) async fn deploy_gateway_with_panel(
|
||||
"x".red().bold(),
|
||||
"Gateway failed:".red().bold(),
|
||||
);
|
||||
// Try to diagnose the failure and provide guidance
|
||||
let err_str = format!("{err:?}");
|
||||
if let Some(diagnosis) =
|
||||
navigator_bootstrap::errors::diagnose_failure(name, &err_str, None)
|
||||
{
|
||||
print_failure_diagnosis(&diagnosis);
|
||||
}
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
@@ -960,13 +1137,33 @@ pub(crate) async fn deploy_gateway_with_panel(
|
||||
/// Print post-deploy summary showing the gateway name and endpoint.
|
||||
pub(crate) fn print_deploy_summary(name: &str, handle: &navigator_bootstrap::GatewayHandle) {
|
||||
eprintln!();
|
||||
eprintln!("{} {} {name}", "✓".green().bold(), "Gateway ready:".green(),);
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
"Gateway endpoint:".bold(),
|
||||
handle.gateway_endpoint()
|
||||
);
|
||||
eprintln!("{} Gateway ready", "✓".green().bold());
|
||||
eprintln!();
|
||||
eprintln!(" {} {name}", "Name:".bold());
|
||||
eprintln!(" {} {}", "Endpoint:".bold(), handle.gateway_endpoint());
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
/// Print a user-friendly failure diagnosis with recovery steps.
|
||||
fn print_failure_diagnosis(diagnosis: &navigator_bootstrap::errors::GatewayFailureDiagnosis) {
|
||||
eprintln!();
|
||||
eprintln!("{}", diagnosis.summary.yellow().bold());
|
||||
eprintln!();
|
||||
eprintln!(" {}", diagnosis.explanation);
|
||||
eprintln!();
|
||||
|
||||
if !diagnosis.recovery_steps.is_empty() {
|
||||
eprintln!(" {}:", "To fix".bold());
|
||||
for (i, step) in diagnosis.recovery_steps.iter().enumerate() {
|
||||
eprintln!();
|
||||
eprintln!(" {}. {}", i + 1, step.description);
|
||||
if let Some(cmd) = &step.command {
|
||||
eprintln!();
|
||||
eprintln!(" {}", cmd.cyan());
|
||||
}
|
||||
}
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
|
||||
/// Provision or start a gateway (local or remote).
|
||||
@@ -983,13 +1180,15 @@ pub async fn gateway_admin_deploy(
|
||||
disable_tls: bool,
|
||||
disable_gateway_auth: bool,
|
||||
registry_token: Option<&str>,
|
||||
gpu: bool,
|
||||
) -> Result<()> {
|
||||
let location = if remote.is_some() { "remote" } else { "local" };
|
||||
|
||||
let mut options = DeployOptions::new(name)
|
||||
.with_port(port)
|
||||
.with_disable_tls(disable_tls)
|
||||
.with_disable_gateway_auth(disable_gateway_auth);
|
||||
.with_disable_gateway_auth(disable_gateway_auth)
|
||||
.with_gpu(gpu);
|
||||
if let Some(kp) = kube_port {
|
||||
let resolved_kp = if kp == 0 {
|
||||
navigator_bootstrap::pick_available_port()?
|
||||
@@ -1098,10 +1297,10 @@ fn resolve_gateway_control_target_from(
|
||||
}
|
||||
|
||||
match metadata {
|
||||
Some(metadata) if metadata.is_remote => metadata
|
||||
.remote_host
|
||||
.map(GatewayControlTarget::Remote)
|
||||
.unwrap_or(GatewayControlTarget::ExternalRegistration),
|
||||
Some(metadata) if metadata.is_remote => metadata.remote_host.map_or(
|
||||
GatewayControlTarget::ExternalRegistration,
|
||||
GatewayControlTarget::Remote,
|
||||
),
|
||||
_ => GatewayControlTarget::Local,
|
||||
}
|
||||
}
|
||||
@@ -1241,7 +1440,7 @@ pub fn gateway_admin_info(name: &str) -> Result<()> {
|
||||
if let Some(ref host) = metadata.remote_host {
|
||||
println!(" {} {host}", "Remote host:".dimmed());
|
||||
} else {
|
||||
println!(" {} {}", "Type:".dimmed(), "External registration");
|
||||
println!(" {} External registration", "Type:".dimmed());
|
||||
}
|
||||
if let Some(ref resolved) = metadata.resolved_host {
|
||||
println!(" {} {resolved}", "Resolved host:".dimmed());
|
||||
@@ -1436,18 +1635,7 @@ pub async fn sandbox_create(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut policy = load_sandbox_policy(policy)?;
|
||||
|
||||
// When a custom image is specified, clear the default run_as_user/group
|
||||
// to prevent failures on images that lack the "sandbox" user/group.
|
||||
// If no explicit policy was provided, start from the restrictive default
|
||||
// so the server stores a policy with cleared identity — otherwise the
|
||||
// sandbox falls back to disk discovery which may re-introduce
|
||||
// run_as_user: sandbox for images that don't have that user.
|
||||
if image.is_some() {
|
||||
let p = policy.get_or_insert_with(navigator_policy::restrictive_default_policy);
|
||||
navigator_policy::clear_process_identity(p);
|
||||
}
|
||||
let policy = load_sandbox_policy(policy)?;
|
||||
|
||||
let template = image.map(|img| SandboxTemplate {
|
||||
image: img,
|
||||
@@ -1491,7 +1679,7 @@ pub async fn sandbox_create(
|
||||
|
||||
// Set initial active step on the spinner.
|
||||
if let Some(d) = display.as_mut() {
|
||||
d.set_active_step(ProvisioningStep::RequestingCompute);
|
||||
d.set_active_step(ProvisioningStep::RequestingSandbox);
|
||||
} else {
|
||||
let ts = format_timestamp(Duration::ZERO);
|
||||
println!(" {} Requesting compute...", ts.dimmed());
|
||||
@@ -1577,7 +1765,7 @@ pub async fn sandbox_create(
|
||||
// non-Ready phase, proving the controller has reconciled.
|
||||
if saw_non_ready && phase == SandboxPhase::Ready {
|
||||
if let Some(d) = display.as_mut() {
|
||||
d.spinner.finish_and_clear();
|
||||
d.clear();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1586,77 +1774,90 @@ pub async fn sandbox_create(
|
||||
// Detect gateway readiness from log messages.
|
||||
if !saw_gateway_ready && line.message.contains("listening") {
|
||||
saw_gateway_ready = true;
|
||||
if let Some(d) = display.as_mut() {
|
||||
d.set_active_step(ProvisioningStep::SandboxReady);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(navigator_core::proto::sandbox_stream_event::Payload::Event(ev)) => {
|
||||
// Map Kubernetes events to provisioning steps.
|
||||
if let Some(step) = kube_event_to_step(&ev.reason) {
|
||||
match step {
|
||||
ProvisioningStep::Scheduled => {
|
||||
// We simplify the display to: Sandbox allocated -> Pulling image -> Ready
|
||||
// Init container events are ignored; we only show the user's sandbox image.
|
||||
if let Some(reason) = parse_kube_event_reason(&ev.reason) {
|
||||
match reason {
|
||||
KubeEventReason::Scheduled => {
|
||||
if let Some(d) = display.as_mut() {
|
||||
// Complete "Requesting compute" step, then show "Pulling image"
|
||||
d.complete_step_with_label(
|
||||
ProvisioningStep::RequestingCompute,
|
||||
"Compute allocated",
|
||||
ProvisioningStep::RequestingSandbox,
|
||||
"Sandbox allocated",
|
||||
);
|
||||
d.set_active_step(ProvisioningStep::Pulling);
|
||||
d.set_active_step(ProvisioningStep::PullingSandboxImage);
|
||||
} else {
|
||||
let ts = format_timestamp(provision_start.elapsed());
|
||||
println!(" {} Compute allocated", ts.dimmed());
|
||||
println!("{} Sandbox allocated", ts.dimmed());
|
||||
}
|
||||
}
|
||||
ProvisioningStep::Pulling => {
|
||||
KubeEventReason::Pulling => {
|
||||
// Extract image name from the event message.
|
||||
let image_detail = ev
|
||||
let image_name = ev
|
||||
.message
|
||||
.strip_prefix("Pulling image ")
|
||||
.map(|s| s.trim_matches('"'))
|
||||
.unwrap_or("");
|
||||
if let Some(d) = display.as_mut() {
|
||||
d.set_active("Pulling image...");
|
||||
if !image_detail.is_empty() {
|
||||
d.set_active_detail(image_detail);
|
||||
}
|
||||
} else {
|
||||
let ts = format_timestamp(provision_start.elapsed());
|
||||
if image_detail.is_empty() {
|
||||
println!(" {} Pulling image...", ts.dimmed());
|
||||
.map_or("", |s| s.trim_matches('"'));
|
||||
// Skip init container image pulls (openshell/sandbox).
|
||||
// Only show progress for the user's sandbox image.
|
||||
let is_init_container_image =
|
||||
image_name.contains("/openshell/sandbox:");
|
||||
if !is_init_container_image {
|
||||
if let Some(d) = display.as_mut() {
|
||||
d.set_active("Pulling image...");
|
||||
if !image_name.is_empty() {
|
||||
d.set_active_detail(image_name);
|
||||
}
|
||||
} else {
|
||||
println!(" {} Pulling image {image_detail}", ts.dimmed());
|
||||
let ts = format_timestamp(provision_start.elapsed());
|
||||
if image_name.is_empty() {
|
||||
println!("{} Pulling image...", ts.dimmed());
|
||||
} else {
|
||||
println!("{} Pulling image {image_name}", ts.dimmed());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ProvisioningStep::Pulled => {
|
||||
if let Some(d) = display.as_mut() {
|
||||
d.complete_step(ProvisioningStep::Pulled);
|
||||
d.set_active_step(ProvisioningStep::ContainerCreated);
|
||||
} else {
|
||||
let ts = format_timestamp(provision_start.elapsed());
|
||||
println!(" {} Image pulled", ts.dimmed());
|
||||
KubeEventReason::Pulled => {
|
||||
// Skip init container pulls.
|
||||
let is_init_container_image =
|
||||
ev.message.contains("/openshell/sandbox:");
|
||||
if !is_init_container_image {
|
||||
// Extract image size from message like:
|
||||
// "Successfully pulled image ... Image size: 620405524 bytes."
|
||||
let size_label = extract_image_size(&ev.message)
|
||||
.map(format_bytes)
|
||||
.unwrap_or_default();
|
||||
let label = if size_label.is_empty() {
|
||||
"Image pulled".to_string()
|
||||
} else {
|
||||
format!("Image pulled ({})", size_label)
|
||||
};
|
||||
if let Some(d) = display.as_mut() {
|
||||
d.complete_step_with_label(
|
||||
ProvisioningStep::PullingSandboxImage,
|
||||
&label,
|
||||
);
|
||||
d.set_active_step(ProvisioningStep::StartingSandbox);
|
||||
} else {
|
||||
let ts = format_timestamp(provision_start.elapsed());
|
||||
println!("{} {}", ts.dimmed(), label);
|
||||
}
|
||||
}
|
||||
}
|
||||
ProvisioningStep::ContainerCreated => {
|
||||
if let Some(d) = display.as_mut() {
|
||||
d.complete_step(ProvisioningStep::ContainerCreated);
|
||||
d.set_active_step(ProvisioningStep::ContainerStarted);
|
||||
} else {
|
||||
let ts = format_timestamp(provision_start.elapsed());
|
||||
println!(" {} Container created", ts.dimmed());
|
||||
KubeEventReason::Started => {
|
||||
// Started fires for both init and main containers.
|
||||
// Only complete StartingSandbox if we've already completed
|
||||
// PullingSandboxImage (meaning this is the main container starting).
|
||||
if let Some(d) = display.as_mut()
|
||||
&& d.completed_steps
|
||||
.contains(&ProvisioningStep::PullingSandboxImage)
|
||||
{
|
||||
d.complete_step(ProvisioningStep::StartingSandbox);
|
||||
}
|
||||
}
|
||||
ProvisioningStep::ContainerStarted => {
|
||||
if let Some(d) = display.as_mut() {
|
||||
d.complete_step(ProvisioningStep::ContainerStarted);
|
||||
d.set_active_step(ProvisioningStep::SandboxReady);
|
||||
} else {
|
||||
let ts = format_timestamp(provision_start.elapsed());
|
||||
println!(" {} Container started", ts.dimmed());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if let Some(d) = display.as_mut() {
|
||||
// Unknown events: show as detail on the current spinner.
|
||||
@@ -1679,18 +1880,18 @@ pub async fn sandbox_create(
|
||||
|
||||
// If we exited the loop without hitting the Ready break, finish the display.
|
||||
let final_phase = SandboxPhase::try_from(last_phase).unwrap_or(SandboxPhase::Unknown);
|
||||
if final_phase != SandboxPhase::Ready {
|
||||
if let Some(d) = display.as_mut() {
|
||||
if final_phase == SandboxPhase::Error {
|
||||
let msg = if last_error_reason.is_empty() {
|
||||
"Sandbox entered error phase".to_string()
|
||||
} else {
|
||||
format!("Error: {last_error_reason}")
|
||||
};
|
||||
d.finish_error(&msg);
|
||||
if final_phase != SandboxPhase::Ready
|
||||
&& let Some(d) = display.as_mut()
|
||||
{
|
||||
if final_phase == SandboxPhase::Error {
|
||||
let msg = if last_error_reason.is_empty() {
|
||||
"Sandbox entered error phase".to_string()
|
||||
} else {
|
||||
d.finish_error("Provisioning stream ended unexpectedly");
|
||||
}
|
||||
format!("Error: {last_error_reason}")
|
||||
};
|
||||
d.finish_error(&msg);
|
||||
} else {
|
||||
d.finish_error("Provisioning stream ended unexpectedly");
|
||||
}
|
||||
}
|
||||
drop(display);
|
||||
@@ -1790,6 +1991,7 @@ pub async fn sandbox_create(
|
||||
&& let Err(err) = sandbox_delete(
|
||||
&effective_server,
|
||||
std::slice::from_ref(&sandbox_name),
|
||||
false,
|
||||
&effective_tls,
|
||||
)
|
||||
.await
|
||||
@@ -2178,11 +2380,35 @@ pub async fn sandbox_list(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a sandbox by name.
|
||||
pub async fn sandbox_delete(server: &str, names: &[String], tls: &TlsOptions) -> Result<()> {
|
||||
/// Delete a sandbox by name, or all sandboxes when `all` is true.
|
||||
pub async fn sandbox_delete(
|
||||
server: &str,
|
||||
names: &[String],
|
||||
all: bool,
|
||||
tls: &TlsOptions,
|
||||
) -> Result<()> {
|
||||
let mut client = grpc_client(server, tls).await?;
|
||||
|
||||
for name in names {
|
||||
let names_to_delete: Vec<String> = if all {
|
||||
// Fetch all sandboxes (use a large page size).
|
||||
let response = client
|
||||
.list_sandboxes(ListSandboxesRequest {
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
})
|
||||
.await
|
||||
.into_diagnostic()?;
|
||||
let sandboxes = response.into_inner().sandboxes;
|
||||
if sandboxes.is_empty() {
|
||||
println!("No sandboxes to delete.");
|
||||
return Ok(());
|
||||
}
|
||||
sandboxes.into_iter().map(|s| s.name).collect()
|
||||
} else {
|
||||
names.to_vec()
|
||||
};
|
||||
|
||||
for name in &names_to_delete {
|
||||
// Stop any background port forwards for this sandbox before deleting.
|
||||
if let Ok(stopped) = stop_forwards_for_sandbox(name) {
|
||||
for port in stopped {
|
||||
@@ -3124,8 +3350,7 @@ pub async fn sandbox_policy_set(
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|r| r.into_inner().revision)
|
||||
.map(|r| r.version)
|
||||
.unwrap_or(0);
|
||||
.map_or(0, |r| r.version);
|
||||
|
||||
let response = client
|
||||
.update_sandbox_policy(UpdateSandboxPolicyRequest {
|
||||
@@ -3292,8 +3517,8 @@ pub async fn sandbox_policy_list(
|
||||
}
|
||||
|
||||
println!(
|
||||
"{:<8} {:<14} {:<12} {:<24} {}",
|
||||
"VERSION", "HASH", "STATUS", "CREATED", "ERROR"
|
||||
"{:<8} {:<14} {:<12} {:<24} ERROR",
|
||||
"VERSION", "HASH", "STATUS", "CREATED"
|
||||
);
|
||||
for rev in &revisions {
|
||||
let status = PolicyStatus::try_from(rev.status).unwrap_or(PolicyStatus::Unspecified);
|
||||
@@ -3460,10 +3685,14 @@ fn print_log_line(log: &navigator_core::proto::SandboxLogLine) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
GatewayControlTarget, TlsOptions, git_sync_files, http_health_check,
|
||||
inferred_provider_type, parse_credential_pairs, resolve_gateway_control_target_from,
|
||||
GatewayControlTarget, TlsOptions, format_gateway_select_header,
|
||||
format_gateway_select_items, gateway_select_with, gateway_type_label, git_sync_files,
|
||||
http_health_check, inferred_provider_type, parse_credential_pairs,
|
||||
resolve_gateway_control_target_from,
|
||||
};
|
||||
use crate::TEST_ENV_LOCK;
|
||||
use hyper::StatusCode;
|
||||
use navigator_bootstrap::{load_active_gateway, store_gateway_metadata};
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
@@ -3512,6 +3741,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_tmp_xdg<F: FnOnce()>(tmp: &Path, f: F) {
|
||||
let _guard = TEST_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let guard = EnvVarGuard::set(
|
||||
"XDG_CONFIG_HOME",
|
||||
tmp.to_str().expect("temp path should be utf-8"),
|
||||
);
|
||||
f();
|
||||
drop(guard);
|
||||
}
|
||||
|
||||
fn edge_registration(name: &str, endpoint: &str) -> GatewayMetadata {
|
||||
GatewayMetadata {
|
||||
name: name.to_string(),
|
||||
@@ -3670,6 +3911,111 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_select_uses_explicit_name_without_prompting() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
with_tmp_xdg(tmpdir.path(), || {
|
||||
store_gateway_metadata(
|
||||
"alpha",
|
||||
&edge_registration("alpha", "https://alpha.example.com"),
|
||||
)
|
||||
.expect("store gateway");
|
||||
|
||||
let mut prompted = false;
|
||||
gateway_select_with(Some("alpha"), &None, true, |_, _| {
|
||||
prompted = true;
|
||||
Ok(None)
|
||||
})
|
||||
.expect("select explicit gateway");
|
||||
|
||||
assert_eq!(load_active_gateway().as_deref(), Some("alpha"));
|
||||
assert!(!prompted, "explicit gateway should skip prompting");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_select_prefers_active_gateway_as_default_choice() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
with_tmp_xdg(tmpdir.path(), || {
|
||||
store_gateway_metadata(
|
||||
"alpha",
|
||||
&edge_registration("alpha", "https://alpha.example.com"),
|
||||
)
|
||||
.expect("store alpha");
|
||||
store_gateway_metadata(
|
||||
"beta",
|
||||
&edge_registration("beta", "https://beta.example.com"),
|
||||
)
|
||||
.expect("store beta");
|
||||
super::save_active_gateway("beta").expect("save active gateway");
|
||||
|
||||
let mut seen_default = None;
|
||||
gateway_select_with(None, &None, true, |gateways, default| {
|
||||
seen_default = Some(default);
|
||||
Ok(Some(gateways[default].name.clone()))
|
||||
})
|
||||
.expect("interactive selection");
|
||||
|
||||
assert_eq!(seen_default, Some(1));
|
||||
assert_eq!(load_active_gateway().as_deref(), Some("beta"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_select_non_interactive_lists_gateways_without_prompting() {
|
||||
let tmpdir = tempfile::tempdir().expect("create tmpdir");
|
||||
with_tmp_xdg(tmpdir.path(), || {
|
||||
store_gateway_metadata(
|
||||
"alpha",
|
||||
&edge_registration("alpha", "https://alpha.example.com"),
|
||||
)
|
||||
.expect("store gateway");
|
||||
|
||||
let mut prompted = false;
|
||||
gateway_select_with(None, &None, false, |_, _| {
|
||||
prompted = true;
|
||||
Ok(None)
|
||||
})
|
||||
.expect("non-interactive selection");
|
||||
|
||||
assert!(!prompted, "non-interactive mode should not prompt");
|
||||
assert_eq!(load_active_gateway(), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_select_items_include_endpoint_and_type() {
|
||||
let gateways = vec![
|
||||
edge_registration("alpha", "https://edge.example.com"),
|
||||
GatewayMetadata {
|
||||
name: "local".to_string(),
|
||||
gateway_endpoint: "http://127.0.0.1:8080".to_string(),
|
||||
is_remote: false,
|
||||
gateway_port: 8080,
|
||||
kube_port: None,
|
||||
remote_host: None,
|
||||
resolved_host: None,
|
||||
auth_mode: None,
|
||||
edge_team_domain: None,
|
||||
edge_auth_url: None,
|
||||
},
|
||||
];
|
||||
|
||||
let items = format_gateway_select_items(&gateways);
|
||||
let header = format_gateway_select_header(&gateways);
|
||||
|
||||
assert_eq!(gateway_type_label(&gateways[0]), "edge");
|
||||
assert_eq!(gateway_type_label(&gateways[1]), "local");
|
||||
assert!(header.contains("NAME"));
|
||||
assert!(header.contains("ENDPOINT"));
|
||||
assert!(header.contains("TYPE"));
|
||||
assert!(items[0].contains("alpha"));
|
||||
assert!(items[0].contains("https://edge.example.com"));
|
||||
assert!(items[0].contains("edge"));
|
||||
assert!(items[1].contains("local"));
|
||||
assert!(items[1].contains("http://127.0.0.1:8080"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_health_check_supports_plain_http_endpoints() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");
|
||||
|
||||
@@ -363,14 +363,20 @@ pub fn restrictive_default_policy() -> SandboxPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear `run_as_user` / `run_as_group` from the policy's process section.
|
||||
/// Ensure the policy has `run_as_user: sandbox` and `run_as_group: sandbox`.
|
||||
///
|
||||
/// Call this when a custom image is specified, since the image may lack the
|
||||
/// default "sandbox" user/group.
|
||||
pub fn clear_process_identity(policy: &mut SandboxPolicy) {
|
||||
if let Some(ref mut process) = policy.process {
|
||||
process.run_as_user = String::new();
|
||||
process.run_as_group = String::new();
|
||||
/// If the process section is missing, or either field is empty, this fills in
|
||||
/// the required `"sandbox"` value. Call this before validation so that
|
||||
/// policies without an explicit process section get the correct default.
|
||||
pub fn ensure_sandbox_process_identity(policy: &mut SandboxPolicy) {
|
||||
let process = policy
|
||||
.process
|
||||
.get_or_insert_with(|| ProcessPolicy::default());
|
||||
if process.run_as_user.is_empty() {
|
||||
process.run_as_user = "sandbox".into();
|
||||
}
|
||||
if process.run_as_group.is_empty() {
|
||||
process.run_as_group = "sandbox".into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,8 +393,8 @@ const MAX_PATH_LENGTH: usize = 4096;
|
||||
/// A safety violation found in a sandbox policy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PolicyViolation {
|
||||
/// `run_as_user` or `run_as_group` is "root" or "0".
|
||||
RootProcessIdentity { field: &'static str, value: String },
|
||||
/// `run_as_user` or `run_as_group` is not "sandbox".
|
||||
InvalidProcessIdentity { field: &'static str, value: String },
|
||||
/// A filesystem path contains `..` components.
|
||||
PathTraversal { path: String },
|
||||
/// A filesystem path is not absolute (does not start with `/`).
|
||||
@@ -404,8 +410,8 @@ pub enum PolicyViolation {
|
||||
impl fmt::Display for PolicyViolation {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::RootProcessIdentity { field, value } => {
|
||||
write!(f, "{field} cannot be '{value}' (root is not allowed)")
|
||||
Self::InvalidProcessIdentity { field, value } => {
|
||||
write!(f, "{field} must be 'sandbox', got '{value}'")
|
||||
}
|
||||
Self::PathTraversal { path } => {
|
||||
write!(f, "path contains '..' traversal component: {path}")
|
||||
@@ -439,7 +445,7 @@ impl fmt::Display for PolicyViolation {
|
||||
/// error vs. logged warning).
|
||||
///
|
||||
/// Checks performed:
|
||||
/// - `run_as_user` / `run_as_group` must not be "root" or "0"
|
||||
/// - `run_as_user` / `run_as_group` must be "sandbox"
|
||||
/// - Filesystem paths must be absolute (start with `/`)
|
||||
/// - Filesystem paths must not contain `..` components
|
||||
/// - Read-write paths must not be overly broad (just `/`)
|
||||
@@ -450,16 +456,18 @@ pub fn validate_sandbox_policy(
|
||||
) -> std::result::Result<(), Vec<PolicyViolation>> {
|
||||
let mut violations = Vec::new();
|
||||
|
||||
// Check process identity
|
||||
// Check process identity — must be "sandbox".
|
||||
// `ensure_sandbox_process_identity` should be called before this to
|
||||
// fill in defaults; anything other than "sandbox" is rejected.
|
||||
if let Some(ref process) = policy.process {
|
||||
if is_root_identity(&process.run_as_user) {
|
||||
violations.push(PolicyViolation::RootProcessIdentity {
|
||||
if process.run_as_user != "sandbox" {
|
||||
violations.push(PolicyViolation::InvalidProcessIdentity {
|
||||
field: "run_as_user",
|
||||
value: process.run_as_user.clone(),
|
||||
});
|
||||
}
|
||||
if is_root_identity(&process.run_as_group) {
|
||||
violations.push(PolicyViolation::RootProcessIdentity {
|
||||
if process.run_as_group != "sandbox" {
|
||||
violations.push(PolicyViolation::InvalidProcessIdentity {
|
||||
field: "run_as_group",
|
||||
value: process.run_as_group.clone(),
|
||||
});
|
||||
@@ -519,15 +527,6 @@ pub fn validate_sandbox_policy(
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a user/group identity string refers to root.
|
||||
fn is_root_identity(value: &str) -> bool {
|
||||
if value.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let trimmed = value.trim();
|
||||
trimmed == "root" || trimmed == "0"
|
||||
}
|
||||
|
||||
/// Truncate a string for safe inclusion in error messages.
|
||||
fn truncate_for_display(s: &str) -> String {
|
||||
if s.len() <= 80 {
|
||||
@@ -724,13 +723,35 @@ network_policies:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_process_identity_clears_fields() {
|
||||
fn ensure_sandbox_process_identity_fills_defaults() {
|
||||
let mut policy = restrictive_default_policy();
|
||||
assert_eq!(policy.process.as_ref().unwrap().run_as_user, "sandbox");
|
||||
clear_process_identity(&mut policy);
|
||||
policy.process = None;
|
||||
ensure_sandbox_process_identity(&mut policy);
|
||||
let proc = policy.process.unwrap();
|
||||
assert!(proc.run_as_user.is_empty());
|
||||
assert!(proc.run_as_group.is_empty());
|
||||
assert_eq!(proc.run_as_user, "sandbox");
|
||||
assert_eq!(proc.run_as_group, "sandbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_sandbox_process_identity_fills_empty_strings() {
|
||||
let mut policy = restrictive_default_policy();
|
||||
policy.process = Some(ProcessPolicy {
|
||||
run_as_user: String::new(),
|
||||
run_as_group: String::new(),
|
||||
});
|
||||
ensure_sandbox_process_identity(&mut policy);
|
||||
let proc = policy.process.unwrap();
|
||||
assert_eq!(proc.run_as_user, "sandbox");
|
||||
assert_eq!(proc.run_as_group, "sandbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_sandbox_process_identity_preserves_sandbox() {
|
||||
let mut policy = restrictive_default_policy();
|
||||
ensure_sandbox_process_identity(&mut policy);
|
||||
let proc = policy.process.unwrap();
|
||||
assert_eq!(proc.run_as_user, "sandbox");
|
||||
assert_eq!(proc.run_as_group, "sandbox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -750,7 +771,7 @@ network_policies:
|
||||
let violations = validate_sandbox_policy(&policy).unwrap_err();
|
||||
assert!(violations.iter().any(|v| matches!(
|
||||
v,
|
||||
PolicyViolation::RootProcessIdentity {
|
||||
PolicyViolation::InvalidProcessIdentity {
|
||||
field: "run_as_user",
|
||||
..
|
||||
}
|
||||
@@ -768,6 +789,28 @@ network_policies:
|
||||
assert_eq!(violations.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_non_sandbox_user() {
|
||||
let mut policy = restrictive_default_policy();
|
||||
policy.process = Some(ProcessPolicy {
|
||||
run_as_user: "nobody".into(),
|
||||
run_as_group: "nogroup".into(),
|
||||
});
|
||||
let violations = validate_sandbox_policy(&policy).unwrap_err();
|
||||
assert_eq!(violations.len(), 2);
|
||||
assert!(
|
||||
violations
|
||||
.iter()
|
||||
.all(|v| matches!(v, PolicyViolation::InvalidProcessIdentity { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_sandbox_identity() {
|
||||
let policy = restrictive_default_policy();
|
||||
assert!(validate_sandbox_policy(&policy).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_path_traversal() {
|
||||
let mut policy = restrictive_default_policy();
|
||||
@@ -835,13 +878,14 @@ network_policies:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_empty_run_as_user() {
|
||||
fn validate_rejects_empty_run_as_user() {
|
||||
let mut policy = restrictive_default_policy();
|
||||
policy.process = Some(ProcessPolicy {
|
||||
run_as_user: String::new(),
|
||||
run_as_group: String::new(),
|
||||
});
|
||||
assert!(validate_sandbox_policy(&policy).is_ok());
|
||||
let violations = validate_sandbox_policy(&policy).unwrap_err();
|
||||
assert_eq!(violations.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -893,12 +937,13 @@ network_policies:
|
||||
|
||||
#[test]
|
||||
fn policy_violation_display() {
|
||||
let v = PolicyViolation::RootProcessIdentity {
|
||||
let v = PolicyViolation::InvalidProcessIdentity {
|
||||
field: "run_as_user",
|
||||
value: "root".into(),
|
||||
};
|
||||
let s = format!("{v}");
|
||||
assert!(s.contains("root"));
|
||||
assert!(s.contains("run_as_user"));
|
||||
assert!(s.contains("sandbox"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ pub async fn run_sandbox(
|
||||
|
||||
// Load policy and initialize OPA engine
|
||||
let navigator_endpoint_for_proxy = navigator_endpoint.clone();
|
||||
let (mut policy, opa_engine) = load_policy(
|
||||
let (policy, opa_engine) = load_policy(
|
||||
sandbox_id.clone(),
|
||||
sandbox,
|
||||
navigator_endpoint.clone(),
|
||||
@@ -172,13 +172,10 @@ pub async fn run_sandbox(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// When no run_as_user/group is configured (e.g. custom/community images
|
||||
// where the CLI cleared identity) and the supervisor is running as root
|
||||
// (forced via runAsUser: 0 in the pod spec), auto-detect the image's
|
||||
// intended sandbox user. This prevents child processes from running as
|
||||
// root unnecessarily.
|
||||
// Validate that the required "sandbox" user exists in this image.
|
||||
// All sandbox images must include this user for privilege dropping.
|
||||
#[cfg(unix)]
|
||||
maybe_infer_sandbox_user(&mut policy);
|
||||
validate_sandbox_user(&policy)?;
|
||||
|
||||
// Fetch provider environment variables from the server.
|
||||
// This is done after loading the policy so the sandbox can still start
|
||||
@@ -1026,61 +1023,36 @@ fn discover_policy_from_path(path: &std::path::Path) -> navigator_core::proto::S
|
||||
}
|
||||
}
|
||||
|
||||
/// When the policy has no `run_as_user`/`run_as_group` and the supervisor is
|
||||
/// running as root, try to detect the image's intended non-root user for
|
||||
/// child process privilege dropping.
|
||||
/// Validate that the `sandbox` user exists in this image.
|
||||
///
|
||||
/// This handles the BYOC / community-image case where the CLI clears
|
||||
/// `run_as_user` (to avoid failures if the default "sandbox" user doesn't
|
||||
/// exist), but the K8s pod spec forces `runAsUser: 0` so the supervisor can
|
||||
/// perform privileged setup. Without this, child processes would inherit
|
||||
/// root — undermining the image author's security intent.
|
||||
///
|
||||
/// Detection strategy: look for a `sandbox` user in `/etc/passwd`. This is
|
||||
/// the conventional non-root user in OpenShell sandbox images.
|
||||
/// All sandbox images must include a `sandbox` user for privilege dropping.
|
||||
/// This check runs at supervisor startup (inside the container) where we can
|
||||
/// inspect `/etc/passwd`. If the user is missing, the sandbox fails fast
|
||||
/// with a clear error instead of silently running child processes as root.
|
||||
#[cfg(unix)]
|
||||
fn maybe_infer_sandbox_user(policy: &mut SandboxPolicy) {
|
||||
use nix::unistd::{Uid, User};
|
||||
fn validate_sandbox_user(policy: &SandboxPolicy) -> Result<()> {
|
||||
use nix::unistd::User;
|
||||
|
||||
// Only infer when both fields are empty and we're running as root.
|
||||
let has_user = policy
|
||||
.process
|
||||
.run_as_user
|
||||
.as_ref()
|
||||
.is_some_and(|u| !u.is_empty());
|
||||
let has_group = policy
|
||||
.process
|
||||
.run_as_group
|
||||
.as_ref()
|
||||
.is_some_and(|g| !g.is_empty());
|
||||
if has_user || has_group || !Uid::effective().is_root() {
|
||||
return;
|
||||
let user_name = policy.process.run_as_user.as_deref().unwrap_or("sandbox");
|
||||
|
||||
if user_name.is_empty() || user_name == "sandbox" {
|
||||
match User::from_name("sandbox") {
|
||||
Ok(Some(_)) => {
|
||||
info!("Validated 'sandbox' user exists in image");
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err(miette::miette!(
|
||||
"sandbox user 'sandbox' not found in image; \
|
||||
all sandbox images must include a 'sandbox' user and group"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(miette::miette!("failed to look up 'sandbox' user: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a "sandbox" user exists in the image.
|
||||
match User::from_name("sandbox") {
|
||||
Ok(Some(_)) => {
|
||||
info!(
|
||||
"Policy has no run_as_user but image has 'sandbox' user; \
|
||||
inferring run_as_user=sandbox, run_as_group=sandbox"
|
||||
);
|
||||
policy.process.run_as_user = Some("sandbox".to_string());
|
||||
policy.process.run_as_group = Some("sandbox".to_string());
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
"Policy has no run_as_user and image has no 'sandbox' user; \
|
||||
child processes will run as root. Set process.run_as_user in \
|
||||
your sandbox policy to specify a non-root user."
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
error = %e,
|
||||
"Failed to look up 'sandbox' user; child processes will run as root"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prepare filesystem for the sandboxed process.
|
||||
|
||||
@@ -569,8 +569,8 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy) -> String {
|
||||
let process = proto.process.as_ref().map_or_else(
|
||||
|| {
|
||||
serde_json::json!({
|
||||
"run_as_user": "",
|
||||
"run_as_group": "",
|
||||
"run_as_user": "sandbox",
|
||||
"run_as_group": "sandbox",
|
||||
})
|
||||
},
|
||||
|p| {
|
||||
@@ -1292,8 +1292,8 @@ filesystem_policy:
|
||||
landlock:
|
||||
compatibility: best_effort
|
||||
process:
|
||||
run_as_user: ""
|
||||
run_as_group: ""
|
||||
run_as_user: sandbox
|
||||
run_as_group: sandbox
|
||||
"#;
|
||||
|
||||
fn l7_engine() -> OpaEngine {
|
||||
@@ -1535,8 +1535,8 @@ filesystem_policy:
|
||||
landlock:
|
||||
compatibility: best_effort
|
||||
process:
|
||||
run_as_user: ""
|
||||
run_as_group: ""
|
||||
run_as_user: sandbox
|
||||
run_as_group: sandbox
|
||||
"#;
|
||||
|
||||
const NO_INFERENCE_TEST_DATA: &str = r#"
|
||||
@@ -1554,8 +1554,8 @@ filesystem_policy:
|
||||
landlock:
|
||||
compatibility: best_effort
|
||||
process:
|
||||
run_as_user: ""
|
||||
run_as_group: ""
|
||||
run_as_user: sandbox
|
||||
run_as_group: sandbox
|
||||
"#;
|
||||
|
||||
fn inference_engine() -> OpaEngine {
|
||||
@@ -1777,8 +1777,8 @@ filesystem_policy:
|
||||
landlock:
|
||||
compatibility: best_effort
|
||||
process:
|
||||
run_as_user: ""
|
||||
run_as_group: ""
|
||||
run_as_user: sandbox
|
||||
run_as_group: sandbox
|
||||
"#;
|
||||
|
||||
fn allowed_ips_engine() -> OpaEngine {
|
||||
@@ -1918,8 +1918,8 @@ process:
|
||||
compatibility: "best_effort".to_string(),
|
||||
}),
|
||||
process: Some(ProtoProc {
|
||||
run_as_user: "".to_string(),
|
||||
run_as_group: "".to_string(),
|
||||
run_as_user: "sandbox".to_string(),
|
||||
run_as_group: "sandbox".to_string(),
|
||||
}),
|
||||
network_policies,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! When the CLI runs `gateway add` or `gateway login`, it opens the user's
|
||||
//! browser to `GET /auth/connect?callback_port=<port>`. The edge proxy
|
||||
//! (e.g., Cloudflare Access) intercepts the request and shows its IdP login
|
||||
//! (e.g., Cloudflare Access) intercepts the request and shows its `IdP` login
|
||||
//! page. After authentication, the proxy sets the `CF_Authorization` cookie
|
||||
//! and proxies the request to this endpoint.
|
||||
//!
|
||||
@@ -66,8 +66,7 @@ async fn auth_connect(
|
||||
.get("x-forwarded-host")
|
||||
.or_else(|| headers.get("host"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| state.config.bind_address.to_string());
|
||||
.map_or_else(|| state.config.bind_address.to_string(), String::from);
|
||||
|
||||
match cf_token {
|
||||
Some(token) => Html(render_connect_page(
|
||||
@@ -312,11 +311,6 @@ fn render_connect_page(
|
||||
</script>
|
||||
</body>
|
||||
</html>"#,
|
||||
gateway_addr = gateway_addr,
|
||||
version = version,
|
||||
escaped_token = escaped_token,
|
||||
escaped_code = escaped_code,
|
||||
callback_port = callback_port,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -408,8 +402,6 @@ fn render_waiting_page(callback_port: u16, code: &str) -> String {
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
callback_port = callback_port,
|
||||
safe_code = safe_code,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ use crate::ServerState;
|
||||
///
|
||||
/// Client-provided `limit` values are clamped to this ceiling to prevent
|
||||
/// unbounded memory allocation from an excessively large page request.
|
||||
pub(crate) const MAX_PAGE_SIZE: u32 = 1000;
|
||||
pub const MAX_PAGE_SIZE: u32 = 1000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field-level size limits
|
||||
@@ -99,7 +99,7 @@ const MAX_PROVIDER_CONFIG_ENTRIES: usize = 64;
|
||||
///
|
||||
/// Returns `default` when `raw` is 0 (the protobuf zero-value convention),
|
||||
/// otherwise returns the smaller of `raw` and `max`.
|
||||
pub(crate) fn clamp_limit(raw: u32, default: u32, max: u32) -> u32 {
|
||||
pub fn clamp_limit(raw: u32, default: u32, max: u32) -> u32 {
|
||||
if raw == 0 { default } else { raw.min(max) }
|
||||
}
|
||||
|
||||
@@ -163,8 +163,10 @@ impl Navigator for NavigatorService {
|
||||
template.image = self.state.sandbox_client.default_image().to_string();
|
||||
}
|
||||
|
||||
// Validate policy safety before persisting.
|
||||
if let Some(ref policy) = spec.policy {
|
||||
// Ensure process identity defaults to "sandbox" when missing or
|
||||
// empty, then validate policy safety before persisting.
|
||||
if let Some(ref mut policy) = spec.policy {
|
||||
navigator_policy::ensure_sandbox_process_identity(policy);
|
||||
validate_policy_safety(policy)?;
|
||||
}
|
||||
|
||||
@@ -566,21 +568,19 @@ impl Navigator for NavigatorService {
|
||||
.await
|
||||
{
|
||||
for record in records {
|
||||
if let Ok(session) = SshSession::decode(record.payload.as_slice()) {
|
||||
if session.sandbox_id == id {
|
||||
if let Err(e) = self
|
||||
.state
|
||||
.store
|
||||
.delete(SshSession::object_type(), &session.id)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
session_id = %session.id,
|
||||
error = %e,
|
||||
"Failed to delete SSH session during sandbox cleanup"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Ok(session) = SshSession::decode(record.payload.as_slice())
|
||||
&& session.sandbox_id == id
|
||||
&& let Err(e) = self
|
||||
.state
|
||||
.store
|
||||
.delete(SshSession::object_type(), &session.id)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
session_id = %session.id,
|
||||
error = %e,
|
||||
"Failed to delete SSH session during sandbox cleanup"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -970,7 +970,7 @@ impl Navigator for NavigatorService {
|
||||
if req.name.is_empty() {
|
||||
return Err(Status::invalid_argument("name is required"));
|
||||
}
|
||||
let new_policy = req
|
||||
let mut new_policy = req
|
||||
.policy
|
||||
.ok_or_else(|| Status::invalid_argument("policy is required"))?;
|
||||
|
||||
@@ -991,6 +991,9 @@ impl Navigator for NavigatorService {
|
||||
.as_ref()
|
||||
.ok_or_else(|| Status::internal("sandbox has no spec"))?;
|
||||
|
||||
// Ensure process identity defaults to "sandbox" when missing or empty.
|
||||
navigator_policy::ensure_sandbox_process_identity(&mut new_policy);
|
||||
|
||||
if let Some(baseline_policy) = spec.policy.as_ref() {
|
||||
// Validate static fields haven't changed.
|
||||
validate_static_fields_unchanged(baseline_policy, &new_policy)?;
|
||||
@@ -1032,13 +1035,13 @@ impl Navigator for NavigatorService {
|
||||
let payload = new_policy.encode_to_vec();
|
||||
let hash = deterministic_policy_hash(&new_policy);
|
||||
|
||||
if let Some(ref current) = latest {
|
||||
if current.policy_hash == hash {
|
||||
return Ok(Response::new(UpdateSandboxPolicyResponse {
|
||||
version: u32::try_from(current.version).unwrap_or(0),
|
||||
policy_hash: hash,
|
||||
}));
|
||||
}
|
||||
if let Some(ref current) = latest
|
||||
&& current.policy_hash == hash
|
||||
{
|
||||
return Ok(Response::new(UpdateSandboxPolicyResponse {
|
||||
version: u32::try_from(current.version).unwrap_or(0),
|
||||
policy_hash: hash,
|
||||
}));
|
||||
}
|
||||
|
||||
let next_version = latest.map_or(1, |r| r.version + 1);
|
||||
@@ -1662,7 +1665,7 @@ fn validate_filesystem_additive(
|
||||
}
|
||||
|
||||
/// Validate that network mode hasn't changed (Block ↔ Proxy).
|
||||
/// Adding network_policies when none existed (or removing all) changes the mode.
|
||||
/// Adding `network_policies` when none existed (or removing all) changes the mode.
|
||||
fn validate_network_mode_unchanged(
|
||||
baseline: &ProtoSandboxPolicy,
|
||||
new: &ProtoSandboxPolicy,
|
||||
@@ -2301,9 +2304,7 @@ async fn update_provider_record(
|
||||
// Provider type is immutable after creation. Reject if the caller
|
||||
// sends a non-empty type that differs from the existing one.
|
||||
let incoming_type = provider.r#type.trim();
|
||||
if !incoming_type.is_empty()
|
||||
&& incoming_type.to_ascii_lowercase() != existing.r#type.trim().to_ascii_lowercase()
|
||||
{
|
||||
if !incoming_type.is_empty() && !incoming_type.eq_ignore_ascii_case(existing.r#type.trim()) {
|
||||
return Err(Status::invalid_argument(
|
||||
"provider type cannot be changed; delete and recreate the provider",
|
||||
));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! OpenShell Server library.
|
||||
//! `OpenShell` Server library.
|
||||
//!
|
||||
//! This crate provides the server implementation for OpenShell, including:
|
||||
//! This crate provides the server implementation for `OpenShell`, including:
|
||||
//! - gRPC service implementation
|
||||
//! - HTTP health endpoints
|
||||
//! - Protocol multiplexing (gRPC + HTTP on same port)
|
||||
@@ -91,7 +91,7 @@ impl ServerState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the OpenShell server.
|
||||
/// Run the `OpenShell` server.
|
||||
///
|
||||
/// This starts a multiplexed gRPC/HTTP server on the configured bind address.
|
||||
///
|
||||
@@ -162,17 +162,16 @@ pub async fn run_server(config: Config, tracing_log_bus: TracingLogBus) -> Resul
|
||||
info!(address = %config.bind_address, "Server listening");
|
||||
|
||||
// Build TLS acceptor when TLS is configured; otherwise serve plaintext.
|
||||
let tls_acceptor = match &config.tls {
|
||||
Some(tls) => Some(TlsAcceptor::from_files(
|
||||
let tls_acceptor = if let Some(tls) = &config.tls {
|
||||
Some(TlsAcceptor::from_files(
|
||||
&tls.cert_path,
|
||||
&tls.key_path,
|
||||
&tls.client_ca_path,
|
||||
tls.allow_unauthenticated,
|
||||
)?),
|
||||
None => {
|
||||
info!("TLS disabled — accepting plaintext connections");
|
||||
None
|
||||
}
|
||||
)?)
|
||||
} else {
|
||||
info!("TLS disabled — accepting plaintext connections");
|
||||
None
|
||||
};
|
||||
|
||||
// Accept connections
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! OpenShell Server - gRPC/HTTP server with protocol multiplexing.
|
||||
//! `OpenShell` Server - gRPC/HTTP server with protocol multiplexing.
|
||||
|
||||
use clap::Parser;
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
@@ -12,7 +12,7 @@ use tracing_subscriber::EnvFilter;
|
||||
|
||||
use navigator_server::{run_server, tracing_bus::TracingLogBus};
|
||||
|
||||
/// OpenShell Server - gRPC and HTTP server with protocol multiplexing.
|
||||
/// `OpenShell` Server - gRPC and HTTP server with protocol multiplexing.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "navigator-server")]
|
||||
#[command(about = "OpenShell gRPC/HTTP server", long_about = None)]
|
||||
@@ -49,7 +49,7 @@ struct Args {
|
||||
#[arg(long, env = "OPENSHELL_SANDBOX_IMAGE")]
|
||||
sandbox_image: Option<String>,
|
||||
|
||||
/// gRPC endpoint for sandboxes to callback to OpenShell.
|
||||
/// gRPC endpoint for sandboxes to callback to `OpenShell`.
|
||||
/// This should be reachable from within the Kubernetes cluster.
|
||||
#[arg(long, env = "OPENSHELL_GRPC_ENDPOINT")]
|
||||
grpc_endpoint: Option<String>,
|
||||
|
||||
@@ -68,7 +68,7 @@ impl MultiplexService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined gRPC service that routes between OpenShell and Inference services
|
||||
/// Combined gRPC service that routes between `OpenShell` and Inference services
|
||||
/// based on the request path prefix.
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcRouter<N, I> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Persistence layer for OpenShell Server.
|
||||
//! Persistence layer for `OpenShell` Server.
|
||||
|
||||
mod postgres;
|
||||
mod sqlite;
|
||||
|
||||
@@ -127,9 +127,14 @@ fn map_kube_event_to_platform(
|
||||
"Sandbox" => state
|
||||
.sandbox_index
|
||||
.sandbox_id_for_sandbox_name(&involved_name)?,
|
||||
"Pod" => state
|
||||
.sandbox_index
|
||||
.sandbox_id_for_agent_pod(&involved_name)?,
|
||||
"Pod" => {
|
||||
// The sandbox controller creates pods with the same name as the sandbox,
|
||||
// so try looking up by sandbox name first, then fall back to agent_pod index.
|
||||
state
|
||||
.sandbox_index
|
||||
.sandbox_id_for_sandbox_name(&involved_name)
|
||||
.or_else(|| state.sandbox_index.sandbox_id_for_agent_pod(&involved_name))?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
|
||||
@@ -1163,14 +1163,13 @@ fn spawn_create_sandbox(app: &mut App, tx: mpsc::UnboundedSender<Event>) {
|
||||
None
|
||||
};
|
||||
|
||||
// For custom images, provide a restrictive default policy so the
|
||||
// server has a baseline. The server ensures process identity is set
|
||||
// to "sandbox". For the default image, let the server apply the
|
||||
// sandbox's own default policy.
|
||||
let policy = if has_custom_image {
|
||||
// Custom images may lack the default "sandbox" user/group, so
|
||||
// use a restrictive default with cleared process identity.
|
||||
let mut p = navigator_policy::restrictive_default_policy();
|
||||
navigator_policy::clear_process_identity(&mut p);
|
||||
Some(p)
|
||||
Some(navigator_policy::restrictive_default_policy())
|
||||
} else {
|
||||
// Let the server apply the sandbox's own default policy.
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# CI runner image with all development tools pre-installed
|
||||
# Rebuild triggered automatically when mise.toml or this file changes
|
||||
|
||||
FROM ubuntu:24.04
|
||||
FROM nvcr.io/nvidia/base/ubuntu:noble-20251013
|
||||
|
||||
ARG DOCKER_VERSION=29.3.0
|
||||
ARG BUILDX_VERSION=v0.32.1
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
|
||||
# k3s cluster image with OpenShell Helm charts and manifests
|
||||
#
|
||||
# This image is based on k3s and includes:
|
||||
# Multi-stage build: extracts k3s artifacts from the upstream rancher/k3s
|
||||
# Alpine image and layers them onto the NVIDIA Ubuntu base image.
|
||||
#
|
||||
# This image includes:
|
||||
# - k3s binary and all supporting binaries (containerd-shim, runc, CNI, etc.)
|
||||
# - Packaged OpenShell Helm chart
|
||||
# - HelmChart CR for auto-deploying OpenShell
|
||||
# - Custom entrypoint for DNS configuration in Docker environments
|
||||
#
|
||||
# Component images (openshell/server, openshell/sandbox) are
|
||||
# Component images (openshell/gateway, openshell/sandbox) are
|
||||
# pulled at runtime from the distribution registry rather than bundled as tarballs.
|
||||
# Registry credentials are generated by the entrypoint script at container start.
|
||||
#
|
||||
@@ -23,15 +27,99 @@
|
||||
# GHSA-9h8m-3fm2-qjrq otel/sdk v1.39.0 (macOS-only PATH hijack; N/A for Linux)
|
||||
# CVE-2024-36623 docker/docker v25.0.8 (streamformatter race condition)
|
||||
# Bump K3S_VERSION when a release with updated dependencies ships.
|
||||
|
||||
ARG K3S_VERSION=v1.35.2-k3s1
|
||||
FROM rancher/k3s:${K3S_VERSION}
|
||||
ARG NVIDIA_CONTAINER_TOOLKIT_VERSION=1.18.2-1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 1: Extract k3s artifacts from upstream rancher image (Alpine-based)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM rancher/k3s:${K3S_VERSION} AS k3s
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Install NVIDIA container toolkit on Ubuntu
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ubuntu:24.04 AS nvidia-toolkit
|
||||
|
||||
ARG NVIDIA_CONTAINER_TOOLKIT_VERSION
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gpg curl ca-certificates && \
|
||||
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
|
||||
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg && \
|
||||
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
|
||||
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
|
||||
| tee /etc/apt/sources.list.d/nvidia-container-toolkit.list && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
"nvidia-container-toolkit=${NVIDIA_CONTAINER_TOOLKIT_VERSION}" \
|
||||
"nvidia-container-toolkit-base=${NVIDIA_CONTAINER_TOOLKIT_VERSION}" \
|
||||
"libnvidia-container-tools=${NVIDIA_CONTAINER_TOOLKIT_VERSION}" \
|
||||
"libnvidia-container1=${NVIDIA_CONTAINER_TOOLKIT_VERSION}" && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 3: Runtime on NVIDIA hardened Ubuntu base
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM nvcr.io/nvidia/base/ubuntu:noble-20251013
|
||||
|
||||
# Install runtime dependencies that k3s expects from the host OS.
|
||||
# - iptables: used by flannel/kube-proxy for network policy and NAT rules
|
||||
# - mount/umount: needed by kubelet for volume mounts (provided by mount package)
|
||||
# - ca-certificates: TLS verification for registry pulls
|
||||
# - conntrack: k3s/kube-proxy uses conntrack for connection tracking
|
||||
# - dnsutils: nslookup used by entrypoint/healthcheck for DNS probe
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
iptables \
|
||||
mount \
|
||||
dnsutils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the full /bin directory from k3s (contains all statically-linked
|
||||
# binaries and their symlinks: k3s, kubectl, crictl, ctr, containerd,
|
||||
# containerd-shim-runc-v2, runc, cni plugins, busybox, coreutils,
|
||||
# ip, ipset, conntrack, nsenter, pigz, etc.)
|
||||
COPY --from=k3s /bin/ /bin/
|
||||
|
||||
# Copy iptables/nftables tooling (xtables-nft-multi, iptables-detect.sh, etc.)
|
||||
# These are in /bin/aux/ in the k3s image and must be on PATH.
|
||||
# Note: the Ubuntu iptables package provides /usr/sbin/iptables, but k3s
|
||||
# expects its own bundled version at /bin/aux/iptables. Both are on PATH;
|
||||
# k3s finds its copy via /bin/aux in PATH.
|
||||
|
||||
# Copy CA certificates from k3s (bundled Alpine CA bundle).
|
||||
# The Ubuntu ca-certificates package also installs certs; having both is fine.
|
||||
COPY --from=k3s /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/k3s-ca-certificates.crt
|
||||
|
||||
# Copy timezone data used by k3s/Go for time.LoadLocation
|
||||
COPY --from=k3s /usr/share/zoneinfo/ /usr/share/zoneinfo/
|
||||
|
||||
# Set environment variables matching the upstream k3s image.
|
||||
# PATH includes /bin/aux for iptables tooling and /var/lib/rancher/k3s/data/cni
|
||||
# for runtime-extracted CNI binaries.
|
||||
ENV PATH="/var/lib/rancher/k3s/data/cni:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/bin/aux" \
|
||||
CRI_CONFIG_FILE="/var/lib/rancher/k3s/agent/etc/crictl.yaml"
|
||||
|
||||
# Copy NVIDIA Container Toolkit files from the build stage.
|
||||
# k3s auto-detects nvidia-container-runtime on PATH and registers it as a
|
||||
# containerd runtime + creates the "nvidia" RuntimeClass automatically.
|
||||
COPY --from=nvidia-toolkit /usr/bin/nvidia-cdi-hook /usr/bin/
|
||||
COPY --from=nvidia-toolkit /usr/bin/nvidia-container-runtime /usr/bin/
|
||||
COPY --from=nvidia-toolkit /usr/bin/nvidia-container-runtime-hook /usr/bin/
|
||||
COPY --from=nvidia-toolkit /usr/bin/nvidia-container-cli /usr/bin/
|
||||
COPY --from=nvidia-toolkit /usr/bin/nvidia-ctk /usr/bin/
|
||||
COPY --from=nvidia-toolkit /etc/nvidia-container-runtime /etc/nvidia-container-runtime
|
||||
COPY --from=nvidia-toolkit /usr/lib/*-linux-gnu/libnvidia-container*.so* /usr/lib/
|
||||
|
||||
# Create directories for manifests, charts, and configuration
|
||||
RUN mkdir -p /var/lib/rancher/k3s/server/manifests \
|
||||
/var/lib/rancher/k3s/server/static/charts \
|
||||
/etc/rancher/k3s \
|
||||
/opt/openshell/manifests \
|
||||
/opt/openshell/charts
|
||||
/opt/openshell/charts \
|
||||
/opt/openshell/gpu-manifests \
|
||||
/run/flannel
|
||||
|
||||
# Copy entrypoint script that configures DNS for Docker environments
|
||||
# This script detects the host gateway IP and configures CoreDNS to use it
|
||||
@@ -54,6 +142,9 @@ COPY deploy/docker/.build/charts/*.tgz /opt/openshell/charts/
|
||||
# The bootstrap code will copy these to /var/lib/rancher/k3s/server/manifests/ after cluster start.
|
||||
COPY deploy/kube/manifests/*.yaml /opt/openshell/manifests/
|
||||
|
||||
# Copy GPU-specific manifests (deployed conditionally by entrypoint when GPU_ENABLED=true)
|
||||
COPY deploy/kube/gpu-manifests/*.yaml /opt/openshell/gpu-manifests/
|
||||
|
||||
# Use custom entrypoint that configures DNS before starting k3s
|
||||
ENTRYPOINT ["/usr/local/bin/cluster-entrypoint.sh"]
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# OpenShell Server Docker image
|
||||
# OpenShell Gateway Docker image
|
||||
# Multi-stage build with cross-compilation support for multi-arch
|
||||
|
||||
# Stage 1: Rust builder (runs on build platform, cross-compiles for target)
|
||||
@@ -52,9 +52,9 @@ COPY proto/ proto/
|
||||
# sccache uses memcached in CI (SCCACHE_MEMCACHED_ENDPOINT) or the local
|
||||
# disk cache mount for local dev builds. The cargo-target mount gives cargo
|
||||
# a persistent target/ dir for true incremental rebuilds on source changes.
|
||||
RUN --mount=type=cache,id=cargo-registry-server-${TARGETARCH},sharing=locked,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=cargo-target-server-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \
|
||||
--mount=type=cache,id=sccache-server-${TARGETARCH},sharing=locked,target=/tmp/sccache \
|
||||
RUN --mount=type=cache,id=cargo-registry-gateway-${TARGETARCH},sharing=locked,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=cargo-target-gateway-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \
|
||||
--mount=type=cache,id=sccache-gateway-${TARGETARCH},sharing=locked,target=/tmp/sccache \
|
||||
. cross-build.sh && cargo_cross_build --release -p navigator-server 2>/dev/null || true
|
||||
|
||||
# Copy actual source code
|
||||
@@ -68,9 +68,9 @@ RUN touch crates/navigator-server/src/main.rs \
|
||||
proto/*.proto
|
||||
|
||||
# Build the actual application
|
||||
RUN --mount=type=cache,id=cargo-registry-server-${TARGETARCH},sharing=locked,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=cargo-target-server-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \
|
||||
--mount=type=cache,id=sccache-server-${TARGETARCH},sharing=locked,target=/tmp/sccache \
|
||||
RUN --mount=type=cache,id=cargo-registry-gateway-${TARGETARCH},sharing=locked,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=cargo-target-gateway-${TARGETARCH}-${CARGO_TARGET_CACHE_SCOPE},sharing=locked,target=/build/target \
|
||||
--mount=type=cache,id=sccache-gateway-${TARGETARCH},sharing=locked,target=/tmp/sccache \
|
||||
. cross-build.sh && \
|
||||
if [ -n "${OPENSHELL_CARGO_VERSION:-}" ]; then \
|
||||
sed -i -E '/^\[workspace\.package\]/,/^\[/{s/^version[[:space:]]*=[[:space:]]*".*"/version = "'"${OPENSHELL_CARGO_VERSION}"'"/}' Cargo.toml; \
|
||||
@@ -79,8 +79,8 @@ RUN --mount=type=cache,id=cargo-registry-server-${TARGETARCH},sharing=locked,tar
|
||||
cp "$(cross_output_dir release)/navigator-server" /build/navigator-server
|
||||
|
||||
# Stage 2: Runtime (uses target platform)
|
||||
# Pin to specific Debian point release for reproducible builds.
|
||||
FROM debian:bookworm-20260223-slim AS runtime
|
||||
# NVIDIA hardened Ubuntu base for supply chain consistency.
|
||||
FROM nvcr.io/nvidia/base/ubuntu:noble-20251013 AS runtime
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
@@ -119,6 +119,59 @@ nameserver 8.8.4.4
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify DNS is functional after proxy setup.
|
||||
# If resolution fails, the cluster will be unable to pull images and will
|
||||
# spin for minutes with opaque "Try again" errors. Log a clear marker so
|
||||
# the CLI polling loop can detect this early and fail fast.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Check whether a string looks like an IP address (v4 or v6) with an
|
||||
# optional port suffix. When the registry host is an IP literal, DNS
|
||||
# resolution is not required and we should skip the nslookup probe.
|
||||
is_ip_literal() {
|
||||
# Strip an optional :port suffix
|
||||
local host="${1%:*}"
|
||||
# IPv4: digits and dots only
|
||||
echo "$host" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' && return 0
|
||||
# IPv6 (bare or bracketed)
|
||||
echo "$host" | grep -qE '^\[?[0-9a-fA-F:]+\]?$' && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
verify_dns() {
|
||||
local dns_target="${REGISTRY_HOST:-ghcr.io}"
|
||||
|
||||
# IP-literal registry hosts (e.g. 127.0.0.1:5000) don't need DNS.
|
||||
if is_ip_literal "$dns_target"; then
|
||||
echo "Registry host is an IP literal ($dns_target), skipping DNS probe"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Strip port suffix — nslookup doesn't understand host:port.
|
||||
local lookup_host="${dns_target%%:*}"
|
||||
|
||||
local attempts=5
|
||||
local i=1
|
||||
while [ "$i" -le "$attempts" ]; do
|
||||
if nslookup "$lookup_host" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! verify_dns; then
|
||||
echo "DNS_PROBE_FAILED: cannot resolve ${REGISTRY_HOST:-ghcr.io} after DNS proxy setup"
|
||||
echo " resolv.conf: $(cat "$RESOLV_CONF")"
|
||||
echo " This usually means Docker DNS forwarding is broken."
|
||||
echo " Try restarting Docker or pruning networks: docker network prune -f"
|
||||
# Don't exit — let k3s start so the Rust-side polling loop can detect the
|
||||
# failure via the log marker and present a user-friendly diagnosis.
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generate k3s private registry configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -257,6 +310,26 @@ if [ -d "$BUNDLED_MANIFESTS" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU support: deploy NVIDIA device plugin when GPU_ENABLED=true
|
||||
# ---------------------------------------------------------------------------
|
||||
# When the cluster is started with --gpu, the bootstrap code sets
|
||||
# GPU_ENABLED=true. This copies the NVIDIA device plugin HelmChart CR into
|
||||
# the k3s manifests directory so the Helm controller installs it automatically.
|
||||
# The nvidia-container-runtime binary is already on PATH (baked into the image)
|
||||
# so k3s registers the "nvidia" RuntimeClass at startup.
|
||||
if [ "${GPU_ENABLED:-}" = "true" ]; then
|
||||
echo "GPU support enabled — deploying NVIDIA device plugin"
|
||||
|
||||
GPU_MANIFESTS="/opt/openshell/gpu-manifests"
|
||||
if [ -d "$GPU_MANIFESTS" ]; then
|
||||
for manifest in "$GPU_MANIFESTS"/*.yaml; do
|
||||
[ ! -f "$manifest" ] && continue
|
||||
cp "$manifest" "$K3S_MANIFESTS/"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Override image tag and pull policy for local development
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -269,7 +342,7 @@ HELMCHART="/var/lib/rancher/k3s/server/manifests/openshell-helmchart.yaml"
|
||||
if [ -n "${IMAGE_REPO_BASE:-}" ] && [ -f "$HELMCHART" ]; then
|
||||
target_tag="${IMAGE_TAG:-latest}"
|
||||
echo "Setting image repository base: ${IMAGE_REPO_BASE}"
|
||||
sed -i -E "s|repository:[[:space:]]*[^[:space:]]+|repository: ${IMAGE_REPO_BASE}/server|" "$HELMCHART"
|
||||
sed -i -E "s|repository:[[:space:]]*[^[:space:]]+|repository: ${IMAGE_REPO_BASE}/gateway|" "$HELMCHART"
|
||||
sed -i -E "s|sandboxImage:[[:space:]]*[^[:space:]]+|sandboxImage: ${IMAGE_REPO_BASE}/sandbox:${target_tag}|" "$HELMCHART"
|
||||
fi
|
||||
|
||||
@@ -282,7 +355,7 @@ if [ -n "${PUSH_IMAGE_REFS:-}" ] && [ -f "$HELMCHART" ]; then
|
||||
IFS=','
|
||||
for ref in $PUSH_IMAGE_REFS; do
|
||||
case "$ref" in
|
||||
*/server:*) server_image="$ref" ;;
|
||||
*/gateway:*) server_image="$ref" ;;
|
||||
*/sandbox:*) sandbox_image="$ref" ;;
|
||||
esac
|
||||
done
|
||||
@@ -374,6 +447,18 @@ else
|
||||
sed -i '/__CHART_CHECKSUM__/d' "$HELMCHART"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ensure flannel CNI directories exist
|
||||
# ---------------------------------------------------------------------------
|
||||
# k3s uses flannel as its default CNI. Flannel writes subnet configuration to
|
||||
# /run/flannel/subnet.env during startup. When running inside a Docker
|
||||
# container, /run/flannel/ may not exist, causing a race where kubelet tries
|
||||
# to create pod sandboxes before flannel can write the file. Without it, every
|
||||
# pod (including CoreDNS) fails with:
|
||||
# plugin type="flannel" failed (add): failed to load flannel 'subnet.env'
|
||||
# Pre-creating the directory eliminates this failure mode.
|
||||
mkdir -p /run/flannel
|
||||
|
||||
# Docker Desktop can briefly start the container before its bridge default route
|
||||
# is fully installed. k3s exits immediately in that state, so wait briefly for
|
||||
# routing to settle first.
|
||||
|
||||
@@ -7,8 +7,46 @@ set -eu
|
||||
|
||||
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-flight: verify container DNS resolution is functional.
|
||||
# If the DNS proxy is broken, nothing will work (image pulls fail, pods
|
||||
# can't start, etc.). Fail fast with a clear signal instead of letting the
|
||||
# health check return unhealthy for 5+ minutes with no useful output.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Check whether a string looks like an IP address (v4 or v6) with optional port.
|
||||
is_ip_literal() {
|
||||
local host="${1%:*}"
|
||||
echo "$host" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' && return 0
|
||||
echo "$host" | grep -qE '^\[?[0-9a-fA-F:]+\]?$' && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
DNS_TARGET="${REGISTRY_HOST:-ghcr.io}"
|
||||
# IP-literal registry hosts (e.g. 127.0.0.1:5000) don't need DNS resolution.
|
||||
if ! is_ip_literal "$DNS_TARGET"; then
|
||||
DNS_LOOKUP="${DNS_TARGET%%:*}"
|
||||
if ! nslookup "$DNS_LOOKUP" >/dev/null 2>&1; then
|
||||
echo "HEALTHCHECK_DNS_FAILURE: cannot resolve $DNS_TARGET" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
kubectl get --raw='/readyz' >/dev/null 2>&1 || exit 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check for node pressure conditions (DiskPressure, MemoryPressure, PIDPressure).
|
||||
# When a node is under pressure the kubelet evicts pods and rejects new ones,
|
||||
# so the cluster will never become healthy. Emit a marker to stderr so the
|
||||
# bootstrap polling loop can detect it early and surface a clear diagnosis.
|
||||
# ---------------------------------------------------------------------------
|
||||
NODE_CONDITIONS=$(kubectl get nodes -o jsonpath='{range .items[*]}{range .status.conditions[*]}{.type}={.status}{"\n"}{end}{end}' 2>/dev/null || true)
|
||||
for PRESSURE in DiskPressure MemoryPressure PIDPressure; do
|
||||
if echo "$NODE_CONDITIONS" | grep -q "^${PRESSURE}=True$"; then
|
||||
echo "HEALTHCHECK_NODE_PRESSURE: ${PRESSURE}" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
kubectl -n openshell get statefulset/openshell >/dev/null 2>&1 || exit 1
|
||||
kubectl -n openshell wait --for=jsonpath='{.status.readyReplicas}'=1 statefulset/openshell --timeout=1s >/dev/null 2>&1 || exit 1
|
||||
|
||||
|
||||
@@ -86,21 +86,24 @@ RUN --mount=type=cache,id=cargo-registry-sandbox-${TARGETARCH},sharing=locked,ta
|
||||
cp "$(cross_output_dir "$RUST_BUILD_PROFILE")/navigator-sandbox" /build/out/
|
||||
|
||||
# Stage 2: Python base (uses target platform)
|
||||
# Pin to specific Python patch + Debian release for reproducible builds.
|
||||
# CVE-2025-13836 (stdlib urllib Content-Length OOM) affects Python <= 3.12.13;
|
||||
# update to python:3.12.14-slim-bookworm (or later) when released.
|
||||
FROM python:3.12.13-slim-bookworm AS base
|
||||
# NVIDIA hardened Ubuntu base for supply chain consistency.
|
||||
# Ubuntu Noble ships Python 3.12 natively; install from default repos.
|
||||
FROM nvcr.io/nvidia/base/ubuntu:noble-20251013 AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /sandbox
|
||||
|
||||
# Install system dependencies
|
||||
# iproute2 is needed for network namespace management (ip netns, veth pairs)
|
||||
# Install Python 3.12 and system dependencies.
|
||||
# python3.12-venv provides the venv module (required by uv venv).
|
||||
# iproute2 is needed for network namespace management (ip netns, veth pairs).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3.12 python3.12-venv python3.12-dev python3-pip \
|
||||
curl dnsutils iproute2 iputils-ping \
|
||||
net-tools netcat-openbsd openssh-sftp-server procps traceroute \
|
||||
&& ln -sf /usr/bin/python3.12 /usr/local/bin/python3 \
|
||||
&& ln -sf /usr/bin/python3.12 /usr/local/bin/python \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create supervisor and sandbox users/groups
|
||||
@@ -135,7 +138,7 @@ FROM base AS coding-agents
|
||||
# CVE-2026-21637, CVE-2025-59466, CVE-2025-59465, CVE-2025-55131 affect
|
||||
# Node.js <= 22.22.1. Update to 22.23.0+ when a patched release ships.
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
|
||||
apt-get install -y --no-install-recommends build-essential git nodejs=22.22.1-1nodesource1 python3 nano && \
|
||||
apt-get install -y --no-install-recommends build-essential git nodejs=22.22.1-1nodesource1 nano && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install GitHub CLI (gh) from the official apt repository
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
replicaCount: 1
|
||||
|
||||
image:
|
||||
repository: ghcr.io/nvidia/openshell/server
|
||||
repository: ghcr.io/nvidia/openshell/gateway
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# HelmChart CR for auto-deploying the NVIDIA k8s-device-plugin via k3s Helm controller.
|
||||
# This manifest is conditionally copied into /var/lib/rancher/k3s/server/manifests/
|
||||
# by the cluster entrypoint when GPU_ENABLED=true.
|
||||
#
|
||||
# The chart installs:
|
||||
# - NVIDIA device plugin DaemonSet (advertises nvidia.com/gpu resources)
|
||||
# - GPU Feature Discovery (labels nodes with GPU properties)
|
||||
# - Node Feature Discovery (dependency for GFD)
|
||||
#
|
||||
# k3s auto-detects nvidia-container-runtime on PATH and registers the "nvidia"
|
||||
# RuntimeClass automatically, so no manual RuntimeClass manifest is needed.
|
||||
|
||||
apiVersion: helm.cattle.io/v1
|
||||
kind: HelmChart
|
||||
metadata:
|
||||
name: nvidia-device-plugin
|
||||
namespace: kube-system
|
||||
spec:
|
||||
repo: https://nvidia.github.io/k8s-device-plugin
|
||||
chart: nvidia-device-plugin
|
||||
version: "0.18.2"
|
||||
targetNamespace: nvidia-device-plugin
|
||||
createNamespace: true
|
||||
valuesContent: |-
|
||||
runtimeClassName: nvidia
|
||||
gfd:
|
||||
enabled: true
|
||||
nfd:
|
||||
enabled: true
|
||||
@@ -25,7 +25,7 @@ spec:
|
||||
valuesContent: |-
|
||||
chartChecksum: __CHART_CHECKSUM__
|
||||
image:
|
||||
repository: ghcr.io/nvidia/openshell/server
|
||||
repository: ghcr.io/nvidia/openshell/gateway
|
||||
tag: latest
|
||||
pullPolicy: Always
|
||||
server:
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Resolve missing and hash-based licenses in CycloneDX SBOM JSON files.
|
||||
|
||||
Queries public registries:
|
||||
- crates.io (pkg:cargo/*)
|
||||
- npm (pkg:npm/*)
|
||||
- PyPI (pkg:pypi/*)
|
||||
- Known maps (pkg:golang/*, pkg:deb/*, operating-system, application)
|
||||
|
||||
Updates the JSON files in-place, then reports what was resolved.
|
||||
|
||||
Usage:
|
||||
python resolve_licenses.py # resolve all *.cdx.json in deploy/sbom/output/
|
||||
python resolve_licenses.py file1.json ... # resolve specific files
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Known licenses for packages that registries won't resolve
|
||||
# ---------------------------------------------------------------------------
|
||||
KNOWN_LICENSES: dict[str, str] = {
|
||||
# libxcrypt -- all variants
|
||||
"libcrypt-dev": "LGPL-2.1-or-later",
|
||||
"libcrypt1": "LGPL-2.1-or-later",
|
||||
# python3-defaults -- all variants
|
||||
"python3": "PSF-2.0",
|
||||
"python3-minimal": "PSF-2.0",
|
||||
"libpython3-stdlib": "PSF-2.0",
|
||||
"python3-venv": "PSF-2.0",
|
||||
# Go standard library / well-known
|
||||
"golang.org/x/crypto": "BSD-3-Clause",
|
||||
"golang.org/x/mod": "BSD-3-Clause",
|
||||
"golang.org/x/net": "BSD-3-Clause",
|
||||
"golang.org/x/sync": "BSD-3-Clause",
|
||||
"golang.org/x/sys": "BSD-3-Clause",
|
||||
"golang.org/x/term": "BSD-3-Clause",
|
||||
"golang.org/x/text": "BSD-3-Clause",
|
||||
"google.golang.org/grpc": "Apache-2.0",
|
||||
"google.golang.org/protobuf": "BSD-3-Clause",
|
||||
"google.golang.org/genproto/googleapis/api": "Apache-2.0",
|
||||
"google.golang.org/genproto/googleapis/rpc": "Apache-2.0",
|
||||
"gopkg.in/yaml.v3": "MIT",
|
||||
"go.yaml.in/yaml/v3": "MIT",
|
||||
"go.opentelemetry.io/otel": "Apache-2.0",
|
||||
"go.opentelemetry.io/otel/metric": "Apache-2.0",
|
||||
"go.opentelemetry.io/otel/trace": "Apache-2.0",
|
||||
"go.opentelemetry.io/auto/sdk": "Apache-2.0",
|
||||
"go.mongodb.org/mongo-driver": "Apache-2.0",
|
||||
# Debian / Ubuntu system packages
|
||||
"debian": "GPL-2.0-only",
|
||||
"ubuntu": "GPL-2.0-only",
|
||||
# Application entries without purl
|
||||
"Simple Launcher": "Proprietary",
|
||||
"python": "PSF-2.0",
|
||||
}
|
||||
|
||||
# Well-known Go module licenses (GitHub-based)
|
||||
GO_KNOWN: dict[str, str] = {
|
||||
"github.com/AlecAivazis/survey": "MIT",
|
||||
"github.com/MakeNowJust/heredoc": "MIT",
|
||||
"github.com/Masterminds/goutils": "Apache-2.0",
|
||||
"github.com/Masterminds/semver": "MIT",
|
||||
"github.com/Masterminds/sprig": "MIT",
|
||||
"github.com/alecthomas/chroma": "MIT",
|
||||
"github.com/asaskevich/govalidator": "MIT",
|
||||
"github.com/atotto/clipboard": "BSD-3-Clause",
|
||||
"github.com/aymanbagabas/go-osc52": "MIT",
|
||||
"github.com/aymerick/douceur": "MIT",
|
||||
"github.com/blang/semver": "MIT",
|
||||
"github.com/briandowns/spinner": "Apache-2.0",
|
||||
"github.com/catppuccin/go": "MIT",
|
||||
"github.com/cenkalti/backoff": "MIT",
|
||||
"github.com/charmbracelet/bubbles": "MIT",
|
||||
"github.com/charmbracelet/bubbletea": "MIT",
|
||||
"github.com/charmbracelet/colorprofile": "MIT",
|
||||
"github.com/charmbracelet/glamour": "MIT",
|
||||
"github.com/charmbracelet/huh": "MIT",
|
||||
"github.com/charmbracelet/lipgloss": "MIT",
|
||||
"github.com/charmbracelet/x": "MIT",
|
||||
"github.com/cli/browser": "BSD-2-Clause",
|
||||
"github.com/cli/cli": "MIT",
|
||||
"github.com/cli/go-gh": "MIT",
|
||||
"github.com/cli/oauth": "MIT",
|
||||
"github.com/cli/safeexec": "BSD-2-Clause",
|
||||
"github.com/cli/shurcooL-graphql": "MIT",
|
||||
"github.com/containerd/stargz-snapshotter": "Apache-2.0",
|
||||
"github.com/cyberphone/json-canonicalization": "Apache-2.0",
|
||||
"github.com/davecgh/go-spew": "ISC",
|
||||
"github.com/digitorus/pkcs7": "MIT",
|
||||
"github.com/digitorus/timestamp": "MIT",
|
||||
"github.com/distribution/reference": "Apache-2.0",
|
||||
"github.com/dlclark/regexp2": "MIT",
|
||||
"github.com/docker/cli": "Apache-2.0",
|
||||
"github.com/docker/distribution": "Apache-2.0",
|
||||
"github.com/docker/docker-credential-helpers": "MIT",
|
||||
"github.com/dustin/go-humanize": "MIT",
|
||||
"github.com/fatih/color": "MIT",
|
||||
"github.com/gabriel-vasile/mimetype": "MIT",
|
||||
"github.com/gdamore/encoding": "Apache-2.0",
|
||||
"github.com/gdamore/tcell": "Apache-2.0",
|
||||
"github.com/go-logr/logr": "Apache-2.0",
|
||||
"github.com/go-logr/stdr": "Apache-2.0",
|
||||
"github.com/go-openapi/analysis": "Apache-2.0",
|
||||
"github.com/go-openapi/errors": "Apache-2.0",
|
||||
"github.com/go-openapi/jsonpointer": "Apache-2.0",
|
||||
"github.com/go-openapi/jsonreference": "Apache-2.0",
|
||||
"github.com/go-openapi/loads": "Apache-2.0",
|
||||
"github.com/go-openapi/runtime": "Apache-2.0",
|
||||
"github.com/go-openapi/spec": "Apache-2.0",
|
||||
"github.com/go-openapi/strfmt": "Apache-2.0",
|
||||
"github.com/go-openapi/swag": "Apache-2.0",
|
||||
"github.com/go-openapi/validate": "Apache-2.0",
|
||||
"github.com/go-viper/mapstructure": "MIT",
|
||||
"github.com/godbus/dbus": "BSD-2-Clause",
|
||||
"github.com/golang/snappy": "BSD-3-Clause",
|
||||
"github.com/google/certificate-transparency-go": "Apache-2.0",
|
||||
"github.com/google/go-containerregistry": "Apache-2.0",
|
||||
"github.com/google/shlex": "Apache-2.0",
|
||||
"github.com/google/uuid": "BSD-3-Clause",
|
||||
"github.com/gorilla/css": "BSD-3-Clause",
|
||||
"github.com/gorilla/websocket": "BSD-2-Clause",
|
||||
"github.com/grpc-ecosystem/grpc-gateway": "BSD-3-Clause",
|
||||
"github.com/hashicorp/go-version": "MPL-2.0",
|
||||
"github.com/henvic/httpretty": "MIT",
|
||||
"github.com/huandu/xstrings": "MIT",
|
||||
"github.com/in-toto/attestation": "Apache-2.0",
|
||||
"github.com/in-toto/in-toto-golang": "Apache-2.0",
|
||||
"github.com/itchyny/gojq": "MIT",
|
||||
"github.com/itchyny/timefmt-go": "MIT",
|
||||
"github.com/joho/godotenv": "MIT",
|
||||
"github.com/kballard/go-shellquote": "MIT",
|
||||
"github.com/klauspost/compress": "Apache-2.0",
|
||||
"github.com/lucasb-eyer/go-colorful": "MIT",
|
||||
"github.com/mattn/go-colorable": "MIT",
|
||||
"github.com/mattn/go-isatty": "MIT",
|
||||
"github.com/mattn/go-runewidth": "MIT",
|
||||
"github.com/mgutz/ansi": "MIT",
|
||||
"github.com/microcosm-cc/bluemonday": "BSD-3-Clause",
|
||||
"github.com/microsoft/dev-tunnels": "MIT",
|
||||
"github.com/mitchellh/copystructure": "MIT",
|
||||
"github.com/mitchellh/go-homedir": "MIT",
|
||||
"github.com/mitchellh/hashstructure": "MIT",
|
||||
"github.com/mitchellh/reflectwalk": "MIT",
|
||||
"github.com/muesli/ansi": "MIT",
|
||||
"github.com/muesli/cancelreader": "MIT",
|
||||
"github.com/muesli/reflow": "MIT",
|
||||
"github.com/muesli/termenv": "MIT",
|
||||
"github.com/muhammadmuzzammil1998/jsonc": "MIT",
|
||||
"github.com/oklog/ulid": "Apache-2.0",
|
||||
"github.com/opencontainers/go-digest": "Apache-2.0",
|
||||
"github.com/opencontainers/image-spec": "Apache-2.0",
|
||||
"github.com/opentracing/opentracing-go": "Apache-2.0",
|
||||
"github.com/pkg/errors": "BSD-2-Clause",
|
||||
"github.com/pmezard/go-difflib": "BSD-3-Clause",
|
||||
"github.com/rivo/tview": "MIT",
|
||||
"github.com/rivo/uniseg": "MIT",
|
||||
"github.com/rodaine/table": "MIT",
|
||||
"github.com/secure-systems-lab/go-securesystemslib": "MIT",
|
||||
"github.com/shibumi/go-pathspec": "Apache-2.0",
|
||||
"github.com/shopspring/decimal": "MIT",
|
||||
"github.com/shurcooL/githubv4": "MIT",
|
||||
"github.com/shurcooL/graphql": "MIT",
|
||||
"github.com/sigstore/protobuf-specs": "Apache-2.0",
|
||||
"github.com/sigstore/rekor-tiles": "Apache-2.0",
|
||||
"github.com/sigstore/rekor": "Apache-2.0",
|
||||
"github.com/sigstore/sigstore-go": "Apache-2.0",
|
||||
"github.com/sigstore/sigstore": "Apache-2.0",
|
||||
"github.com/sigstore/timestamp-authority": "Apache-2.0",
|
||||
"github.com/sirupsen/logrus": "MIT",
|
||||
"github.com/spf13/cast": "MIT",
|
||||
"github.com/spf13/cobra": "Apache-2.0",
|
||||
"github.com/spf13/pflag": "BSD-3-Clause",
|
||||
"github.com/stretchr/objx": "MIT",
|
||||
"github.com/stretchr/testify": "MIT",
|
||||
"github.com/theupdateframework/go-tuf": "MIT",
|
||||
"github.com/thlib/go-timezone-local": "Unlicense",
|
||||
"github.com/transparency-dev/formats": "Apache-2.0",
|
||||
"github.com/transparency-dev/merkle": "Apache-2.0",
|
||||
"github.com/vbatts/tar-split": "BSD-3-Clause",
|
||||
"github.com/vmihailenco/msgpack": "BSD-2-Clause",
|
||||
"github.com/vmihailenco/tagparser": "BSD-2-Clause",
|
||||
"github.com/xo/terminfo": "MIT",
|
||||
"github.com/yuin/goldmark-emoji": "MIT",
|
||||
"github.com/yuin/goldmark": "MIT",
|
||||
"github.com/zalando/go-keyring": "MIT",
|
||||
"dario.cat/mergo": "BSD-3-Clause",
|
||||
}
|
||||
|
||||
# Rate-limit helpers (thread-safe)
|
||||
_last_request: dict[str, float] = {}
|
||||
_rate_lock = threading.Lock()
|
||||
|
||||
|
||||
def _rate_limit(domain: str, interval: float = 0.15) -> None:
|
||||
with _rate_lock:
|
||||
now = time.time()
|
||||
last = _last_request.get(domain, 0)
|
||||
wait = interval - (now - last)
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
_last_request[domain] = time.time()
|
||||
|
||||
|
||||
def _get_json(url: str, domain: str) -> dict | None:
|
||||
_rate_limit(domain)
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "sbom-license-resolver/1.0"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read())
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry lookups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def lookup_cargo(name: str, version: str) -> str | None:
|
||||
"""Query crates.io for a crate's license."""
|
||||
data = _get_json(f"https://crates.io/api/v1/crates/{name}/{version}", "crates.io")
|
||||
if data and "version" in data:
|
||||
return data["version"].get("license")
|
||||
# fallback: try crate-level
|
||||
data = _get_json(f"https://crates.io/api/v1/crates/{name}", "crates.io")
|
||||
if data and "crate" in data:
|
||||
versions = data.get("versions", [])
|
||||
for v in versions:
|
||||
if v.get("num") == version:
|
||||
return v.get("license")
|
||||
# last resort: latest version license
|
||||
if versions:
|
||||
return versions[0].get("license")
|
||||
return None
|
||||
|
||||
|
||||
def lookup_npm(name: str, version: str) -> str | None:
|
||||
"""Query npm registry for a package's license."""
|
||||
encoded = urllib.parse.quote(name, safe="")
|
||||
data = _get_json(
|
||||
f"https://registry.npmjs.org/{encoded}/{version}", "registry.npmjs.org"
|
||||
)
|
||||
if data:
|
||||
lic = data.get("license")
|
||||
if isinstance(lic, dict):
|
||||
return lic.get("type")
|
||||
if isinstance(lic, str):
|
||||
return lic
|
||||
return None
|
||||
|
||||
|
||||
def lookup_pypi(name: str, version: str) -> str | None:
|
||||
"""Query PyPI for a package's license."""
|
||||
data = _get_json(f"https://pypi.org/pypi/{name}/{version}/json", "pypi.org")
|
||||
if data and "info" in data:
|
||||
lic = data["info"].get("license")
|
||||
if lic and len(lic) < 100: # skip full license texts
|
||||
return lic
|
||||
# Try classifiers
|
||||
for c in data["info"].get("classifiers", []):
|
||||
if c.startswith("License :: OSI Approved :: "):
|
||||
return c.split(" :: ")[-1]
|
||||
return None
|
||||
|
||||
|
||||
def resolve_go_name(full_name: str) -> str | None:
|
||||
"""Resolve a Go module name to a known license via GO_KNOWN map."""
|
||||
# Try exact match first
|
||||
if full_name in GO_KNOWN:
|
||||
return GO_KNOWN[full_name]
|
||||
# Try stripping version suffix (e.g. github.com/foo/bar/v2 -> github.com/foo/bar)
|
||||
parts = full_name.split("/")
|
||||
for i in range(len(parts), 1, -1):
|
||||
candidate = "/".join(parts[:i])
|
||||
if candidate in GO_KNOWN:
|
||||
return GO_KNOWN[candidate]
|
||||
# Try just org/repo for github.com paths
|
||||
if full_name.startswith("github.com/") and len(parts) >= 3:
|
||||
base = "/".join(parts[:3])
|
||||
if base in GO_KNOWN:
|
||||
return GO_KNOWN[base]
|
||||
return None
|
||||
|
||||
|
||||
def resolve_component(comp: dict) -> str | None:
|
||||
"""Try to resolve a license for a component."""
|
||||
name = comp.get("name", "")
|
||||
version = comp.get("version", "")
|
||||
purl = comp.get("purl", "")
|
||||
comp_type = comp.get("type", "")
|
||||
|
||||
# 1. Check hardcoded known licenses
|
||||
if name in KNOWN_LICENSES:
|
||||
return KNOWN_LICENSES[name]
|
||||
|
||||
# 2. Route by purl type
|
||||
if purl.startswith("pkg:cargo/"):
|
||||
return lookup_cargo(name, version)
|
||||
if purl.startswith("pkg:npm/"):
|
||||
return lookup_npm(name, version)
|
||||
if purl.startswith("pkg:pypi/"):
|
||||
return lookup_pypi(name, version)
|
||||
if purl.startswith("pkg:golang/"):
|
||||
return resolve_go_name(name)
|
||||
if purl.startswith("pkg:deb/"):
|
||||
# Check known map by base package name
|
||||
base = name.split(":")[0] # strip arch qualifier
|
||||
if base in KNOWN_LICENSES:
|
||||
return KNOWN_LICENSES[base]
|
||||
return None
|
||||
if comp_type == "operating-system":
|
||||
return KNOWN_LICENSES.get(name)
|
||||
if not purl:
|
||||
return KNOWN_LICENSES.get(name)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def set_license(comp: dict, license_id: str) -> None:
|
||||
"""Set the license on a component, replacing hash or empty."""
|
||||
comp["licenses"] = [{"license": {"id": license_id}}]
|
||||
|
||||
|
||||
def needs_fix(comp: dict) -> bool:
|
||||
"""Check if component has missing or hash-based license."""
|
||||
licenses = comp.get("licenses", [])
|
||||
if not licenses:
|
||||
return True
|
||||
for entry in licenses:
|
||||
lic = entry.get("license", {})
|
||||
lid = lic.get("id", "")
|
||||
lname = lic.get("name", "")
|
||||
if lid.startswith("sha256:") or lname.startswith("sha256:"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _find_sbom_files() -> list[Path]:
|
||||
"""Find SBOM JSON files in the default output directory."""
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
output_dir = repo_root / "deploy" / "sbom" / "output"
|
||||
return sorted(output_dir.glob("*.cdx.json"))
|
||||
|
||||
|
||||
def _classify_registry(comp: dict) -> str:
|
||||
"""Return the registry group for a component."""
|
||||
purl = comp.get("purl", "")
|
||||
name = comp.get("name", "")
|
||||
comp_type = comp.get("type", "")
|
||||
|
||||
if name in KNOWN_LICENSES:
|
||||
return "known"
|
||||
if purl.startswith("pkg:cargo/"):
|
||||
return "crates.io"
|
||||
if purl.startswith("pkg:npm/"):
|
||||
return "npm"
|
||||
if purl.startswith("pkg:pypi/"):
|
||||
return "pypi"
|
||||
if purl.startswith("pkg:golang/"):
|
||||
return "golang"
|
||||
if purl.startswith("pkg:deb/"):
|
||||
return "deb"
|
||||
if comp_type == "operating-system" or not purl:
|
||||
return "known"
|
||||
return "other"
|
||||
|
||||
|
||||
def _resolve_one(key: str, comp: dict) -> tuple[str, str | None]:
|
||||
"""Resolve a single component, returning (key, license_id | None)."""
|
||||
return key, resolve_component(comp)
|
||||
|
||||
|
||||
# Concurrency: different registries can run in parallel; within a domain
|
||||
# the rate limiter serialises requests via the shared lock.
|
||||
_MAX_WORKERS = 12
|
||||
|
||||
|
||||
def main() -> None:
|
||||
files = [Path(p) for p in sys.argv[1:]] if len(sys.argv) > 1 else _find_sbom_files()
|
||||
|
||||
if not files:
|
||||
print("No SBOM JSON files found.")
|
||||
print("Run 'mise run sbom:generate' first, or pass file paths as arguments.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Loading {len(files)} SBOM file(s)...")
|
||||
|
||||
# Collect unique components needing fixes
|
||||
to_resolve: dict[str, dict] = {} # key -> representative component
|
||||
total_components = 0
|
||||
for f in files:
|
||||
with f.open() as fh:
|
||||
sbom = json.load(fh)
|
||||
components = sbom.get("components", [])
|
||||
total_components += len(components)
|
||||
for comp in components:
|
||||
if needs_fix(comp):
|
||||
key = f"{comp.get('name', '')}@{comp.get('version', '')}"
|
||||
if key not in to_resolve:
|
||||
to_resolve[key] = comp
|
||||
|
||||
total = len(to_resolve)
|
||||
print(f" {total_components} total components, {total} need license resolution")
|
||||
|
||||
if total == 0:
|
||||
print("All licenses already resolved.")
|
||||
return
|
||||
|
||||
# Classify by registry for progress reporting
|
||||
groups: dict[str, list[tuple[str, dict]]] = {}
|
||||
for key, comp in to_resolve.items():
|
||||
registry = _classify_registry(comp)
|
||||
groups.setdefault(registry, []).append((key, comp))
|
||||
|
||||
print("\n Breakdown by registry:")
|
||||
for registry in sorted(groups):
|
||||
count = len(groups[registry])
|
||||
marker = "(local)" if registry in {"known", "golang", "deb"} else "(API)"
|
||||
print(f" {registry:<12} {count:>5} {marker}")
|
||||
|
||||
# Resolve -- local lookups first (instant), then API calls concurrently
|
||||
resolved: dict[str, str] = {}
|
||||
failed: list[str] = []
|
||||
t0 = time.monotonic()
|
||||
|
||||
local_registries = {"known", "golang", "deb", "other"}
|
||||
api_registries = {"crates.io", "npm", "pypi"}
|
||||
|
||||
# Phase 1: local (no network)
|
||||
local_items = [
|
||||
(key, comp) for reg in local_registries for key, comp in groups.get(reg, [])
|
||||
]
|
||||
for key, comp in local_items:
|
||||
lic = resolve_component(comp)
|
||||
if lic:
|
||||
resolved[key] = lic
|
||||
else:
|
||||
failed.append(key)
|
||||
|
||||
if local_items:
|
||||
print(
|
||||
f"\n Local lookups: {len(resolved)} resolved, "
|
||||
f"{len(failed)} unresolved ({time.monotonic() - t0:.1f}s)"
|
||||
)
|
||||
|
||||
# Phase 2: API calls (concurrent)
|
||||
api_items = [
|
||||
(key, comp) for reg in api_registries for key, comp in groups.get(reg, [])
|
||||
]
|
||||
|
||||
if api_items:
|
||||
api_total = len(api_items)
|
||||
api_resolved = 0
|
||||
api_failed = 0
|
||||
print(
|
||||
f"\n Resolving {api_total} packages via registry APIs "
|
||||
f"({_MAX_WORKERS} workers)..."
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=_MAX_WORKERS) as pool:
|
||||
futures = {
|
||||
pool.submit(_resolve_one, key, comp): key for key, comp in api_items
|
||||
}
|
||||
for done_count, future in enumerate(as_completed(futures), 1):
|
||||
key, lic = future.result()
|
||||
if lic:
|
||||
resolved[key] = lic
|
||||
api_resolved += 1
|
||||
else:
|
||||
failed.append(key)
|
||||
api_failed += 1
|
||||
|
||||
if done_count % 50 == 0 or done_count == api_total:
|
||||
elapsed = time.monotonic() - t0
|
||||
sys.stdout.write(
|
||||
f"\r [{done_count}/{api_total}] "
|
||||
f"resolved={api_resolved} failed={api_failed} "
|
||||
f"({elapsed:.1f}s)"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
print() # newline after progress
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
print(
|
||||
f"\n Done: {len(resolved)}/{total} resolved, "
|
||||
f"{len(failed)} unresolved ({elapsed:.1f}s)"
|
||||
)
|
||||
|
||||
# Apply to all files
|
||||
total_patched = 0
|
||||
for f in files:
|
||||
with f.open() as fh:
|
||||
sbom = json.load(fh)
|
||||
|
||||
patched = 0
|
||||
for comp in sbom.get("components", []):
|
||||
if needs_fix(comp):
|
||||
key = f"{comp.get('name', '')}@{comp.get('version', '')}"
|
||||
if key in resolved:
|
||||
set_license(comp, resolved[key])
|
||||
patched += 1
|
||||
|
||||
with f.open("w") as fh:
|
||||
json.dump(sbom, fh, indent=2)
|
||||
fh.write("\n")
|
||||
|
||||
total_patched += patched
|
||||
print(f" {f.name}: patched {patched} components")
|
||||
|
||||
print(f"\n Total patches applied: {total_patched}")
|
||||
|
||||
if failed:
|
||||
print(f"\n --- Unresolved ({len(failed)}) ---")
|
||||
for key in sorted(failed):
|
||||
comp = to_resolve[key]
|
||||
print(f" {key} purl={comp.get('purl', '(none)')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Convert CycloneDX SBOM JSON files to CSV.
|
||||
|
||||
Usage:
|
||||
python sbom_to_csv.py # convert all *.cdx.json in deploy/sbom/output/
|
||||
python sbom_to_csv.py file1.json ... # convert specific files
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def extract_licenses(component: dict) -> str:
|
||||
"""Pull license IDs/names from CycloneDX license entries."""
|
||||
licenses = component.get("licenses", [])
|
||||
ids = []
|
||||
for entry in licenses:
|
||||
lic = entry.get("license", {})
|
||||
ids.append(lic.get("id") or lic.get("name", ""))
|
||||
return " | ".join(filter(None, ids))
|
||||
|
||||
|
||||
def sbom_to_csv(json_path: Path) -> Path:
|
||||
"""Read a CycloneDX JSON SBOM and write a CSV beside it."""
|
||||
with json_path.open() as f:
|
||||
sbom = json.load(f)
|
||||
|
||||
csv_path = json_path.with_suffix(".csv")
|
||||
components = sbom.get("components", [])
|
||||
|
||||
with csv_path.open("w", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["name", "version", "type", "purl", "licenses", "bom-ref"])
|
||||
|
||||
for comp in components:
|
||||
writer.writerow(
|
||||
[
|
||||
comp.get("name", ""),
|
||||
comp.get("version", ""),
|
||||
comp.get("type", ""),
|
||||
comp.get("purl", ""),
|
||||
extract_licenses(comp),
|
||||
comp.get("bom-ref", ""),
|
||||
]
|
||||
)
|
||||
|
||||
return csv_path
|
||||
|
||||
|
||||
def _find_sbom_files() -> list[Path]:
|
||||
"""Find SBOM JSON files in the default output directory."""
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
output_dir = repo_root / "deploy" / "sbom" / "output"
|
||||
return sorted(output_dir.glob("*.cdx.json"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
files = [Path(p) for p in sys.argv[1:]] if len(sys.argv) > 1 else _find_sbom_files()
|
||||
|
||||
if not files:
|
||||
print("No SBOM JSON files found.")
|
||||
print("Run 'mise run sbom:generate' first, or pass file paths as arguments.")
|
||||
sys.exit(1)
|
||||
|
||||
for path in files:
|
||||
csv_path = sbom_to_csv(path)
|
||||
with csv_path.open() as count_fh:
|
||||
components_count = sum(1 for _ in count_fh) - 1 # minus header
|
||||
print(f"{path.name} -> {csv_path.name} ({components_count} components)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -88,6 +88,7 @@ mermaid_init_js = (
|
||||
|
||||
html_domain_indices = False
|
||||
html_use_index = False
|
||||
html_extra_path = ["project.json", "versions1.json"]
|
||||
highlight_language = "console"
|
||||
|
||||
html_theme_options = {
|
||||
@@ -105,8 +106,14 @@ html_theme_options = {
|
||||
"type": "fontawesome",
|
||||
},
|
||||
],
|
||||
"switcher": {
|
||||
"json_url": "../versions1.json",
|
||||
"version_match": release,
|
||||
},
|
||||
}
|
||||
|
||||
html_baseurl = "https://docs.nvidia.com/openshell/latest/"
|
||||
|
||||
json_output_settings = {
|
||||
"enabled": True,
|
||||
"verbose": True,
|
||||
|
||||
+1
-1
@@ -196,7 +196,7 @@ Release Notes <about/release-notes>
|
||||
:hidden:
|
||||
|
||||
Quickstart <get-started/quickstart>
|
||||
GitHub Sandbox <tutorials/github-sandbox>
|
||||
Tutorial: GitHub Policy Iteration <tutorials/github-sandbox>
|
||||
```
|
||||
|
||||
```{toctree}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "openshell", "version": "0.1.0" }
|
||||
@@ -53,7 +53,7 @@ openshell
|
||||
│ ├── create
|
||||
│ ├── get [name]
|
||||
│ ├── list
|
||||
│ ├── delete <name...>
|
||||
│ ├── delete <name...> [--all]
|
||||
│ ├── connect [name]
|
||||
│ ├── upload [name]
|
||||
│ ├── download [name]
|
||||
@@ -115,7 +115,7 @@ Create and manage isolated agent execution environments.
|
||||
| `openshell sandbox create` | Create a new sandbox. Refer to the flag reference below. |
|
||||
| `openshell sandbox get [name]` | Show detailed information about a sandbox. |
|
||||
| `openshell sandbox list` | List all sandboxes in the active cluster. |
|
||||
| `openshell sandbox delete <name...>` | Delete one or more sandboxes by name. |
|
||||
| `openshell sandbox delete <name...>` | Delete one or more sandboxes by name. Use `--all` to delete every sandbox in the active gateway. |
|
||||
| `openshell sandbox connect [name]` | Open an interactive SSH session into a running sandbox. |
|
||||
| `openshell sandbox upload [name]` | Upload files from the host into a sandbox. |
|
||||
| `openshell sandbox download [name]` | Download files from a sandbox to the host. |
|
||||
@@ -243,3 +243,5 @@ $ openshell sandbox --help
|
||||
$ openshell sandbox create --help
|
||||
$ openshell gateway --help
|
||||
```
|
||||
|
||||
Help output groups flags under distinct headings: `FLAGS` for command-specific options, `GATEWAY FLAGS` for `--gateway` and `--gateway-endpoint`, and `GLOBAL FLAGS` for `--verbose`, `--help`, and `--version`.
|
||||
|
||||
@@ -52,9 +52,9 @@ OpenShell uses several container images that are pulled automatically during gat
|
||||
| Image | Registry | Reference | Pulled When |
|
||||
|---|---|---|---|
|
||||
| Cluster | ghcr.io | `ghcr.io/nvidia/openshell/cluster:latest` | `openshell gateway start` |
|
||||
| Server | ghcr.io | `ghcr.io/nvidia/openshell/server:latest` | Cluster startup (via Helm chart) |
|
||||
| Gateway | ghcr.io | `ghcr.io/nvidia/openshell/gateway:latest` | Cluster startup (via Helm chart) |
|
||||
| Sandbox | ghcr.io | `ghcr.io/nvidia/openshell/sandbox:latest` | First sandbox creation (via Helm chart) |
|
||||
| Community sandboxes | CloudFront CDN | `d1i0nduu2f6qxk.cloudfront.net/openshell-community/sandboxes/{name}:latest` | `openshell sandbox create --from <name>` |
|
||||
| Community sandboxes | GHCR | `ghcr.io/nvidia/openshell-community/sandboxes/{name}:latest` | `openshell sandbox create --from <name>` |
|
||||
|
||||
The cluster image is based on `rancher/k3s:v1.35.2-k3s1` and bundles the Helm charts and Kubernetes manifests required to bootstrap the control plane. The server and sandbox images are pulled separately at runtime.
|
||||
|
||||
|
||||
@@ -187,6 +187,12 @@ Delete a sandbox by name:
|
||||
$ openshell sandbox delete my-sandbox
|
||||
```
|
||||
|
||||
Delete all sandboxes in the active gateway:
|
||||
|
||||
```console
|
||||
$ openshell sandbox delete --all
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
Explore related topics:
|
||||
|
||||
+118
-141
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title:
|
||||
page: Set Up a Sandbox with Custom GitHub Policies
|
||||
page: Set Up a Sandbox of Claude Code with a Custom GitHub Policy
|
||||
nav: GitHub Sandbox Tutorial
|
||||
description: Configure a sandbox with per-repository GitHub access policies and credential providers for Claude Code.
|
||||
description: Learn the iterative policy workflow by launching a sandbox, diagnosing a GitHub access denial, and applying a custom policy to fix it.
|
||||
topics:
|
||||
- Generative AI
|
||||
- Cybersecurity
|
||||
@@ -12,7 +12,6 @@ tags:
|
||||
- Sandbox
|
||||
- Policy
|
||||
- Claude Code
|
||||
- Credential Provider
|
||||
content:
|
||||
type: how_to
|
||||
difficulty: technical_intermediate
|
||||
@@ -27,61 +26,132 @@ content:
|
||||
|
||||
# Set Up a Sandbox of Claude Code with a Custom GitHub Policy
|
||||
|
||||
This tutorial walks through configuring a sandbox that grants different access levels to two GitHub repositories and enforces these rules at the network layer. The sandbox runs Claude Code as the agent.
|
||||
This tutorial walks through the iterative policy workflow. You launch a sandbox, ask Claude Code to push code to GitHub, get blocked by the default network policy, diagnose the denial from two angles — the OpenShell Terminal on your laptop and the sandbox logs from inside — and then apply a custom policy to fix it, all without recreating the sandbox.
|
||||
|
||||
The tutorial uses the following two example repositories for illustration purposes.
|
||||
After completing this tutorial, you will have:
|
||||
|
||||
- A `alpha-repo` repository with read-write access. The agent can clone, push, and call mutating GitHub API endpoints such as pull requests, issues, and comments.
|
||||
- A `bravo-repo` repository with read-only access. The agent can clone and fetch, but push operations and mutating API calls are denied.
|
||||
- All other GitHub repositories are denied by default. No clone, fetch, or API call to an unlisted repository is allowed.
|
||||
- A running sandbox with Claude Code that can push to a GitHub repository.
|
||||
- A custom network policy that grants GitHub access for a specific repository.
|
||||
- Experience with the policy iteration workflow: fail, diagnose, update, verify.
|
||||
|
||||
After completing this tutorial, the sandbox environment includes the following:
|
||||
|
||||
- A GitHub credential provider that injects your GitHub token into the sandbox at runtime.
|
||||
- A network policy that extends the default policy with per-repository GitHub access rules.
|
||||
- A running sandbox in which Claude Code operates under the defined access constraints.
|
||||
:::{note}
|
||||
This tutorial shows example prompts and responses from Claude Code. The exact wording you see may differ between sessions — use the examples as a guide for the type of interaction, not as expected output.
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This tutorial requires the following:
|
||||
|
||||
- Completed the {doc}`Quickstart </get-started/quickstart>` tutorial.
|
||||
- A GitHub personal access token (PAT) with `repo` scope, exported as `GITHUB_TOKEN`.
|
||||
- An agent API key configured in the environment. For example, `ANTHROPIC_API_KEY` for Claude Code.
|
||||
- A GitHub personal access token (PAT) with `repo` scope. To create one, go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens), select **Generate new token (classic)**, check the `repo` scope, and copy the token.
|
||||
|
||||
## Create a GitHub Provider
|
||||
:::{tip}
|
||||
Instead of pasting the token into Claude during the tutorial, use a {doc}`credential provider </sandboxes/providers>` to inject it into the sandbox automatically at startup:
|
||||
|
||||
In this section, you learn how to create a GitHub provider that injects your token into the sandbox at runtime.
|
||||
```console
|
||||
$ openshell provider create --name my-github --type github --from-existing
|
||||
$ openshell sandbox create --provider my-github --keep -- claude
|
||||
```
|
||||
|
||||
:::{admonition} Already have a sandbox running?
|
||||
:class: tip
|
||||
The provider reads `GITHUB_TOKEN` from your host environment and sets it as an environment variable inside the sandbox so Claude can use it directly.
|
||||
:::
|
||||
|
||||
If you completed the Quickstart tutorial and have the default sandbox without a GitHub provider, there are two options to add the provider.
|
||||
- Your own [Anthropic account](https://console.anthropic.com/) for Claude Code. OpenShell provides the sandbox, not the agent — you need your own account to log in to Claude Code.
|
||||
- A public GitHub repository you own (used as the push target). A scratch or test repository works well — the tutorial pushes a small file to it. You can [create a new repository](https://github.com/new) with a README if you do not have one handy.
|
||||
|
||||
- **Option 1**: Recreate with a provider. Delete the existing sandbox, create the provider below, then recreate the sandbox with `--provider my-github`.
|
||||
- **Option 2**: Inject the token manually. Connect with `openshell sandbox connect <name>` and run `export GITHUB_TOKEN=<your-token>`. This bypasses the provider workflow. Note that the token does not persist across sandbox recreations.
|
||||
:::{important}
|
||||
This tutorial uses two terminals throughout:
|
||||
|
||||
- **Terminal 1 (sandbox)** — The terminal where you launch the sandbox. Claude Code runs here. You interact with the agent in this terminal.
|
||||
- **Terminal 2 (laptop)** — A separate terminal on your laptop. You use this for `openshell term`, `openshell policy set`, and other CLI commands that manage the sandbox from the outside.
|
||||
|
||||
Each section below indicates which terminal to use.
|
||||
:::
|
||||
|
||||
Create a provider that sources your GitHub token from the host environment:
|
||||
## Launch the Sandbox
|
||||
|
||||
**Terminal 1 (sandbox)** — Create a sandbox and start Claude Code. No custom policy is needed yet — the {doc}`default policy </reference/default-policy>` is applied automatically:
|
||||
|
||||
```console
|
||||
$ openshell provider create --name my-github --type github --from-existing
|
||||
$ openshell sandbox create --keep -- claude
|
||||
```
|
||||
|
||||
This command reads `GITHUB_TOKEN` (and `GH_TOKEN` if set) from the current shell session and stores them in the provider configuration. At sandbox startup, the provider injects these values as environment variables into the container.
|
||||
The `--keep` flag keeps the sandbox running after Claude Code exits, so you can apply policy updates later without recreating the environment.
|
||||
|
||||
For additional provider types, refer to {doc}`/sandboxes/providers`.
|
||||
Claude Code starts inside the sandbox. Log in through your preferred authentication method and trust the `/sandbox` workspace when prompted.
|
||||
|
||||
## Write the Policy
|
||||
## Push Code to GitHub
|
||||
|
||||
In this section, you learn how to write a policy that extends the default policy with per-repository GitHub access rules.
|
||||
**Terminal 1 (sandbox)** — Ask Claude Code to write a simple script and push it to your repository:
|
||||
|
||||
Run the following command to create a file named `github-policy.yaml` with policy blocks. This policy extends the {doc}`default policy </reference/default-policy>` and overrides the GitHub network blocks with per-repository rules. The `alpha-repo` receives read-write access, the `bravo-repo` receives read-only access, and all other repositories are denied by default.
|
||||
```text
|
||||
Write a hello_world.py script and push it to https://github.com/<org>/<repo>.
|
||||
```
|
||||
|
||||
Replace every occurrence of `<org>` with your GitHub organization or username.
|
||||
Claude recognizes that it needs GitHub credentials. It asks how you want to authenticate. Provide your GitHub personal access token by pasting it into the conversation. Claude configures authentication and attempts the push.
|
||||
|
||||
The push fails. Claude reports an error — but the failure is not an authentication problem. The default sandbox policy does not permit outbound requests to GitHub, so the proxy blocks the connection before the request reaches GitHub's servers.
|
||||
|
||||
## Diagnose the Denial
|
||||
|
||||
### View the logs from your laptop
|
||||
|
||||
**Terminal 2 (laptop)** — Open a separate terminal on your laptop and launch the OpenShell Terminal:
|
||||
|
||||
```console
|
||||
$ cat << 'EOF' > github-policy.yaml
|
||||
$ openshell term
|
||||
```
|
||||
|
||||
The dashboard shows sandbox status and a live stream of policy decisions. Look for entries with `l7_decision=deny`. Select a deny entry to see the full detail:
|
||||
|
||||
```text
|
||||
l7_action: PUT
|
||||
l7_target: /repos/<org>/<repo>/contents/hello_world.py
|
||||
l7_decision: deny
|
||||
dst_host: api.github.com
|
||||
dst_port: 443
|
||||
l7_protocol: rest
|
||||
policy: github_rest_api
|
||||
l7_deny_reason: PUT /repos/<org>/<repo>/contents/hello_world.py not permitted by policy
|
||||
```
|
||||
|
||||
The log shows that the sandbox proxy intercepted an outbound `PUT` request to `api.github.com` and denied it. The `github_rest_api` policy allows read operations (GET) but blocks write operations (PUT, POST, DELETE) to the GitHub API. A similar denial appears for `github.com` if Claude attempted a git push over HTTPS.
|
||||
|
||||
### Ask Claude to check the sandbox logs
|
||||
|
||||
**Terminal 1 (sandbox)** — Switch back to Claude Code. Ask it to check the sandbox logs for denied requests:
|
||||
|
||||
```text
|
||||
Check the sandbox logs for any denied network requests. What is blocking the push?
|
||||
```
|
||||
|
||||
Claude reads the deny entries and identifies the root cause. It explains that the failure is a sandbox network policy restriction, not a token permissions issue:
|
||||
|
||||
> The sandbox runs a proxy that enforces policies on outbound traffic. The
|
||||
> `github_rest_api` policy allows GET requests (used to read the file) but blocks
|
||||
> PUT/write requests to GitHub. This is a sandbox-level restriction, not a token
|
||||
> issue — no matter what token you provide, pushes via the API will be blocked
|
||||
> until the policy is updated.
|
||||
|
||||
Both perspectives confirm the same thing: the proxy is doing its job. The default policy is designed to be restrictive. To allow GitHub pushes, you need to update the network policy.
|
||||
|
||||
## Update the Policy from Your Laptop
|
||||
|
||||
**Terminal 2 (laptop)** — Ask your coding agent (for example, Claude Code or Cursor running on your laptop) to recommend a policy update based on the denied requests and apply it to the running sandbox.
|
||||
|
||||
The agent inspects the deny reasons, writes an updated policy that adds `github_git` and `github_api` blocks for your repository, and runs `openshell policy set` to apply it:
|
||||
|
||||
```console
|
||||
$ openshell policy set <sandbox-name> --policy /tmp/sandbox-policy-update.yaml --wait
|
||||
```
|
||||
|
||||
Your coding agent generates both the policy file and this command. Network policies are hot-reloadable — the `--wait` flag blocks until the policy engine confirms the new revision loaded, and the update takes effect immediately without restarting the sandbox or reconnecting Claude Code.
|
||||
|
||||
:::{dropdown} Full reference policy
|
||||
|
||||
The following YAML shows a complete policy that extends the {doc}`default policy </reference/default-policy>` with GitHub access for a single repository. This is representative of what a coding agent generates when asked to unblock GitHub pushes. Replace `<org>` with your GitHub organization or username and `<repo>` with your repository name.
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
|
||||
# ── Static (locked at sandbox creation) ──────────────────────────
|
||||
@@ -146,23 +216,15 @@ network_policies:
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
rules:
|
||||
# alpha-repo: clone, fetch, and push
|
||||
- allow:
|
||||
method: GET
|
||||
path: "/<org>/alpha-repo.git/info/refs*"
|
||||
path: "/<org>/<repo>.git/info/refs*"
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/<org>/alpha-repo.git/git-upload-pack"
|
||||
path: "/<org>/<repo>.git/git-upload-pack"
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/<org>/alpha-repo.git/git-receive-pack"
|
||||
# bravo-repo: clone and fetch only (no push)
|
||||
- allow:
|
||||
method: GET
|
||||
path: "/<org>/bravo-repo.git/info/refs*"
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/<org>/bravo-repo.git/git-upload-pack"
|
||||
path: "/<org>/<repo>.git/git-receive-pack"
|
||||
binaries:
|
||||
- { path: /usr/bin/git }
|
||||
|
||||
@@ -181,20 +243,10 @@ network_policies:
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/graphql"
|
||||
# alpha-repo: full read-write (PRs, issues, comments, etc.)
|
||||
# Full read-write access to the repository
|
||||
- allow:
|
||||
method: "*"
|
||||
path: "/repos/<org>/alpha-repo/**"
|
||||
# bravo-repo: read-only
|
||||
- allow:
|
||||
method: GET
|
||||
path: "/repos/<org>/bravo-repo/**"
|
||||
- allow:
|
||||
method: HEAD
|
||||
path: "/repos/<org>/bravo-repo/**"
|
||||
- allow:
|
||||
method: OPTIONS
|
||||
path: "/repos/<org>/bravo-repo/**"
|
||||
path: "/repos/<org>/<repo>/**"
|
||||
binaries:
|
||||
- { path: /usr/local/bin/claude }
|
||||
- { path: /usr/local/bin/opencode }
|
||||
@@ -237,109 +289,34 @@ network_policies:
|
||||
- { path: /usr/bin/wget }
|
||||
- { path: "/sandbox/.vscode-server/**" }
|
||||
- { path: "/sandbox/.vscode-remote-containers/**" }
|
||||
EOF
|
||||
```
|
||||
|
||||
The following table summarizes the behavior of the GitHub policy blocks.
|
||||
The following table summarizes the two GitHub-specific blocks:
|
||||
|
||||
| Block | Endpoint | Behavior |
|
||||
|---|---|---|
|
||||
| `github_git` | `github.com:443` | Git Smart HTTP protocol with TLS termination. Permits `info/refs` (clone/fetch) for both repositories. Permits `git-receive-pack` (push) for `alpha-repo` only. Denies all operations on unlisted repositories. |
|
||||
| `github_api` | `api.github.com:443` | REST API with TLS termination. Permits all HTTP methods for `alpha-repo`. Restricts `bravo-repo` to GET, HEAD, and OPTIONS (read-only). Denies API access to unlisted repositories. |
|
||||
| `github_git` | `github.com:443` | Git Smart HTTP protocol with TLS termination. Permits `info/refs` (clone/fetch), `git-upload-pack` (fetch data), and `git-receive-pack` (push) for the specified repository. Denies all operations on unlisted repositories. |
|
||||
| `github_api` | `api.github.com:443` | REST API with TLS termination. Permits all HTTP methods for the specified repository and GraphQL queries. Denies API access to unlisted repositories. |
|
||||
|
||||
The remaining blocks (`claude_code`, `nvidia_inference`, `pypi`, `vscode`) are identical to the {doc}`default policy </reference/default-policy>`. Sandbox behavior outside of GitHub operations is unchanged.
|
||||
|
||||
For details on network policy block structure, refer to [Network Access Rules](/sandboxes/index.md#network-access-rules).
|
||||
For details on policy block structure, refer to [Network Access Rules](/sandboxes/index.md#network-access-rules).
|
||||
:::
|
||||
|
||||
## Create the Sandbox
|
||||
## Retry the Push
|
||||
|
||||
Run the following command to create the sandbox with the GitHub provider, the custom policy applied, and Claude Code running inside the sandbox:
|
||||
|
||||
```console
|
||||
$ openshell sandbox create \
|
||||
--provider my-github \
|
||||
--policy github-policy.yaml \
|
||||
--keep \
|
||||
-- claude
|
||||
```
|
||||
|
||||
The `--keep` flag keeps the sandbox running after Claude Code exits. With this flag, you can reconnect to the same sandbox or apply policy updates without recreating the environment.
|
||||
|
||||
## Verify Access
|
||||
|
||||
With Claude Code running inside the sandbox, validate that the policy enforces the expected access levels for each repository.
|
||||
|
||||
### Verify Read-Write Access
|
||||
|
||||
Instruct Claude to clone, commit, and push to `alpha-repo`:
|
||||
**Terminal 1 (sandbox)** — Switch back to Claude Code and ask it to retry the push:
|
||||
|
||||
```text
|
||||
Clone https://github.com/<org>/alpha-repo.git, add a blank line to the
|
||||
README.md file, commit, and push.
|
||||
The sandbox policy has been updated. Try pushing to the repository again.
|
||||
```
|
||||
|
||||
The clone, commit, and push operations should complete successfully. Verify that the sandbox logs contain `action=allow` entries for `github.com` (the git push) and `api.github.com` (any associated API calls).
|
||||
|
||||
### Verify Read-Only Enforcement
|
||||
|
||||
Instruct Claude to perform the same operations on `bravo-repo`:
|
||||
|
||||
```text
|
||||
Clone https://github.com/<org>/bravo-repo.git, add a blank line to the
|
||||
README.md file, commit, and push.
|
||||
```
|
||||
|
||||
The clone operation succeeds because the policy permits read access. The push operation fails because the proxy denies `git-receive-pack` for `bravo-repo`. Verify the denial by inspecting the sandbox logs:
|
||||
|
||||
```console
|
||||
$ openshell logs <sandbox-name> --tail --source sandbox
|
||||
```
|
||||
|
||||
The output contains an `action=deny` entry with `host=github.com` and `path=/<org>/bravo-repo.git/git-receive-pack`.
|
||||
|
||||
### Verify API Scoping
|
||||
|
||||
Instruct Claude to create a GitHub issue on each repository:
|
||||
|
||||
```text
|
||||
Create a GitHub issue titled "Test from sandbox" on <org>/alpha-repo.
|
||||
Then try to create the same issue on <org>/bravo-repo.
|
||||
```
|
||||
|
||||
Expected result is that the issue is created on `alpha-repo`. The request to `bravo-repo` is denied because the policy restricts that repository to GET, HEAD, and OPTIONS methods. The POST required to create an issue is blocked by the proxy.
|
||||
|
||||
## Iterate on the Policy
|
||||
|
||||
In this section, you learn how to iterate on the policy to modify repository access or add new repositories.
|
||||
|
||||
Network policies support hot-reloading. To modify repository access or add new repositories, edit `github-policy.yaml` and run the following command to apply the updated policy to the running sandbox:
|
||||
|
||||
```console
|
||||
$ openshell policy set <sandbox-name> --policy github-policy.yaml --wait
|
||||
```
|
||||
|
||||
For example, to change the access level of `bravo-repo` from read-only to read-write, add the following rule under `github_api`:
|
||||
|
||||
```yaml
|
||||
- allow:
|
||||
method: "*"
|
||||
path: "/repos/<org>/bravo-repo/**"
|
||||
```
|
||||
|
||||
Then add the corresponding push rule under `github_git`:
|
||||
|
||||
```yaml
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/<org>/bravo-repo.git/git-receive-pack"
|
||||
```
|
||||
|
||||
For the complete policy iteration workflow (pull, edit, push, verify), refer to {doc}`/sandboxes/policies`.
|
||||
The push completes successfully. The `openshell term` dashboard now shows `l7_decision=allow` entries for `api.github.com` and `github.com` where it previously showed denials.
|
||||
|
||||
## Next Steps
|
||||
|
||||
The following resources cover related topics in greater depth:
|
||||
|
||||
- To configure additional credential types, refer to {doc}`/sandboxes/providers`.
|
||||
- To iterate on policy configuration, refer to {doc}`/sandboxes/policies`.
|
||||
- To view the policy YAML specification, refer to the [Policy Schema Reference](/reference/policy-schema.md).
|
||||
- To add per-repository access levels (read-write vs read-only) or restrict to specific API methods, refer to the [Policy Schema Reference](/reference/policy-schema.md).
|
||||
- To learn the full policy iteration workflow (pull, edit, push, verify), refer to {doc}`/sandboxes/policies`.
|
||||
- To inject credentials automatically instead of pasting tokens, refer to {doc}`/sandboxes/providers`.
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"preferred": true,
|
||||
"version": "0.1.0",
|
||||
"url": "../0.1.0/"
|
||||
}
|
||||
]
|
||||
@@ -19,6 +19,8 @@ uv = "0.10.2"
|
||||
protoc = "29.6"
|
||||
helm = "4.1.1"
|
||||
"ubi:mozilla/sccache" = { version = "0.14.0", matching = "sccache-v" }
|
||||
"ubi:anchore/syft" = { version = "1.42.2", matching = "syft_" }
|
||||
"ubi:EmbarkStudios/cargo-about" = "0.8.4"
|
||||
|
||||
[env]
|
||||
_.path = ["{{config_root}}/scripts/bin"]
|
||||
@@ -42,7 +44,7 @@ DOCKER_BUILDKIT = "1"
|
||||
|
||||
[vars]
|
||||
# Python paths to include in formatting/linting
|
||||
python_paths = "python/ tasks/scripts/*.py"
|
||||
python_paths = "python/ tasks/scripts/*.py deploy/sbom/*.py"
|
||||
|
||||
[task_config]
|
||||
includes = ["tasks/*.toml"]
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Generate THIRD-PARTY-NOTICES from Rust and Python dependency metadata.
|
||||
"""Generate THIRD-PARTY-NOTICES with full license texts.
|
||||
|
||||
Uses cargo-about (Rust) and pip-licenses (Python) to collect third-party
|
||||
dependency licenses and produce a single attribution file at the repo root.
|
||||
|
||||
Usage:
|
||||
python scripts/generate_third_party_notices.py
|
||||
uv run python scripts/generate_third_party_notices.py
|
||||
mise run notices
|
||||
|
||||
Writes THIRD-PARTY-NOTICES to the repo root. Requires `cargo` on PATH
|
||||
for Rust deps. Python deps are read from pyproject.toml.
|
||||
Requires cargo-about (installed via mise) and pip-licenses (fetched via uv).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,8 +20,34 @@ from __future__ import annotations
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Our own workspace crates and packages — excluded from notices.
|
||||
WORKSPACE_CRATES = frozenset(
|
||||
{
|
||||
"navigator-bootstrap",
|
||||
"navigator-cli",
|
||||
"navigator-core",
|
||||
"navigator-policy",
|
||||
"navigator-providers",
|
||||
"navigator-router",
|
||||
"navigator-sandbox",
|
||||
"navigator-server",
|
||||
"navigator-tui",
|
||||
"openshell-e2e",
|
||||
}
|
||||
)
|
||||
|
||||
OWN_PYTHON_PACKAGES = frozenset(
|
||||
{
|
||||
"openshell",
|
||||
}
|
||||
)
|
||||
|
||||
SEPARATOR = "=" * 80
|
||||
THIN_SEP = "-" * 80
|
||||
|
||||
|
||||
def find_repo_root() -> Path:
|
||||
"""Walk up from CWD to find the directory containing .git."""
|
||||
@@ -30,193 +59,216 @@ def find_repo_root() -> Path:
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
# Workspace member crate names to exclude (these are ours, not third-party).
|
||||
WORKSPACE_PREFIXES = ("navigator-",)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rust dependencies via cargo-about
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_rust_deps_from_cargo_metadata() -> list[dict[str, str]] | None:
|
||||
"""Try to extract third-party Rust deps via `cargo metadata`."""
|
||||
def get_rust_notices() -> list[dict]:
|
||||
"""Run cargo-about and return structured license groups.
|
||||
|
||||
Each entry: {id, crates: [{name, version, repository, description}], text}
|
||||
"""
|
||||
print(" Running cargo-about generate --format json ...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["cargo", "metadata", "--format-version", "1"],
|
||||
["cargo-about", "generate", "--format", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
return None
|
||||
|
||||
meta = json.loads(result.stdout)
|
||||
workspace_members = set(meta.get("workspace_members", []))
|
||||
|
||||
deps = []
|
||||
for pkg in meta["packages"]:
|
||||
if pkg["id"] in workspace_members:
|
||||
continue
|
||||
if any(pkg["name"].startswith(p) for p in WORKSPACE_PREFIXES):
|
||||
continue
|
||||
deps.append({
|
||||
"name": pkg["name"],
|
||||
"version": pkg["version"],
|
||||
"license": pkg.get("license") or "Unknown",
|
||||
"repository": pkg.get("repository") or "",
|
||||
})
|
||||
|
||||
return sorted(deps, key=lambda d: d["name"].lower())
|
||||
|
||||
|
||||
def get_rust_deps_from_lockfile(root: Path) -> list[dict[str, str]]:
|
||||
"""Fallback: parse Cargo.lock for name+version (no license info)."""
|
||||
lockfile = root / "Cargo.lock"
|
||||
if not lockfile.exists():
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
" WARNING: cargo-about not found, skipping Rust notices", file=sys.stderr
|
||||
)
|
||||
return []
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" WARNING: cargo-about failed: {e.stderr[:200]}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
deps = []
|
||||
content = lockfile.read_text()
|
||||
name = None
|
||||
version = None
|
||||
for line in content.splitlines():
|
||||
if line.startswith("name = "):
|
||||
name = line.split('"')[1]
|
||||
elif line.startswith("version = ") and '"' in line:
|
||||
version = line.split('"')[1]
|
||||
elif line == "[[package]]" or line == "":
|
||||
if name and version:
|
||||
if not any(name.startswith(p) for p in WORKSPACE_PREFIXES):
|
||||
deps.append({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"license": "See crates.io",
|
||||
"repository": f"https://crates.io/crates/{name}",
|
||||
})
|
||||
name = None
|
||||
version = None
|
||||
data = json.loads(result.stdout)
|
||||
groups: list[dict] = []
|
||||
|
||||
# Catch the last entry.
|
||||
if name and version:
|
||||
if not any(name.startswith(p) for p in WORKSPACE_PREFIXES):
|
||||
deps.append({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"license": "See crates.io",
|
||||
"repository": f"https://crates.io/crates/{name}",
|
||||
})
|
||||
for lic in data.get("licenses", []):
|
||||
crates = []
|
||||
for entry in lic.get("used_by", []):
|
||||
crate = entry.get("crate", {})
|
||||
name = crate.get("name", "")
|
||||
if name in WORKSPACE_CRATES:
|
||||
continue
|
||||
crates.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": crate.get("version", ""),
|
||||
"repository": crate.get("repository", ""),
|
||||
"description": crate.get("description", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(deps, key=lambda d: d["name"].lower())
|
||||
if not crates:
|
||||
continue
|
||||
|
||||
groups.append(
|
||||
{
|
||||
"id": lic.get("id", "Unknown"),
|
||||
"crates": sorted(crates, key=lambda c: c["name"].lower()),
|
||||
"text": (lic.get("text") or "").rstrip(),
|
||||
}
|
||||
)
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def get_rust_deps(root: Path) -> list[dict[str, str]]:
|
||||
"""Extract third-party Rust dependencies.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python dependencies via pip-licenses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Prefers `cargo metadata` for full license info. Falls back to parsing
|
||||
Cargo.lock when cargo is not available.
|
||||
|
||||
def get_python_notices() -> list[dict]:
|
||||
"""Run pip-licenses and return structured package notices.
|
||||
|
||||
Each entry: {name, version, license_id, text}
|
||||
"""
|
||||
deps = get_rust_deps_from_cargo_metadata()
|
||||
if deps is not None:
|
||||
return deps
|
||||
|
||||
print(" cargo not found, falling back to Cargo.lock parsing", file=sys.stderr)
|
||||
return get_rust_deps_from_lockfile(root)
|
||||
|
||||
|
||||
def get_python_deps(root: Path) -> list[dict[str, str]]:
|
||||
"""Extract Python dependencies from pyproject.toml [project.dependencies]."""
|
||||
pyproject = root / "pyproject.toml"
|
||||
if not pyproject.exists():
|
||||
print(" Running pip-licenses ...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"uv",
|
||||
"run",
|
||||
"--with",
|
||||
"pip-licenses",
|
||||
"pip-licenses",
|
||||
"--format=json",
|
||||
"--with-license-file",
|
||||
"--no-license-path",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print(" WARNING: uv not found, skipping Python notices", file=sys.stderr)
|
||||
return []
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" WARNING: pip-licenses failed: {e.stderr[:200]}", file=sys.stderr)
|
||||
return []
|
||||
|
||||
# Simple parser: read the dependencies list without a TOML library.
|
||||
content = pyproject.read_text()
|
||||
deps = []
|
||||
in_deps = False
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("dependencies = ["):
|
||||
in_deps = True
|
||||
packages: list[dict] = []
|
||||
for pkg in json.loads(result.stdout):
|
||||
name = pkg.get("Name", "")
|
||||
if name.lower() in OWN_PYTHON_PACKAGES:
|
||||
continue
|
||||
# Skip pip/setuptools/wheel (installer tools, not shipped deps)
|
||||
if name.lower() in {"pip", "wheel"}:
|
||||
continue
|
||||
if in_deps:
|
||||
if stripped == "]":
|
||||
break
|
||||
# Parse "package>=version" style.
|
||||
dep = stripped.strip('",').strip()
|
||||
if dep:
|
||||
# Split on first version specifier.
|
||||
for sep in (">=", "==", "~=", "!=", "<", ">"):
|
||||
if sep in dep:
|
||||
name = dep[:dep.index(sep)].strip()
|
||||
deps.append({
|
||||
"name": name,
|
||||
"version": dep[dep.index(sep):].strip(),
|
||||
"license": "See PyPI",
|
||||
"repository": f"https://pypi.org/project/{name}/",
|
||||
})
|
||||
break
|
||||
else:
|
||||
deps.append({
|
||||
"name": dep,
|
||||
"version": "",
|
||||
"license": "See PyPI",
|
||||
"repository": f"https://pypi.org/project/{dep}/",
|
||||
})
|
||||
|
||||
return sorted(deps, key=lambda d: d["name"].lower())
|
||||
packages.append(
|
||||
{
|
||||
"name": name,
|
||||
"version": pkg.get("Version", ""),
|
||||
"license_id": pkg.get("License", "Unknown"),
|
||||
"text": (pkg.get("LicenseText") or "").rstrip(),
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(packages, key=lambda p: p["name"].lower())
|
||||
|
||||
|
||||
def format_notices(rust_deps: list[dict], python_deps: list[dict]) -> str:
|
||||
"""Format the THIRD-PARTY-NOTICES file content."""
|
||||
lines = [
|
||||
"THIRD-PARTY SOFTWARE NOTICES",
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def format_notices(
|
||||
rust_groups: list[dict],
|
||||
python_packages: list[dict],
|
||||
) -> str:
|
||||
"""Format the complete THIRD-PARTY-NOTICES file."""
|
||||
lines: list[str] = [
|
||||
"THIRD-PARTY SOFTWARE NOTICES AND INFORMATION",
|
||||
"",
|
||||
"This file lists the third-party software packages used by OpenShell,",
|
||||
"along with their respective licenses.",
|
||||
"This product includes third-party software components. The following",
|
||||
"notices and licenses are provided in compliance with the terms of the",
|
||||
"respective licenses.",
|
||||
"",
|
||||
"To regenerate: uv run python scripts/generate_third_party_notices.py",
|
||||
"To regenerate: mise run notices",
|
||||
"",
|
||||
]
|
||||
|
||||
if rust_deps:
|
||||
lines.append("=" * 80)
|
||||
lines.append("Rust Dependencies")
|
||||
lines.append("=" * 80)
|
||||
# --- Rust section ---
|
||||
if rust_groups:
|
||||
rust_crate_count = sum(len(g["crates"]) for g in rust_groups)
|
||||
lines.append(SEPARATOR)
|
||||
lines.append(f"Rust Dependencies ({rust_crate_count} packages)")
|
||||
lines.append(SEPARATOR)
|
||||
lines.append("")
|
||||
for dep in rust_deps:
|
||||
lines.append(f"Package: {dep['name']} {dep['version']}")
|
||||
lines.append(f"License: {dep['license']}")
|
||||
if dep["repository"]:
|
||||
lines.append(f"Repository: {dep['repository']}")
|
||||
|
||||
for group in rust_groups:
|
||||
lines.append(SEPARATOR)
|
||||
lines.append(f"License: {group['id']}")
|
||||
lines.append(THIN_SEP)
|
||||
lines.append("")
|
||||
lines.append("Used by:")
|
||||
for crate in group["crates"]:
|
||||
repo = f" ({crate['repository']})" if crate["repository"] else ""
|
||||
lines.append(f" - {crate['name']} {crate['version']}{repo}")
|
||||
lines.append("")
|
||||
|
||||
if python_deps:
|
||||
lines.append("=" * 80)
|
||||
lines.append("Python Dependencies")
|
||||
lines.append("=" * 80)
|
||||
if group["text"]:
|
||||
lines.append(group["text"])
|
||||
lines.append("")
|
||||
|
||||
# --- Python section ---
|
||||
if python_packages:
|
||||
lines.append(SEPARATOR)
|
||||
lines.append(f"Python Dependencies ({len(python_packages)} packages)")
|
||||
lines.append(SEPARATOR)
|
||||
lines.append("")
|
||||
for dep in python_deps:
|
||||
version_str = f" {dep['version']}" if dep["version"] else ""
|
||||
lines.append(f"Package: {dep['name']}{version_str}")
|
||||
lines.append(f"License: {dep['license']}")
|
||||
if dep["repository"]:
|
||||
lines.append(f"Repository: {dep['repository']}")
|
||||
|
||||
for pkg in python_packages:
|
||||
lines.append(SEPARATOR)
|
||||
lines.append(f"{pkg['name']} {pkg['version']}")
|
||||
lines.append(f"License: {pkg['license_id']}")
|
||||
lines.append(THIN_SEP)
|
||||
lines.append("")
|
||||
if pkg["text"]:
|
||||
lines.append(pkg["text"])
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = find_repo_root()
|
||||
t0 = time.monotonic()
|
||||
|
||||
print("Generating third-party notices...")
|
||||
print()
|
||||
|
||||
print("Collecting Rust dependencies...")
|
||||
rust_deps = get_rust_deps(root)
|
||||
print(f" Found {len(rust_deps)} Rust dependencies")
|
||||
rust_groups = get_rust_notices()
|
||||
rust_count = sum(len(g["crates"]) for g in rust_groups)
|
||||
print(f" {rust_count} Rust packages across {len(rust_groups)} license groups")
|
||||
print()
|
||||
|
||||
print("Collecting Python dependencies...")
|
||||
python_deps = get_python_deps(root)
|
||||
print(f" Found {len(python_deps)} Python dependencies")
|
||||
python_packages = get_python_notices()
|
||||
print(f" {len(python_packages)} Python packages")
|
||||
print()
|
||||
|
||||
notices = format_notices(rust_deps, python_deps)
|
||||
notices = format_notices(rust_groups, python_packages)
|
||||
output = root / "THIRD-PARTY-NOTICES"
|
||||
output.write_text(notices)
|
||||
print(f"Wrote {output}")
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
line_count = notices.count("\n") + 1
|
||||
print(f"Wrote {output.name} ({line_count} lines, {len(notices)} bytes)")
|
||||
print(f"Done in {elapsed:.1f}s")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -249,11 +249,11 @@ echo "==> Building Docker images (tag=${IMAGE_TAG})..."
|
||||
export OPENSHELL_CARGO_VERSION="${CARGO_VERSION}"
|
||||
export IMAGE_TAG
|
||||
mise exec -- tasks/scripts/docker-build-cluster.sh
|
||||
mise exec -- tasks/scripts/docker-build-component.sh server
|
||||
mise exec -- tasks/scripts/docker-build-component.sh gateway
|
||||
mise exec -- tasks/scripts/docker-build-component.sh sandbox
|
||||
|
||||
export OPENSHELL_CLUSTER_IMAGE="openshell/cluster:${IMAGE_TAG}"
|
||||
export OPENSHELL_PUSH_IMAGES="openshell/server:${IMAGE_TAG},openshell/sandbox:${IMAGE_TAG}"
|
||||
export OPENSHELL_PUSH_IMAGES="openshell/gateway:${IMAGE_TAG},openshell/sandbox:${IMAGE_TAG}"
|
||||
|
||||
start_args=(
|
||||
gateway
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@ run = "tasks/scripts/cluster.sh"
|
||||
["cluster:build:full"]
|
||||
description = "Build and deploy local k3s cluster with OpenShell"
|
||||
depends = [
|
||||
"docker:build:server",
|
||||
"docker:build:gateway",
|
||||
"docker:build:sandbox",
|
||||
]
|
||||
run = "tasks/scripts/cluster-bootstrap.sh build"
|
||||
@@ -31,9 +31,9 @@ description = "Pull-mode deploy using local registry pushes"
|
||||
run = "tasks/scripts/cluster-deploy-fast.sh all"
|
||||
hide = true
|
||||
|
||||
["cluster:push:server"]
|
||||
description = "Tag and push server image to pull registry"
|
||||
run = "tasks/scripts/cluster-push-component.sh server"
|
||||
["cluster:push:gateway"]
|
||||
description = "Tag and push gateway image to pull registry"
|
||||
run = "tasks/scripts/cluster-push-component.sh gateway"
|
||||
hide = true
|
||||
|
||||
["cluster:push:sandbox"]
|
||||
|
||||
+4
-4
@@ -7,7 +7,7 @@
|
||||
description = "Build all Docker images"
|
||||
depends = [
|
||||
"docker:build:sandbox",
|
||||
"docker:build:server",
|
||||
"docker:build:gateway",
|
||||
"docker:build:cluster",
|
||||
]
|
||||
hide = true
|
||||
@@ -28,9 +28,9 @@ depends = ["docker:build:sandbox"]
|
||||
run = "tasks/scripts/docker-build-component.sh sandbox nvidia --build-arg BASE_IMAGE=openshell/sandbox:${IMAGE_TAG:-dev}"
|
||||
hide = true
|
||||
|
||||
["docker:build:server"]
|
||||
description = "Build the server Docker image"
|
||||
run = "tasks/scripts/docker-build-component.sh server"
|
||||
["docker:build:gateway"]
|
||||
description = "Build the gateway Docker image"
|
||||
run = "tasks/scripts/docker-build-component.sh gateway"
|
||||
hide = true
|
||||
|
||||
["docker:build:cluster"]
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Third-party license notice generation tasks
|
||||
#
|
||||
# Usage:
|
||||
# mise run notices # regenerate THIRD-PARTY-NOTICES at repo root
|
||||
|
||||
[notices]
|
||||
description = "Regenerate THIRD-PARTY-NOTICES with full license texts"
|
||||
run = "uv run python scripts/generate_third_party_notices.py"
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# SBOM generation, license resolution, and CSV export tasks
|
||||
#
|
||||
# Usage:
|
||||
# mise run sbom # generate SBOMs, resolve licenses, export CSVs
|
||||
# mise run sbom:check # advisory license check (for CI)
|
||||
|
||||
[sbom]
|
||||
description = "Generate SBOMs, resolve licenses, and export CSVs to deploy/sbom/output/"
|
||||
depends = ["sbom:csv"]
|
||||
|
||||
["sbom:generate"]
|
||||
description = "Generate CycloneDX SBOMs with Syft"
|
||||
hide = true
|
||||
run = """
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTPUT_DIR="deploy/sbom/output"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
VERSION=$(uv run python tasks/scripts/release.py get-version --cargo)
|
||||
|
||||
echo "Generating SBOM for workspace (version ${VERSION})..."
|
||||
|
||||
syft dir:. \
|
||||
--exclude './.github/**' \
|
||||
--exclude './.venv/**' \
|
||||
--exclude './.cache/**' \
|
||||
--output "cyclonedx-json=$OUTPUT_DIR/openshell-source-${VERSION}.cdx.json" \
|
||||
--source-name openshell \
|
||||
--source-version "$VERSION"
|
||||
|
||||
echo ""
|
||||
echo "SBOM written to $OUTPUT_DIR/"
|
||||
ls -la "$OUTPUT_DIR"/*.cdx.json
|
||||
"""
|
||||
|
||||
["sbom:resolve"]
|
||||
description = "Resolve missing licenses in SBOM JSON files via public registries"
|
||||
depends = ["sbom:generate"]
|
||||
hide = true
|
||||
run = "uv run python deploy/sbom/resolve_licenses.py"
|
||||
|
||||
["sbom:csv"]
|
||||
description = "Convert SBOM JSON files to CSV"
|
||||
depends = ["sbom:resolve"]
|
||||
hide = true
|
||||
run = "uv run python deploy/sbom/sbom_to_csv.py"
|
||||
|
||||
["sbom:check"]
|
||||
description = "Check SBOMs for unresolved licenses (advisory, non-blocking)"
|
||||
hide = true
|
||||
run = """
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTPUT_DIR="deploy/sbom/output"
|
||||
|
||||
if [ ! -d "$OUTPUT_DIR" ] || [ -z "$(ls -A "$OUTPUT_DIR"/*.cdx.json 2>/dev/null)" ]; then
|
||||
echo "No SBOM files found in $OUTPUT_DIR/. Run 'mise run sbom' first."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Checking for unresolved licenses..."
|
||||
UNRESOLVED=0
|
||||
for f in "$OUTPUT_DIR"/*.cdx.json; do
|
||||
COUNT=$(uv run python -c "
|
||||
import json, sys
|
||||
with open('$f') as fh:
|
||||
sbom = json.load(fh)
|
||||
missing = 0
|
||||
for c in sbom.get('components', []):
|
||||
lics = c.get('licenses', [])
|
||||
if not lics:
|
||||
missing += 1
|
||||
continue
|
||||
for e in lics:
|
||||
lid = e.get('license', {}).get('id', '')
|
||||
lname = e.get('license', {}).get('name', '')
|
||||
if lid.startswith('sha256:') or lname.startswith('sha256:'):
|
||||
missing += 1
|
||||
break
|
||||
print(missing)
|
||||
")
|
||||
if [ "$COUNT" -gt 0 ]; then
|
||||
echo " $(basename "$f"): $COUNT components with unresolved licenses"
|
||||
UNRESOLVED=$((UNRESOLVED + COUNT))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$UNRESOLVED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "WARNING: $UNRESOLVED total components with unresolved licenses."
|
||||
echo "This is advisory -- not blocking the build."
|
||||
else
|
||||
echo "All licenses resolved."
|
||||
fi
|
||||
"""
|
||||
@@ -223,7 +223,7 @@ fi
|
||||
if [ "${SKIP_IMAGE_PUSH:-}" = "1" ]; then
|
||||
echo "Skipping image push (SKIP_IMAGE_PUSH=1; images already in registry)."
|
||||
elif [ "${MODE}" = "build" ] || [ "${MODE}" = "fast" ]; then
|
||||
for component in server sandbox; do
|
||||
for component in gateway sandbox; do
|
||||
tasks/scripts/cluster-push-component.sh "${component}"
|
||||
done
|
||||
fi
|
||||
|
||||
@@ -36,28 +36,28 @@ if ! docker ps -q --filter "name=^${CONTAINER_NAME}$" --filter "health=healthy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_server=0
|
||||
build_gateway=0
|
||||
build_sandbox=0
|
||||
needs_helm_upgrade=0
|
||||
explicit_target=0
|
||||
|
||||
previous_server_fingerprint=""
|
||||
previous_gateway_fingerprint=""
|
||||
previous_sandbox_fingerprint=""
|
||||
previous_helm_fingerprint=""
|
||||
current_server_fingerprint=""
|
||||
current_gateway_fingerprint=""
|
||||
current_sandbox_fingerprint=""
|
||||
current_helm_fingerprint=""
|
||||
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
explicit_target=1
|
||||
build_server=0
|
||||
build_gateway=0
|
||||
build_sandbox=0
|
||||
needs_helm_upgrade=0
|
||||
|
||||
for target in "$@"; do
|
||||
case "${target}" in
|
||||
server)
|
||||
build_server=1
|
||||
gateway)
|
||||
build_gateway=1
|
||||
;;
|
||||
sandbox)
|
||||
build_sandbox=1
|
||||
@@ -66,12 +66,12 @@ if [[ "$#" -gt 0 ]]; then
|
||||
needs_helm_upgrade=1
|
||||
;;
|
||||
all)
|
||||
build_server=1
|
||||
build_gateway=1
|
||||
build_sandbox=1
|
||||
needs_helm_upgrade=1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown target '${target}'. Use server, sandbox, chart, or all."
|
||||
echo "Unknown target '${target}'. Use gateway, sandbox, chart, or all."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -105,8 +105,8 @@ if [[ -f "${DEPLOY_FAST_STATE_FILE}" ]]; then
|
||||
container_id)
|
||||
previous_container_id=${value}
|
||||
;;
|
||||
server)
|
||||
previous_server_fingerprint=${value}
|
||||
gateway)
|
||||
previous_gateway_fingerprint=${value}
|
||||
;;
|
||||
sandbox)
|
||||
previous_sandbox_fingerprint=${value}
|
||||
@@ -118,7 +118,7 @@ if [[ -f "${DEPLOY_FAST_STATE_FILE}" ]]; then
|
||||
done < "${DEPLOY_FAST_STATE_FILE}"
|
||||
|
||||
if [[ "${previous_cluster_name:-}" != "${CLUSTER_NAME}" ]]; then
|
||||
previous_server_fingerprint=""
|
||||
previous_gateway_fingerprint=""
|
||||
previous_sandbox_fingerprint=""
|
||||
previous_helm_fingerprint=""
|
||||
fi
|
||||
@@ -127,13 +127,13 @@ if [[ -f "${DEPLOY_FAST_STATE_FILE}" ]]; then
|
||||
# changed (recreated or replaced). The new k3s instance has no pushed
|
||||
# images so everything must be rebuilt.
|
||||
if [[ -n "${current_container_id}" && "${current_container_id}" != "${previous_container_id:-}" ]]; then
|
||||
previous_server_fingerprint=""
|
||||
previous_gateway_fingerprint=""
|
||||
previous_sandbox_fingerprint=""
|
||||
previous_helm_fingerprint=""
|
||||
fi
|
||||
fi
|
||||
|
||||
matches_server() {
|
||||
matches_gateway() {
|
||||
local path=$1
|
||||
case "${path}" in
|
||||
Cargo.toml|Cargo.lock|proto/*|deploy/docker/cross-build.sh)
|
||||
@@ -145,7 +145,7 @@ matches_server() {
|
||||
crates/navigator-router/*)
|
||||
return 0
|
||||
;;
|
||||
crates/navigator-server/*|deploy/docker/Dockerfile.server)
|
||||
crates/navigator-server/*|deploy/docker/Dockerfile.gateway)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
@@ -195,8 +195,8 @@ compute_fingerprint() {
|
||||
# or amend) are detected even when there are no uncommitted edits.
|
||||
local committed_trees=""
|
||||
case "${component}" in
|
||||
server)
|
||||
committed_trees=$(git ls-tree HEAD Cargo.toml Cargo.lock proto/ deploy/docker/cross-build.sh crates/navigator-core/ crates/navigator-providers/ crates/navigator-router/ crates/navigator-server/ deploy/docker/Dockerfile.server 2>/dev/null || true)
|
||||
gateway)
|
||||
committed_trees=$(git ls-tree HEAD Cargo.toml Cargo.lock proto/ deploy/docker/cross-build.sh crates/navigator-core/ crates/navigator-providers/ crates/navigator-router/ crates/navigator-server/ deploy/docker/Dockerfile.gateway 2>/dev/null || true)
|
||||
;;
|
||||
sandbox)
|
||||
committed_trees=$(git ls-tree HEAD Cargo.toml Cargo.lock proto/ deploy/docker/cross-build.sh crates/navigator-core/ crates/navigator-policy/ crates/navigator-providers/ crates/navigator-sandbox/ deploy/docker/sandbox/ python/ pyproject.toml uv.lock 2>/dev/null || true)
|
||||
@@ -212,8 +212,8 @@ compute_fingerprint() {
|
||||
# Layer uncommitted changes on top so dirty files trigger a rebuild too.
|
||||
for path in "${changed_files[@]}"; do
|
||||
case "${component}" in
|
||||
server)
|
||||
if ! matches_server "${path}"; then
|
||||
gateway)
|
||||
if ! matches_gateway "${path}"; then
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
@@ -244,17 +244,17 @@ compute_fingerprint() {
|
||||
fi
|
||||
}
|
||||
|
||||
current_server_fingerprint=$(compute_fingerprint server)
|
||||
current_gateway_fingerprint=$(compute_fingerprint gateway)
|
||||
current_sandbox_fingerprint=$(compute_fingerprint sandbox)
|
||||
current_helm_fingerprint=$(compute_fingerprint helm)
|
||||
|
||||
if [[ "${explicit_target}" == "0" && "${DEPLOY_FAST_MODE}" == "full" ]]; then
|
||||
build_server=1
|
||||
build_gateway=1
|
||||
build_sandbox=1
|
||||
needs_helm_upgrade=1
|
||||
elif [[ "${explicit_target}" == "0" ]]; then
|
||||
if [[ "${current_server_fingerprint}" != "${previous_server_fingerprint}" ]]; then
|
||||
build_server=1
|
||||
if [[ "${current_gateway_fingerprint}" != "${previous_gateway_fingerprint}" ]]; then
|
||||
build_gateway=1
|
||||
fi
|
||||
if [[ "${current_sandbox_fingerprint}" != "${previous_sandbox_fingerprint}" ]]; then
|
||||
build_sandbox=1
|
||||
@@ -271,16 +271,16 @@ fi
|
||||
# Always run helm upgrade when images are rebuilt so that the
|
||||
# OPENSHELL_SANDBOX_IMAGE env var on the server pod is set correctly
|
||||
# and image pull policy is Always (not IfNotPresent from bootstrap).
|
||||
if [[ "${build_server}" == "1" || "${build_sandbox}" == "1" ]]; then
|
||||
if [[ "${build_gateway}" == "1" || "${build_sandbox}" == "1" ]]; then
|
||||
needs_helm_upgrade=1
|
||||
fi
|
||||
|
||||
echo "Fast deploy plan:"
|
||||
echo " build server: ${build_server}"
|
||||
echo " build gateway: ${build_gateway}"
|
||||
echo " build sandbox: ${build_sandbox}"
|
||||
echo " helm upgrade: ${needs_helm_upgrade}"
|
||||
|
||||
if [[ "${explicit_target}" == "0" && "${build_server}" == "0" && "${build_sandbox}" == "0" && "${needs_helm_upgrade}" == "0" && "${DEPLOY_FAST_MODE}" != "full" ]]; then
|
||||
if [[ "${explicit_target}" == "0" && "${build_gateway}" == "0" && "${build_sandbox}" == "0" && "${needs_helm_upgrade}" == "0" && "${DEPLOY_FAST_MODE}" != "full" ]]; then
|
||||
echo "No new local changes since last deploy."
|
||||
fi
|
||||
|
||||
@@ -290,21 +290,21 @@ build_start=$(date +%s)
|
||||
# from the k3s containerd cache after pushing.
|
||||
declare -a built_components=()
|
||||
|
||||
server_pid=""
|
||||
gateway_pid=""
|
||||
sandbox_pid=""
|
||||
build_failed=0
|
||||
|
||||
if [[ "${build_server}" == "1" ]]; then
|
||||
if [[ "${build_gateway}" == "1" ]]; then
|
||||
if [[ "${build_sandbox}" == "1" ]]; then
|
||||
tasks/scripts/docker-build-component.sh server &
|
||||
server_pid=$!
|
||||
tasks/scripts/docker-build-component.sh gateway &
|
||||
gateway_pid=$!
|
||||
else
|
||||
tasks/scripts/docker-build-component.sh server
|
||||
tasks/scripts/docker-build-component.sh gateway
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${build_sandbox}" == "1" ]]; then
|
||||
if [[ -n "${server_pid}" ]]; then
|
||||
if [[ -n "${gateway_pid}" ]]; then
|
||||
tasks/scripts/docker-build-component.sh sandbox --build-arg RUST_BUILD_PROFILE=${RUST_BUILD_PROFILE} &
|
||||
sandbox_pid=$!
|
||||
else
|
||||
@@ -314,25 +314,25 @@ fi
|
||||
|
||||
# Wait for parallel builds and fail fast: if either build fails, kill the
|
||||
# other one immediately instead of letting it run to completion.
|
||||
if [[ -n "${server_pid}" && -n "${sandbox_pid}" ]]; then
|
||||
if [[ -n "${gateway_pid}" && -n "${sandbox_pid}" ]]; then
|
||||
# Both running in parallel — wait for either to finish first.
|
||||
if ! wait -n "${server_pid}" "${sandbox_pid}" 2>/dev/null; then
|
||||
if ! wait -n "${gateway_pid}" "${sandbox_pid}" 2>/dev/null; then
|
||||
build_failed=1
|
||||
fi
|
||||
# Whichever finished, wait for the other (or kill it on failure).
|
||||
if [[ "${build_failed}" == "1" ]]; then
|
||||
echo "Error: a parallel image build failed. Killing remaining build..." >&2
|
||||
kill "${server_pid}" "${sandbox_pid}" 2>/dev/null || true
|
||||
wait "${server_pid}" "${sandbox_pid}" 2>/dev/null || true
|
||||
kill "${gateway_pid}" "${sandbox_pid}" 2>/dev/null || true
|
||||
wait "${gateway_pid}" "${sandbox_pid}" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
# First build succeeded; wait for the second.
|
||||
if ! wait -n "${server_pid}" "${sandbox_pid}" 2>/dev/null; then
|
||||
if ! wait -n "${gateway_pid}" "${sandbox_pid}" 2>/dev/null; then
|
||||
echo "Error: a parallel image build failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [[ -n "${server_pid}" ]]; then
|
||||
wait "${server_pid}"
|
||||
elif [[ -n "${gateway_pid}" ]]; then
|
||||
wait "${gateway_pid}"
|
||||
elif [[ -n "${sandbox_pid}" ]]; then
|
||||
wait "${sandbox_pid}"
|
||||
fi
|
||||
@@ -342,7 +342,7 @@ log_duration "Image builds" "${build_start}" "${build_end}"
|
||||
|
||||
declare -a pushed_images=()
|
||||
|
||||
for component in server sandbox; do
|
||||
for component in gateway sandbox; do
|
||||
var="build_${component//-/_}"
|
||||
if [[ "${!var}" == "1" ]]; then
|
||||
# Tag may fail with AlreadyExists when the image digest hasn't changed;
|
||||
@@ -394,7 +394,7 @@ if [[ "${needs_helm_upgrade}" == "1" ]]; then
|
||||
|
||||
helm upgrade openshell deploy/helm/openshell \
|
||||
--namespace openshell \
|
||||
--set image.repository=${IMAGE_REPO_BASE}/server \
|
||||
--set image.repository=${IMAGE_REPO_BASE}/gateway \
|
||||
--set image.tag=${IMAGE_TAG} \
|
||||
--set image.pullPolicy=Always \
|
||||
--set-string server.grpcEndpoint=https://openshell.openshell.svc.cluster.local:8080 \
|
||||
@@ -431,7 +431,7 @@ if [[ "${explicit_target}" == "0" ]]; then
|
||||
cat > "${DEPLOY_FAST_STATE_FILE}" <<EOF
|
||||
cluster_name=${CLUSTER_NAME}
|
||||
container_id=${current_container_id}
|
||||
server=${current_server_fingerprint}
|
||||
gateway=${current_gateway_fingerprint}
|
||||
sandbox=${current_sandbox_fingerprint}
|
||||
helm=${current_helm_fingerprint}
|
||||
EOF
|
||||
|
||||
@@ -7,15 +7,15 @@ set -euo pipefail
|
||||
|
||||
component=${1:-}
|
||||
if [ -z "${component}" ]; then
|
||||
echo "usage: $0 <server|sandbox>" >&2
|
||||
echo "usage: $0 <gateway|sandbox>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${component}" in
|
||||
server|sandbox)
|
||||
gateway|sandbox)
|
||||
;;
|
||||
*)
|
||||
echo "invalid component '${component}'; expected server or sandbox" >&2
|
||||
echo "invalid component '${component}'; expected gateway or sandbox" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# docker-build-component.sh sandbox nvidia -> Dockerfile.nvidia -> openshell/sandbox-nvidia:dev
|
||||
#
|
||||
# Components without a subdirectory use the flat layout:
|
||||
# docker-build-component.sh server -> Dockerfile.server -> openshell/server:dev
|
||||
# docker-build-component.sh gateway -> Dockerfile.gateway -> openshell/gateway:dev
|
||||
#
|
||||
# Environment:
|
||||
# IMAGE_TAG - Image tag (default: dev)
|
||||
@@ -87,7 +87,7 @@ if [[ ! -f "${DOCKERFILE}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prefix with registry when set (e.g. ghcr.io/org/repo/server:tag).
|
||||
# Prefix with registry when set (e.g. ghcr.io/org/repo/gateway:tag).
|
||||
# Replaces the default "openshell/" prefix with the registry path.
|
||||
if [[ -n "${IMAGE_REGISTRY:-}" ]]; then
|
||||
_suffix="${IMAGE_NAME#openshell/}"
|
||||
@@ -141,7 +141,7 @@ fi
|
||||
VERSION_ARGS=()
|
||||
if [[ -n "${OPENSHELL_CARGO_VERSION:-}" ]]; then
|
||||
VERSION_ARGS=(--build-arg "OPENSHELL_CARGO_VERSION=${OPENSHELL_CARGO_VERSION}")
|
||||
elif [[ "${COMPONENT}" == "server" ]]; then
|
||||
elif [[ "${COMPONENT}" == "gateway" ]]; then
|
||||
CARGO_VERSION=$(uv run python tasks/scripts/release.py get-version --cargo)
|
||||
VERSION_ARGS=(--build-arg "OPENSHELL_CARGO_VERSION=${CARGO_VERSION}")
|
||||
fi
|
||||
|
||||
@@ -157,7 +157,7 @@ resolve_dockerfile() {
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "Building multi-arch component images..."
|
||||
LOCK_HASH=$(sha256_16 Cargo.lock)
|
||||
for component in sandbox server; do
|
||||
for component in sandbox gateway; do
|
||||
echo "Building ${IMAGE_PREFIX}${component} for ${PLATFORMS}..."
|
||||
BUILD_ARGS=""
|
||||
if [ "$component" = "sandbox" ]; then
|
||||
@@ -218,7 +218,7 @@ if [ "$TAG_LATEST" = true ]; then
|
||||
fi
|
||||
|
||||
if [ ${#TAGS_TO_APPLY[@]} -gt 0 ]; then
|
||||
for component in sandbox server cluster; do
|
||||
for component in sandbox gateway cluster; do
|
||||
FULL_IMAGE="${REGISTRY}/${IMAGE_PREFIX:+${IMAGE_PREFIX}}${component}"
|
||||
for tag in "${TAGS_TO_APPLY[@]}"; do
|
||||
if [ "${tag}" = "${IMAGE_TAG}" ]; then
|
||||
@@ -236,7 +236,7 @@ fi
|
||||
echo ""
|
||||
echo "Done! Multi-arch images pushed to ${REGISTRY}:"
|
||||
echo " ${REGISTRY}/${IMAGE_PREFIX}sandbox:${IMAGE_TAG}"
|
||||
echo " ${REGISTRY}/${IMAGE_PREFIX}server:${IMAGE_TAG}"
|
||||
echo " ${REGISTRY}/${IMAGE_PREFIX}gateway:${IMAGE_TAG}"
|
||||
echo " ${REGISTRY}/${IMAGE_PREFIX:+${IMAGE_PREFIX}}cluster:${IMAGE_TAG}"
|
||||
if [ "$TAG_LATEST" = true ]; then
|
||||
echo " (all also tagged :latest)"
|
||||
|
||||
Reference in New Issue
Block a user