diff --git a/.env.sample b/.env.sample index 136846d..c53f389 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,4 @@ DEEPSEEK_API_KEY= DSH_VERSION=dsh-v0.2.1-rc.2 -PNPM_VERSION=11.28.5 \ No newline at end of file +PNPM_VERSION=11.28.5 +DSH_PORT=3080 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..821abeb --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @ngeorger diff --git a/.github/workflows/multi-build.yaml b/.github/workflows/multi-build.yaml new file mode 100644 index 0000000..dc7dd5f --- /dev/null +++ b/.github/workflows/multi-build.yaml @@ -0,0 +1,283 @@ +# This workflow builds a multi-arch Docker image using GitHub Actions and separated Github Runners with native support for ARM64 and AMD64 architectures, without using QEMU emulation. +# It uses Docker Buildx to build and push the image to GitHub Container Registry (GHCR). +name: Build multi arch Docker Image with separate Github Runners + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'Dockerfile' + - '.github/workflows/multi-build.yaml' +env: + # The name of the Docker image to be built and pushed to GHCR + # The image name is derived from the GitHub repository name and the GitHub Container Registry (GHCR) URL. + # The image name will be in the format: ghcr.io// + GHCR_IMAGE: ghcr.io/${{ github.repository }} + DSH_VERSION: dsh-v0.2.1-rc.2 #TODO: Make this configurable from workflow inputs and/or an custom Github Action +permissions: + # Global permissions for the workflow, which can be overridden at the job level + contents: read + +concurrency: + # This concurrency group ensures that only one job in the group runs at a time. + # If a new job is triggered, the previous one will be canceled. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The build job builds the Docker image for each platform specified in the matrix. + build: + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + # The matrix includes two platforms: linux/amd64 and linux/arm64. + # The build job will run for each platform in the matrix. + + permissions: + # Permissions for the build job, which can be overridden at the step level + # The permissions are set to allow the job to write to the GitHub Container Registry (GHCR) and read from the repository. + attestations: write + actions: read + checks: write + contents: write + deployments: none + id-token: write + issues: read + discussions: read + packages: write + pages: none + pull-requests: read + repository-projects: read + security-events: read + statuses: read + + runs-on: ${{ matrix.platform == 'linux/amd64' && 'ubuntu-latest' || matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' }} + # The job runs on different runners based on the platform. + # For linux/amd64, it runs on the latest Ubuntu runner. + # For linux/arm64, it runs on an Ubuntu 24.04 ARM runner. + # The runner is selected based on the platform specified in the matrix. + + name: Build Docker image for ${{ matrix.platform }} + + steps: + - + name: Prepare environment for current platform + # This step sets up the environment for the current platform being built. + # It replaces the '/' character in the platform name with '-' and sets it as an environment variable. + # This is useful for naming artifacts and other resources that cannot contain '/'. + # The environment variable PLATFORMS_PAIR will be used later in the workflow. + id: prepare + run: | + platform=${{ matrix.platform }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # This step checks out the code from the repository. + # It uses the actions/checkout action to clone the repository into the runner's workspace. + + - name: Docker meta default + # This step generates metadata for the Docker image. + # It uses the docker/metadata-action to create metadata based on the repository information. + # The metadata includes information such as the image name, tags, and labels. + # The metadata will be used later in the workflow to build and push the Docker image. + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.GHCR_IMAGE }} + + + - name: Set up Docker Context for Buildx + # This step sets up a Docker context for Buildx. + # It creates a new context named "builders" that will be used for building the Docker image. + # The context allows Buildx to use the Docker daemon for building images. + id: buildx-context + run: | + docker context create builders + + - name: Set up Docker Buildx + # This step sets up Docker Buildx, which is a Docker CLI plugin for extended build capabilities with BuildKit. + # It uses the docker/setup-buildx-action to configure Buildx with the specified context and platforms. + # The platforms are specified in the matrix and will be used for building the Docker image. + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + with: + endpoint: builders + platforms: ${{ matrix.platform }} + + - name: Login to GitHub Container Registry + # This step logs in to the GitHub Container Registry (GHCR) using the docker/login-action. + # It uses the GitHub actor's username and the GITHUB_TOKEN secret for authentication. + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + + - name: Build and push by digest + # This step builds and pushes the Docker image using Buildx. + # It uses the docker/build-push-action to build the image with the specified context and platforms. + # The image is built with the labels and annotations generated in the previous steps. + # The outputs are configured to push the image by digest, which allows for better caching and versioning. + # The cache-from and cache-to options are used to enable caching for the build process. + # The cache is stored in GitHub Actions cache and is scoped to the repository, branch, and platform. + id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + env: + DOCKER_BUILDKIT: 1 + DSH_VERSION: dsh-v0.2.1-rc.2 #TODO: Make this configurable from workflow inputs and/or an custom Github Action + PNPM_VERSION: 11.28.5 + DSH_PORT: 3080 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + build-args: DSH_VERSION=${{ env.DSH_VERSION }} + annotations: ${{ steps.meta.outputs.annotations }} + outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true + cache-from: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }} + cache-to: type=gha,scope=${{ github.repository }}-${{ github.ref_name }}-${{ matrix.platform }} + + + - name: Export digest + # This step exports the digest of the built image to a file. + # It creates a directory in /tmp/digests and saves the digest of the image to a file. + # The digest is obtained from the output of the build step. + # The digest is used to uniquely identify the built image and can be used for further processing or verification. + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + # This step uploads the digest file to the GitHub Actions artifact storage. + # It uses the actions/upload-artifact action to upload the file created in the previous step. + # The artifact is named digests-${{ env.PLATFORM_PAIR }}, where PLATFORM_PAIR is the platform name with '/' replaced by '-'. + # The artifact is retained for 1 day, and if no files are found, it will throw an error. + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + + merge: + # This job merges the Docker manifests for the different platforms built in the previous job. + name: Merge Docker manifests + runs-on: ubuntu-latest + permissions: + attestations: write + actions: read + checks: read + contents: read + deployments: none + id-token: write + issues: read + discussions: read + packages: write + pages: none + pull-requests: read + repository-projects: read + security-events: read + statuses: read + + needs: + - build + # This job depends on the build job to complete before it starts. + # It ensures that the Docker images for all platforms are built before merging the manifests. + steps: + - name: Download digests + # This step downloads the digest files uploaded in the build job. + # It uses the actions/download-artifact action to download the artifacts with the pattern digests-*. + # The downloaded files are merged into the /tmp/digests directory. + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + + - name: Docker meta + # This step generates metadata for the Docker image. + # It uses the docker/metadata-action to create metadata based on the repository information. + # The metadata includes information such as the image name, tags, and labels. + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + env: + DSH_VERSION: ${{ env.DSH_VERSION }} + with: + images: ${{ env.GHCR_IMAGE }} + annotations: | + type=org.opencontainers.image.description,value=${{ github.event.repository.description || 'No description provided' }} + tags: | + type=raw,value=main,enable=${{ github.ref_name == 'main' }} + type=raw,value=latest,enable=${{ github.ref_name == 'main' }} + type=raw,value=${{ env.DSH_VERSION }},enable=${{ github.ref_name == 'main' }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + # This step sets up Docker Buildx, which is a Docker CLI plugin for extended build capabilities with BuildKit. + with: + driver-opts: | + network=host + + - name: Login to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + # This step logs in to the GitHub Container Registry (GHCR) using the docker/login-action. + # It uses the GitHub actor's username and the GITHUB_TOKEN secret for authentication. + # The login is necessary to push the merged manifest list to GHCR. + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Get execution timestamp with RFC3339 format + # This step gets the current execution timestamp in RFC3339 format. + # It uses the date command to get the current UTC time and formats it as a string. + # The timestamp is used for annotating the Docker manifest list. + id: timestamp + run: | + echo "timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> $GITHUB_OUTPUT + + - name: Create manifest list and pushs + # This step creates a manifest list for the Docker images built for different platforms. + # It uses the docker buildx imagetools create command to create the manifest list. + # The manifest list is annotated with metadata such as description, creation timestamp, and source URL. + # The annotations are obtained from the metadata generated in the previous steps. + # The manifest list is pushed to the GitHub Container Registry (GHCR) with the specified tags. + working-directory: /tmp/digests + id: manifest-annotate + continue-on-error: true + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + --annotation='index:org.opencontainers.image.description=${{ github.event.repository.description }}' \ + --annotation='index:org.opencontainers.image.created=${{ steps.timestamp.outputs.timestamp }}' \ + --annotation='index:org.opencontainers.image.url=${{ github.event.repository.url }}' \ + --annotation='index:org.opencontainers.image.source=${{ github.event.repository.url }}' \ + $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *) + + - name: Create manifest list and push without annotations + # This step creates a manifest list for the Docker images built for different platforms. + # It uses the docker buildx imagetools create command to create the manifest list. + # The manifest list is created without annotations if the previous step fails. + # The manifest list is pushed to the GitHub Container Registry (GHCR) with the specified tags. + if: steps.manifest-annotate.outcome == 'failure' + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *) + + - name: Inspect image + # This step inspects the created manifest list to verify its contents. + # It uses the docker buildx imagetools inspect command to display information about the manifest list. + # The inspection output will show the platforms and tags associated with the manifest list. + id: inspect + run: | + docker buildx imagetools inspect '${{ env.GHCR_IMAGE }}:${{ steps.meta.outputs.version }}' diff --git a/Dockerfile b/Dockerfile index 0fdba00..95ff466 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,9 +48,9 @@ FROM ${NODE_RUNTIME_IMAGE} AS runtime ARG DSH_VERSION -LABEL org.opencontainers.image.title="DeepSeek Harness Web" \ +LABEL org.opencontainers.image.title="DeepSeek Harness Web packaged by SREDevOps.org" \ org.opencontainers.image.version="${DSH_VERSION}" \ - org.opencontainers.image.source="https://github.com/deepseek-ai/deepseek-harness" + org.opencontainers.image.source="https://github.com/sredevopsorg/deepseek-kubernetes-sandbox" ENV NODE_ENV=production \ HOME=/home/node \ diff --git a/README.md b/README.md index 9cde3c2..970b37d 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,178 @@ -Here is the complete updated Dockerfile incorporating the hoisted deploy fix and the dependency patch. The key changes versus the last version are in the deploy block: `--legacy` is dropped, `node-linker=hoisted` and `inject-workspace-packages` are enabled, and the missing workspace peer is added via the `workspace:*` protocol. +# deepseek-kubernetes-sandbox based on hermes-agents-as-a-service -```dockerfile -# syntax=docker/dockerfile:1.7 +Original: https://github.com/kubernetes-sigs/agent-sandbox/tree/main/examples/hermes-agents-as-a-service -ARG NODE_BUILD_IMAGE=docker.io/node:24-trixie -ARG NODE_RUNTIME_IMAGE=docker.io/node:trixie-slim +After analyzing the `hermes-agents-as-a-service` example, its core pattern maps directly onto your `dsh-web` container. The example's key mechanisms are: **`SandboxTemplate` defines a reusable Pod blueprint, `SandboxWarmPool` pre-warms instances, `SandboxClaim` lets each user claim a persistent Sandbox, and `service: true` gives each Sandbox a stable network identity for gateway routing.** Your DSH container is a strong fit for this model — it needs a persistent `DSH_HOME` (like Hermes needs a persistent data directory) and exposes a web port. The main differences are how secrets are injected and how the port is surfaced to end users. -# --------------------------------------------------------------------------- -# Build stage -# --------------------------------------------------------------------------- -FROM ${NODE_BUILD_IMAGE} AS build +### 🔑 Mechanism Mapping: Hermes → DSH -ARG DSH_REPO=https://github.com/deepseek-ai/deepseek-harness.git -ARG DSH_VERSION=dsh-v0.2.1-rc.2 -ARG PNPM_VERSION=11.28.5 +| Hermes example mechanism | Purpose | Adaptation for DSH | +| :--- | :--- | :--- | +| **`SandboxTemplate`** | Reusable Pod blueprint | Change image to `dsh-web:0.2.1-rc.2`, expose port `3080` | +| **`volumeClaimTemplates`** | Auto-create a PVC per user, persisting state | Mount point becomes `/home/node/.dsh` (DSH's state directory) | +| **`SandboxWarmPool`** | Pre-warmed instances for sub-second provisioning | Keep as-is; size the pool for expected concurrent users | +| **`SandboxClaim` + `warmPoolRef`** | User "registration" claims a lightweight object, adopts a pooled instance | Keep exactly as-is; one Claim per DSH user | +| **`service: true`** | Creates a headless Service per Sandbox with a stable DNS name | **Enable it** so a front-end gateway or Ingress can reliably route to each user's DSH Web | +| **`envVarsInjectionPolicy`** | Controls whether a Claim may inject/override env vars | DSH needs `DEEPSEEK_API_KEY` injected, so either allow it or reference a Secret from the template | +| **`operatingMode`** | Cost switch: `Running` / `Suspended` (deletes Pod, keeps PVC + Service) | Reuse directly — suspend idle DSH instances to save cost | -# CI=true skips the repository's lefthook postinstall in the container. -ENV CI=true \ - NODE_OPTIONS=--max-old-space-size=4096 +### 📦 Equivalent Kubernetes Manifests -RUN corepack enable \ - && corepack prepare "pnpm@${PNPM_VERSION}" --activate +The manifests below mirror the Hermes example's organization. Apply them in order, all in a dedicated namespace (e.g. `dsh-demo`). -WORKDIR /src +#### 1. Secret: DSH API key -# Exact upstream tag, e.g. dsh-v0.2.1-rc.2. -RUN git clone --depth 1 --branch "${DSH_VERSION}" "${DSH_REPO}" . - -RUN pnpm install --frozen-lockfile - -# ── Workaround for upstream packaging defect ────────────────────────────── -# dsh-app-boot statically imports @deepseek-ai/cordis-plugin-group, which is -# only a peer/workspace dependency and is omitted by `pnpm deploy --prod`. -# Promote it to a real dependency of @deepseek-ai/dsh so it is always part of -# the deploy closure. -RUN pnpm --filter @deepseek-ai/dsh add @deepseek-ai/cordis-plugin-group@workspace:* -# ────────────────────────────────────────────────────────────────────────── - -# Builds native addon, Host/Client bundles, and the Web frontend. -RUN pnpm run build - -# ── Production deploy closure ──────────────────────────────────────────── -# A hoisted (npm-style) node_modules is required: the default isolated pnpm -# layout drops peer dependencies such as @deepseek-ai/cosmokit from the -# deployed tree, causing ERR_MODULE_NOT_FOUND at runtime. `--legacy` is -# intentionally NOT used, so pnpm's modern deploy binds peers correctly. -RUN pnpm --config.node-linker=hoisted \ - --config.inject-workspace-packages=true \ - deploy --prod /opt/dsh \ - && test -f /opt/dsh/lib/bin.js \ - && test -d /opt/dsh/node_modules/@deepseek-ai/cosmokit \ - && test -d /opt/dsh/node_modules/@deepseek-ai/cordis-plugin-group \ - && node /opt/dsh/lib/bin.js web --help >/dev/null -# ────────────────────────────────────────────────────────────────────────── - -# --------------------------------------------------------------------------- -# Runtime stage -# --------------------------------------------------------------------------- -FROM ${NODE_RUNTIME_IMAGE} AS runtime - -ARG DSH_VERSION - -LABEL org.opencontainers.image.title="DeepSeek Harness Web" \ - org.opencontainers.image.version="${DSH_VERSION}" \ - org.opencontainers.image.source="https://github.com/deepseek-ai/deepseek-harness" - -ENV NODE_ENV=production \ - HOME=/home/node \ - DSH_HOME=/home/node/.dsh \ - DSH_PORT=3080 \ - PATH="/app/node_modules/.bin:${PATH}" - -# git/ripgrep are useful for DSH agent tools; tini handles signals. -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - ca-certificates \ - git \ - ripgrep \ - tini \ - && rm -rf /var/lib/apt/lists/* \ - && install -d -o node -g node -m 0755 /workspace "${DSH_HOME}" - -COPY --from=build --chown=node:node /opt/dsh /app - -COPY <<'EOF' /usr/local/bin/dsh-entrypoint -#!/bin/sh -set -eu - -: "${DSH_PORT:=3080}" -: "${DSH_HOST:=}" -DSH_PUBLIC_URL="${DSH_PUBLIC_URL-http://localhost:${DSH_PORT}}" - -# DSH rejects wildcard hosts. Pick the container/pod's first non-loopback IPv4. -if [ -z "$DSH_HOST" ]; then - DSH_HOST="$(node -e 'const os=require("os"); const ifaces=Object.values(os.networkInterfaces()).flat(); const addr=ifaces.find(v=>v&&v.family==="IPv4"&&!v.internal); if(!addr){process.exit(1)} process.stdout.write(addr.address)')" -fi - -if [ -n "$DSH_PUBLIC_URL" ]; then - set -- --public-url "$DSH_PUBLIC_URL" "$@" -fi - -exec node /app/lib/bin.js web \ - --no-open \ - --host "$DSH_HOST" \ - --port "$DSH_PORT" \ - --trusted-host localhost \ - --trusted-host 127.0.0.1 \ - "$@" -EOF - -RUN chmod 0755 /usr/local/bin/dsh-entrypoint - -WORKDIR /workspace -USER node - -EXPOSE 3080 -VOLUME ["/home/node/.dsh"] -STOPSIGNAL SIGTERM -ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/dsh-entrypoint"] +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: dsh-api-secret + namespace: dsh-demo +type: Opaque +stringData: + DEEPSEEK_API_KEY: "sk-..." # replace with a real key ``` -### What changed and why +#### 2. SandboxTemplate: DSH Web blueprint -| Area | Before | Now | Reason | -| ------ | -------- | ----- | -------- | -| Deploy linker | default isolated `.pnpm` store | `--config.node-linker=hoisted` | Produces a flat npm-style tree so peer deps like `@deepseek-ai/cosmokit` are physically present. | -| Deploy flag | `--legacy` | removed | Legacy deployer does not reliably bind peer dependencies; the modern deployer does. | -| Workspace binding | implicit | `--config.inject-workspace-packages=true` | Guarantees workspace packages and their peers are bound into the closure deterministically. | -| Missing peer patch | `@deepseek-ai/cordis-plugin-group@^1.0.1` | `@deepseek-ai/cordis-plugin-group@workspace:*` | Correct protocol for a monorepo-internal package; no dependency on a published npm version. | -| Verification | only `bin.js` exists + `web --help` | also asserts `cosmokit` and `cordis-plugin-group` directories exist | Fails the build early if the closure is still incomplete, instead of failing at container start. | +This is the core resource, corresponding to `10-sandboxtemplate.yaml` in the example. Key settings: **`service: true`** for stable DNS, **`volumeClaimTemplates`** to persist `/home/node/.dsh`, and a security context that runs as the non-root `node` user (UID 1000). -### Build and run +```yaml +apiVersion: extensions.agents.x-k8s.io/v1beta1 +kind: SandboxTemplate +metadata: + name: dsh-web-template + namespace: dsh-demo +spec: + service: true # headless Service per Sandbox → stable DNS + podTemplate: + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: dsh-web + image: dsh-web:0.2.1-rc.2 # replace with your registry path + ports: + - containerPort: 3080 + name: web + env: + - name: DEEPSEEK_API_KEY + valueFrom: + secretKeyRef: + name: dsh-api-secret + key: DEEPSEEK_API_KEY + - name: DSH_PUBLIC_URL + value: "http://localhost:3080" # change to the external URL when behind a gateway/Ingress + volumeMounts: + - name: dsh-home + mountPath: /home/node/.dsh + readinessProbe: + httpGet: + path: / + port: 3080 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: 3080 + initialDelaySeconds: 15 + periodSeconds: 20 + # If your cluster uses gVisor / Kata, set the runtime class here + # runtimeClassName: gvisor + volumeClaimTemplates: + - metadata: + name: dsh-home + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 5Gi # adjust to your DSH state size +``` + +#### 3. SandboxWarmPool: pre-warmed pool + +Corresponds to `20-sandboxwarmpool.yaml`. Pooled Sandboxes pull the image and boot ahead of time; a user Claim adopts one instantly. + +```yaml +apiVersion: extensions.agents.x-k8s.io/v1beta1 +kind: SandboxWarmPool +metadata: + name: dsh-web-pool + namespace: dsh-demo +spec: + replicas: 2 # number of pre-warmed instances + sandboxTemplateRef: + name: dsh-web-template +``` + +#### 4. SandboxClaim: per-user claim + +Corresponds to `40-claim.yaml`. Each user creates one lightweight Claim and receives a pooled instance with its own PVC and stable DNS. + +```yaml +apiVersion: extensions.agents.x-k8s.io/v1beta1 +kind: SandboxClaim +metadata: + name: user-alice-dsh + namespace: dsh-demo +spec: + sandboxTemplateRef: + name: dsh-web-template + warmPoolRef: + name: dsh-web-pool + # If the template allows env injection (envVarsInjectionPolicy: Allowed), + # you can override/add variables here: + # env: + # - name: DSH_PUBLIC_URL + # value: "https://alice.dsh.example.com" +``` + +### 🌐 Networking: How Users Reach DSH Web + +Unlike Hermes agents, which are mainly consumed by an internal gateway, DSH Web is a **user-facing web UI** — each Sandbox's `3080` port must be reachable by end users. Two patterns: + +#### Pattern A: Direct via headless Service (simple) + +With `service: true`, each Sandbox gets a headless Service whose DNS name is exposed in the Sandbox's `status.serviceFQDN`. You can reach it from inside the cluster via that FQDN, or add an Ingress/upper-layer gateway that routes a path to the corresponding Service. + +#### Pattern B: Gateway-proxied (recommended, mirrors Hermes's service layer) + +The Hermes example ships a ~250-line Python gateway (`gateway/gateway.py`) that handles **per-user routing, auth, and auto-waking suspended Sandboxes**. You can build a simpler equivalent for DSH: + +1. Accept external requests (e.g. `/u/{user}/**`). +2. Look up the `status.serviceFQDN` of that user's Sandbox. +3. Reverse-proxy to `http://{serviceFQDN}:3080`. +4. If the Sandbox is `Suspended`, flip its `operatingMode` back to `Running`, wait for `Ready`, then proxy. + +If you don't want a gateway yet, you can manually create one Ingress per user pointing at their Service — less automated, but functional. + +### ⚙️ Key Differences and Caveats + +1. **Secret-injection policy.** The Hermes example sets `envVarsInjectionPolicy: Disallowed` to strictly limit what Claims can inject. DSH needs `DEEPSEEK_API_KEY` injected, so you have two choices: + - **Set it to `Allowed`**, then reference the Secret from `SandboxClaim.env` (more flexible, but requires trusting Claim creators). + - **Keep it `Disallowed`** and reference the Secret directly inside the `SandboxTemplate`'s `podTemplate` (stricter, but all users share the same key — fine for a single-tenant internal setup). + Pick based on your multi-tenancy model. + +2. **`DSH_PUBLIC_URL` configuration.** Your entrypoint defaults to `http://localhost:3080`. When accessed via a headless Service or gateway, set `DSH_PUBLIC_URL` to the real external URL (e.g. `https://alice.dsh.example.com`), otherwise links DSH generates may be unreachable. You can override it in `SandboxClaim.env` (if injection is allowed) or rewrite URLs in the gateway. + +3. **Image pull and platform.** As with the Hermes example, push `dsh-web` to a registry reachable by the cluster. Your image is built for `amd64`; node architecture must match. + +4. **PVC size.** DSH state size (sessions, configs, etc.) depends on usage; Hermes allocated a PVC for `/opt/data`. Adjust the `storage` request in `volumeClaimTemplates` accordingly. + +5. **Auto-suspend via `operatingMode`.** Hermes's gateway includes an idle sweeper that suspends idle instances automatically. If your DSH gateway implements a similar sweeper, you get the same cost model: `Running` while active, `Suspended` when idle (PVC + Service retained), auto-woken when the user returns. + +### ✅ Deployment Order ```bash -docker build \ - --build-arg DSH_VERSION=dsh-v0.2.1-rc.2 \ - -t dsh-web:0.2.1-rc.2 . - -docker run --rm -it \ - -p 3080:3080 \ - -v dsh-home:/home/node/.dsh \ - -e DEEPSEEK_API_KEY \ - dsh-web:0.2.1-rc.2 +kubectl create namespace dsh-demo +kubectl apply -f 00-secret.yaml +kubectl apply -f 10-sandboxtemplate.yaml +kubectl apply -f 20-sandboxwarmpool.yaml +kubectl apply -f 40-claim.yaml # one per user ``` -Then open the `dsh web:` URL printed in the logs (it carries a per-process access token). - -### If the `pnpm add …@workspace:*` step ever fails - -That would mean `@deepseek-ai/cordis-plugin-group` is not a workspace package in the chosen tag. In that case remove the two `pnpm --filter … add` lines; the hoisted linker alone is usually sufficient to include peer dependencies. You can confirm by checking that the `test -d /opt/dsh/node_modules/@deepseek-ai/cordis-plugin-group` assertion still passes. +After applying, watch the warm pool spin up with `kubectl -n dsh-demo get sandboxes -w`, and inspect adoption and `status.serviceFQDN` with `kubectl -n dsh-demo get sandboxclaim user-alice-dsh -o yaml`. At that point you have a platform structurally identical to the Hermes example, but running DSH Web for multiple users. diff --git a/deploy/k8s/sandbox-template.yaml b/deploy/k8s/sandbox-template.yaml new file mode 100644 index 0000000..230f304 --- /dev/null +++ b/deploy/k8s/sandbox-template.yaml @@ -0,0 +1,30 @@ +apiVersion: extensions.agents.x-k8s.io/v1alpha1 +kind: SandboxTemplate +metadata: + name: dsh-web-template + namespace: default +spec: + podTemplate: + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 # Matches the UID of the 'node' user + runAsGroup: 1000 + containers: + - name: dsh-web + image: ghcr.io/sredevopsorg/deepseek-kubernetes-sandbox/deepseek-kubernetes-sandbox:0.2.1-rc.2 + ports: + - containerPort: 3080 + env: + - name: DEEPSEEK_API_KEY + valueFrom: + secretKeyRef: + name: deepseek-secret + key: api-key + volumeMounts: + - name: dsh-home + mountPath: /home/node/.dsh + volumes: + - name: dsh-home + persistentVolumeClaim: + claimName: dsh-home-pvc # You will need to create this PVC \ No newline at end of file