mirror of
https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git
synced 2026-11-03 01:13:24 +00:00
Compare commits
20 Commits
dsh-v0.2.1
...
chore/enti
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a585e6be13 | ||
|
|
c5b8afde48 | ||
|
|
8e9bd478b4 | ||
|
|
1a929e3e24 | ||
|
|
2f959e6afb | ||
|
|
4c073eef71 | ||
|
|
24f23e09ae | ||
|
|
82ee0d0cdb | ||
|
|
a09d4ac340 | ||
|
|
367294b230 | ||
|
|
cbcc56f97e | ||
|
|
8c34f43789 | ||
|
|
97c563296a | ||
|
|
dcb5b436f6 | ||
|
|
b57916d854 | ||
|
|
bed8e5675a | ||
|
|
14c3fb840e | ||
|
|
96d3030a49 | ||
|
|
2b43d5dfd5 | ||
|
|
97abf75dfe |
@@ -104,8 +104,9 @@ Verified from the upstream server:
|
|||||||
`shutdown_after_seconds` defaults to 300 s (`null` disables it).
|
`shutdown_after_seconds` defaults to 300 s (`null` disables it).
|
||||||
- **Output:** servers before v1.0.8 return unbounded `execute_command` output.
|
- **Output:** servers before v1.0.8 return unbounded `execute_command` output.
|
||||||
v1.0.8 adds `max_output_chars` (128-300,000, default 30,000) and truncation
|
v1.0.8 adds `max_output_chars` (128-300,000, default 30,000) and truncation
|
||||||
metadata. Never pass the new argument to an older server; redirect verbose
|
metadata. The repository pins v1.0.8 (`AGENT_SANDBOX_VERSION`), so the
|
||||||
output and read a file instead.
|
documented deployment is bounded; never pass the new argument to an older
|
||||||
|
server, and on one redirect verbose output and read a file instead.
|
||||||
|
|
||||||
Deployment, connection matrix and DSH row:
|
Deployment, connection matrix and DSH row:
|
||||||
[MCP deployment](references/mcp-deployment.md). Limits and cleanup:
|
[MCP deployment](references/mcp-deployment.md). Limits and cleanup:
|
||||||
@@ -125,13 +126,14 @@ Read `docs/examples.md` and the testing table in `AGENTS.md` before editing.
|
|||||||
alternatives, never a stack.
|
alternatives, never a stack.
|
||||||
- **New example:** follow `docs/examples.md`, add the index entry, keep scripts
|
- **New example:** follow `docs/examples.md`, add the index entry, keep scripts
|
||||||
executable/idempotent/self-cleaning, run `make check`.
|
executable/idempotent/self-cleaning, run `make check`.
|
||||||
- **Version bump:** edit `versions.env` only, update prerequisites, run
|
- **Version bump:** use `make set-version`; never edit pins by hand. Update
|
||||||
`make check` and the e2e. Never mix a bump with an unrelated feature.
|
prerequisites, run `make check` and the e2e on a cluster. Never mix a bump
|
||||||
|
with an unrelated feature.
|
||||||
- **MCP integration:** pin the build to the cluster's tag, least-privilege SA,
|
- **MCP integration:** pin the build to the cluster's tag, least-privilege SA,
|
||||||
ClusterIP + auth proxy, raised DSH timeout, no secrets in the repo.
|
ClusterIP + auth proxy, raised DSH timeout, no secrets in the repo.
|
||||||
- **Optional hardening:** gVisor, `nodeSelector`, NetworkPolicy and extra
|
- **Optional hardening:** gVisor, `nodeSelector`, NetworkPolicy, extra
|
||||||
provider keys stay opt-in; the sandbox policy lives under
|
provider keys and failed-Pod recovery stay opt-in; the sandbox policy lives
|
||||||
`manifests/optional/`. All four are documented in
|
under `manifests/optional/`. All of them are documented in
|
||||||
`examples/dsh-agents-as-a-service/docs/optional-hardening.md`.
|
`examples/dsh-agents-as-a-service/docs/optional-hardening.md`.
|
||||||
- **Docs:** update the affected doc in the same change.
|
- **Docs:** update the affected doc in the same change.
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ DSH template.
|
|||||||
Never pass `max_output_chars` to a pre-v1.0.8 server. On older servers, redirect
|
Never pass `max_output_chars` to a pre-v1.0.8 server. On older servers, redirect
|
||||||
verbose output to a file and `download_file` it, or pipe through `tail`/`head`.
|
verbose output to a file and `download_file` it, or pipe through `tail`/`head`.
|
||||||
|
|
||||||
|
The repository pins `AGENT_SANDBOX_VERSION=v1.0.8`, so the right-hand column is
|
||||||
|
what the documented platform and a same-tag MCP build provide; keep the
|
||||||
|
before-column for clusters still on v1.0.7.
|
||||||
|
|
||||||
## Safety and cleanup
|
## Safety and cleanup
|
||||||
|
|
||||||
- No auth: anyone reaching `:8000` gets every tool and the service account's
|
- No auth: anyone reaching `:8000` gets every tool and the service account's
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ a missing CRD is a prerequisite failure, not an application bug.
|
|||||||
| `SandboxWarmPool` | Pre-warmed spares from one template | `spec.replicas`, `sandboxTemplateRef` |
|
| `SandboxWarmPool` | Pre-warmed spares from one template | `spec.replicas`, `sandboxTemplateRef` |
|
||||||
| `SandboxClaim` | One tenant/user request; adopts a spare | `spec.warmPoolRef`, `additionalPodMetadata`, `status.sandbox.name` |
|
| `SandboxClaim` | One tenant/user request; adopts a spare | `spec.warmPoolRef`, `additionalPodMetadata`, `status.sandbox.name` |
|
||||||
| `Sandbox` | Adopted or directly created pod + Service + PVC | `spec.operatingMode`, `status.conditions`, `status.serviceFQDN`, `status.podIPs` |
|
| `Sandbox` | Adopted or directly created pod + Service + PVC | `spec.operatingMode`, `status.conditions`, `status.serviceFQDN`, `status.podIPs` |
|
||||||
| `Pod` | The DSH Web process | `:3080`, PVC at `/home/node/.dsh` |
|
| `Pod` | The DSH Web process | `:3080`, PVC at `/home/node/.dsh` and `/workspace` (`subPath: workspace`) |
|
||||||
| `PersistentVolumeClaim` | Per-sandbox state | `dsh-home-<sandbox>` |
|
| `PersistentVolumeClaim` | Per-sandbox DSH state + agent workspace | `dsh-home-<sandbox>` |
|
||||||
| `Service` | Stable per-sandbox DNS | `<sandbox>`, `status.serviceFQDN` |
|
| `Service` | Stable per-sandbox DNS | `<sandbox>`, `status.serviceFQDN` |
|
||||||
|
|
||||||
Delete a claim to cascade-delete its sandbox and PVC (account deletion). Never
|
Delete a claim to cascade-delete its sandbox and PVC (account deletion). Never
|
||||||
@@ -123,6 +123,25 @@ kubectl -n "$NS" patch sandboxwarmpool "$POOL" --type merge -p '{"spec":{"replic
|
|||||||
`0600`; the template uses `fsGroupChangePolicy: OnRootMismatch`. If a resumed
|
`0600`; the template uses `fsGroupChangePolicy: OnRootMismatch`. If a resumed
|
||||||
sandbox crash-loops with a mode-660 error, suspend it, fix the file with a
|
sandbox crash-loops with a mode-660 error, suspend it, fix the file with a
|
||||||
throwaway pod, patch the policy, then resume.
|
throwaway pod, patch the policy, then resume.
|
||||||
|
- Agent files live at `/workspace`, the same PVC mounted with
|
||||||
|
`subPath: workspace`; it survives suspend/resume and dies with the PVC.
|
||||||
|
- The templates set `HOME=/workspace` (the image default is `/home/node`). DSH's
|
||||||
|
directory picker lists `$HOME` when it opens and has no start-path setting, so
|
||||||
|
this is what makes the folder explorer start on the persisted workspace; `~`
|
||||||
|
and `$HOME`-relative tool caches/config also resolve there. `DSH_HOME` remains
|
||||||
|
`/home/node/.dsh`, so state and credentials stay out of the workspace. Pod env
|
||||||
|
is stamped at sandbox creation: a sandbox created before the override keeps
|
||||||
|
the old `HOME` until it is recreated (or its `podTemplate` is patched and the
|
||||||
|
sandbox resumed). Verify with
|
||||||
|
`kubectl -n "$NS" exec "$POD" -- printenv HOME`.
|
||||||
|
- DSH confines bash with its own file sandbox (`bubblewrap`, else Landlock) and
|
||||||
|
fails closed with `SANDBOX_UNAVAILABLE` when neither works. The image ships
|
||||||
|
`bubblewrap`, but it is unsetuid and needs an unprivileged user namespace that
|
||||||
|
this hardened pod (all capabilities dropped, RuntimeDefault seccomp) does not
|
||||||
|
grant; that plus no Landlock runner is why the template sets
|
||||||
|
`DSH_PERMISSION_MODE=danger-full-access` and treats the pod as the boundary.
|
||||||
|
Never "fix" it by granting `CAP_SYS_ADMIN` or `seccompProfile: Unconfined`
|
||||||
|
without an explicit decision.
|
||||||
|
|
||||||
## 8. Gateway layer
|
## 8. Gateway layer
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ events, then compare desired vs observed state. Keep output bounded. Never
|
|||||||
| `ImagePullBackOff` | describe events | Wrong image/tag, private GHCR package, missing pull secret; on kind, load the image under the exact manifest ref. |
|
| `ImagePullBackOff` | describe events | Wrong image/tag, private GHCR package, missing pull secret; on kind, load the image under the exact manifest ref. |
|
||||||
| `CrashLoopBackOff`, probe 401 | logs, `describe` | Probe path is `/`; use `/favicon.svg`. |
|
| `CrashLoopBackOff`, probe 401 | logs, `describe` | Probe path is `/`; use `/favicon.svg`. |
|
||||||
| Restart with mode-660 error | logs mention `.credentials.yaml` | kubelet fsGroup widened the file; suspend, `chmod 600` from a throwaway pod, patch `fsGroupChangePolicy: OnRootMismatch`, resume. |
|
| Restart with mode-660 error | logs mention `.credentials.yaml` | kubelet fsGroup widened the file; suspend, `chmod 600` from a throwaway pod, patch `fsGroupChangePolicy: OnRootMismatch`, resume. |
|
||||||
|
| Every bash command fails with `SANDBOX_UNAVAILABLE` | DSH tool result names the requested mode and backends | DSH's own command sandbox has no usable runner: `bubblewrap` ships in the image but is unsetuid and needs an unprivileged user namespace this hardened pod does not grant, and no Landlock runner is available. The example template sets `DSH_PERMISSION_MODE=danger-full-access`; do not grant capabilities to restore it unless the operator explicitly accepts that downgrade. |
|
||||||
|
| Folder picker opens on `/home/node`, not the workspace | `kubectl exec <pod> -- printenv HOME` | The sandbox predates the template's `HOME=/workspace` and pod env is stamped at creation. Recreate the sandbox (PVC survives) or patch its `podTemplate` with `HOME`, then resume. Workspace dotfiles (`.cache`, `.config`, `.npm`, `.agents`) are the expected trade-off of the override, not a fault. |
|
||||||
| PVC `Pending` | `kubectl describe pvc`, StorageClass | No default provisioner, access mode, zone binding. |
|
| PVC `Pending` | `kubectl describe pvc`, StorageClass | No default provisioner, access mode, zone binding. |
|
||||||
| Launch token not found | `kubectl logs <pod> --tail=2000 | grep 'dsh web:'` | Pod not ready yet, or logs rotated. Do not guess tokens; wait for readiness. |
|
| Launch token not found | `kubectl logs <pod> --tail=2000 | grep 'dsh web:'` | Pod not ready yet, or logs rotated. Do not guess tokens; wait for readiness. |
|
||||||
|
| Sandbox reports `Finished=True` | pod phase, `kubectl get events` | The backing Pod reached `Failed` (eviction, spot reclaim) and `failedPodPolicy` defaults to `Retain`. Delete the Pod to force a replacement, or set `Recreate` (v1.0.8+) on the Sandbox/template; the claim and PVCs survive either way. |
|
||||||
| Hand-applied sandbox disappeared | claim annotations, gateway logs | Idle sweeper only manages claims with `aaas.example.com/managed-by=dsh-gateway`; if it was managed, recreate/resume. |
|
| Hand-applied sandbox disappeared | claim annotations, gateway logs | Idle sweeper only manages claims with `aaas.example.com/managed-by=dsh-gateway`; if it was managed, recreate/resume. |
|
||||||
| Gateway 401 | gateway logs, cookie flags | Missing/expired cookie, DSH handshake failed, or `COOKIE_SECURE=true` over HTTP. |
|
| Gateway 401 | gateway logs, cookie flags | Missing/expired cookie, DSH handshake failed, or `COOKIE_SECURE=true` over HTTP. |
|
||||||
| Gateway 502/503 | sandbox Ready, gateway logs | Wake timeout, stale DNS, token not yet in logs; check `WAKE_TIMEOUT` and RBAC `pods/log`. |
|
| Gateway 502/503 | sandbox Ready, gateway logs | Wake timeout, stale DNS, token not yet in logs; check `WAKE_TIMEOUT` and RBAC `pods/log`. |
|
||||||
|
|||||||
5
.entire/.gitignore
vendored
Normal file
5
.entire/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
tmp/
|
||||||
|
settings.local.json
|
||||||
|
metadata/
|
||||||
|
logs/
|
||||||
|
redactors/local/
|
||||||
9
.entire/settings.json
Normal file
9
.entire/settings.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"telemetry": true,
|
||||||
|
"checkpoints": {
|
||||||
|
"primary": {
|
||||||
|
"type": "git-refs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
.env.sample
34
.env.sample
@@ -1,4 +1,6 @@
|
|||||||
# Copy to .env and fill in. Never commit a real key.
|
# Reference environment names for local runs. Nothing sources this file
|
||||||
|
# automatically: export the values or pass them with `docker run -e/--env-file`.
|
||||||
|
# Copy it to .env if you prefer --env-file. Never commit a real key.
|
||||||
#
|
#
|
||||||
# The version values below are documentation only: versions.env is the single
|
# The version values below are documentation only: versions.env is the single
|
||||||
# source of truth, and scripts/check-versions.sh fails if they drift.
|
# source of truth, and scripts/check-versions.sh fails if they drift.
|
||||||
@@ -20,8 +22,11 @@ TOGETHER_API_KEY=
|
|||||||
CEREBRAS_API_KEY=
|
CEREBRAS_API_KEY=
|
||||||
AI_GATEWAY_API_KEY=
|
AI_GATEWAY_API_KEY=
|
||||||
HF_TOKEN=
|
HF_TOKEN=
|
||||||
DSH_VERSION=dsh-v0.2.1-rc.3
|
DSH_VERSION=dsh-v0.2.1-rc.5
|
||||||
RELEASE_REVISION=r4
|
# Full upstream commit SHA that DSH_VERSION resolves to; the main image build
|
||||||
|
# fails if the clone's HEAD differs (guards against a moved tag).
|
||||||
|
DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1
|
||||||
|
RELEASE_REVISION=r6
|
||||||
PNPM_VERSION=11.28.5
|
PNPM_VERSION=11.28.5
|
||||||
# Container port, and the host port published by "make run".
|
# Container port, and the host port published by "make run".
|
||||||
DSH_PORT=3080
|
DSH_PORT=3080
|
||||||
@@ -29,22 +34,7 @@ DSH_PORT=3080
|
|||||||
# public hostname). localhost and 127.0.0.1 are always trusted.
|
# public hostname). localhost and 127.0.0.1 are always trusted.
|
||||||
DSH_TRUSTED_HOSTS=
|
DSH_TRUSTED_HOSTS=
|
||||||
|
|
||||||
# --- agents-as-a-service example ---
|
# The example gateway's runtime settings (NAMESPACE, POOL, IDLE_TIMEOUT,
|
||||||
NAMESPACE=dsh-demo
|
# WAKE_TIMEOUT, COOKIE_*, AUTH_PROXY_HEADER, ...) are documented in
|
||||||
POOL=dsh-pool
|
# examples/dsh-agents-as-a-service/gateway/README.md and set in
|
||||||
# Seconds a sandbox may stay idle before the sweeper suspends it, and the max
|
# examples/dsh-agents-as-a-service/manifests/50-gateway.yaml.
|
||||||
# time a proxied request waits for a suspended sandbox to become Ready.
|
|
||||||
IDLE_TIMEOUT=360
|
|
||||||
WAKE_TIMEOUT=240
|
|
||||||
DSH_CONTAINER=dsh-web
|
|
||||||
DSH_LOG_TAIL_LINES=2000
|
|
||||||
COOKIE_NAME=dsh_gw_session
|
|
||||||
# Set to true only behind HTTPS; secure cookies are not sent over plain HTTP
|
|
||||||
# (localhost counts as a secure context in browsers).
|
|
||||||
COOKIE_SECURE=false
|
|
||||||
COOKIE_MAX_AGE=604800
|
|
||||||
MAX_BODY_MB=32
|
|
||||||
HTTP_TIMEOUT=300
|
|
||||||
# SSO is opt-in. Set this only when the gateway Service is reachable
|
|
||||||
# exclusively through oauth2-proxy (see the example exposure docs).
|
|
||||||
# AUTH_PROXY_HEADER=X-Forwarded-User
|
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -28,7 +28,7 @@ body:
|
|||||||
id: image-tag
|
id: image-tag
|
||||||
attributes:
|
attributes:
|
||||||
label: Image or release
|
label: Image or release
|
||||||
description: The pinned tag or digest, for example dsh-v0.2.1-rc.3-r4.
|
description: The tag or digest you deployed (from the manifest or release metadata).
|
||||||
- type: input
|
- type: input
|
||||||
id: cluster
|
id: cluster
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
3
.github/pull_request_template.md
vendored
3
.github/pull_request_template.md
vendored
@@ -19,8 +19,7 @@
|
|||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] make check passes (versions, example layout, manifest YAML)
|
- [ ] make check passes (versions, example layout, manifest YAML)
|
||||||
- [ ] make test passes (gateway unit tests)
|
- [ ] make test / make lint pass when the change touches code (gateway unit tests, ruff, shellcheck; yamllint/actionlint where installed)
|
||||||
- [ ] make lint passes (ruff, shellcheck; yamllint/actionlint where installed)
|
|
||||||
- [ ] Docs updated for the change (root README, docs/, example README)
|
- [ ] Docs updated for the change (root README, docs/, example README)
|
||||||
- [ ] No secrets or usable credentials committed
|
- [ ] No secrets or usable credentials committed
|
||||||
- [ ] CHANGELOG.md was not edited by hand (generated by git-cliff)
|
- [ ] CHANGELOG.md was not edited by hand (generated by git-cliff)
|
||||||
|
|||||||
30
.github/workflows/_build-image.yaml
vendored
30
.github/workflows/_build-image.yaml
vendored
@@ -53,6 +53,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Prepare environment
|
- name: Prepare environment
|
||||||
env:
|
env:
|
||||||
@@ -60,14 +62,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-')
|
pair=$(printf '%s' "${{ matrix.platform }}" | tr '/' '-')
|
||||||
echo "PLATFORM_PAIR=$pair" >> "$GITHUB_ENV"
|
# This reusable workflow runs once for the main image and once for the
|
||||||
echo "CACHE_SCOPE=$(printf '%s' "$IMAGE" | tr '/' '-')" >> "$GITHUB_ENV"
|
# gateway in the same release run. Artifact names must include a
|
||||||
|
# unique token for the image: the repository path is a prefix of the
|
||||||
|
# gateway path, so a path-derived pattern would match both. Use a
|
||||||
|
# short hash of the full image name.
|
||||||
|
image_hash=$(printf '%s' "$IMAGE" | sha256sum | cut -c1-12)
|
||||||
|
{
|
||||||
|
echo "PLATFORM_PAIR=$pair"
|
||||||
|
echo "CACHE_SCOPE=$(printf '%s' "$IMAGE" | tr '/' '-')"
|
||||||
|
echo "ARTIFACT_PREFIX=$image_hash"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -101,7 +112,7 @@ jobs:
|
|||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM_PAIR }}
|
name: digests-${{ env.ARTIFACT_PREFIX }}-${{ env.PLATFORM_PAIR }}
|
||||||
path: /tmp/digests/*
|
path: /tmp/digests/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
@@ -112,18 +123,25 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
|
- name: Prepare environment
|
||||||
|
env:
|
||||||
|
IMAGE: ${{ inputs.image }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "ARTIFACT_PREFIX=$(printf '%s' "$IMAGE" | sha256sum | cut -c1-12)" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-${{ env.ARTIFACT_PREFIX }}-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
4
.github/workflows/build-gateway.yaml
vendored
4
.github/workflows/build-gateway.yaml
vendored
@@ -12,11 +12,9 @@ on:
|
|||||||
- .github/workflows/build-gateway.yaml
|
- .github/workflows/build-gateway.yaml
|
||||||
- .github/workflows/_build-image.yaml
|
- .github/workflows/_build-image.yaml
|
||||||
|
|
||||||
|
# Write scopes are granted to the build job only; every other job stays read-only.
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: build-gateway-${{ github.ref }}
|
group: build-gateway-${{ github.ref }}
|
||||||
|
|||||||
7
.github/workflows/build-main.yaml
vendored
7
.github/workflows/build-main.yaml
vendored
@@ -13,11 +13,9 @@ on:
|
|||||||
- .github/workflows/build-main.yaml
|
- .github/workflows/build-main.yaml
|
||||||
- .github/workflows/_build-image.yaml
|
- .github/workflows/_build-image.yaml
|
||||||
|
|
||||||
|
# Write scopes are granted to the build job only; every other job stays read-only.
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: build-main-${{ github.ref }}
|
group: build-main-${{ github.ref }}
|
||||||
@@ -31,6 +29,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
identity: ${{ steps.meta.outputs.identity }}
|
identity: ${{ steps.meta.outputs.identity }}
|
||||||
dsh_version: ${{ steps.meta.outputs.dsh_version }}
|
dsh_version: ${{ steps.meta.outputs.dsh_version }}
|
||||||
|
dsh_commit: ${{ steps.meta.outputs.dsh_commit }}
|
||||||
pnpm_version: ${{ steps.meta.outputs.pnpm_version }}
|
pnpm_version: ${{ steps.meta.outputs.pnpm_version }}
|
||||||
release_revision: ${{ steps.meta.outputs.release_revision }}
|
release_revision: ${{ steps.meta.outputs.release_revision }}
|
||||||
ghcr_image: ${{ steps.meta.outputs.ghcr_image }}
|
ghcr_image: ${{ steps.meta.outputs.ghcr_image }}
|
||||||
@@ -55,6 +54,7 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "identity=$identity"
|
echo "identity=$identity"
|
||||||
echo "dsh_version=$DSH_VERSION"
|
echo "dsh_version=$DSH_VERSION"
|
||||||
|
echo "dsh_commit=$DSH_COMMIT"
|
||||||
echo "pnpm_version=$PNPM_VERSION"
|
echo "pnpm_version=$PNPM_VERSION"
|
||||||
echo "release_revision=$RELEASE_REVISION"
|
echo "release_revision=$RELEASE_REVISION"
|
||||||
echo "ghcr_image=$GHCR_IMAGE"
|
echo "ghcr_image=$GHCR_IMAGE"
|
||||||
@@ -77,6 +77,7 @@ jobs:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
DSH_VERSION=${{ needs.meta.outputs.dsh_version }}
|
DSH_VERSION=${{ needs.meta.outputs.dsh_version }}
|
||||||
|
DSH_COMMIT=${{ needs.meta.outputs.dsh_commit }}
|
||||||
RELEASE_REVISION=${{ needs.meta.outputs.release_revision }}
|
RELEASE_REVISION=${{ needs.meta.outputs.release_revision }}
|
||||||
PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }}
|
PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }}
|
||||||
VCS_REF=${{ github.sha }}
|
VCS_REF=${{ github.sha }}
|
||||||
|
|||||||
128
.github/workflows/ci-image.yaml
vendored
Normal file
128
.github/workflows/ci-image.yaml
vendored
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Build-only check for the main DSH Web image: linux/amd64, no registry login,
|
||||||
|
# no push. It proves the Dockerfile still builds from the pinned versions.env and
|
||||||
|
# that the resulting image can actually run DSH Web. The published multi-arch
|
||||||
|
# build stays in build-main.yaml (merge to main) and release.yaml (tags).
|
||||||
|
#
|
||||||
|
# Like ci-python.yaml and ci-manifests.yaml this is path-filtered, so it is an
|
||||||
|
# on-demand check rather than a required status check.
|
||||||
|
name: CI image
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- Dockerfile
|
||||||
|
- .dockerignore
|
||||||
|
- versions.env
|
||||||
|
- .github/workflows/ci-image.yaml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-image-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-amd64:
|
||||||
|
name: build-amd64
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Read versions.env
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source versions.env
|
||||||
|
set +a
|
||||||
|
identity="$DSH_VERSION"
|
||||||
|
[ -z "${RELEASE_REVISION:-}" ] || identity="$DSH_VERSION-$RELEASE_REVISION"
|
||||||
|
{
|
||||||
|
echo "identity=$identity"
|
||||||
|
echo "dsh_version=$DSH_VERSION"
|
||||||
|
echo "dsh_commit=$DSH_COMMIT"
|
||||||
|
echo "pnpm_version=$PNPM_VERSION"
|
||||||
|
echo "release_revision=${RELEASE_REVISION:-}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
|
- name: Build linux/amd64 (load locally, never push)
|
||||||
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: dsh-web:ci
|
||||||
|
build-args: |
|
||||||
|
DSH_VERSION=${{ steps.meta.outputs.dsh_version }}
|
||||||
|
DSH_COMMIT=${{ steps.meta.outputs.dsh_commit }}
|
||||||
|
RELEASE_REVISION=${{ steps.meta.outputs.release_revision }}
|
||||||
|
PNPM_VERSION=${{ steps.meta.outputs.pnpm_version }}
|
||||||
|
VCS_REF=${{ github.sha }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||||
|
org.opencontainers.image.version=${{ steps.meta.outputs.identity }}
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
cache-from: type=gha,scope=ci-image-linux-amd64
|
||||||
|
cache-to: type=gha,mode=max,scope=ci-image-linux-amd64
|
||||||
|
|
||||||
|
- name: Smoke test the image
|
||||||
|
env:
|
||||||
|
IDENTITY: ${{ steps.meta.outputs.identity }}
|
||||||
|
DSH_VERSION: ${{ steps.meta.outputs.dsh_version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
image=dsh-web:ci
|
||||||
|
|
||||||
|
# The packaged CLI runs and reports the version pinned in versions.env.
|
||||||
|
version="$(docker run --rm --entrypoint node "$image" /app/lib/bin.js --version)"
|
||||||
|
printf 'DSH CLI: %s\n' "$version"
|
||||||
|
grep -q "${DSH_VERSION#dsh-v}" <<<"$version"
|
||||||
|
|
||||||
|
# The container runs as the non-root node user and its writable
|
||||||
|
# directories exist and are owned by that user.
|
||||||
|
[ "$(docker run --rm --entrypoint id "$image" -u)" = "1000" ]
|
||||||
|
docker run --rm --entrypoint sh "$image" -c \
|
||||||
|
'test -d /workspace && test -d "$DSH_HOME" && [ "$(stat -c %u /workspace)" = 1000 ]'
|
||||||
|
|
||||||
|
# The command-sandbox runner this image is expected to ship.
|
||||||
|
docker run --rm --entrypoint bwrap "$image" --version
|
||||||
|
|
||||||
|
# Labels come from the build args rather than a hard-coded value.
|
||||||
|
label="$(docker inspect -f '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$image")"
|
||||||
|
printf 'image version label: %s\n' "$label"
|
||||||
|
[ "$label" = "$IDENTITY" ]
|
||||||
|
|
||||||
|
- name: Serve DSH Web
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker run -d --name dsh-ci -p 18080:3080 \
|
||||||
|
-e DSH_TRUSTED_HOSTS=127.0.0.1:18080 dsh-web:ci >/dev/null
|
||||||
|
trap 'docker rm -f dsh-ci >/dev/null 2>&1 || true' EXIT
|
||||||
|
|
||||||
|
# /favicon.svg is unauthenticated and served by the web layer, which is
|
||||||
|
# why the shipped probes use it too.
|
||||||
|
for second in $(seq 1 60); do
|
||||||
|
if curl -fsS -o /dev/null http://127.0.0.1:18080/favicon.svg; then
|
||||||
|
printf 'DSH Web served /favicon.svg after ~%ss\n' "$((second * 2))"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "::error::DSH Web did not serve /favicon.svg within 120s"
|
||||||
|
# Pod logs can hold the DSH launch token: never dump them verbatim.
|
||||||
|
docker logs dsh-ci 2>&1 | grep -v 'token=' | tail -50 || true
|
||||||
|
exit 1
|
||||||
88
.github/workflows/ci-manifests.yaml
vendored
Normal file
88
.github/workflows/ci-manifests.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Manifest shape validation and server-side dry-run. Split from ci.yaml so this
|
||||||
|
# job can use native on.pull_request.paths filtering and skip PRs that do not
|
||||||
|
# touch manifests, scripts or versions.env. The job name is `manifests`; because
|
||||||
|
# it is path-filtered it is an on-demand check, not a required branch-protection
|
||||||
|
# check (see docs/gitflow.md).
|
||||||
|
name: CI (manifests)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- examples/**/manifests/**
|
||||||
|
- scripts/check-manifests.py
|
||||||
|
- versions.env
|
||||||
|
- .github/workflows/ci-manifests.yaml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-manifests-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
manifests:
|
||||||
|
name: manifests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Create kind cluster
|
||||||
|
uses: helm/kind-action@1544676c07bb3570fcb70166beaa07c4528fd4aa # v1.15.1
|
||||||
|
with:
|
||||||
|
version: v0.33.0
|
||||||
|
cluster_name: dsh-ci
|
||||||
|
- name: Read versions.env
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in ''|'#'*) continue ;; esac
|
||||||
|
echo "$line" >> "$GITHUB_ENV"
|
||||||
|
done < versions.env
|
||||||
|
- name: Install agent-sandbox and Gateway API CRDs
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Download, verify the pinned digest from versions.env, then apply.
|
||||||
|
curl -fsSL --proto '=https' -o /tmp/agent-sandbox.yaml \
|
||||||
|
"https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml"
|
||||||
|
printf '%s %s\n' "$AGENT_SANDBOX_MANIFEST_SHA256" /tmp/agent-sandbox.yaml | sha256sum -c -
|
||||||
|
kubectl apply -f /tmp/agent-sandbox.yaml
|
||||||
|
kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s
|
||||||
|
kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s
|
||||||
|
kubectl wait --for=condition=Established crd/sandboxclaims.extensions.agents.x-k8s.io --timeout=180s
|
||||||
|
kubectl wait --for=condition=Established crd/sandboxes.agents.x-k8s.io --timeout=180s
|
||||||
|
curl -fsSL --proto '=https' -o /tmp/gateway-api.yaml \
|
||||||
|
"https://github.com/kubernetes-sigs/gateway-api/releases/download/$GATEWAY_API_VERSION/standard-install.yaml"
|
||||||
|
printf '%s %s\n' "$GATEWAY_API_MANIFEST_SHA256" /tmp/gateway-api.yaml | sha256sum -c -
|
||||||
|
kubectl apply -f /tmp/gateway-api.yaml
|
||||||
|
kubectl wait --for=condition=Established crd/gateways.gateway.networking.k8s.io --timeout=180s
|
||||||
|
kubectl wait --for=condition=Established crd/httproutes.gateway.networking.k8s.io --timeout=180s
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Install check dependencies
|
||||||
|
run: pip install "pyyaml==6.0.3"
|
||||||
|
- name: Validate manifest shape
|
||||||
|
run: python scripts/check-manifests.py
|
||||||
|
- name: Validate manifests (server dry-run)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
kubectl create namespace dsh-demo
|
||||||
|
shopt -s globstar nullglob
|
||||||
|
count=0
|
||||||
|
for file in examples/*/manifests/**/*.yaml; do
|
||||||
|
case "$file" in *.yaml.example) continue ;; esac
|
||||||
|
echo "== $file =="
|
||||||
|
kubectl apply --dry-run=server -f "$file"
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
echo "no manifests were validated" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "validated $count manifests"
|
||||||
75
.github/workflows/ci-python.yaml
vendored
Normal file
75
.github/workflows/ci-python.yaml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Gateway unit tests and lint. Split from ci.yaml so this job can use native
|
||||||
|
# on.pull_request.paths filtering and skip PRs that do not touch the gateway.
|
||||||
|
# The job name is `python`; because it is path-filtered it is an on-demand check,
|
||||||
|
# not a required branch-protection check (see docs/gitflow.md).
|
||||||
|
name: CI (python)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- examples/dsh-agents-as-a-service/gateway/**
|
||||||
|
- .github/workflows/ci-python.yaml
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-python-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python:
|
||||||
|
name: python
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: examples/dsh-agents-as-a-service/gateway
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: |
|
||||||
|
examples/dsh-agents-as-a-service/gateway/requirements.txt
|
||||||
|
examples/dsh-agents-as-a-service/gateway/requirements-dev.txt
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# Both files are fully hash-pinned (uv pip compile --generate-hashes);
|
||||||
|
# pip refuses to install anything not listed with a matching hash.
|
||||||
|
pip install --require-hashes -r requirements.txt -r requirements-dev.txt
|
||||||
|
- name: Lint
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m ruff check .
|
||||||
|
python -m ruff format --check .
|
||||||
|
- name: Unit tests with coverage
|
||||||
|
run: >-
|
||||||
|
python -m pytest -q
|
||||||
|
--cov=. --cov-report=xml:coverage.xml --cov-report=term
|
||||||
|
--junitxml=junit.xml
|
||||||
|
- name: Coverage summary
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
{
|
||||||
|
echo "### Gateway test coverage"
|
||||||
|
python -m coverage report
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
- name: Upload test artefacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||||
|
with:
|
||||||
|
name: gateway-test-results
|
||||||
|
path: |
|
||||||
|
examples/dsh-agents-as-a-service/gateway/junit.xml
|
||||||
|
examples/dsh-agents-as-a-service/gateway/coverage.xml
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
189
.github/workflows/ci.yaml
vendored
189
.github/workflows/ci.yaml
vendored
@@ -1,12 +1,11 @@
|
|||||||
# Continuous integration: lint, unit tests, manifest dry-run, gateway image
|
# Continuous integration: lint and dependency review. Job names are the required
|
||||||
# smoke test and dependency review. Job names are the required branch-protection
|
# branch-protection checks documented in docs/gitflow.md. The gateway unit tests
|
||||||
# checks documented in docs/gitflow.md.
|
# and the manifest dry-run live in ci-python.yaml / ci-manifests.yaml so each can
|
||||||
|
# use native on.pull_request.paths filtering and skip unrelated changes.
|
||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -38,19 +37,29 @@ jobs:
|
|||||||
- name: Install actionlint
|
- name: Install actionlint
|
||||||
env:
|
env:
|
||||||
ACTIONLINT_VERSION: v1.7.12
|
ACTIONLINT_VERSION: v1.7.12
|
||||||
|
# SHA-256 of actionlint_1.7.12_linux_amd64.tar.gz, published in the
|
||||||
|
# release's actionlint_1.7.12_checksums.txt. Update it with the version.
|
||||||
|
ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
version="${ACTIONLINT_VERSION#v}"
|
version="${ACTIONLINT_VERSION#v}"
|
||||||
curl -fsSL -o /tmp/actionlint.tar.gz "https://github.com/rhysd/actionlint/releases/download/$ACTIONLINT_VERSION/actionlint_${version}_linux_amd64.tar.gz"
|
asset="actionlint_${version}_linux_amd64.tar.gz"
|
||||||
tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint
|
curl -fsSL --proto '=https' -o "/tmp/$asset" \
|
||||||
|
"https://github.com/rhysd/actionlint/releases/download/$ACTIONLINT_VERSION/$asset"
|
||||||
|
printf '%s %s\n' "$ACTIONLINT_SHA256" "/tmp/$asset" | sha256sum -c -
|
||||||
|
tar -xzf "/tmp/$asset" -C /tmp actionlint
|
||||||
install -m 0755 /tmp/actionlint /usr/local/bin/actionlint
|
install -m 0755 /tmp/actionlint /usr/local/bin/actionlint
|
||||||
actionlint -version
|
actionlint -version
|
||||||
- name: Install hadolint
|
- name: Install hadolint
|
||||||
env:
|
env:
|
||||||
HADOLINT_VERSION: v2.12.0
|
HADOLINT_VERSION: v2.12.0
|
||||||
|
# SHA-256 of hadolint-Linux-x86_64, published next to the download.
|
||||||
|
HADOLINT_SHA256: 56de6d5e5ec427e17b74fa48d51271c7fc0d61244bf5c90e828aab8362d55010
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
curl -fsSL -o /tmp/hadolint "https://github.com/hadolint/hadolint/releases/download/$HADOLINT_VERSION/hadolint-Linux-x86_64"
|
curl -fsSL --proto '=https' -o /tmp/hadolint \
|
||||||
|
"https://github.com/hadolint/hadolint/releases/download/$HADOLINT_VERSION/hadolint-Linux-x86_64"
|
||||||
|
printf '%s %s\n' "$HADOLINT_SHA256" /tmp/hadolint | sha256sum -c -
|
||||||
install -m 0755 /tmp/hadolint /usr/local/bin/hadolint
|
install -m 0755 /tmp/hadolint /usr/local/bin/hadolint
|
||||||
hadolint --version
|
hadolint --version
|
||||||
- name: Repository checks
|
- name: Repository checks
|
||||||
@@ -58,7 +67,6 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
./scripts/check-versions.sh
|
./scripts/check-versions.sh
|
||||||
./scripts/check-examples.sh
|
./scripts/check-examples.sh
|
||||||
python scripts/check-manifests.py
|
|
||||||
- name: Shellcheck
|
- name: Shellcheck
|
||||||
run: shellcheck -x build.sh scripts/*.sh examples/*/scripts/*.sh
|
run: shellcheck -x build.sh scripts/*.sh examples/*/scripts/*.sh
|
||||||
- name: Actionlint
|
- name: Actionlint
|
||||||
@@ -77,169 +85,6 @@ jobs:
|
|||||||
--offline --no-progress --verbose
|
--offline --no-progress --verbose
|
||||||
./README.md ./CONTRIBUTING.md ./SECURITY.md ./AGENTS.md ./docs ./examples
|
./README.md ./CONTRIBUTING.md ./SECURITY.md ./AGENTS.md ./docs ./examples
|
||||||
|
|
||||||
python:
|
|
||||||
name: python
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: examples/dsh-agents-as-a-service/gateway
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
cache: pip
|
|
||||||
cache-dependency-path: |
|
|
||||||
examples/dsh-agents-as-a-service/gateway/requirements.txt
|
|
||||||
examples/dsh-agents-as-a-service/gateway/requirements-dev.txt
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt -r requirements-dev.txt
|
|
||||||
- name: Lint
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
python -m ruff check .
|
|
||||||
python -m ruff format --check .
|
|
||||||
- name: Unit tests with coverage
|
|
||||||
run: >-
|
|
||||||
python -m pytest -q
|
|
||||||
--cov=. --cov-report=xml:coverage.xml --cov-report=term
|
|
||||||
--junitxml=junit.xml
|
|
||||||
- name: Coverage summary
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
{
|
|
||||||
echo "### Gateway test coverage"
|
|
||||||
python -m coverage report
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
- name: Upload test artefacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
|
||||||
with:
|
|
||||||
name: gateway-test-results
|
|
||||||
path: |
|
|
||||||
examples/dsh-agents-as-a-service/gateway/junit.xml
|
|
||||||
examples/dsh-agents-as-a-service/gateway/coverage.xml
|
|
||||||
if-no-files-found: warn
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
manifests:
|
|
||||||
name: manifests
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 25
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Create kind cluster
|
|
||||||
uses: helm/kind-action@1544676c07bb3570fcb70166beaa07c4528fd4aa # v1.15.1
|
|
||||||
with:
|
|
||||||
version: v0.33.0
|
|
||||||
cluster_name: dsh-ci
|
|
||||||
- name: Read versions.env
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
while IFS= read -r line; do
|
|
||||||
case "$line" in ''|'#'*) continue ;; esac
|
|
||||||
echo "$line" >> "$GITHUB_ENV"
|
|
||||||
done < versions.env
|
|
||||||
- name: Install agent-sandbox and Gateway API CRDs
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml"
|
|
||||||
kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s
|
|
||||||
kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s
|
|
||||||
kubectl wait --for=condition=Established crd/sandboxclaims.extensions.agents.x-k8s.io --timeout=180s
|
|
||||||
kubectl wait --for=condition=Established crd/sandboxes.agents.x-k8s.io --timeout=180s
|
|
||||||
kubectl apply -f "https://github.com/kubernetes-sigs/gateway-api/releases/download/$GATEWAY_API_VERSION/standard-install.yaml"
|
|
||||||
kubectl wait --for=condition=Established crd/gateways.gateway.networking.k8s.io --timeout=180s
|
|
||||||
kubectl wait --for=condition=Established crd/httproutes.gateway.networking.k8s.io --timeout=180s
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- name: Install check dependencies
|
|
||||||
run: pip install "pyyaml==6.0.3"
|
|
||||||
- name: Validate manifest shape
|
|
||||||
run: python scripts/check-manifests.py
|
|
||||||
- name: Validate manifests (server dry-run)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
kubectl create namespace dsh-demo
|
|
||||||
shopt -s globstar nullglob
|
|
||||||
count=0
|
|
||||||
for file in examples/*/manifests/**/*.yaml; do
|
|
||||||
case "$file" in *.yaml.example) continue ;; esac
|
|
||||||
echo "== $file =="
|
|
||||||
kubectl apply --dry-run=server -f "$file"
|
|
||||||
count=$((count + 1))
|
|
||||||
done
|
|
||||||
if [ "$count" -eq 0 ]; then
|
|
||||||
echo "no manifests were validated" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "validated $count manifests"
|
|
||||||
|
|
||||||
gateway-image:
|
|
||||||
name: gateway-image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- name: Build gateway image
|
|
||||||
run: docker build -t dsh-aaas-gateway:ci examples/dsh-agents-as-a-service/gateway/
|
|
||||||
- name: Smoke-test /healthz without a cluster
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cat > /tmp/kubeconfig <<'EOF'
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Config
|
|
||||||
clusters:
|
|
||||||
- name: dummy
|
|
||||||
cluster:
|
|
||||||
server: https://127.0.0.1:6443
|
|
||||||
insecure-skip-tls-verify: true
|
|
||||||
contexts:
|
|
||||||
- name: dummy
|
|
||||||
context:
|
|
||||||
cluster: dummy
|
|
||||||
user: dummy
|
|
||||||
users:
|
|
||||||
- name: dummy
|
|
||||||
user: {}
|
|
||||||
current-context: dummy
|
|
||||||
EOF
|
|
||||||
docker run -d --name gateway-smoke \
|
|
||||||
-p 18080:8080 \
|
|
||||||
-e NAMESPACE=dsh-demo \
|
|
||||||
-e POOL=dsh-pool \
|
|
||||||
-e KUBECONFIG=/tmp/kubeconfig \
|
|
||||||
-v /tmp/kubeconfig:/tmp/kubeconfig:ro \
|
|
||||||
dsh-aaas-gateway:ci
|
|
||||||
trap 'docker logs gateway-smoke || true; docker rm -f gateway-smoke || true' EXIT
|
|
||||||
code=""
|
|
||||||
for _ in $(seq 1 30); do
|
|
||||||
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:18080/healthz || true)
|
|
||||||
[ "$code" = "200" ] && break
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
if [ "$code" != "200" ]; then
|
|
||||||
echo "gateway /healthz returned '$code' instead of 200" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "gateway image healthy: GET /healthz -> 200"
|
|
||||||
|
|
||||||
dependency-review:
|
dependency-review:
|
||||||
name: dependency-review
|
name: dependency-review
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
|
|||||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -58,6 +58,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||||
|
|||||||
9
.github/workflows/e2e-kind.yaml
vendored
9
.github/workflows/e2e-kind.yaml
vendored
@@ -40,7 +40,10 @@ jobs:
|
|||||||
- name: Install agent-sandbox
|
- name: Install agent-sandbox
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml"
|
curl -fsSL --proto '=https' -o /tmp/agent-sandbox.yaml \
|
||||||
|
"https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$AGENT_SANDBOX_VERSION/sandbox-with-extensions.yaml"
|
||||||
|
printf '%s %s\n' "$AGENT_SANDBOX_MANIFEST_SHA256" /tmp/agent-sandbox.yaml | sha256sum -c -
|
||||||
|
kubectl apply -f /tmp/agent-sandbox.yaml
|
||||||
kubectl -n agent-sandbox-system rollout status deployment --timeout=180s
|
kubectl -n agent-sandbox-system rollout status deployment --timeout=180s
|
||||||
kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s
|
kubectl wait --for=condition=Established crd/sandboxtemplates.extensions.agents.x-k8s.io --timeout=180s
|
||||||
kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s
|
kubectl wait --for=condition=Established crd/sandboxwarmpools.extensions.agents.x-k8s.io --timeout=180s
|
||||||
@@ -66,7 +69,9 @@ jobs:
|
|||||||
echo "== events =="
|
echo "== events =="
|
||||||
kubectl get events -n dsh-demo --sort-by=.lastTimestamp 2>&1 || true
|
kubectl get events -n dsh-demo --sort-by=.lastTimestamp 2>&1 || true
|
||||||
echo "== gateway logs =="
|
echo "== gateway logs =="
|
||||||
kubectl -n dsh-demo logs deploy/dsh-gateway --tail=200 2>&1 || true
|
# Redact any token material before this file becomes an artifact.
|
||||||
|
kubectl -n dsh-demo logs deploy/dsh-gateway --tail=200 2>&1 \
|
||||||
|
| sed -E 's/(token=)[^&"[:space:]]+/\1[REDACTED]/g' || true
|
||||||
} > /tmp/e2e-cluster.txt
|
} > /tmp/e2e-cluster.txt
|
||||||
- name: Upload cluster state
|
- name: Upload cluster state
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
90
.github/workflows/release.yaml
vendored
90
.github/workflows/release.yaml
vendored
@@ -16,17 +16,11 @@ on:
|
|||||||
description: Existing tag to release (must match versions.env)
|
description: Existing tag to release (must match versions.env)
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
publish_legacy_gateway_alias:
|
|
||||||
description: Also publish the legacy :gateway alias (one release only)
|
|
||||||
required: false
|
|
||||||
default: true
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
|
# Read-only by default; the build and publish jobs add the write scopes they
|
||||||
|
# need as job-level grants.
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
attestations: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
@@ -40,11 +34,13 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
identity: ${{ steps.meta.outputs.identity }}
|
identity: ${{ steps.meta.outputs.identity }}
|
||||||
dsh_version: ${{ steps.meta.outputs.dsh_version }}
|
dsh_version: ${{ steps.meta.outputs.dsh_version }}
|
||||||
|
dsh_commit: ${{ steps.meta.outputs.dsh_commit }}
|
||||||
pnpm_version: ${{ steps.meta.outputs.pnpm_version }}
|
pnpm_version: ${{ steps.meta.outputs.pnpm_version }}
|
||||||
release_revision: ${{ steps.meta.outputs.release_revision }}
|
release_revision: ${{ steps.meta.outputs.release_revision }}
|
||||||
ghcr_image: ${{ steps.meta.outputs.ghcr_image }}
|
ghcr_image: ${{ steps.meta.outputs.ghcr_image }}
|
||||||
gateway_image: ${{ steps.meta.outputs.gateway_image }}
|
gateway_image: ${{ steps.meta.outputs.gateway_image }}
|
||||||
tag: ${{ steps.meta.outputs.tag }}
|
tag: ${{ steps.meta.outputs.tag }}
|
||||||
|
run_tests: ${{ steps.meta.outputs.run_tests }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -55,13 +51,17 @@ jobs:
|
|||||||
- name: Validate tag against versions.env
|
- name: Validate tag against versions.env
|
||||||
id: meta
|
id: meta
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
# Passed through the environment, never interpolated into the script:
|
||||||
|
# a tag name or dispatch input can contain shell metacharacters.
|
||||||
|
REQUESTED_TAG: ${{ inputs.tag || github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
set -a
|
set -a
|
||||||
# shellcheck disable=SC1091
|
# shellcheck disable=SC1091
|
||||||
source versions.env
|
source versions.env
|
||||||
set +a
|
set +a
|
||||||
tag="${{ inputs.tag || github.ref_name }}"
|
tag="$REQUESTED_TAG"
|
||||||
identity="$DSH_VERSION"
|
identity="$DSH_VERSION"
|
||||||
[ -z "$RELEASE_REVISION" ] || identity="$DSH_VERSION-$RELEASE_REVISION"
|
[ -z "$RELEASE_REVISION" ] || identity="$DSH_VERSION-$RELEASE_REVISION"
|
||||||
if [ "$tag" != "$identity" ]; then
|
if [ "$tag" != "$identity" ]; then
|
||||||
@@ -82,22 +82,44 @@ jobs:
|
|||||||
echo "tag commit $commit is not on main or a release branch" >&2
|
echo "tag commit $commit is not on main or a release branch" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# Tests already ran in PR CI when the tag is on main; only re-run
|
||||||
|
# them for release-branch tags (where ci.yaml does not trigger).
|
||||||
|
run_tests=true
|
||||||
|
if git rev-parse -q --verify origin/main >/dev/null && git merge-base --is-ancestor "$commit" origin/main; then
|
||||||
|
run_tests=false
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "tag '$tag' does not exist in this repository" >&2
|
echo "tag '$tag' does not exist in this repository" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! git ls-remote --tags --exit-code "$DSH_REPO" "$DSH_VERSION" >/dev/null; then
|
if ! upstream="$(git ls-remote --tags --exit-code "$DSH_REPO" \
|
||||||
|
"refs/tags/$DSH_VERSION^{}" "refs/tags/$DSH_VERSION")"; then
|
||||||
echo "upstream ref '$DSH_VERSION' not found in $DSH_REPO" >&2
|
echo "upstream ref '$DSH_VERSION' not found in $DSH_REPO" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# Never release a ref that no longer resolves to the pinned commit: an
|
||||||
|
# annotated tag has a peeled ^{} line, a lightweight tag only the ref.
|
||||||
|
resolved="$(printf '%s\n' "$upstream" \
|
||||||
|
| awk -v t="refs/tags/$DSH_VERSION^{}" '$2 == t {print $1}' | head -n1)"
|
||||||
|
if [ -z "$resolved" ]; then
|
||||||
|
resolved="$(printf '%s\n' "$upstream" \
|
||||||
|
| awk -v t="refs/tags/$DSH_VERSION" '$2 == t {print $1}' | head -n1)"
|
||||||
|
fi
|
||||||
|
if [ "$resolved" != "$DSH_COMMIT" ]; then
|
||||||
|
echo "upstream $DSH_REPO@$DSH_VERSION resolved to '$resolved'," \
|
||||||
|
"versions.env pins DSH_COMMIT=$DSH_COMMIT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
{
|
{
|
||||||
echo "identity=$identity"
|
echo "identity=$identity"
|
||||||
echo "dsh_version=$DSH_VERSION"
|
echo "dsh_version=$DSH_VERSION"
|
||||||
|
echo "dsh_commit=$DSH_COMMIT"
|
||||||
echo "pnpm_version=$PNPM_VERSION"
|
echo "pnpm_version=$PNPM_VERSION"
|
||||||
echo "release_revision=$RELEASE_REVISION"
|
echo "release_revision=$RELEASE_REVISION"
|
||||||
echo "ghcr_image=$GHCR_IMAGE"
|
echo "ghcr_image=$GHCR_IMAGE"
|
||||||
echo "gateway_image=$GATEWAY_IMAGE"
|
echo "gateway_image=$GATEWAY_IMAGE"
|
||||||
echo "tag=$tag"
|
echo "tag=$tag"
|
||||||
|
echo "run_tests=$run_tests"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Verify repository pins
|
- name: Verify repository pins
|
||||||
@@ -106,6 +128,9 @@ jobs:
|
|||||||
tests:
|
tests:
|
||||||
name: Unit tests and lint
|
name: Unit tests and lint
|
||||||
needs: meta
|
needs: meta
|
||||||
|
# The test steps are guarded individually rather than the whole job so a
|
||||||
|
# main-branch tag (run_tests=false) still leaves this job "successful" and
|
||||||
|
# does not cascade-skip build-main/build-gateway.
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
defaults:
|
defaults:
|
||||||
@@ -117,6 +142,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
|
if: needs.meta.outputs.run_tests == 'true'
|
||||||
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
@@ -125,16 +151,19 @@ jobs:
|
|||||||
examples/dsh-agents-as-a-service/gateway/requirements.txt
|
examples/dsh-agents-as-a-service/gateway/requirements.txt
|
||||||
examples/dsh-agents-as-a-service/gateway/requirements-dev.txt
|
examples/dsh-agents-as-a-service/gateway/requirements-dev.txt
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: needs.meta.outputs.run_tests == 'true'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install -r requirements.txt -r requirements-dev.txt
|
pip install --require-hashes -r requirements.txt -r requirements-dev.txt
|
||||||
- name: Lint
|
- name: Lint
|
||||||
|
if: needs.meta.outputs.run_tests == 'true'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python -m ruff check .
|
python -m ruff check .
|
||||||
python -m ruff format --check .
|
python -m ruff format --check .
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
|
if: needs.meta.outputs.run_tests == 'true'
|
||||||
run: python -m pytest -q
|
run: python -m pytest -q
|
||||||
|
|
||||||
build-main:
|
build-main:
|
||||||
@@ -153,6 +182,7 @@ jobs:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
build-args: |
|
build-args: |
|
||||||
DSH_VERSION=${{ needs.meta.outputs.dsh_version }}
|
DSH_VERSION=${{ needs.meta.outputs.dsh_version }}
|
||||||
|
DSH_COMMIT=${{ needs.meta.outputs.dsh_commit }}
|
||||||
RELEASE_REVISION=${{ needs.meta.outputs.release_revision }}
|
RELEASE_REVISION=${{ needs.meta.outputs.release_revision }}
|
||||||
PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }}
|
PNPM_VERSION=${{ needs.meta.outputs.pnpm_version }}
|
||||||
VCS_REF=${{ github.sha }}
|
VCS_REF=${{ github.sha }}
|
||||||
@@ -178,33 +208,14 @@ jobs:
|
|||||||
${{ needs.meta.outputs.gateway_image }}:${{ needs.meta.outputs.identity }}
|
${{ needs.meta.outputs.gateway_image }}:${{ needs.meta.outputs.identity }}
|
||||||
${{ needs.meta.outputs.gateway_image }}:latest
|
${{ needs.meta.outputs.gateway_image }}:latest
|
||||||
|
|
||||||
legacy-gateway-alias:
|
|
||||||
name: Publish legacy :gateway alias (temporary)
|
|
||||||
needs: [meta, build-gateway]
|
|
||||||
if: github.event_name != 'workflow_dispatch' || inputs.publish_legacy_gateway_alias
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
steps:
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Retag gateway as the legacy package tag
|
|
||||||
env:
|
|
||||||
SOURCE: ${{ needs.meta.outputs.gateway_image }}:${{ needs.meta.outputs.identity }}
|
|
||||||
LEGACY: ${{ needs.meta.outputs.ghcr_image }}:gateway
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker buildx imagetools create -t "$LEGACY" "$SOURCE"
|
|
||||||
echo "Deprecated alias $LEGACY now points at $SOURCE" >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
name: Changelog and GitHub Release
|
name: Changelog and GitHub Release
|
||||||
needs: [meta, build-main, build-gateway]
|
needs: [meta, build-main, build-gateway]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -214,11 +225,17 @@ jobs:
|
|||||||
- name: Install git-cliff
|
- name: Install git-cliff
|
||||||
env:
|
env:
|
||||||
GIT_CLIFF_VERSION: v2.14.2
|
GIT_CLIFF_VERSION: v2.14.2
|
||||||
|
# SHA-256 of git-cliff-2.14.2-x86_64-unknown-linux-gnu.tar.gz; upstream
|
||||||
|
# publishes the matching SHA-512 next to the asset. Update with the version.
|
||||||
|
GIT_CLIFF_SHA256: 24f397c733add5390fdceee3a2088588ab0d5f944ce00d34cb7029b888cf2db4
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
version="${GIT_CLIFF_VERSION#v}"
|
version="${GIT_CLIFF_VERSION#v}"
|
||||||
curl -fsSL -o /tmp/git-cliff.tar.gz "https://github.com/orhun/git-cliff/releases/download/$GIT_CLIFF_VERSION/git-cliff-$version-x86_64-unknown-linux-gnu.tar.gz"
|
asset="git-cliff-${version}-x86_64-unknown-linux-gnu.tar.gz"
|
||||||
tar -xzf /tmp/git-cliff.tar.gz -C /tmp
|
curl -fsSL --proto '=https' -o "/tmp/$asset" \
|
||||||
|
"https://github.com/orhun/git-cliff/releases/download/$GIT_CLIFF_VERSION/$asset"
|
||||||
|
printf '%s %s\n' "$GIT_CLIFF_SHA256" "/tmp/$asset" | sha256sum -c -
|
||||||
|
tar -xzf "/tmp/$asset" -C /tmp
|
||||||
install -m 0755 "/tmp/git-cliff-$version/git-cliff" /usr/local/bin/git-cliff
|
install -m 0755 "/tmp/git-cliff-$version/git-cliff" /usr/local/bin/git-cliff
|
||||||
git-cliff --version
|
git-cliff --version
|
||||||
|
|
||||||
@@ -248,6 +265,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
IDENTITY: ${{ needs.meta.outputs.identity }}
|
IDENTITY: ${{ needs.meta.outputs.identity }}
|
||||||
|
continue-on-error: true
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
|
|||||||
585
.opencode/plugins/entire.ts
Normal file
585
.opencode/plugins/entire.ts
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
// Entire CLI plugin for OpenCode
|
||||||
|
// Auto-generated by `entire enable --agent opencode`
|
||||||
|
// Do not edit manually — changes will be overwritten on next install.
|
||||||
|
//
|
||||||
|
// Uses node:child_process so hooks work under both Bun (OpenCode CLI/TUI)
|
||||||
|
// and Node (OpenCode Desktop's Electron sidecar). See #2014.
|
||||||
|
import { spawn, spawnSync } from "node:child_process"
|
||||||
|
import type { Plugin } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
|
export const EntirePlugin: Plugin = async ({ directory }) => {
|
||||||
|
// Track seen user messages to fire turn-start only once per message
|
||||||
|
const seenUserMessages = new Set<string>()
|
||||||
|
// Track current session ID for message events (which don't include sessionID)
|
||||||
|
let currentSessionID: string | null = null
|
||||||
|
// Track the model used by the most recent assistant message
|
||||||
|
let currentModel: string | null = null
|
||||||
|
// In-memory store for message metadata (role, tokens, etc.)
|
||||||
|
const messageStore = new Map<string, any>()
|
||||||
|
// One-time model-context injection captured from the turn-start hook's stdout,
|
||||||
|
// applied on the next LLM call via experimental.chat.system.transform.
|
||||||
|
let pendingInjection: string | null = null
|
||||||
|
// Child (subagent) sessions. OpenCode's task tool runs each subagent as a
|
||||||
|
// real session, so without this set a child would register as the user's
|
||||||
|
// session: its write would take the checkpoint and the parent would log
|
||||||
|
// "no files modified". Learned from session.* events' `parentID`, and from the child
|
||||||
|
// ID surfaced in the parent's task part / tool.execute.after — the latter
|
||||||
|
// two also cover a child session resumed via `task_id` from an earlier
|
||||||
|
// process, whose own session.created predates this plugin instance. These
|
||||||
|
// sets live for the process and are cleared only on server.instance.disposed.
|
||||||
|
const childSessions = new Set<string>()
|
||||||
|
// child session ID -> the top-level (user's) session it descends from. With
|
||||||
|
// subagent_depth > 1 a child can itself call the task tool; that
|
||||||
|
// grandchild's task is recorded on the top-level session, the only one
|
||||||
|
// Entire tracks, keyed by its own callID like any other task.
|
||||||
|
const rootOf = new Map<string, string>()
|
||||||
|
// child session ID -> the session that launched it, so aborting a session
|
||||||
|
// can end the background calls it launched.
|
||||||
|
const parentOf = new Map<string, string>()
|
||||||
|
// The per-call maps and sets below are keyed by callKey: the calling
|
||||||
|
// session plus the hook key. A callID is unique only within one session
|
||||||
|
// (some providers number calls per conversation), so a child's own task
|
||||||
|
// call can share one with its parent's running call.
|
||||||
|
// Call keys already announced via subagent-start (the running part update
|
||||||
|
// repeats), so a late running update cannot announce a task again after its
|
||||||
|
// stop. Cleared for a key when a new call starts under it (a reused callID).
|
||||||
|
const announcedTasks = new Set<string>()
|
||||||
|
// Call key -> Date.now() at tool.execute.before, sent as started_at. A
|
||||||
|
// child resumed via `task_id` holds every earlier call's messages too; this
|
||||||
|
// is what lets the CLI keep only the ones this call produced. The hook key
|
||||||
|
// is the part's callID, except for a command subtask (`subtask: true`),
|
||||||
|
// whose before/after hooks carry the task part's own id instead.
|
||||||
|
const taskStartedAt = new Map<string, number>()
|
||||||
|
// Announced tasks not yet stopped: call key -> its subagent-stop payload.
|
||||||
|
// Every stop path takes the entry out, so a task stops at most once.
|
||||||
|
const liveTasks = new Map<string, Record<string, unknown>>()
|
||||||
|
// Call key of a task part id -> call key of its callID, to map a command
|
||||||
|
// subtask's hook key to the callID its subagent-start used.
|
||||||
|
const partCallID = new Map<string, string>()
|
||||||
|
// Call keys whose stop fired before their running part was announced. The
|
||||||
|
// late announcement is then skipped rather than opening a task that would
|
||||||
|
// never stop.
|
||||||
|
const stoppedBeforeStart = new Set<string>()
|
||||||
|
// Call keys of tasks stopped from their error part, so a
|
||||||
|
// tool.execute.after that still arrives does not stop them again.
|
||||||
|
const stoppedOnError = new Set<string>()
|
||||||
|
// Background children (OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS, or a
|
||||||
|
// foreground task promoted to the background): child ID -> subagent-stop
|
||||||
|
// payloads held back until the child's own session goes idle. A queue: a
|
||||||
|
// resume via `task_id` that joins a running child is queued behind the
|
||||||
|
// current run and runs as its own busy -> idle cycle, so each idle ends the
|
||||||
|
// oldest held call.
|
||||||
|
const backgroundTasks = new Map<string, Record<string, unknown>[]>()
|
||||||
|
// Sessions whose last session.status was not idle. An idle only ends a
|
||||||
|
// held call when it follows a busy period: an errored run reports idle
|
||||||
|
// twice.
|
||||||
|
const busySessions = new Set<string>()
|
||||||
|
// Children whose held stops all end at their next idle: an abort reached
|
||||||
|
// them mid-run. Cancelling a job does not cancel a joined run already
|
||||||
|
// executing, so the child's own idle, not the abort, is when it ends.
|
||||||
|
const drainAtIdle = new Set<string>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command for a hook invocation.
|
||||||
|
* Uses sh -c so the command is guarded by a `command -v` probe: when the
|
||||||
|
* entire binary is not on PATH the hook exits 0 rather than failing the
|
||||||
|
* surrounding OpenCode operation.
|
||||||
|
*/
|
||||||
|
function hookCmd(hookName: string): string[] {
|
||||||
|
return ["sh", "-c", `if ! command -v entire >/dev/null 2>&1; then exit 0; fi; exec entire hooks opencode ${hookName}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pipe JSON payload to an entire hooks command (async).
|
||||||
|
* Errors are logged but never thrown — plugin failures must not crash OpenCode.
|
||||||
|
*/
|
||||||
|
async function callHook(hookName: string, payload: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(payload)
|
||||||
|
const [cmd, ...args] = hookCmd(hookName)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const proc = spawn(cmd, args, {
|
||||||
|
cwd: directory,
|
||||||
|
stdio: ["pipe", "ignore", "ignore"],
|
||||||
|
})
|
||||||
|
proc.on("error", () => resolve())
|
||||||
|
proc.on("close", () => resolve())
|
||||||
|
proc.stdin?.end(json + "\n")
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronous variant for hooks that must complete before subsequent agent work
|
||||||
|
* or process exit. `turn-start` must finish initializing session state before a
|
||||||
|
* fast mid-turn commit can hit git hooks, and `turn-end` / `session-end` must
|
||||||
|
* finish before `opencode run` tears down its event loop.
|
||||||
|
*/
|
||||||
|
function callHookSync(hookName: string, payload: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(payload)
|
||||||
|
const [cmd, ...args] = hookCmd(hookName)
|
||||||
|
spawnSync(cmd, args, {
|
||||||
|
cwd: directory,
|
||||||
|
input: json + "\n",
|
||||||
|
stdio: ["pipe", "ignore", "ignore"],
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInjectedContext scans a hook's stdout for Entire's injection envelope
|
||||||
|
// ({"inject_context":"..."}) and returns the text to inject, or null.
|
||||||
|
function parseInjectedContext(stdout: string): string | null {
|
||||||
|
if (!stdout) return null
|
||||||
|
for (const line of stdout.split("\n")) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed.startsWith("{")) continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as { inject_context?: unknown }
|
||||||
|
if (typeof parsed.inject_context === "string" && parsed.inject_context.length > 0) {
|
||||||
|
return parsed.inject_context
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not our envelope — ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// fireTurnStart fires turn-start synchronously (state must be ready before
|
||||||
|
// mid-turn commits) and stashes any model-context injection emitted on stdout
|
||||||
|
// for experimental.chat.system.transform to apply. Entire emits the injection
|
||||||
|
// at most once per session, so pendingInjection is set on the first turn only.
|
||||||
|
function fireTurnStart(payload: Record<string, unknown>) {
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(payload)
|
||||||
|
const [cmd, ...args] = hookCmd("turn-start")
|
||||||
|
const proc = spawnSync(cmd, args, {
|
||||||
|
cwd: directory,
|
||||||
|
input: json + "\n",
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["pipe", "pipe", "ignore"],
|
||||||
|
})
|
||||||
|
const out = typeof proc.stdout === "string" ? proc.stdout : ""
|
||||||
|
const injected = parseInjectedContext(out)
|
||||||
|
if (injected) pendingInjection = injected
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackChild records a child and the top-level session it descends from.
|
||||||
|
function trackChild(childID: string, parentID: string) {
|
||||||
|
childSessions.add(childID)
|
||||||
|
if (!rootOf.has(childID)) rootOf.set(childID, rootOf.get(parentID) ?? parentID)
|
||||||
|
if (!parentOf.has(childID)) parentOf.set(childID, parentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// callKey scopes a hook key (a callID or a task part id) to the session
|
||||||
|
// that made the call.
|
||||||
|
function callKey(sessionID: string, hookKey: string): string {
|
||||||
|
return sessionID + "\0" + hookKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// topLevelSession maps a session to the top-level session it belongs to.
|
||||||
|
function topLevelSession(sessionID: string): string {
|
||||||
|
return rootOf.get(sessionID) ?? sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
// announceTask fires subagent-start the first time a task part is running
|
||||||
|
// with its child bound (state.metadata.sessionId). tool.execute.before fires
|
||||||
|
// earlier but has no child ID yet, and session.created for the child can
|
||||||
|
// interleave with a sibling's, so neither is a safe join. The running update
|
||||||
|
// repeats, hence once per call key. Called for task parts in child sessions
|
||||||
|
// too, so a nested subagent is announced on the top-level session.
|
||||||
|
function announceTask(part: any) {
|
||||||
|
if (!(part?.type === "tool" && part.tool === "task" && part.callID &&
|
||||||
|
part.state?.status === "running" && part.state?.metadata?.sessionId)) return
|
||||||
|
const sessionID = part.sessionID ?? currentSessionID
|
||||||
|
if (!sessionID) return
|
||||||
|
const key = callKey(sessionID, part.callID)
|
||||||
|
const partKey = callKey(sessionID, part.id)
|
||||||
|
if (announcedTasks.has(key)) return
|
||||||
|
announcedTasks.add(key)
|
||||||
|
trackChild(part.state.metadata.sessionId, sessionID)
|
||||||
|
// Without the before hook (the plugin loaded mid-call), the part's own
|
||||||
|
// start, OpenCode's clock before the child's prompt, still bounds it.
|
||||||
|
const startedAt = taskStartedAt.get(key) ?? taskStartedAt.get(partKey) ?? part.state?.time?.start ?? 0
|
||||||
|
if (stoppedBeforeStart.delete(key) || stoppedBeforeStart.delete(partKey)) {
|
||||||
|
forgetTaskKeys(part, sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
session_id: topLevelSession(sessionID),
|
||||||
|
tool_use_id: part.callID,
|
||||||
|
subagent_id: part.state.metadata.sessionId,
|
||||||
|
subagent_type: part.state?.input?.subagent_type ?? "",
|
||||||
|
task_description: part.state?.input?.description ?? "",
|
||||||
|
started_at: startedAt,
|
||||||
|
}
|
||||||
|
liveTasks.set(key, payload)
|
||||||
|
if (part.id) partCallID.set(partKey, key)
|
||||||
|
callHookSync("subagent-start", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// announcedKey maps a call key to the call key the task was announced under.
|
||||||
|
function announcedKey(key: string): string {
|
||||||
|
return partCallID.get(key) ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
// takeLiveTask removes and returns the announced, not yet stopped task a
|
||||||
|
// call key belongs to.
|
||||||
|
function takeLiveTask(key: string): Record<string, unknown> | undefined {
|
||||||
|
const announced = announcedKey(key)
|
||||||
|
partCallID.delete(key)
|
||||||
|
const payload = liveTasks.get(announced)
|
||||||
|
liveTasks.delete(announced)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetTaskKeys(part: any, sessionID: string) {
|
||||||
|
taskStartedAt.delete(callKey(sessionID, part.callID))
|
||||||
|
if (part.id) taskStartedAt.delete(callKey(sessionID, part.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopFailedTask fires subagent-stop for an announced task whose part
|
||||||
|
// ended in error without tool.execute.after: OpenCode skips that hook when
|
||||||
|
// the call is aborted (Esc, `opencode run` teardown) or its execute throws.
|
||||||
|
// A task that failed before its child was bound was never announced, so
|
||||||
|
// nothing fires for it.
|
||||||
|
function stopFailedTask(part: any) {
|
||||||
|
if (!(part?.type === "tool" && part.tool === "task" && part.callID &&
|
||||||
|
part.state?.status === "error")) return
|
||||||
|
const sessionID = part.sessionID ?? currentSessionID ?? ""
|
||||||
|
const key = callKey(sessionID, part.callID)
|
||||||
|
const partKey = callKey(sessionID, part.id)
|
||||||
|
const payload = takeLiveTask(key)
|
||||||
|
forgetTaskKeys(part, sessionID)
|
||||||
|
stoppedBeforeStart.delete(key)
|
||||||
|
if (part.id) {
|
||||||
|
partCallID.delete(partKey)
|
||||||
|
stoppedBeforeStart.delete(partKey)
|
||||||
|
}
|
||||||
|
if (!payload) return
|
||||||
|
stoppedOnError.add(key)
|
||||||
|
if (part.id) stoppedOnError.add(partKey)
|
||||||
|
callHookSync("subagent-stop", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishBackgroundTask fires the oldest held subagent-stop for a background
|
||||||
|
// child whose run went idle: its work finished, failed, or was aborted
|
||||||
|
// (`opencode run` exiting with the child still running). Each case ends the
|
||||||
|
// call; a later call queued on the same child ends at its own run's idle.
|
||||||
|
function finishBackgroundTask(childID: string) {
|
||||||
|
const payloads = backgroundTasks.get(childID)
|
||||||
|
const payload = payloads?.shift()
|
||||||
|
if (!payload) return
|
||||||
|
if (payloads.length === 0) backgroundTasks.delete(childID)
|
||||||
|
callHookSync("subagent-stop", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drainBackgroundTasks fires every held subagent-stop for a child whose
|
||||||
|
// background job is over. A job that fails or is cancelled drops the runs
|
||||||
|
// still queued on it, so their calls get no idle of their own; left held,
|
||||||
|
// the next resume of the child would end them at its idle instead.
|
||||||
|
function drainBackgroundTasks(childID: string) {
|
||||||
|
const payloads = backgroundTasks.get(childID)
|
||||||
|
if (!payloads) return
|
||||||
|
backgroundTasks.delete(childID)
|
||||||
|
for (const payload of payloads) callHookSync("subagent-stop", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A background job's end reaches the session that launched it as a
|
||||||
|
// synthetic text part: <task id="<child>" state="completed|error">.
|
||||||
|
const backgroundResult = /^<task id="([^"]+)" state="(completed|error)">/
|
||||||
|
|
||||||
|
// backgroundSessionAborted ends the held calls of an aborted session and of
|
||||||
|
// the children it launched (Esc cancels its background jobs and reports no
|
||||||
|
// result): at once for an idle child, at its next idle for a busy one.
|
||||||
|
function backgroundSessionAborted(sessionID: string) {
|
||||||
|
const targets = [sessionID]
|
||||||
|
for (const [childID, parentID] of parentOf) if (parentID === sessionID) targets.push(childID)
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!backgroundTasks.has(target)) continue
|
||||||
|
if (busySessions.has(target)) drainAtIdle.add(target)
|
||||||
|
else drainBackgroundTasks(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSessionTracking(sessionID: string) {
|
||||||
|
if (currentSessionID === sessionID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seenUserMessages.clear()
|
||||||
|
messageStore.clear()
|
||||||
|
currentModel = null
|
||||||
|
currentSessionID = sessionID
|
||||||
|
// Drop any turn-start injection captured for the prior session so it
|
||||||
|
// can't leak into the new session's system prompt.
|
||||||
|
pendingInjection = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Apply the one-time Entire context injection captured at turn-start by
|
||||||
|
// appending it to the system prompt for this LLM call.
|
||||||
|
"experimental.chat.system.transform": async (input: { sessionID?: string }, output: { system: string[] }) => {
|
||||||
|
if (input?.sessionID && childSessions.has(input.sessionID)) return
|
||||||
|
if (pendingInjection && Array.isArray(output.system)) {
|
||||||
|
output.system.push(pendingInjection)
|
||||||
|
pendingInjection = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Subagent call start. No child ID exists yet, so this only records the
|
||||||
|
// time; subagent-start fires from the running task part below.
|
||||||
|
"tool.execute.before": async (input) => {
|
||||||
|
try {
|
||||||
|
if (input.tool !== "task") return
|
||||||
|
const key = callKey(input.sessionID, input.callID)
|
||||||
|
// A new call under this key: some providers number calls per
|
||||||
|
// conversation, so an earlier call's markers must not dedupe or
|
||||||
|
// swallow this one's start and stop.
|
||||||
|
announcedTasks.delete(key)
|
||||||
|
stoppedBeforeStart.delete(key)
|
||||||
|
stoppedOnError.delete(key)
|
||||||
|
taskStartedAt.set(key, Date.now())
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Subagent completion. tool.execute.after for the task tool fires once, with
|
||||||
|
// the child session ID in output.metadata: at true completion for a
|
||||||
|
// foreground task, immediately for a background one (metadata.background),
|
||||||
|
// whose stop is held until the child goes idle. Synchronous: `opencode run`
|
||||||
|
// exits on the parent's idle right after this, and an async hook would be
|
||||||
|
// killed before it finished.
|
||||||
|
"tool.execute.after": async (input, output) => {
|
||||||
|
try {
|
||||||
|
if (input.tool !== "task") return
|
||||||
|
const key = callKey(input.sessionID, input.callID)
|
||||||
|
const announced = takeLiveTask(key)
|
||||||
|
if (stoppedOnError.delete(key) && !announced) {
|
||||||
|
// Already stopped: its part ended in error first.
|
||||||
|
taskStartedAt.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A failed command subtask fires this hook with no output; its
|
||||||
|
// announced child still ends here.
|
||||||
|
const childID = output?.metadata?.sessionId ?? announced?.subagent_id
|
||||||
|
if (!childID) {
|
||||||
|
taskStartedAt.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trackChild(childID, input.sessionID)
|
||||||
|
const startedAt = taskStartedAt.get(key) ?? 0
|
||||||
|
taskStartedAt.delete(key)
|
||||||
|
if (!announced) stoppedBeforeStart.add(key)
|
||||||
|
// A child's own task call (subagent_depth > 1) is recorded on the
|
||||||
|
// top-level session: the child has no Entire session of its own.
|
||||||
|
const payload = announced ?? {
|
||||||
|
session_id: topLevelSession(input.sessionID),
|
||||||
|
tool_use_id: input.callID,
|
||||||
|
subagent_id: childID,
|
||||||
|
subagent_type: input.args?.subagent_type ?? "",
|
||||||
|
task_description: input.args?.description ?? "",
|
||||||
|
started_at: startedAt,
|
||||||
|
}
|
||||||
|
if (output?.metadata?.background === true) {
|
||||||
|
backgroundTasks.set(childID, [...(backgroundTasks.get(childID) ?? []), payload])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callHookSync("subagent-stop", payload)
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
event: async ({ event }) => {
|
||||||
|
try {
|
||||||
|
const props = (event as any).properties
|
||||||
|
const info = props?.info
|
||||||
|
if (event.type.startsWith("session.") && info?.parentID && info?.id) trackChild(info.id, info.parentID)
|
||||||
|
if (event.type === "message.part.updated") {
|
||||||
|
announceTask(props?.part)
|
||||||
|
stopFailedTask(props?.part)
|
||||||
|
const part = props?.part
|
||||||
|
// Only a part in a tracked session: synthetic text also carries
|
||||||
|
// attachment contents, which could spell the same envelope.
|
||||||
|
const sid = part?.sessionID
|
||||||
|
const tracked = sid === currentSessionID || childSessions.has(sid) || [...parentOf.values()].includes(sid)
|
||||||
|
if (tracked && part?.type === "text" && part.synthetic === true && typeof part.text === "string") {
|
||||||
|
const result = backgroundResult.exec(part.text)
|
||||||
|
if (result) drainBackgroundTasks(result[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An aborted message, reported on session.error, or on the message
|
||||||
|
// itself when the run was interrupted outside the processor.
|
||||||
|
if (event.type === "session.error" && props?.error?.name === "MessageAbortedError" && props?.sessionID) {
|
||||||
|
backgroundSessionAborted(props.sessionID)
|
||||||
|
}
|
||||||
|
if (event.type === "message.updated" && info?.error?.name === "MessageAbortedError" && info?.sessionID) {
|
||||||
|
backgroundSessionAborted(info.sessionID)
|
||||||
|
}
|
||||||
|
if (event.type === "session.status" && props?.sessionID) {
|
||||||
|
if (props?.status?.type !== "idle") busySessions.add(props.sessionID)
|
||||||
|
else if (busySessions.delete(props.sessionID)) {
|
||||||
|
if (drainAtIdle.delete(props.sessionID)) drainBackgroundTasks(props.sessionID)
|
||||||
|
else finishBackgroundTask(props.sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const eventSessionID: string | undefined =
|
||||||
|
props?.sessionID ?? info?.sessionID ?? info?.id ?? props?.part?.sessionID
|
||||||
|
if (eventSessionID && childSessions.has(eventSessionID)) return
|
||||||
|
switch (event.type) {
|
||||||
|
case "session.created": {
|
||||||
|
const session = info
|
||||||
|
if (!session?.id) break
|
||||||
|
// Reset per-session tracking state when switching sessions.
|
||||||
|
if (resetSessionTracking(session.id)) {
|
||||||
|
await callHook("session-start", {
|
||||||
|
session_id: session.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.updated": {
|
||||||
|
const msg = info
|
||||||
|
if (!msg) break
|
||||||
|
|
||||||
|
if (msg.sessionID && resetSessionTracking(msg.sessionID)) {
|
||||||
|
callHookSync("session-start", {
|
||||||
|
session_id: msg.sessionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store message metadata (role, time, tokens, etc.)
|
||||||
|
messageStore.set(msg.id, msg)
|
||||||
|
// Track model from assistant messages
|
||||||
|
if (msg.role === "assistant" && msg.modelID) {
|
||||||
|
currentModel = msg.modelID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: some opencode run flows commit before any message.part.updated
|
||||||
|
// event is delivered for the user's prompt. Start the turn from the
|
||||||
|
// user message itself so git hooks see an ACTIVE session in time.
|
||||||
|
if (msg.role === "user" && !seenUserMessages.has(msg.id)) {
|
||||||
|
seenUserMessages.add(msg.id)
|
||||||
|
const sessionID = msg.sessionID ?? currentSessionID
|
||||||
|
if (sessionID) {
|
||||||
|
fireTurnStart({
|
||||||
|
session_id: sessionID,
|
||||||
|
prompt: "",
|
||||||
|
model: currentModel ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "message.part.updated": {
|
||||||
|
const part = props?.part
|
||||||
|
if (!part?.messageID) break
|
||||||
|
|
||||||
|
// Fire turn-start on the first text part of a new user message
|
||||||
|
const msg = messageStore.get(part.messageID)
|
||||||
|
if (msg?.role === "user" && part.type === "text" && !seenUserMessages.has(msg.id)) {
|
||||||
|
seenUserMessages.add(msg.id)
|
||||||
|
const sessionID = msg.sessionID ?? currentSessionID
|
||||||
|
if (sessionID) {
|
||||||
|
// A synthetic part is OpenCode writing, not the user: a
|
||||||
|
// background task's result arrives this way. The parent does
|
||||||
|
// take a turn on it, so the turn still starts, with no prompt.
|
||||||
|
fireTurnStart({
|
||||||
|
session_id: sessionID,
|
||||||
|
prompt: part.synthetic === true ? "" : (part.text ?? ""),
|
||||||
|
model: currentModel ?? "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.status": {
|
||||||
|
// session.status fires in both TUI and non-interactive (run) mode.
|
||||||
|
// session.idle is deprecated and not reliably emitted in run mode.
|
||||||
|
if (props?.status?.type !== "idle") break
|
||||||
|
const sessionID = props?.sessionID ?? currentSessionID
|
||||||
|
if (!sessionID) break
|
||||||
|
// Use sync variant: `opencode run` exits on the same idle event,
|
||||||
|
// so an async hook would be killed before completing.
|
||||||
|
callHookSync("turn-end", {
|
||||||
|
session_id: sessionID,
|
||||||
|
model: currentModel ?? "",
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.compacted": {
|
||||||
|
const sessionID = props?.sessionID
|
||||||
|
if (!sessionID) break
|
||||||
|
await callHook("compaction", {
|
||||||
|
session_id: sessionID,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "session.deleted": {
|
||||||
|
const session = info
|
||||||
|
if (!session?.id) break
|
||||||
|
seenUserMessages.clear()
|
||||||
|
messageStore.clear()
|
||||||
|
currentSessionID = null
|
||||||
|
pendingInjection = null
|
||||||
|
// childSessions/announcedTasks are NOT cleared here: a child's own
|
||||||
|
// deletion never reaches this case (the guard above returns first),
|
||||||
|
// so this only ever fires for an unrelated top-level session, and
|
||||||
|
// clearing here would drop a live child back to top-level mid-task.
|
||||||
|
// Use sync variant: session-end may fire during shutdown.
|
||||||
|
callHookSync("session-end", {
|
||||||
|
session_id: session.id,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case "server.instance.disposed": {
|
||||||
|
// Fires when OpenCode shuts down (TUI close or `opencode run` exit).
|
||||||
|
// session.deleted only fires on explicit user deletion, not on quit,
|
||||||
|
// so this is the only reliable way to end sessions on exit.
|
||||||
|
if (!currentSessionID) break
|
||||||
|
const sessionID = currentSessionID
|
||||||
|
seenUserMessages.clear()
|
||||||
|
messageStore.clear()
|
||||||
|
currentSessionID = null
|
||||||
|
pendingInjection = null
|
||||||
|
childSessions.clear()
|
||||||
|
rootOf.clear()
|
||||||
|
parentOf.clear()
|
||||||
|
announcedTasks.clear()
|
||||||
|
taskStartedAt.clear()
|
||||||
|
liveTasks.clear()
|
||||||
|
partCallID.clear()
|
||||||
|
stoppedBeforeStart.clear()
|
||||||
|
stoppedOnError.clear()
|
||||||
|
// A background child still running here is ended by the Go side's
|
||||||
|
// SessionEnd sweep, which completes every live task record.
|
||||||
|
backgroundTasks.clear()
|
||||||
|
busySessions.clear()
|
||||||
|
drainAtIdle.clear()
|
||||||
|
// Use sync variant: this is the last event before process exit.
|
||||||
|
callHookSync("session-end", {
|
||||||
|
session_id: sessionID,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore — plugin failures must not crash OpenCode
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
63
AGENTS.md
63
AGENTS.md
@@ -9,8 +9,8 @@ before changing anything; it encodes the conventions the CI enforces.
|
|||||||
Harness Web (dsh web) as a slim, non-root, multi-arch image. Everything else
|
Harness Web (dsh web) as a slim, non-root, multi-arch image. Everything else
|
||||||
exists to support it or to demonstrate an implementation on top of it.
|
exists to support it or to demonstrate an implementation on top of it.
|
||||||
- **Every implementation is a self-contained example** under examples/<name>/.
|
- **Every implementation is a self-contained example** under examples/<name>/.
|
||||||
There is exactly one example for the agents-as-a-service platform; do not
|
The multi-tenant platform lives in exactly one directory; do not split it into
|
||||||
split it into parallel directories.
|
parallel examples.
|
||||||
- **versions.env is the single source of truth** for the DSH ref, pnpm, the
|
- **versions.env is the single source of truth** for the DSH ref, pnpm, the
|
||||||
control-plane versions and the image names.
|
control-plane versions and the image names.
|
||||||
- **Docs describe the shipped behavior**, not intentions. If you change a
|
- **Docs describe the shipped behavior**, not intentions. If you change a
|
||||||
@@ -43,18 +43,18 @@ before changing anything; it encodes the conventions the CI enforces.
|
|||||||
| versions.env | Every pinned version and image name |
|
| versions.env | Every pinned version and image name |
|
||||||
| Makefile | Canonical local entrypoint |
|
| Makefile | Canonical local entrypoint |
|
||||||
| build.sh | Deprecated shim that forwards to make |
|
| build.sh | Deprecated shim that forwards to make |
|
||||||
| docs/ | main-image.md, examples.md, gitflow.md, versioning.md |
|
| docs/ | main-image.md, examples.md, gitflow.md, versioning.md (index: README.md) |
|
||||||
| examples/ | One implementation per directory (contract in docs/examples.md) |
|
| examples/ | One implementation per directory (contract in docs/examples.md) |
|
||||||
| examples/dsh-agents-as-a-service/ | The multi-tenant platform example (manifests, gateway, docs, scripts) |
|
| examples/dsh-agents-as-a-service/ | The multi-tenant platform example (manifests, gateway, docs, scripts) |
|
||||||
| examples/minimal-single-tenant/ | The just-one-DSH-Web quickstart |
|
| examples/minimal-single-tenant/ | The just-one-DSH-Web quickstart |
|
||||||
| scripts/ | check-versions.sh, check-examples.sh, check-manifests.py, set-version.sh |
|
| scripts/ | check-versions.sh, check-examples.sh, check-manifests.py, set-version.sh |
|
||||||
| .dsh/skills/ | Project-local DSH skills (auto-discovered); dsh-agent-sandbox-platform covers platform operation, MCP limits and extension recipes |
|
| .dsh/skills/ | Project-local DSH skills (auto-discovered); dsh-agent-sandbox-platform covers platform operation, MCP limits and extension recipes |
|
||||||
| .github/workflows/ | ci.yaml, reusable _build-image.yaml, build-*, release.yaml, e2e-kind.yaml, pr-title.yaml, codeql.yml |
|
| .github/workflows/ | ci.yaml (lint, dependency-review), ci-python.yaml, ci-manifests.yaml, ci-image.yaml (amd64 build + smoke test on Dockerfile/versions.env changes), reusable _build-image.yaml, build-*, release.yaml, e2e-kind.yaml, pr-title.yaml, codeql.yml |
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
make check # versions, example layout and manifest YAML (run before every commit)
|
make check # versions, example layout and manifest YAML (run before every commit)
|
||||||
make test # gateway unit tests (109+ tests)
|
make test # gateway unit tests (pytest)
|
||||||
make lint # ruff + shellcheck (+ yamllint/actionlint when installed)
|
make lint # ruff + shellcheck (+ yamllint/actionlint when installed)
|
||||||
make image # build the main DSH Web image (slow: clones and builds DSH)
|
make image # build the main DSH Web image (slow: clones and builds DSH)
|
||||||
make gateway # build the example gateway image (fast)
|
make gateway # build the example gateway image (fast)
|
||||||
@@ -72,21 +72,18 @@ needs kind plus the agent-sandbox CRDs (see the example README).
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Documentation only | make check; verify relative links |
|
| Documentation only | make check; verify relative links |
|
||||||
| Example manifest | make check; kubectl apply --dry-run=server in CI |
|
| Example manifest | make check; kubectl apply --dry-run=server in CI |
|
||||||
| Optional hardening (gVisor, nodeSelector, NetworkPolicy, provider keys) | make check; server dry-run in CI; manual verification on a capable cluster |
|
| Optional hardening (gVisor, nodeSelector, NetworkPolicy, provider keys, failed-Pod recovery) | make check; server dry-run in CI; manual verification on a capable cluster |
|
||||||
| Gateway code | make test; make lint; add or update tests |
|
| Gateway code | make test; make lint; add or update tests |
|
||||||
| Dockerfile / versions.env | make check; make image (amd64) and a container smoke test |
|
| Dockerfile / versions.env | make check; the CI `build-amd64` job (`ci-image.yaml`) builds and smoke-tests the image on the PR; run `make image` locally only when you need the image |
|
||||||
| Workflow | actionlint in CI; keep actions SHA-pinned; no hard-coded versions |
|
| Workflow | actionlint in CI; keep actions SHA-pinned; no hard-coded versions |
|
||||||
| New example | make check; all files described in docs/examples.md |
|
| New example | make check; all files described in docs/examples.md |
|
||||||
| Project skill (.dsh/skills/) | make check; frontmatter and size check; shellcheck skill scripts; load it with the skill tool |
|
| Project skill (.dsh/skills/) | make check; validate frontmatter; keep SKILL.md under ~8 KB; shellcheck skill scripts; load it with the skill tool |
|
||||||
|
|
||||||
## Versioning and releases
|
## Versioning and releases
|
||||||
|
|
||||||
- Release identity = DSH_VERSION, optionally with a repository revision suffix
|
- Release identity = DSH_VERSION plus an optional -rN repository revision.
|
||||||
for re-releases at the same DSH ref (current identity:
|
The current identity is dsh-v0.2.1-rc.5-r6 (first release at this DSH ref, so no
|
||||||
dsh-v0.2.1-rc.3-r4).
|
suffix); a repository-only change at the same ref publishes -r2.
|
||||||
- The current identity is dsh-v0.2.1-rc.3-r4 (stored in versions.env). The
|
|
||||||
dsh-v0.2.1-rc.3 image tag already exists and tags are immutable, which is why
|
|
||||||
the first release under this scheme carries -r2.
|
|
||||||
- Channels: release tags are immutable; latest follows the newest release;
|
- Channels: release tags are immutable; latest follows the newest release;
|
||||||
main is the development channel for both image packages.
|
main is the development channel for both image packages.
|
||||||
- A broken release is fixed by publishing -r(N+1), never by re-tagging.
|
- A broken release is fixed by publishing -r(N+1), never by re-tagging.
|
||||||
@@ -99,8 +96,9 @@ needs kind plus the agent-sandbox CRDs (see the example README).
|
|||||||
- Branch names: feat/, fix/, docs/, chore/, refactor/, test/.
|
- Branch names: feat/, fix/, docs/, chore/, refactor/, test/.
|
||||||
- Conventional Commit subjects and PR titles (feat(scope): ..., fix(scope): ...).
|
- Conventional Commit subjects and PR titles (feat(scope): ..., fix(scope): ...).
|
||||||
- PRs are squash-merged; main stays linear and protected.
|
- PRs are squash-merged; main stays linear and protected.
|
||||||
- Required checks: lint, python, manifests, gateway-image, pr-title, CodeQL,
|
- Required on every PR: lint, pr-title, CodeQL (both Analyze jobs) and
|
||||||
dependency-review.
|
dependency-review. python, manifests and build-amd64 are path-filtered
|
||||||
|
on-demand checks; run them when the change touches their scope.
|
||||||
- Keep diffs scoped; update docs with the code; never mix a version bump with
|
- Keep diffs scoped; update docs with the code; never mix a version bump with
|
||||||
unrelated changes.
|
unrelated changes.
|
||||||
|
|
||||||
@@ -108,9 +106,9 @@ needs kind plus the agent-sandbox CRDs (see the example README).
|
|||||||
|
|
||||||
1. Read docs/examples.md and examples/README.md.
|
1. Read docs/examples.md and examples/README.md.
|
||||||
2. Create examples/<name>/ with README.md, manifests/NN-*.yaml, optional
|
2. Create examples/<name>/ with README.md, manifests/NN-*.yaml, optional
|
||||||
manifests/exposure/<variant>/ (plus README), optional components with their
|
manifests/optional/<feature>/ or manifests/exposure/<variant>/ (each with a
|
||||||
own Dockerfile/README/tests, and scripts/ (executable, idempotent,
|
README), optional components with their own Dockerfile/README/tests, and
|
||||||
self-cleaning).
|
scripts/ (executable, idempotent, self-cleaning).
|
||||||
3. Publish component images under <registry>/<owner>/<repo>/<example>/<component>.
|
3. Publish component images under <registry>/<owner>/<repo>/<example>/<component>.
|
||||||
4. Add the example to examples/README.md; scripts/check-examples.sh enforces
|
4. Add the example to examples/README.md; scripts/check-examples.sh enforces
|
||||||
the layout.
|
the layout.
|
||||||
@@ -119,16 +117,33 @@ needs kind plus the agent-sandbox CRDs (see the example README).
|
|||||||
|
|
||||||
- AUTH_PROXY_HEADER is opt-in. Only enable it when the gateway Service is
|
- AUTH_PROXY_HEADER is opt-in. Only enable it when the gateway Service is
|
||||||
reachable exclusively through the authenticating proxy; otherwise any client
|
reachable exclusively through the authenticating proxy; otherwise any client
|
||||||
can spoof X-Forwarded-User.
|
can spoof X-Forwarded-User. The SSO variant's NetworkPolicy is required for
|
||||||
|
production, and `examples/dsh-agents-as-a-service/scripts/preflight-sso.sh`
|
||||||
|
verifies the setup (Secrets, gateway env, TLS reference, placeholders, second
|
||||||
|
paths).
|
||||||
- The example gateway reads the DSH launch token from pod logs. Treat pod logs
|
- The example gateway reads the DSH launch token from pod logs. Treat pod logs
|
||||||
as sensitive and keep its RBAC namespace-scoped.
|
as sensitive and keep its RBAC namespace-scoped.
|
||||||
|
- Browser sessions are opaque server-side ids held in gateway memory: they are
|
||||||
|
revocable (logout/delete/recreate/expiry) but a gateway restart logs everyone
|
||||||
|
out and horizontal scaling needs a shared store. Session cookies are never
|
||||||
|
accepted as bearer tokens.
|
||||||
|
- AUTH_RATE_LIMIT, SIGNUP_INVITE_CODE, MAX_IN_FLIGHT_PER_USER and
|
||||||
|
WS_MAX_MESSAGE_BYTES are in-process abuse guards, not ingress rate limits,
|
||||||
|
quotas or an audit log. Add those for an internet-facing deployment.
|
||||||
- The example shares provider keys (dsh-provider-keys plus the optional
|
- The example shares provider keys (dsh-provider-keys plus the optional
|
||||||
dsh-provider-keys-extra) across sandboxes; do not describe it as
|
dsh-provider-keys-extra) across sandboxes; do not describe it as
|
||||||
tenant-isolated.
|
tenant-isolated.
|
||||||
- gVisor, nodeSelector and the NetworkPolicies are opt-in and are not active in
|
- gVisor, nodeSelector, the shipped sandbox/exposure NetworkPolicies and the
|
||||||
the base manifests; do not describe the base example as hardened.
|
documented gateway egress policy, extra provider keys and failed-Pod recovery
|
||||||
- TLS terminates outside the gateway; set COOKIE_SECURE=true for HTTPS and keep
|
are opt-in and are not active in the base manifests; do not describe the base
|
||||||
the base manifest safe for local HTTP.
|
example as hardened.
|
||||||
|
The sandbox and gateway-egress policies are required production steps; an
|
||||||
|
exposure without its NetworkPolicy is demo-only. See the example's
|
||||||
|
production checklist.
|
||||||
|
- TLS terminates outside the gateway. Set COOKIE_SECURE=true for HTTPS and drop
|
||||||
|
ALLOW_INSECURE_COOKIES; the gateway refuses COOKIE_SECURE=false unless
|
||||||
|
ALLOW_INSECURE_COOKIES=1, which is only for the plain-HTTP local demo. Never
|
||||||
|
expose that combination.
|
||||||
|
|
||||||
## Ask before
|
## Ask before
|
||||||
|
|
||||||
|
|||||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -3,6 +3,67 @@
|
|||||||
All notable changes to this repository are documented here.
|
All notable changes to this repository are documented here.
|
||||||
Generated by git-cliff from Conventional Commits; do not edit this file by hand.
|
Generated by git-cliff from Conventional Commits; do not edit this file by hand.
|
||||||
|
|
||||||
|
## [dsh-v0.2.1-rc.5-r6] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **example:** Persist the agent workspace on the PVC via subPath (#34) (367294b)
|
||||||
|
- **example:** Start DSH's folder picker on the persisted workspace (#36) (82ee0d0)
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
- **workflows:** Streamline CI and remove redundant builds (#31) (dcb5b43)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Align readmes, docs and agent guidance with current behavior (#37) (24f23e0)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **gateway:** Answer client-aborted requests with 499, not a traceback (#32) (cbcc56f)
|
||||||
|
- Ship bubblewrap for DSH's command sandbox, keep agents usable without it (#35) (a09d4ac)
|
||||||
|
- **ci:** Harden workflow token scope and download integrity (#39) (2f959e6)
|
||||||
|
- **gateway:** Replace token cookies with revocable sessions and harden abuse controls (#40) (1a929e3)
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- **changelog:** Refresh for dsh-v0.2.1-rc.5 (#30) (b57916d)
|
||||||
|
- Bump docker/login-action (#28) (8c34f43)
|
||||||
|
- **deps:** Bump agent-sandbox to v1.0.9 (#38) (4c073ee)
|
||||||
|
- Improve package installation and add Python depend… (#42) (8e9bd47)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Add continue-on-error to CHANGELOG PR step (97c5632)
|
||||||
|
|
||||||
|
## [dsh-v0.2.1-rc.5] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **example:** Add optional failed-Pod recovery (#26) (14c3fb8)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ci:** Make release image digests unique and allow changelog PRs (#24) (2b43d5d)
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- **changelog:** Refresh for dsh-v0.2.1-rc.3-r4 (#23) (97abf75)
|
||||||
|
- **deps:** Bump agent-sandbox to v1.0.8 (#25) (96d3030)
|
||||||
|
- **release:** Dsh-v0.2.1-rc.5 (#29) (bed8e56)
|
||||||
|
|
||||||
|
## [dsh-v0.2.1-rc.3-r4] - 2026-10-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **skills:** Add agent-sandbox platform skill (#20) (bf24306)
|
||||||
|
- **example:** Add optional gVisor, nodeSelector, NetworkPolicy and provider keys (#21) (3e9ba38)
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- **changelog:** Refresh for dsh-v0.2.1-rc.3-r3 (#19) (8681412)
|
||||||
|
- **release:** Dsh-v0.2.1-rc.3-r4 (#22) (e2f1fe5)
|
||||||
|
|
||||||
## [dsh-v0.2.1-rc.3-r3] - 2026-10-25
|
## [dsh-v0.2.1-rc.3-r3] - 2026-10-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ Install the gateway dev dependencies once:
|
|||||||
- Use a Conventional Commit title, for example feat(gateway): add wake metrics
|
- Use a Conventional Commit title, for example feat(gateway): add wake metrics
|
||||||
or docs(examples): clarify SSO trust boundary. PR titles are linted.
|
or docs(examples): clarify SSO trust boundary. PR titles are linted.
|
||||||
- Keep the diff scoped. Squash merge into main; history stays linear.
|
- Keep the diff scoped. Squash merge into main; history stays linear.
|
||||||
- Required checks: lint, python, manifests, gateway-image, pr-title, CodeQL,
|
- Required on every PR: lint, pr-title, CodeQL (both Analyze jobs) and
|
||||||
and dependency-review. Branch protection is described in docs/gitflow.md.
|
dependency-review. python, manifests and build-amd64 are path-filtered
|
||||||
|
on-demand checks; run them when the change touches their scope. Branch
|
||||||
|
protection is described in docs/gitflow.md.
|
||||||
- Update the docs that the change affects; docs are part of the change, not a
|
- Update the docs that the change affects; docs are part of the change, not a
|
||||||
follow-up.
|
follow-up.
|
||||||
- Never commit a real secret. Secret-shaped YAML belongs in a *.yaml.example
|
- Never commit a real secret. Secret-shaped YAML belongs in a *.yaml.example
|
||||||
@@ -60,16 +62,12 @@ it by hand; the release workflow regenerates it.
|
|||||||
|
|
||||||
## Adding an example
|
## Adding an example
|
||||||
|
|
||||||
Read docs/examples.md, then add examples/<name>/ with:
|
Read docs/examples.md, then add examples/<name>/ with a README.md,
|
||||||
|
numbered manifests/NN-*.yaml, optional manifests/optional/<feature>/ or
|
||||||
- README.md (what it demonstrates, prerequisites, steps, teardown)
|
manifests/exposure/<variant>/ (each with a README), optional components (a
|
||||||
- manifests/NN-*.yaml (numbered, plain kubectl)
|
Dockerfile plus README and tests), and executable, idempotent, self-cleaning
|
||||||
- optional manifests/exposure/<variant>/ with its own README.md
|
scripts. Add the literal examples/<name>/ entry to examples/README.md;
|
||||||
- optional components (a Dockerfile plus README and tests per component)
|
scripts/check-examples.sh enforces the layout.
|
||||||
- scripts/ with executable, idempotent, self-cleaning scripts
|
|
||||||
- docs/ for deeper material
|
|
||||||
|
|
||||||
List it in examples/README.md. scripts/check-examples.sh enforces this layout.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
39
Dockerfile
39
Dockerfile
@@ -6,13 +6,14 @@
|
|||||||
# Build args (all pinned in versions.env, verified by scripts/check-versions.sh):
|
# Build args (all pinned in versions.env, verified by scripts/check-versions.sh):
|
||||||
# DSH_REPO upstream git remote to package
|
# DSH_REPO upstream git remote to package
|
||||||
# DSH_VERSION upstream git ref to build (tag or branch)
|
# DSH_VERSION upstream git ref to build (tag or branch)
|
||||||
|
# DSH_COMMIT full commit SHA DSH_VERSION must resolve to
|
||||||
# RELEASE_REVISION optional repository revision label (r2, r3, ...)
|
# RELEASE_REVISION optional repository revision label (r2, r3, ...)
|
||||||
# PNPM_VERSION pnpm used to build DSH
|
# PNPM_VERSION pnpm used to build DSH
|
||||||
# VCS_REF this repository's commit, recorded in OCI labels
|
# VCS_REF this repository's commit, recorded in OCI labels
|
||||||
# NODE_BUILD_IMAGE node image used to build DSH
|
# NODE_BUILD_IMAGE node image used to build DSH (pinned by digest)
|
||||||
# NODE_RUNTIME_IMAGE node image used at runtime
|
# NODE_RUNTIME_IMAGE node image used at runtime (pinned by digest)
|
||||||
ARG NODE_BUILD_IMAGE=docker.io/node:24-trixie
|
ARG NODE_BUILD_IMAGE=docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19
|
||||||
ARG NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim
|
ARG NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Build stage: clone DSH at the pinned ref, build, and package the CLI.
|
# Build stage: clone DSH at the pinned ref, build, and package the CLI.
|
||||||
@@ -20,7 +21,8 @@ ARG NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim
|
|||||||
FROM $NODE_BUILD_IMAGE AS build
|
FROM $NODE_BUILD_IMAGE AS build
|
||||||
|
|
||||||
ARG DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git
|
ARG DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git
|
||||||
ARG DSH_VERSION=dsh-v0.2.1-rc.3
|
ARG DSH_VERSION=dsh-v0.2.1-rc.5
|
||||||
|
ARG DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1
|
||||||
ARG PNPM_VERSION=11.28.5
|
ARG PNPM_VERSION=11.28.5
|
||||||
|
|
||||||
ENV CI=true \
|
ENV CI=true \
|
||||||
@@ -31,7 +33,15 @@ RUN corepack enable \
|
|||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
RUN git clone --depth 1 --branch "$DSH_VERSION" "$DSH_REPO" .
|
# Clone the pinned ref and fail closed unless it resolves to DSH_COMMIT. A moved
|
||||||
|
# tag would otherwise silently change what the image packages.
|
||||||
|
RUN git clone --depth 1 --branch "$DSH_VERSION" "$DSH_REPO" . \
|
||||||
|
&& actual="$(git -C /src rev-parse HEAD)" \
|
||||||
|
&& if [ "$actual" != "$DSH_COMMIT" ]; then \
|
||||||
|
echo "error: $DSH_REPO $DSH_VERSION resolved to $actual, expected DSH_COMMIT $DSH_COMMIT" >&2; \
|
||||||
|
echo "error: update DSH_VERSION/DSH_COMMIT in versions.env with scripts/set-version.sh" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -57,8 +67,8 @@ RUN cd /opt/dsh \
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
FROM $NODE_RUNTIME_IMAGE AS runtime
|
FROM $NODE_RUNTIME_IMAGE AS runtime
|
||||||
|
|
||||||
ARG DSH_VERSION=dsh-v0.2.1-rc.3
|
ARG DSH_VERSION=dsh-v0.2.1-rc.5
|
||||||
ARG RELEASE_REVISION=r4
|
ARG RELEASE_REVISION=r6
|
||||||
ARG PNPM_VERSION=11.28.5
|
ARG PNPM_VERSION=11.28.5
|
||||||
ARG VCS_REF=unknown
|
ARG VCS_REF=unknown
|
||||||
|
|
||||||
@@ -79,6 +89,11 @@ ENV NODE_ENV=production \
|
|||||||
|
|
||||||
# git/ripgrep/jq/yq/curl/gh are deliberate agent tooling parity: a sandboxed DSH
|
# git/ripgrep/jq/yq/curl/gh are deliberate agent tooling parity: a sandboxed DSH
|
||||||
# instance expects the usual developer CLI. tini reaps signals for a clean stop.
|
# instance expects the usual developer CLI. tini reaps signals for a clean stop.
|
||||||
|
# bubblewrap is DSH's first-choice command-sandbox runner on Linux. It ships
|
||||||
|
# unsetuid, so it needs unprivileged user namespaces plus the namespace/mount
|
||||||
|
# syscalls; a hardened pod (all capabilities dropped, RuntimeDefault seccomp)
|
||||||
|
# usually cannot create one, which is why DSH then falls back to Landlock and,
|
||||||
|
# when that is unavailable too, to DSH_PERMISSION_MODE=danger-full-access.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
@@ -90,8 +105,12 @@ RUN apt-get update \
|
|||||||
wget \
|
wget \
|
||||||
tini \
|
tini \
|
||||||
gh \
|
gh \
|
||||||
|
bubblewrap \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
python3-venv \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
|
&& apt-get autoclean -y \
|
||||||
&& install -d -o node -g node -m 0755 /workspace "$DSH_HOME" \
|
&& install -d -o node -g node -m 0755 /workspace "$DSH_HOME" \
|
||||||
&& corepack enable \
|
&& corepack enable \
|
||||||
&& corepack prepare "pnpm@$PNPM_VERSION" --activate \
|
&& corepack prepare "pnpm@$PNPM_VERSION" --activate \
|
||||||
@@ -144,6 +163,6 @@ WORKDIR /workspace
|
|||||||
USER node
|
USER node
|
||||||
|
|
||||||
EXPOSE 3080
|
EXPOSE 3080
|
||||||
VOLUME ["/home/node/.dsh"]
|
VOLUME ["/home/node/.dsh", "/workspace"]
|
||||||
STOPSIGNAL SIGTERM
|
STOPSIGNAL SIGTERM
|
||||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/dsh-entrypoint"]
|
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/dsh-entrypoint"]
|
||||||
|
|||||||
1
Makefile
1
Makefile
@@ -27,6 +27,7 @@ help: ## Show available targets
|
|||||||
image: ## Build the main DSH Web image (no push)
|
image: ## Build the main DSH Web image (no push)
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg DSH_VERSION=$(DSH_VERSION) \
|
--build-arg DSH_VERSION=$(DSH_VERSION) \
|
||||||
|
--build-arg DSH_COMMIT=$(DSH_COMMIT) \
|
||||||
--build-arg RELEASE_REVISION=$(RELEASE_REVISION) \
|
--build-arg RELEASE_REVISION=$(RELEASE_REVISION) \
|
||||||
--build-arg PNPM_VERSION=$(PNPM_VERSION) \
|
--build-arg PNPM_VERSION=$(PNPM_VERSION) \
|
||||||
--build-arg VCS_REF=$(VCS_REF) \
|
--build-arg VCS_REF=$(VCS_REF) \
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -20,7 +20,7 @@ docker run -d --name dsh-web \
|
|||||||
-p 3080:3080 \
|
-p 3080:3080 \
|
||||||
-v dsh-home:/home/node/.dsh \
|
-v dsh-home:/home/node/.dsh \
|
||||||
-e DEEPSEEK_API_KEY \
|
-e DEEPSEEK_API_KEY \
|
||||||
ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4
|
ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
|
||||||
```
|
```
|
||||||
|
|
||||||
DSH Web is **not usable at a bare `/`**. Without its launch-token cookie it
|
DSH Web is **not usable at a bare `/`**. Without its launch-token cookie it
|
||||||
@@ -45,7 +45,7 @@ docker run -d --name dsh-web -p 18081:3080 \
|
|||||||
-e DSH_PUBLIC_URL=http://localhost:18081 \
|
-e DSH_PUBLIC_URL=http://localhost:18081 \
|
||||||
-v dsh-home:/home/node/.dsh \
|
-v dsh-home:/home/node/.dsh \
|
||||||
-e DEEPSEEK_API_KEY \
|
-e DEEPSEEK_API_KEY \
|
||||||
ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4
|
ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
|
||||||
# then open http://localhost:18081/?token=...
|
# then open http://localhost:18081/?token=...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -94,21 +94,20 @@ ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway
|
|||||||
|
|
||||||
| Tag | Contents | Meaning |
|
| Tag | Contents | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `dsh-v0.2.1-rc.3-r4` | DSH Web, port `3080` | **Current release identity.** Immutable: once published it never moves. |
|
| `dsh-v0.2.1-rc.5-r6` | DSH Web, port `3080` | **Current release identity.** Immutable: once published it never moves. |
|
||||||
| `latest` | DSH Web | Most recent release; same digest as the current identity. |
|
| `latest` | DSH Web | Most recent release; same digest as the current identity. |
|
||||||
| `main` | DSH Web | Development channel rebuilt from every push to `main`. Not a release; not for production. |
|
| `main` | DSH Web | Development channel rebuilt when image-definition files change on `main`. Not a release; not for production. |
|
||||||
| `dsh-v0.2.1-rc.3` | DSH Web | **Legacy tag** published before the release-identity scheme. It is not updated. New deployments use `dsh-v0.2.1-rc.3-r4`. |
|
|
||||||
|
|
||||||
The example gateway is published under its nested path with the same release
|
The example gateway is published under its nested path with the same release
|
||||||
identity, e.g.
|
identity, e.g.
|
||||||
`ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.3-r4`.
|
`ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.5-r6`.
|
||||||
|
|
||||||
Release identities, channels and rollback rules are explained in
|
Release identities, channels and rollback rules are explained in
|
||||||
[docs/versioning.md](docs/versioning.md). Production deployments should pin a
|
[docs/versioning.md](docs/versioning.md). Production deployments should pin a
|
||||||
digest:
|
digest:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4
|
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
|
||||||
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
|
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -132,8 +131,8 @@ Self-contained implementations that consume the image. Index and contract:
|
|||||||
|
|
||||||
| Example | What it demonstrates |
|
| Example | What it demonstrates |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [`examples/dsh-agents-as-a-service/`](examples/dsh-agents-as-a-service/) | Full multi-tenant platform: SandboxTemplate with per-user PVCs, warm pool, one claim per user, suspend-when-idle, wake-on-return, browser gateway with signup + `HttpOnly` sessions + HTTP/WebSocket proxy, Ingress or Gateway API + GitHub SSO exposure, and opt-in gVisor/NetworkPolicy/provider-key hardening. Start with the [walkthrough](examples/dsh-agents-as-a-service/README.md) and the [architecture](examples/dsh-agents-as-a-service/docs/architecture.md). |
|
| [`examples/dsh-agents-as-a-service/`](examples/dsh-agents-as-a-service/) | Full multi-tenant platform: SandboxTemplate with per-user PVCs, warm pool, one claim per user, suspend-when-idle, wake-on-return, browser gateway with signup + `HttpOnly` sessions + HTTP/WebSocket proxy, Ingress or Gateway API + GitHub SSO exposure, and opt-in gVisor/NetworkPolicy/provider-key hardening and failed-Pod recovery. Start with the [walkthrough](examples/dsh-agents-as-a-service/README.md) and the [architecture](examples/dsh-agents-as-a-service/docs/architecture.md). |
|
||||||
| [`examples/minimal-single-tenant/`](examples/minimal-single-tenant/) | The smallest useful setup: one `SandboxTemplate` and one `SandboxClaim`, reached through `kubectl port-forward` + the launch-token URL. Manifest: [`manifests/sandbox-template.yaml`](examples/minimal-single-tenant/manifests/sandbox-template.yaml). |
|
| [`examples/minimal-single-tenant/`](examples/minimal-single-tenant/) | The smallest useful setup: one `SandboxTemplate` and one `SandboxClaim`, reached through `kubectl port-forward` + the launch-token URL. Manifest: [`manifests/10-sandboxtemplate.yaml`](examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml). |
|
||||||
|
|
||||||
The gateway is a component of the multi-tenant example and has its own
|
The gateway is a component of the multi-tenant example and has its own
|
||||||
[README](examples/dsh-agents-as-a-service/gateway/README.md) (API, configuration,
|
[README](examples/dsh-agents-as-a-service/gateway/README.md) (API, configuration,
|
||||||
@@ -164,7 +163,7 @@ guide and troubleshooting notes live beside `SKILL.md`.
|
|||||||
| [`scripts/`](scripts/) | Repository checks: versions, example layout, manifest YAML, version bump. |
|
| [`scripts/`](scripts/) | Repository checks: versions, example layout, manifest YAML, version bump. |
|
||||||
| [`.dsh/skills/`](.dsh/skills/) | Project-local DSH skills, auto-discovered from the git root. |
|
| [`.dsh/skills/`](.dsh/skills/) | Project-local DSH skills, auto-discovered from the git root. |
|
||||||
| [`.github/`](.github/) | CI, release automation, dependency updates, PR/issue templates. |
|
| [`.github/`](.github/) | CI, release automation, dependency updates, PR/issue templates. |
|
||||||
| [`.env.sample`](.env.sample) | Documented local configuration (copy to `.env`; never commit it). |
|
| [`.env.sample`](.env.sample) | Reference environment names for local runs; nothing reads it automatically. Never commit a real key. |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -189,34 +188,15 @@ required checks and releases: [docs/gitflow.md](docs/gitflow.md).
|
|||||||
|
|
||||||
The release identity is the upstream DSH ref plus an optional repository
|
The release identity is the upstream DSH ref plus an optional repository
|
||||||
revision: `DSH_VERSION` and `RELEASE_REVISION` in [versions.env](versions.env).
|
revision: `DSH_VERSION` and `RELEASE_REVISION` in [versions.env](versions.env).
|
||||||
The current identity is **`dsh-v0.2.1-rc.3-r4`** — the first release under the
|
The current identity is **`dsh-v0.2.1-rc.5-r6`** — the first release at this
|
||||||
mirrored-DSH scheme at the same upstream DSH ref as the legacy
|
upstream DSH ref, so it carries no repository revision. Tags are immutable; a
|
||||||
`dsh-v0.2.1-rc.3` tag. Tags are immutable; a broken release is fixed by
|
broken release is fixed by publishing `-r(N+1)`, never by re-tagging.
|
||||||
publishing `-r(N+1)`, never by re-tagging.
|
|
||||||
|
|
||||||
`CHANGELOG.md` is generated by [git-cliff](https://git-cliff.org) from
|
`CHANGELOG.md` is generated by [git-cliff](https://git-cliff.org) from
|
||||||
Conventional Commits and is never edited by hand. See
|
Conventional Commits and is never edited by hand. See
|
||||||
[`CHANGELOG.md`](CHANGELOG.md), [docs/versioning.md](docs/versioning.md) and
|
[`CHANGELOG.md`](CHANGELOG.md), [docs/versioning.md](docs/versioning.md) and
|
||||||
[cliff.toml](cliff.toml).
|
[cliff.toml](cliff.toml).
|
||||||
|
|
||||||
## Migrating from the previous layout
|
|
||||||
|
|
||||||
If you are coming from an older checkout, these paths moved. Nothing else changed
|
|
||||||
semantically; the content was split into the example it belongs to.
|
|
||||||
|
|
||||||
| Old path | New path |
|
|
||||||
| --- | --- |
|
|
||||||
| `gateway-api/dsh-demo/` (Gateway API + oauth2-proxy / GitHub SSO) | `examples/dsh-agents-as-a-service/manifests/exposure/gateway-api-sso/` (now `70-gateway.yaml` ... `74-tls-secret.yaml.example`) |
|
|
||||||
| `examples/dsh-agents-as-a-service/60-ingress.yaml` | `examples/dsh-agents-as-a-service/manifests/exposure/ingress/60-ingress.yaml` |
|
|
||||||
| `examples/dsh-agents-as-a-service/NN-*.yaml` (`00-` ... `50-`) | `examples/dsh-agents-as-a-service/manifests/NN-*.yaml` |
|
|
||||||
| `examples/dsh-agents-as-a-service/run-test-kind.sh` | `examples/dsh-agents-as-a-service/scripts/e2e-kind.sh` |
|
|
||||||
| `deploy/k8s/sandbox-template.yaml` | `examples/minimal-single-tenant/manifests/sandbox-template.yaml` |
|
|
||||||
| `docs/architecture.md` | `examples/dsh-agents-as-a-service/docs/architecture.md` |
|
|
||||||
|
|
||||||
The SSO walkthrough that lived in `gateway-api/dsh-demo/README.md` is merged
|
|
||||||
into the [multi-tenant example README](examples/dsh-agents-as-a-service/README.md)
|
|
||||||
(step 9) and the [exposure README](examples/dsh-agents-as-a-service/manifests/exposure/README.md).
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — local setup, PR workflow, what a change needs.
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — local setup, PR workflow, what a change needs.
|
||||||
|
|||||||
40
SECURITY.md
40
SECURITY.md
@@ -21,23 +21,41 @@ disclosure once a fix is available.
|
|||||||
from pod logs (pods/log). Anyone with access to those logs can impersonate a
|
from pod logs (pods/log). Anyone with access to those logs can impersonate a
|
||||||
tenant. Keep the gateway's RBAC namespace-scoped and treat pod logs as
|
tenant. Keep the gateway's RBAC namespace-scoped and treat pod logs as
|
||||||
sensitive. Replacing log scraping with an upstream token API is tracked in
|
sensitive. Replacing log scraping with an upstream token API is tracked in
|
||||||
the example architecture document.
|
the [example architecture document](examples/dsh-agents-as-a-service/docs/architecture.md#upstream-feature-request-remove-log-scraping).
|
||||||
|
- **Browser sessions are server-side.** The gateway session cookie holds an
|
||||||
|
opaque session id, not a user name or token; it is not accepted as a bearer
|
||||||
|
token. Sessions are revoked on logout, user delete, `/recreate` and expiry,
|
||||||
|
but they live in gateway memory, so a gateway restart logs every browser out
|
||||||
|
and scaling beyond one replica requires a shared store. Do not treat the
|
||||||
|
cookie as durable or cross-replica state.
|
||||||
- **SSO header trust boundary.** AUTH_PROXY_HEADER makes the gateway trust an
|
- **SSO header trust boundary.** AUTH_PROXY_HEADER makes the gateway trust an
|
||||||
identity header (for example X-Forwarded-User). Enable it only when the
|
identity header (for example X-Forwarded-User). Enable it only when the
|
||||||
gateway Service is reachable exclusively through the authenticating proxy;
|
gateway Service is reachable exclusively through the authenticating proxy
|
||||||
otherwise any client can spoof the header. The SSO exposure variant ships a
|
and its NetworkPolicy; otherwise any client can spoof the header. The SSO
|
||||||
NetworkPolicy that restricts gateway access to oauth2-proxy pods; a custom
|
exposure variant's policy is required, not optional: an exposure without it
|
||||||
exposure needs an equivalent.
|
is demo-only. Run `examples/dsh-agents-as-a-service/scripts/preflight-sso.sh`
|
||||||
|
after applying the variant; it checks the Secrets, gateway env, TLS
|
||||||
|
reference, leftover placeholders and accidental second paths.
|
||||||
|
- **Insecure-cookie guard.** TLS terminates outside the gateway. Set
|
||||||
|
COOKIE_SECURE=true for HTTPS and remove ALLOW_INSECURE_COOKIES; the gateway
|
||||||
|
refuses to start with COOKIE_SECURE=false unless ALLOW_INSECURE_COOKIES=1 is
|
||||||
|
explicitly set, which exists only for the plain-HTTP local demo. Never expose
|
||||||
|
that combination outside localhost.
|
||||||
|
- **Abuse controls are in-process.** AUTH_RATE_LIMIT/AUTH_RATE_WINDOW,
|
||||||
|
SIGNUP_INVITE_CODE, MAX_IN_FLIGHT_PER_USER and WS_MAX_MESSAGE_BYTES bound
|
||||||
|
casual abuse and resource exhaustion on the single gateway replica. They
|
||||||
|
reset on restart and are not an ingress rate limiter, WAF, quota system or
|
||||||
|
audit log; add those for an internet-facing deployment.
|
||||||
- **Shared provider keys.** The example injects DEEPSEEK_API_KEY and any keys in
|
- **Shared provider keys.** The example injects DEEPSEEK_API_KEY and any keys in
|
||||||
the optional dsh-provider-keys-extra Secret into every tenant sandbox (a
|
the optional dsh-provider-keys-extra Secret into every tenant sandbox (a
|
||||||
warm-pool constraint). Treat tenants as mutually trusting until per-tenant
|
warm-pool constraint). Treat tenants as mutually trusting until per-tenant
|
||||||
credentials or an egress proxy exist.
|
credentials or an egress proxy exist.
|
||||||
- **Optional isolation is opt-in.** gVisor, nodeSelector, the sandbox/exposure
|
- **The base example is demo-only, not hardened.** gVisor, nodeSelector, the
|
||||||
NetworkPolicies and extra provider keys are opt-in. Without a
|
sandbox/exposure NetworkPolicies, the gateway egress policy and extra provider
|
||||||
NetworkPolicy-capable CNI or a gVisor runtime, the base example is not
|
keys are opt-in. Without gVisor and the network policies there is no
|
||||||
tenant-isolated; see examples/dsh-agents-as-a-service/docs/optional-hardening.md.
|
tenant-to-tenant isolation; the sandboxes share the host kernel and can reach
|
||||||
- **TLS is external.** Terminate TLS at an Ingress or Gateway and set
|
cluster-internal endpoints. Production requirements are enumerated in the
|
||||||
COOKIE_SECURE=true. Plain HTTP outside localhost exposes session cookies.
|
[production checklist](examples/dsh-agents-as-a-service/docs/optional-hardening.md#6-production-checklist).
|
||||||
- **No secrets in the repository.** The repository only ships empty Secret
|
- **No secrets in the repository.** The repository only ships empty Secret
|
||||||
manifests and *.yaml.example files.
|
manifests and *.yaml.example files.
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,6 @@ commit_parsers = [
|
|||||||
]
|
]
|
||||||
protect_breaking_commits = false
|
protect_breaking_commits = false
|
||||||
filter_commits = false
|
filter_commits = false
|
||||||
# Release tags are dsh-vX.Y.Z[-rN]; the single legacy pre-scheme tag is v0.0.1.
|
# Release tags are dsh-vX.Y.Z[-rN]; the single pre-scheme tag is v0.0.1.
|
||||||
tag_pattern = "^(v|dsh-v).*$"
|
tag_pattern = "^(v|dsh-v).*$"
|
||||||
sort_commits = "oldest"
|
sort_commits = "oldest"
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
Repository-wide documentation lives here. Example-specific material lives with
|
Repository-wide documentation lives here. Example-specific material lives with
|
||||||
its example under [`examples/<name>/docs/`](../examples/) — see the
|
its example under [`examples/<name>/docs/`](../examples/) — see the
|
||||||
[agents-as-a-service architecture](../examples/dsh-agents-as-a-service/docs/architecture.md)
|
[agents-as-a-service architecture](../examples/dsh-agents-as-a-service/docs/architecture.md).
|
||||||
for the deep-dive that used to live in this directory.
|
|
||||||
|
|
||||||
If you are new, read the [root README](../README.md) first: it is image-first and
|
If you are new, read the [root README](../README.md) first: it is image-first and
|
||||||
gets a DSH Web container running before anything else.
|
gets a DSH Web container running before anything else.
|
||||||
@@ -15,7 +14,8 @@ gets a DSH Web container running before anything else.
|
|||||||
| [main-image.md](main-image.md) | The primary artifact: build args, runtime environment, entrypoint, probes, volumes/ports/signals, non-root contract, image names, tags and digests. |
|
| [main-image.md](main-image.md) | The primary artifact: build args, runtime environment, entrypoint, probes, volumes/ports/signals, non-root contract, image names, tags and digests. |
|
||||||
| [examples.md](examples.md) | The example contract: required layout, manifest rules, exposure variants, components and image naming, scripts, docs, and how to add a new example. |
|
| [examples.md](examples.md) | The example contract: required layout, manifest rules, exposure variants, components and image naming, scripts, docs, and how to add a new example. |
|
||||||
| [gitflow.md](gitflow.md) | Branches, protected `main`, squash merges, Conventional PR titles, required CI checks, release branches, hotfixes and the one-time GitHub settings checklist. |
|
| [gitflow.md](gitflow.md) | Branches, protected `main`, squash merges, Conventional PR titles, required CI checks, release branches, hotfixes and the one-time GitHub settings checklist. |
|
||||||
| [versioning.md](versioning.md) | Release identity (`DSH ref` + optional `-rN`), why the current identity is `dsh-v0.2.1-rc.3-r4`, channels, compatibility matrix, bumping pins, the release checklist, rollback, git-cliff and digest pinning. |
|
| [versioning.md](versioning.md) | Release identity (`DSH ref` + optional `-rN`), the current identity `dsh-v0.2.1-rc.5-r6`, why identities are immutable, channels, compatibility matrix, bumping pins, the release checklist, rollback, git-cliff and digest pinning. |
|
||||||
|
| [workflow-security.md](workflow-security.md) | GitHub Actions security posture: least-privilege tokens, pinned actions, untrusted-input handling, download integrity checks, artifact hygiene, the latest audit record and residual repository-setting recommendations. |
|
||||||
|
|
||||||
## Project skills
|
## Project skills
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ DSH discovers project skills from `<git root>/.dsh/skills/<name>/SKILL.md`
|
|||||||
| [dsh-agents-as-a-service README](../examples/dsh-agents-as-a-service/README.md) | The multi-tenant platform walkthrough: warm pool, claims, suspend/resume, gateway, exposure and troubleshooting. |
|
| [dsh-agents-as-a-service README](../examples/dsh-agents-as-a-service/README.md) | The multi-tenant platform walkthrough: warm pool, claims, suspend/resume, gateway, exposure and troubleshooting. |
|
||||||
| [dsh-agents-as-a-service architecture](../examples/dsh-agents-as-a-service/docs/architecture.md) | Container/sequence diagrams, ADRs, security and RBAC, failure modes and the evolution path. |
|
| [dsh-agents-as-a-service architecture](../examples/dsh-agents-as-a-service/docs/architecture.md) | Container/sequence diagrams, ADRs, security and RBAC, failure modes and the evolution path. |
|
||||||
| [gateway README](../examples/dsh-agents-as-a-service/gateway/README.md) | The gateway's HTTP/WebSocket API, configuration, auth (including SSO), and limitations. |
|
| [gateway README](../examples/dsh-agents-as-a-service/gateway/README.md) | The gateway's HTTP/WebSocket API, configuration, auth (including SSO), and limitations. |
|
||||||
|
| [optional hardening](../examples/dsh-agents-as-a-service/docs/optional-hardening.md) | Opt-in gVisor, nodeSelector, NetworkPolicy, provider keys and failed-Pod recovery. |
|
||||||
|
| [exposure variants](../examples/dsh-agents-as-a-service/manifests/exposure/README.md) | The mutually exclusive Ingress and Gateway API + SSO exposure paths. |
|
||||||
|
|
||||||
## Project policies
|
## Project policies
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ examples/<name>/
|
|||||||
example directory;
|
example directory;
|
||||||
- an example has no `README.md` or no `manifests/` directory;
|
- an example has no `README.md` or no `manifests/` directory;
|
||||||
- `manifests/` contains no top-level `*.yaml` files;
|
- `manifests/` contains no top-level `*.yaml` files;
|
||||||
|
- a top-level `manifests/*.yaml` is not named `NN-*.yaml`;
|
||||||
- a `*.yaml` file sits at the example root instead of under `manifests/`;
|
- a `*.yaml` file sits at the example root instead of under `manifests/`;
|
||||||
- `manifests/exposure/` exists without a `manifests/exposure/README.md` describing
|
- `manifests/exposure/` exists without a `manifests/exposure/README.md` describing
|
||||||
the variants;
|
the variants;
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ branch; the PR is squash-merged, so individual branch commits are never part of
|
|||||||
`docs(scope): ...`, `build: ...`, `ci: ...`, `refactor: ...`, `test: ...`,
|
`docs(scope): ...`, `build: ...`, `ci: ...`, `refactor: ...`, `test: ...`,
|
||||||
`chore: ...`. Breaking changes use `feat(scope)!: ...` or a
|
`chore: ...`. Breaking changes use `feat(scope)!: ...` or a
|
||||||
`BREAKING CHANGE:` footer.
|
`BREAKING CHANGE:` footer.
|
||||||
3. **CI must be green.** All required checks below must pass before merge.
|
3. **CI must be green.** All applicable checks below must pass before merge.
|
||||||
4. **Squash merge only.** `main` stays linear and protected; merged PRs are one
|
4. **Squash merge only.** `main` stays linear and protected; merged PRs are one
|
||||||
commit each.
|
commit each.
|
||||||
5. **No direct pushes to `main`** or to a release branch. Both are protected.
|
5. **No direct pushes to `main`** or to a release branch. Both are protected.
|
||||||
@@ -40,15 +40,33 @@ branch; the PR is squash-merged, so individual branch commits are never part of
|
|||||||
|
|
||||||
## Required checks
|
## Required checks
|
||||||
|
|
||||||
| Check | Workflow / job | What it verifies |
|
| Check | Workflow / job | Required on every PR? | What it verifies |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `lint` | `ci.yaml` | `scripts/check-versions.sh` (version pins and markdown tokens), ruff on the gateway, shellcheck on shell scripts, yamllint/actionlint when available. |
|
| `lint` | `ci.yaml` | yes | `scripts/check-versions.sh` (version pins and markdown tokens), `scripts/check-examples.sh` (example layout and numbered manifests), shellcheck, actionlint, yamllint, hadolint and documentation links. |
|
||||||
| `python` | `ci.yaml` | Gateway unit tests (`pytest`). |
|
| `python` | `ci-python.yaml` | no (path-filtered) | Gateway ruff check/format and unit tests (`pytest` with coverage). Runs only when the gateway or this workflow changes. |
|
||||||
| `manifests` | `ci.yaml` | `scripts/check-examples.sh`, `scripts/check-manifests.py` and a server-side dry-run of every example manifest against the agent-sandbox CRDs. |
|
| `manifests` | `ci-manifests.yaml` | no (path-filtered) | `scripts/check-manifests.py` (manifest shape) and a server-side dry-run of every example manifest against the agent-sandbox CRDs. Runs when manifest files, `scripts/check-manifests.py` or `versions.env` change. |
|
||||||
| `gateway-image` | `ci.yaml` | Builds the gateway image and smoke-tests `/healthz` without a cluster. |
|
| `pr-title` | `pr-title.yaml` | yes | Conventional Commit PR title. |
|
||||||
| `pr-title` | `pr-title.yaml` | Conventional Commit PR title. |
|
| `Analyze (python)`, `Analyze (actions)` | `codeql.yml` | yes (both jobs) | CodeQL static analysis for Python and GitHub Actions. |
|
||||||
| `CodeQL` | `codeql.yml` | Static analysis for Python and GitHub Actions. |
|
| `dependency-review` | `ci.yaml` | yes | Dependency changes introduced by the PR (job `dependency-review`; runs on pull requests only). |
|
||||||
| `dependency-review` | `ci.yaml` | Dependency changes introduced by the PR (job `dependency-review`; runs on pull requests only). |
|
|
||||||
|
`python`, `manifests` and `build-amd64` use native `on.pull_request.paths`
|
||||||
|
filtering, so PRs that do not touch the gateway, the manifests or the image
|
||||||
|
definition skip them. The gateway image is built and published by
|
||||||
|
`build-gateway.yaml` on merge; `/healthz` is covered by the gateway unit tests and
|
||||||
|
the Dockerfile is linted by `lint` (hadolint).
|
||||||
|
|
||||||
|
`ci-image.yaml` (`build-amd64`) is the PR-side check for the main image: when
|
||||||
|
`Dockerfile`, `.dockerignore` or `versions.env` change it builds `linux/amd64`
|
||||||
|
with the pins from `versions.env` — no registry login, no push — and smoke-tests
|
||||||
|
the result (`--version`, non-root UID, `/workspace` + `DSH_HOME`, the
|
||||||
|
`bubblewrap` runner, the version label, and `/favicon.svg` served from a running
|
||||||
|
container). The published multi-arch build still happens only in
|
||||||
|
`build-main.yaml` on merge.
|
||||||
|
|
||||||
|
Because a skipped path-filtered check is not a passing check, require only the
|
||||||
|
always-on set: `lint`, `pr-title`, both CodeQL `Analyze` jobs and
|
||||||
|
`dependency-review`. Keep `python`, `manifests` and `build-amd64` as on-demand
|
||||||
|
checks that skip when unrelated to the change.
|
||||||
|
|
||||||
The image build workflows (`_build-image.yaml`, `build-main.yaml`,
|
The image build workflows (`_build-image.yaml`, `build-main.yaml`,
|
||||||
`build-gateway.yaml`) and `e2e-kind.yaml` are **not** required on every PR: the
|
`build-gateway.yaml`) and `e2e-kind.yaml` are **not** required on every PR: the
|
||||||
@@ -90,7 +108,8 @@ Rollback is also a re-release, never a retag: see
|
|||||||
|
|
||||||
## Tag protection
|
## Tag protection
|
||||||
|
|
||||||
All release tags match `dsh-*` and are protected:
|
Release tags follow `dsh-*` and are protected (the pre-scheme `v0.0.1` tag is
|
||||||
|
historical and unmaintained):
|
||||||
|
|
||||||
- only maintainers (the release workflow) may create them;
|
- only maintainers (the release workflow) may create them;
|
||||||
- they cannot be deleted or force-updated;
|
- they cannot be deleted or force-updated;
|
||||||
@@ -104,9 +123,10 @@ respectively.
|
|||||||
|
|
||||||
Configure these on the repository before relying on the workflow:
|
Configure these on the repository before relying on the workflow:
|
||||||
|
|
||||||
- [ ] **Branches → main**: require a pull request; require the checks `lint`,
|
- [ ] **Branches → main**: require a pull request; require the always-on checks
|
||||||
`python`, `manifests`, `gateway-image`, `pr-title`, `CodeQL`,
|
`lint`, `pr-title`, both CodeQL `Analyze` jobs and `dependency-review`
|
||||||
`dependency-review`; require branches to be up to date; require conversation
|
(`python`, `manifests` and `build-amd64` are path-filtered and must not be
|
||||||
|
required); require branches to be up to date; require conversation
|
||||||
resolution; restrict direct pushes; allow only squash merges and enable
|
resolution; restrict direct pushes; allow only squash merges and enable
|
||||||
"Automatically delete head branches".
|
"Automatically delete head branches".
|
||||||
- [ ] **Branches → release/***: same protections, plus allow the release workflow
|
- [ ] **Branches → release/***: same protections, plus allow the release workflow
|
||||||
|
|||||||
@@ -11,14 +11,19 @@ The image is image-first: it is the product, and the examples under
|
|||||||
|
|
||||||
## What the build does
|
## What the build does
|
||||||
|
|
||||||
The Dockerfile is a two-stage build:
|
The Dockerfile is a two-stage build. Both stages use the `node` images pinned by
|
||||||
|
digest in [versions.env](../versions.env): a mutable tag alone is not trusted, and
|
||||||
|
`./scripts/check-versions.sh` rejects an image reference without a `@sha256:` digest.
|
||||||
|
|
||||||
1. **Build stage** (`node:24-trixie`) clones DSH at the pinned git ref, installs
|
1. **Build stage** (`NODE_BUILD_IMAGE`) clones DSH at the pinned git ref, verifies
|
||||||
|
that the clone's `HEAD` is exactly `DSH_COMMIT` (the build fails otherwise, so a
|
||||||
|
moved upstream tag cannot silently change what the image packages), installs
|
||||||
dependencies with pnpm, runs the production build, packages the `@deepseek-ai/dsh`
|
dependencies with pnpm, runs the production build, packages the `@deepseek-ai/dsh`
|
||||||
CLI with `pnpm pack`, and installs its production dependencies with npm.
|
CLI with `pnpm pack`, and installs its production dependencies with npm.
|
||||||
2. **Runtime stage** (`node:24-trixie-slim`) copies `/opt/dsh` to `/app`, adds the
|
2. **Runtime stage** (`NODE_RUNTIME_IMAGE`) copies `/opt/dsh` to `/app`, adds the
|
||||||
CLI tools DSH agents commonly use (`git`, `ripgrep`, `jq`, `yq`, `curl`, `wget`,
|
CLI tools DSH agents commonly use (`git`, `ripgrep`, `jq`, `yq`, `curl`, `wget`,
|
||||||
`gh`, `tini`, `uv`), and starts `dsh web` through a small entrypoint.
|
`gh`, `tini`, `uv`), installs `bubblewrap` as DSH's preferred command-sandbox
|
||||||
|
runner, and starts `dsh web` through a small entrypoint.
|
||||||
|
|
||||||
## Build it
|
## Build it
|
||||||
|
|
||||||
@@ -27,15 +32,19 @@ make image # canonical: builds the DSH Web image, no push
|
|||||||
```
|
```
|
||||||
|
|
||||||
`make image` reads [versions.env](../versions.env) and passes the pins as build args.
|
`make image` reads [versions.env](../versions.env) and passes the pins as build args.
|
||||||
|
Pull requests that touch `Dockerfile`, `.dockerignore` or `versions.env` get the
|
||||||
|
same build for `linux/amd64` from the [`ci-image.yaml`](../.github/workflows/ci-image.yaml)
|
||||||
|
workflow — no registry login, no push — followed by a container smoke test.
|
||||||
To reproduce the equivalent by hand:
|
To reproduce the equivalent by hand:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build \
|
docker build \
|
||||||
--build-arg DSH_VERSION=dsh-v0.2.1-rc.3 \
|
--build-arg DSH_VERSION=dsh-v0.2.1-rc.5-r6 \
|
||||||
--build-arg RELEASE_REVISION=r4 \
|
--build-arg DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1 \
|
||||||
|
--build-arg RELEASE_REVISION= \
|
||||||
--build-arg PNPM_VERSION=11.28.5 \
|
--build-arg PNPM_VERSION=11.28.5 \
|
||||||
--build-arg VCS_REF="$(git rev-parse --short HEAD)" \
|
--build-arg VCS_REF="$(git rev-parse --short HEAD)" \
|
||||||
-t ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 .
|
-t ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6 .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build args
|
### Build args
|
||||||
@@ -43,12 +52,18 @@ docker build \
|
|||||||
| Arg | Default / source | Purpose |
|
| Arg | Default / source | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `DSH_REPO` | `https://github.com/deepseek-ai/deepseek-harness.git` | Upstream DSH repository to clone. |
|
| `DSH_REPO` | `https://github.com/deepseek-ai/deepseek-harness.git` | Upstream DSH repository to clone. |
|
||||||
| `DSH_VERSION` | `DSH_VERSION` in [versions.env](../versions.env) | Upstream DSH git tag/ref to package; recorded as `org.opencontainers.image.version`. |
|
| `DSH_VERSION` | `DSH_VERSION` in [versions.env](../versions.env) | Upstream DSH git tag/ref to package. The Dockerfile records it as `io.sredevopsorg.dsh.version` and (for local builds) `org.opencontainers.image.version`; published builds set the version label to the full release identity. |
|
||||||
| `RELEASE_REVISION` | `RELEASE_REVISION` in [versions.env](../versions.env) | Repository revision suffix (`rN`); the release identity is `DSH_VERSION-RELEASE_REVISION`, recorded in the `io.sredevopsorg.release.revision` label. See [versioning](versioning.md). |
|
| `DSH_COMMIT` | `DSH_COMMIT` in [versions.env](../versions.env) | Full commit SHA `DSH_VERSION` must resolve to upstream. The build stage fails unless `git rev-parse HEAD` in the clone equals this value, which pins the source independently of a movable tag. |
|
||||||
|
| `RELEASE_REVISION` | `RELEASE_REVISION` in [versions.env](../versions.env) | Repository revision suffix (`rN`), recorded in `io.sredevopsorg.release.revision`. The release identity is `DSH_VERSION` plus `-RELEASE_REVISION` when set. See [versioning](versioning.md). |
|
||||||
| `PNPM_VERSION` | `PNPM_VERSION` in [versions.env](../versions.env) | pnpm version activated with corepack in both stages. |
|
| `PNPM_VERSION` | `PNPM_VERSION` in [versions.env](../versions.env) | pnpm version activated with corepack in both stages. |
|
||||||
| `VCS_REF` | `git rev-parse --short HEAD` (via `make image`) | Source revision recorded for traceability. |
|
| `VCS_REF` | `git rev-parse --short HEAD` (via `make image`) | Source revision recorded for traceability. |
|
||||||
| `NODE_BUILD_IMAGE` | `docker.io/node:24-trixie` | Build-stage base image. |
|
| `NODE_BUILD_IMAGE` | `docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19` | Build-stage base image, pinned to the multi-arch index digest. |
|
||||||
| `NODE_RUNTIME_IMAGE` | `docker.io/node:24-trixie-slim` | Runtime-stage base image. |
|
| `NODE_RUNTIME_IMAGE` | `docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446` | Runtime-stage base image, pinned to the multi-arch index digest. |
|
||||||
|
|
||||||
|
Base images are pinned by digest because tags are mutable: `node:24-trixie` can be
|
||||||
|
re-pointed at a new build at any time. Bumping the tag and its digest is a
|
||||||
|
deliberate, reviewable change in [versions.env](../versions.env); environments that
|
||||||
|
mirror the image should also mirror the `node` digest they build from.
|
||||||
|
|
||||||
Never hard-code a version outside [versions.env](../versions.env):
|
Never hard-code a version outside [versions.env](../versions.env):
|
||||||
`./scripts/check-versions.sh` (and `make check`) fails when a pin drifts.
|
`./scripts/check-versions.sh` (and `make check`) fails when a pin drifts.
|
||||||
@@ -76,10 +91,40 @@ The entrypoint is `tini -- /usr/local/bin/dsh-entrypoint`:
|
|||||||
| `DSH_TRUSTED_HOSTS` | empty | Extra `Host`/`Origin` allow-list entries, comma- or space-separated. `localhost`/`127.0.0.1` are always trusted. Required when a proxy forwards the browser's `Host`. |
|
| `DSH_TRUSTED_HOSTS` | empty | Extra `Host`/`Origin` allow-list entries, comma- or space-separated. `localhost`/`127.0.0.1` are always trusted. Required when a proxy forwards the browser's `Host`. |
|
||||||
| `DEEPSEEK_API_KEY` | empty | Provider credential read by DSH. Inject it with `-e`/`envFrom`; never bake it into the image. Only model calls fail when it is missing. |
|
| `DEEPSEEK_API_KEY` | empty | Provider credential read by DSH. Inject it with `-e`/`envFrom`; never bake it into the image. Only model calls fail when it is missing. |
|
||||||
| `DSH_HOME` | `/home/node/.dsh` | DSH state directory (conversations, sessions, config, credentials). Mount a volume here to persist it. |
|
| `DSH_HOME` | `/home/node/.dsh` | DSH state directory (conversations, sessions, config, credentials). Mount a volume here to persist it. |
|
||||||
|
| `HOME` | `/home/node` | OS home: where `~` resolves and where tools keep caches and config. DSH's directory picker has no start-path setting, so it lists `$HOME` when opened. The shipped sandbox templates override it to `/workspace` so the picker opens on the persisted workspace; the trade-offs are below. |
|
||||||
|
| `DSH_PERMISSION_MODE` | `workspace-write` | Deployment default for DSH's file policy: `read-only`, `workspace-write`, or `danger-full-access` (which also turns approval prompts off). The confined modes need `bubblewrap` or Landlock inside the container; where neither is usable every bash command fails closed with `SANDBOX_UNAVAILABLE`. See the note below. |
|
||||||
|
|
||||||
The image also sets `NODE_ENV=production`, `HOME=/home/node`, and a `PATH` that
|
The image also sets `NODE_ENV=production`, `HOME=/home/node`, and a `PATH` that
|
||||||
includes `/app/node_modules/.bin`.
|
includes `/app/node_modules/.bin`.
|
||||||
|
|
||||||
|
`HOME` decides where DSH's folder picker starts. DSH has no start-path setting:
|
||||||
|
the picker's browse backend lists `$HOME` when the dialog opens and labels that
|
||||||
|
listing *Home*. With the image default (`/home/node`) that listing is outside a
|
||||||
|
mounted volume; the example sandbox templates
|
||||||
|
([minimal](../examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml),
|
||||||
|
[agents as a service](../examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml))
|
||||||
|
mount the same PVC's `workspace` subPath at `/workspace` and set `HOME=/workspace`
|
||||||
|
so the picker opens on the persisted agent workspace. `~`
|
||||||
|
and every tool that keeps caches or config under `$HOME` (`npm`/`pnpm`, `uv`,
|
||||||
|
`git`, `gh`, DSH's global `~/.agents` instructions and skills) follow, so those
|
||||||
|
dotfiles then live on the workspace volume too. `DSH_HOME` is separate and stays
|
||||||
|
`/home/node/.dsh`, so sessions, config and credentials stay out of the
|
||||||
|
workspace. An existing sandbox stamps this environment at pod creation, so the
|
||||||
|
override only applies to sandboxes created after it was added.
|
||||||
|
|
||||||
|
DSH confines bash commands itself (a second layer inside whatever container
|
||||||
|
sandbox you already run). Its Linux runners are `bubblewrap` and then Landlock,
|
||||||
|
and it fails closed rather than running unconfined. The image ships
|
||||||
|
`bubblewrap`, but Debian's binary is unsetuid: it needs **unprivileged user
|
||||||
|
namespaces** plus the namespace/mount syscalls. A hardened pod — non-root,
|
||||||
|
`capabilities.drop: ["ALL"]`, `seccompProfile: RuntimeDefault` — usually cannot
|
||||||
|
create one, and it may expose no Landlock runner either, in which case every
|
||||||
|
command fails with `SANDBOX_UNAVAILABLE`. Set
|
||||||
|
`DSH_PERMISSION_MODE=danger-full-access` only when the surrounding pod or VM is
|
||||||
|
the isolation boundary you rely on; keep the confined modes when the host
|
||||||
|
provides a working runner (check `grep -o landlock /sys/kernel/security/lsm` and
|
||||||
|
`bwrap --ro-bind / / true` inside the container first).
|
||||||
|
|
||||||
### Provider credentials
|
### Provider credentials
|
||||||
|
|
||||||
DSH resolves provider API keys from the environment when no stored credential
|
DSH resolves provider API keys from the environment when no stored credential
|
||||||
@@ -108,7 +153,7 @@ procedure.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Exposed port | `3080/tcp` | `EXPOSE 3080`; one port serves the UI, assets, API, SSE and WebSocket traffic. |
|
| Exposed port | `3080/tcp` | `EXPOSE 3080`; one port serves the UI, assets, API, SSE and WebSocket traffic. |
|
||||||
| State volume | `/home/node/.dsh` | Declared with `VOLUME`; use a named volume (Docker) or PVC (Kubernetes). |
|
| State volume | `/home/node/.dsh` | Declared with `VOLUME`; use a named volume (Docker) or PVC (Kubernetes). |
|
||||||
| Working directory | `/workspace` | The agent workspace; mount a volume here if agents should keep files. |
|
| Working directory | `/workspace` | The agent workspace. Mount a volume here if agents should keep files; the shipped example templates mount the same PVC's `workspace` subPath here and point `HOME` at it, so DSH's folder picker opens on it. |
|
||||||
| Stop signal | `SIGTERM` | `tini` forwards it to `dsh web`; allow a graceful shutdown period. |
|
| Stop signal | `SIGTERM` | `tini` forwards it to `dsh web`; allow a graceful shutdown period. |
|
||||||
| User | `node`, UID/GID `1000` | The process is non-root. A PVC mounted at `DSH_HOME` must be writable by UID 1000. |
|
| User | `node`, UID/GID `1000` | The process is non-root. A PVC mounted at `DSH_HOME` must be writable by UID 1000. |
|
||||||
|
|
||||||
@@ -131,8 +176,8 @@ livenessProbe:
|
|||||||
periodSeconds: 15
|
periodSeconds: 15
|
||||||
```
|
```
|
||||||
|
|
||||||
The shipped templates use exactly this shape; see
|
The shipped templates use the same path and startup budget; see
|
||||||
[`examples/minimal-single-tenant/manifests/sandbox-template.yaml`](../examples/minimal-single-tenant/manifests/sandbox-template.yaml).
|
[`examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml`](../examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml).
|
||||||
A `startupProbe` is important because plugin activation (or the first start of a
|
A `startupProbe` is important because plugin activation (or the first start of a
|
||||||
large sandbox) can exceed a default liveness budget.
|
large sandbox) can exceed a default liveness budget.
|
||||||
|
|
||||||
@@ -164,17 +209,16 @@ GitHub Container Registry:
|
|||||||
|
|
||||||
| Tag | Meaning |
|
| Tag | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `dsh-v0.2.1-rc.3-r4` | Current release identity. **Immutable**: once published it never moves. |
|
| `dsh-v0.2.1-rc.5-r6` | Current release identity. **Immutable**: once published it never moves. |
|
||||||
| `latest` | Most recent release; points at the same digest as the current identity. |
|
| `latest` | Most recent release; points at the same digest as the current identity. |
|
||||||
| `main` | Development channel rebuilt from every push to `main`; not a release, not for production. |
|
| `main` | Development channel rebuilt when image-definition files change on `main`; not a release, not for production. |
|
||||||
| `dsh-v0.2.1-rc.3` | Legacy tag published before the release-identity scheme existed. Do not use it for new deployments; see the migration table in the [root README](../README.md). |
|
|
||||||
|
|
||||||
Release identities, channels and rollback rules are defined in
|
Release identities, channels and rollback rules are defined in
|
||||||
[versioning](versioning.md). Tags are immutable, so production deployments should
|
[versioning](versioning.md). Tags are immutable, so production deployments should
|
||||||
pin a digest:
|
pin a digest:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4
|
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
|
||||||
# ... pin the digest in your deployment manifests:
|
# ... pin the digest in your deployment manifests:
|
||||||
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
|
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
|
||||||
```
|
```
|
||||||
@@ -182,9 +226,10 @@ docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbo
|
|||||||
## Verifying an image
|
## Verifying an image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4 \
|
docker inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6 \
|
||||||
--format '{{json .Config.Labels}}'
|
--format '{{json .Config.Labels}}'
|
||||||
# org.opencontainers.image.title / .version / .source
|
# org.opencontainers.image.title / .version / .source
|
||||||
|
# io.sredevopsorg.dsh.version / io.sredevopsorg.release.revision
|
||||||
```
|
```
|
||||||
|
|
||||||
Release builds also publish SBOM and provenance attestations from the release
|
Release builds also publish SBOM and provenance attestations from the release
|
||||||
|
|||||||
@@ -15,48 +15,40 @@ release identity = DSH_VERSION + optional "-" + RELEASE_REVISION
|
|||||||
|
|
||||||
| Piece | Meaning |
|
| Piece | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `DSH_VERSION` | The upstream DeepSeek Harness git ref being packaged, e.g. `dsh-v0.2.1-rc.3`. It mirrors upstream's own tag, which is what "mirrored-DSH scheme" means. |
|
| `DSH_VERSION` | The upstream DeepSeek Harness git ref being packaged, e.g. `dsh-v0.2.1-rc.5-r6`. It mirrors upstream's own tag. |
|
||||||
| `RELEASE_REVISION` | An optional repository revision at that DSH ref: empty for the first release, `rN` (`N >= 2`) for a repository-only re-release. |
|
| `RELEASE_REVISION` | An optional repository revision at that DSH ref: empty for the first release, `rN` (`N >= 2`) for a repository-only re-release. |
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
- `DSH_VERSION=dsh-v0.2.1-rc.3`, `RELEASE_REVISION=` → identity `dsh-v0.2.1-rc.3`
|
- `DSH_VERSION=dsh-v0.2.1-rc.5-r6`, `RELEASE_REVISION=` → identity `dsh-v0.2.1-rc.5-r6`
|
||||||
(first release at that upstream ref).
|
(first release at that upstream ref).
|
||||||
- `DSH_VERSION=dsh-v0.2.1-rc.3`, `RELEASE_REVISION=r4` → identity
|
- `DSH_VERSION=<ref>`, `RELEASE_REVISION=rN` → identity `<ref>-rN` (for example
|
||||||
`dsh-v0.2.1-rc.3-r4` (documentation, CI or gateway changes on top of the same
|
documentation, CI or gateway changes on top of an already-released ref; no DSH
|
||||||
upstream DSH; no DSH upgrade).
|
upgrade). Use `r2` for the first re-release: revision 1 is the plain tag.
|
||||||
|
|
||||||
A DSH upgrade produces a new base identity (for example the next upstream ref
|
A DSH upgrade produces a new base identity (for example the next upstream ref
|
||||||
with an empty revision); a repository-only change at the same DSH ref increments
|
with an empty revision); a repository-only change at the same DSH ref increments
|
||||||
`rN`.
|
`rN`.
|
||||||
|
|
||||||
## Why the current identity is `dsh-v0.2.1-rc.3-r4`
|
## Why release identities are immutable
|
||||||
|
|
||||||
The image tag `dsh-v0.2.1-rc.3` was already published to GHCR for an earlier
|
**Container tags are immutable** in this project: re-pushing a tag would silently
|
||||||
repository state. **Container tags are immutable** in this project: re-pushing a
|
change what an existing deployment runs. A fix therefore publishes a new identity;
|
||||||
tag would silently change what an existing deployment runs. The mirrored-DSH
|
see the rules above.
|
||||||
scheme therefore starts its first identity at `-r2`: the plain tag is
|
|
||||||
conceptually revision 1 and is taken, so the first publishable repository
|
|
||||||
revision at the same upstream ref is `r2`.
|
|
||||||
|
|
||||||
The identity is stored in [versions.env](../versions.env) and consumed everywhere:
|
The current identity is `dsh-v0.2.1-rc.5-r6`, the first release at this upstream
|
||||||
the Dockerfile label, the example manifests, the Makefile and the release
|
ref, so it carries no revision suffix. The identity is stored in
|
||||||
workflow. The legacy `dsh-v0.2.1-rc.3` tag remains published but is not updated;
|
[versions.env](../versions.env) and consumed everywhere: the Dockerfile labels
|
||||||
new deployments use `dsh-v0.2.1-rc.3-r4`.
|
(version and revision), the example manifests, the Makefile and the release
|
||||||
|
workflow.
|
||||||
The gateway image moved from the repository-level `:gateway` tag to a nested
|
|
||||||
package (`<repository>/dsh-agents-as-a-service/gateway`). The release workflow
|
|
||||||
also republishes the legacy `<repository>:gateway` tag as a deprecated
|
|
||||||
compatibility alias; it may be removed in a later release, so deployments
|
|
||||||
should pin the nested package.
|
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
| Channel | Image tag | Meaning |
|
| Channel | Image tag | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Release | `dsh-v0.2.1-rc.3-r4` | The current immutable release identity. What production should pin (preferably by digest). |
|
| Release | `dsh-v0.2.1-rc.5-r6` | The current immutable release identity. What production should pin (preferably by digest). |
|
||||||
| `latest` | `latest` | Alias for the most recent release; moves only when a new release is published. |
|
| `latest` | `latest` | Alias for the most recent release; moves only when a new release is published. |
|
||||||
| `main` | `main` | Development channel, rebuilt from every push to `main`. Never treat it as a release; it exists so `main` can be tested. |
|
| `main` | `main` | Development channel, rebuilt when image or gateway sources change on `main`. Never treat it as a release; it exists so `main` can be tested. |
|
||||||
|
|
||||||
The same channels exist for every published package, including the nested
|
The same channels exist for every published package, including the nested
|
||||||
example component image.
|
example component image.
|
||||||
@@ -68,15 +60,28 @@ Values from [versions.env](../versions.env) at the current identity:
|
|||||||
| Component | Pin | Source |
|
| Component | Pin | Source |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Upstream DSH source | `DSH_REPO` | [versions.env](../versions.env) |
|
| Upstream DSH source | `DSH_REPO` | [versions.env](../versions.env) |
|
||||||
| DSH ref | `dsh-v0.2.1-rc.3` | `DSH_VERSION` |
|
| DSH ref | `dsh-v0.2.1-rc.5-r6` | `DSH_VERSION` |
|
||||||
| Repository revision | `r4` | `RELEASE_REVISION` |
|
| DSH commit | `b830ec950faba0a46dda564be3e2d854afed94f1` | `DSH_COMMIT`; the main image build fails unless the clone's `HEAD` matches |
|
||||||
|
| Repository revision | `(empty)` | `RELEASE_REVISION` |
|
||||||
| pnpm | `11.28.5` | `PNPM_VERSION` |
|
| pnpm | `11.28.5` | `PNPM_VERSION` |
|
||||||
| agent-sandbox (CRDs) | `v1.0.7` | `AGENT_SANDBOX_VERSION`, used by the examples and CI dry-run |
|
| Node build image | `docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19` | `NODE_BUILD_IMAGE`, digest-pinned |
|
||||||
|
| Node runtime image | `docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446` | `NODE_RUNTIME_IMAGE`, digest-pinned |
|
||||||
|
| Gateway Python image | `docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159` | `GATEWAY_PYTHON_IMAGE`, digest-pinned |
|
||||||
|
| agent-sandbox (CRDs) | `v1.0.9` | `AGENT_SANDBOX_VERSION`, used by the examples and CI dry-run |
|
||||||
| Kubernetes Gateway API | `v1.2.1` | `GATEWAY_API_VERSION`, used by the SSO exposure variant |
|
| Kubernetes Gateway API | `v1.2.1` | `GATEWAY_API_VERSION`, used by the SSO exposure variant |
|
||||||
| oauth2-proxy | `quay.io/oauth2-proxy/oauth2-proxy:v7.15.5` | `OAUTH2_PROXY_IMAGE` |
|
| oauth2-proxy | `quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:8498b0d0ef0a7b29686414000a08aee467f02d0299c9ed1e006a8f33fc017916` | `OAUTH2_PROXY_IMAGE`, digest-pinned and used by the SSO exposure variant |
|
||||||
| Main image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox` | `GHCR_IMAGE` |
|
| Main image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox` | `GHCR_IMAGE` |
|
||||||
| Gateway image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway` | `GATEWAY_IMAGE`, nested below `GHCR_IMAGE` |
|
| Gateway image | `ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway` | `GATEWAY_IMAGE`, nested below `GHCR_IMAGE` |
|
||||||
|
|
||||||
|
Every container base image is referenced as `name:tag@sha256:<index digest>`, not
|
||||||
|
by tag alone: `check-versions.sh` rejects a pin without a digest, so a re-pushed
|
||||||
|
tag cannot change what we build from. The example gateway's Python dependencies
|
||||||
|
are fully hash-pinned in
|
||||||
|
[requirements.txt](../examples/dsh-agents-as-a-service/gateway/requirements.txt)
|
||||||
|
and
|
||||||
|
[requirements-dev.txt](../examples/dsh-agents-as-a-service/gateway/requirements-dev.txt)
|
||||||
|
and installed with `pip install --require-hashes`.
|
||||||
|
|
||||||
Example component images are always nested below the main package:
|
Example component images are always nested below the main package:
|
||||||
`<GHCR_IMAGE>/<example>/<component>`. `check-versions.sh` rejects an unrelated
|
`<GHCR_IMAGE>/<example>/<component>`. `check-versions.sh` rejects an unrelated
|
||||||
top-level package.
|
top-level package.
|
||||||
@@ -94,8 +99,15 @@ file to derive from it. Never edit the Dockerfile, manifests, Makefile or
|
|||||||
workflows with a literal version.
|
workflows with a literal version.
|
||||||
|
|
||||||
- New upstream DSH ref: change `DSH_VERSION` and clear `RELEASE_REVISION`.
|
- New upstream DSH ref: change `DSH_VERSION` and clear `RELEASE_REVISION`.
|
||||||
- Repository-only change at the same DSH ref: keep `DSH_VERSION`, set
|
`set-version` resolves the new ref's commit with `git ls-remote` (peeled `^{}`
|
||||||
`RELEASE_REVISION=r(N+1)`.
|
commit first, tag ref as fallback) and rewrites `DSH_COMMIT` in `versions.env`,
|
||||||
|
the Dockerfile ARG default and `.env.sample`; the build then verifies it.
|
||||||
|
- Repository-only change at the same DSH ref: keep `DSH_VERSION` and `DSH_COMMIT`,
|
||||||
|
set `RELEASE_REVISION=r(N+1)`.
|
||||||
|
- Base images (Node and Python) are bumped by hand: change the tag *and* the
|
||||||
|
`@sha256:` digest in `versions.env` together, then run `make check` and a build.
|
||||||
|
There is deliberately no automated digest refresh, because a silent base-image
|
||||||
|
change is exactly what digest pinning exists to prevent.
|
||||||
|
|
||||||
## Release checklist
|
## Release checklist
|
||||||
|
|
||||||
@@ -106,14 +118,16 @@ workflows with a literal version.
|
|||||||
the tag does not already exist, and previews the generated notes.
|
the tag does not already exist, and previews the generated notes.
|
||||||
5. Create and push the annotated `dsh-*` release tag (or dispatch
|
5. Create and push the annotated `dsh-*` release tag (or dispatch
|
||||||
`release.yaml` with an existing tag). The workflow validates that the tag
|
`release.yaml` with an existing tag). The workflow validates that the tag
|
||||||
matches [versions.env](../versions.env).
|
matches [versions.env](../versions.env) and that the upstream ref still
|
||||||
|
resolves to `DSH_COMMIT` before building anything.
|
||||||
6. The workflow builds and pushes multi-arch images for the main image and the
|
6. The workflow builds and pushes multi-arch images for the main image and the
|
||||||
example components (the identity tag plus `latest`), creates the GitHub
|
example components (the identity tag plus `latest`), creates the GitHub
|
||||||
Release from the git-cliff notes, records the published digests, and opens a
|
Release from the git-cliff notes, and opens a `chore(changelog)` PR that
|
||||||
`chore(changelog)` PR that refreshes `CHANGELOG.md` on `main`.
|
refreshes `CHANGELOG.md` on `main`. The publish log shows each image manifest
|
||||||
|
and digest.
|
||||||
7. Merge the changelog PR. Verify:
|
7. Merge the changelog PR. Verify:
|
||||||
`docker buildx imagetools inspect <image>:<identity>` shows both platforms,
|
`docker buildx imagetools inspect <image>:<identity>` shows both platforms;
|
||||||
and the GitHub Release references the tag and the digest.
|
the GitHub Release references the tag, and the workflow log shows the digest.
|
||||||
8. Announce the identity and the digest; deployments pin the digest.
|
8. Announce the identity and the digest; deployments pin the digest.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
@@ -149,11 +163,20 @@ make changelog # regenerate CHANGELOG.md locally
|
|||||||
|
|
||||||
## Digest pinning
|
## Digest pinning
|
||||||
|
|
||||||
Release identities are mutable only in theory; deployments should still pin the
|
Supply-chain inputs are pinned to immutable content, not to mutable tags:
|
||||||
resolved digest for reproducibility and supply-chain safety:
|
|
||||||
|
- **Build inputs**: every container base image is referenced by multi-arch index
|
||||||
|
digest in [versions.env](../versions.env), and the example gateway's Python
|
||||||
|
dependencies are hash-pinned (`--require-hashes`).
|
||||||
|
- **Upstream source**: the Dockerfile clones `DSH_VERSION` and fails unless the
|
||||||
|
clone's `HEAD` equals `DSH_COMMIT`, so a moved upstream tag cannot change what
|
||||||
|
a rebuild packages.
|
||||||
|
|
||||||
|
Release identities are immutable in theory; deployments should still pin the
|
||||||
|
_resolved_ digest for reproducibility and supply-chain safety:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4
|
docker buildx imagetools inspect ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
|
||||||
# then, in the deployment:
|
# then, in the deployment:
|
||||||
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
|
# image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox@sha256:<digest>
|
||||||
```
|
```
|
||||||
|
|||||||
69
docs/workflow-security.md
Normal file
69
docs/workflow-security.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Workflow security (GitHub Actions)
|
||||||
|
|
||||||
|
The CI/CD surface lives entirely in [`.github/workflows/`](../.github/workflows/)
|
||||||
|
and runs on GitHub-hosted runners. This page describes the controls those
|
||||||
|
workflows enforce and records the most recent audit. They complement the threat
|
||||||
|
notes in [SECURITY.md](../SECURITY.md) and the release procedure in
|
||||||
|
[versioning.md](versioning.md).
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Control | Where | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Least-privilege tokens | Every workflow | A top-level `permissions:` block defaults to read-only; `packages: write`, `contents: write`, `id-token: write` and `attestations: write` are granted only to the jobs that publish images, attestations or releases. |
|
||||||
|
| Pinned actions | Every `uses:` | Third-party actions and reusable workflows are pinned to a full 40-character commit SHA (the version is kept in the trailing comment). Dependabot updates the pins weekly. The only unpinned reference is the in-repository `./.github/workflows/_build-image.yaml`. |
|
||||||
|
| No privileged untrusted triggers | Trigger blocks | No workflow uses `pull_request_target` or `workflow_run`. PR-triggered workflows run untrusted code with a read-only token and no secrets. |
|
||||||
|
| Untrusted input is data | `release.yaml` | The release tag (from a tag push or `workflow_dispatch` input) is passed through `env:` and read as a shell variable; it is never interpolated into a `run:` script, so metacharacters in a tag name cannot execute. |
|
||||||
|
| Checkout credential hygiene | Checkout steps | `persist-credentials: false` is set wherever the job does not need to push. The changelog job keeps credentials because it pushes a branch and opens a PR with `GITHUB_TOKEN`. |
|
||||||
|
| Download integrity | `ci.yaml`, `release.yaml` | actionlint, hadolint and git-cliff are downloaded with `curl --proto '=https'` and verified against a pinned SHA-256 with `sha256sum -c` before execution. |
|
||||||
|
| Cluster manifest integrity | `ci-manifests.yaml`, `e2e-kind.yaml` | The agent-sandbox and Gateway API release manifests are downloaded and checked against the `*_MANIFEST_SHA256` pins in [versions.env](../versions.env) before `kubectl apply`. |
|
||||||
|
| Log and artifact hygiene | `ci-image.yaml`, `e2e-kind.yaml` | Pod logs are truncated and token material is redacted before logs are printed or diagnostic files are uploaded as artifacts. |
|
||||||
|
| Supply-chain monitoring | Repository settings | Dependabot (GitHub Actions, pip, Docker), CodeQL advanced setup and the dependency-review action run on every applicable change. CODEOWNERS covers the whole repository, including `.github/workflows/`. |
|
||||||
|
|
||||||
|
## Maintaining the pins
|
||||||
|
|
||||||
|
- The tool versions and their SHA-256 digests live together in the install step
|
||||||
|
(`ACTIONLINT_SHA256`, `HADOLINT_SHA256`, `GIT_CLIFF_SHA256`); update both
|
||||||
|
values when bumping a version.
|
||||||
|
- Control-plane versions are in `versions.env`; update
|
||||||
|
`AGENT_SANDBOX_MANIFEST_SHA256` or `GATEWAY_API_MANIFEST_SHA256` together with
|
||||||
|
the corresponding version, otherwise CI fails the digest check by design.
|
||||||
|
- GitHub Actions pins are updated by Dependabot; never replace a SHA with a tag.
|
||||||
|
|
||||||
|
## Audit record (2026-10-30)
|
||||||
|
|
||||||
|
Scope: all 11 workflows under `.github/workflows/`, plus `.github/CODEOWNERS`
|
||||||
|
and `.github/dependabot.yml`. Findings are grouped by the categories in
|
||||||
|
GitHub's [Secure use reference](https://docs.github.com/en/actions/reference/security/secure-use).
|
||||||
|
|
||||||
|
| # | Category | File | Severity | Finding | Status |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Script injection | `release.yaml` | High | Tag name / dispatch input interpolated into a privileged `run:` block | Fixed: passed through `env` |
|
||||||
|
| 2 | Token permissions | `release.yaml` | Medium | `meta` and `tests` inherited workflow-level write scopes | Fixed: workflow default is `contents: read` |
|
||||||
|
| 3 | Token permissions | `build-main.yaml`, `build-gateway.yaml` | Low | `meta` inherited `packages`/`id-token`/`attestations` write | Fixed: workflow default is `contents: read` |
|
||||||
|
| 4 | Checkout | `_build-image.yaml` | Low | Checkout persisted the token in `.git/config` | Fixed: `persist-credentials: false` |
|
||||||
|
| 5 | Checkout | `codeql.yml` | Low | Checkout persisted the token in `.git/config` | Fixed: `persist-credentials: false` |
|
||||||
|
| 6 | Supply chain | `ci.yaml` | Medium | actionlint and hadolint were executed without a checksum | Fixed: pinned SHA-256 verified before install |
|
||||||
|
| 7 | Supply chain | `release.yaml` | Medium | git-cliff was executed without a checksum in the release job | Fixed: pinned SHA-256 verified before install |
|
||||||
|
| 8 | Supply chain | `ci-manifests.yaml`, `e2e-kind.yaml` | Medium | Upstream CRD/controller manifests applied without integrity checks | Fixed: pinned SHA-256 in `versions.env` |
|
||||||
|
| 9 | Secret hygiene | `e2e-kind.yaml` | Low | Raw gateway logs uploaded in a 7-day artifact | Fixed: `token=` values redacted |
|
||||||
|
| 10 | Repository controls | Repository settings | Low | No environment approval gate on publishing; single CODEOWNER | Recommendation: see below |
|
||||||
|
|
||||||
|
Checked and clean: no `pull_request_target`/`workflow_run`, no self-hosted
|
||||||
|
runners, all third-party actions pinned, PR workflows read-only, no secrets in
|
||||||
|
workflow files, no secret interpolation into scripts.
|
||||||
|
|
||||||
|
## Residual recommendations (repository settings)
|
||||||
|
|
||||||
|
These cannot be enforced from workflow files alone:
|
||||||
|
|
||||||
|
- Add a protected `release` environment with required reviewers and attach it to
|
||||||
|
the `build-main`, `build-gateway` and `publish-release` jobs. This requires the
|
||||||
|
environment to exist in repository settings first, so it is intentionally not
|
||||||
|
wired into the workflow yet.
|
||||||
|
- Keep "Allow GitHub Actions to create and approve pull requests" limited to
|
||||||
|
creating PRs (the changelog job needs create, not approve).
|
||||||
|
- Require review from Code Owners for `.github/workflows/` and add a second
|
||||||
|
owner so workflow changes are never single-person.
|
||||||
|
- Optionally run OpenSSF Scorecard on a schedule for continuous checks on these
|
||||||
|
controls.
|
||||||
@@ -11,7 +11,7 @@ reference material, not libraries.
|
|||||||
| [`examples/minimal-single-tenant/`](minimal-single-tenant/) | The smallest useful setup: a single `SandboxTemplate` and one `SandboxClaim`, no gateway — reach DSH Web with a launch-token URL through `kubectl port-forward`. | agent-sandbox with extensions. |
|
| [`examples/minimal-single-tenant/`](minimal-single-tenant/) | The smallest useful setup: a single `SandboxTemplate` and one `SandboxClaim`, no gateway — reach DSH Web with a launch-token URL through `kubectl port-forward`. | agent-sandbox with extensions. |
|
||||||
|
|
||||||
The multi-tenant example also documents opt-in gVisor, `nodeSelector`,
|
The multi-tenant example also documents opt-in gVisor, `nodeSelector`,
|
||||||
NetworkPolicy and extra provider-key hardening in
|
NetworkPolicy, extra provider-key hardening and failed-Pod recovery in
|
||||||
[optional hardening](dsh-agents-as-a-service/docs/optional-hardening.md).
|
[optional hardening](dsh-agents-as-a-service/docs/optional-hardening.md).
|
||||||
|
|
||||||
## The example contract (short version)
|
## The example contract (short version)
|
||||||
@@ -19,37 +19,26 @@ NetworkPolicy and extra provider-key hardening in
|
|||||||
Full rules: [docs/examples.md](../docs/examples.md). `scripts/check-examples.sh`
|
Full rules: [docs/examples.md](../docs/examples.md). `scripts/check-examples.sh`
|
||||||
enforces the layout, and `make check` runs it.
|
enforces the layout, and `make check` runs it.
|
||||||
|
|
||||||
```
|
- `README.md` is the walkthrough; `manifests/NN-*.yaml` is applied with plain
|
||||||
examples/<name>/
|
kubectl and read in order.
|
||||||
+-- README.md # walkthrough: prerequisites, steps, troubleshooting, cleanup
|
- Optional `manifests/optional/<feature>/` and `manifests/exposure/<variant>/`
|
||||||
+-- manifests/ # NN-*.yaml, applied with plain kubectl
|
each ship a README; exposure variants are mutually exclusive.
|
||||||
| +-- optional/<feature>/ # optional opt-in manifests...
|
- Optional `scripts/` must be executable, idempotent and self-cleaning; `docs/`
|
||||||
| +-- optional/README.md # ...required whenever optional/ exists
|
holds deeper material.
|
||||||
| +-- exposure/<variant>/ # optional per-variant manifests...
|
- Components with a Dockerfile need a README and tests; images are published
|
||||||
| +-- exposure/README.md # ...required whenever exposure/ exists
|
nested below the main image (`<registry>/<owner>/<repo>/<example>/<component>`).
|
||||||
+-- scripts/ # optional executable, idempotent, self-cleaning *.sh
|
- Secrets are `*.yaml.example` with placeholders; every example is listed above
|
||||||
+-- docs/ # optional architecture/ADR material
|
with its literal `examples/<name>/` path.
|
||||||
+-- <component>/ # optional component with Dockerfile + README + tests
|
|
||||||
```
|
|
||||||
|
|
||||||
- Secrets never ship populated: use `*.yaml.example` files with placeholders.
|
|
||||||
- Manifests are numbered (`00-`, `10-`, ...) and read as an ordered walkthrough.
|
|
||||||
- Component images are published nested below the main image:
|
|
||||||
`<registry>/<owner>/<repo>/<example>/<component>`.
|
|
||||||
- Every example must be listed above with its literal `examples/<name>/` path.
|
|
||||||
- Scripts under `scripts/` must be executable, idempotent and clean up after
|
|
||||||
themselves.
|
|
||||||
|
|
||||||
## Running an example
|
## Running an example
|
||||||
|
|
||||||
```bash
|
Each example README has the exact prerequisites, commands, optional hardening,
|
||||||
# e.g. the multi-tenant platform example
|
exposure options and teardown. The multi-tenant example also has a full kind
|
||||||
kubectl apply -f examples/dsh-agents-as-a-service/manifests/
|
walkthrough that cleans up after itself:
|
||||||
|
|
||||||
# or run the whole walkthrough (kind + agent-sandbox required) and let it clean up
|
```bash
|
||||||
examples/dsh-agents-as-a-service/scripts/e2e-kind.sh
|
examples/dsh-agents-as-a-service/scripts/e2e-kind.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`kubectl apply -f manifests/` is non-recursive: `optional/` and `exposure/`
|
`kubectl apply -f manifests/` is non-recursive: `optional/` and `exposure/`
|
||||||
subdirectories are applied explicitly. Each example README has the exact
|
subdirectories are applied explicitly.
|
||||||
prerequisites, optional hardening, exposure options and teardown.
|
|
||||||
|
|||||||
@@ -6,16 +6,19 @@ platform pattern into pure agent-sandbox resources + kubectl, running **DeepSeek
|
|||||||
Harness Web (DSH)** instead of Hermes. Every user of such a platform gets a
|
Harness Web (DSH)** instead of Hermes. Every user of such a platform gets a
|
||||||
personal, long-lived DSH Web instance that is **provisioned in ~2 seconds from a
|
personal, long-lived DSH Web instance that is **provisioned in ~2 seconds from a
|
||||||
warm pool, suspended when idle to save cost, and woken transparently when the
|
warm pool, suspended when idle to save cost, and woken transparently when the
|
||||||
user returns** — with all state (conversations, sessions, config) surviving on a
|
user returns** — with all state (conversations, sessions, config, and the agent
|
||||||
per-user PVC at `/home/node/.dsh`.
|
workspace) surviving on the per-user PVC at `/home/node/.dsh` + `/workspace`.
|
||||||
|
Suspend-when-idle plus the warm pool keep cost proportional to use: a return
|
||||||
|
after absence pays a short cold-start pause, not an always-on instance.
|
||||||
|
|
||||||
It ships in three layers:
|
It ships in three layers:
|
||||||
|
|
||||||
1. **The resource layer** (steps 1–7): pure manifests + kubectl, so every
|
1. **The resource layer** (steps 1–7): pure manifests + kubectl, so every
|
||||||
mechanism is visible.
|
mechanism is visible.
|
||||||
2. **The service layer** (step 8): a small included **gateway** (`gateway/`) that
|
2. **The service layer** (step 8): a small included **gateway** (`gateway/`) that
|
||||||
makes the platform browser-usable — a signup page/API, `HttpOnly` cookie
|
makes the platform browser-usable — a signup page/API, revocable server-side
|
||||||
sessions, a `/u/{user}/**` proxy for HTTP **and WebSocket** that transparently
|
**opaque-cookie sessions**, auth rate limiting and an optional signup invite
|
||||||
|
code, a `/u/{user}/**` proxy for HTTP **and WebSocket** that transparently
|
||||||
**wakes suspended instances on connect**, and an idle sweeper that suspends
|
**wakes suspended instances on connect**, and an idle sweeper that suspends
|
||||||
them again. The gateway performs the DSH launch-token handshake server-side,
|
them again. The gateway performs the DSH launch-token handshake server-side,
|
||||||
so the browser never sees the token.
|
so the browser never sees the token.
|
||||||
@@ -46,7 +49,7 @@ What it demonstrates, feature by feature:
|
|||||||
|
|
||||||
| agent-sandbox feature | Role in the platform |
|
| agent-sandbox feature | Role in the platform |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `SandboxTemplate` with `volumeClaimTemplates` | Identical DSH pods; per-user state on a PVC at `/home/node/.dsh` |
|
| `SandboxTemplate` with `volumeClaimTemplates` | Identical DSH pods; per-user state at `/home/node/.dsh` and the agent workspace at `/workspace`, on one PVC (the workspace is a `subPath` mount, so DSH state stays private) |
|
||||||
| `envVarsInjectionPolicy` / `volumeClaimTemplatesPolicy: Disallowed` | Claims that would bypass the warm pool are **rejected loudly**, not cold-started silently |
|
| `envVarsInjectionPolicy` / `volumeClaimTemplatesPolicy: Disallowed` | Claims that would bypass the warm pool are **rejected loudly**, not cold-started silently |
|
||||||
| `service: true` | Stable per-sandbox DNS (`status.serviceFQDN`) for the gateway to route on |
|
| `service: true` | Stable per-sandbox DNS (`status.serviceFQDN`) for the gateway to route on |
|
||||||
| `SandboxWarmPool` | Signup latency is pre-paid: image pull, PVC provision, DSH boot all happen before any user exists |
|
| `SandboxWarmPool` | Signup latency is pre-paid: image pull, PVC provision, DSH boot all happen before any user exists |
|
||||||
@@ -63,12 +66,13 @@ What it demonstrates, feature by feature:
|
|||||||
upstream instead of splitting `/v1/*` (API) from the dashboard.
|
upstream instead of splitting `/v1/*` (API) from the dashboard.
|
||||||
- **Shared provider keys**: `DEEPSEEK_API_KEY` plus any keys in the optional
|
- **Shared provider keys**: `DEEPSEEK_API_KEY` plus any keys in the optional
|
||||||
`dsh-provider-keys-extra` Secret are injected via `envFrom` into every
|
`dsh-provider-keys-extra` Secret are injected via `envFrom` into every
|
||||||
sandbox. There is no per-tenant key, dashboard basic-auth, session-cookie
|
sandbox. There is no per-tenant key, dashboard basic-auth, signed-session
|
||||||
secret, or API-server key.
|
secret (sessions are opaque server-side ids), or API-server key.
|
||||||
- **Launch-token handshake**: DSH gates `/` behind a token it prints only in its
|
- **Launch-token handshake**: DSH gates `/` behind a token it prints only in its
|
||||||
logs. The gateway reads it, performs the handshake, and keeps the resulting
|
logs. The gateway reads it, performs the handshake, and keeps the resulting
|
||||||
auth cookie server-side; the browser authenticates to the gateway with its own
|
auth cookie server-side; the browser authenticates to the gateway with its own
|
||||||
cookie. See [How the DSH handshake works](#how-the-dsh-handshake-works).
|
opaque session cookie, never the token. See
|
||||||
|
[How the DSH handshake works](#how-the-dsh-handshake-works).
|
||||||
- **Host allow-list**: DSH rejects unknown `Host` headers. The gateway forwards
|
- **Host allow-list**: DSH rejects unknown `Host` headers. The gateway forwards
|
||||||
the browser's `Host`, so the public hostname must be added to
|
the browser's `Host`, so the public hostname must be added to
|
||||||
`DSH_TRUSTED_HOSTS` in the template.
|
`DSH_TRUSTED_HOSTS` in the template.
|
||||||
@@ -79,12 +83,14 @@ What it demonstrates, feature by feature:
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
Run the walkthrough from this example's directory (`examples/dsh-agents-as-a-service/`).
|
||||||
|
|
||||||
- A Kubernetes cluster with a default StorageClass (kind and GKE both work as-is).
|
- A Kubernetes cluster with a default StorageClass (kind and GKE both work as-is).
|
||||||
- agent-sandbox with extensions installed:
|
- agent-sandbox with extensions installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
VERSION=v1.0.7 # AGENT_SANDBOX_VERSION in versions.env
|
. ../../versions.env
|
||||||
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/$VERSION/sandbox-with-extensions.yaml
|
kubectl apply -f "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/${AGENT_SANDBOX_VERSION}/sandbox-with-extensions.yaml"
|
||||||
kubectl -n agent-sandbox-system rollout status deployment --timeout=180s
|
kubectl -n agent-sandbox-system rollout status deployment --timeout=180s
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -201,9 +207,30 @@ kubectl -n dsh-demo port-forward "pod/$SB" 18081:3080
|
|||||||
## 4. Write state on the PVC
|
## 4. Write state on the PVC
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
kubectl -n dsh-demo exec "$SB" -- printenv HOME # /workspace
|
||||||
kubectl -n dsh-demo exec "$SB" -- sh -c 'echo "remember me" > /home/node/.dsh/marker.txt'
|
kubectl -n dsh-demo exec "$SB" -- sh -c 'echo "remember me" > /home/node/.dsh/marker.txt'
|
||||||
|
kubectl -n dsh-demo exec "$SB" -- sh -c 'echo "remember me too" > /workspace/marker.txt'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`/workspace` (the DSH image's `WORKDIR`, where agents keep files) is the same
|
||||||
|
`dsh-home` PVC mounted again with `subPath: workspace`. The two trees stay
|
||||||
|
separate, so `.credentials.yaml` and the session store are never part of the
|
||||||
|
agent-visible workspace.
|
||||||
|
|
||||||
|
The template also sets `HOME=/workspace`. DSH's directory picker (the folder
|
||||||
|
explorer used to choose a workspace) has no start-path setting: when it opens,
|
||||||
|
its browse backend lists `$HOME`, so with the image default (`/home/node`, which
|
||||||
|
is not on a volume) it would start outside the persisted tree. Pointing `HOME`
|
||||||
|
at the workspace makes the picker open on it (labelled *Home* in its breadcrumbs)
|
||||||
|
and makes `~` in agent commands mean `/workspace`. The trade-off is that tools
|
||||||
|
keeping caches or config under `$HOME` (`npm`/`pnpm`, `uv`, `git`, `gh`, and
|
||||||
|
DSH's global `~/.agents` instructions and skills) write dotfiles onto the PVC
|
||||||
|
inside the agent workspace, where the agent and the file explorer can see them.
|
||||||
|
`DSH_HOME` is explicit (`/home/node/.dsh`), so sessions, config and credentials
|
||||||
|
are unaffected. Pod env is stamped when a sandbox is created: a sandbox from
|
||||||
|
before this setting keeps `HOME=/home/node` until it is recreated (see
|
||||||
|
[Troubleshooting](#troubleshooting)).
|
||||||
|
|
||||||
## 5. Suspend: delete the pod, keep the PVC + Service
|
## 5. Suspend: delete the pod, keep the PVC + Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -219,13 +246,12 @@ kubectl -n dsh-demo get svc "$SB" # still here
|
|||||||
kubectl -n dsh-demo patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Running"}}'
|
kubectl -n dsh-demo patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Running"}}'
|
||||||
kubectl -n dsh-demo wait sandbox "$SB" --for=condition=Ready --timeout=180s
|
kubectl -n dsh-demo wait sandbox "$SB" --for=condition=Ready --timeout=180s
|
||||||
kubectl -n dsh-demo exec "$SB" -- cat /home/node/.dsh/marker.txt # "remember me"
|
kubectl -n dsh-demo exec "$SB" -- cat /home/node/.dsh/marker.txt # "remember me"
|
||||||
|
kubectl -n dsh-demo exec "$SB" -- cat /workspace/marker.txt # "remember me too"
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Why resume works:** the pod template sets `fsGroupChangePolicy: OnRootMismatch`.
|
> **Why resume works:** the pod template sets `fsGroupChangePolicy: OnRootMismatch`;
|
||||||
> DSH requires its `.credentials.yaml` to be `0600`, while the kubelet's default
|
> without it the kubelet widens `.credentials.yaml` to `0660` and DSH crash-loops.
|
||||||
> fsGroup policy widens every file to `0660` on each pod start — a resumed sandbox
|
> See [Troubleshooting](#troubleshooting) for the one-time fix on older sandboxes.
|
||||||
> would crash-loop. See [Troubleshooting](#troubleshooting) for the one-time fix
|
|
||||||
> on sandboxes created before this setting existed.
|
|
||||||
|
|
||||||
## 7. Injection policy rejects env-carrying claims
|
## 7. Injection policy rejects env-carrying claims
|
||||||
|
|
||||||
@@ -244,13 +270,13 @@ kubectl delete -f manifests/40-claim-rejected.yaml
|
|||||||
```bash
|
```bash
|
||||||
# Published image (on kind, load it into the cluster; on a real cluster the
|
# Published image (on kind, load it into the cluster; on a real cluster the
|
||||||
# kubelet pulls it from GHCR):
|
# kubelet pulls it from GHCR):
|
||||||
GW=ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.3-r4
|
GW=ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.5-r6
|
||||||
docker pull "$GW"
|
docker pull "$GW"
|
||||||
kind load docker-image "$GW" --name <kind-cluster>
|
kind load docker-image "$GW" --name <kind-cluster>
|
||||||
|
|
||||||
# ... or build it locally and tag it exactly as manifests/50-gateway.yaml
|
# ... or build it locally and tag it exactly as manifests/50-gateway.yaml
|
||||||
# expects (make gateway builds dsh-aaas-gateway:dev):
|
# expects (make gateway builds dsh-aaas-gateway:dev):
|
||||||
make gateway
|
make -C ../.. gateway
|
||||||
docker tag dsh-aaas-gateway:dev "$GW"
|
docker tag dsh-aaas-gateway:dev "$GW"
|
||||||
kind load docker-image "$GW" --name <kind-cluster>
|
kind load docker-image "$GW" --name <kind-cluster>
|
||||||
|
|
||||||
@@ -261,6 +287,15 @@ kubectl -n dsh-demo port-forward svc/dsh-gateway 18080:8080
|
|||||||
|
|
||||||
> The gateway binds `8080`; the local port-forward uses `18080` and never `3080`.
|
> The gateway binds `8080`; the local port-forward uses `18080` and never `3080`.
|
||||||
>
|
>
|
||||||
|
> **Cookie/TLS guard.** The base manifest serves plain HTTP through port-forward,
|
||||||
|
> so it sets `COOKIE_SECURE=false` **and** `ALLOW_INSECURE_COOKIES=1`. The gateway
|
||||||
|
> refuses to start when `COOKIE_SECURE=false` unless
|
||||||
|
> `ALLOW_INSECURE_COOKIES=1` explicitly acknowledges that the session cookie
|
||||||
|
> travels in clear text; that prevents an HTTPS deployment from silently serving
|
||||||
|
> an insecure cookie. Before exposing the gateway over TLS, set
|
||||||
|
> `COOKIE_SECURE=true` and remove the acknowledgement:
|
||||||
|
> `kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
|
||||||
|
>
|
||||||
> **Image pull policy.** `manifests/50-gateway.yaml` uses
|
> **Image pull policy.** `manifests/50-gateway.yaml` uses
|
||||||
> `imagePullPolicy: IfNotPresent` with the release identity, so a locally built
|
> `imagePullPolicy: IfNotPresent` with the release identity, so a locally built
|
||||||
> image must be loaded into the cluster under exactly that name (kind:
|
> image must be loaded into the cluster under exactly that name (kind:
|
||||||
@@ -293,25 +328,40 @@ kubectl -n dsh-demo port-forward svc/dsh-gateway 18080:8080
|
|||||||
|
|
||||||
### Browser flow
|
### Browser flow
|
||||||
|
|
||||||
Open <http://localhost:18080>. The landing page has a one-field signup form.
|
Open <http://localhost:18080>. The landing page has a one-field signup form (plus
|
||||||
Enter `bob`, and the gateway:
|
an invite-code field when `SIGNUP_INVITE_CODE` is configured). Enter `bob`, and
|
||||||
|
the gateway:
|
||||||
|
|
||||||
1. creates the `SandboxClaim` over the warm pool,
|
1. enforces the auth rate limit and, if configured, the signup invite code,
|
||||||
2. waits for it to become `Ready`,
|
2. creates the `SandboxClaim` over the warm pool,
|
||||||
3. sets an `HttpOnly`, `SameSite=Lax` cookie `dsh_gw_session=<user>:<token>` on
|
3. waits for it to become `Ready`,
|
||||||
the browser,
|
4. mints a random, server-side session (the cookie value is an opaque `sid`, not
|
||||||
4. redirects to `/u/bob/`, where the DSH Web UI loads through the gateway.
|
a user name and not a token), stores `sid -> (user, expiry)` in memory, and
|
||||||
|
sets the `HttpOnly`, `SameSite=Lax` cookie `dsh_gw_session=<sid>` on the
|
||||||
|
browser (`__Host-dsh_gw_session` when `COOKIE_SECURE=true`),
|
||||||
|
5. redirects to `/u/bob/`, where the DSH Web UI loads through the gateway.
|
||||||
|
|
||||||
From then on every request under `/u/bob/` (assets, API calls, SSE, WebSocket) is
|
From then on every request under `/u/bob/` (assets, API calls, SSE, WebSocket) is
|
||||||
proxied to bob's sandbox. The DSH launch token and DSH's own auth cookie stay in
|
proxied to bob's sandbox. The DSH launch token and DSH's own auth cookie stay in
|
||||||
the gateway; the browser only holds the gateway cookie.
|
the gateway; the browser only holds the opaque gateway session id. The cookie is
|
||||||
|
useless as a bearer token.
|
||||||
|
|
||||||
|
**Sessions are revocable and mostly short-lived in practice.** `POST /logout`,
|
||||||
|
deleting the user, `POST /recreate` and cookie expiry (default 7 days,
|
||||||
|
`COOKIE_MAX_AGE`) invalidate the server-side session. The session table lives in
|
||||||
|
gateway memory, so **restarting the gateway logs every browser out** — expected
|
||||||
|
for this single-replica demo; a production deployment needs a shared session
|
||||||
|
store and sticky routing or per-request re-authentication. With SSO enabled, the
|
||||||
|
gateway mints a session for the identity oauth2-proxy asserts instead of asking
|
||||||
|
for a token.
|
||||||
|
|
||||||
The same flow with `curl`, no JavaScript (the session cookie is the only
|
The same flow with `curl`, no JavaScript (the session cookie is the only
|
||||||
credential):
|
credential):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JAR=$(mktemp)
|
JAR=$(mktemp)
|
||||||
# signup (form POST, exactly what the landing page sends)
|
# signup (form POST, exactly what the landing page sends); add -d 'invite=...'
|
||||||
|
# when SIGNUP_INVITE_CODE is configured, or send X-Signup-Invite
|
||||||
curl -s -i -c "$JAR" -H 'Origin: http://localhost:18080' \
|
curl -s -i -c "$JAR" -H 'Origin: http://localhost:18080' \
|
||||||
-d 'user=bob' http://localhost:18080/users
|
-d 'user=bob' http://localhost:18080/users
|
||||||
# proxy: bob's DSH Web through the gateway
|
# proxy: bob's DSH Web through the gateway
|
||||||
@@ -320,52 +370,54 @@ curl -s -b "$JAR" http://localhost:18080/u/bob/ | head -5
|
|||||||
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/u/bob/ # 401
|
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/u/bob/ # 401
|
||||||
# derived state: Ready / Waking / Suspended / ...
|
# derived state: Ready / Waking / Suspended / ...
|
||||||
curl -s -b "$JAR" http://localhost:18080/users/bob
|
curl -s -b "$JAR" http://localhost:18080/users/bob
|
||||||
# delete bob (cascades sandbox + PVC)
|
# sign out (revokes the server-side session)
|
||||||
|
curl -s -b "$JAR" -H 'Origin: http://localhost:18080' -X POST http://localhost:18080/logout
|
||||||
|
# delete bob (cascades sandbox + PVC and revokes every session for bob)
|
||||||
curl -s -b "$JAR" -H 'Origin: http://localhost:18080' -X DELETE http://localhost:18080/users/bob
|
curl -s -b "$JAR" -H 'Origin: http://localhost:18080' -X DELETE http://localhost:18080/users/bob
|
||||||
```
|
```
|
||||||
|
|
||||||
API clients that prefer bearer tokens can use `POST /users` with a JSON body
|
API clients that prefer bearer tokens can use `POST /users` with a JSON body
|
||||||
(`{"user":"bob"}`) and the returned one-time token, `POST /login` to exchange a
|
(`{"user":"bob"}`) and the returned one-time token, `POST /login` to exchange a
|
||||||
token for a cookie, and `Authorization: Bearer <token>` on subsequent calls.
|
token for a cookie, and `Authorization: Bearer <token>` on subsequent calls. A
|
||||||
|
cookie/session value is never accepted as a bearer token, and a token for one
|
||||||
|
user cannot read another user's path.
|
||||||
|
|
||||||
### Gateway API
|
### Auth abuse controls and proxy limits
|
||||||
|
|
||||||
| Route | Purpose |
|
The gateway is a single process, so these limits are per replica. They stop
|
||||||
| --- | --- |
|
casual abuse and one tenant monopolizing the service; an internet-facing
|
||||||
| `GET /healthz` | Liveness/readiness; no cluster access |
|
deployment still needs ingress-level rate limiting and an audit trail.
|
||||||
| `GET /` | Landing page: signup, token login, logout. With SSO it auto-provisions and connects instead. |
|
|
||||||
| `POST /users` | Signup. JSON or form; `201` (JSON), `202` (waking), `303` (browser redirect), `409` if user exists; sets the session cookie |
|
|
||||||
| `POST /login` | Exchange a bearer token for the session cookie |
|
|
||||||
| `POST /logout` | Clear the session cookie |
|
|
||||||
| `POST /recreate` | SSO only: delete the authenticated user's claim (wiping workspace data) and provision a fresh one; `303` to `/u/{user}/`. Cookie-authenticated calls must be same-origin. |
|
|
||||||
| `GET /users/{user}` | Derived state (`Ready`/`Waking`/`Suspended`) |
|
|
||||||
| `DELETE /users/{user}` | Delete claim (cascades sandbox + PVC) |
|
|
||||||
| `GET /u/{user}` | `308` redirect to `/u/{user}/` |
|
|
||||||
| `GET /u/{user}/**` | HTTP/SSE proxy to the user's DSH Web |
|
|
||||||
| `WS /u/{user}/**` | WebSocket proxy to the user's DSH Web |
|
|
||||||
|
|
||||||
The full API reference, configuration table, auth semantics and failure matrix
|
| Setting | Default | Behavior |
|
||||||
live in the [gateway README](gateway/README.md).
|
| --- | --- | --- |
|
||||||
|
| `AUTH_RATE_LIMIT` / `AUTH_RATE_WINDOW` | `30` per `60` s | Throttles `/users` and `/login` per client (rightmost `X-Forwarded-For` when behind a proxy, else the TCP peer) and answers `429` + `Retry-After` before reading the body or touching the cluster. `0` disables it. The base manifest sets it explicitly. |
|
||||||
|
| `SIGNUP_INVITE_CODE` | unset | When set, `POST /users` requires the code as form field `invite` or header `X-Signup-Invite` (constant-time compare, `403` before user lookup). Put it in a Secret and restart the gateway to rotate. |
|
||||||
|
| `MAX_IN_FLIGHT_PER_USER` | `32` | Caps concurrent proxied requests + WebSockets per user; excess HTTP gets `503` + `Retry-After`, excess WebSocket upgrades close with `1011`. |
|
||||||
|
| `WS_MAX_MESSAGE_BYTES` | `16 MiB` | Largest WebSocket message received from DSH; oversized frames close the connection. Browser-to-DSH frames are bounded by uvicorn's own limit. HTTP/SSE streaming is not capped by this. |
|
||||||
|
| `MAX_BODY_MB` | `32` | Largest proxied HTTP request body (`413`). |
|
||||||
|
|
||||||
|
The gateway also strips client-supplied `Forwarded`, `X-Forwarded-User` and
|
||||||
|
`X-Auth-Request-*` headers (and `x-forwarded-*` / `x-auth-request-*` prefixes)
|
||||||
|
before proxying, so a tenant cannot inject identity or spoof hop headers into
|
||||||
|
DSH. `UPSTREAM_HEADER_ALLOWLIST` can replace that denylist with an explicit
|
||||||
|
comma-separated allowlist. See the [gateway README](gateway/README.md) for the
|
||||||
|
full configuration.
|
||||||
|
|
||||||
|
### Gateway routes
|
||||||
|
|
||||||
|
The browser flow above uses the gateway's main routes. The full route table,
|
||||||
|
status codes, configuration and failure matrix live in the
|
||||||
|
[gateway README](gateway/README.md).
|
||||||
|
|
||||||
### How the DSH handshake works
|
### How the DSH handshake works
|
||||||
|
|
||||||
DSH does not expose an API for its launch token; it prints a URL containing
|
DSH prints its launch token only in pod logs. The gateway resolves
|
||||||
`?token=…` in the pod logs. For each `(user, pod UID, Host)` the gateway:
|
claim → sandbox → pod, reads the pod-log tail, performs the `?token=` handshake
|
||||||
|
under the browser's `Host`, caches `dsh-auth-*` per `(user, pod UID, Host)`, and
|
||||||
1. resolves the claim → sandbox → pod (annotation `agents.x-k8s.io/pod-name`, or
|
refreshes once on a `401`. That is why its RBAC includes `pods` `get`/`list` and
|
||||||
the sandbox name as pod name),
|
`pods/log` `get`; it is also the most fragile part of the design (see
|
||||||
2. reads the pod's logs (last `DSH_LOG_TAIL_LINES` lines) and parses the most
|
[Limitations](#limitations) and the
|
||||||
recent `dsh web: …?token=…` line,
|
[architecture ADR](docs/architecture.md#adr-3-server-side-launch-token-handshake-instead-of-exposing-the-token)).
|
||||||
3. requests `http://<serviceFQDN>:3080/?token=…` with the browser's `Host`,
|
|
||||||
4. captures DSH's `dsh-auth-*` `Set-Cookie` response and caches it,
|
|
||||||
5. injects that cookie (and strips the browser's `Cookie`/`Authorization` and any
|
|
||||||
`?token=`) on every proxied HTTP and WebSocket request,
|
|
||||||
6. refreshes the handshake once if DSH answers `401`, and drops the cached cookie
|
|
||||||
when the sandbox suspends or is deleted.
|
|
||||||
|
|
||||||
This is why the gateway's RBAC includes `pods` `get`/`list` and `pods/log`
|
|
||||||
`get`. It is also the most fragile part of the design; see
|
|
||||||
[Limitations](#limitations).
|
|
||||||
|
|
||||||
### DSH_TRUSTED_HOSTS
|
### DSH_TRUSTED_HOSTS
|
||||||
|
|
||||||
@@ -384,27 +436,21 @@ The same applies to a local `docker run`: the printed token URL uses
|
|||||||
|
|
||||||
### WebSocket
|
### WebSocket
|
||||||
|
|
||||||
The gateway is a Starlette ASGI app and proxies WebSocket upgrades with a
|
The gateway proxies WebSocket upgrades with a bidirectional pump, so the DSH web
|
||||||
bidirectional pump, so the DSH web GUI is fully interactive. Requirements:
|
GUI is fully interactive. The entry point must pass `Upgrade`/`Connection`
|
||||||
|
headers; [`manifests/exposure/ingress/60-ingress.yaml`](manifests/exposure/ingress/60-ingress.yaml)
|
||||||
|
is a starting point (Traefik handles upgrades by default). Idle suspension is
|
||||||
|
disabled while a proxied request or socket is in flight, so a long-running turn is
|
||||||
|
not cut off.
|
||||||
|
|
||||||
- the ingress/load balancer in front of the gateway must pass `Upgrade`/`Connection`
|
The upstream upgrade dials the sandbox **pod IP**, forwards no `Host`/`Origin`,
|
||||||
headers; [`manifests/exposure/ingress/60-ingress.yaml`](manifests/exposure/ingress/60-ingress.yaml)
|
and performs the launch-token handshake under the same `pod-IP:3080` authority.
|
||||||
is a starting point (Traefik handles upgrades by default),
|
Missing or cross-origin auth closes with `4401`; upstream bridging failures close
|
||||||
- idle suspension is disabled for users with in-flight proxied requests or open
|
with `1011`, as does exceeding `MAX_IN_FLIGHT_PER_USER` (per-user socket cap).
|
||||||
WebSockets, so a long-running chat stream is not cut off mid-turn,
|
Messages received from DSH larger than `WS_MAX_MESSAGE_BYTES` (16 MiB by
|
||||||
- the upstream upgrade dials the sandbox **pod IP**, forwards no `Host`/`Origin`,
|
default) close the connection instead of being buffered; browser-to-DSH frames
|
||||||
and performs the launch-token handshake under that same `pod-IP:3080` authority.
|
are bounded by uvicorn's own limit. See the
|
||||||
DSH fences on `Host` (only its bind address, loopback, or an allow-listed host
|
[gateway README](gateway/README.md#proxy-failure-semantics) for the full matrix.
|
||||||
passes) and binds its auth cookie to the request authority
|
|
||||||
(`payload.authority === authority`), while `websockets` derives `Host` from the
|
|
||||||
URI and cannot be overridden. A browser-host cookie or forwarded origin makes
|
|
||||||
the upgrade fail (`401`/`403`, surfaced as close `1011`), which the client
|
|
||||||
retries forever as `[connection] connection lost, retry #N`,
|
|
||||||
- missing/invalid gateway auth is rejected right after the upgrade with close code
|
|
||||||
`4401` (WebSocket auth has no HTTP `401` path); cross-origin or Origin-less
|
|
||||||
upgrades with the cookie are rejected the same way (CSWSH protection), so
|
|
||||||
clients must send a matching `Origin`; upstream bridging failures close with
|
|
||||||
`1011`.
|
|
||||||
|
|
||||||
### Path routing
|
### Path routing
|
||||||
|
|
||||||
@@ -428,33 +474,27 @@ not a stack. Details, commands and the switch procedure are in the
|
|||||||
Both variants also ship NetworkPolicies: `ingress/61-networkpolicy.yaml` limits
|
Both variants also ship NetworkPolicies: `ingress/61-networkpolicy.yaml` limits
|
||||||
gateway ingress to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml`
|
gateway ingress to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml`
|
||||||
limits the gateway to oauth2-proxy and oauth2-proxy egress to DNS, public HTTPS
|
limits the gateway to oauth2-proxy and oauth2-proxy egress to DNS, public HTTPS
|
||||||
and the gateway. They are part of the variant; see the
|
and the gateway. They are part of the variant **and required for production**:
|
||||||
[exposure README](manifests/exposure/README.md) and
|
an exposure applied without its policy is demo-only. The sandbox side needs a
|
||||||
[optional hardening](docs/optional-hardening.md).
|
separate policy, and the gateway needs an egress policy; both are covered in
|
||||||
|
[optional hardening](docs/optional-hardening.md) (see the production checklist)
|
||||||
|
and summarized in the [exposure README](manifests/exposure/README.md).
|
||||||
|
|
||||||
### Option A — plain Ingress (gateway's own session auth)
|
### Option A — plain Ingress (gateway's own session auth)
|
||||||
|
|
||||||
```bash
|
1. Set the Ingress host/TLS (or drop the `tls:` block), and set
|
||||||
# Replace the dsh.example.com host and TLS Secret (or drop the tls: block for
|
`DSH_TRUSTED_HOSTS` in `manifests/10-sandboxtemplate.yaml` to the same public
|
||||||
# plain HTTP), then:
|
host; re-apply the template.
|
||||||
kubectl apply -f manifests/exposure/ingress/
|
2. Set `COOKIE_SECURE=true` and drop the plain-HTTP acknowledgement once the
|
||||||
```
|
Ingress serves HTTPS:
|
||||||
|
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
|
||||||
1. Set `DSH_TRUSTED_HOSTS` in `manifests/10-sandboxtemplate.yaml` to the same
|
3. `kubectl apply -f manifests/exposure/ingress/`; verify
|
||||||
public host (the gateway forwards the browser's `Host`) and re-apply the
|
`curl -sI https://<host>/healthz` returns `200`.
|
||||||
template.
|
|
||||||
2. Set `COOKIE_SECURE=true` on the gateway once the Ingress serves HTTPS:
|
|
||||||
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true`.
|
|
||||||
(The base manifest keeps `false` so the plain-HTTP port-forward demo works.)
|
|
||||||
3. Verify: `curl -sI https://<host>/healthz` returns `200`; sign up in a browser
|
|
||||||
at `https://<host>/`.
|
|
||||||
4. `manifests/exposure/ingress/61-networkpolicy.yaml` is part of this variant:
|
|
||||||
it allows only Traefik pods to reach the gateway on `8080`. Adjust its
|
|
||||||
selector if your ingress controller uses a different pod label or
|
|
||||||
`hostNetwork`; if kubelet probes fail, see the hardening doc.
|
|
||||||
|
|
||||||
Keep the router free of buffering/compression middlewares: they break WebSocket
|
Keep the router free of buffering/compression middlewares: they break WebSocket
|
||||||
upgrades and streaming.
|
upgrades and streaming. The variant's `61-networkpolicy.yaml` allows only Traefik
|
||||||
|
pods to reach the gateway; see the [exposure README](manifests/exposure/README.md)
|
||||||
|
for selector and probe caveats.
|
||||||
|
|
||||||
### Option B — Gateway API + GitHub SSO (oauth2-proxy)
|
### Option B — Gateway API + GitHub SSO (oauth2-proxy)
|
||||||
|
|
||||||
@@ -464,99 +504,86 @@ front of **oauth2-proxy**, which authenticates the browser against GitHub
|
|||||||
`X-Forwarded-User` set. The gateway trusts that header only when
|
`X-Forwarded-User` set. The gateway trusts that header only when
|
||||||
`AUTH_PROXY_HEADER` is set (SSO is **opt-in**).
|
`AUTH_PROXY_HEADER` is set (SSO is **opt-in**).
|
||||||
|
|
||||||
Prerequisites:
|
1. Install Gateway API CRDs and a Gateway implementation; the manifests assume
|
||||||
|
`gatewayClassName: traefik` (the CRD version is `GATEWAY_API_VERSION` in
|
||||||
- Gateway API CRDs and a Gateway implementation; the manifests assume
|
`versions.env`).
|
||||||
`gatewayClassName: traefik` (the CRD version is pinned as
|
2. Create a GitHub OAuth App whose homepage/callback match the public FQDN, and
|
||||||
`GATEWAY_API_VERSION` in `versions.env`).
|
create the oauth2-proxy and TLS Secrets from
|
||||||
- A GitHub OAuth App whose homepage/callback match the public FQDN
|
`73-oauth2-proxy-secret.yaml.example` and `74-tls-secret.yaml.example`.
|
||||||
(`https://<host>/` and `https://<host>/oauth2/callback`).
|
3. Replace `dsh-demo.example.com` and `your-github-org` in the variant
|
||||||
- The oauth2-proxy credentials Secret (template:
|
manifests, set `DSH_TRUSTED_HOSTS` in the template, and enable SSO:
|
||||||
`73-oauth2-proxy-secret.yaml.example`) and a TLS Secret for the Gateway
|
`kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
|
||||||
listener (template: `74-tls-secret.yaml.example`).
|
4. `kubectl apply -f manifests/exposure/gateway-api-sso/`; then run
|
||||||
|
[`scripts/preflight-sso.sh`](scripts/preflight-sso.sh) and fix everything it
|
||||||
Steps:
|
reports **before** pointing real traffic at the Gateway. It verifies the
|
||||||
|
Secrets (including a valid oauth2-proxy cookie secret length), the gateway's
|
||||||
```bash
|
`AUTH_PROXY_HEADER`/`COOKIE_SECURE`, that the Gateway's TLS certificate
|
||||||
# 1. Real Secrets; the committed *.yaml.example files are templates only.
|
reference resolves, and that no `your-github-org`/`dsh-demo.example.com`
|
||||||
kubectl -n dsh-demo create secret generic oauth2-proxy \
|
placeholder was left applied. Full verification steps are in the
|
||||||
--from-literal=client-id='<GitHub OAuth App client ID>' \
|
[exposure README](manifests/exposure/README.md).
|
||||||
--from-literal=client-secret='<GitHub OAuth App client secret>' \
|
|
||||||
--from-literal=cookie-secret="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
|
||||||
kubectl -n dsh-demo create secret tls dsh-demo-tls --cert=tls.crt --key=tls.key
|
|
||||||
|
|
||||||
# 2. Replace dsh-demo.example.com (70/71/72) and your-github-org (72).
|
|
||||||
|
|
||||||
# 3. Enable SSO on the gateway and trust the public host in the template.
|
|
||||||
kubectl -n dsh-demo set env deploy/dsh-gateway \
|
|
||||||
AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true
|
|
||||||
# ... and set DSH_TRUSTED_HOSTS=dsh-demo.example.com in
|
|
||||||
# manifests/10-sandboxtemplate.yaml, then re-apply the template.
|
|
||||||
|
|
||||||
# 4. Apply the variant.
|
|
||||||
kubectl apply -f manifests/exposure/gateway-api-sso/
|
|
||||||
```
|
|
||||||
|
|
||||||
Verification:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl get gateway dsh-gateway -n dsh-demo -o yaml # Programmed=True
|
|
||||||
kubectl get httproute -n dsh-demo -o wide # backend oauth2-proxy:4180, Accepted
|
|
||||||
kubectl get deploy oauth2-proxy -n dsh-demo # 1/1 Ready
|
|
||||||
kubectl get endpoints oauth2-proxy -n dsh-demo # backend present
|
|
||||||
kubectl -n dsh-demo logs deploy/oauth2-proxy --tail=50 # no config errors
|
|
||||||
```
|
|
||||||
|
|
||||||
Browser check: open `https://<host>/` — you are redirected to GitHub, and after
|
|
||||||
signing in as a member of the allowed organization the gateway auto-provisions
|
|
||||||
your sandbox (the first page can show `Provisioning`) and connects you. A
|
|
||||||
non-member is denied before reaching the gateway. GitHub logins are lowercase
|
|
||||||
DNS-1123 labels, so they map 1:1 onto the gateway's `user` validation. The SSO
|
|
||||||
landing page also offers **Recreate sandbox** (`POST /recreate`), which deletes
|
|
||||||
the claim — wiping the workspace PVC — and provisions a fresh one.
|
|
||||||
|
|
||||||
Sign-out: the gateway `/logout` clears `dsh_gw_session`; oauth2-proxy's session
|
|
||||||
can be cleared at `/oauth2/sign_out`.
|
|
||||||
|
|
||||||
|
> **Exposure without the variant NetworkPolicy is demo-only.** The SSO
|
||||||
|
> variant's `75-networkpolicies.yaml` is not optional in practice: it is the
|
||||||
|
> only thing that makes oauth2-proxy the sole path to the gateway. Applying
|
||||||
|
> only the Gateway/HTTPRoute/oauth2-proxy objects leaves the trust boundary
|
||||||
|
> unenforced, so anyone who can reach `dsh-gateway:8080` can send
|
||||||
|
> `X-Forwarded-User` and impersonate the user they claim to be.
|
||||||
|
>
|
||||||
> **SSO trust boundary — read this before enabling it.** After SSO is on, the
|
> **SSO trust boundary — read this before enabling it.** After SSO is on, the
|
||||||
> `dsh-gateway` Service must be reachable **only** through oauth2-proxy (it is
|
> `dsh-gateway` Service must be reachable **only** through oauth2-proxy (it is
|
||||||
> ClusterIP). The variant's `75-networkpolicies.yaml` restricts pod traffic
|
> ClusterIP). The variant's `75-networkpolicies.yaml` restricts pod traffic
|
||||||
> accordingly: the gateway accepts traffic only from oauth2-proxy pods,
|
> accordingly: the gateway accepts traffic only from oauth2-proxy pods,
|
||||||
> oauth2-proxy only from the Gateway implementation, and oauth2-proxy egress is
|
> oauth2-proxy only from the Gateway implementation, and its egress only to DNS,
|
||||||
> limited to DNS, public HTTPS and the gateway. Anyone who can reach
|
> public HTTPS and the gateway. The gateway's own egress (Kubernetes API,
|
||||||
> `dsh-gateway:8080` directly can still send `X-Forwarded-User: <victim>` and be
|
> sandbox pods) is not restricted by that file because the API-server endpoint is
|
||||||
> treated as that user, so never add a second path (the Ingress variant, a
|
> cluster-specific; add the
|
||||||
> shared-host port-forward, a permissive policy). To turn SSO off, remove the
|
> [gateway egress policy](docs/optional-hardening.md#gateway-egress-required-for-production)
|
||||||
> variable (`kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER-`)
|
> for production. Anyone who can reach `dsh-gateway:8080` directly can send
|
||||||
> and delete the SSO variant; the gateway falls back to bearer-token/session
|
> `X-Forwarded-User: <victim>`, so never add a second path. To turn SSO off,
|
||||||
> auth.
|
> remove the variable
|
||||||
|
> (`kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER-`) and
|
||||||
|
> delete the SSO variant.
|
||||||
|
|
||||||
### Switching variants
|
### Switching variants
|
||||||
|
|
||||||
Delete the variant you had before applying the other one
|
Delete the variant you had before applying the other one, revert
|
||||||
(`kubectl delete -f manifests/exposure/ingress/`), revert
|
|
||||||
`AUTH_PROXY_HEADER`/`COOKIE_SECURE`, and verify with
|
`AUTH_PROXY_HEADER`/`COOKIE_SECURE`, and verify with
|
||||||
`kubectl -n dsh-demo get ingress,gateway,httproute` that exactly one path to the
|
`kubectl -n dsh-demo get ingress,gateway,httproute` that exactly one path to the
|
||||||
gateway remains.
|
gateway remains. Full commands and verification are in the
|
||||||
|
[exposure README](manifests/exposure/README.md).
|
||||||
|
|
||||||
## Optional hardening
|
## Optional hardening
|
||||||
|
|
||||||
The base walkthrough is deliberately portable. Four opt-in layers are documented
|
The base walkthrough is deliberately portable, which means it is **not
|
||||||
in [optional hardening](docs/optional-hardening.md):
|
hardened**: without the policies and gVisor it is demo-only, not a safe
|
||||||
|
multi-tenant platform. [Optional hardening](docs/optional-hardening.md) has the
|
||||||
|
settings and a production checklist. In short:
|
||||||
|
|
||||||
- **gVisor** — uncomment `runtimeClassName: gvisor` before applying, or patch an
|
- **gVisor** — uncomment `runtimeClassName: gvisor` before applying, or patch an
|
||||||
existing install. Requires a `gvisor` RuntimeClass and capable nodes.
|
existing install. Requires a `gvisor` RuntimeClass and capable nodes.
|
||||||
`kubectl port-forward` does not work under gVisor; the gateway path does.
|
`kubectl port-forward` does not work under gVisor; the gateway path does.
|
||||||
|
Required for untrusted agents in production.
|
||||||
- **nodeSelector** — uncomment and replace the placeholder node label to pin
|
- **nodeSelector** — uncomment and replace the placeholder node label to pin
|
||||||
sandboxes and the gateway to a dedicated pool; add a toleration for tainted
|
sandboxes and the gateway to a dedicated pool; add a toleration for tainted
|
||||||
pools.
|
pools.
|
||||||
- **Sandbox NetworkPolicy** —
|
- **Sandbox NetworkPolicy** —
|
||||||
`kubectl apply -f manifests/optional/network-policy/` allows only
|
`kubectl apply -f manifests/optional/network-policy/` allows only
|
||||||
gateway→sandbox on `3080` and sandbox→DNS/public `443`.
|
gateway→sandbox on `3080` and sandbox→DNS/public `443`. Required for
|
||||||
|
production; exposure without it is demo-only.
|
||||||
|
- **Gateway egress policy** — the gateway needs DNS, the Kubernetes API and
|
||||||
|
`3080` to sandbox pods. It is not shipped because the API-server endpoint is
|
||||||
|
cluster-specific; use the
|
||||||
|
[recipe](docs/optional-hardening.md#gateway-egress-required-for-production).
|
||||||
|
Required for production.
|
||||||
- **Provider keys** — create `dsh-provider-keys-extra` as shown in step 1.
|
- **Provider keys** — create `dsh-provider-keys-extra` as shown in step 1.
|
||||||
|
- **Failed-Pod recovery** — uncomment `failedPodPolicy: Recreate` so the
|
||||||
|
controller replaces a `Failed` sandbox Pod (node eviction) instead of parking
|
||||||
|
the sandbox at `Finished=True`. Requires agent-sandbox v1.0.8+.
|
||||||
|
|
||||||
Apply each independently and re-run the walkthrough. `kubectl apply -f manifests/`
|
The **exposure variants' NetworkPolicies** are not optional in production
|
||||||
is non-recursive, so the optional manifest is not applied by the base step.
|
either; see step 9. Apply each setting independently and re-run the walkthrough.
|
||||||
|
`kubectl apply -f manifests/` is non-recursive, so the optional manifests are not
|
||||||
|
applied by the base step.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -565,17 +592,26 @@ is non-recursive, so the optional manifest is not applied by the base step.
|
|||||||
| Pod `CrashLoopBackOff`, probes failing with `401` | Probe path is `/`; change readiness/liveness to `/favicon.svg` |
|
| Pod `CrashLoopBackOff`, probes failing with `401` | Probe path is `/`; change readiness/liveness to `/favicon.svg` |
|
||||||
| Browser shows `401` from DSH through the gateway | Gateway handshake failed — check `kubectl -n dsh-demo logs deploy/dsh-gateway` and that RBAC grants `pods/log` |
|
| Browser shows `401` from DSH through the gateway | Gateway handshake failed — check `kubectl -n dsh-demo logs deploy/dsh-gateway` and that RBAC grants `pods/log` |
|
||||||
| `502` / `upstream unavailable` | Sandbox pod is not Ready (wake timed out) or the Service DNS is stale; check `kubectl get sandbox`, `WAKE_TIMEOUT` |
|
| `502` / `upstream unavailable` | Sandbox pod is not Ready (wake timed out) or the Service DNS is stale; check `kubectl get sandbox`, `WAKE_TIMEOUT` |
|
||||||
| `503` with `Retry-After` | The launch token was not found in the pod logs yet; the gateway retries after a short delay. Check the pod logs and `DSH_LOG_TAIL_LINES` |
|
| `503` with `Retry-After` | Either the launch token was not found in the pod logs yet (the gateway retries after a short delay; check the pod logs and `DSH_LOG_TAIL_LINES`) or the user hit `MAX_IN_FLIGHT_PER_USER` (default 32 concurrent requests/WebSockets); reduce client concurrency or raise the limit deliberately |
|
||||||
|
| `429` with `Retry-After` on signup/login | The per-client auth rate limit (`AUTH_RATE_LIMIT`, default explicitly `30` per `AUTH_RATE_WINDOW=60` s) rejected the attempt before reading the body. Wait for the window, or raise the limit; behind a proxy make sure the real client IP is the rightmost `X-Forwarded-For` entry, not a shared NAT address |
|
||||||
|
| `403` on signup: invite required/invalid | `SIGNUP_INVITE_CODE` is configured. Send the code as form field `invite` or header `X-Signup-Invite`; the landing page shows the field when enabled. A wrong code is rejected before the user lookup |
|
||||||
|
| Gateway pod `CrashLoopBackOff` with `refusing to start with an insecure session cookie` | Intentional guard: plain HTTP is only allowed when explicitly acknowledged. For the local demo set `ALLOW_INSECURE_COOKIES=1` (the base manifest does); for HTTPS set `COOKIE_SECURE=true` and remove `ALLOW_INSECURE_COOKIES` |
|
||||||
|
| Every browser is logged out after a gateway rollout/restart | Expected: sessions are opaque ids in gateway memory and a restart drops them. Sign in again; a production deployment needs shared session state or per-request re-authentication. The DSH sandboxes and their data are unaffected |
|
||||||
|
| WebSocket closes with `1011` for a busy client | `MAX_IN_FLIGHT_PER_USER` (per-user connection cap) or an upstream/handshake failure closed the socket; check the gateway logs to tell them apart |
|
||||||
|
| WebSocket closes after a very large message from DSH | A message received from DSH exceeded `WS_MAX_MESSAGE_BYTES` (16 MiB), so the relay closed it instead of buffering; raise the limit only if DSH legitimately sends larger frames. Browser-to-DSH frames are bounded by uvicorn's own limit |
|
||||||
|
| `scripts/preflight-sso.sh` exits non-zero | It names the failing item: a missing Secret/key, a bad oauth2-proxy cookie-secret length, missing `AUTH_PROXY_HEADER`/`COOKIE_SECURE`, a dangling Gateway TLS reference, a leftover `your-github-org`/`dsh-demo.example.com` placeholder, or a second path to the gateway (an Ingress). Fix all items before exposing the SSO variant |
|
||||||
| `409` on signup | User already has a claim; use `POST /login` with the existing token or delete the user first |
|
| `409` on signup | User already has a claim; use `POST /login` with the existing token or delete the user first |
|
||||||
| Cookie set but browser keeps getting `401` | `COOKIE_SECURE=true` while serving over plain HTTP (secure cookies are not sent); use HTTPS or set `false` for local demos |
|
| Cookie set but browser keeps getting `401` | `COOKIE_SECURE=true` while serving over plain HTTP (secure cookies are not sent); use HTTPS or set `false` **and** `ALLOW_INSECURE_COOKIES=1` for local demos |
|
||||||
| WebSocket closes immediately (`4401`) | Missing/expired gateway cookie, or the upgrade's `Origin` does not match `Host` (CSWSH check); DSH rejecting `Host` instead surfaces as close `1011` — add the host to `DSH_TRUSTED_HOSTS` |
|
| WebSocket closes immediately (`4401`) | Missing/expired gateway cookie, or the upgrade's `Origin` does not match `Host` (CSWSH check); DSH rejecting `Host` instead surfaces as close `1011` — add the host to `DSH_TRUSTED_HOSTS` |
|
||||||
| Console spams `[connection] connection lost, retry #N` | The upstream WS upgrade is being rejected, surfaced as close `1011`. The gateway dials the sandbox **pod IP**, forwards no `Host`/`Origin`, and handshakes under the same `pod-IP:3080` authority DSH binds its cookie to; an older gateway image, or a sandbox with an empty `status.podIPs`, hits this loop |
|
| Console spams `[connection] connection lost, retry #N` | The upstream WS upgrade is being rejected, surfaced as close `1011`. The gateway dials the sandbox **pod IP**, forwards no `Host`/`Origin`, and handshakes under the same `pod-IP:3080` authority DSH binds its cookie to; an older gateway image, or a sandbox with an empty `status.podIPs`, hits this loop |
|
||||||
| `manifest.webmanifest` returns `401` in the console | Browsers fetch the Web App Manifest without credentials, so the gateway's session cookie is absent and the request fails closed. The UI is unaffected; only the PWA install prompt is lost |
|
| `manifest.webmanifest` returns `401` in the console | Browsers fetch the Web App Manifest without credentials, so the gateway's session cookie is absent and the request fails closed. The UI is unaffected; only the PWA install prompt is lost |
|
||||||
| Signup is slow / `202` then `Waking` | Warm pool exhausted (`kubectl get sandboxes -l agents.x-k8s.io/warm-pool-sandbox`); scale the pool |
|
| Signup is slow / `202` then `Waking` | Warm pool exhausted (`kubectl get sandboxes -l agents.x-k8s.io/warm-pool-sandbox`); scale the pool |
|
||||||
| `403` from the Kubernetes API in gateway logs | The gateway ServiceAccount is missing claim/sandbox/pod-log verbs (see `manifests/50-gateway.yaml`) |
|
| `403` from the Kubernetes API in gateway logs | The gateway ServiceAccount is missing claim/sandbox/pod-log verbs (see `manifests/50-gateway.yaml`) |
|
||||||
| Gateway pod `ErrImagePull` / `ImagePullBackOff` (especially on kind) | The manifest uses `imagePullPolicy: IfNotPresent` with the release identity. Build with `make gateway`, retag to exactly the manifest's image, `kind load docker-image <that-ref>`, or set the Deployment image to your local tag / pinned digest. On a real cluster, make sure the GHCR package is readable (public or a pull secret) |
|
| Gateway pod `ErrImagePull` / `ImagePullBackOff` (especially on kind) | The manifest uses `imagePullPolicy: IfNotPresent` with the release identity. Build with `make -C ../.. gateway`, retag to exactly the manifest's image, `kind load docker-image <that-ref>`, or set the Deployment image to your local tag / pinned digest. On a real cluster, make sure the GHCR package is readable (public or a pull secret) |
|
||||||
| Step 3: `pod "$SB" not found` after deploying the gateway | The claim was suspended by the idle sweeper. Claims created by the gateway carry `aaas.example.com/managed-by=dsh-gateway`; hand-applied claims (steps 1–7) do not and are never swept. Resume the sandbox: `kubectl -n dsh-demo patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Running"}}'` |
|
| Step 3: `pod "$SB" not found` after deploying the gateway | The claim was suspended by the idle sweeper. Claims created by the gateway carry `aaas.example.com/managed-by=dsh-gateway`; hand-applied claims (steps 1–7) do not and are never swept. Resume the sandbox: `kubectl -n dsh-demo patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Running"}}'` |
|
||||||
| Resume crash-loops: `credentials-local: … is readable beyond its owner (mode 660)` | The pod template lacks `fsGroupChangePolicy: OnRootMismatch`, so the kubelet's default fsGroup policy rewrote `.credentials.yaml` to `0660`; DSH requires `0600` and refuses to start. New installs get the policy from the template. For an existing sandbox: suspend it, run a throwaway pod on `dsh-home-$SB` to `chmod 600 /home/node/.dsh/.credentials.yaml`, patch the sandbox's `podTemplate` with the policy, then resume |
|
| Resume crash-loops: `credentials-local: … is readable beyond its owner (mode 660)` | The pod template lacks `fsGroupChangePolicy: OnRootMismatch`, so the kubelet's default fsGroup policy rewrote `.credentials.yaml` to `0660`; DSH requires `0600` and refuses to start. New installs get the policy from the template. For an existing sandbox: suspend it, run a throwaway pod on `dsh-home-$SB` to `chmod 600 /home/node/.dsh/.credentials.yaml`, patch the sandbox's `podTemplate` with the policy, then resume |
|
||||||
|
| Agents get `Permission denied` writing to `/workspace` on a fresh or resumed pod | The `/workspace` mount is the `dsh-home` PVC's `workspace/` subPath, and the kubelet is known to leave a `subPath` directory at `0755` if it is interrupted between creating and chmodding it ([kubernetes#142464](https://github.com/kubernetes/kubernetes/issues/142464)); later mounts never correct it. Suspend the sandbox, run a throwaway pod on `dsh-home-$SB` mounted at `/data`, `chmod 2775 /data/workspace` (do **not** chown/chmod recursively — that rewrites `.credentials.yaml`), then resume. If you recreate the sandbox instead, its current `/workspace` contents go with the PVC |
|
||||||
|
| Every bash command fails: `SANDBOX_UNAVAILABLE`, `sandbox mode "workspace-write" is requested but no sandbox backend is usable` | DSH's own command sandbox found neither backend: `bubblewrap` ships in the image but is unsetuid, so it needs an unprivileged user namespace this hardened pod does not grant, and no Landlock runner exists on this kernel/runtime — the confined executor fails closed. The template sets `DSH_PERMISSION_MODE=danger-full-access` to keep agents usable inside the pod boundary. To restore confinement, confirm a backend inside the pod (`bwrap --ro-bind / / true`; `grep -o landlock /sys/kernel/security/lsm`), set `DSH_PERMISSION_MODE=workspace-write` (or `read-only`), and recreate the pod — the value is stamped at creation. A session that explicitly chose a mode keeps that choice |
|
||||||
| SSO: GitHub login succeeds but the gateway asks for a token / returns `401` to `/recreate` | `AUTH_PROXY_HEADER` is not set on `deploy/dsh-gateway` (SSO is opt-in). Set `AUTH_PROXY_HEADER=X-Forwarded-User` and confirm the request actually reaches the gateway through oauth2-proxy |
|
| SSO: GitHub login succeeds but the gateway asks for a token / returns `401` to `/recreate` | `AUTH_PROXY_HEADER` is not set on `deploy/dsh-gateway` (SSO is opt-in). Set `AUTH_PROXY_HEADER=X-Forwarded-User` and confirm the request actually reaches the gateway through oauth2-proxy |
|
||||||
| SSO: gateway treats a caller as another user / bad `X-Forwarded-User` accepted | Trust boundary violation: something other than oauth2-proxy can reach `dsh-gateway:8080`. Remove the second path (Ingress/Gateway/port-forward), keep the Service ClusterIP, and only re-enable SSO once the proxy is the sole entry point. Never put a trusted identity header in front of a service that is directly reachable |
|
| SSO: gateway treats a caller as another user / bad `X-Forwarded-User` accepted | Trust boundary violation: something other than oauth2-proxy can reach `dsh-gateway:8080`. Remove the second path (Ingress/Gateway/port-forward), keep the Service ClusterIP, and only re-enable SSO once the proxy is the sole entry point. Never put a trusted identity header in front of a service that is directly reachable |
|
||||||
| SSO: oauth2-proxy `cookie_secret must be 16, 24, or 32 bytes ... but is 44 bytes` | The cookie secret was generated as standard base64. Use URL-safe unpadded base64 (see `73-oauth2-proxy-secret.yaml.example`) |
|
| SSO: oauth2-proxy `cookie_secret must be 16, 24, or 32 bytes ... but is 44 bytes` | The cookie secret was generated as standard base64. Use URL-safe unpadded base64 (see `73-oauth2-proxy-secret.yaml.example`) |
|
||||||
@@ -586,14 +622,27 @@ is non-recursive, so the optional manifest is not applied by the base step.
|
|||||||
| Sandbox cannot reach a model API after applying the optional NetworkPolicy | The policy allows DNS and public TCP `443` only; a proxy, custom base URL, port `80` or non-443 endpoint needs an explicit egress rule |
|
| Sandbox cannot reach a model API after applying the optional NetworkPolicy | The policy allows DNS and public TCP `443` only; a proxy, custom base URL, port `80` or non-443 endpoint needs an explicit egress rule |
|
||||||
| Gateway cannot reach a sandbox after applying an exposure NetworkPolicy | Check that the CNI enforces NetworkPolicy and that the Traefik/oauth2-proxy pod labels match; kubelet probes may also need the node CIDR |
|
| Gateway cannot reach a sandbox after applying an exposure NetworkPolicy | Check that the CNI enforces NetworkPolicy and that the Traefik/oauth2-proxy pod labels match; kubelet probes may also need the node CIDR |
|
||||||
| New provider key not visible in a sandbox | Pod env is stamped at creation: delete the warm-pool spares and recreate claimed pods. A key saved in DSH's credential store also wins over env |
|
| New provider key not visible in a sandbox | Pod env is stamped at creation: delete the warm-pool spares and recreate claimed pods. A key saved in DSH's credential store also wins over env |
|
||||||
|
| Folder picker opens on `/home/node` instead of the workspace | The sandbox was created before the template set `HOME=/workspace`, and pod env is stamped at creation. Verify with `kubectl -n dsh-demo exec "$SB" -- printenv HOME`; patch the sandbox's `podTemplate` with the env (or recreate the sandbox — the PVC keeps DSH state and workspace) and resume |
|
||||||
|
| File explorer and `ls /workspace` show `.cache`, `.config`, `.local`, `.npm`, `.agents` | Expected with `HOME=/workspace`: `~`-relative tool caches and DSH's global agent instructions/skills live on the PVC inside the workspace. Remove the `HOME` env from the template (and recreate the sandbox) if you would rather keep the picker on the ephemeral image home; point caches elsewhere per tool if only they are unwanted |
|
||||||
|
| Sandbox reports `Finished=True` and every gateway request fails | The sandbox Pod reached phase `Failed` (node eviction, spot reclaim) and the default `Retain` keeps it. Delete the Pod so the controller recreates it, or set `failedPodPolicy: Recreate` (agent-sandbox v1.0.8+; see optional hardening) |
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
Deliberately **not** production code:
|
Deliberately **not** production code:
|
||||||
|
|
||||||
- **Single replica.** Idle clocks, in-flight tracking, and the handshake cache
|
- **Single replica.** Idle clocks, in-flight tracking, the handshake cache and
|
||||||
are in memory. Running two gateway replicas would double-suspend or lose wake
|
the browser session table are in memory. Running two gateway replicas would
|
||||||
tracking; scale up only after moving that state to a shared store.
|
double-suspend or lose wake tracking, and sessions would land on the wrong
|
||||||
|
replica; scale up only after moving that state to a shared store.
|
||||||
|
- **Sessions die with the process.** Browser sessions are opaque ids held in
|
||||||
|
gateway memory (revocable on logout/delete/recreate, but not durable). A
|
||||||
|
gateway restart logs every browser out. There is no SSO refresh-token or
|
||||||
|
password store; identity is re-established by oauth2-proxy on the next
|
||||||
|
request.
|
||||||
|
- **Abuse controls are per-process and coarse.** `AUTH_RATE_LIMIT`, the signup
|
||||||
|
invite code, `MAX_IN_FLIGHT_PER_USER` and `WS_MAX_MESSAGE_BYTES` limit casual
|
||||||
|
abuse, but they are not a substitute for ingress rate limiting, WAF/quota, or
|
||||||
|
an audit log, and the in-memory rate buckets reset on restart.
|
||||||
- **Log scraping.** The launch token is recovered from pod logs. Log rotation, a
|
- **Log scraping.** The launch token is recovered from pod logs. Log rotation, a
|
||||||
restarted pod, or a changed DSH log format can break the handshake until the
|
restarted pod, or a changed DSH log format can break the handshake until the
|
||||||
next `401` refresh.
|
next `401` refresh.
|
||||||
@@ -603,30 +652,41 @@ Deliberately **not** production code:
|
|||||||
but the platform does not manage that. Real multi-tenancy needs per-tenant
|
but the platform does not manage that. Real multi-tenancy needs per-tenant
|
||||||
credentials or an egress proxy.
|
credentials or an egress proxy.
|
||||||
- **No TLS in the gateway.** Terminate TLS at an Ingress or Gateway and set
|
- **No TLS in the gateway.** Terminate TLS at an Ingress or Gateway and set
|
||||||
`COOKIE_SECURE=true`.
|
`COOKIE_SECURE=true`, then remove `ALLOW_INSECURE_COOKIES`. The base manifest
|
||||||
|
runs plain HTTP for the port-forward demo and is demo-only.
|
||||||
- **SSO trust is hop-based.** `AUTH_PROXY_HEADER` trusts whoever sets the header.
|
- **SSO trust is hop-based.** `AUTH_PROXY_HEADER` trusts whoever sets the header.
|
||||||
The SSO variant ships `75-networkpolicies.yaml`, which restricts the gateway
|
The SSO variant ships `75-networkpolicies.yaml`, which restricts the gateway
|
||||||
to oauth2-proxy and oauth2-proxy egress to the gateway/DNS/public HTTPS; a
|
to oauth2-proxy and oauth2-proxy egress to the gateway/DNS/public HTTPS; a
|
||||||
custom exposure without that policy has no signature, mTLS or network
|
custom exposure without that policy has no signature, mTLS or network
|
||||||
enforcement.
|
enforcement. Exposure without the policy is demo-only.
|
||||||
- **Isolation is opt-in.** gVisor, the sandbox NetworkPolicy and the exposure
|
- **DSH's inner file sandbox is off here.** The template sets
|
||||||
policies are opt-in; without them, sandboxes share the host kernel and can
|
`DSH_PERMISSION_MODE=danger-full-access` because neither backend of DSH's own
|
||||||
reach the cluster network. The base example is a demo, not a hardened
|
command sandbox is usable in this pod: `bubblewrap` is installed in the image
|
||||||
multi-tenant platform.
|
but is unsetuid, so it needs an unprivileged user namespace that the pod's
|
||||||
|
seccomp/capability surface does not grant (`capabilities.drop: ["ALL"]`), and
|
||||||
|
the kernel/runtime exposes no Landlock runner. The executor therefore fails
|
||||||
|
closed with `SANDBOX_UNAVAILABLE` and every bash command fails. The per-user
|
||||||
|
pod remains the boundary — non-root,
|
||||||
|
capabilities dropped, `seccompProfile: RuntimeDefault`, its own PVC and Service
|
||||||
|
— but an agent command can now read its own `DSH_HOME` (including
|
||||||
|
`.credentials.yaml`) and write anywhere in the container. Run sandboxes under
|
||||||
|
gVisor in production. On hosts where a backend works, set `workspace-write`
|
||||||
|
(or `read-only`) and verify before relying on the inner policy.
|
||||||
|
- **Isolation is opt-in.** gVisor, the sandbox NetworkPolicy, the gateway egress
|
||||||
|
policy and the exposure policies are opt-in; without them, sandboxes share the
|
||||||
|
host kernel and can reach the cluster network. The base example is a demo, not
|
||||||
|
a hardened multi-tenant platform. See the
|
||||||
|
[production checklist](docs/optional-hardening.md#6-production-checklist).
|
||||||
- **OAuth org is the only authorization.** Any GitHub user in the configured
|
- **OAuth org is the only authorization.** Any GitHub user in the configured
|
||||||
organization gets an auto-provisioned sandbox; there is no per-user allow-list,
|
organization gets an auto-provisioned sandbox; there is no per-user allow-list,
|
||||||
quota, or admin API.
|
quota, or admin API. `SIGNUP_INVITE_CODE` gates the gateway's own signup form,
|
||||||
|
not SSO auto-provisioning.
|
||||||
- **In-memory user directory.** Users are claims + annotations; there is no
|
- **In-memory user directory.** Users are claims + annotations; there is no
|
||||||
database, password reset, or admin API. Tokens are stored only as SHA-256
|
database, password reset, or admin API. Tokens are stored only as SHA-256
|
||||||
hashes in claim annotations.
|
hashes in claim annotations.
|
||||||
- **Bearer tokens in URLs.** Avoid putting the signup token in a URL/log; the
|
- **Bearer tokens in URLs.** Avoid putting the signup token in a URL/log; the
|
||||||
browser flow uses the `HttpOnly` cookie instead.
|
browser flow uses the `HttpOnly` cookie instead. The cookie no longer contains
|
||||||
|
the token, and a cookie value is never accepted as a bearer token.
|
||||||
## Cost model
|
|
||||||
|
|
||||||
The same economics as the upstream pattern: suspend-when-idle plus warm pools turn
|
|
||||||
an always-on instance into a sub-second cold-start pause per return-after-absence,
|
|
||||||
at a fraction of the cost.
|
|
||||||
|
|
||||||
## Cleanup
|
## Cleanup
|
||||||
|
|
||||||
@@ -648,5 +708,8 @@ delete the Secrets with the namespace and revoke the OAuth App in GitHub.
|
|||||||
- [Architecture](docs/architecture.md) — container/sequence diagrams, ADRs,
|
- [Architecture](docs/architecture.md) — container/sequence diagrams, ADRs,
|
||||||
security and RBAC, failure modes, evolution path.
|
security and RBAC, failure modes, evolution path.
|
||||||
- [Exposure README](manifests/exposure/README.md) — the two variants in detail.
|
- [Exposure README](manifests/exposure/README.md) — the two variants in detail.
|
||||||
|
- [SSO preflight](scripts/preflight-sso.sh) — read-only checks for the SSO
|
||||||
|
variant (Secrets, gateway env, TLS reference, placeholders, second paths).
|
||||||
- [Optional hardening](docs/optional-hardening.md) — gVisor, nodeSelector,
|
- [Optional hardening](docs/optional-hardening.md) — gVisor, nodeSelector,
|
||||||
NetworkPolicy and extra provider keys, with verification and rollback.
|
NetworkPolicy, extra provider keys and failed-Pod recovery, with verification,
|
||||||
|
the production checklist and rollback.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ Kubernetes, and DSH Web fit together, and why the design looks the way it does.
|
|||||||
|
|
||||||
Scope: one Kubernetes namespace (`dsh-demo`), one shared DSH Web image, one gateway deployment,
|
Scope: one Kubernetes namespace (`dsh-demo`), one shared DSH Web image, one gateway deployment,
|
||||||
and one DSH Web pod + PVC per user. The cluster is assumed to run
|
and one DSH Web pod + PVC per user. The cluster is assumed to run
|
||||||
[agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) v1.0.7 with the extensions
|
[agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox) with the pinned
|
||||||
|
`AGENT_SANDBOX_VERSION` from [`versions.env`](../../../versions.env) and the extensions
|
||||||
(`SandboxTemplate`, `SandboxWarmPool`, `SandboxClaim`).
|
(`SandboxTemplate`, `SandboxWarmPool`, `SandboxClaim`).
|
||||||
|
|
||||||
## Container view
|
## Container view
|
||||||
@@ -24,7 +25,7 @@ flowchart LR
|
|||||||
sb["Sandbox dsh-pool-xxxx<br/>spec.operatingMode"]
|
sb["Sandbox dsh-pool-xxxx<br/>spec.operatingMode"]
|
||||||
svc["Headless Service<br/>status.serviceFQDN"]
|
svc["Headless Service<br/>status.serviceFQDN"]
|
||||||
pod["DSH Web pod<br/>node process :3080"]
|
pod["DSH Web pod<br/>node process :3080"]
|
||||||
pvc[("PVC dsh-home-SANDBOX<br/>/home/node/.dsh")]
|
pvc[("PVC dsh-home-SANDBOX<br/>/home/node/.dsh + /workspace (subPath)")]
|
||||||
end
|
end
|
||||||
|
|
||||||
browser -->|"HTTP + WebSocket<br/>/u/USER/**"| gw
|
browser -->|"HTTP + WebSocket<br/>/u/USER/**"| gw
|
||||||
@@ -46,7 +47,7 @@ Responsibilities:
|
|||||||
| **Gateway** | Signup/login, session auth, `SandboxClaim` lifecycle, wake-on-connect, idle sweeper, HTTP/SSE/WebSocket reverse proxy, DSH launch-token handshake. |
|
| **Gateway** | Signup/login, session auth, `SandboxClaim` lifecycle, wake-on-connect, idle sweeper, HTTP/SSE/WebSocket reverse proxy, DSH launch-token handshake. |
|
||||||
| **Kubernetes API** | Source of truth for claims, sandboxes, and pods; also the log source for the DSH launch token. |
|
| **Kubernetes API** | Source of truth for claims, sandboxes, and pods; also the log source for the DSH launch token. |
|
||||||
| **agent-sandbox controller** | Adopts warm spares on claim, flips `operatingMode` (Running/Suspended), garbage-collects sandbox + PVC on claim delete. |
|
| **agent-sandbox controller** | Adopts warm spares on claim, flips `operatingMode` (Running/Suspended), garbage-collects sandbox + PVC on claim delete. |
|
||||||
| **DSH Web pod** | One `dsh web` process per user, state on the per-user PVC, listening on `3080`. |
|
| **DSH Web pod** | One `dsh web` process per user, DSH state and the agent workspace on the per-user PVC (`/home/node/.dsh` + `/workspace`), listening on `3080`. |
|
||||||
| **Warm pool** | Keeps `N` Ready sandboxes so signup is a label flip instead of a cold start. |
|
| **Warm pool** | Keeps `N` Ready sandboxes so signup is a label flip instead of a cold start. |
|
||||||
|
|
||||||
## Exposure variants
|
## Exposure variants
|
||||||
@@ -71,8 +72,10 @@ Each variant also ships a NetworkPolicy: `ingress/61-networkpolicy.yaml` limits
|
|||||||
to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml` limits the gateway to
|
to Traefik pods, and `gateway-api-sso/75-networkpolicies.yaml` limits the gateway to
|
||||||
oauth2-proxy, oauth2-proxy ingress to the Gateway implementation, and oauth2-proxy egress to
|
oauth2-proxy, oauth2-proxy ingress to the Gateway implementation, and oauth2-proxy egress to
|
||||||
DNS/public HTTPS/the gateway. The SSO policy turns the trust boundary into pod-traffic
|
DNS/public HTTPS/the gateway. The SSO policy turns the trust boundary into pod-traffic
|
||||||
enforcement; the caveats (Traefik label, `hostNetwork`, kubelet probes) are in
|
enforcement. These policies are **required for production**: exposing the gateway by applying
|
||||||
[optional hardening](optional-hardening.md).
|
only the Gateway/HTTPRoute/Ingress objects is demo-only. The sandbox-side policy and the
|
||||||
|
gateway's own egress policy are separate (the API-server endpoint is cluster-specific); see
|
||||||
|
[optional hardening](optional-hardening.md#gateway-egress-required-for-production).
|
||||||
|
|
||||||
## Flows
|
## Flows
|
||||||
|
|
||||||
@@ -86,20 +89,22 @@ sequenceDiagram
|
|||||||
participant K as Kubernetes API
|
participant K as Kubernetes API
|
||||||
participant C as agent-sandbox controller
|
participant C as agent-sandbox controller
|
||||||
|
|
||||||
B->>G: POST /users (form: user=bob, Origin)
|
B->>G: POST /users (form: user=bob, invite, Origin)
|
||||||
G->>G: validate user name and body size
|
G->>G: rate limit, invite check, validate user name and body size
|
||||||
G->>K: create SandboxClaim dsh-bob (warmPoolRef)
|
G->>K: create SandboxClaim dsh-bob (warmPoolRef)
|
||||||
K->>C: reconcile claim
|
K->>C: reconcile claim
|
||||||
C-->>K: adopt spare, Ready, status.sandbox.name = dsh-pool-xxxx
|
C-->>K: adopt spare, Ready, status.sandbox.name = dsh-pool-xxxx
|
||||||
G->>K: read claim (status) + sandbox + pod
|
G->>K: read claim (status) + sandbox + pod
|
||||||
G-->>B: 303 Location /u/bob/ + Set-Cookie dsh_gw_session
|
G->>G: mint opaque sid, store sid -> (user, expiry)
|
||||||
|
G-->>B: 303 Location /u/bob/ + Set-Cookie dsh_gw_session=<sid>
|
||||||
Note over G: JSON clients instead get 201 (Ready),<br/>202 (accepted, still waking), or 409 (conflict)
|
Note over G: JSON clients instead get 201 (Ready),<br/>202 (accepted, still waking), or 409 (conflict)
|
||||||
```
|
```
|
||||||
|
|
||||||
Form signup waits up to `WAKE_TIMEOUT` for the claim to become `Ready`, then redirects; the
|
Form signup waits up to `WAKE_TIMEOUT` for the claim to become `Ready`, then redirects; the
|
||||||
first proxied request also waits for `Ready` and performs the DSH handshake (below). A `409`
|
first proxied request also waits for `Ready` and performs the DSH handshake (below). A `409`
|
||||||
means the user already has a claim, in which case `POST /login` (bearer token) or the existing
|
means the user already has a claim, in which case `POST /login` (bearer token) or the existing
|
||||||
cookie is the right path.
|
cookie is the right path. The cookie value is an opaque session id: it is meaningless outside
|
||||||
|
this gateway process and is never accepted as a bearer token.
|
||||||
|
|
||||||
### First request / proxy (with handshake)
|
### First request / proxy (with handshake)
|
||||||
|
|
||||||
@@ -111,8 +116,8 @@ sequenceDiagram
|
|||||||
participant K as Kubernetes API
|
participant K as Kubernetes API
|
||||||
participant D as DSH Web pod
|
participant D as DSH Web pod
|
||||||
|
|
||||||
B->>G: GET /u/bob/ (Cookie dsh_gw_session)
|
B->>G: GET /u/bob/ (Cookie dsh_gw_session=<sid>)
|
||||||
G->>G: authenticate cookie, user = bob
|
G->>G: look up sid -> session (valid, not expired), user = bob
|
||||||
G->>K: resolve claim -> sandbox -> pod
|
G->>K: resolve claim -> sandbox -> pod
|
||||||
alt sandbox Suspended
|
alt sandbox Suspended
|
||||||
G->>K: patch operatingMode=Running (wake)
|
G->>K: patch operatingMode=Running (wake)
|
||||||
@@ -135,6 +140,36 @@ Handshake cache behavior: one entry per `(user, pod UID, Host)`; refreshed once
|
|||||||
`401`; dropped when the sandbox is suspended or deleted. The launch token and the DSH auth
|
`401`; dropped when the sandbox is suspended or deleted. The launch token and the DSH auth
|
||||||
cookie never reach the browser.
|
cookie never reach the browser.
|
||||||
|
|
||||||
|
Inside `proxy_http` the order is auth, in-flight slot, sandbox resolution/wake, DSH handshake,
|
||||||
|
body read, upstream send. An upload aborted mid-body may therefore already have paid for a wake
|
||||||
|
and a handshake, but it never reaches DSH.
|
||||||
|
|
||||||
|
### Browser sessions
|
||||||
|
|
||||||
|
The browser never holds a DSH credential. On signup/login the gateway mints a random session id
|
||||||
|
(`secrets.token_urlsafe(32)`), stores `sid -> Session(user, expires_at)` in memory, and sets it
|
||||||
|
as the `HttpOnly`, `SameSite=Lax` cookie `dsh_gw_session` (`__Host-dsh_gw_session` when
|
||||||
|
`COOKIE_SECURE=true`). Every browser request is resolved through that table; the cookie value is
|
||||||
|
not a bearer token and cannot be replayed against another user's path.
|
||||||
|
|
||||||
|
Sessions end on explicit logout, user delete, `/recreate` (all sessions for that user) and
|
||||||
|
expiry (`COOKIE_MAX_AGE`, default 7 days). Because the table is process memory, a gateway
|
||||||
|
restart logs every browser out; scaling beyond one replica therefore requires a shared session
|
||||||
|
store (or re-authentication on every request). The sweeper prunes expired entries and sessions
|
||||||
|
for users whose claim no longer exists.
|
||||||
|
|
||||||
|
### Auth abuse controls and proxy limits
|
||||||
|
|
||||||
|
Unauthenticated endpoints are throttled before the body is read: `AUTH_RATE_LIMIT` attempts per
|
||||||
|
client per `AUTH_RATE_WINDOW` (defaults 30/60s; keyed on the rightmost `X-Forwarded-For` entry
|
||||||
|
when present, else the peer address), answered with `429` + `Retry-After`. When
|
||||||
|
`SIGNUP_INVITE_CODE` is set, signup additionally requires a constant-time-matched code (form
|
||||||
|
field or `X-Signup-Invite`) and answers `403` otherwise. Proxied traffic is capped per user
|
||||||
|
(`MAX_IN_FLIGHT_PER_USER`, default 32 concurrent HTTP requests + WebSockets: `503` for HTTP,
|
||||||
|
close `1011` for WebSocket) and per WebSocket message received from DSH
|
||||||
|
(`WS_MAX_MESSAGE_BYTES`, 16 MiB). These are in-process guards for a single replica, not an ingress
|
||||||
|
rate limiter or quota system.
|
||||||
|
|
||||||
### Wake-on-connect and idle suspend
|
### Wake-on-connect and idle suspend
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -155,8 +190,21 @@ sequenceDiagram
|
|||||||
G-->>B: request held, then served
|
G-->>B: request held, then served
|
||||||
```
|
```
|
||||||
|
|
||||||
Suspend deletes only the pod. The PVC keeps conversations, sessions, and config, so resume
|
Suspend deletes only the pod. The PVC keeps conversations, sessions, config **and the agent
|
||||||
reattaches exactly the same state.
|
workspace**, so resume reattaches exactly the same state. Both trees live on one PVC: DSH state at
|
||||||
|
`/home/node/.dsh` (the volume root) and the workspace at `/workspace` (a second mount of the same
|
||||||
|
volume with `subPath: workspace`, which keeps `.credentials.yaml` out of the agent-visible tree).
|
||||||
|
The idle sweeper never touches the volume; deleting the claim still garbage-collects it, so
|
||||||
|
`POST /recreate` wipes state and workspace together.
|
||||||
|
|
||||||
|
**Workspace as home.** The template sets `HOME=/workspace`. DSH has no start-path setting for its
|
||||||
|
directory picker: the browse backend lists `$HOME` when the dialog opens, so with the image default
|
||||||
|
(`/home/node`, off the PVC) the picker would start on a throwaway directory. The override makes the
|
||||||
|
picker open on the persisted workspace and makes `~` resolve there; the cost is that tool caches
|
||||||
|
and config kept under `$HOME` (`npm`/`pnpm`, `uv`, `git`, `gh`, and DSH's global `~/.agents`
|
||||||
|
instructions and skills) become dotfiles in the workspace. `DSH_HOME` stays `/home/node/.dsh`, so
|
||||||
|
sessions, config and credentials remain outside the agent-visible tree. Pod env is stamped when a
|
||||||
|
sandbox is created, so an existing sandbox keeps the old `HOME` until it is recreated.
|
||||||
|
|
||||||
**Ownership boundary.** The gateway shares its namespace with claims created outside it — the
|
**Ownership boundary.** The gateway shares its namespace with claims created outside it — the
|
||||||
example's resource-layer walkthrough applies `dsh-alice` by hand, and every claim is named
|
example's resource-layer walkthrough applies `dsh-alice` by hand, and every claim is named
|
||||||
@@ -202,6 +250,14 @@ sequenceDiagram
|
|||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
An upgrade with a missing/expired session, or one that would exceed
|
||||||
|
`MAX_IN_FLIGHT_PER_USER`, is accepted and then closed: `4401` for authentication, `1011` for
|
||||||
|
the per-user socket cap. Messages received from DSH above `WS_MAX_MESSAGE_BYTES` (16 MiB) close
|
||||||
|
the connection instead of being buffered; browser-to-DSH frames are bounded by uvicorn's own
|
||||||
|
limit. Client-supplied `Forwarded`, `X-Forwarded-User`,
|
||||||
|
`X-Auth-Request-*` and `x-forwarded-*`/`x-auth-request-*` headers are stripped on the HTTP and
|
||||||
|
WebSocket paths; `UPSTREAM_HEADER_ALLOWLIST` replaces that denylist with an explicit allowlist.
|
||||||
|
|
||||||
## Decisions and trade-offs
|
## Decisions and trade-offs
|
||||||
|
|
||||||
### ADR-1: Starlette ASGI instead of a sync Flask proxy
|
### ADR-1: Starlette ASGI instead of a sync Flask proxy
|
||||||
@@ -261,14 +317,21 @@ sequenceDiagram
|
|||||||
(`kubectl annotate sandboxclaim ...`) to keep being swept. Fine for a reference pattern, not
|
(`kubectl annotate sandboxclaim ...`) to keep being swept. Fine for a reference pattern, not
|
||||||
for a product.
|
for a product.
|
||||||
|
|
||||||
### ADR-6: Gateway cookie session instead of bearer tokens in the browser
|
### ADR-6: Opaque server-side session cookie instead of bearer tokens in the browser
|
||||||
|
|
||||||
- **Decision:** browsers get `dsh_gw_session=<user>:<token>`, `HttpOnly`, `SameSite=Lax`
|
- **Decision:** browsers get `dsh_gw_session=<sid>`, `HttpOnly`, `SameSite=Lax` (`Secure` when
|
||||||
(`Secure` when enabled); API clients may still use `Authorization: Bearer`.
|
enabled); the sid maps to `Session(user, expires_at)` in gateway memory. API clients may still
|
||||||
- **Why:** tokens never appear in URLs or JS-readable storage, and `SameSite=Lax` plus a strict
|
use `Authorization: Bearer`. The cookie name is `__Host-dsh_gw_session` when the Secure flag
|
||||||
`Origin`/`Referer` check on cookie-authenticated mutations blocks basic CSRF.
|
is on and no explicit `COOKIE_NAME` is set.
|
||||||
- **Cost:** cookie-based auth needs TLS and correct `COOKIE_SECURE`; there is no signed-cookie
|
- **Why:** tokens never appear in URLs or JS-readable storage, the cookie value is useless if
|
||||||
layer, so the gateway trusts the token hash comparison for validation.
|
leaked outside the gateway session table, sessions can be revoked server-side (logout,
|
||||||
|
user delete, recreate, expiry), and a cookie cannot be reused as a bearer token for another
|
||||||
|
user's path. `SameSite=Lax` plus a strict `Origin`/`Referer` check on cookie-authenticated
|
||||||
|
mutations blocks basic CSRF.
|
||||||
|
- **Cost:** stateful auth in a single process: a restart logs everyone out and horizontal
|
||||||
|
scaling needs a shared session store. Cookie-based auth also needs TLS and the correct
|
||||||
|
`COOKIE_SECURE` setting; the gateway refuses to start with `COOKIE_SECURE=false` unless
|
||||||
|
`ALLOW_INSECURE_COOKIES=1` is set for a local HTTP demo.
|
||||||
|
|
||||||
## Security and RBAC
|
## Security and RBAC
|
||||||
|
|
||||||
@@ -287,26 +350,52 @@ Notes and limits:
|
|||||||
permission is scoped to one namespace, but anyone who can read gateway memory or logs can
|
permission is scoped to one namespace, but anyone who can read gateway memory or logs can
|
||||||
impersonate users. Prefer the upstream token API when available.
|
impersonate users. Prefer the upstream token API when available.
|
||||||
- **No cluster-wide access, no secret reads.** The gateway does not read `dsh-provider-keys`.
|
- **No cluster-wide access, no secret reads.** The gateway does not read `dsh-provider-keys`.
|
||||||
- **Browser-facing hardening:** `HttpOnly`/`SameSite=Lax`/optional `Secure` cookies,
|
(It does read the optional signup invite code if it is mounted as an env var.)
|
||||||
timing-safe SHA-256 token comparison, strict same-origin check on cookie-auth mutations,
|
- **Browser-facing hardening:** opaque server-side sessions instead of token-bearing cookies,
|
||||||
request-body limit (`MAX_BODY_MB`), hop-by-hop header stripping, upstream `Set-Cookie`
|
`HttpOnly`/`SameSite=Lax`/optional `Secure` (+ `__Host-` prefix when secure),
|
||||||
dropped, DSH `Location` headers rewritten under `/u/<user>/`.
|
timing-safe SHA-256 bearer-token comparison, timing-safe invite-code comparison, strict
|
||||||
|
same-origin check on cookie-auth mutations, request-body limit (`MAX_BODY_MB`), hop-by-hop
|
||||||
|
header stripping, client identity/hop headers (`Forwarded`, `X-Forwarded-User`,
|
||||||
|
`X-Auth-Request-*`, `x-forwarded-*`/`x-auth-request-*`) stripped on HTTP and WebSocket,
|
||||||
|
upstream `Set-Cookie` dropped, DSH `Location` headers rewritten under `/u/<user>/`.
|
||||||
|
- **Session lifecycle:** login/signup mint an opaque sid; logout and user delete revoke it;
|
||||||
|
`/recreate` revokes every session for that user; expiry (`COOKIE_MAX_AGE`, default 7 days) is
|
||||||
|
enforced on lookup. The table is in memory, so a restart logs browsers out and a second
|
||||||
|
replica needs shared state.
|
||||||
|
- **Abuse controls (per replica):** `AUTH_RATE_LIMIT`/`AUTH_RATE_WINDOW` throttle
|
||||||
|
unauthenticated signup/login per client with `429` + `Retry-After`; `SIGNUP_INVITE_CODE`
|
||||||
|
gates signup; `MAX_IN_FLIGHT_PER_USER` caps concurrent proxied requests + WebSockets per user;
|
||||||
|
`WS_MAX_MESSAGE_BYTES` caps messages received from DSH; browser-to-DSH frames are bounded by
|
||||||
|
uvicorn. These are in-process guards, not an ingress rate limiter or audit log.
|
||||||
- **Host allow-list:** DSH rejects unknown `Host` headers. The gateway forwards the browser's
|
- **Host allow-list:** DSH rejects unknown `Host` headers. The gateway forwards the browser's
|
||||||
`Host`, so every public hostname must be in `DSH_TRUSTED_HOSTS`; `localhost`/`127.0.0.1` are
|
`Host`, so every public hostname must be in `DSH_TRUSTED_HOSTS`; `localhost`/`127.0.0.1` are
|
||||||
always trusted.
|
always trusted.
|
||||||
- **TLS is external.** The gateway serves plain HTTP; terminate TLS at the Ingress and set
|
- **TLS is external.** The gateway serves plain HTTP; terminate TLS at the Ingress and set
|
||||||
`COOKIE_SECURE=true`. Without TLS, session cookies are replayable on the network.
|
`COOKIE_SECURE=true`. The base demo sets `COOKIE_SECURE=false` with
|
||||||
|
`ALLOW_INSECURE_COOKIES=1` for the port-forward walkthrough; without TLS, session cookies are
|
||||||
|
replayable on the network and that combination must never be exposed.
|
||||||
- **Tenant isolation** is pod + PVC separation; all tenants share one namespace, one DSH binary,
|
- **Tenant isolation** is pod + PVC separation; all tenants share one namespace, one DSH binary,
|
||||||
and the shared provider keys (`dsh-provider-keys` plus the optional
|
and the shared provider keys (`dsh-provider-keys` plus the optional
|
||||||
`dsh-provider-keys-extra`). `manifests/optional/network-policy/` adds an opt-in sandbox
|
`dsh-provider-keys-extra`). The base example is demo-only: production requires gVisor for the
|
||||||
NetworkPolicy, and gVisor/`nodeSelector` are opt-in PodSpec fields. Add per-tenant
|
sandboxes, the sandbox NetworkPolicy, the gateway egress policy and the exposure NetworkPolicy,
|
||||||
credentials before calling it multi-tenant-grade.
|
plus per-tenant credentials or an egress proxy. See the
|
||||||
- **Optional hardening is opt-in.** gVisor, node selection, the sandbox/exposure NetworkPolicies
|
[production checklist](optional-hardening.md#6-production-checklist).
|
||||||
and extra provider keys are documented in [optional hardening](optional-hardening.md). The
|
- **DSH's inner file sandbox is disabled by deployment override.** The template sets
|
||||||
base manifests stay portable (no gVisor RuntimeClass, no active node labels, no
|
`DSH_PERMISSION_MODE=danger-full-access` because the in-container backends are unusable here:
|
||||||
NetworkPolicy and no extra Secret required).
|
`bubblewrap` ships in the image but is unsetuid and cannot create an unprivileged user namespace
|
||||||
- **No gateway rate limiting.** Signup can create claims until the cluster or warm pool is
|
under the pod's seccomp/capability surface, and no Landlock runner is available; the confined
|
||||||
exhausted; put limits at the ingress for real deployments.
|
executor would otherwise fail closed on every command. The pod remains the boundary, so an
|
||||||
|
agent can read its own `DSH_HOME`. Hosts with a working backend can keep a confined mode;
|
||||||
|
multi-tenant deployments must put the pod in gVisor and a NetworkPolicy.
|
||||||
|
- **Optional hardening is opt-in.** gVisor, node selection, the sandbox/exposure NetworkPolicies,
|
||||||
|
extra provider keys and failed-Pod recovery are documented in
|
||||||
|
[optional hardening](optional-hardening.md). The base manifests stay portable (no gVisor
|
||||||
|
RuntimeClass, no active node labels, no NetworkPolicy, no extra Secret required, and the
|
||||||
|
controller's default `failedPodPolicy: Retain`).
|
||||||
|
- **SSO is trust-on-hop.** `AUTH_PROXY_HEADER` makes the gateway trust the proxy-asserted
|
||||||
|
identity; the SSO variant must remain the only path to the Service, and its NetworkPolicy is
|
||||||
|
required, not optional. Run [`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh) after
|
||||||
|
applying it.
|
||||||
|
|
||||||
## Failure modes
|
## Failure modes
|
||||||
|
|
||||||
@@ -315,7 +404,15 @@ Notes and limits:
|
|||||||
| DSH log line changed or rotated away | Proxy returns `503` (missing launch token) | Fix the parser or restart the pod so the token line is re-emitted |
|
| DSH log line changed or rotated away | Proxy returns `503` (missing launch token) | Fix the parser or restart the pod so the token line is re-emitted |
|
||||||
| Probe on `/` | Pod `CrashLoopBackOff`, `401` in probe logs | Use `/favicon.svg` for readiness/liveness (shipped templates do) |
|
| Probe on `/` | Pod `CrashLoopBackOff`, `401` in probe logs | Use `/favicon.svg` for readiness/liveness (shipped templates do) |
|
||||||
| Warm pool exhausted | JSON signup returns `202` (state `Waking`); form signup is slow | Increase `replicas` in the warm pool |
|
| Warm pool exhausted | JSON signup returns `202` (state `Waking`); form signup is slow | Increase `replicas` in the warm pool |
|
||||||
| Gateway restart | Idle sandboxes stop suspending; handshake cache lost | Rebuilt lazily on next request; consider a shared store for production |
|
| Gateway restart | Idle sandboxes stop suspending; handshake cache and browser sessions lost | Idle clocks and handshake cache rebuild lazily; sessions do not, so browsers must sign in again after a rollout. Use a shared session store for production |
|
||||||
|
| Gateway configured `COOKIE_SECURE=false` without `ALLOW_INSECURE_COOKIES=1` | Gateway pod exits at startup / `CrashLoopBackOff` | Intentional guard against an insecure cookie on an HTTPS deployment; set `ALLOW_INSECURE_COOKIES=1` only for the local plain-HTTP demo, or set `COOKIE_SECURE=true` |
|
||||||
|
| Auth rate limit hit | `429` + `Retry-After` on signup/login | Per-client in-memory bucket (`AUTH_RATE_LIMIT`/`AUTH_RATE_WINDOW`); wait for the window or tune it. Behind a proxy, keying uses the rightmost `X-Forwarded-For` entry |
|
||||||
|
| Signup invite missing/wrong | `403` before the claim is created | `SIGNUP_INVITE_CODE` is set; send the `invite` form field or `X-Signup-Invite` header |
|
||||||
|
| Per-user concurrency cap hit | HTTP `503` + `Retry-After`, or WebSocket close `1011` | `MAX_IN_FLIGHT_PER_USER` protects the single gateway from one busy tenant; reduce concurrency or raise the cap deliberately. These are per replica |
|
||||||
|
| Oversized WebSocket message from DSH | Connection closes | `WS_MAX_MESSAGE_BYTES` (16 MiB) is enforced on upstream messages; raise it only if DSH legitimately needs larger frames |
|
||||||
|
| Session expiry / revocation | `401` (HTTP) or close `4401` (WebSocket) | Expected after logout, user delete, `/recreate`, `COOKIE_MAX_AGE` expiry or a gateway restart; sign in again |
|
||||||
|
| SSO variant applied without its NetworkPolicy (or with an Ingress) | Gateway accepts spoofed `X-Forwarded-User` | Demo-only configuration; keep `75-networkpolicies.yaml`, remove the second path, and run `scripts/preflight-sso.sh` |
|
||||||
|
| Browser aborts a request (tab reload, back navigation) during a slow wake or handshake | No response reaches the client; nothing appears in the access log | If the abort lands before the response starts, the body read raises `ClientDisconnect` and the app answers `499` (`CLIENT_CLOSED_REQUEST`), which cannot reach the departed peer. Nothing reached DSH, no ERROR traceback is logged, and the in-flight slot is released. A request already streaming ends with its upstream status. |
|
||||||
| DSH auth cookie expired mid-session | Proxy `401` once, then recovers | Gateway refreshes the handshake once |
|
| DSH auth cookie expired mid-session | Proxy `401` once, then recovers | Gateway refreshes the handshake once |
|
||||||
| Upstream pod name changed after resume | Cached cookie keyed by old pod UID is useless | Cache is invalidated on suspend; next request re-handshakes |
|
| Upstream pod name changed after resume | Cached cookie keyed by old pod UID is useless | Cache is invalidated on suspend; next request re-handshakes |
|
||||||
| Two gateway replicas | Double suspend/wake races | Keep `replicas: 1` until state is externalized |
|
| Two gateway replicas | Double suspend/wake races | Keep `replicas: 1` until state is externalized |
|
||||||
@@ -323,6 +420,7 @@ Notes and limits:
|
|||||||
| gVisor + `kubectl port-forward` | Forward refuses or returns nothing | Port-forward is incompatible with gVisor; use the gateway or a reachable Service |
|
| gVisor + `kubectl port-forward` | Forward refuses or returns nothing | Port-forward is incompatible with gVisor; use the gateway or a reachable Service |
|
||||||
| Provider key added but missing in the pod | Requests still fail with `MISSING_CREDENTIAL` | Pod env is stamped at creation; recreate spares/pods. A stored credential wins over env |
|
| Provider key added but missing in the pod | Requests still fail with `MISSING_CREDENTIAL` | Pod env is stamped at creation; recreate spares/pods. A stored credential wins over env |
|
||||||
| `nodeSelector` matches no node | Pod `Pending`, `0/N nodes are available` | Fix the label, pool capacity or taint/toleration; RuntimeClass scheduling may also constrain it |
|
| `nodeSelector` matches no node | Pod `Pending`, `0/N nodes are available` | Fix the label, pool capacity or taint/toleration; RuntimeClass scheduling may also constrain it |
|
||||||
|
| Sandbox Pod reaches phase `Failed` (node eviction, spot reclaim) | Sandbox reports `Finished=True`; the gateway keeps failing against a dead Pod | Set `failedPodPolicy: Recreate` (agent-sandbox v1.0.8+) so the controller replaces the Pod with the same identity and PVCs; otherwise delete the Pod by hand. See the hardening doc |
|
||||||
|
|
||||||
## Upstream feature request: remove log scraping
|
## Upstream feature request: remove log scraping
|
||||||
|
|
||||||
@@ -345,10 +443,11 @@ single-retry refresh are the supported workaround.
|
|||||||
## Evolution path
|
## Evolution path
|
||||||
|
|
||||||
1. Replace log scraping with the upstream token API (above), deleting the `pods/log` permission.
|
1. Replace log scraping with the upstream token API (above), deleting the `pods/log` permission.
|
||||||
2. Move idle clocks, in-flight tracking, and handshake cache to a shared store; then scale the
|
2. Move idle clocks, in-flight tracking, handshake cache **and sessions** to a shared store; then
|
||||||
gateway horizontally (sticky routing not required if state is shared).
|
scale the gateway horizontally (sticky routing not required if state is shared).
|
||||||
3. Add per-tenant API keys or an egress proxy so a single key is not shared by all sandboxes.
|
3. Add per-tenant API keys or an egress proxy so a single key is not shared by all sandboxes.
|
||||||
4. Add NetworkPolicies (gateway → sandboxes only), TLS everywhere, rate limits, and audit
|
4. Add NetworkPolicies (gateway → sandboxes only, plus the documented gateway egress policy),
|
||||||
logging at the ingress.
|
gVisor for sandbox pods, TLS everywhere, ingress-level rate limits/quota and audit logging.
|
||||||
|
The gateway's in-process limits are not a substitute.
|
||||||
5. If DSH grows absolute URLs or per-user cookies that cannot be rewritten, switch to
|
5. If DSH grows absolute URLs or per-user cookies that cannot be rewritten, switch to
|
||||||
per-user subdomains with wildcard DNS/TLS.
|
per-user subdomains with wildcard DNS/TLS.
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
# Optional hardening: gVisor, nodeSelector, NetworkPolicy and provider keys
|
# Optional hardening: gVisor, nodeSelector, NetworkPolicy, provider keys and failed-Pod recovery
|
||||||
|
|
||||||
The base example is deliberately portable: it runs on kind and GKE without a
|
The base example is deliberately portable: it runs on kind and GKE without a
|
||||||
gVisor runtime, a tainted node pool or a NetworkPolicy-capable CNI, and it
|
gVisor runtime, a tainted node pool or a NetworkPolicy-capable CNI, and it
|
||||||
boots with only the required `dsh-provider-keys` Secret. This document covers
|
boots with only the required `dsh-provider-keys` Secret. That portability is
|
||||||
four **opt-in** layers:
|
exactly why the base manifests are **demo-only**. This document covers the
|
||||||
|
controls you add for production and the steps to enable or roll them back.
|
||||||
|
|
||||||
| Layer | Where it lives | What it changes |
|
**Production is not the base walkthrough.** At minimum, a production deployment
|
||||||
|
needs: gVisor for the sandbox pods (or another kernel-isolation boundary), the
|
||||||
|
sandbox NetworkPolicy, the gateway egress policy, the exposure variant's
|
||||||
|
NetworkPolicy, TLS with `COOKIE_SECURE=true` (and no
|
||||||
|
`ALLOW_INSECURE_COOKIES`), SSO (or an equivalent authenticating proxy) as the
|
||||||
|
only path to the gateway, and per-tenant provider credentials/egress. The
|
||||||
|
checklist at the end summarizes this; without the network and kernel layers the
|
||||||
|
example is not tenant-isolated.
|
||||||
|
|
||||||
|
This document covers four **opt-in** controls plus failed-Pod recovery:
|
||||||
|
|
||||||
|
| Setting | Where it lives | What it changes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| gVisor | commented `runtimeClassName` in the SandboxTemplates and Deployments | puts DSH and/or the gateway in a userspace kernel |
|
| gVisor | commented `runtimeClassName` in the SandboxTemplates and Deployments | puts DSH and/or the gateway in a userspace kernel; required for untrusted agents in production |
|
||||||
| nodeSelector | commented `nodeSelector` in the same files | pins pods to a chosen node pool |
|
| nodeSelector | commented `nodeSelector` in the same files | pins pods to a chosen node pool |
|
||||||
| NetworkPolicy | `manifests/optional/network-policy/`, `manifests/exposure/*/` | limits sandbox, gateway and proxy connectivity |
|
| NetworkPolicy | `manifests/optional/network-policy/`, `manifests/exposure/*/`, plus the gateway egress recipe | limits sandbox, gateway and proxy connectivity; the sandbox and gateway-egress policies are required for production |
|
||||||
| Provider keys | optional `dsh-provider-keys-extra` Secret | injects extra provider API keys into every sandbox |
|
| Provider keys | optional `dsh-provider-keys-extra` Secret | injects extra provider API keys into every sandbox |
|
||||||
|
| Failed-Pod recovery | commented `failedPodPolicy` in the SandboxTemplates | replaces a Failed sandbox Pod instead of parking the sandbox |
|
||||||
|
|
||||||
Each layer is independent. Enable only what the cluster supports and test the
|
Each setting is independent. Enable only what the cluster supports and test the
|
||||||
walkthrough after each change.
|
walkthrough after each change.
|
||||||
|
|
||||||
## 1. gVisor
|
## 1. gVisor
|
||||||
@@ -51,7 +64,7 @@ PodSpec; no CRD change is involved.
|
|||||||
- [`manifests/50-gateway.yaml`](../manifests/50-gateway.yaml)
|
- [`manifests/50-gateway.yaml`](../manifests/50-gateway.yaml)
|
||||||
- [`manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml`](../manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml)
|
- [`manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml`](../manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml)
|
||||||
(only with the SSO variant)
|
(only with the SSO variant)
|
||||||
- [minimal sandbox-template.yaml](../../minimal-single-tenant/manifests/sandbox-template.yaml)
|
- [minimal template](../../minimal-single-tenant/manifests/10-sandboxtemplate.yaml)
|
||||||
|
|
||||||
**Existing install:** a template edit does not update existing Sandboxes, and
|
**Existing install:** a template edit does not update existing Sandboxes, and
|
||||||
warm-pool spares use `OnReplenish`, so patch explicitly. Plan a maintenance
|
warm-pool spares use `OnReplenish`, so patch explicitly. Plan a maintenance
|
||||||
@@ -233,10 +246,17 @@ kubectl -n dsh-demo patch sandboxtemplate dsh-web --type merge \
|
|||||||
|
|
||||||
The base example does **not** manage NetworkPolicy: the SandboxTemplate is
|
The base example does **not** manage NetworkPolicy: the SandboxTemplate is
|
||||||
`networkPolicyManagement: Unmanaged`, and the gateway has no policy. That keeps
|
`networkPolicyManagement: Unmanaged`, and the gateway has no policy. That keeps
|
||||||
the walkthrough portable and independent of the sandbox-router; the opt-in
|
the walkthrough portable and independent of the sandbox-router. **For
|
||||||
policies below are standard Kubernetes `NetworkPolicy` objects.
|
production this is not optional**: without the sandbox policy every tenant can
|
||||||
|
reach every other tenant and cluster-internal endpoints, and without a gateway
|
||||||
|
egress policy the gateway can talk to anything the cluster network allows.
|
||||||
|
Apply both. The policies below are standard Kubernetes `NetworkPolicy` objects.
|
||||||
|
|
||||||
### Optional sandbox policy
|
> **Demo vs production.** An installation that skips the sandbox NetworkPolicy
|
||||||
|
> and the gateway egress policy is a demo. Exposure (Ingress/Gateway API) adds
|
||||||
|
> ingress protection only; it does not isolate tenants from each other.
|
||||||
|
|
||||||
|
### Sandbox policy (required for production)
|
||||||
|
|
||||||
[`manifests/optional/network-policy/10-sandbox-networkpolicy.yaml`](../manifests/optional/network-policy/10-sandbox-networkpolicy.yaml)
|
[`manifests/optional/network-policy/10-sandbox-networkpolicy.yaml`](../manifests/optional/network-policy/10-sandbox-networkpolicy.yaml)
|
||||||
selects every Sandbox pod through the agent-sandbox tracking label
|
selects every Sandbox pod through the agent-sandbox tracking label
|
||||||
@@ -300,10 +320,10 @@ Caveats:
|
|||||||
- `kubectl port-forward` still works for debugging (the connection is local to
|
- `kubectl port-forward` still works for debugging (the connection is local to
|
||||||
the pod), except under gVisor.
|
the pod), except under gVisor.
|
||||||
|
|
||||||
### Exposure-variant policies
|
### Exposure-variant policies (required, part of the variant)
|
||||||
|
|
||||||
Each exposure variant ships its own policy. They are part of the variant, not a
|
Each exposure variant ships its own policy. They are part of the variant and
|
||||||
separate opt-in step:
|
**required for production**; an exposure without its policy is demo-only:
|
||||||
|
|
||||||
- [`manifests/exposure/ingress/61-networkpolicy.yaml`](../manifests/exposure/ingress/61-networkpolicy.yaml)
|
- [`manifests/exposure/ingress/61-networkpolicy.yaml`](../manifests/exposure/ingress/61-networkpolicy.yaml)
|
||||||
allows only Traefik pods (`app.kubernetes.io/name: traefik`) to reach the
|
allows only Traefik pods (`app.kubernetes.io/name: traefik`) to reach the
|
||||||
@@ -316,7 +336,10 @@ separate opt-in step:
|
|||||||
The SSO policy is what turns the trust boundary in the
|
The SSO policy is what turns the trust boundary in the
|
||||||
[exposure README](../manifests/exposure/README.md) into network enforcement.
|
[exposure README](../manifests/exposure/README.md) into network enforcement.
|
||||||
The `dsh-gateway` Service must still stay ClusterIP; do not apply both exposure
|
The `dsh-gateway` Service must still stay ClusterIP; do not apply both exposure
|
||||||
variants.
|
variants. After applying the SSO variant, run
|
||||||
|
[`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh): it checks the
|
||||||
|
gateway's SSO env, the Secrets, the TLS reference, leftover placeholders and
|
||||||
|
accidental second paths (such as an Ingress).
|
||||||
|
|
||||||
Verify the negative case from a sandbox pod (it is not the proxy):
|
Verify the negative case from a sandbox pod (it is not the proxy):
|
||||||
|
|
||||||
@@ -331,11 +354,12 @@ different pod label, replace the `podSelector` with the correct selector or an
|
|||||||
`ipBlock`. As with the sandbox policy, kubelet probes can be affected on some
|
`ipBlock`. As with the sandbox policy, kubelet probes can be affected on some
|
||||||
CNIs.
|
CNIs.
|
||||||
|
|
||||||
### Gateway egress (documented, not shipped)
|
### Gateway egress (required for production)
|
||||||
|
|
||||||
The gateway needs DNS, the Kubernetes API, and TCP `3080` to sandbox pods. The
|
The gateway needs DNS, the Kubernetes API, and TCP `3080` to sandbox pods. An
|
||||||
API server endpoint is cluster-specific (Service CIDR or node IP/port), so an
|
egress policy is **required for production** but is not shipped, because the API
|
||||||
egress policy is **not** shipped. If your cluster has a stable API-server CIDR
|
server endpoint is cluster-specific (Service CIDR or node IP/port) and a wrong
|
||||||
|
value silently breaks signup/proxy. If your cluster has a stable API-server CIDR
|
||||||
and port, add an egress policy like this (replace the placeholders; verify the
|
and port, add an egress policy like this (replace the placeholders; verify the
|
||||||
port, commonly `443` or `6443`):
|
port, commonly `443` or `6443`):
|
||||||
|
|
||||||
@@ -412,7 +436,7 @@ Secrets; when the same key exists twice, the later `envFrom` entry wins.
|
|||||||
|
|
||||||
### Supported names (curated)
|
### Supported names (curated)
|
||||||
|
|
||||||
These are the names DSH v0.2.1-rc.3 resolves through its packaged provider
|
These are the names the pinned DSH release resolves through its packaged provider
|
||||||
catalog; the catalog has more entries, these are the common ones:
|
catalog; the catalog has more entries, these are the common ones:
|
||||||
|
|
||||||
| Provider | Environment variable |
|
| Provider | Environment variable |
|
||||||
@@ -473,14 +497,194 @@ kubectl -n dsh-demo exec "$POD" -- sh -c '
|
|||||||
- The optional Secret is created imperatively; the repository never contains a
|
- The optional Secret is created imperatively; the repository never contains a
|
||||||
real key. Keep it out of Git and out of shell history where possible.
|
real key. Keep it out of Git and out of shell history where possible.
|
||||||
|
|
||||||
## 5. Verification summary
|
## 5. Failed-Pod recovery
|
||||||
|
|
||||||
|
### What it changes
|
||||||
|
|
||||||
|
When a sandbox Pod reaches phase `Failed`, the controller's default policy,
|
||||||
|
`Retain`, leaves the Pod in place and surfaces `Finished=True` on the Sandbox.
|
||||||
|
The Sandbox, its claim and its PVC survive, but nothing runs: the gateway keeps
|
||||||
|
proxying to a dead Pod and every request fails until someone deletes the Pod or
|
||||||
|
the claim.
|
||||||
|
|
||||||
|
agent-sandbox v1.0.8+ (the current pin in [versions.env](../../../versions.env) is newer) adds
|
||||||
|
`spec.failedPodPolicy` to `Sandbox` and to `SandboxTemplate`:
|
||||||
|
|
||||||
|
| Value | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| `Retain` (default) | The Failed Pod stays and the Sandbox reports `Finished=True`; the evidence stays available for debugging. |
|
||||||
|
| `Recreate` | The controller deletes the Failed Pod and starts a replacement; the Sandbox identity and its Sandbox-owned PVCs are retained. |
|
||||||
|
|
||||||
|
Repeated failures back off like a Deployment - 5 s, doubling to 5 min between
|
||||||
|
creations - so a crash loop cannot hammer the API server. The backoff resets
|
||||||
|
once the replacement Pod has been continuously `Running` for 10 minutes, and it
|
||||||
|
lives in memory only, so a controller restart clears it.
|
||||||
|
|
||||||
|
The base templates ship it commented out, for two reasons:
|
||||||
|
|
||||||
|
- A deterministic failure (bad image, bad Secret, a wrong probe) would recreate
|
||||||
|
forever while hiding the cause.
|
||||||
|
- The templates use `restartPolicy: OnFailure`, so the kubelet restarts a
|
||||||
|
crashing container in place and the Pod does not reach phase `Failed` anyway.
|
||||||
|
This policy matters for terminal failures - most commonly node eviction
|
||||||
|
(`Evicted`), when a spot/preemptible node is reclaimed or the node hits memory
|
||||||
|
pressure.
|
||||||
|
|
||||||
|
No gateway change is needed: the handshake cache is keyed by Pod UID, so a
|
||||||
|
replacement Pod re-handshakes on the next request, and the existing `401` retry
|
||||||
|
covers containers restarted in place.
|
||||||
|
|
||||||
|
### Enable it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. The blueprint for future sandboxes.
|
||||||
|
kubectl -n dsh-demo patch sandboxtemplate dsh-web --type merge \
|
||||||
|
-p '{"spec":{"failedPodPolicy":"Recreate"}}'
|
||||||
|
|
||||||
|
# 2. Warm-pool spares were stamped at creation: delete them and let the pool
|
||||||
|
# replenish from the updated template.
|
||||||
|
kubectl -n dsh-demo delete sandboxes -l agents.x-k8s.io/warm-pool-sandbox
|
||||||
|
|
||||||
|
# 3. Claimed sandboxes keep their own spec: patch them in place.
|
||||||
|
for sb in $(kubectl -n dsh-demo get sandboxes \
|
||||||
|
-l '!agents.x-k8s.io/warm-pool-sandbox' -o jsonpath='{.items[*].metadata.name}'); do
|
||||||
|
kubectl -n dsh-demo patch sandbox "$sb" --type merge \
|
||||||
|
-p '{"spec":{"failedPodPolicy":"Recreate"}}'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
For a fresh install, uncomment `failedPodPolicy: Recreate` in the template
|
||||||
|
instead.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SB=$(kubectl -n dsh-demo get sandboxclaim dsh-alice -o jsonpath='{.status.sandbox.name}')
|
||||||
|
kubectl -n dsh-demo get sandbox "$SB" -o jsonpath='{.spec.failedPodPolicy}{"\n"}'
|
||||||
|
# Recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
A failure drill that never touches a user sandbox - a `Sandbox` with
|
||||||
|
`restartPolicy: Never` and a container that exits non-zero (swap the image if
|
||||||
|
Docker Hub is unreachable):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n dsh-demo apply -f - <<'EOF'
|
||||||
|
apiVersion: agents.x-k8s.io/v1beta1
|
||||||
|
kind: Sandbox
|
||||||
|
metadata:
|
||||||
|
name: failed-pod-drill
|
||||||
|
spec:
|
||||||
|
failedPodPolicy: Recreate
|
||||||
|
podTemplate:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: drill
|
||||||
|
image: busybox:1.36
|
||||||
|
command: ["sh", "-c", "exit 1"]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
kubectl -n dsh-demo get pods -w
|
||||||
|
# The drill Pod reaches phase Failed; a replacement appears within ~5 s, then
|
||||||
|
# 10 s, 20 s, ... as the container keeps exiting non-zero.
|
||||||
|
kubectl -n dsh-demo delete sandbox failed-pod-drill
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat the drill with `failedPodPolicy: Retain` (or omit the field) to see the
|
||||||
|
default: the Pod stays `Failed` and the Sandbox condition `Finished` becomes
|
||||||
|
`True`.
|
||||||
|
|
||||||
|
### Caveats
|
||||||
|
|
||||||
|
- `Recreate` deletes the Failed Pod, so its logs go with it. Capture
|
||||||
|
`kubectl get events` or switch back to `Retain` when debugging.
|
||||||
|
- The replacement is a cold start: DSH boots again and issues a new launch
|
||||||
|
token. The workspace comes back from the PVC; in-flight sessions do not.
|
||||||
|
- The field needs agent-sandbox v1.0.8+. A v1.0.7 cluster rejects it as an
|
||||||
|
unknown field.
|
||||||
|
- It applies to sandbox Pods only. The gateway and oauth2-proxy are plain
|
||||||
|
Deployments, and their ReplicaSets already replace Failed Pods.
|
||||||
|
|
||||||
|
### Roll back
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n dsh-demo patch sandboxtemplate dsh-web --type merge \
|
||||||
|
-p '{"spec":{"failedPodPolicy":"Retain"}}'
|
||||||
|
for sb in $(kubectl -n dsh-demo get sandboxes -o jsonpath='{.items[*].metadata.name}'); do
|
||||||
|
kubectl -n dsh-demo patch sandbox "$sb" --type merge \
|
||||||
|
-p '{"spec":{"failedPodPolicy":"Retain"}}'
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Warm-pool spares pick the default up as they are claimed and replenished, or
|
||||||
|
delete them as in step 2 above.
|
||||||
|
|
||||||
|
## 6. Production checklist
|
||||||
|
|
||||||
|
The base walkthrough is a demo. A production install must satisfy every item
|
||||||
|
below; the sections above give the commands and caveats.
|
||||||
|
|
||||||
|
**Isolation and network**
|
||||||
|
|
||||||
|
- [ ] Sandbox pods run under gVisor (or an equivalent kernel-isolation
|
||||||
|
boundary) because DSH runs with `DSH_PERMISSION_MODE=danger-full-access`
|
||||||
|
in this image; the pod is the boundary. (Section 1.)
|
||||||
|
- [ ] The sandbox NetworkPolicy is applied and verified: no tenant→tenant,
|
||||||
|
no tenant→cluster-Service/API, DNS + public HTTPS only. (Section 3.)
|
||||||
|
- [ ] The gateway egress policy is applied with the cluster's real API-server
|
||||||
|
CIDR/port, scoped to DNS, the API server and sandbox pods on `3080`.
|
||||||
|
(Section 3, "Gateway egress".)
|
||||||
|
- [ ] The exposure variant's NetworkPolicy is applied. An exposure without it is
|
||||||
|
demo-only. For SSO, the gateway is reachable **only** through
|
||||||
|
oauth2-proxy; there is no second Ingress/Gateway/port-forward path.
|
||||||
|
- [ ] Nodes/CNI enforce NetworkPolicy (verify the negative cases, not just the
|
||||||
|
positive path).
|
||||||
|
- [ ] The gateway and (optionally) oauth2-proxy run with `runAsNonRoot`,
|
||||||
|
dropped capabilities, `RuntimeDefault` seccomp and a read-only root
|
||||||
|
filesystem, as shipped.
|
||||||
|
|
||||||
|
**Authentication, TLS and abuse controls**
|
||||||
|
|
||||||
|
- [ ] TLS terminates in front of the gateway, `COOKIE_SECURE=true`, and
|
||||||
|
`ALLOW_INSECURE_COOKIES` is **not** set. The gateway refuses to start with
|
||||||
|
an insecure cookie unless explicitly acknowledged.
|
||||||
|
- [ ] SSO (or an equivalent authenticating proxy) is enabled with
|
||||||
|
`AUTH_PROXY_HEADER=X-Forwarded-User`, and
|
||||||
|
[`scripts/preflight-sso.sh`](../scripts/preflight-sso.sh) exits `0`.
|
||||||
|
- [ ] No `your-github-org` / `dsh-demo.example.com` placeholder remains.
|
||||||
|
- [ ] `SIGNUP_INVITE_CODE` is set from a Secret if the gateway's own signup form
|
||||||
|
is reachable, and `AUTH_RATE_LIMIT` is set deliberately (`30`/60s shipped).
|
||||||
|
Add ingress-level rate limiting/quota too; the gateway's buckets are
|
||||||
|
in-process and reset on restart.
|
||||||
|
- [ ] Session lifetime (`COOKIE_MAX_AGE`) and the logout/delete revocation
|
||||||
|
behavior are acceptable; account for gateway restarts logging users out.
|
||||||
|
- [ ] `MAX_IN_FLIGHT_PER_USER` and `WS_MAX_MESSAGE_BYTES` fit the workload.
|
||||||
|
|
||||||
|
**Credentials, platform and operations**
|
||||||
|
|
||||||
|
- [ ] Provider keys are per-tenant (or routed through an egress proxy) instead
|
||||||
|
of the shared `dsh-provider-keys` Secrets; any shared key is treated as
|
||||||
|
visible to every tenant.
|
||||||
|
- [ ] RBAC stays namespace-scoped; `pods/log` is treated as sensitive because
|
||||||
|
it contains launch tokens and model output. Plan the upstream token API
|
||||||
|
migration.
|
||||||
|
- [ ] Images are digest-pinned and dependency installs are hash-locked.
|
||||||
|
- [ ] Gateway replicas stay at `1` (in-memory sessions/idle state) or the state
|
||||||
|
is externalized.
|
||||||
|
- [ ] `failedPodPolicy: Recreate` is set if node eviction/spot reclaim is
|
||||||
|
expected.
|
||||||
|
- [ ] Audit logging, alerting and backups of the PVCs are in place.
|
||||||
|
|
||||||
|
## 7. Verification summary
|
||||||
|
|
||||||
Required locally for any change to this example:
|
Required locally for any change to this example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make check # versions, example layout, manifest YAML shape
|
make check # versions, example layout, manifest YAML shape
|
||||||
yamllint .
|
yamllint .
|
||||||
make test # gateway unit tests (gateway code is untouched)
|
make test # only when the gateway code changed
|
||||||
|
shellcheck examples/dsh-agents-as-a-service/scripts/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
CI additionally parses every manifest and runs `kubectl apply
|
CI additionally parses every manifest and runs `kubectl apply
|
||||||
@@ -494,19 +698,30 @@ Manual checks, on a cluster with the relevant CNI/runtime:
|
|||||||
gateway proxy works, port-forward limitation acknowledged.
|
gateway proxy works, port-forward limitation acknowledged.
|
||||||
- nodeSelector: pods land on the intended nodes; no `Pending` events.
|
- nodeSelector: pods land on the intended nodes; no `Pending` events.
|
||||||
- Sandbox policy: the allow/deny matrix above.
|
- Sandbox policy: the allow/deny matrix above.
|
||||||
|
- Gateway egress policy: signup and proxy still work; the gateway cannot reach
|
||||||
|
an arbitrary cluster Service or the metadata server.
|
||||||
- Exposure policies: browser path works; a non-proxy pod cannot reach the
|
- Exposure policies: browser path works; a non-proxy pod cannot reach the
|
||||||
gateway; probes stay healthy.
|
gateway; probes stay healthy. `scripts/preflight-sso.sh` exits `0`.
|
||||||
|
- Cookie/TLS guard: with `COOKIE_SECURE=false` and no
|
||||||
|
`ALLOW_INSECURE_COOKIES` the gateway refuses to start; with TLS it starts and
|
||||||
|
the cookie is `Secure` (`__Host-` prefixed).
|
||||||
|
- Auth abuse controls: `429` + `Retry-After` after the configured limit, `403`
|
||||||
|
on a wrong invite code, and revocations on logout/delete/recreate.
|
||||||
- Provider keys: set/unset output (never the values) and the refresh procedure.
|
- Provider keys: set/unset output (never the values) and the refresh procedure.
|
||||||
|
- Failed-Pod recovery: `spec.failedPodPolicy` on the Sandbox, and the drill
|
||||||
|
above replaces a `Failed` Pod while keeping the claim and PVC.
|
||||||
|
|
||||||
## 6. Rollback summary
|
## 8. Rollback summary
|
||||||
|
|
||||||
| Layer | Rollback |
|
| Layer | Rollback |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Sandbox policy | `kubectl delete -f manifests/optional/network-policy/` |
|
| Sandbox policy | `kubectl delete -f manifests/optional/network-policy/` |
|
||||||
|
| Gateway egress policy | `kubectl delete networkpolicy dsh-gateway-egress -n dsh-demo` (do not leave it half-configured) |
|
||||||
| Exposure policy | delete the exposure variant directory |
|
| Exposure policy | delete the exposure variant directory |
|
||||||
| gVisor | patch `runtimeClassName: null`, recreate pods |
|
| gVisor | patch `runtimeClassName: null`, recreate pods |
|
||||||
| nodeSelector | patch `nodeSelector: null`, recreate pods |
|
| nodeSelector | patch `nodeSelector: null`, recreate pods |
|
||||||
| Provider keys | delete the extra Secret or a key, recreate pods |
|
| Provider keys | delete the extra Secret or a key, recreate pods |
|
||||||
|
| Failed-Pod recovery | patch `failedPodPolicy: Retain`, delete warm-pool spares |
|
||||||
|
|
||||||
No PVC or workspace data is touched by any rollback. Deleting a `SandboxClaim`
|
No PVC or workspace data is touched by any rollback. Deleting a `SandboxClaim`
|
||||||
is different: it cascades to the Sandbox **and** its PVC, so do not use claim
|
is different: it cascades to the Sandbox **and** its PVC, so do not use claim
|
||||||
|
|||||||
@@ -1,16 +1,73 @@
|
|||||||
FROM python:3.12-slim
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Example gateway image: the tenant-facing proxy in front of DSH sandboxes.
|
||||||
|
# The base image is digest-pinned in versions.env (GATEWAY_PYTHON_IMAGE) and
|
||||||
|
# scripts/check-versions.sh verifies the ARG default below.
|
||||||
|
#
|
||||||
|
# Two stages on that base:
|
||||||
|
# build installs the fully hash-pinned runtime dependencies into /venv as
|
||||||
|
# a dedicated non-root UID, so the runtime stage copies a venv that
|
||||||
|
# contains no root-owned files.
|
||||||
|
# runtime copies /venv and the app, runs as UID 10000 with HOME=/tmp and no
|
||||||
|
# writable location of its own: usable with readOnlyRootFilesystem.
|
||||||
|
ARG GATEWAY_PYTHON_IMAGE=docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159
|
||||||
|
|
||||||
# Read-only-rootfs friendly: no .pyc writes, no home directory writes.
|
# ---------------------------------------------------------------------------
|
||||||
|
# Build stage: hash-pinned virtualenv owned by the runtime identity.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM $GATEWAY_PYTHON_IMAGE AS build
|
||||||
|
|
||||||
|
ARG APP_UID=10000
|
||||||
|
ARG APP_GID=10000
|
||||||
|
|
||||||
|
# No .pyc writes and no user cache dirs: the runtime image stays read-only.
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
HOME=/tmp
|
HOME=/tmp
|
||||||
|
|
||||||
WORKDIR /app
|
RUN groupadd --gid "$APP_GID" app \
|
||||||
|
&& useradd --uid "$APP_UID" --gid "$APP_GID" --no-create-home \
|
||||||
|
--home-dir /tmp --shell /usr/sbin/nologin app \
|
||||||
|
&& python -m venv /venv \
|
||||||
|
&& chown -R "$APP_UID:$APP_GID" /venv
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
COPY gateway.py .
|
# Install as the unprivileged user so every file in /venv is owned by UID 10000.
|
||||||
|
# --require-hashes makes pip reject any requirement without a matching hash:
|
||||||
|
# the dependency set in requirements.txt is fully pinned and verified. Use the
|
||||||
|
# venv's own pip: the system pip would user-install into $HOME instead.
|
||||||
|
USER $APP_UID:$APP_GID
|
||||||
|
RUN /venv/bin/pip install --no-cache-dir --require-hashes -r requirements.txt
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Runtime stage
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM $GATEWAY_PYTHON_IMAGE AS runtime
|
||||||
|
|
||||||
|
ARG APP_UID=10000
|
||||||
|
ARG APP_GID=10000
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
HOME=/tmp \
|
||||||
|
PATH="/venv/bin:$PATH"
|
||||||
|
|
||||||
|
RUN groupadd --gid "$APP_GID" app \
|
||||||
|
&& useradd --uid "$APP_UID" --gid "$APP_GID" --no-create-home \
|
||||||
|
--home-dir /tmp --shell /usr/sbin/nologin app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dependencies come from the build stage: same interpreter, no build tools left
|
||||||
|
# in the runtime image and nothing owned by root inside /venv.
|
||||||
|
COPY --from=build --chown=$APP_UID:$APP_GID /venv /venv
|
||||||
|
COPY --chown=$APP_UID:$APP_GID gateway.py .
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Run as a fixed non-root UID; HOME=/tmp keeps caches out of the image.
|
# Run as a fixed non-root UID; HOME=/tmp keeps caches out of the image.
|
||||||
USER 10000
|
USER 10000
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ It does four things:
|
|||||||
(only its SHA-256 is stored as a claim annotation), reports derived state,
|
(only its SHA-256 is stored as a claim annotation), reports derived state,
|
||||||
and deletes the claim (cascading the sandbox and PVC).
|
and deletes the claim (cascading the sandbox and PVC).
|
||||||
2. **Browser sessions** — exchanges a user token for an HttpOnly session
|
2. **Browser sessions** — exchanges a user token for an HttpOnly session
|
||||||
cookie so the DSH UI works in a browser at `/u/<user>/`. Automation can
|
cookie whose value is only an opaque, server-side session id (user and
|
||||||
still use `Authorization: Bearer <token>`.
|
expiry stay in the gateway's memory) so the DSH UI works in a browser at
|
||||||
|
`/u/<user>/`. Automation can still use
|
||||||
|
`Authorization: Bearer <token>`, which never comes from the cookie.
|
||||||
3. **Reverse proxy** — proxies HTTP (including SSE) and WebSocket traffic to
|
3. **Reverse proxy** — proxies HTTP (including SSE) and WebSocket traffic to
|
||||||
the sandbox's DSH Web server on `:3080`, including DSH's own cookie
|
the sandbox's DSH Web server on `:3080`, including DSH's own cookie
|
||||||
handshake.
|
handshake. Client-supplied forwarding/identity headers are stripped.
|
||||||
4. **Optional SSO** — when `AUTH_PROXY_HEADER` is set, trusts the identity
|
4. **Optional SSO** — when `AUTH_PROXY_HEADER` is set, trusts the identity
|
||||||
header an authenticating reverse proxy (for example oauth2-proxy) forwards,
|
header an authenticating reverse proxy (for example oauth2-proxy) forwards,
|
||||||
auto-provisions that user, and exposes `POST /recreate` for a clean restart.
|
auto-provisions that user, and exposes `POST /recreate` for a clean restart.
|
||||||
@@ -22,7 +24,7 @@ It does four things:
|
|||||||
The DSH version pin lives in [`versions.env`](../../../versions.env)
|
The DSH version pin lives in [`versions.env`](../../../versions.env)
|
||||||
(`DSH_VERSION` plus the optional repository `RELEASE_REVISION`); the example
|
(`DSH_VERSION` plus the optional repository `RELEASE_REVISION`); the example
|
||||||
manifests and published images use the release identity built from it (currently
|
manifests and published images use the release identity built from it (currently
|
||||||
`dsh-v0.2.1-rc.3-r4`). DSH binds `:3080`, serves `/`, gates `/api` behind a
|
`dsh-v0.2.1-rc.5-r6`). DSH binds `:3080`, gates the UI and `/api` behind a
|
||||||
`dsh-auth-*` cookie and requires a WebSocket at `/api/remote.mux`.
|
`dsh-auth-*` cookie and requires a WebSocket at `/api/remote.mux`.
|
||||||
|
|
||||||
## Flow
|
## Flow
|
||||||
@@ -63,10 +65,10 @@ token. A failed handshake returns `503` with `Retry-After: 10`.
|
|||||||
| Method | Path | Auth | Purpose |
|
| Method | Path | Auth | Purpose |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET` | `/healthz` | none | Liveness/readiness. Never touches the cluster. |
|
| `GET` | `/healthz` | none | Liveness/readiness. Never touches the cluster. |
|
||||||
| `GET` | `/` | none | Landing page with signup + token login forms. With SSO it auto-provisions the proxy-identified user and redirects to their workspace. |
|
| `GET` | `/` | none | Landing page with signup + token login forms (plus an invite-code input when `SIGNUP_INVITE_CODE` is set). With SSO it auto-provisions the proxy-identified user and redirects to their workspace. |
|
||||||
| `POST` | `/users` | none | Signup. JSON body `{"user": "<dns-1123 label>"}` or form field `user`. JSON returns `201` when Ready, `202` when accepted but still Waking; form returns `303` to `/u/<user>/`. Both set the session cookie. `409` if the user exists, `400` if invalid, `413` if the body is too large. |
|
| `POST` | `/users` | none | Signup. JSON body `{"user": "<dns-1123 label>"}` or form field `user`; when `SIGNUP_INVITE_CODE` is set, an `invite` form field or `X-Signup-Invite` header must match it (`403` otherwise, checked before the user lookup). JSON returns `201` when Ready, `202` when accepted but still Waking; form returns `303` to `/u/<user>/`. Both set the session cookie. `409` if the user exists, `400` if invalid, `413` if the body is too large, `429` when the auth rate limit is hit. |
|
||||||
| `POST` | `/login` | none | Form (`user`, `token`) returns `303` to `/u/<user>/`; JSON returns `200`. Sets the session cookie. `401` on a bad token, `404` for an unknown user. |
|
| `POST` | `/login` | none | Form (`user`, `token`) returns `303` to `/u/<user>/`; JSON returns `200`. Sets the session cookie. `401` on a bad token **and** for an unknown user (no username enumeration). `429` when the auth rate limit is hit. Both auth routes reject a form post whose `Origin`/`Referer` contradicts `Host` (`403`) when the browser sent one. |
|
||||||
| `POST` | `/logout` | none | Clears the session cookie and `303`s to `/`. |
|
| `POST` | `/logout` | none | Revokes the server-side session, clears the cookie and `303`s to `/`. |
|
||||||
| `POST` | `/recreate` | trusted proxy header | **SSO only.** Deletes the authenticated user's claim (cascading sandbox + PVC, so workspace data is wiped), waits out finalizers, provisions a fresh claim and `303`s to `/u/<user>/`. `401` without a valid identity header; cookie-authenticated calls must be same-origin. |
|
| `POST` | `/recreate` | trusted proxy header | **SSO only.** Deletes the authenticated user's claim (cascading sandbox + PVC, so workspace data is wiped), waits out finalizers, provisions a fresh claim and `303`s to `/u/<user>/`. `401` without a valid identity header; cookie-authenticated calls must be same-origin. |
|
||||||
| `GET` | `/users/{user}` | bearer or cookie | Derived state: `Provisioning`, `Waking`, `Ready`, `Suspending`, `Suspended`. |
|
| `GET` | `/users/{user}` | bearer or cookie | Derived state: `Provisioning`, `Waking`, `Ready`, `Suspending`, `Suspended`. |
|
||||||
| `DELETE` | `/users/{user}` | bearer or cookie | Delete the claim (sandbox + PVC cascade). Cookie-auth mutations require a same-origin `Origin`/`Referer`. |
|
| `DELETE` | `/users/{user}` | bearer or cookie | Delete the claim (sandbox + PVC cascade). Cookie-auth mutations require a same-origin `Origin`/`Referer`. |
|
||||||
@@ -78,19 +80,49 @@ token. A failed handshake returns `503` with `Retry-After: 10`.
|
|||||||
|
|
||||||
* `Authorization: Bearer <token>` is checked with a timing-safe SHA-256
|
* `Authorization: Bearer <token>` is checked with a timing-safe SHA-256
|
||||||
comparison against the `aaas.example.com/token-sha256` claim annotation.
|
comparison against the `aaas.example.com/token-sha256` claim annotation.
|
||||||
* The browser session cookie is
|
* The browser session cookie is **opaque**: its value is a random
|
||||||
`dsh_gw_session=<user>:<token>` (HttpOnly, SameSite=Lax, Path=/, optional
|
server-side session id; the user and expiry live in gateway memory
|
||||||
Secure). A session for user A never authorizes user B.
|
(`GatewayState.sessions`). A cookie can never be edited into another user's
|
||||||
|
credentials, a session id is not accepted as a bearer token, and a bearer
|
||||||
|
token never authorizes a different user. The cookie name defaults to
|
||||||
|
`__Host-dsh_gw_session` when `COOKIE_SECURE=true` (HTTPS) and to
|
||||||
|
`dsh_gw_session` for plain HTTP; attributes are HttpOnly, SameSite=Lax,
|
||||||
|
Path=/ and (with `COOKIE_SECURE`) Secure.
|
||||||
|
* Sessions are revoked on `POST /logout`, on `DELETE /users/{user}`, on
|
||||||
|
`POST /recreate`, and when they expire (looked up on every request; the
|
||||||
|
sweeper also drops expired entries). `from_env()` refuses to start without
|
||||||
|
`COOKIE_SECURE=true` or the explicit `ALLOW_INSECURE_COOKIES=1` opt-out for
|
||||||
|
local HTTP.
|
||||||
* Cookie-authenticated **mutations** (`POST`, `PUT`, `PATCH`, `DELETE`) and
|
* Cookie-authenticated **mutations** (`POST`, `PUT`, `PATCH`, `DELETE`) and
|
||||||
cookie-authenticated **WebSocket upgrades** require the request `Origin`
|
cookie-authenticated **WebSocket upgrades** require the request `Origin`
|
||||||
(or `Referer` fallback) to match the `Host` header. Missing or mismatched
|
(or `Referer` fallback) to match the `Host` header. Missing or mismatched
|
||||||
cross-origin metadata is rejected (`403` HTTP, close code `4401` for WS).
|
cross-origin metadata is rejected (`403` HTTP, close code `4401` for WS).
|
||||||
|
Form signup/login additionally reject a cross-origin `Origin`/`Referer`
|
||||||
|
when the browser sent one, before the body is read.
|
||||||
|
* `POST /users` and `POST /login` are rate limited per client:
|
||||||
|
`AUTH_RATE_LIMIT` attempts per `AUTH_RATE_WINDOW` seconds (default 30/60,
|
||||||
|
`0` disables). The client key is the rightmost `X-Forwarded-For` entry when
|
||||||
|
present (the address the last trusted proxy saw, not a spoofable leftmost
|
||||||
|
value), else the socket peer. Over-limit requests get `429` +
|
||||||
|
`Retry-After` before the body is read.
|
||||||
|
* Proxy requests strip client-supplied forwarding/identity headers in both
|
||||||
|
HTTP and WebSocket paths: `Forwarded`, every `X-Forwarded-*`
|
||||||
|
(including `X-Forwarded-User`), every `X-Auth-Request-*`, and `X-Real-IP`.
|
||||||
|
The gateway sets its own `X-Forwarded-For/Host/Proto/Port` values from the
|
||||||
|
connection. `UPSTREAM_HEADER_ALLOWLIST` optionally narrows the forwarded
|
||||||
|
client headers further (comma-separated, case-insensitive); unset keeps the
|
||||||
|
denylist behavior, and the gateway's own values are always added.
|
||||||
* The gateway's bearer token, the browser `Cookie` header and any `?token=`
|
* The gateway's bearer token, the browser `Cookie` header and any `?token=`
|
||||||
query parameter are never forwarded upstream. Only the cached `dsh-auth-*`
|
query parameter are never forwarded upstream. Only the cached `dsh-auth-*`
|
||||||
cookie is injected.
|
cookie is injected.
|
||||||
* Upstream `Set-Cookie` headers are dropped. `Location` headers that are
|
* Upstream `Set-Cookie` headers are dropped. `Location` headers that are
|
||||||
absolute paths are rewritten under `/u/<user>/`, and any `token` query
|
absolute paths are rewritten under `/u/<user>/`, and any `token` query
|
||||||
parameter is stripped.
|
parameter is stripped.
|
||||||
|
* Concurrency is capped per user: `MAX_IN_FLIGHT_PER_USER` proxied requests
|
||||||
|
(default 32) before the gateway answers `503` + `Retry-After` (HTTP) or
|
||||||
|
closes the WebSocket with `1011`. Messages received from DSH are limited to
|
||||||
|
`WS_MAX_MESSAGE_BYTES` (default 16 MiB); browser-to-DSH frames are bounded by
|
||||||
|
uvicorn's own WebSocket size limit.
|
||||||
|
|
||||||
### SSO (opt-in)
|
### SSO (opt-in)
|
||||||
|
|
||||||
@@ -106,7 +138,8 @@ kubectl -n dsh-demo set env deploy/dsh-gateway AUTH_PROXY_HEADER=X-Forwarded-Use
|
|||||||
When set, the gateway treats a request carrying that header as authenticated as
|
When set, the gateway treats a request carrying that header as authenticated as
|
||||||
that user, provided the value is a valid DNS-1123 label; the header is never
|
that user, provided the value is a valid DNS-1123 label; the header is never
|
||||||
trusted when the variable is unset. The landing page then auto-provisions the
|
trusted when the variable is unset. The landing page then auto-provisions the
|
||||||
user (creates their claim idempotently, mints a rotated session token) and
|
user (creates their claim idempotently, rotates the claim token when a new
|
||||||
|
session is needed and mints an opaque session cookie) and
|
||||||
redirects to `/u/<user>/`, and `POST /recreate` becomes available for a clean
|
redirects to `/u/<user>/`, and `POST /recreate` becomes available for a clean
|
||||||
restart. The base manifests leave it unset on purpose; the
|
restart. The base manifests leave it unset on purpose; the
|
||||||
[SSO exposure variant](../manifests/exposure/gateway-api-sso/) documents how to
|
[SSO exposure variant](../manifests/exposure/gateway-api-sso/) documents how to
|
||||||
@@ -133,17 +166,30 @@ oauth2-proxy's egress to the gateway, DNS and public HTTPS.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Unknown user / claim | `404` |
|
| Unknown user / claim | `404` |
|
||||||
| Missing or invalid credentials | `401` |
|
| Missing or invalid credentials | `401` |
|
||||||
| Cross-origin cookie mutation | `403` |
|
| Unknown user on `/login` | `401` (same as a bad token; no username enumeration) |
|
||||||
|
| Cross-origin cookie mutation / cross-origin form signup or login | `403` |
|
||||||
|
| Auth route over `AUTH_RATE_LIMIT` | `429` + `Retry-After`, before the body is read |
|
||||||
| Request body over `MAX_BODY_MB` | `413` |
|
| Request body over `MAX_BODY_MB` | `413` |
|
||||||
| Sandbox not provisioned, wake timeout, handshake failure | `503` + `Retry-After: 10` |
|
| Client aborts mid-body, before the response starts | `499` (`CLIENT_CLOSED_REQUEST`). Nothing is forwarded to DSH and nothing can be delivered; it is not an error, so no traceback is logged. Covers the body-reading control routes (`POST /users`, `POST /login`) as well as the proxy. |
|
||||||
|
| Client aborts mid-stream (SSE, long response) | The response already started, so its upstream status stands (typically `200`); the stream simply ends. No traceback, and the in-flight slot is released as the relay unwinds. |
|
||||||
|
| Sandbox not provisioned, wake timeout, handshake failure | `503` + `Retry-After: 10`. The handshake error body is a fixed message: it never contains the launch token, the handshake URL or pod/credential details. |
|
||||||
|
| User over `MAX_IN_FLIGHT_PER_USER` | `503` + `Retry-After` (HTTP); the existing in-flight requests are untouched |
|
||||||
| Upstream connection/protocol failure | `502` |
|
| Upstream connection/protocol failure | `502` |
|
||||||
| WS auth failure (unknown user, bad token, cross-origin) | accept, then close `4401` |
|
| WS auth failure (unknown user, bad token, cross-origin) | accept, then close `4401` |
|
||||||
|
| WS per-user concurrency cap | accept, then close `1011` |
|
||||||
| WS upstream/handshake failure | accept, then close `1011` |
|
| WS upstream/handshake failure | accept, then close `1011` |
|
||||||
|
| WS message from DSH over `WS_MAX_MESSAGE_BYTES` | the upstream/relay closes the connection (websockets' message-too-big path) |
|
||||||
|
|
||||||
A close code can only be delivered after the WebSocket upgrade is accepted;
|
A close code can only be delivered after the WebSocket upgrade is accepted;
|
||||||
Starlette would turn an early close into an HTTP 403, which is why WS auth
|
Starlette would turn an early close into an HTTP 403, which is why WS auth
|
||||||
failures are `101`, then close `4401`.
|
failures are `101`, then close `4401`.
|
||||||
|
|
||||||
|
An aborted request is deliberately silent: uvicorn runs with `--no-access-log`,
|
||||||
|
and the `499` cannot reach a peer that has already left (uvicorn's `send()` is
|
||||||
|
a no-op once the socket is closed). Sandbox resolution, the wake and the DSH
|
||||||
|
handshake run before the body is read, so an aborted upload can cost a wake or
|
||||||
|
a handshake but never reaches DSH.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All values are read from the environment by `Settings.from_env()`; every one
|
All values are read from the environment by `Settings.from_env()`; every one
|
||||||
@@ -158,21 +204,35 @@ can also be injected into `create_app` for tests.
|
|||||||
| `DSH_PORT` | `3080` | Upstream DSH port. |
|
| `DSH_PORT` | `3080` | Upstream DSH port. |
|
||||||
| `DSH_CONTAINER` | `dsh-web` | Container whose logs carry the launch token. |
|
| `DSH_CONTAINER` | `dsh-web` | Container whose logs carry the launch token. |
|
||||||
| `DSH_LOG_TAIL_LINES` | `2000` | How many log lines to scan for the launch token. |
|
| `DSH_LOG_TAIL_LINES` | `2000` | How many log lines to scan for the launch token. |
|
||||||
| `COOKIE_NAME` | `dsh_gw_session` | Browser session cookie name. |
|
| `COOKIE_NAME` | `__Host-dsh_gw_session` when secure, else `dsh_gw_session` | Browser session cookie name. Only an opaque session id is stored in it. |
|
||||||
| `COOKIE_SECURE` | `false` | Add `Secure` to the session cookie when served over TLS. |
|
| `COOKIE_SECURE` | `false` | Add `Secure` to the session cookie. Required unless `ALLOW_INSECURE_COOKIES=1` is set for local HTTP. |
|
||||||
| `COOKIE_MAX_AGE` | `604800` | Session cookie lifetime in seconds. |
|
| `ALLOW_INSECURE_COOKIES` | `0` | Explicit opt-out for plain-HTTP demos. Without it, `Settings.from_env()` refuses to start unless `COOKIE_SECURE=true`. |
|
||||||
|
| `COOKIE_MAX_AGE` | `604800` | Session lifetime in seconds (server-side expiry and cookie `Max-Age`). |
|
||||||
| `MAX_BODY_MB` | `32` | Maximum buffered request body (and `413` threshold). |
|
| `MAX_BODY_MB` | `32` | Maximum buffered request body (and `413` threshold). |
|
||||||
| `HTTP_TIMEOUT` | `300` | Upstream connect/read timeout in seconds. Proxied streams (SSE, long responses) keep the connect/write budget but have **no read timeout**, so an idle event stream is not cut every five minutes. |
|
| `HTTP_TIMEOUT` | `300` | Upstream connect/read timeout in seconds. Proxied streams (SSE, long responses) keep the connect/write budget but have **no read timeout**, so an idle event stream is not cut every five minutes. |
|
||||||
| `SWEEP_INTERVAL` | `15` | Seconds between idle sweeps. |
|
| `SWEEP_INTERVAL` | `15` | Seconds between idle sweeps. Sweeps also prune expired sessions and rate buckets and orphaned per-user state. |
|
||||||
|
| `SIGNUP_INVITE_CODE` | unset | When set, `POST /users` requires the matching `invite` form field or `X-Signup-Invite` header (timing-safe compare); the landing page then shows an invite input. Unset leaves signup open. |
|
||||||
|
| `AUTH_RATE_LIMIT` | `30` | Attempts allowed per client per `AUTH_RATE_WINDOW` on `POST /users` and `POST /login`; `0` disables the limit. |
|
||||||
|
| `AUTH_RATE_WINDOW` | `60` | Rate-limit window in seconds. |
|
||||||
|
| `UPSTREAM_HEADER_ALLOWLIST` | unset | Comma-separated, case-insensitive allowlist of client headers to forward upstream. Unset keeps the denylist behavior; forwarding/identity headers are stripped in both modes. |
|
||||||
|
| `MAX_IN_FLIGHT_PER_USER` | `32` | Concurrent proxied requests per user before `503`/WS `1011`. `0` disables the cap. |
|
||||||
|
| `WS_MAX_MESSAGE_BYTES` | `16777216` | Largest WebSocket message (16 MiB) accepted from DSH. Browser-to-DSH frames are bounded by uvicorn's own limit. |
|
||||||
| `AUTH_PROXY_HEADER` | unset | Opt-in SSO: name of the trusted identity header set by an authenticating reverse proxy (e.g. `X-Forwarded-User`). Unset disables SSO and the header is never trusted. Enable only when the Service is reachable exclusively through that proxy (see [SSO](#sso-opt-in)). |
|
| `AUTH_PROXY_HEADER` | unset | Opt-in SSO: name of the trusted identity header set by an authenticating reverse proxy (e.g. `X-Forwarded-User`). Unset disables SSO and the header is never trusted. Enable only when the Service is reachable exclusively through that proxy (see [SSO](#sso-opt-in)). |
|
||||||
|
|
||||||
|
The shipped `manifests/50-gateway.yaml` sets `IDLE_TIMEOUT=360` and
|
||||||
|
`WAKE_TIMEOUT=240` for the demo; the e2e script overrides them to 60/120 so the
|
||||||
|
smoke test does not wait minutes for the sweeper. It also sets
|
||||||
|
`COOKIE_SECURE=false` together with the explicit `ALLOW_INSECURE_COOKIES=1`
|
||||||
|
acknowledgement because the demo is reached over plain HTTP (a `Secure` cookie
|
||||||
|
would never be sent). HTTPS deployments must set `COOKIE_SECURE=true`.
|
||||||
|
|
||||||
### Required RBAC
|
### Required RBAC
|
||||||
|
|
||||||
The gateway's ServiceAccount needs, in its namespace:
|
The gateway's ServiceAccount needs, in its namespace:
|
||||||
|
|
||||||
* `sandboxclaims` (`extensions.agents.x-k8s.io`): get, list, create, delete
|
* `sandboxclaims` (`extensions.agents.x-k8s.io`): get, list, create, delete
|
||||||
* `sandboxes` (`agents.x-k8s.io`): get, patch
|
* `sandboxes` (`agents.x-k8s.io`): get, list, patch
|
||||||
* `pods`: get
|
* `pods`: get, list
|
||||||
* `pods/log`: get
|
* `pods/log`: get
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
@@ -185,8 +245,10 @@ python -m pip install -r requirements-dev.txt
|
|||||||
python -m pytest -q
|
python -m pytest -q
|
||||||
python -m ruff check .
|
python -m ruff check .
|
||||||
|
|
||||||
# Uvicorn factory (loads in-cluster credentials only on the first claim call)
|
# Uvicorn factory (loads in-cluster credentials only on the first claim call).
|
||||||
uvicorn gateway:create_app --factory --host 0.0.0.0 --port 8080
|
# from_env() insists on an explicit acknowledgement for plain HTTP; behind TLS
|
||||||
|
# set COOKIE_SECURE=true instead.
|
||||||
|
ALLOW_INSECURE_COOKIES=1 uvicorn gateway:create_app --factory --host 0.0.0.0 --port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Container image, from the repository root:
|
Container image, from the repository root:
|
||||||
@@ -214,18 +276,27 @@ uvicorn's access log so bearer tokens in URLs are not logged.
|
|||||||
Log rotation, a very chatty container, or DSH changing its startup message
|
Log rotation, a very chatty container, or DSH changing its startup message
|
||||||
breaks the handshake. Prefer a first-class token/credential API when DSH
|
breaks the handshake. Prefer a first-class token/credential API when DSH
|
||||||
offers one.
|
offers one.
|
||||||
* **Single replica, in-memory state.** The handshake cookie cache, idle
|
* **Single replica, in-memory state.** The handshake cookie cache, the opaque
|
||||||
activity clocks and in-flight counters live in one process. Running more
|
session table, idle activity clocks, in-flight counters and rate-limit
|
||||||
than one replica would need shared state (or sticky routing), otherwise each
|
buckets live in one process. Running more than one replica would need shared
|
||||||
replica handshakes independently and the sweeper may race another replica.
|
state (or sticky routing), otherwise each replica handshakes independently,
|
||||||
|
the sweeper may race another replica, and a session/rate budget is only seen
|
||||||
|
by the replica that created it.
|
||||||
|
* **Sessions die with the process.** An opaque session id is valid only in the
|
||||||
|
process that minted it and expires after `COOKIE_MAX_AGE`; a restart logs
|
||||||
|
every browser out (tokens are unaffected).
|
||||||
* **In-memory token cache has no TTL.** It is keyed by pod UID, so pod
|
* **In-memory token cache has no TTL.** It is keyed by pod UID, so pod
|
||||||
restarts invalidate it naturally; otherwise entries survive until the pod or
|
restarts invalidate it naturally; otherwise entries survive until the pod or
|
||||||
claim is deleted.
|
claim is deleted.
|
||||||
* **No TLS termination.** Run behind an ingress/load balancer; set
|
* **No TLS termination.** Run behind an ingress/load balancer and set
|
||||||
`COOKIE_SECURE=true` there.
|
`COOKIE_SECURE=true`. `Settings.from_env()` refuses to start without it
|
||||||
|
unless `ALLOW_INSECURE_COOKIES=1` explicitly acknowledges plain-HTTP
|
||||||
|
sessions.
|
||||||
* **Origin-based CSRF protection only.** Cookie-auth mutations require a
|
* **Origin-based CSRF protection only.** Cookie-auth mutations require a
|
||||||
same-origin `Origin`/`Referer`; there is no per-form CSRF token, rate
|
same-origin `Origin`/`Referer`, and form signup/login reject a contradicting
|
||||||
limiting or login throttling.
|
origin when the browser sends one; there is no per-form CSRF token. Auth
|
||||||
|
routes have per-client rate limiting and login throttling, but the budget is
|
||||||
|
in-process (per replica) and keyed by the rightmost `X-Forwarded-For` value.
|
||||||
* **HTTP request bodies are buffered** up to `MAX_BODY_MB`; uploads are not
|
* **HTTP request bodies are buffered** up to `MAX_BODY_MB`; uploads are not
|
||||||
streamed. Responses (including SSE) are streamed.
|
streamed. Responses (including SSE) are streamed.
|
||||||
* **Upstream cookies are not forwarded.** DSH cannot rotate its own
|
* **Upstream cookies are not forwarded.** DSH cannot rotate its own
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ single teaching-friendly module with three jobs:
|
|||||||
(only its SHA-256 is stored, as a claim annotation), report derived state,
|
(only its SHA-256 is stored, as a claim annotation), report derived state,
|
||||||
and delete the claim (which cascades the sandbox and its PVC).
|
and delete the claim (which cascades the sandbox and its PVC).
|
||||||
* **Browser sessions** -- exchange a user token for an HttpOnly session cookie
|
* **Browser sessions** -- exchange a user token for an HttpOnly session cookie
|
||||||
so the DSH UI can be opened in a browser at ``/u/<user>/``. Browser requests
|
whose value is an opaque, server-side session id (the user and expiry live in
|
||||||
authenticate with that cookie; automation can keep using
|
memory) so the DSH UI can be opened in a browser at ``/u/<user>/``. Browser
|
||||||
``Authorization: Bearer <token>``.
|
requests authenticate with that cookie; automation can keep using
|
||||||
|
``Authorization: Bearer <token>``. Sessions are revoked on logout, user
|
||||||
|
deletion, recreate and expiry, and a cookie is never accepted as a bearer
|
||||||
|
token (nor the other way around).
|
||||||
* **Reverse proxy** -- HTTP (including SSE) and WebSocket proxying to the
|
* **Reverse proxy** -- HTTP (including SSE) and WebSocket proxying to the
|
||||||
sandbox's DSH Web server on ``:3080``. DSH itself is cookie-gated: the
|
sandbox's DSH Web server on ``:3080``. DSH itself is cookie-gated: the
|
||||||
gateway scrapes the launch token from the pod logs, performs the ``?token=``
|
gateway scrapes the launch token from the pod logs, performs the ``?token=``
|
||||||
@@ -38,6 +41,7 @@ import contextlib
|
|||||||
import hashlib
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
@@ -62,7 +66,7 @@ from kubernetes import config as k8s_config
|
|||||||
from kubernetes.client.exceptions import ApiException
|
from kubernetes.client.exceptions import ApiException
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.datastructures import Headers
|
from starlette.datastructures import Headers
|
||||||
from starlette.requests import Request
|
from starlette.requests import ClientDisconnect, Request
|
||||||
from starlette.responses import (
|
from starlette.responses import (
|
||||||
HTMLResponse,
|
HTMLResponse,
|
||||||
JSONResponse,
|
JSONResponse,
|
||||||
@@ -121,6 +125,19 @@ OWNED_REQUEST_HEADERS = HOP_BY_HOP | {
|
|||||||
"x-real-ip",
|
"x-real-ip",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Default session cookie names. ``__Host-`` is only legal over HTTPS and pins
|
||||||
|
# the cookie to Path=/ with no Domain attribute; the insecure default keeps the
|
||||||
|
# example usable over plain HTTP behind a port-forward.
|
||||||
|
DEFAULT_COOKIE_NAME = "dsh_gw_session"
|
||||||
|
SECURE_COOKIE_NAME = "__Host-dsh_gw_session"
|
||||||
|
|
||||||
|
# Header prefixes a client must never be able to smuggle upstream. The gateway
|
||||||
|
# derives its own X-Forwarded-* values from the connection; anything the browser
|
||||||
|
# sends with these names is attacker-controlled and is dropped in both the HTTP
|
||||||
|
# and WebSocket proxy paths.
|
||||||
|
FORWARDING_HEADER_PREFIXES = ("x-forwarded-", "x-auth-request-")
|
||||||
|
FORWARDING_HEADERS = frozenset({"forwarded"})
|
||||||
|
|
||||||
_DSH_WEB_LINE = re.compile(r"dsh web:\s+(?P<url>https?://\S+)")
|
_DSH_WEB_LINE = re.compile(r"dsh web:\s+(?P<url>https?://\S+)")
|
||||||
|
|
||||||
|
|
||||||
@@ -144,6 +161,11 @@ class UpstreamError(Exception):
|
|||||||
"""The upstream DSH connection failed."""
|
"""The upstream DSH connection failed."""
|
||||||
|
|
||||||
|
|
||||||
|
# nginx's "client closed request": the peer went away before it could be
|
||||||
|
# answered, so no status is ever delivered.
|
||||||
|
CLIENT_CLOSED_REQUEST = 499
|
||||||
|
|
||||||
|
|
||||||
# --- Pure helpers (unit-testable without an app) --------------------------
|
# --- Pure helpers (unit-testable without an app) --------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -244,6 +266,33 @@ def same_origin(headers) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def strip_forwarding_header(name: str) -> bool:
|
||||||
|
"""True for client-supplied identity/forwarding headers we never forward.
|
||||||
|
|
||||||
|
``X-Forwarded-User`` and ``X-Auth-Request-*`` carry identities an
|
||||||
|
authenticating proxy or the gateway itself owns; ``Forwarded`` /
|
||||||
|
``X-Forwarded-*`` describe the hop chain. Forwarding any of them lets a
|
||||||
|
browser spoof its origin or a trusted SSO user upstream.
|
||||||
|
"""
|
||||||
|
lowered = name.lower()
|
||||||
|
return lowered in FORWARDING_HEADERS or lowered.startswith(FORWARDING_HEADER_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def client_identity(headers, client) -> str:
|
||||||
|
"""The rate-limit bucket for a request.
|
||||||
|
|
||||||
|
The rightmost ``X-Forwarded-For`` entry is the address the last trusted
|
||||||
|
proxy saw (each proxy appends), so it cannot be spoofed by prepending
|
||||||
|
arbitrary values. Without the header fall back to the socket peer.
|
||||||
|
"""
|
||||||
|
forwarded = (headers.get("x-forwarded-for") or "").strip()
|
||||||
|
if forwarded:
|
||||||
|
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||||
|
if parts:
|
||||||
|
return parts[-1]
|
||||||
|
return getattr(client, "host", None) or "unknown"
|
||||||
|
|
||||||
|
|
||||||
def rewrite_location(location: str, user: str) -> str:
|
def rewrite_location(location: str, user: str) -> str:
|
||||||
"""Prefix absolute-path redirects so the browser stays under ``/u/<user>/``."""
|
"""Prefix absolute-path redirects so the browser stays under ``/u/<user>/``."""
|
||||||
if not location.startswith("/") or location.startswith("//"):
|
if not location.startswith("/") or location.startswith("//"):
|
||||||
@@ -301,6 +350,14 @@ def _env_float(name: str, default: float) -> float:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _env_allowlist(name: str) -> frozenset[str] | None:
|
||||||
|
"""Parse a comma-separated header allowlist; unset means "denylist mode"."""
|
||||||
|
raw = os.environ.get(name)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
return frozenset(part.strip().lower() for part in raw.split(",") if part.strip())
|
||||||
|
|
||||||
|
|
||||||
# --- Settings --------------------------------------------------------------
|
# --- Settings --------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -320,7 +377,7 @@ class Settings:
|
|||||||
dsh_port: int = 3080
|
dsh_port: int = 3080
|
||||||
dsh_container: str = "dsh-web"
|
dsh_container: str = "dsh-web"
|
||||||
dsh_log_tail_lines: int = 2000
|
dsh_log_tail_lines: int = 2000
|
||||||
cookie_name: str = "dsh_gw_session"
|
cookie_name: str = DEFAULT_COOKIE_NAME
|
||||||
cookie_secure: bool = False
|
cookie_secure: bool = False
|
||||||
cookie_max_age: int = 7 * 24 * 60 * 60
|
cookie_max_age: int = 7 * 24 * 60 * 60
|
||||||
max_body_bytes: int = 32 * 1024 * 1024
|
max_body_bytes: int = 32 * 1024 * 1024
|
||||||
@@ -334,9 +391,36 @@ class Settings:
|
|||||||
# valid user in this header are treated as authenticated (SSO); unset (None)
|
# valid user in this header are treated as authenticated (SSO); unset (None)
|
||||||
# disables SSO entirely and the header is never trusted.
|
# disables SSO entirely and the header is never trusted.
|
||||||
auth_proxy_header: str | None = None
|
auth_proxy_header: str | None = None
|
||||||
|
# Abuse controls. AUTH_RATE_LIMIT applies to the unauthenticated auth routes
|
||||||
|
# (POST /users and POST /login) per client and window; 0 disables it.
|
||||||
|
auth_rate_limit: int = 30
|
||||||
|
auth_rate_window: int = 60
|
||||||
|
# When set, signup requires this invite code (form field ``invite`` or the
|
||||||
|
# ``X-Signup-Invite`` header). None keeps signup open, as in the demo.
|
||||||
|
signup_invite_code: str | None = None
|
||||||
|
# Optional allowlist of client request headers to forward upstream. None
|
||||||
|
# keeps the default denylist behavior; a set (possibly empty) forwards only
|
||||||
|
# the named headers (plus the gateway's own X-Forwarded-* values).
|
||||||
|
upstream_header_allowlist: frozenset[str] | None = None
|
||||||
|
# Concurrent proxied requests per user before 503/1011 is returned.
|
||||||
|
max_in_flight_per_user: int = 32
|
||||||
|
# Largest WebSocket message accepted from DSH, in bytes. Browser-to-DSH
|
||||||
|
# frames are bounded by the ASGI server's own WebSocket limit.
|
||||||
|
ws_max_message_bytes: int = 16 * 1024 * 1024
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> Settings:
|
def from_env(cls) -> Settings:
|
||||||
|
cookie_secure = _env_bool("COOKIE_SECURE", False)
|
||||||
|
allow_insecure = _env_bool("ALLOW_INSECURE_COOKIES", False)
|
||||||
|
if not cookie_secure and not allow_insecure:
|
||||||
|
raise RuntimeError(
|
||||||
|
"refusing to start with an insecure session cookie: set "
|
||||||
|
"COOKIE_SECURE=true behind TLS, or ALLOW_INSECURE_COOKIES=1 "
|
||||||
|
"for a local plain-HTTP demo"
|
||||||
|
)
|
||||||
|
cookie_name = os.environ.get("COOKIE_NAME") or (
|
||||||
|
SECURE_COOKIE_NAME if cookie_secure else DEFAULT_COOKIE_NAME
|
||||||
|
)
|
||||||
return cls(
|
return cls(
|
||||||
namespace=os.environ.get("NAMESPACE", "dsh-demo"),
|
namespace=os.environ.get("NAMESPACE", "dsh-demo"),
|
||||||
pool=os.environ.get("POOL", "dsh-pool"),
|
pool=os.environ.get("POOL", "dsh-pool"),
|
||||||
@@ -345,13 +429,19 @@ class Settings:
|
|||||||
dsh_port=_env_int("DSH_PORT", 3080),
|
dsh_port=_env_int("DSH_PORT", 3080),
|
||||||
dsh_container=os.environ.get("DSH_CONTAINER", "dsh-web"),
|
dsh_container=os.environ.get("DSH_CONTAINER", "dsh-web"),
|
||||||
dsh_log_tail_lines=_env_int("DSH_LOG_TAIL_LINES", 2000),
|
dsh_log_tail_lines=_env_int("DSH_LOG_TAIL_LINES", 2000),
|
||||||
cookie_name=os.environ.get("COOKIE_NAME", "dsh_gw_session"),
|
cookie_name=cookie_name,
|
||||||
cookie_secure=_env_bool("COOKIE_SECURE", False),
|
cookie_secure=cookie_secure,
|
||||||
cookie_max_age=_env_int("COOKIE_MAX_AGE", 7 * 24 * 60 * 60),
|
cookie_max_age=_env_int("COOKIE_MAX_AGE", 7 * 24 * 60 * 60),
|
||||||
max_body_bytes=_env_int("MAX_BODY_MB", 32) * 1024 * 1024,
|
max_body_bytes=_env_int("MAX_BODY_MB", 32) * 1024 * 1024,
|
||||||
http_timeout=_env_float("HTTP_TIMEOUT", 300.0),
|
http_timeout=_env_float("HTTP_TIMEOUT", 300.0),
|
||||||
sweep_interval=_env_float("SWEEP_INTERVAL", 15.0),
|
sweep_interval=_env_float("SWEEP_INTERVAL", 15.0),
|
||||||
auth_proxy_header=os.environ.get("AUTH_PROXY_HEADER") or None,
|
auth_proxy_header=os.environ.get("AUTH_PROXY_HEADER") or None,
|
||||||
|
auth_rate_limit=_env_int("AUTH_RATE_LIMIT", 30),
|
||||||
|
auth_rate_window=_env_int("AUTH_RATE_WINDOW", 60),
|
||||||
|
signup_invite_code=os.environ.get("SIGNUP_INVITE_CODE") or None,
|
||||||
|
upstream_header_allowlist=_env_allowlist("UPSTREAM_HEADER_ALLOWLIST"),
|
||||||
|
max_in_flight_per_user=_env_int("MAX_IN_FLIGHT_PER_USER", 32),
|
||||||
|
ws_max_message_bytes=_env_int("WS_MAX_MESSAGE_BYTES", 16 * 1024 * 1024),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -541,6 +631,19 @@ class Kubernetes:
|
|||||||
# --- Gateway ---------------------------------------------------------------
|
# --- Gateway ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Session:
|
||||||
|
"""A browser session stored server-side, keyed by an opaque random id.
|
||||||
|
|
||||||
|
The cookie only carries the id; the user and the expiry stay in memory, so
|
||||||
|
a cookie can never be edited into another user's credentials and a stolen
|
||||||
|
token is not recoverable from the browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user: str
|
||||||
|
expires_at: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GatewayState:
|
class GatewayState:
|
||||||
"""In-memory, per-process state (single replica by design)."""
|
"""In-memory, per-process state (single replica by design)."""
|
||||||
@@ -550,6 +653,10 @@ class GatewayState:
|
|||||||
maintenance: dict[str, bool] = field(default_factory=dict)
|
maintenance: dict[str, bool] = field(default_factory=dict)
|
||||||
# (user, pod UID, browser Host) -> "dsh-auth-a=...; dsh-auth-b=..."
|
# (user, pod UID, browser Host) -> "dsh-auth-a=...; dsh-auth-b=..."
|
||||||
handshake: dict[tuple[str, str, str], str] = field(default_factory=dict)
|
handshake: dict[tuple[str, str, str], str] = field(default_factory=dict)
|
||||||
|
# Opaque session id -> server-side session (user + expiry).
|
||||||
|
sessions: dict[str, Session] = field(default_factory=dict)
|
||||||
|
# Auth rate limiting: client -> (window start, attempts in window).
|
||||||
|
auth_rate: dict[str, tuple[float, int]] = field(default_factory=dict)
|
||||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
|
|
||||||
|
|
||||||
@@ -571,17 +678,40 @@ class Gateway:
|
|||||||
|
|
||||||
# -- credentials -------------------------------------------------------
|
# -- credentials -------------------------------------------------------
|
||||||
|
|
||||||
|
def session_for(self, sid: str | None) -> Session | None:
|
||||||
|
"""Look up a live session, dropping it when it has expired."""
|
||||||
|
if not sid:
|
||||||
|
return None
|
||||||
|
session = self.state.sessions.get(sid)
|
||||||
|
if session is None:
|
||||||
|
return None
|
||||||
|
if session.expires_at <= time.time():
|
||||||
|
self.state.sessions.pop(sid, None)
|
||||||
|
return None
|
||||||
|
return session
|
||||||
|
|
||||||
|
def revoke_session(self, sid: str | None) -> None:
|
||||||
|
if sid:
|
||||||
|
self.state.sessions.pop(sid, None)
|
||||||
|
|
||||||
|
def revoke_sessions(self, user: str) -> None:
|
||||||
|
for sid in [key for key, session in self.state.sessions.items() if session.user == user]:
|
||||||
|
self.state.sessions.pop(sid, None)
|
||||||
|
|
||||||
def credentials(self, headers, cookies) -> tuple[str | None, str | None]:
|
def credentials(self, headers, cookies) -> tuple[str | None, str | None]:
|
||||||
"""Return ``(token, cookie_user)``; bearer wins over the session cookie."""
|
"""Return ``(token, session_user)``; bearer wins over the session cookie.
|
||||||
|
|
||||||
|
A bearer token is never resolved from a cookie and a session id is never
|
||||||
|
accepted as a bearer token. An unknown or expired session is treated as
|
||||||
|
anonymous (and the expired entry is dropped).
|
||||||
|
"""
|
||||||
auth = headers.get("authorization", "")
|
auth = headers.get("authorization", "")
|
||||||
if auth.lower().startswith("bearer "):
|
if auth.lower().startswith("bearer "):
|
||||||
return auth[7:].strip(), None
|
return auth[7:].strip(), None
|
||||||
raw = cookies.get(self.settings.cookie_name)
|
session = self.session_for(cookies.get(self.settings.cookie_name))
|
||||||
if raw:
|
if session is None:
|
||||||
user, sep, token = raw.partition(":")
|
return None, None
|
||||||
if sep and user and token:
|
return None, session.user
|
||||||
return token, user
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def remote_user(self, headers) -> str | None:
|
def remote_user(self, headers) -> str | None:
|
||||||
"""The user asserted by a trusted reverse proxy, when SSO is enabled.
|
"""The user asserted by a trusted reverse proxy, when SSO is enabled.
|
||||||
@@ -600,31 +730,37 @@ class Gateway:
|
|||||||
self,
|
self,
|
||||||
claim: dict,
|
claim: dict,
|
||||||
token: str | None,
|
token: str | None,
|
||||||
cookie_user: str | None,
|
session_user: str | None,
|
||||||
user: str,
|
user: str,
|
||||||
remote: str | None = None,
|
remote: str | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if remote == user:
|
if remote == user:
|
||||||
return True # identity asserted by the authenticating proxy
|
return True # identity asserted by the authenticating proxy
|
||||||
if cookie_user is not None and cookie_user != user:
|
if session_user is not None:
|
||||||
return False # a session for another user never authorizes this one
|
return session_user == user # server-side session bound to this user
|
||||||
return token_matches(claim, token)
|
return token_matches(claim, token)
|
||||||
|
|
||||||
async def _sso_session(self, remote: str, headers, cookies, response: Response) -> dict | None:
|
async def _sso_session(self, remote: str, headers, cookies, response: Response) -> dict | None:
|
||||||
"""Provision ``remote`` idempotently and mint/refresh a session token.
|
"""Provision ``remote`` idempotently and mint/refresh a session.
|
||||||
|
|
||||||
The authenticating proxy has already verified ``remote``, so it is
|
The authenticating proxy has already verified ``remote``, so it is
|
||||||
trusted. A brand-new user is provisioned (claim created, token minted);
|
trusted. A brand-new user is provisioned (claim created, token minted);
|
||||||
an existing user whose session cookie is missing or no longer matches
|
an existing user whose session cookie is missing, foreign or no longer
|
||||||
gets a rotated token so a fresh cookie can be issued. A still-valid
|
matches gets a rotated token so a fresh cookie can be issued. A still-valid
|
||||||
session is reused untouched. Provisioning is non-blocking: the sandbox
|
session for the same user is reused untouched. Provisioning is
|
||||||
is awaited by the proxy path (``_resolve_sandbox``), so the landing page
|
non-blocking: the sandbox is awaited by the proxy path
|
||||||
can report "Provisioning" instead of stalling for ``WAKE_TIMEOUT``.
|
(``_resolve_sandbox``), so the landing page can report "Provisioning"
|
||||||
|
instead of stalling for ``WAKE_TIMEOUT``.
|
||||||
"""
|
"""
|
||||||
claim = await self.k8s.get_claim(remote)
|
claim = await self.k8s.get_claim(remote)
|
||||||
token, cookie_user = self.credentials(headers, cookies)
|
sid = cookies.get(self.settings.cookie_name)
|
||||||
if claim is not None and cookie_user == remote and token_matches(claim, token):
|
session = self.session_for(sid)
|
||||||
return claim
|
if session is not None:
|
||||||
|
if session.user != remote:
|
||||||
|
# A session for another identity never authorizes this user.
|
||||||
|
self.revoke_session(sid)
|
||||||
|
elif claim is not None:
|
||||||
|
return claim
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
is_new = claim is None
|
is_new = claim is None
|
||||||
@@ -640,7 +776,7 @@ class Gateway:
|
|||||||
else:
|
else:
|
||||||
await self.k8s.patch_token(remote, token_hash)
|
await self.k8s.patch_token(remote, token_hash)
|
||||||
await self._touch(remote)
|
await self._touch(remote)
|
||||||
self.set_session(response, remote, token)
|
self.set_session(response, remote)
|
||||||
return await self.k8s.get_claim(remote) if is_new else claim
|
return await self.k8s.get_claim(remote) if is_new else claim
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -651,16 +787,73 @@ class Gateway:
|
|||||||
and not same_origin(headers)
|
and not same_origin(headers)
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_session(self, response: Response, user: str, token: str) -> None:
|
def set_session(self, response: Response, user: str) -> str:
|
||||||
|
"""Mint an opaque server-side session and set the browser cookie."""
|
||||||
|
sid = secrets.token_urlsafe(32)
|
||||||
|
self.state.sessions[sid] = Session(
|
||||||
|
user=user,
|
||||||
|
expires_at=time.time() + self.settings.cookie_max_age,
|
||||||
|
)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
self.settings.cookie_name,
|
self.settings.cookie_name,
|
||||||
f"{user}:{token}",
|
sid,
|
||||||
max_age=self.settings.cookie_max_age,
|
max_age=self.settings.cookie_max_age,
|
||||||
path="/",
|
path="/",
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
secure=self.settings.cookie_secure,
|
secure=self.settings.cookie_secure,
|
||||||
)
|
)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
# -- abuse controls ----------------------------------------------------
|
||||||
|
|
||||||
|
def _rate_limit(self, request: Request) -> Response | None:
|
||||||
|
"""Return a 429 response when this client is over the auth budget."""
|
||||||
|
limit = self.settings.auth_rate_limit
|
||||||
|
if limit <= 0:
|
||||||
|
return None
|
||||||
|
window = max(1, self.settings.auth_rate_window)
|
||||||
|
key = client_identity(request.headers, request.client)
|
||||||
|
now = time.time()
|
||||||
|
bucket = self.state.auth_rate.get(key)
|
||||||
|
if bucket is None or now - bucket[0] >= window:
|
||||||
|
self.state.auth_rate[key] = (now, 1)
|
||||||
|
return None
|
||||||
|
start, count = bucket
|
||||||
|
if count >= limit:
|
||||||
|
retry_after = max(1, math.ceil(start + window - now))
|
||||||
|
return self._error(
|
||||||
|
429,
|
||||||
|
"too many authentication attempts, retry shortly",
|
||||||
|
{"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
self.state.auth_rate[key] = (start, count + 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _invite_allowed(self, request: Request, payload: dict) -> bool:
|
||||||
|
"""Check the signup invite code, when one is configured."""
|
||||||
|
expected = self.settings.signup_invite_code
|
||||||
|
if expected is None:
|
||||||
|
return True
|
||||||
|
provided = payload.get("invite")
|
||||||
|
if not isinstance(provided, str) or not provided:
|
||||||
|
provided = request.headers.get("x-signup-invite", "")
|
||||||
|
if not isinstance(provided, str) or not provided:
|
||||||
|
return False
|
||||||
|
return secrets.compare_digest(provided.encode("utf-8"), expected.encode("utf-8"))
|
||||||
|
|
||||||
|
def _form_cross_origin(self, request: Request) -> bool:
|
||||||
|
"""Reject a form signup/login whose Origin/Referer contradicts Host.
|
||||||
|
|
||||||
|
Only checked when the browser actually sent one: non-browser clients
|
||||||
|
(automation, tests) commonly omit both headers and must keep working.
|
||||||
|
"""
|
||||||
|
if not self._wants_form(request):
|
||||||
|
return False
|
||||||
|
headers = request.headers
|
||||||
|
if not (headers.get("origin") or headers.get("referer")):
|
||||||
|
return False
|
||||||
|
return not same_origin(headers)
|
||||||
|
|
||||||
# -- activity / maintenance -------------------------------------------
|
# -- activity / maintenance -------------------------------------------
|
||||||
|
|
||||||
@@ -669,12 +862,21 @@ class Gateway:
|
|||||||
self.state.last_activity[user] = time.time()
|
self.state.last_activity[user] = time.time()
|
||||||
|
|
||||||
async def _acquire(self, user: str) -> bool:
|
async def _acquire(self, user: str) -> bool:
|
||||||
"""Reserve an in-flight slot, waiting out a concurrent suspend."""
|
"""Reserve an in-flight slot, waiting out a concurrent suspend.
|
||||||
|
|
||||||
|
Returns False when the user already has ``MAX_IN_FLIGHT_PER_USER``
|
||||||
|
requests in flight (an abuse guard, not a transient error) or when a
|
||||||
|
suspend does not finish within ``WAKE_TIMEOUT``.
|
||||||
|
"""
|
||||||
deadline = time.monotonic() + self.settings.wake_timeout
|
deadline = time.monotonic() + self.settings.wake_timeout
|
||||||
|
cap = self.settings.max_in_flight_per_user
|
||||||
while True:
|
while True:
|
||||||
async with self.state.lock:
|
async with self.state.lock:
|
||||||
if not self.state.maintenance.get(user):
|
if not self.state.maintenance.get(user):
|
||||||
self.state.in_flight[user] = self.state.in_flight.get(user, 0) + 1
|
current = self.state.in_flight.get(user, 0)
|
||||||
|
if cap > 0 and current >= cap:
|
||||||
|
return False
|
||||||
|
self.state.in_flight[user] = current + 1
|
||||||
self.state.last_activity[user] = time.time()
|
self.state.last_activity[user] = time.time()
|
||||||
return True
|
return True
|
||||||
if time.monotonic() > deadline:
|
if time.monotonic() > deadline:
|
||||||
@@ -682,9 +884,14 @@ class Gateway:
|
|||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
async def _release(self, user: str) -> None:
|
async def _release(self, user: str) -> None:
|
||||||
async with self.state.lock:
|
# Deliberately await-free: this runs from ``finally`` blocks, and the
|
||||||
self.state.in_flight[user] = max(self.state.in_flight.get(user, 1) - 1, 0)
|
# slot must be freed even when a client disconnect cancels the request
|
||||||
self.state.last_activity[user] = time.time()
|
# mid-stream. Awaiting the lock there can be interrupted before the
|
||||||
|
# decrement, stranding the slot (the user's sandbox would then never
|
||||||
|
# look idle again). A plain dict update is atomic because the event
|
||||||
|
# loop cannot switch tasks between two synchronous statements.
|
||||||
|
self.state.in_flight[user] = max(self.state.in_flight.get(user, 1) - 1, 0)
|
||||||
|
self.state.last_activity[user] = time.time()
|
||||||
|
|
||||||
# -- sandbox / handshake helpers --------------------------------------
|
# -- sandbox / handshake helpers --------------------------------------
|
||||||
|
|
||||||
@@ -749,14 +956,19 @@ class Gateway:
|
|||||||
host: str,
|
host: str,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> tuple[str, tuple[str, str, str], bool]:
|
) -> tuple[str, tuple[str, str, str], bool]:
|
||||||
"""Return ``(cookie, cache_key, was_cached)``, handshaking on a miss."""
|
"""Return ``(cookie, cache_key, was_cached)``, handshaking on a miss.
|
||||||
|
|
||||||
|
Every failure raises ``HandshakeError`` with a fixed, credential-free
|
||||||
|
message: the exception reaches the browser in the 503 body, so it must
|
||||||
|
never carry the launch token, the handshake URL or pod details.
|
||||||
|
"""
|
||||||
pod_name = self._pod_name(sandbox)
|
pod_name = self._pod_name(sandbox)
|
||||||
try:
|
try:
|
||||||
pod = await self.k8s.get_pod(pod_name)
|
pod = await self.k8s.get_pod(pod_name)
|
||||||
except ApiException as exc:
|
except ApiException as exc:
|
||||||
raise HandshakeError(f"could not read pod {pod_name}: {exc}") from exc
|
raise HandshakeError("could not read the DSH handshake pod") from exc
|
||||||
if pod is None:
|
if pod is None:
|
||||||
raise HandshakeError(f"pod {pod_name} not found")
|
raise HandshakeError("the DSH handshake pod is not available")
|
||||||
uid = (pod.get("metadata") or {}).get("uid") or pod_name
|
uid = (pod.get("metadata") or {}).get("uid") or pod_name
|
||||||
key = (user, uid, host)
|
key = (user, uid, host)
|
||||||
if not force:
|
if not force:
|
||||||
@@ -770,7 +982,7 @@ class Gateway:
|
|||||||
self.settings.dsh_log_tail_lines,
|
self.settings.dsh_log_tail_lines,
|
||||||
)
|
)
|
||||||
except ApiException as exc:
|
except ApiException as exc:
|
||||||
raise HandshakeError(f"could not read logs for {pod_name}: {exc}") from exc
|
raise HandshakeError("could not read the pod logs for the handshake") from exc
|
||||||
launch_token = parse_dsh_launch_token(logs)
|
launch_token = parse_dsh_launch_token(logs)
|
||||||
if not launch_token:
|
if not launch_token:
|
||||||
raise HandshakeError("DSH launch token not found in pod logs")
|
raise HandshakeError("DSH launch token not found in pod logs")
|
||||||
@@ -788,12 +1000,12 @@ class Gateway:
|
|||||||
timeout=self.settings.http_timeout,
|
timeout=self.settings.http_timeout,
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise HandshakeError(f"DSH handshake failed: {exc}") from exc
|
raise HandshakeError("the DSH handshake request failed") from exc
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
raise HandshakeError(f"DSH handshake returned {response.status_code}")
|
raise HandshakeError("the DSH handshake returned an error status")
|
||||||
cookie = extract_dsh_cookies(response.headers.get_list("set-cookie"))
|
cookie = extract_dsh_cookies(response.headers.get_list("set-cookie"))
|
||||||
if not cookie:
|
if not cookie:
|
||||||
raise HandshakeError("DSH handshake set no dsh-auth cookie")
|
raise HandshakeError("the DSH handshake set no auth cookie")
|
||||||
self.state.handshake[key] = cookie
|
self.state.handshake[key] = cookie
|
||||||
return cookie, key, False
|
return cookie, key, False
|
||||||
|
|
||||||
@@ -869,9 +1081,9 @@ class Gateway:
|
|||||||
if "set-cookie" in response.headers:
|
if "set-cookie" in response.headers:
|
||||||
page.headers["set-cookie"] = response.headers["set-cookie"]
|
page.headers["set-cookie"] = response.headers["set-cookie"]
|
||||||
return page
|
return page
|
||||||
token, user = self.credentials(request.headers, request.cookies)
|
_token, user = self.credentials(request.headers, request.cookies)
|
||||||
session = ""
|
session = ""
|
||||||
if user and token:
|
if user:
|
||||||
safe_user = html.escape(user)
|
safe_user = html.escape(user)
|
||||||
session = (
|
session = (
|
||||||
f'<p class="session">Signed in as <strong>{safe_user}</strong>. '
|
f'<p class="session">Signed in as <strong>{safe_user}</strong>. '
|
||||||
@@ -879,6 +1091,11 @@ class Gateway:
|
|||||||
'<form method="post" action="/logout">'
|
'<form method="post" action="/logout">'
|
||||||
'<button type="submit">Sign out</button></form>'
|
'<button type="submit">Sign out</button></form>'
|
||||||
)
|
)
|
||||||
|
invite_input = (
|
||||||
|
'<input name="invite" type="password" placeholder="invite code" required>'
|
||||||
|
if self.settings.signup_invite_code is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
body = f"""<!doctype html>
|
body = f"""<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -901,6 +1118,7 @@ class Gateway:
|
|||||||
<h2>Create a sandbox</h2>
|
<h2>Create a sandbox</h2>
|
||||||
<form method="post" action="/users">
|
<form method="post" action="/users">
|
||||||
<input name="user" placeholder="username (dns-1123 label)" required>
|
<input name="user" placeholder="username (dns-1123 label)" required>
|
||||||
|
{invite_input}
|
||||||
<button type="submit">Create</button>
|
<button type="submit">Create</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -960,11 +1178,18 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
# -- users -------------------------------------------------------------
|
# -- users -------------------------------------------------------------
|
||||||
|
|
||||||
async def signup(self, request: Request) -> Response:
|
async def signup(self, request: Request) -> Response:
|
||||||
|
limited = self._rate_limit(request)
|
||||||
|
if limited is not None:
|
||||||
|
return limited
|
||||||
|
if self._form_cross_origin(request):
|
||||||
|
return self._error(403, "cross-origin request rejected")
|
||||||
wants_form = self._wants_form(request)
|
wants_form = self._wants_form(request)
|
||||||
try:
|
try:
|
||||||
payload = await self._read_payload(request)
|
payload = await self._read_payload(request)
|
||||||
except BodyTooLarge:
|
except BodyTooLarge:
|
||||||
return self._error(413, "request body too large")
|
return self._error(413, "request body too large")
|
||||||
|
if not self._invite_allowed(request, payload):
|
||||||
|
return self._error(403, "invalid or missing invite code")
|
||||||
user = payload.get("user", "")
|
user = payload.get("user", "")
|
||||||
if not valid_user(user):
|
if not valid_user(user):
|
||||||
return self._error(400, "body must be {'user': '<dns-1123 label>'}")
|
return self._error(400, "body must be {'user': '<dns-1123 label>'}")
|
||||||
@@ -982,16 +1207,21 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
sandbox_name = (sandbox or {}).get("metadata", {}).get("name")
|
sandbox_name = (sandbox or {}).get("metadata", {}).get("name")
|
||||||
if wants_form:
|
if wants_form:
|
||||||
response = RedirectResponse(f"/u/{user}/", status_code=303)
|
response = RedirectResponse(f"/u/{user}/", status_code=303)
|
||||||
self.set_session(response, user, token)
|
self.set_session(response, user)
|
||||||
return response
|
return response
|
||||||
response = JSONResponse(
|
response = JSONResponse(
|
||||||
{"user": user, "token": token, "state": state, "sandbox": sandbox_name},
|
{"user": user, "token": token, "state": state, "sandbox": sandbox_name},
|
||||||
status_code=201 if state == "Ready" else 202,
|
status_code=201 if state == "Ready" else 202,
|
||||||
)
|
)
|
||||||
self.set_session(response, user, token)
|
self.set_session(response, user)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def login(self, request: Request) -> Response:
|
async def login(self, request: Request) -> Response:
|
||||||
|
limited = self._rate_limit(request)
|
||||||
|
if limited is not None:
|
||||||
|
return limited
|
||||||
|
if self._form_cross_origin(request):
|
||||||
|
return self._error(403, "cross-origin request rejected")
|
||||||
wants_form = self._wants_form(request)
|
wants_form = self._wants_form(request)
|
||||||
try:
|
try:
|
||||||
payload = await self._read_payload(request)
|
payload = await self._read_payload(request)
|
||||||
@@ -1002,21 +1232,29 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
if not isinstance(user, str) or not isinstance(token, str):
|
if not isinstance(user, str) or not isinstance(token, str):
|
||||||
return self._error(400, "user and token are required")
|
return self._error(400, "user and token are required")
|
||||||
claim = await self.k8s.get_claim(user)
|
claim = await self.k8s.get_claim(user)
|
||||||
if claim is None:
|
# An unknown user is indistinguishable from a bad token to the client:
|
||||||
return self._error(404, "not found")
|
# never reveal which usernames exist.
|
||||||
if not token_matches(claim, token):
|
if claim is None or not token_matches(claim, token):
|
||||||
return self._error(401, "unauthorized")
|
return self._error(401, "unauthorized")
|
||||||
await self._touch(user)
|
await self._touch(user)
|
||||||
if wants_form:
|
if wants_form:
|
||||||
response = RedirectResponse(f"/u/{user}/", status_code=303)
|
response = RedirectResponse(f"/u/{user}/", status_code=303)
|
||||||
else:
|
else:
|
||||||
response = JSONResponse({"user": user, "state": "ok"})
|
response = JSONResponse({"user": user, "state": "ok"})
|
||||||
self.set_session(response, user, token)
|
self.set_session(response, user)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def logout(self, request: Request) -> Response:
|
async def logout(self, request: Request) -> Response:
|
||||||
|
self.revoke_session(request.cookies.get(self.settings.cookie_name))
|
||||||
response = RedirectResponse("/", status_code=303)
|
response = RedirectResponse("/", status_code=303)
|
||||||
response.delete_cookie(self.settings.cookie_name, path="/")
|
# Match the attributes the cookie was set with: a __Host- cookie
|
||||||
|
# deletion that lacks Secure is rejected by browsers, leaving a stale
|
||||||
|
# (though already revoked) cookie behind.
|
||||||
|
response.delete_cookie(
|
||||||
|
self.settings.cookie_name,
|
||||||
|
path="/",
|
||||||
|
secure=self.settings.cookie_secure,
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def get_user(self, request: Request) -> Response:
|
async def get_user(self, request: Request) -> Response:
|
||||||
@@ -1051,6 +1289,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
if self._mutation_from_cookie(request.headers, "DELETE", cookie_user):
|
if self._mutation_from_cookie(request.headers, "DELETE", cookie_user):
|
||||||
return self._error(403, "cross-origin request rejected")
|
return self._error(403, "cross-origin request rejected")
|
||||||
await self.k8s.delete_claim(user)
|
await self.k8s.delete_claim(user)
|
||||||
|
self.revoke_sessions(user)
|
||||||
self._clear_handshake(user)
|
self._clear_handshake(user)
|
||||||
async with self.state.lock:
|
async with self.state.lock:
|
||||||
self.state.last_activity.pop(user, None)
|
self.state.last_activity.pop(user, None)
|
||||||
@@ -1074,6 +1313,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
if self._mutation_from_cookie(request.headers, "POST", cookie_user):
|
if self._mutation_from_cookie(request.headers, "POST", cookie_user):
|
||||||
return self._error(403, "cross-origin request rejected")
|
return self._error(403, "cross-origin request rejected")
|
||||||
await self.k8s.delete_claim(remote)
|
await self.k8s.delete_claim(remote)
|
||||||
|
self.revoke_sessions(remote)
|
||||||
self._clear_handshake(remote)
|
self._clear_handshake(remote)
|
||||||
async with self.state.lock:
|
async with self.state.lock:
|
||||||
self.state.last_activity.pop(remote, None)
|
self.state.last_activity.pop(remote, None)
|
||||||
@@ -1101,7 +1341,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
return RedirectResponse("/", status_code=303)
|
return RedirectResponse("/", status_code=303)
|
||||||
return self._error(404, "unknown user")
|
return self._error(404, "unknown user")
|
||||||
token, cookie_user = self.credentials(request.headers, request.cookies)
|
token, cookie_user = self.credentials(request.headers, request.cookies)
|
||||||
if remote == user and not (cookie_user == user and token_matches(claim, token)):
|
if remote == user and cookie_user != user:
|
||||||
# SSO user without a valid session cookie: bootstrap, then continue.
|
# SSO user without a valid session cookie: bootstrap, then continue.
|
||||||
return RedirectResponse("/", status_code=303)
|
return RedirectResponse("/", status_code=303)
|
||||||
if not self._authorized(claim, token, cookie_user, user, remote):
|
if not self._authorized(claim, token, cookie_user, user, remote):
|
||||||
@@ -1161,11 +1401,17 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
if query:
|
if query:
|
||||||
url = f"{url}?{query}"
|
url = f"{url}?{query}"
|
||||||
|
|
||||||
headers = {
|
headers = {}
|
||||||
key: value
|
allowlist = self.settings.upstream_header_allowlist
|
||||||
for key, value in request.headers.items()
|
for key, value in request.headers.items():
|
||||||
if key.lower() not in OWNED_REQUEST_HEADERS and key.lower() != "content-length"
|
lowered = key.lower()
|
||||||
}
|
if lowered in OWNED_REQUEST_HEADERS or lowered == "content-length":
|
||||||
|
continue
|
||||||
|
if strip_forwarding_header(lowered):
|
||||||
|
continue # client-supplied identity/hop-chain headers are spoofable
|
||||||
|
if allowlist is not None and lowered not in allowlist:
|
||||||
|
continue
|
||||||
|
headers[key] = value
|
||||||
host = request.headers.get("host", "")
|
host = request.headers.get("host", "")
|
||||||
headers["host"] = host
|
headers["host"] = host
|
||||||
headers["cookie"] = cookie # only the cached DSH auth cookie
|
headers["cookie"] = cookie # only the cached DSH auth cookie
|
||||||
@@ -1238,9 +1484,17 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
print(f"stream for {user} ended early: {exc!r}", flush=True)
|
print(f"stream for {user} ended early: {exc!r}", flush=True)
|
||||||
return
|
return
|
||||||
finally:
|
finally:
|
||||||
with contextlib.suppress(Exception):
|
# The slot must be freed even when this ``finally`` runs inside a
|
||||||
await upstream.aclose()
|
# cancelled task: on a client disconnect Starlette cancels the
|
||||||
await self._release(user)
|
# streaming task through an anyio cancel scope, and CancelledError
|
||||||
|
# is a BaseException, so ``suppress(Exception)`` would let a close
|
||||||
|
# that awaits abort the whole block before ``_release`` ran. A
|
||||||
|
# stranded slot keeps the user's sandbox looking busy forever.
|
||||||
|
try:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await upstream.aclose()
|
||||||
|
finally:
|
||||||
|
await self._release(user)
|
||||||
|
|
||||||
# -- WebSocket proxy ---------------------------------------------------
|
# -- WebSocket proxy ---------------------------------------------------
|
||||||
|
|
||||||
@@ -1291,13 +1545,19 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
query = filtered_query(websocket.url.query)
|
query = filtered_query(websocket.url.query)
|
||||||
if query:
|
if query:
|
||||||
url = f"{url}?{query}"
|
url = f"{url}?{query}"
|
||||||
headers = {
|
headers = {}
|
||||||
key: value
|
allowlist = self.settings.upstream_header_allowlist
|
||||||
for key, value in websocket.headers.items()
|
for key, value in websocket.headers.items():
|
||||||
if key.lower() not in OWNED_REQUEST_HEADERS
|
lowered = key.lower()
|
||||||
and key.lower() != "origin"
|
if lowered in OWNED_REQUEST_HEADERS or lowered == "origin":
|
||||||
and not key.lower().startswith("sec-websocket-")
|
continue
|
||||||
}
|
if lowered.startswith("sec-websocket-"):
|
||||||
|
continue
|
||||||
|
if strip_forwarding_header(lowered):
|
||||||
|
continue # client-supplied identity/hop-chain headers
|
||||||
|
if allowlist is not None and lowered not in allowlist:
|
||||||
|
continue
|
||||||
|
headers[key] = value
|
||||||
headers["cookie"] = cookie
|
headers["cookie"] = cookie
|
||||||
headers["x-forwarded-host"] = host
|
headers["x-forwarded-host"] = host
|
||||||
headers["x-forwarded-proto"] = websocket.url.scheme
|
headers["x-forwarded-proto"] = websocket.url.scheme
|
||||||
@@ -1313,7 +1573,7 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
additional_headers=headers,
|
additional_headers=headers,
|
||||||
subprotocols=subprotocols or None,
|
subprotocols=subprotocols or None,
|
||||||
open_timeout=self.settings.wake_timeout,
|
open_timeout=self.settings.wake_timeout,
|
||||||
max_size=None,
|
max_size=self.settings.ws_max_message_bytes,
|
||||||
proxy=None,
|
proxy=None,
|
||||||
)
|
)
|
||||||
upstream = await upstream_cm.__aenter__()
|
upstream = await upstream_cm.__aenter__()
|
||||||
@@ -1373,8 +1633,41 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
|
|||||||
|
|
||||||
# -- idle sweeper ------------------------------------------------------
|
# -- idle sweeper ------------------------------------------------------
|
||||||
|
|
||||||
|
def _prune_state(self, claims: list[dict]) -> None:
|
||||||
|
"""Drop state that can no longer be reached.
|
||||||
|
|
||||||
|
Sessions and activity clocks for a user whose claim is gone, expired
|
||||||
|
sessions and expired rate-limit buckets would otherwise accumulate for
|
||||||
|
the lifetime of the process. In-flight counters are only pruned at
|
||||||
|
zero, and a user with work in flight is always preserved.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
present = set()
|
||||||
|
for claim in claims:
|
||||||
|
name = (claim.get("metadata") or {}).get("name") or ""
|
||||||
|
if name.startswith("dsh-"):
|
||||||
|
present.add(name[len("dsh-") :])
|
||||||
|
for user in [user for user in self.state.last_activity if user not in present]:
|
||||||
|
self.state.last_activity.pop(user, None)
|
||||||
|
for user, count in list(self.state.in_flight.items()):
|
||||||
|
if user not in present and count == 0:
|
||||||
|
self.state.in_flight.pop(user, None)
|
||||||
|
for user, flag in list(self.state.maintenance.items()):
|
||||||
|
if user not in present and not flag:
|
||||||
|
self.state.maintenance.pop(user, None)
|
||||||
|
for key in [key for key in self.state.handshake if key[0] not in present]:
|
||||||
|
self.state.handshake.pop(key, None)
|
||||||
|
for sid, session in list(self.state.sessions.items()):
|
||||||
|
if session.user not in present or session.expires_at <= now:
|
||||||
|
self.state.sessions.pop(sid, None)
|
||||||
|
window = max(1, self.settings.auth_rate_window)
|
||||||
|
for key, (start, _count) in list(self.state.auth_rate.items()):
|
||||||
|
if now - start >= window:
|
||||||
|
self.state.auth_rate.pop(key, None)
|
||||||
|
|
||||||
async def sweep_once(self) -> None:
|
async def sweep_once(self) -> None:
|
||||||
claims = await self.k8s.list_claims()
|
claims = await self.k8s.list_claims()
|
||||||
|
self._prune_state(claims)
|
||||||
for claim in claims:
|
for claim in claims:
|
||||||
# Only suspend claims this gateway created. Everything else in
|
# Only suspend claims this gateway created. Everything else in
|
||||||
# the namespace (resource-layer claims from the README walkthrough,
|
# the namespace (resource-layer claims from the README walkthrough,
|
||||||
@@ -1439,6 +1732,20 @@ def _lifespan(gateway: Gateway, close_http_client: httpx.AsyncClient | None = No
|
|||||||
return lifespan
|
return lifespan
|
||||||
|
|
||||||
|
|
||||||
|
async def client_gone(request: Request, exc: ClientDisconnect) -> Response:
|
||||||
|
"""Answer a client that vanished mid-request instead of logging a traceback.
|
||||||
|
|
||||||
|
Reading a request body raises ``ClientDisconnect`` when a browser reload, a
|
||||||
|
back navigation or an ingress timeout aborts the upload. The request never
|
||||||
|
reached DSH, and nothing can be delivered to the peer that left, so the
|
||||||
|
honest outcome is 499 (nginx's "client closed request"). Letting it escape
|
||||||
|
would make Starlette's ``ServerErrorMiddleware`` log one ERROR traceback per
|
||||||
|
aborted request, which buries real faults. It also covers the control
|
||||||
|
routes that read a body (signup and login), not just the proxy.
|
||||||
|
"""
|
||||||
|
return Response(status_code=CLIENT_CLOSED_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
k8s=None,
|
k8s=None,
|
||||||
@@ -1471,6 +1778,7 @@ def create_app(
|
|||||||
]
|
]
|
||||||
app = Starlette(
|
app = Starlette(
|
||||||
routes=routes,
|
routes=routes,
|
||||||
|
exception_handlers={ClientDisconnect: client_gone},
|
||||||
lifespan=_lifespan(gateway, http_client if owns_http_client else None),
|
lifespan=_lifespan(gateway, http_client if owns_http_client else None),
|
||||||
)
|
)
|
||||||
app.state.gateway = gateway
|
app.state.gateway = gateway
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,15 @@ spec:
|
|||||||
# be blocked. See docs/optional-hardening.md.
|
# be blocked. See docs/optional-hardening.md.
|
||||||
networkPolicyManagement: Unmanaged
|
networkPolicyManagement: Unmanaged
|
||||||
|
|
||||||
|
# OPTIONAL: replace a Failed sandbox Pod instead of parking the sandbox.
|
||||||
|
# The default, Retain, leaves the Failed Pod in place and surfaces
|
||||||
|
# Finished=True on the Sandbox until a human intervenes. Recreate deletes the
|
||||||
|
# Failed Pod and starts a replacement with the same Sandbox identity and
|
||||||
|
# Sandbox-owned PVCs, backing off (5s doubling to 5m) on repeated failures.
|
||||||
|
# Requires agent-sandbox v1.0.8+; existing Sandboxes keep their own stamped
|
||||||
|
# policy, so patch them separately. See docs/optional-hardening.md.
|
||||||
|
# failedPodPolicy: Recreate
|
||||||
|
|
||||||
podTemplate:
|
podTemplate:
|
||||||
spec:
|
spec:
|
||||||
# DSH Web is multi-arch (amd64 + arm64); no arch nodeSelector is needed.
|
# DSH Web is multi-arch (amd64 + arm64); no arch nodeSelector is needed.
|
||||||
@@ -60,11 +69,11 @@ spec:
|
|||||||
# OnRootMismatch skips that walk once the volume root already carries
|
# OnRootMismatch skips that walk once the volume root already carries
|
||||||
# the fsGroup ownership/mode applied on the first mount.
|
# the fsGroup ownership/mode applied on the first mount.
|
||||||
# Keep in sync with the minimal variant:
|
# Keep in sync with the minimal variant:
|
||||||
# examples/minimal-single-tenant/manifests/sandbox-template.yaml.
|
# examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml.
|
||||||
fsGroupChangePolicy: OnRootMismatch
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
containers:
|
containers:
|
||||||
- name: dsh-web
|
- name: dsh-web
|
||||||
image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4
|
image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: web
|
- name: web
|
||||||
@@ -80,6 +89,23 @@ spec:
|
|||||||
name: dsh-provider-keys-extra
|
name: dsh-provider-keys-extra
|
||||||
optional: true
|
optional: true
|
||||||
env:
|
env:
|
||||||
|
# Point the OS home at the persisted agent workspace. DSH's
|
||||||
|
# directory picker (the folder explorer used to choose a workspace)
|
||||||
|
# has no start-path setting: when it opens it lists $HOME, and
|
||||||
|
# os.homedir() follows this variable. The image default is
|
||||||
|
# /home/node, which is not on a volume, so the picker would open on
|
||||||
|
# a throwaway directory; /workspace is the PVC-backed WORKDIR. `~`
|
||||||
|
# in agent commands resolves here too. Trade-off: tools that keep
|
||||||
|
# caches/config under $HOME (npm/pnpm, uv, git, gh, and DSH's global
|
||||||
|
# ~/.agents instructions and skills) now write dotfiles onto the PVC
|
||||||
|
# inside the agent workspace, so they are visible to the agent and
|
||||||
|
# in the file explorer. DSH_HOME is explicit (/home/node/.dsh), so
|
||||||
|
# sessions, config and credentials are unaffected. Existing
|
||||||
|
# sandboxes stamp pod env at creation and keep the old HOME until
|
||||||
|
# recreated. Keep in sync with the minimal variant:
|
||||||
|
# examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml.
|
||||||
|
- name: HOME
|
||||||
|
value: /workspace
|
||||||
# Empty => DSH derives links from the request Host, which the
|
# Empty => DSH derives links from the request Host, which the
|
||||||
# gateway forwards (plus X-Forwarded-*). Point this at the
|
# gateway forwards (plus X-Forwarded-*). Point this at the
|
||||||
# gateway base URL if you want hard-coded absolute links instead.
|
# gateway base URL if you want hard-coded absolute links instead.
|
||||||
@@ -93,6 +119,38 @@ spec:
|
|||||||
# dsh-demo.example.com for the gateway-api-sso/ HTTPRoute).
|
# dsh-demo.example.com for the gateway-api-sso/ HTTPRoute).
|
||||||
- name: DSH_TRUSTED_HOSTS
|
- name: DSH_TRUSTED_HOSTS
|
||||||
value: ""
|
value: ""
|
||||||
|
# DSH confines every bash command with its own file sandbox
|
||||||
|
# (bubblewrap, else Landlock). Neither backend is usable inside this
|
||||||
|
# pod: bubblewrap ships in the image but is unsetuid, so it needs an
|
||||||
|
# unprivileged user namespace that the pod's RuntimeDefault seccomp
|
||||||
|
# and dropped capabilities do not grant, and the runtime/kernel
|
||||||
|
# surface here exposes no Landlock runner. The executor therefore
|
||||||
|
# fails closed with SANDBOX_UNAVAILABLE and every command fails. The
|
||||||
|
# pod is the isolation boundary in this example (one pod
|
||||||
|
# per user, non-root, capabilities dropped, seccomp RuntimeDefault,
|
||||||
|
# a private PVC), so trade DSH's inner file policy for a working
|
||||||
|
# agent. Set this to workspace-write (or read-only) on hosts where a
|
||||||
|
# backend is available; see the README troubleshooting row.
|
||||||
|
# SECURITY: the pod is the isolation boundary in this example.
|
||||||
|
# danger-full-access is set only because DSH's own command sandbox
|
||||||
|
# has no usable backend in this pod (see above), which means an
|
||||||
|
# agent command can read its own DSH_HOME (including
|
||||||
|
# .credentials.yaml) and write anywhere in the container. What
|
||||||
|
# still contains it is the pod itself: one pod per user, non-root,
|
||||||
|
# all capabilities dropped, RuntimeDefault seccomp, its own PVC and
|
||||||
|
# Service. That is NOT a hardened multi-tenant boundary on a shared
|
||||||
|
# kernel. For production/multi-tenant use, additionally run the
|
||||||
|
# sandbox under gVisor (`runtimeClassName: gvisor` above) so a
|
||||||
|
# container escape is trapped in a userspace kernel, and apply the
|
||||||
|
# sandbox NetworkPolicy (manifests/optional/network-policy/) so
|
||||||
|
# tenants cannot reach each other or cluster-internal endpoints.
|
||||||
|
# The base manifests without those two controls are a demo. Treat
|
||||||
|
# danger-full-access as a fallback, not a target: on hosts where
|
||||||
|
# DSH's command-sandbox backend works, prefer workspace-write (or
|
||||||
|
# read-only), and recreate the pod because the value is stamped at
|
||||||
|
# creation.
|
||||||
|
- name: DSH_PERMISSION_MODE
|
||||||
|
value: danger-full-access
|
||||||
# Probes target /favicon.svg: DSH gates / behind its launch-token
|
# Probes target /favicon.svg: DSH gates / behind its launch-token
|
||||||
# cookie and answers 401 to an unauthenticated GET, which would
|
# cookie and answers 401 to an unauthenticated GET, which would
|
||||||
# otherwise fail readiness and then get the pod killed by liveness.
|
# otherwise fail readiness and then get the pod killed by liveness.
|
||||||
@@ -126,7 +184,8 @@ spec:
|
|||||||
periodSeconds: 15
|
periodSeconds: 15
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
# The container only needs to serve HTTP; drop every capability and
|
# The container only needs to serve HTTP; drop every capability and
|
||||||
# disallow privilege escalation. DSH writes only to DSH_HOME (PVC).
|
# disallow privilege escalation. DSH writes only to its PVC-backed
|
||||||
|
# directories (DSH_HOME and the agent workspace).
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
capabilities:
|
capabilities:
|
||||||
@@ -139,6 +198,16 @@ spec:
|
|||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: dsh-home
|
- name: dsh-home
|
||||||
mountPath: /home/node/.dsh # DSH_HOME: sessions, config, state
|
mountPath: /home/node/.dsh # DSH_HOME: sessions, config, state
|
||||||
|
# The agent workspace (the image's WORKDIR) is PVC-backed too, but
|
||||||
|
# under its own subPath so DSH state - including .credentials.yaml -
|
||||||
|
# never shows up as workspace data. Kubelet creates workspace/ on
|
||||||
|
# first mount with the volume's fsGroup permissions (0775, setgid
|
||||||
|
# inherited from the volume root), so UID 1000 can write; no init
|
||||||
|
# container is needed. If the kubelet is killed mid-creation the
|
||||||
|
# directory stays 0755: see Troubleshooting in the README.
|
||||||
|
- name: dsh-home
|
||||||
|
mountPath: /workspace
|
||||||
|
subPath: workspace
|
||||||
volumeClaimTemplates:
|
volumeClaimTemplates:
|
||||||
- metadata:
|
- metadata:
|
||||||
name: dsh-home
|
name: dsh-home
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: gateway
|
- name: gateway
|
||||||
image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.3-r4
|
image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/dsh-agents-as-a-service/gateway:dsh-v0.2.1-rc.5-r6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
@@ -134,14 +134,30 @@ spec:
|
|||||||
# the DSH launch token.
|
# the DSH launch token.
|
||||||
- name: DSH_CONTAINER
|
- name: DSH_CONTAINER
|
||||||
value: dsh-web
|
value: dsh-web
|
||||||
# Cookie dsh_gw_session is HttpOnly + SameSite=Lax. "false" keeps
|
# Browser session cookie: HttpOnly + SameSite=Lax, holds only an
|
||||||
# the plain-HTTP demo usable through port-forward: a Secure cookie
|
# opaque, server-side session id (sid) - never the user's DSH
|
||||||
# is never sent over HTTP, so login would silently fail.
|
# token. Sessions can be revoked server-side: POST /logout, user
|
||||||
# HTTPS deployments (the ingress/ variant with TLS, or
|
# delete, POST /recreate and expiry (COOKIE_MAX_AGE, default
|
||||||
# gateway-api-sso/, which terminates TLS at the Gateway) MUST set
|
# 7 days) all invalidate them. The session table is in process
|
||||||
# this to "true".
|
# memory, so restarting this Deployment logs every browser out.
|
||||||
|
# With COOKIE_SECURE=true the cookie is named
|
||||||
|
# __Host-dsh_gw_session; COOKIE_NAME overrides the name.
|
||||||
|
#
|
||||||
|
# "false" keeps the plain-HTTP demo usable through port-forward: a
|
||||||
|
# Secure cookie is never sent over HTTP, so login would silently
|
||||||
|
# fail. The gateway REFUSES TO START with COOKIE_SECURE=false
|
||||||
|
# unless ALLOW_INSECURE_COOKIES=1 acknowledges that the session
|
||||||
|
# travels in clear text, so the base demo sets both. HTTPS
|
||||||
|
# deployments (the ingress/ variant with TLS, or gateway-api-sso/,
|
||||||
|
# which terminates TLS at the Gateway) MUST set this to "true".
|
||||||
- name: COOKIE_SECURE
|
- name: COOKIE_SECURE
|
||||||
value: "false"
|
value: "false"
|
||||||
|
# Explicit acknowledgement that this base demo serves plain HTTP.
|
||||||
|
# Required when COOKIE_SECURE=false (the gateway exits at startup
|
||||||
|
# otherwise); ignored once COOKIE_SECURE=true, so HTTPS installs
|
||||||
|
# should drop it. Never set this on an internet-facing deployment.
|
||||||
|
- name: ALLOW_INSECURE_COOKIES
|
||||||
|
value: "1"
|
||||||
# SSO IS OPT-IN AND DISABLED HERE. With AUTH_PROXY_HEADER unset the
|
# SSO IS OPT-IN AND DISABLED HERE. With AUTH_PROXY_HEADER unset the
|
||||||
# gateway trusts only its own bearer/session auth and ignores every
|
# gateway trusts only its own bearer/session auth and ignores every
|
||||||
# identity header. Enable it ONLY behind
|
# identity header. Enable it ONLY behind
|
||||||
@@ -152,9 +168,49 @@ spec:
|
|||||||
# reach :8080 can impersonate any user by sending that header.
|
# reach :8080 can impersonate any user by sending that header.
|
||||||
# - name: AUTH_PROXY_HEADER
|
# - name: AUTH_PROXY_HEADER
|
||||||
# value: X-Forwarded-User
|
# value: X-Forwarded-User
|
||||||
|
# Brute-force / abuse throttle for the unauthenticated auth
|
||||||
|
# endpoints (/users and /login): at most N attempts per client per
|
||||||
|
# AUTH_RATE_WINDOW seconds (default 60). A client is the rightmost
|
||||||
|
# X-Forwarded-For entry (the one added by the trusted proxy) when
|
||||||
|
# present, otherwise the TCP peer. Over the limit the gateway
|
||||||
|
# answers 429 + Retry-After BEFORE reading the body or touching the
|
||||||
|
# cluster; 0 disables the limiter. This is a per-process bucket, so
|
||||||
|
# it is not a substitute for an ingress rate limit.
|
||||||
|
- name: AUTH_RATE_LIMIT
|
||||||
|
value: "30"
|
||||||
|
# Window in seconds for AUTH_RATE_LIMIT (default 60).
|
||||||
|
# - name: AUTH_RATE_WINDOW
|
||||||
|
# value: "60"
|
||||||
|
# Optional signup gate. When set, POST /users requires the invite
|
||||||
|
# code - form field "invite" or header X-Signup-Invite - compared in
|
||||||
|
# constant time. A missing/wrong code is rejected with 403 before
|
||||||
|
# the user is looked up, so it also stops unauthenticated claim
|
||||||
|
# creation. The landing page shows the invite input only when this
|
||||||
|
# is set. Keep the value in a Secret, create it before rollout and
|
||||||
|
# restart the gateway to pick up a rotation:
|
||||||
|
# kubectl -n dsh-demo create secret generic dsh-signup-invite \
|
||||||
|
# --from-literal=code="$(openssl rand -hex 16)"
|
||||||
|
# - name: SIGNUP_INVITE_CODE
|
||||||
|
# valueFrom:
|
||||||
|
# secretKeyRef:
|
||||||
|
# name: dsh-signup-invite
|
||||||
|
# key: code
|
||||||
# Reject proxied request bodies larger than this (413).
|
# Reject proxied request bodies larger than this (413).
|
||||||
- name: MAX_BODY_MB
|
- name: MAX_BODY_MB
|
||||||
value: "32"
|
value: "32"
|
||||||
|
# Per-user concurrency guard (default 32): at most this many
|
||||||
|
# proxied HTTP requests and WebSocket connections per user.
|
||||||
|
# Excess HTTP requests get 503 + Retry-After and excess WebSocket
|
||||||
|
# upgrades close with 1011, so one tenant cannot exhaust the
|
||||||
|
# single-replica gateway for everyone else.
|
||||||
|
# - name: MAX_IN_FLIGHT_PER_USER
|
||||||
|
# value: "32"
|
||||||
|
# Largest WebSocket message received from DSH, in bytes (default
|
||||||
|
# 16 MiB); oversized frames close the connection. Browser-to-DSH
|
||||||
|
# frames are bounded by uvicorn's own limit. HTTP/SSE responses are
|
||||||
|
# streamed without this cap.
|
||||||
|
# - name: WS_MAX_MESSAGE_BYTES
|
||||||
|
# value: "16777216"
|
||||||
# /healthz is unauthenticated and returns 200 without touching the
|
# /healthz is unauthenticated and returns 200 without touching the
|
||||||
# cluster, so it is safe for both readiness and liveness.
|
# cluster, so it is safe for both readiness and liveness.
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ Applying both gives the gateway two entry points, and the SSO variant is only
|
|||||||
safe when the gateway is reachable **exclusively** through oauth2-proxy. Do not
|
safe when the gateway is reachable **exclusively** through oauth2-proxy. Do not
|
||||||
mix them.
|
mix them.
|
||||||
|
|
||||||
|
> **Production requirement.** Each variant's NetworkPolicy is part of the
|
||||||
|
> variant, and applying the variant without it is demo-only. The sandbox
|
||||||
|
> NetworkPolicy and the gateway egress policy (the API server is cluster-specific)
|
||||||
|
> are separate and equally required; see the
|
||||||
|
> [production checklist](../../docs/optional-hardening.md#6-production-checklist)
|
||||||
|
> and the
|
||||||
|
> [gateway egress recipe](../../docs/optional-hardening.md#gateway-egress-required-for-production).
|
||||||
|
> For SSO, run [`scripts/preflight-sso.sh`](../../scripts/preflight-sso.sh)
|
||||||
|
> after applying the variant and before sending real traffic.
|
||||||
|
|
||||||
Each variant also ships a NetworkPolicy, applied by the same directory apply:
|
Each variant also ships a NetworkPolicy, applied by the same directory apply:
|
||||||
|
|
||||||
- `ingress/61-networkpolicy.yaml` allows only Traefik pods to reach the gateway
|
- `ingress/61-networkpolicy.yaml` allows only Traefik pods to reach the gateway
|
||||||
@@ -27,7 +37,8 @@ Each variant also ships a NetworkPolicy, applied by the same directory apply:
|
|||||||
The policies assume Traefik's `app.kubernetes.io/name: traefik` pod label.
|
The policies assume Traefik's `app.kubernetes.io/name: traefik` pod label.
|
||||||
Adjust the selector for another implementation or a `hostNetwork` install; see
|
Adjust the selector for another implementation or a `hostNetwork` install; see
|
||||||
[optional hardening](../../docs/optional-hardening.md) for the probe caveat and
|
[optional hardening](../../docs/optional-hardening.md) for the probe caveat and
|
||||||
verification commands.
|
verification commands. Keep the policies: an exposure without its NetworkPolicy
|
||||||
|
is demo-only.
|
||||||
|
|
||||||
## Applying a variant
|
## Applying a variant
|
||||||
|
|
||||||
@@ -69,10 +80,13 @@ Secrets imperatively (commands below) before applying the directory.
|
|||||||
2. Set `DSH_TRUSTED_HOSTS` in the SandboxTemplate to the same public host:
|
2. Set `DSH_TRUSTED_HOSTS` in the SandboxTemplate to the same public host:
|
||||||
the gateway forwards the browser's `Host`, and DSH rejects unknown hosts on
|
the gateway forwards the browser's `Host`, and DSH rejects unknown hosts on
|
||||||
`/api` (see `../10-sandboxtemplate.yaml`).
|
`/api` (see `../10-sandboxtemplate.yaml`).
|
||||||
3. Set `COOKIE_SECURE=true` on the gateway once the Ingress serves HTTPS:
|
3. Set `COOKIE_SECURE=true` and remove the plain-HTTP acknowledgement once the
|
||||||
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true`. The
|
Ingress serves HTTPS:
|
||||||
base manifest keeps `false` so the plain-HTTP port-forward demo works; a
|
`kubectl -n dsh-demo set env deploy/dsh-gateway COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-`.
|
||||||
`Secure` cookie is never sent over HTTP, which silently breaks login.
|
The base manifest keeps `false` + `ALLOW_INSECURE_COOKIES=1` so the
|
||||||
|
plain-HTTP port-forward demo works; a `Secure` cookie is never sent over HTTP,
|
||||||
|
which silently breaks login, while the acknowledgement must not survive into
|
||||||
|
production.
|
||||||
4. Do not attach buffering or compression middlewares to this router: they
|
4. Do not attach buffering or compression middlewares to this router: they
|
||||||
break WebSocket upgrades and streaming.
|
break WebSocket upgrades and streaming.
|
||||||
5. `61-networkpolicy.yaml` is part of this variant: it allows only Traefik pods
|
5. `61-networkpolicy.yaml` is part of this variant: it allows only Traefik pods
|
||||||
@@ -116,14 +130,23 @@ kubectl -n dsh-demo create secret tls dsh-demo-tls --cert=tls.crt --key=tls.key
|
|||||||
|
|
||||||
# 3. Enable SSO on the gateway and trust the public host in the template.
|
# 3. Enable SSO on the gateway and trust the public host in the template.
|
||||||
kubectl -n dsh-demo set env deploy/dsh-gateway \
|
kubectl -n dsh-demo set env deploy/dsh-gateway \
|
||||||
AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true
|
AUTH_PROXY_HEADER=X-Forwarded-User COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-
|
||||||
# then set DSH_TRUSTED_HOSTS=dsh-demo.example.com in the SandboxTemplate
|
# then set DSH_TRUSTED_HOSTS=dsh-demo.example.com in the SandboxTemplate
|
||||||
# (see ../10-sandboxtemplate.yaml) and re-apply it.
|
# (see ../10-sandboxtemplate.yaml) and re-apply it.
|
||||||
|
|
||||||
# 4. Apply the variant.
|
# 4. Apply the variant.
|
||||||
kubectl apply -f manifests/exposure/gateway-api-sso/
|
kubectl apply -f manifests/exposure/gateway-api-sso/
|
||||||
|
|
||||||
|
# 5. Preflight: fails loudly on missing Secrets, a bad oauth2-proxy cookie
|
||||||
|
# secret, missing gateway SSO env, a dangling TLS reference, leftover
|
||||||
|
# placeholders, or a second path to the gateway.
|
||||||
|
./scripts/preflight-sso.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The preflight is read-only and exits non-zero on any failure, naming the exact
|
||||||
|
fix. Run it again after rotating a Secret or changing the public host; it also
|
||||||
|
verifies that the oauth2-proxy cookie secret decodes to 16, 24 or 32 bytes.
|
||||||
|
|
||||||
### SSO trust boundary
|
### SSO trust boundary
|
||||||
|
|
||||||
`AUTH_PROXY_HEADER` is opt-in and deliberately unset in the base gateway
|
`AUTH_PROXY_HEADER` is opt-in and deliberately unset in the base gateway
|
||||||
@@ -141,8 +164,14 @@ takes all of these:
|
|||||||
impersonate any user.
|
impersonate any user.
|
||||||
3. Keep `75-networkpolicies.yaml` with the variant. It enforces the boundary in
|
3. Keep `75-networkpolicies.yaml` with the variant. It enforces the boundary in
|
||||||
the network layer (gateway ← oauth2-proxy ← Traefik, and limited oauth2-proxy
|
the network layer (gateway ← oauth2-proxy ← Traefik, and limited oauth2-proxy
|
||||||
egress). A custom exposure without an equivalent policy has no network
|
egress). This is required, not optional: an exposure without the policy is
|
||||||
|
demo-only, and a custom exposure without an equivalent policy has no network
|
||||||
enforcement.
|
enforcement.
|
||||||
|
4. Apply the gateway egress policy too: the gateway talks to the Kubernetes API
|
||||||
|
and to sandbox pods, and `75-networkpolicies.yaml` intentionally leaves that
|
||||||
|
direction unrestricted because the API-server endpoint is cluster-specific.
|
||||||
|
See
|
||||||
|
[gateway egress](../../docs/optional-hardening.md#gateway-egress-required-for-production).
|
||||||
|
|
||||||
Also terminate TLS in front of the gateway (the Gateway listener does) and
|
Also terminate TLS in front of the gateway (the Gateway listener does) and
|
||||||
leave `COOKIE_SECURE=true` so the session cookie never travels in clear text.
|
leave `COOKIE_SECURE=true` so the session cookie never travels in clear text.
|
||||||
|
|||||||
@@ -25,8 +25,9 @@
|
|||||||
# TLS terminates at the Gateway listener (70-gateway.yaml); keep
|
# TLS terminates at the Gateway listener (70-gateway.yaml); keep
|
||||||
# --cookie-secure=true and COOKIE_SECURE=true on the gateway.
|
# --cookie-secure=true and COOKIE_SECURE=true on the gateway.
|
||||||
#
|
#
|
||||||
# The image is pinned to quay.io/oauth2-proxy/oauth2-proxy:v7.15.5
|
# The image is digest-pinned: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:...
|
||||||
# (versions.env: OAUTH2_PROXY_IMAGE). Credentials come from the
|
# (versions.env: OAUTH2_PROXY_IMAGE). The digest is the immutable multi-arch
|
||||||
|
# index, so the tag can never be moved under us. Credentials come from the
|
||||||
# "oauth2-proxy" Secret (see 73-oauth2-proxy-secret.yaml.example); this
|
# "oauth2-proxy" Secret (see 73-oauth2-proxy-secret.yaml.example); this
|
||||||
# manifest never carries secrets.
|
# manifest never carries secrets.
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
@@ -61,7 +62,7 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: oauth2-proxy
|
- name: oauth2-proxy
|
||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:8498b0d0ef0a7b29686414000a08aee467f02d0299c9ed1e006a8f33fc017916
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# OPTIONAL with the SSO exposure variant: enforce the trust boundary at the
|
# REQUIRED with the SSO exposure variant: enforce the trust boundary at the
|
||||||
# network layer.
|
# network layer. Applying the SSO variant without this file is demo-only.
|
||||||
#
|
#
|
||||||
# The chain is Traefik (Gateway API) -> oauth2-proxy -> dsh-gateway. These
|
# The chain is Traefik (Gateway API) -> oauth2-proxy -> dsh-gateway. These
|
||||||
# policies make it the ONLY chain:
|
# policies make it the ONLY chain:
|
||||||
@@ -8,8 +8,9 @@
|
|||||||
# - oauth2-proxy egress is limited to DNS, public HTTPS (GitHub) and the
|
# - oauth2-proxy egress is limited to DNS, public HTTPS (GitHub) and the
|
||||||
# gateway.
|
# gateway.
|
||||||
# The gateway's own egress (Kubernetes API, sandbox pods) is intentionally not
|
# The gateway's own egress (Kubernetes API, sandbox pods) is intentionally not
|
||||||
# restricted here: the API-server endpoint is cluster-specific. See
|
# restricted here: the API-server endpoint is cluster-specific. Add it for
|
||||||
# ../../../docs/optional-hardening.md for that recipe.
|
# production. See
|
||||||
|
# ../../../docs/optional-hardening.md#gateway-egress-required-for-production.
|
||||||
#
|
#
|
||||||
# This manifest assumes Traefik, matching gatewayClassName: traefik in
|
# This manifest assumes Traefik, matching gatewayClassName: traefik in
|
||||||
# 70-gateway.yaml. Adjust the app.kubernetes.io/name selector for another
|
# 70-gateway.yaml. Adjust the app.kubernetes.io/name selector for another
|
||||||
|
|||||||
@@ -14,8 +14,13 @@
|
|||||||
# DSH_TRUSTED_HOSTS to this host so DSH accepts the forwarded Host/Origin
|
# DSH_TRUSTED_HOSTS to this host so DSH accepts the forwarded Host/Origin
|
||||||
# on /api.
|
# on /api.
|
||||||
# 2. Gateway Deployment (manifests/50-gateway.yaml): set COOKIE_SECURE to
|
# 2. Gateway Deployment (manifests/50-gateway.yaml): set COOKIE_SECURE to
|
||||||
# "true" once this Ingress serves HTTPS, so the session cookie is Secure.
|
# "true" and remove ALLOW_INSECURE_COOKIES once this Ingress serves HTTPS,
|
||||||
# The base manifest sets "false" for the plain-HTTP port-forward demo.
|
# so the session cookie is Secure:
|
||||||
|
# kubectl -n dsh-demo set env deploy/dsh-gateway \
|
||||||
|
# COOKIE_SECURE=true ALLOW_INSECURE_COOKIES-
|
||||||
|
# The base manifest sets "false" + ALLOW_INSECURE_COOKIES=1 for the
|
||||||
|
# plain-HTTP port-forward demo; without the acknowledgement the gateway
|
||||||
|
# refuses to start.
|
||||||
#
|
#
|
||||||
# WebSocket support is REQUIRED: the browser talks to /u/<user>/... and DSH
|
# WebSocket support is REQUIRED: the browser talks to /u/<user>/... and DSH
|
||||||
# uses WebSockets for live session traffic. Traefik upgrades WS/SSE
|
# uses WebSockets for live session traffic. Traefik upgrades WS/SSE
|
||||||
@@ -24,9 +29,11 @@
|
|||||||
# and upgrades. `path: /` Prefix covers the landing page and every per-user
|
# and upgrades. `path: /` Prefix covers the landing page and every per-user
|
||||||
# proxy path, so no per-sandbox Ingress is needed.
|
# proxy path, so no per-sandbox Ingress is needed.
|
||||||
#
|
#
|
||||||
# 61-networkpolicy.yaml is part of this variant: it restricts the gateway's
|
# 61-networkpolicy.yaml is part of this variant and required for production:
|
||||||
# ingress to Traefik pods. Adjust its selector if your ingress controller uses
|
# it restricts the gateway's ingress to Traefik pods. Adjust its selector if
|
||||||
# a different label or hostNetwork.
|
# your ingress controller uses a different label or hostNetwork. The sandbox
|
||||||
|
# and gateway-egress NetworkPolicies are separate requirements; see
|
||||||
|
# ../../../docs/optional-hardening.md#6-production-checklist.
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
# OPTIONAL with the Ingress exposure variant: restrict the gateway's ingress
|
# REQUIRED with the Ingress exposure variant for production: restrict the
|
||||||
# to the ingress controller.
|
# gateway's ingress to the ingress controller. Applying the variant without
|
||||||
|
# this policy is demo-only.
|
||||||
#
|
#
|
||||||
# This manifest assumes Traefik, matching this variant's
|
# This manifest assumes Traefik, matching this variant's
|
||||||
# `ingressClassName: traefik`. Traefik's Helm chart labels its pods
|
# `ingressClassName: traefik`. Traefik's Helm chart labels its pods
|
||||||
# app.kubernetes.io/name=traefik; adjust the selector for another controller or
|
# app.kubernetes.io/name=traefik; adjust the selector for another controller or
|
||||||
# for a hostNetwork install (where the source is the node IP, not a pod IP).
|
# for a hostNetwork install (where the source is the node IP, not a pod IP).
|
||||||
#
|
#
|
||||||
|
# The sandbox NetworkPolicy and the gateway egress policy are separate and also
|
||||||
|
# required for production; see
|
||||||
|
# ../../../docs/optional-hardening.md#6-production-checklist.
|
||||||
|
#
|
||||||
# NetworkPolicy enforcement of kubelet probe traffic is CNI-dependent. If the
|
# NetworkPolicy enforcement of kubelet probe traffic is CNI-dependent. If the
|
||||||
# gateway starts failing its /healthz probes, allow the node CIDR on port 8080
|
# gateway starts failing its /healthz probes, allow the node CIDR on port 8080
|
||||||
# or remove this policy. See ../../../docs/optional-hardening.md.
|
# or remove this policy. See ../../../docs/optional-hardening.md.
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ It requires a CNI that enforces `NetworkPolicy`. The selector uses the
|
|||||||
agent-sandbox controller's pod tracking label, so the policy applies to
|
agent-sandbox controller's pod tracking label, so the policy applies to
|
||||||
existing warm-pool and claimed sandboxes without recreation.
|
existing warm-pool and claimed sandboxes without recreation.
|
||||||
|
|
||||||
|
**This is opt-in for the demo but required for production.** Without it every
|
||||||
|
tenant can reach every other tenant and cluster-internal endpoints. Production
|
||||||
|
also needs the gateway egress policy (cluster-specific API-server endpoint) and
|
||||||
|
the exposure variant's policy; see the
|
||||||
|
[production checklist](../../docs/optional-hardening.md#6-production-checklist).
|
||||||
|
|
||||||
## gVisor, nodeSelector and provider keys
|
## gVisor, nodeSelector and provider keys
|
||||||
|
|
||||||
These are configured in the main manifests or with an extra Secret:
|
These are configured in the main manifests or with an extra Secret:
|
||||||
@@ -30,6 +36,9 @@ These are configured in the main manifests or with an extra Secret:
|
|||||||
- **gVisor:** uncomment `runtimeClassName: gvisor` in the SandboxTemplate and
|
- **gVisor:** uncomment `runtimeClassName: gvisor` in the SandboxTemplate and
|
||||||
the Deployments before applying, or patch an existing install.
|
the Deployments before applying, or patch an existing install.
|
||||||
- **nodeSelector:** uncomment the field and replace the placeholder node label.
|
- **nodeSelector:** uncomment the field and replace the placeholder node label.
|
||||||
|
- **Failed-Pod recovery:** uncomment `failedPodPolicy: Recreate` in the
|
||||||
|
SandboxTemplate (agent-sandbox v1.0.8+) so a Failed Pod is replaced instead
|
||||||
|
of parked.
|
||||||
- **Provider keys:** create the optional `dsh-provider-keys-extra` Secret; the
|
- **Provider keys:** create the optional `dsh-provider-keys-extra` Secret; the
|
||||||
base `dsh-provider-keys` Secret remains the one required key.
|
base `dsh-provider-keys` Secret remains the one required key.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
# OPTIONAL, opt-in tenant network policy for the DSH sandboxes.
|
# OPTIONAL for the base walkthrough, REQUIRED for production. Opt-in tenant
|
||||||
|
# network policy for the DSH sandboxes: without it every tenant can reach every
|
||||||
|
# other tenant and cluster-internal endpoints.
|
||||||
#
|
#
|
||||||
# It is NOT part of the top-level walkthrough. Apply and delete it explicitly:
|
# It is NOT part of the top-level walkthrough. Apply and delete it explicitly:
|
||||||
# kubectl apply -f manifests/optional/network-policy/
|
# kubectl apply -f manifests/optional/network-policy/
|
||||||
# kubectl delete -f manifests/optional/network-policy/
|
# kubectl delete -f manifests/optional/network-policy/
|
||||||
#
|
#
|
||||||
|
# Production also needs the gateway egress policy (cluster-specific API-server
|
||||||
|
# endpoint) and the exposure variant's policy, plus gVisor
|
||||||
|
# (docs/optional-hardening.md#6-production-checklist).
|
||||||
|
#
|
||||||
# The selector uses the agent-sandbox controller's pod tracking label, which is
|
# The selector uses the agent-sandbox controller's pod tracking label, which is
|
||||||
# present on every Sandbox pod (including warm-pool spares), so the policy
|
# present on every Sandbox pod (including warm-pool spares), so the policy
|
||||||
# applies to existing sandboxes without recreating them. The SandboxTemplate
|
# applies to existing sandboxes without recreating them. The SandboxTemplate
|
||||||
|
|||||||
@@ -72,8 +72,23 @@ pass "claim Ready in ${ELAPSED}s, adopted $SB"
|
|||||||
|
|
||||||
echo "=== 3. write state on the PVC"
|
echo "=== 3. write state on the PVC"
|
||||||
POD="$SB"
|
POD="$SB"
|
||||||
|
# HOME must point at the PVC-backed workspace: DSH's directory picker lists
|
||||||
|
# $HOME when it opens and has no start-path setting, so this is what makes the
|
||||||
|
# folder explorer start on the persisted tree instead of the ephemeral image
|
||||||
|
# home. It also makes ~ resolve to /workspace inside agent commands.
|
||||||
|
HOME_VALUE=$(kubectl -n "$NS" exec "$POD" -- printenv HOME)
|
||||||
|
[ "$HOME_VALUE" = "/workspace" ] || fail "HOME is '$HOME_VALUE', expected /workspace"
|
||||||
|
TILDE=$(kubectl -n "$NS" exec "$POD" -- sh -c 'cd ~ && pwd')
|
||||||
|
[ "$TILDE" = "/workspace" ] || fail "~ resolves to '$TILDE', expected /workspace"
|
||||||
|
pass "HOME=$HOME_VALUE and ~ resolve to the workspace"
|
||||||
kubectl -n "$NS" exec "$POD" -- sh -c 'echo "remember me" > /home/node/.dsh/marker.txt'
|
kubectl -n "$NS" exec "$POD" -- sh -c 'echo "remember me" > /home/node/.dsh/marker.txt'
|
||||||
pass "marker written in $POD"
|
pass "state marker written in $POD"
|
||||||
|
# The agent workspace must be the dsh-home PVC's workspace/ subPath, writable
|
||||||
|
# as UID 1000 without an init container.
|
||||||
|
SUBPATH=$(kubectl -n "$NS" get pod "$POD" -o jsonpath='{range .spec.containers[0].volumeMounts[?(@.mountPath=="/workspace")]}{.subPath}{end}')
|
||||||
|
[ "$SUBPATH" = "workspace" ] || fail "/workspace is not mounted from the dsh-home PVC subPath"
|
||||||
|
kubectl -n "$NS" exec "$POD" -- sh -c 'echo "remember me too" > /workspace/marker.txt'
|
||||||
|
pass "workspace marker written in $POD (subPath=$SUBPATH)"
|
||||||
|
|
||||||
echo "=== 4. suspend (pod deleted, PVC + Service retained)"
|
echo "=== 4. suspend (pod deleted, PVC + Service retained)"
|
||||||
kubectl -n "$NS" patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Suspended"}}'
|
kubectl -n "$NS" patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"Suspended"}}'
|
||||||
@@ -87,8 +102,11 @@ kubectl -n "$NS" patch sandbox "$SB" --type merge -p '{"spec":{"operatingMode":"
|
|||||||
kubectl -n "$NS" wait sandbox "$SB" --for=condition=Ready --timeout=180s
|
kubectl -n "$NS" wait sandbox "$SB" --for=condition=Ready --timeout=180s
|
||||||
POD="$SB"
|
POD="$SB"
|
||||||
MARKER=$(kubectl -n "$NS" exec "$POD" -- cat /home/node/.dsh/marker.txt)
|
MARKER=$(kubectl -n "$NS" exec "$POD" -- cat /home/node/.dsh/marker.txt)
|
||||||
[ "$MARKER" = "remember me" ] || fail "marker did not survive suspend/resume"
|
[ "$MARKER" = "remember me" ] || fail "state marker did not survive suspend/resume"
|
||||||
pass "resumed: state survived on the reattached PVC"
|
pass "resumed: DSH state survived on the reattached PVC"
|
||||||
|
WS_MARKER=$(kubectl -n "$NS" exec "$POD" -- cat /workspace/marker.txt)
|
||||||
|
[ "$WS_MARKER" = "remember me too" ] || fail "workspace marker did not survive suspend/resume"
|
||||||
|
pass "resumed: agent workspace survived on the PVC subPath"
|
||||||
|
|
||||||
echo "=== 6. injection policy rejects env-carrying claims"
|
echo "=== 6. injection policy rejects env-carrying claims"
|
||||||
kubectl apply -f manifests/40-claim-rejected.yaml
|
kubectl apply -f manifests/40-claim-rejected.yaml
|
||||||
|
|||||||
234
examples/dsh-agents-as-a-service/scripts/preflight-sso.sh
Executable file
234
examples/dsh-agents-as-a-service/scripts/preflight-sso.sh
Executable file
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Preflight for the gateway-api-sso exposure variant.
|
||||||
|
#
|
||||||
|
# Run this AFTER creating the oauth2-proxy/TLS Secrets, setting the gateway
|
||||||
|
# env and applying manifests/exposure/gateway-api-sso/, and BEFORE sending real
|
||||||
|
# traffic. It checks the configuration that is easy to get wrong and whose
|
||||||
|
# failure is either silent (login appears to work but the gateway still asks
|
||||||
|
# for its own token) or dangerous (a spoofable identity header, a placeholder
|
||||||
|
# host, a TLS Secret that does not exist).
|
||||||
|
#
|
||||||
|
# Checks:
|
||||||
|
# 1. kubectl reaches the cluster and the SSO objects exist;
|
||||||
|
# 2. the oauth2-proxy Secret has client-id, client-secret and a cookie-secret
|
||||||
|
# that decodes to 16, 24 or 32 bytes; the TLS Secret exists;
|
||||||
|
# 3. deploy/dsh-gateway has AUTH_PROXY_HEADER=X-Forwarded-User and
|
||||||
|
# COOKIE_SECURE=true (both are opt-in and both are required for SSO over
|
||||||
|
# TLS);
|
||||||
|
# 4. the Gateway's TLS certificate reference resolves to an existing Secret;
|
||||||
|
# 5. no your-github-org / dsh-demo.example.com placeholder is left in the
|
||||||
|
# applied oauth2-proxy args, Gateway listeners or HTTPRoute hostnames;
|
||||||
|
# 6. (warning) no Ingress in the namespace creates a second path to
|
||||||
|
# dsh-gateway. With SSO enabled the Service must be reachable only through
|
||||||
|
# oauth2-proxy.
|
||||||
|
#
|
||||||
|
# Exit status: 0 when every check passes (warnings allowed), 1 on any failure.
|
||||||
|
# Every failure prints the fix. This script only reads cluster state.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# examples/dsh-agents-as-a-service/scripts/preflight-sso.sh
|
||||||
|
#
|
||||||
|
# Environment overrides:
|
||||||
|
# NAMESPACE namespace (default: dsh-demo)
|
||||||
|
# GATEWAY_DEPLOY gateway Deployment name (default: dsh-gateway)
|
||||||
|
# OAUTH_DEPLOY oauth2-proxy Deployment name (default: oauth2-proxy)
|
||||||
|
# OAUTH_SECRET oauth2-proxy Secret name (default: oauth2-proxy)
|
||||||
|
# TLS_SECRET TLS Secret name the Gateway should reference (default:
|
||||||
|
# dsh-demo-tls)
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
NS="${NAMESPACE:-dsh-demo}"
|
||||||
|
GATEWAY_DEPLOY="${GATEWAY_DEPLOY:-dsh-gateway}"
|
||||||
|
OAUTH_DEPLOY="${OAUTH_DEPLOY:-oauth2-proxy}"
|
||||||
|
OAUTH_SECRET="${OAUTH_SECRET:-oauth2-proxy}"
|
||||||
|
TLS_SECRET="${TLS_SECRET:-dsh-demo-tls}"
|
||||||
|
|
||||||
|
FAILED=0
|
||||||
|
pass() { printf 'PASS %s\n' "$*"; }
|
||||||
|
warn() { printf 'WARN %s\n' "$*" >&2; }
|
||||||
|
fail() { printf 'FAIL %s\n' "$*" >&2; FAILED=1; }
|
||||||
|
|
||||||
|
if ! command -v kubectl >/dev/null 2>&1; then
|
||||||
|
echo "FAIL kubectl is not installed; install it and select the target context" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! kubectl -n "$NS" get deployment "$GATEWAY_DEPLOY" >/dev/null 2>&1; then
|
||||||
|
echo "FAIL deploy/$GATEWAY_DEPLOY not found in namespace $NS." >&2
|
||||||
|
echo " Apply the base manifests first: kubectl apply -f manifests/" >&2
|
||||||
|
echo " Then apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== Secrets"
|
||||||
|
# check_secret_key <secret> <key> <hint>
|
||||||
|
check_secret_key() {
|
||||||
|
local secret=$1 key=$2 hint=$3 value escaped
|
||||||
|
# Kubernetes JSONPath treats "." as a path separator; escape it in keys such
|
||||||
|
# as tls.crt.
|
||||||
|
escaped=${key//./\\.}
|
||||||
|
value=$(kubectl -n "$NS" get secret "$secret" -o jsonpath="{.data.${escaped}}" 2>/dev/null || true)
|
||||||
|
if [ -n "$value" ]; then
|
||||||
|
pass "Secret $secret has non-empty key '$key'"
|
||||||
|
else
|
||||||
|
fail "Secret $secret is missing a non-empty '$key' key. $hint"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_secret_key "$OAUTH_SECRET" client-id \
|
||||||
|
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
|
||||||
|
check_secret_key "$OAUTH_SECRET" client-secret \
|
||||||
|
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
|
||||||
|
check_secret_key "$OAUTH_SECRET" cookie-secret \
|
||||||
|
"Create it from manifests/exposure/gateway-api-sso/73-oauth2-proxy-secret.yaml.example."
|
||||||
|
|
||||||
|
# The cookie secret must be URL-safe unpadded base64 of 16, 24 or 32 bytes;
|
||||||
|
# oauth2-proxy refuses anything else (the classic error is "44 bytes").
|
||||||
|
if COOKIE_B64=$(kubectl -n "$NS" get secret "$OAUTH_SECRET" \
|
||||||
|
-o jsonpath='{.data.cookie-secret}' 2>/dev/null); then
|
||||||
|
if [ -n "$COOKIE_B64" ]; then
|
||||||
|
if ! COOKIE_LEN=$(printf '%s' "$COOKIE_B64" | base64 -d 2>/dev/null | wc -c); then
|
||||||
|
fail "Secret $OAUTH_SECRET key 'cookie-secret' is not valid base64. Generate it with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
||||||
|
else
|
||||||
|
case "$COOKIE_LEN" in
|
||||||
|
16 | 24 | 32) pass "oauth2-proxy cookie-secret decodes to $COOKIE_LEN bytes" ;;
|
||||||
|
*)
|
||||||
|
fail "oauth2-proxy cookie-secret decodes to $COOKIE_LEN bytes; it must be 16, 24 or 32. Generate a URL-safe unpadded value with: python3 -c 'import secrets; print(secrets.token_urlsafe(32))'"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_secret_key "$TLS_SECRET" tls.crt \
|
||||||
|
"Create the TLS Secret with: kubectl -n $NS create secret tls $TLS_SECRET --cert=tls.crt --key=tls.key"
|
||||||
|
check_secret_key "$TLS_SECRET" tls.key \
|
||||||
|
"Create the TLS Secret with: kubectl -n $NS create secret tls $TLS_SECRET --cert=tls.crt --key=tls.key"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== Gateway SSO configuration (deploy/$GATEWAY_DEPLOY)"
|
||||||
|
GATEWAY_ENV=$(kubectl -n "$NS" get deployment "$GATEWAY_DEPLOY" \
|
||||||
|
-o jsonpath='{range .spec.template.spec.containers[*].env[*]}{.name}={.value}{"\n"}{end}')
|
||||||
|
|
||||||
|
# env_value <NAME>: print the literal env value, or nothing when unset.
|
||||||
|
env_value() {
|
||||||
|
printf '%s\n' "$GATEWAY_ENV" | awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print }'
|
||||||
|
}
|
||||||
|
env_is_set() {
|
||||||
|
printf '%s\n' "$GATEWAY_ENV" | awk -F= -v key="$1" '$1 == key { found = 1 } END { exit found ? 0 : 1 }'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! env_is_set AUTH_PROXY_HEADER; then
|
||||||
|
fail "AUTH_PROXY_HEADER is not set on deploy/$GATEWAY_DEPLOY, so the gateway ignores oauth2-proxy's identity header and users are still asked for the gateway token. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY AUTH_PROXY_HEADER=X-Forwarded-User"
|
||||||
|
elif [ "$(env_value AUTH_PROXY_HEADER)" != "X-Forwarded-User" ]; then
|
||||||
|
fail "AUTH_PROXY_HEADER is '$(env_value AUTH_PROXY_HEADER)', expected 'X-Forwarded-User'. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY AUTH_PROXY_HEADER=X-Forwarded-User"
|
||||||
|
else
|
||||||
|
pass "AUTH_PROXY_HEADER=X-Forwarded-User"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! env_is_set COOKIE_SECURE; then
|
||||||
|
fail "COOKIE_SECURE is not set on deploy/$GATEWAY_DEPLOY; the session cookie must be Secure behind TLS. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY COOKIE_SECURE=true"
|
||||||
|
elif [ "$(env_value COOKIE_SECURE | tr '[:upper:]' '[:lower:]')" != "true" ]; then
|
||||||
|
fail "COOKIE_SECURE is '$(env_value COOKIE_SECURE)', expected 'true' behind TLS. Fix: kubectl -n $NS set env deploy/$GATEWAY_DEPLOY COOKIE_SECURE=true"
|
||||||
|
else
|
||||||
|
pass "COOKIE_SECURE=true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if env_is_set ALLOW_INSECURE_COOKIES; then
|
||||||
|
warn "ALLOW_INSECURE_COOKIES is still set on deploy/$GATEWAY_DEPLOY. It is ignored while COOKIE_SECURE=true but is a footgun: remove it with 'kubectl -n $NS set env deploy/$GATEWAY_DEPLOY ALLOW_INSECURE_COOKIES-'."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== SSO objects and placeholders"
|
||||||
|
if kubectl -n "$NS" get deployment "$OAUTH_DEPLOY" >/dev/null 2>&1; then
|
||||||
|
pass "deploy/$OAUTH_DEPLOY exists"
|
||||||
|
OAUTH_ARGS=$(kubectl -n "$NS" get deployment "$OAUTH_DEPLOY" \
|
||||||
|
-o jsonpath='{range .spec.template.spec.containers[*].args[*]}{@}{"\n"}{end}')
|
||||||
|
else
|
||||||
|
OAUTH_ARGS=""
|
||||||
|
fail "deploy/$OAUTH_DEPLOY not found. Apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GitHub org restriction is the only authorization in the shipped variant.
|
||||||
|
# --email-domain=* is acceptable only because --github-org narrows membership;
|
||||||
|
# a missing or empty org value would accept any GitHub account.
|
||||||
|
if [ -n "$OAUTH_ARGS" ]; then
|
||||||
|
if printf '%s\n' "$OAUTH_ARGS" | grep -q '^--provider=github$'; then
|
||||||
|
pass "oauth2-proxy uses --provider=github"
|
||||||
|
else
|
||||||
|
fail "oauth2-proxy is not configured with --provider=github. Fix: add --provider=github to deploy/$OAUTH_DEPLOY."
|
||||||
|
fi
|
||||||
|
if printf '%s\n' "$OAUTH_ARGS" | grep -Eq '^--github-org=.+$'; then
|
||||||
|
pass "oauth2-proxy restricts membership with --github-org"
|
||||||
|
else
|
||||||
|
fail "oauth2-proxy has no non-empty --github-org. Fix: set --github-org=<your-org> in deploy/$OAUTH_DEPLOY; otherwise --email-domain=* accepts any GitHub account."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if kubectl -n "$NS" get httproute dsh-gateway >/dev/null 2>&1; then
|
||||||
|
pass "HTTPRoute dsh-gateway exists"
|
||||||
|
ROUTE_STATE=$(kubectl -n "$NS" get httproute dsh-gateway \
|
||||||
|
-o jsonpath='{.spec.hostnames[*]}' 2>/dev/null || true)
|
||||||
|
else
|
||||||
|
ROUTE_STATE=""
|
||||||
|
fail "HTTPRoute dsh-gateway not found. Apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if kubectl -n "$NS" get gateway dsh-gateway >/dev/null 2>&1; then
|
||||||
|
pass "Gateway dsh-gateway exists"
|
||||||
|
GATEWAY_STATE=$(kubectl -n "$NS" get gateway dsh-gateway \
|
||||||
|
-o jsonpath='{range .spec.listeners[*]}{.hostname}{"\n"}{end}' 2>/dev/null || true)
|
||||||
|
CERT_REFS=$(kubectl -n "$NS" get gateway dsh-gateway \
|
||||||
|
-o jsonpath='{range .spec.listeners[*].tls.certificateRefs[*]}{.namespace}{"/"}{.name}{"\n"}{end}' 2>/dev/null || true)
|
||||||
|
else
|
||||||
|
GATEWAY_STATE=""
|
||||||
|
CERT_REFS=""
|
||||||
|
fail "Gateway dsh-gateway not found. Apply the variant: kubectl apply -f manifests/exposure/gateway-api-sso/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Placeholders are the default values in the committed manifests. They reach
|
||||||
|
# the cluster if the variant is applied before editing them.
|
||||||
|
PLACEHOLDER_RE='your-github-org|dsh-demo\.example\.com'
|
||||||
|
PLACEHOLDER_STATE=$(printf '%s\n%s\n%s\n' "$OAUTH_ARGS" "$ROUTE_STATE" "$GATEWAY_STATE")
|
||||||
|
if printf '%s\n' "$PLACEHOLDER_STATE" | grep -En "$PLACEHOLDER_RE" >/dev/null 2>&1; then
|
||||||
|
fail "the applied SSO objects still contain a placeholder (your-github-org or dsh-demo.example.com):"
|
||||||
|
printf '%s\n' "$PLACEHOLDER_STATE" | grep -En "$PLACEHOLDER_RE" | sed 's/^/ /' >&2
|
||||||
|
echo " Replace --github-org, --redirect-url, the Gateway listener host and the HTTPRoute hostname with the real public FQDN and organization, then re-apply and restart the proxy." >&2
|
||||||
|
else
|
||||||
|
pass "no your-github-org / dsh-demo.example.com placeholder in the applied SSO objects"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The Gateway must reference an existing TLS Secret; the default name is
|
||||||
|
# dsh-demo-tls, but 70-gateway.yaml can be edited to another name.
|
||||||
|
if [ -n "$CERT_REFS" ]; then
|
||||||
|
while IFS= read -r ref; do
|
||||||
|
[ -n "$ref" ] || continue
|
||||||
|
ref_ns=${ref%%/*}
|
||||||
|
ref_name=${ref#*/}
|
||||||
|
ref_ns=${ref_ns:-$NS}
|
||||||
|
if kubectl -n "$ref_ns" get secret "$ref_name" >/dev/null 2>&1; then
|
||||||
|
pass "Gateway TLS certificate reference ${ref_ns}/${ref_name} exists"
|
||||||
|
else
|
||||||
|
fail "Gateway references TLS Secret ${ref_ns}/${ref_name} but it does not exist. Create it (see manifests/exposure/gateway-api-sso/74-tls-secret.yaml.example)."
|
||||||
|
fi
|
||||||
|
done <<<"$CERT_REFS"
|
||||||
|
else
|
||||||
|
warn "the Gateway has no TLS certificateRefs; SSO over plain HTTP would expose the session cookie. Terminate TLS at the Gateway listener."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A second path to the gateway defeats SSO: anyone who can reach
|
||||||
|
# dsh-gateway:8080 can spoof X-Forwarded-User. This is a best-effort check.
|
||||||
|
if kubectl -n "$NS" get ingress >/dev/null 2>&1; then
|
||||||
|
INGRESS_STATE=$(kubectl -n "$NS" get ingress -o json 2>/dev/null || true)
|
||||||
|
if printf '%s' "$INGRESS_STATE" | grep -q '"name":"dsh-gateway"'; then
|
||||||
|
fail "an Ingress in namespace $NS also routes to dsh-gateway. With SSO enabled the gateway must be reachable only through oauth2-proxy: delete the Ingress variant (kubectl delete -f manifests/exposure/ingress/)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAILED" -ne 0 ]; then
|
||||||
|
echo "preflight-sso: FAILED - fix the items above before exposing the gateway." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "preflight-sso: ok - the SSO variant looks correctly configured."
|
||||||
@@ -48,7 +48,7 @@ kubectl -n dsh-demo create secret generic dsh-provider-keys-extra \
|
|||||||
--from-literal=NVIDIA_API_KEY="nvapi-..."
|
--from-literal=NVIDIA_API_KEY="nvapi-..."
|
||||||
|
|
||||||
# 2. The template itself (image pinned in the manifest).
|
# 2. The template itself (image pinned in the manifest).
|
||||||
kubectl apply -f manifests/sandbox-template.yaml
|
kubectl apply -f manifests/10-sandboxtemplate.yaml
|
||||||
|
|
||||||
# 3. A template only describes pods; a warm pool turns it into running
|
# 3. A template only describes pods; a warm pool turns it into running
|
||||||
# Sandboxes and a claim adopts one. One replica is enough here.
|
# Sandboxes and a claim adopts one. One replica is enough here.
|
||||||
@@ -101,6 +101,28 @@ kubectl -n dsh-demo port-forward "pod/$SB" 18081:3080
|
|||||||
> example or a reachable Service instead; see
|
> example or a reachable Service instead; see
|
||||||
> [optional hardening](../dsh-agents-as-a-service/docs/optional-hardening.md).
|
> [optional hardening](../dsh-agents-as-a-service/docs/optional-hardening.md).
|
||||||
|
|
||||||
|
## Folder picker and the workspace
|
||||||
|
|
||||||
|
The template mounts the `dsh-home` PVC's `workspace/` subPath at
|
||||||
|
`/workspace` and sets `HOME=/workspace`. DSH's directory picker (the folder
|
||||||
|
explorer used to choose a workspace) has no start-path setting: it lists `$HOME`
|
||||||
|
when it opens, so with the image default (`/home/node`) it would start on a
|
||||||
|
directory that is not persisted. Pointing `HOME` at the PVC-backed workspace makes
|
||||||
|
the picker open there (labelled *Home* in its breadcrumbs) and makes `~` in agent
|
||||||
|
commands resolve to `/workspace`. The trade-off is that `$HOME`-relative tool
|
||||||
|
caches and config (`.cache`, `.config`, `.local`, `.npm`, `.agents`) are written
|
||||||
|
into the workspace on the PVC. DSH state is unaffected: `DSH_HOME` stays
|
||||||
|
`/home/node/.dsh`. The rationale and the list of affected paths are in
|
||||||
|
[`10-sandboxtemplate.yaml`](../dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml).
|
||||||
|
Pod specs are stamped at creation, so an existing sandbox keeps its old mounts and
|
||||||
|
`HOME` until it is recreated; the PVC retains DSH state and workspace data.
|
||||||
|
|
||||||
|
Verify inside a running sandbox:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n dsh-demo exec "$SB" -- printenv HOME # /workspace
|
||||||
|
```
|
||||||
|
|
||||||
## Network policy and optional hardening
|
## Network policy and optional hardening
|
||||||
|
|
||||||
The template omits `networkPolicyManagement`, so the agent-sandbox controller
|
The template omits `networkPolicyManagement`, so the agent-sandbox controller
|
||||||
@@ -114,7 +136,7 @@ NetworkPolicy are documented in
|
|||||||
|
|
||||||
## Probes and trusted hosts
|
## Probes and trusted hosts
|
||||||
|
|
||||||
Two settings in `manifests/sandbox-template.yaml` are easy to get wrong:
|
Two settings in `manifests/10-sandboxtemplate.yaml` are easy to get wrong:
|
||||||
|
|
||||||
- **Probe path.** `/` returns `401` without the launch-token cookie, so a probe
|
- **Probe path.** `/` returns `401` without the launch-token cookie, so a probe
|
||||||
there crash-loops the pod. The template probes `/favicon.svg` (served
|
there crash-loops the pod. The template probes `/favicon.svg` (served
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
# - Optional: Secret dsh-provider-keys-extra with additional provider keys
|
# - Optional: Secret dsh-provider-keys-extra with additional provider keys
|
||||||
# (OPENROUTER_API_KEY, GEMINI_API_KEY, NVIDIA_API_KEY, ...). It is
|
# (OPENROUTER_API_KEY, GEMINI_API_KEY, NVIDIA_API_KEY, ...). It is
|
||||||
# referenced with `optional: true`, so it may be absent.
|
# referenced with `optional: true`, so it may be absent.
|
||||||
# The agent-sandbox controller (v1.0.7, API v1beta1) must be installed.
|
# The agent-sandbox controller (v1.0.8, API v1beta1) must be installed.
|
||||||
apiVersion: extensions.agents.x-k8s.io/v1beta1
|
apiVersion: extensions.agents.x-k8s.io/v1beta1
|
||||||
kind: SandboxTemplate
|
kind: SandboxTemplate
|
||||||
metadata:
|
metadata:
|
||||||
@@ -26,6 +26,10 @@ spec:
|
|||||||
# quickstart reaches the sandbox through `kubectl port-forward`, which is
|
# quickstart reaches the sandbox through `kubectl port-forward`, which is
|
||||||
# local to the pod. The multi-tenant example documents opt-in policies in
|
# local to the pod. The multi-tenant example documents opt-in policies in
|
||||||
# examples/dsh-agents-as-a-service/docs/optional-hardening.md.
|
# examples/dsh-agents-as-a-service/docs/optional-hardening.md.
|
||||||
|
#
|
||||||
|
# OPTIONAL: recreate a Failed sandbox Pod instead of retaining it
|
||||||
|
# (agent-sandbox v1.0.8+). Documented with the multi-tenant example.
|
||||||
|
# failedPodPolicy: Recreate
|
||||||
podTemplate:
|
podTemplate:
|
||||||
spec:
|
spec:
|
||||||
# OPTIONAL: run under gVisor. gVisor breaks `kubectl port-forward`, so
|
# OPTIONAL: run under gVisor. gVisor breaks `kubectl port-forward`, so
|
||||||
@@ -52,7 +56,7 @@ spec:
|
|||||||
fsGroupChangePolicy: OnRootMismatch
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
containers:
|
containers:
|
||||||
- name: dsh-web
|
- name: dsh-web
|
||||||
image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.3-r4
|
image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox:dsh-v0.2.1-rc.5-r6
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
ports:
|
ports:
|
||||||
- name: web
|
- name: web
|
||||||
@@ -65,6 +69,21 @@ spec:
|
|||||||
name: dsh-provider-keys-extra
|
name: dsh-provider-keys-extra
|
||||||
optional: true
|
optional: true
|
||||||
env:
|
env:
|
||||||
|
# Point the OS home at the persisted agent workspace. DSH's
|
||||||
|
# directory picker (the folder explorer used to choose a workspace)
|
||||||
|
# lists $HOME when it opens and has no start-path setting, so with
|
||||||
|
# the image default (/home/node, not on a volume) it would open on a
|
||||||
|
# throwaway directory. /workspace is the PVC-backed WORKDIR, so the
|
||||||
|
# picker opens on the tree that survives suspend/resume, and `~` in
|
||||||
|
# agent commands resolves there. Trade-off: tools that keep caches
|
||||||
|
# or config under $HOME (npm/pnpm, uv, git, gh, DSH's global
|
||||||
|
# ~/.agents instructions and skills) write dotfiles onto the PVC
|
||||||
|
# inside the agent workspace. DSH_HOME is explicit
|
||||||
|
# (/home/node/.dsh), so sessions, config and credentials are
|
||||||
|
# unaffected. Rationale in full at
|
||||||
|
# examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml.
|
||||||
|
- name: HOME
|
||||||
|
value: /workspace
|
||||||
# Empty => DSH derives links from the request Host. Keep it empty
|
# Empty => DSH derives links from the request Host. Keep it empty
|
||||||
# when fronting the sandbox with a proxy/gateway.
|
# when fronting the sandbox with a proxy/gateway.
|
||||||
- name: DSH_PUBLIC_URL
|
- name: DSH_PUBLIC_URL
|
||||||
@@ -77,9 +96,25 @@ spec:
|
|||||||
# examples/dsh-agents-as-a-service/manifests/exposure/README.md.
|
# examples/dsh-agents-as-a-service/manifests/exposure/README.md.
|
||||||
- name: DSH_TRUSTED_HOSTS
|
- name: DSH_TRUSTED_HOSTS
|
||||||
value: ""
|
value: ""
|
||||||
|
# DSH's own bash file sandbox (bubblewrap, else Landlock) cannot be
|
||||||
|
# enforced in this hardened pod - bubblewrap is installed but is
|
||||||
|
# unsetuid and needs an unprivileged user namespace - and fails
|
||||||
|
# closed with SANDBOX_UNAVAILABLE. The pod is the boundary here;
|
||||||
|
# keep the deployment override in sync with the multi-tenant example
|
||||||
|
# and see its README troubleshooting row to restore a confined mode.
|
||||||
|
- name: DSH_PERMISSION_MODE
|
||||||
|
value: danger-full-access
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: dsh-home
|
- name: dsh-home
|
||||||
mountPath: /home/node/.dsh # DSH_HOME
|
mountPath: /home/node/.dsh # DSH_HOME
|
||||||
|
# The agent workspace (the image's WORKDIR) is the same PVC under
|
||||||
|
# its own subPath, so DSH state - including .credentials.yaml -
|
||||||
|
# never shows up as workspace data. Kubelet creates workspace/ on
|
||||||
|
# first mount with the volume's fsGroup permissions; no init
|
||||||
|
# container is needed. See the multi-tenant template for details.
|
||||||
|
- name: dsh-home
|
||||||
|
mountPath: /workspace
|
||||||
|
subPath: workspace
|
||||||
# / is behind DSH's launch-token cookie (401 without it); use an
|
# / is behind DSH's launch-token cookie (401 without it); use an
|
||||||
# unauthenticated path for probes. The startup probe gives a cold
|
# unauthenticated path for probes. The startup probe gives a cold
|
||||||
# start (plugin activation on a small node, or the first resume of a
|
# start (plugin activation on a small node, or the first resume of a
|
||||||
@@ -20,6 +20,13 @@ for dir in examples/*/; do
|
|||||||
if [ -d "${dir}manifests" ]; then
|
if [ -d "${dir}manifests" ]; then
|
||||||
count=$(find "${dir}manifests" -maxdepth 1 -type f -name '*.yaml' | wc -l | tr -d ' ')
|
count=$(find "${dir}manifests" -maxdepth 1 -type f -name '*.yaml' | wc -l | tr -d ' ')
|
||||||
[ "$count" -gt 0 ] || err "$name: manifests/ has no top-level *.yaml files"
|
[ "$count" -gt 0 ] || err "$name: manifests/ has no top-level *.yaml files"
|
||||||
|
for manifest in "${dir}"manifests/*.yaml; do
|
||||||
|
[ -e "$manifest" ] || continue
|
||||||
|
case "$(basename "$manifest")" in
|
||||||
|
[0-9][0-9]-*.yaml) ;;
|
||||||
|
*) err "$name: top-level manifest $(basename "$manifest") must be named NN-*.yaml" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
stray=$(find "$dir" -maxdepth 1 -type f -name '*.yaml' | wc -l | tr -d ' ')
|
stray=$(find "$dir" -maxdepth 1 -type f -name '*.yaml' | wc -l | tr -d ' ')
|
||||||
|
|||||||
@@ -22,22 +22,60 @@ expect() { # <file> <literal>
|
|||||||
|
|
||||||
# Main Dockerfile build args
|
# Main Dockerfile build args
|
||||||
expect Dockerfile "ARG DSH_VERSION=$DSH_VERSION"
|
expect Dockerfile "ARG DSH_VERSION=$DSH_VERSION"
|
||||||
|
expect Dockerfile "ARG DSH_COMMIT=$DSH_COMMIT"
|
||||||
expect Dockerfile "ARG PNPM_VERSION=$PNPM_VERSION"
|
expect Dockerfile "ARG PNPM_VERSION=$PNPM_VERSION"
|
||||||
expect Dockerfile "ARG RELEASE_REVISION=$RELEASE_REVISION"
|
expect Dockerfile "ARG RELEASE_REVISION=$RELEASE_REVISION"
|
||||||
|
expect Dockerfile "ARG NODE_BUILD_IMAGE=$NODE_BUILD_IMAGE"
|
||||||
|
expect Dockerfile "ARG NODE_RUNTIME_IMAGE=$NODE_RUNTIME_IMAGE"
|
||||||
|
# The build stage must fail closed when the tag no longer resolves to DSH_COMMIT.
|
||||||
|
expect Dockerfile "git -C /src rev-parse HEAD"
|
||||||
|
|
||||||
|
# Example gateway build base image
|
||||||
|
expect examples/dsh-agents-as-a-service/gateway/Dockerfile \
|
||||||
|
"ARG GATEWAY_PYTHON_IMAGE=$GATEWAY_PYTHON_IMAGE"
|
||||||
|
|
||||||
# Local configuration mirrored for humans
|
# Local configuration mirrored for humans
|
||||||
expect .env.sample "DSH_VERSION=$DSH_VERSION"
|
expect .env.sample "DSH_VERSION=$DSH_VERSION"
|
||||||
|
expect .env.sample "DSH_COMMIT=$DSH_COMMIT"
|
||||||
expect .env.sample "PNPM_VERSION=$PNPM_VERSION"
|
expect .env.sample "PNPM_VERSION=$PNPM_VERSION"
|
||||||
expect .env.sample "RELEASE_REVISION=$RELEASE_REVISION"
|
expect .env.sample "RELEASE_REVISION=$RELEASE_REVISION"
|
||||||
|
|
||||||
|
# Human-facing docs embed the resolved commit in the build example and table.
|
||||||
|
expect docs/main-image.md "$DSH_COMMIT"
|
||||||
|
expect docs/versioning.md "$DSH_COMMIT"
|
||||||
|
|
||||||
|
# Every container image is pinned to an immutable multi-arch index digest, not
|
||||||
|
# a mutable tag. This is a supply-chain invariant: don't "fix" a failing check
|
||||||
|
# by dropping the digest.
|
||||||
|
for image_var in NODE_BUILD_IMAGE NODE_RUNTIME_IMAGE GATEWAY_PYTHON_IMAGE OAUTH2_PROXY_IMAGE; do
|
||||||
|
image_value="${!image_var:-}"
|
||||||
|
if ! printf '%s' "$image_value" | grep -qE '@sha256:[0-9a-f]{64}$'; then
|
||||||
|
err "versions.env: $image_var must be digest-pinned (name:tag@sha256:<64 hex>), got: ${image_value:-<unset>}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! printf '%s' "${DSH_COMMIT:-}" | grep -qE '^[0-9a-f]{40}$'; then
|
||||||
|
err "versions.env: DSH_COMMIT must be a full 40-character commit SHA, got: ${DSH_COMMIT:-<unset>}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build entrypoints must pass DSH_COMMIT rather than trusting the tag alone.
|
||||||
|
expect Makefile "--build-arg DSH_COMMIT=\$(DSH_COMMIT)"
|
||||||
|
for wf in build-main.yaml release.yaml ci-image.yaml; do
|
||||||
|
expect ".github/workflows/$wf" "DSH_COMMIT="
|
||||||
|
done
|
||||||
|
|
||||||
# Image references in the example manifests
|
# Image references in the example manifests
|
||||||
for f in \
|
for f in \
|
||||||
examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml \
|
examples/dsh-agents-as-a-service/manifests/10-sandboxtemplate.yaml \
|
||||||
examples/minimal-single-tenant/manifests/sandbox-template.yaml; do
|
examples/minimal-single-tenant/manifests/10-sandboxtemplate.yaml; do
|
||||||
expect "$f" "image: $GHCR_IMAGE:$IDENTITY"
|
expect "$f" "image: $GHCR_IMAGE:$IDENTITY"
|
||||||
done
|
done
|
||||||
expect examples/dsh-agents-as-a-service/manifests/50-gateway.yaml "image: $GATEWAY_IMAGE:$IDENTITY"
|
expect examples/dsh-agents-as-a-service/manifests/50-gateway.yaml "image: $GATEWAY_IMAGE:$IDENTITY"
|
||||||
|
|
||||||
|
# The oauth2-proxy exposure variant pins the digest, not the mutable tag.
|
||||||
|
expect examples/dsh-agents-as-a-service/manifests/exposure/gateway-api-sso/72-oauth2-proxy.yaml \
|
||||||
|
"image: $OAUTH2_PROXY_IMAGE"
|
||||||
|
|
||||||
# The gateway image must live below the repository image (one package per
|
# The gateway image must live below the repository image (one package per
|
||||||
# example component, no unrelated top-level packages).
|
# example component, no unrelated top-level packages).
|
||||||
case "$GATEWAY_IMAGE" in
|
case "$GATEWAY_IMAGE" in
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
# "make check".
|
# "make check".
|
||||||
#
|
#
|
||||||
# ./scripts/set-version.sh dsh-v0.2.2-rc.1 # new DSH ref, no revision
|
# ./scripts/set-version.sh dsh-v0.2.2-rc.1 # new DSH ref, no revision
|
||||||
# ./scripts/set-version.sh dsh-v0.2.1-rc.3 r3 # same ref, new repo revision
|
# ./scripts/set-version.sh dsh-v0.2.1-rc.5-r6 r3 # same ref, new repo revision
|
||||||
|
#
|
||||||
|
# For a new DSH ref the script resolves the tag's commit with "git ls-remote"
|
||||||
|
# (peeled ^{} commit first, tag ref as fallback) and rewrites DSH_COMMIT in
|
||||||
|
# versions.env, the Dockerfile ARG default and .env.sample. A revision-only bump
|
||||||
|
# at the same ref keeps the existing commit and needs no network access.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
@@ -23,9 +28,11 @@ if [ -n "$new_revision" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# shellcheck source=/dev/null
|
# shellcheck source=/dev/null
|
||||||
old_version="$(. ./versions.env; printf '%s' "$DSH_VERSION")"
|
. ./versions.env
|
||||||
# shellcheck source=/dev/null
|
|
||||||
old_revision="$(. ./versions.env; printf '%s' "${RELEASE_REVISION:-}")"
|
old_version="$DSH_VERSION"
|
||||||
|
old_revision="${RELEASE_REVISION:-}"
|
||||||
|
old_commit="${DSH_COMMIT:-}"
|
||||||
|
|
||||||
old_identity="$old_version"
|
old_identity="$old_version"
|
||||||
[ -z "$old_revision" ] || old_identity="$old_version-$old_revision"
|
[ -z "$old_revision" ] || old_identity="$old_version-$old_revision"
|
||||||
@@ -37,6 +44,29 @@ if [ "$old_identity" = "$new_identity" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Resolve the commit the new upstream tag points at. Annotated tags expose a
|
||||||
|
# peeled ^{} line (the commit) and a tag-object line; prefer the peeled commit
|
||||||
|
# and fall back to the tag ref for lightweight tags. A repository-only revision
|
||||||
|
# bump keeps the already pinned commit, so it needs no network.
|
||||||
|
new_commit="$old_commit"
|
||||||
|
if [ "$new_version" != "$old_version" ] || [ -z "$new_commit" ]; then
|
||||||
|
if ! refs="$(git ls-remote --tags --exit-code "$DSH_REPO" \
|
||||||
|
"refs/tags/$new_version^{}" "refs/tags/$new_version")"; then
|
||||||
|
echo "set-version: upstream tag '$new_version' not found in $DSH_REPO" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
new_commit="$(printf '%s\n' "$refs" \
|
||||||
|
| awk -v t="refs/tags/$new_version^{}" '$2 == t {print $1}' | head -n1)"
|
||||||
|
if [ -z "$new_commit" ]; then
|
||||||
|
new_commit="$(printf '%s\n' "$refs" \
|
||||||
|
| awk -v t="refs/tags/$new_version" '$2 == t {print $1}' | head -n1)"
|
||||||
|
fi
|
||||||
|
if ! printf '%s' "$new_commit" | grep -qE '^[0-9a-f]{40}$'; then
|
||||||
|
echo "set-version: could not resolve a commit for '$new_version' in $DSH_REPO" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Replace the identity first (it contains the base version), then the base
|
# Replace the identity first (it contains the base version), then the base
|
||||||
# version itself. CHANGELOG.md is generated from tags, so it is left alone.
|
# version itself. CHANGELOG.md is generated from tags, so it is left alone.
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
@@ -48,17 +78,30 @@ while IFS= read -r file; do
|
|||||||
sed -i "s/$old_identity/$new_identity/g; s/$old_version/$new_version/g" "$file"
|
sed -i "s/$old_identity/$new_identity/g; s/$old_version/$new_version/g" "$file"
|
||||||
done < <(git ls-files)
|
done < <(git ls-files)
|
||||||
|
|
||||||
# versions.env is authoritative: write both keys explicitly so an empty
|
# versions.env is authoritative: write these keys explicitly so an empty
|
||||||
# revision is stored as an empty value.
|
# revision is stored as an empty value.
|
||||||
sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" versions.env
|
sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" versions.env
|
||||||
sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" versions.env
|
sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" versions.env
|
||||||
|
sed -i "s/^DSH_COMMIT=.*/DSH_COMMIT=$new_commit/" versions.env
|
||||||
|
|
||||||
# Values stored separately from the full identity: Dockerfile ARG defaults and
|
# Values stored separately from the full identity: Dockerfile ARG defaults and
|
||||||
# the human-facing sample cannot be reached by the identity replacement above.
|
# the human-facing sample cannot be reached by the identity replacement above.
|
||||||
sed -i "s/^ARG DSH_VERSION=.*/ARG DSH_VERSION=$new_version/" Dockerfile
|
sed -i "s/^ARG DSH_VERSION=.*/ARG DSH_VERSION=$new_version/" Dockerfile
|
||||||
sed -i "s/^ARG RELEASE_REVISION=.*/ARG RELEASE_REVISION=$new_revision/" Dockerfile
|
sed -i "s/^ARG RELEASE_REVISION=.*/ARG RELEASE_REVISION=$new_revision/" Dockerfile
|
||||||
|
sed -i "s/^ARG DSH_COMMIT=.*/ARG DSH_COMMIT=$new_commit/" Dockerfile
|
||||||
sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" .env.sample
|
sed -i "s/^DSH_VERSION=.*/DSH_VERSION=$new_version/" .env.sample
|
||||||
sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" .env.sample
|
sed -i "s/^RELEASE_REVISION=.*/RELEASE_REVISION=$new_revision/" .env.sample
|
||||||
|
sed -i "s/^DSH_COMMIT=.*/DSH_COMMIT=$new_commit/" .env.sample
|
||||||
|
|
||||||
|
# The human-facing build examples embed the full SHA, which the version
|
||||||
|
# replacement above cannot see. Keep them in step; check-versions.sh verifies.
|
||||||
|
if [ -n "$old_commit" ] && [ "$old_commit" != "$new_commit" ]; then
|
||||||
|
for doc in docs/main-image.md docs/versioning.md; do
|
||||||
|
[ -f "$doc" ] || continue
|
||||||
|
sed -i "s/$old_commit/$new_commit/g" "$doc"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "set-version: $old_identity -> $new_identity"
|
echo "set-version: $old_identity -> $new_identity"
|
||||||
|
echo "set-version: DSH_COMMIT=$new_commit"
|
||||||
echo "set-version: run 'make check' and review the diff"
|
echo "set-version: run 'make check' and review the diff"
|
||||||
|
|||||||
32
versions.env
32
versions.env
@@ -2,23 +2,41 @@
|
|||||||
# repository. Consumed by the Makefile, build.sh, scripts/*.sh and the GitHub
|
# repository. Consumed by the Makefile, build.sh, scripts/*.sh and the GitHub
|
||||||
# Actions workflows. Never hard-code these values elsewhere: run "make check"
|
# Actions workflows. Never hard-code these values elsewhere: run "make check"
|
||||||
# (scripts/check-versions.sh) to verify every pin.
|
# (scripts/check-versions.sh) to verify every pin.
|
||||||
|
#
|
||||||
|
# Every container image below is pinned to an immutable multi-arch index digest
|
||||||
|
# (name:tag@sha256:...), not just a mutable tag. Bump the tag and the digest
|
||||||
|
# together; scripts/check-versions.sh requires the digest form.
|
||||||
|
|
||||||
# Upstream DeepSeek Harness source, and the ref this repository packages.
|
# Upstream DeepSeek Harness source, and the ref this repository packages.
|
||||||
|
# DSH_COMMIT is the full commit SHA that DSH_VERSION resolves to upstream; the
|
||||||
|
# Dockerfile verifies the clone's HEAD against it and fails the build on drift.
|
||||||
DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git
|
DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git
|
||||||
DSH_VERSION=dsh-v0.2.1-rc.3
|
DSH_VERSION=dsh-v0.2.1-rc.5
|
||||||
|
DSH_COMMIT=b830ec950faba0a46dda564be3e2d854afed94f1
|
||||||
PNPM_VERSION=11.28.5
|
PNPM_VERSION=11.28.5
|
||||||
|
|
||||||
# Release identity = DSH_VERSION plus an optional repository revision.
|
# Release identity = DSH_VERSION plus an optional repository revision.
|
||||||
# An empty RELEASE_REVISION is the first release at this DSH ref; rN (N >= 2)
|
# An empty RELEASE_REVISION is the first release at this DSH ref; rN (N >= 2)
|
||||||
# marks a repository-only re-release at the same DSH ref. The dsh-v0.2.1-rc.3
|
# marks a repository-only re-release at the same DSH ref. This is the first
|
||||||
# image tag is already published on GHCR and tags are immutable, so the current
|
# release at dsh-v0.2.1-rc.5-r6, so the identity is the plain tag; a later
|
||||||
# scheme started at -r2; the current identity is dsh-v0.2.1-rc.3-r4.
|
# repository-only change at the same ref becomes -r2.
|
||||||
RELEASE_REVISION=r4
|
RELEASE_REVISION=r6
|
||||||
|
|
||||||
|
# Base images for the main DSH Web image (root Dockerfile), pinned by digest.
|
||||||
|
NODE_BUILD_IMAGE=docker.io/node:24-trixie@sha256:8c761eca2bf7eec965f946cf47b8090143cb41f45f2f8699af0cc0298d2d2d19
|
||||||
|
NODE_RUNTIME_IMAGE=docker.io/node:24-trixie-slim@sha256:54f84b4cf8432b21aaffd5d3ce8d41e12cea0d10d33c09d2f3c9879d49d6b446
|
||||||
|
|
||||||
|
# Base image for the example gateway (examples/dsh-agents-as-a-service/gateway).
|
||||||
|
GATEWAY_PYTHON_IMAGE=docker.io/python:3.12-slim@sha256:aa943ce411423c7582288f18707ff66eddcbaeb510502d747a72aeb34f30d159
|
||||||
|
|
||||||
# Control-plane versions used by the examples and their manifests.
|
# Control-plane versions used by the examples and their manifests.
|
||||||
AGENT_SANDBOX_VERSION=v1.0.7
|
# The matching SHA-256s pin the release manifests that CI downloads and
|
||||||
|
# applies; update both the version and the digest when bumping either pin.
|
||||||
|
AGENT_SANDBOX_VERSION=v1.0.9
|
||||||
|
AGENT_SANDBOX_MANIFEST_SHA256=4bf9ee5558e9a663f91cfa5963e96f2591bfaa1dcf79f03bfab65bc5d65cc699
|
||||||
GATEWAY_API_VERSION=v1.2.1
|
GATEWAY_API_VERSION=v1.2.1
|
||||||
OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy:v7.15.5
|
GATEWAY_API_MANIFEST_SHA256=97598bf6ab3b33b9b5c5432bdd24de091e4e9c3aa0575ebb0710a2a19cd64d64
|
||||||
|
OAUTH2_PROXY_IMAGE=quay.io/oauth2-proxy/oauth2-proxy:v7.15.5@sha256:8498b0d0ef0a7b29686414000a08aee467f02d0299c9ed1e006a8f33fc017916
|
||||||
|
|
||||||
# Registry layout: the main image is the repository itself, and every example
|
# Registry layout: the main image is the repository itself, and every example
|
||||||
# component is a nested package below it (<registry>/<owner>/<repo>/<example>/<component>).
|
# component is a nested package below it (<registry>/<owner>/<repo>/<example>/<component>).
|
||||||
|
|||||||
Reference in New Issue
Block a user