Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Integration

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
local:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Build harness CLI
run: make cli

- name: Install openshell
run: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh

- name: Wait for gateway
run: |
for i in $(seq 1 30); do
openshell inference get &>/dev/null && break
sleep 1
done
openshell gateway list

- name: Run integration tests
run: ./test/test-flow.sh local --full --no-providers --profile=ci

- name: Export gateway logs
if: always()
run: |
mkdir -p /tmp/gateway-logs
journalctl --user -u openshell-gateway --no-pager > /tmp/gateway-logs/gateway.log 2>/dev/null || true
openshell status > /tmp/gateway-logs/status.txt 2>&1 || true
openshell gateway list > /tmp/gateway-logs/gateways.txt 2>&1 || true

- uses: actions/upload-artifact@v4
if: always()
with:
name: gateway-logs-local
path: /tmp/gateway-logs/
14 changes: 7 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ SANDBOX_IMAGE := $(REGISTRY):sandbox
LAUNCHER_IMAGE := $(REGISTRY):launcher

.PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \
vet lint test-unit test test-podman test-ocp test-all validate clean help
vet lint test-unit test test-local test-ocp test-all validate clean help

## ── CLI ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -75,9 +75,9 @@ test-unit:
test: cli sandbox push-launcher
./test/test-flow.sh all --full

## Podman only (full lifecycle)
test-podman: cli sandbox push-launcher
./test/test-flow.sh podman --full
## Local gateway (full lifecycle)
test-local: cli
./test/test-flow.sh local --full

## OCP only (full lifecycle)
test-ocp: cli sandbox push-launcher
Expand All @@ -87,7 +87,7 @@ test-ocp: cli sandbox push-launcher
test-all: cli sandbox push-launcher
./test/test-flow.sh all --full

## Full validation: unit tests + bats + integration (podman + OCP)
## Full validation: unit tests + bats + integration (local + OCP)
## Run this before every commit.
validate: cli sandbox push-launcher
@echo "=== Unit tests ==="
Expand All @@ -97,8 +97,8 @@ validate: cli sandbox push-launcher
@echo "=== Bats ==="
bats test/preflight.bats
@echo ""
@echo "=== Integration: podman ==="
./test/test-flow.sh podman --full
@echo "=== Integration: local ==="
./test/test-flow.sh local --full
@echo ""
@echo "=== Integration: OCP ==="
./test/test-flow.sh ocp --full
Expand Down
10 changes: 5 additions & 5 deletions cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.R
"--clusterrole=cluster-admin",
"--serviceaccount=agent-sandbox-system:agent-sandbox-controller")

// RBAC for launcher (from deploy/rbac.yaml)
if err := kc.RunKubectlPassthrough(ctx, "apply", "-f", filepath.Join(harnessDir, "deploy", "rbac.yaml")); err != nil {
// RBAC for launcher
if err := kc.RunKubectlPassthrough(ctx, "apply", "-f", filepath.Join(harnessDir, "gateways", "ocp", "addons", "rbac.yaml")); err != nil {
return fmt.Errorf("applying launcher RBAC: %w", err)
}

Expand All @@ -125,7 +125,7 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.R
helmArgs := []string{
"upgrade", "--install", "openshell", chartOCI,
"--version", chartVersion,
"--values", filepath.Join(harnessDir, "values-ocp.yaml"),
"--values", filepath.Join(harnessDir, "gateways", "ocp", "helm", "values.yaml"),
"--set", "server.sandboxImage=" + sandboxImage,
"--set", "pkiInitJob.serverDnsNames[0]=" + routeHost,
}
Expand All @@ -145,10 +145,10 @@ func deployRemote(harnessDir string, gw gateway.Gateway, kc, clusterRunner k8s.R
return fmt.Errorf("gateway rollout failed: %w", err)
}

// Step 5: Route (from deploy/route.yaml)
// Step 5: Route
status.Step(5, "Creating OpenShift route")
if err := kc.RunKubectlQuiet(ctx, "get", "route", "gateway"); err != nil {
kc.RunKubectlPassthrough(ctx, "apply", "-f", filepath.Join(harnessDir, "deploy", "route.yaml"))
kc.RunKubectlPassthrough(ctx, "apply", "-f", filepath.Join(harnessDir, "gateways", "ocp", "addons", "route.yaml"))
}
fmt.Printf(" Route: %s\n", routeHost)

Expand Down
16 changes: 12 additions & 4 deletions cmd/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ func newRemote(harnessDir string, gw gateway.Gateway, profileName, sandboxName s
"restartPolicy": "Never",
"containers": []map[string]any{{
"name": "launcher",
"image": "ghcr.io/robbycochran/harness-openshell:launcher",
"image": envOr("LAUNCHER_IMAGE", "ghcr.io/robbycochran/harness-openshell:launcher"),
"imagePullPolicy": "Always",
"env": []map[string]any{
{"name": "GATEWAY_ENDPOINT", "value": "https://openshell.openshell.svc.cluster.local:8080"},
Expand Down Expand Up @@ -332,10 +332,18 @@ func newLocal(opts newLocalOpts) error {

// 6. Build command
var sandboxCmd []string
if opts.noTTY {
sandboxCmd = []string{"bash", "/sandbox/startup.sh"}
if cfg.Startup != "" {
if opts.noTTY {
sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". %s", cfg.Startup)}
} else {
sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". %s && exec %s", cfg.Startup, cfg.Command)}
}
} else {
sandboxCmd = []string{"bash", "-c", fmt.Sprintf(". /sandbox/startup.sh && exec %s", cfg.Command)}
if opts.noTTY {
sandboxCmd = []string{"true"}
} else {
sandboxCmd = []string{"bash", "-c", fmt.Sprintf("exec %s", cfg.Command)}
}
}

// 7. Create sandbox with retry
Expand Down
187 changes: 187 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,193 @@ point proto types ARE the request payloads and the migration pays for itself.
TOML into proto-generated structs internally with a ~50-line mapping layer.
Users keep writing TOML. No textproto, no format change.

## Gateway configs

Gateway configs describe how to deploy and connect to a gateway. Each
config is a directory under `gateways/` with a consistent structure:

```
gateways/
local/
gateway.toml # type = "local", no deploy needed
kind/
gateway.toml # type = "remote", platform = "k8s", NodePort
helm/values.yaml # Helm overrides for kind
ocp/
gateway.toml # type = "remote", platform = "ocp", Route
helm/values.yaml # Helm overrides for OpenShift
addons/
rbac.yaml # launcher ServiceAccount + Role
route.yaml # OpenShift Route for external access
```

### `gateway.toml` schema

```toml
[gateway]
type = "remote" # "local" or "remote"
platform = "ocp" # "ocp", "k8s", or empty (auto-detect)
service = "route" # "route", "nodeport", "loadbalancer"

[helm]
values = "values.yaml" # relative to helm/ subdir

[addons]
manifests = [ # applied after Helm install
"addons/rbac.yaml",
"addons/route.yaml",
]
```

### Deploy behavior by config

`harness deploy [GATEWAY_NAME]` reads the gateway config and acts on it:

| Field | Effect |
|-------|--------|
| `type = "local"` | Verify local gateway is running, skip Helm |
| `platform = "ocp"` | Run SCC grants via `oc`, query appsDomain |
| `platform = "k8s"` | Skip SCCs, use standard k8s networking |
| `service = "route"` | Create OpenShift Route, derive endpoint from appsDomain |
| `service = "nodeport"` | Extract NodePort, configure endpoint as `host:port` |
| `service = "loadbalancer"` | Wait for external IP, configure endpoint |
| `helm.values` | Pass `--values` to Helm install |
| `addons.manifests` | `kubectl apply -f` each manifest after Helm |

### Sandbox profiles vs gateway configs

Two orthogonal axes — *what* to run vs *where* to run it:

```
gateway config (where) × sandbox profile (what)
────────────────────── ──────────────────────
local default (full tooling)
kind ci (minimal, no providers)
ocp research (different model)
k8s-gke ...
```

Any sandbox profile works on any gateway. The harness resolves the
active gateway and creates the sandbox through it.

### Custom gateways

To add a new gateway target: copy an existing directory, edit
`gateway.toml`, add Helm values and addon manifests as needed.
No code changes required.

## Container runtime

The harness doesn't specify or manage the container runtime (podman vs
docker). The openshell gateway auto-detects the driver at startup
(Kubernetes → Podman → Docker). The driver can be overridden via:

```
OPENSHELL_DRIVERS=podman openshell-gateway
OPENSHELL_DRIVERS=docker openshell-gateway
```

Or in a gateway config file (`--config` / `OPENSHELL_GATEWAY_CONFIG`).

The harness refers to "local" (vs "remote/OCP") rather than "podman"
since the runtime is an openshell concern, not a harness concern.

The driver is not exposed via the gRPC API (by design — clients are
abstracted from the compute layer). However, the gateway logs the
driver at startup:

```
INFO openshell_server: Using compute driver driver=podman
INFO openshell_driver_podman::driver: Connected to Podman cgroup_version=v2 ...
```

Log location (macOS/Homebrew):
`/opt/homebrew/var/log/openshell/openshell-gateway.out.log`

`harness status` can grep the gateway log for "Using compute driver"
to report the active driver. This is best-effort — if the log is
unavailable, status just omits the driver line.

## OCP vs vanilla Kubernetes

The upstream openshell Helm chart is vanilla-k8s-first. OpenShift is the
variant that requires extra steps. The harness handles both via gateway
configs.

### What OCP needs beyond vanilla k8s

| Step | OCP | Vanilla k8s | Why |
|------|-----|-------------|-----|
| **SCCs** | Grant `privileged` SCC to openshell SAs via `oc adm policy` | Not needed — no SCC concept | OCP blocks pods from running as certain UIDs by default |
| **Helm values** | Null out `runAsUser` and `runAsNonRoot` (SCC assigns UID) | Chart defaults work (`runAsUser: 1000`) | OCP SCC admission rejects the chart's hardcoded security context |
| **Networking** | OpenShift Route (TLS passthrough) | Ingress, LoadBalancer, or NodePort | Route is OCP-only; vanilla k8s uses standard networking |
| **Apps domain** | Query `ingresses.config.openshift.io/cluster` for wildcard domain | User-provided or derived from service | OCP-only API |
| **PKI init job** | Works if privileged SCC is granted | Works by default | OCP's SCC would block it without the grant |

### What's the same on both

- Namespace creation with Pod Security Standards labels
- Sandbox CRD installation (kubernetes-sigs/agent-sandbox)
- Helm chart install (same chart, different values)
- RBAC for launcher (standard k8s ServiceAccount + Role)
- mTLS certificate generation and extraction
- Launcher Job spec
- Provider registration and sandbox creation

## Security: TLS and authentication

### Three network paths

```
laptop ──[mTLS over Route/Ingress]──▶ gateway (in cluster)
launcher Job ──[mTLS over cluster DNS]──▶ gateway (in cluster)
gateway ──[container runtime]──▶ sandbox pod
```

All external traffic is mTLS-encrypted. The launcher Job mounts the
`openshell-client-tls` Secret for in-cluster mTLS to the gateway.

### Auth roadmap

| Stage | Auth mechanism | Use case |
|-------|---------------|----------|
| **Now** | mTLS certificates extracted from cluster | Single user, CI |
| **Next** | oauth-proxy sidecar + OpenShift OAuth | Team — OCP users log in with cluster creds |
| **Future** | OIDC (Keycloak, Dex, external IdP) | Multi-cluster, external users |

### Current approach: mTLS-as-auth

The Helm chart's PKI init job generates server and client certificates.
The harness extracts the client cert from `openshell-client-tls` Secret
and configures the CLI with it. mTLS serves as both transport encryption
and user authentication.

This is secure (encrypted + authenticated) but single-user — whoever has
the client cert has full access.

### Next: oauth-proxy sidecar on OCP

OpenShift has a built-in OAuth server. The cluster already has it running
(`openshift-authentication` namespace). Any user who can `oc login` can
authenticate.

The approach:
1. Deploy an oauth-proxy sidecar alongside the gateway (addon manifest)
2. Route points at the proxy port, proxy forwards to gateway
3. Gateway runs with `allowUnauthenticatedUsers: true` (trusts the proxy)
4. CLI registers with bare `https://` (browser-based login flow)
5. No external IdP needed — uses the cluster's own user directory

This requires no internal approval for new services — oauth-proxy is a
standard OCP pattern and the OAuth server is already running.

### Future: OIDC

For production multi-cluster deployments, configure the gateway with
`--oidc-issuer` pointing at Keycloak, Dex, Okta, or any OIDC provider.
The CLI uses `--oidc-issuer` and `--oidc-client-id` on `gateway add`
and does a browser-based OIDC login flow.

## Open questions

- Should `harness init` (from release-plan.md) be a separate command or
Expand Down
16 changes: 16 additions & 0 deletions gateways/kind/gateway.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# kind gateway — deploys openshell to a local kind cluster.
#
# Prerequisites:
# - kind installed
# - Cluster created: kind create cluster --name openshell
#
# Uses NodePort for access (no Ingress controller needed).
# No user authentication — suitable for CI and local dev only.

[gateway]
type = "remote"
platform = "k8s"
service = "nodeport"

[helm]
# Chart defaults work for vanilla k8s — no values override needed.
11 changes: 11 additions & 0 deletions gateways/kind/helm/values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# kind gateway Helm values.
#
# Chart defaults are vanilla-k8s-friendly — this file only overrides
# what's needed for kind specifically.

service:
type: NodePort

server:
auth:
allowUnauthenticatedUsers: true
10 changes: 10 additions & 0 deletions gateways/local/gateway.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Local gateway — runs on your machine via podman or docker.
#
# The openshell gateway must be installed and running:
# macOS: brew install openshell && brew services start openshell
# Linux: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh
#
# The harness verifies the gateway is reachable but does not manage its lifecycle.

[gateway]
type = "local"
File renamed without changes.
File renamed without changes.
Loading
Loading