Skip to content
Open
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
152 changes: 1 addition & 151 deletions .github/workflows/docker-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,103 +124,6 @@ jobs:
if-no-files-found: error
retention-days: 1

build-operator:
runs-on: ${{ matrix.runner }}
needs: [get-version]
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm

steps:
- name: Set repository and image name to lowercase
run: |
echo "FULL_IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}-operator" >> $GITHUB_ENV

- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV

- name: Checkout repository
uses: actions/checkout@v5

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata for Docker images
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=${{ needs.get-version.outputs.version }}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}

- name: Extract metadata for Docker cache
id: cache-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }}
flavor: |
prefix=cache-operator-${{ matrix.platform }}-
latest=false

- name: Build Docker image
uses: docker/build-push-action@v5
id: build
with:
context: ./operator
file: ./operator/Dockerfile
push: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.FULL_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ steps.cache-meta.outputs.tags }}
cache-to: type=registry,ref=${{ steps.cache-meta.outputs.tags }},mode=max
build-args: |
BUILD_HASH=${{ github.sha }}

- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"

- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-operator-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1

merge-orchestrator-images:
runs-on: ubuntu-latest
needs: [get-version, build-orchestrator]
Expand Down Expand Up @@ -274,63 +177,10 @@ jobs:
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ needs.get-version.outputs.version }}

merge-operator-images:
runs-on: ubuntu-latest
needs: [get-version, build-operator]
permissions:
contents: read
packages: write
steps:
- name: Set repository and image name to lowercase
run: |
echo "FULL_IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}-operator" >> $GITHUB_ENV

- name: Download digests
uses: actions/download-artifact@v5
with:
pattern: digests-operator-*
path: /tmp/digests
merge-multiple: true

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata for Docker images
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.FULL_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=git-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=${{ needs.get-version.outputs.version }}
flavor: |
latest=${{ github.ref == 'refs/heads/main' }}

- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.FULL_IMAGE_NAME }}@sha256:%s ' *)

- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ needs.get-version.outputs.version }}

create-release:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
needs: [get-version, merge-orchestrator-images, merge-operator-images]
needs: [get-version, merge-orchestrator-images]
permissions:
contents: write
steps:
Expand Down
53 changes: 44 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,51 @@ docker run -p 3000:3000 \

**Prerequisites:** Docker running on the host.

### Kubernetes Operator (recommended for clusters)

For Kubernetes deployments, the operator manages `Terminal` custom resources automatically — handling pod creation, storage, and cleanup through CRDs.
### Kubernetes with Agent Sandbox (recommended for clusters)

For Kubernetes deployments, Terminals builds on the upstream
[Agent Sandbox](https://github.com/kubernetes-sigs/agent-sandbox) project (SIG Apps).
Each user+policy maps to a single `Sandbox` custom resource; the agent-sandbox
controller reconciles it into a Pod, a headless Service (a stable `serviceFQDN`),
and a PersistentVolume when a workspace is requested. Idle terminals are
**suspended** (`operatingMode: Suspended`, scale-to-zero with storage and identity
preserved) and resumed on the next request.

Workspace persistence: the per-user `PersistentVolumeClaim` is created from the
Sandbox's `volumeClaimTemplates` and owned by the Sandbox. Suspending keeps the
Sandbox object, so `/workspace` data survives idle and resume. **Tearing a terminal
down deletes the Sandbox and its PVC** (workspace data is removed) — idle reaping uses
suspend, not teardown, so normal inactivity never destroys data.

```bash
# Install the CRD and operator
kubectl apply -f manifests/terminal-crd.yaml
kubectl apply -f manifests/operator-deployment.yaml
# 1. Install the agent-sandbox controller + extensions (pin a release version)
export VERSION="v0.5.0rc1" # see https://github.com/kubernetes-sigs/agent-sandbox/releases
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${VERSION}/manifest.yaml
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${VERSION}/extensions.yaml

# 2. Grant the Terminals service access to the Sandbox CRDs
kubectl apply -f manifests/sandbox-rbac.yaml
```

Set `TERMINALS_BACKEND=kubernetes-operator` when deploying the Terminals service.
Set `TERMINALS_BACKEND=kubernetes-sandbox` when deploying the Terminals service
(with `serviceAccountName: terminals`). For stronger isolation of user code, set
`TERMINALS_SANDBOX_RUNTIME_CLASS=gvisor` (or `kata-qemu`) once the runtime is
installed on your nodes.

> [!NOTE]
> Agent Sandbox is a young upstream project — this backend targets `v1beta1` (v0.5.x);
> pin a release version and track changes. Two trade-offs to be aware of: the per-user
> API key lives as a plaintext env value in the `Sandbox` pod template (rely on RBAC +
> etcd encryption-at-rest), and **idle tracking is in-memory** in the Terminals process
> (run a single Terminals replica, or expect idle to be tracked per-replica). Warm pools
> are intentionally not used — they are mutually exclusive with per-user API keys (an
> env-injecting claim bypasses the pool), so first connection pays pod start-up (the
> image is cached on the node after the first pull).
>
> These last two are things the backend self-manages **because the controller does not
> yet**. Both are on the [upstream roadmap](https://github.com/kubernetes-sigs/agent-sandbox/blob/main/roadmap.md)
> (*Auto Suspend/Resume*, *Scale to Zero*, *Sandbox/Pod Identity Association*); the
> backend is pinned to `v1beta1` specifically so we can drop these shims as they land.

### From source (development)

Expand All @@ -54,7 +88,7 @@ terminals serve
| Backend | Best for | How it works |
|---------|----------|-------------|
| `docker` | Single-node, local dev | One container per user via Docker socket |
| `kubernetes-operator` | Production K8s clusters | Operator watches `Terminal` CRDs for automated lifecycle |
| `kubernetes-sandbox` | Production K8s clusters | One [Agent Sandbox](https://github.com/kubernetes-sigs/agent-sandbox) `Sandbox` per user; suspend/resume on idle |
| `kubernetes` | K8s without CRDs | Direct Pod + PVC + Service per user (you manage resources) |

Set the backend with `TERMINALS_BACKEND` (defaults to `docker`).
Expand Down Expand Up @@ -109,14 +143,15 @@ All settings are configured through environment variables prefixed with `TERMINA

| Variable | Default | Description |
|----------|---------|-------------|
| `TERMINALS_BACKEND` | `docker` | `docker`, `kubernetes`, or `kubernetes-operator` |
| `TERMINALS_BACKEND` | `docker` | `docker`, `kubernetes`, or `kubernetes-sandbox` |
| `TERMINALS_API_KEY` | *(auto-generated)* | Bearer token for API auth |
| `TERMINALS_IMAGE` | `ghcr.io/open-webui/open-terminal:latest` | Default container image |
| `TERMINALS_MAX_CPU` | | Hard cap on CPU per container |
| `TERMINALS_MAX_MEMORY` | | Hard cap on memory per container |
| `TERMINALS_MAX_STORAGE` | | Hard cap on storage per container |
| `TERMINALS_ALLOWED_IMAGES` | | Comma-separated list of allowed image patterns |
| `TERMINALS_KUBERNETES_STORAGE_MODE` | `per-user` | `per-user`, `shared`, or `shared-rwo` |
| `TERMINALS_SANDBOX_RUNTIME_CLASS` | | RuntimeClass for sandbox isolation, e.g. `gvisor` or `kata-qemu` |

See [`config.py`](terminals/config.py) for the full list.

Expand Down
79 changes: 0 additions & 79 deletions manifests/operator-deployment.yaml

This file was deleted.

50 changes: 50 additions & 0 deletions manifests/sandbox-rbac.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# RBAC for the Terminals service to drive Agent Sandbox CRDs.
#
# Apply AFTER installing the agent-sandbox controller + extensions:
# kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/<VER>/manifest.yaml
# kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/<VER>/extensions.yaml
# kubectl apply -f manifests/sandbox-rbac.yaml
#
# The Terminals Deployment must set `serviceAccountName: terminals` and
# `TERMINALS_BACKEND=kubernetes-sandbox`.
---
apiVersion: v1
kind: Namespace
metadata:
name: terminals
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: terminals
namespace: terminals
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: terminals-sandbox
namespace: terminals
rules:
# Core Sandbox resource: create/delete per user, patch replicas for
# suspend/resume, and read status (serviceFQDN, Ready condition).
- apiGroups: ["agents.x-k8s.io"]
resources: ["sandboxes", "sandboxes/status"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Read-only access to the Services/Pods the controller creates.
- apiGroups: [""]
resources: ["services", "pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: terminals-sandbox
namespace: terminals
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: terminals-sandbox
subjects:
- kind: ServiceAccount
name: terminals
namespace: terminals
Loading