fix(docker): overhaul fragile setup, adopt proven container patterns (#2716)
Split fake multi-stage Dockerfile into independent CI builder and runtime images. Add proper entrypoint with UID/GID remapping via PUID/PGID, sentinel-based first-boot bootstrap, pre-creation of critical file targets, and signal-forwarding privilege drop via gosu. Standardize on Node 24, split compose into minimal + full reference. Closes #9
This commit is contained in:
parent
a952391b33
commit
0e07c647c5
11 changed files with 299 additions and 76 deletions
25
Dockerfile
25
Dockerfile
|
|
@ -1,30 +1,9 @@
|
|||
# ──────────────────────────────────────────────
|
||||
# Stage 1: CI Builder
|
||||
# Image: ghcr.io/gsd-build/gsd-ci-builder
|
||||
# Used by: pipeline.yml Dev stage
|
||||
# ──────────────────────────────────────────────
|
||||
FROM node:24-bookworm AS builder
|
||||
|
||||
# Rust toolchain (stable, minimal profile)
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Cross-compilation for linux-arm64
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc-aarch64-linux-gnu \
|
||||
g++-aarch64-linux-gnu \
|
||||
&& rustup target add aarch64-unknown-linux-gnu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Verify toolchain
|
||||
RUN node --version && rustc --version && cargo --version
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Stage 2: Runtime
|
||||
# Runtime
|
||||
# Image: ghcr.io/gsd-build/gsd-pi
|
||||
# Used by: end users via docker run
|
||||
# ──────────────────────────────────────────────
|
||||
FROM node:24-slim AS runtime
|
||||
FROM node:24-slim
|
||||
|
||||
# Git is required for GSD's git operations
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@
|
|||
# Copy this file to .env and fill in your keys.
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# ── Container User Identity ──
|
||||
# Match your host UID/GID to avoid permission issues on bind mounts.
|
||||
# Run `id -u` and `id -g` on your host to find the right values.
|
||||
PUID=1000
|
||||
PGID=1000
|
||||
|
||||
# ── LLM Provider API Keys (at least one required) ──
|
||||
|
||||
# Anthropic (Claude)
|
||||
|
|
|
|||
20
docker/Dockerfile.ci-builder
Normal file
20
docker/Dockerfile.ci-builder
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# ──────────────────────────────────────────────
|
||||
# CI Builder
|
||||
# Image: ghcr.io/gsd-build/gsd-ci-builder
|
||||
# Used by: pipeline.yml Dev stage
|
||||
# ──────────────────────────────────────────────
|
||||
FROM node:24-bookworm
|
||||
|
||||
# Rust toolchain (stable, minimal profile)
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Cross-compilation for linux-arm64
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc-aarch64-linux-gnu \
|
||||
g++-aarch64-linux-gnu \
|
||||
&& rustup target add aarch64-unknown-linux-gnu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Verify toolchain
|
||||
RUN node --version && rustc --version && cargo --version
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
# Purpose: Isolated environment for GSD auto mode
|
||||
# Usage: docker sandbox create --template ./docker
|
||||
# ──────────────────────────────────────────────
|
||||
FROM node:22-bookworm-slim
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
# System dependencies required by GSD
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
|
@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
curl \
|
||||
ca-certificates \
|
||||
openssh-client \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install GSD globally — version controlled via build arg
|
||||
|
|
@ -29,10 +30,13 @@ RUN mkdir -p /home/gsd/.gsd && chown -R gsd:gsd /home/gsd/.gsd
|
|||
WORKDIR /workspace
|
||||
RUN chown gsd:gsd /workspace
|
||||
|
||||
USER gsd
|
||||
# Entrypoint handles UID/GID remapping, bootstrap, and drops to gsd user
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY bootstrap.sh /usr/local/bin/bootstrap.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/bootstrap.sh
|
||||
|
||||
# Expose default GSD web UI port
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["gsd"]
|
||||
CMD ["--help"]
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["gsd", "--help"]
|
||||
|
|
|
|||
|
|
@ -7,6 +7,22 @@ Run GSD auto mode inside an isolated Docker sandbox so it cannot touch your host
|
|||
- Docker Desktop 4.58+ (macOS or Windows; Linux support is experimental)
|
||||
- At least one LLM provider API key
|
||||
|
||||
## Docker Images
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Dockerfile.sandbox` | Runtime sandbox with entrypoint (UID remapping, bootstrap) |
|
||||
| `Dockerfile.ci-builder` | CI builds — includes build tools, no entrypoint magic |
|
||||
|
||||
## Compose Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `docker-compose.yaml` | Minimal zero-config setup — just works with sensible defaults |
|
||||
| `docker-compose.full.yaml` | Fully documented reference with all options, resource limits, health checks |
|
||||
|
||||
Start with `docker-compose.yaml`. Copy options from `docker-compose.full.yaml` when you need them.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option A: Docker Sandbox CLI (recommended)
|
||||
|
|
@ -34,7 +50,7 @@ cp docker/.env.example docker/.env
|
|||
# Edit docker/.env with your keys
|
||||
|
||||
# 2. Start the sandbox
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
docker compose -f docker/docker-compose.yaml up -d
|
||||
|
||||
# 3. Shell into the container
|
||||
docker exec -it gsd-sandbox bash
|
||||
|
|
@ -43,6 +59,29 @@ docker exec -it gsd-sandbox bash
|
|||
gsd auto "implement the feature described in issue #42"
|
||||
```
|
||||
|
||||
## UID/GID Remapping
|
||||
|
||||
The entrypoint handles UID/GID remapping via `PUID` and `PGID` environment variables. This avoids permission issues on bind-mounted volumes by matching the container's `gsd` user to your host UID/GID.
|
||||
|
||||
```bash
|
||||
# Find your host UID/GID
|
||||
id -u # PUID
|
||||
id -g # PGID
|
||||
```
|
||||
|
||||
Set these in your `.env` file or in the `environment` section of the compose file. Defaults to `1000:1000`.
|
||||
|
||||
## Entrypoint Behavior
|
||||
|
||||
The container entrypoint (`entrypoint.sh`) runs four steps on every start:
|
||||
|
||||
1. **UID/GID remapping** — adjusts the `gsd` user to match `PUID`/`PGID`
|
||||
2. **Pre-create critical files** — prevents Docker bind-mount from creating directories where files are expected
|
||||
3. **Sentinel-based bootstrap** — runs `bootstrap.sh` exactly once on first boot
|
||||
4. **Drop privileges** — `exec gosu gsd` for proper PID 1 signal forwarding
|
||||
|
||||
No hardcoded `user:` directive in compose — the entrypoint starts as root, remaps, then drops to `gsd`.
|
||||
|
||||
## Two-Terminal Workflow
|
||||
|
||||
GSD's recommended workflow uses two terminals — one for auto mode, one for interactive discussion:
|
||||
|
|
@ -85,7 +124,7 @@ If you restrict outbound network access in your sandbox, GSD needs these endpoin
|
|||
Build with a specific GSD version:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml build --build-arg GSD_VERSION=2.43.0
|
||||
docker compose -f docker/docker-compose.yaml build --build-arg GSD_VERSION=2.51.0
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
|
@ -95,7 +134,7 @@ docker compose -f docker/docker-compose.yml build --build-arg GSD_VERSION=2.43.0
|
|||
docker sandbox rm gsd-sandbox
|
||||
|
||||
# Docker Compose
|
||||
docker compose -f docker/docker-compose.yml down -v
|
||||
docker compose -f docker/docker-compose.yaml down -v
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
|
|
|||
27
docker/bootstrap.sh
Executable file
27
docker/bootstrap.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# GSD First-Boot Bootstrap
|
||||
#
|
||||
# Runs once on initial container creation.
|
||||
# Called by entrypoint.sh as the gsd user.
|
||||
#
|
||||
# This script is idempotent — safe to run multiple
|
||||
# times, but the sentinel in entrypoint.sh ensures
|
||||
# it only runs once in practice.
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# ── Git Identity ────────────────────────────────────────
|
||||
# Without this, git commits inside the container will fail
|
||||
# or use garbage defaults.
|
||||
|
||||
if [ -n "${GIT_AUTHOR_NAME}" ]; then
|
||||
git config --global user.name "${GIT_AUTHOR_NAME}"
|
||||
fi
|
||||
|
||||
if [ -n "${GIT_AUTHOR_EMAIL}" ]; then
|
||||
git config --global user.email "${GIT_AUTHOR_EMAIL}"
|
||||
fi
|
||||
|
||||
echo "Bootstrap complete."
|
||||
61
docker/docker-compose.full.yaml
Normal file
61
docker/docker-compose.full.yaml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
services:
|
||||
gsd:
|
||||
build:
|
||||
context: . # Build context is the docker/ directory
|
||||
dockerfile: Dockerfile.sandbox # Runtime sandbox image with entrypoint
|
||||
args:
|
||||
GSD_VERSION: latest # Pin a specific version: GSD_VERSION=2.51.0
|
||||
|
||||
container_name: gsd-sandbox
|
||||
|
||||
ports:
|
||||
- "3000:3000" # GSD web UI
|
||||
|
||||
volumes:
|
||||
- ../:/workspace # Project root mounted into the container
|
||||
- gsd-state:/home/gsd/.gsd # Persistent GSD state across restarts
|
||||
# - ~/.ssh:/home/gsd/.ssh:ro # SSH keys for git operations (read-only)
|
||||
# - ~/.gitconfig:/home/gsd/.gitconfig:ro # Host git config
|
||||
|
||||
env_file:
|
||||
- .env # API keys and secrets (see .env.example)
|
||||
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
# UID/GID remapping — match your host user to avoid permission issues
|
||||
# on bind-mounted volumes. The entrypoint remaps the container's gsd
|
||||
# user to these IDs at startup. Run `id -u` / `id -g` to find yours.
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
# Git identity inside the container (overrides .env if set here)
|
||||
# - GIT_AUTHOR_NAME=Your Name
|
||||
# - GIT_AUTHOR_EMAIL=you@example.com
|
||||
|
||||
stdin_open: true # Keep stdin open for interactive use
|
||||
tty: true # Allocate a pseudo-TTY
|
||||
|
||||
# Health check — verify GSD is installed and responsive
|
||||
healthcheck:
|
||||
test: ["CMD", "gsd", "--version"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Resource limits — uncomment to constrain container resources
|
||||
# deploy:
|
||||
# resources:
|
||||
# limits:
|
||||
# cpus: "4.0"
|
||||
# memory: 8G
|
||||
# reservations:
|
||||
# cpus: "1.0"
|
||||
# memory: 2G
|
||||
|
||||
# Network mode — uncomment ONE if you need host networking
|
||||
# network_mode: host # Full host network access (no port mapping needed)
|
||||
# network_mode: bridge # Default Docker bridge (already the default)
|
||||
|
||||
volumes:
|
||||
gsd-state:
|
||||
driver: local
|
||||
23
docker/docker-compose.yaml
Normal file
23
docker/docker-compose.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
services:
|
||||
gsd:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.sandbox
|
||||
args:
|
||||
GSD_VERSION: latest
|
||||
container_name: gsd-sandbox
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ../:/workspace
|
||||
- gsd-state:/home/gsd/.gsd
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
volumes:
|
||||
gsd-state:
|
||||
driver: local
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# Docker Compose for running GSD in a sandbox
|
||||
# Usage: docker compose -f docker/docker-compose.yml up
|
||||
#
|
||||
# Copy docker/.env.example to docker/.env and fill in your API keys first.
|
||||
# See docker/README.md for full setup instructions.
|
||||
|
||||
services:
|
||||
gsd:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.sandbox
|
||||
args:
|
||||
GSD_VERSION: latest
|
||||
container_name: gsd-sandbox
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
# Sync project code into the sandbox
|
||||
- ../:/workspace
|
||||
# Persistent GSD state across container restarts
|
||||
- gsd-state:/home/gsd/.gsd
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
user: "1000:1000"
|
||||
stdin_open: true
|
||||
tty: true
|
||||
# Override entrypoint for interactive shell access
|
||||
# entrypoint: /bin/bash
|
||||
|
||||
volumes:
|
||||
gsd-state:
|
||||
driver: local
|
||||
81
docker/entrypoint.sh
Executable file
81
docker/entrypoint.sh
Executable file
|
|
@ -0,0 +1,81 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# GSD Container Entrypoint
|
||||
#
|
||||
# Responsibilities:
|
||||
# 1. UID/GID remapping — match host user via PUID/PGID
|
||||
# 2. Pre-create critical files — prevent Docker bind-mount
|
||||
# from creating directories where files are expected
|
||||
# 3. Sentinel-based bootstrap — one-time first-boot setup
|
||||
# 4. Signal forwarding — exec into the final process
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
GSD_USER="gsd"
|
||||
GSD_HOME="/home/${GSD_USER}"
|
||||
GSD_DIR="${GSD_HOME}/.gsd"
|
||||
|
||||
# ── 1. UID/GID Remapping ────────────────────────────────
|
||||
# Accept PUID/PGID from the environment so the container
|
||||
# can run with the same UID/GID as the host user, avoiding
|
||||
# permission headaches on bind-mounted volumes.
|
||||
|
||||
PUID="${PUID:-1000}"
|
||||
PGID="${PGID:-1000}"
|
||||
|
||||
CURRENT_UID=$(id -u "${GSD_USER}")
|
||||
CURRENT_GID=$(id -g "${GSD_USER}")
|
||||
|
||||
REMAPPED=0
|
||||
|
||||
if [ "${PGID}" != "${CURRENT_GID}" ]; then
|
||||
groupmod -o -g "${PGID}" "${GSD_USER}"
|
||||
REMAPPED=1
|
||||
fi
|
||||
|
||||
if [ "${PUID}" != "${CURRENT_UID}" ]; then
|
||||
usermod -o -u "${PUID}" "${GSD_USER}"
|
||||
REMAPPED=1
|
||||
fi
|
||||
|
||||
# Fix ownership only when UID/GID actually changed
|
||||
if [ "${REMAPPED}" -eq 1 ]; then
|
||||
chown -R "${PUID}:${PGID}" "${GSD_HOME}"
|
||||
chown "${PUID}:${PGID}" /workspace
|
||||
fi
|
||||
|
||||
# ── 2. Pre-create Critical Files ────────────────────────
|
||||
# Docker bind-mounts will create a *directory* if the target
|
||||
# path doesn't exist. We need these to be files, so touch
|
||||
# them before Docker gets a chance to mangle things.
|
||||
|
||||
mkdir -p "${GSD_DIR}"
|
||||
|
||||
if [ ! -f "${GSD_DIR}/settings.json" ]; then
|
||||
echo '{}' > "${GSD_DIR}/settings.json"
|
||||
fi
|
||||
|
||||
chown "${PUID}:${PGID}" "${GSD_DIR}" "${GSD_DIR}/settings.json"
|
||||
|
||||
# ── 3. Sentinel-based Bootstrap ─────────────────────────
|
||||
# Run first-boot setup exactly once. Subsequent container
|
||||
# starts (or restarts) skip this entirely.
|
||||
|
||||
SENTINEL="${GSD_DIR}/.bootstrapped"
|
||||
|
||||
if [ ! -f "${SENTINEL}" ]; then
|
||||
if [ -x /usr/local/bin/bootstrap.sh ]; then
|
||||
# Run bootstrap as the gsd user so files get correct ownership
|
||||
gosu "${GSD_USER}" /usr/local/bin/bootstrap.sh
|
||||
fi
|
||||
touch "${SENTINEL}"
|
||||
chown "${PUID}:${PGID}" "${SENTINEL}"
|
||||
fi
|
||||
|
||||
# ── 4. Drop Privileges & Exec ──────────────────────────
|
||||
# Replace this shell process with the final command running
|
||||
# as the gsd user. exec + gosu = proper PID 1 = proper
|
||||
# signal forwarding (SIGTERM, SIGINT, etc.).
|
||||
|
||||
exec gosu "${GSD_USER}" "$@"
|
||||
|
|
@ -15,9 +15,9 @@ function readFile(relativePath: string): string {
|
|||
|
||||
// ── Dockerfile.sandbox ──
|
||||
|
||||
test("docker/Dockerfile.sandbox exists and uses Node 22 base", () => {
|
||||
test("docker/Dockerfile.sandbox exists and uses Node 24 base", () => {
|
||||
const content = readFile("docker/Dockerfile.sandbox");
|
||||
assert.match(content, /FROM node:22/);
|
||||
assert.match(content, /FROM node:24/);
|
||||
});
|
||||
|
||||
test("docker/Dockerfile.sandbox installs gsd-pi globally", () => {
|
||||
|
|
@ -28,7 +28,6 @@ test("docker/Dockerfile.sandbox installs gsd-pi globally", () => {
|
|||
test("docker/Dockerfile.sandbox creates a non-root user", () => {
|
||||
const content = readFile("docker/Dockerfile.sandbox");
|
||||
assert.match(content, /useradd/);
|
||||
assert.match(content, /USER gsd/);
|
||||
});
|
||||
|
||||
test("docker/Dockerfile.sandbox exposes port 3000", () => {
|
||||
|
|
@ -41,29 +40,47 @@ test("docker/Dockerfile.sandbox installs git", () => {
|
|||
assert.match(content, /git/);
|
||||
});
|
||||
|
||||
// ── docker-compose.yml ──
|
||||
// ── docker-compose.yaml (minimal) ──
|
||||
|
||||
test("docker/docker-compose.yml exists and defines gsd service", () => {
|
||||
const content = readFile("docker/docker-compose.yml");
|
||||
test("docker/docker-compose.yaml exists and defines gsd service", () => {
|
||||
const content = readFile("docker/docker-compose.yaml");
|
||||
assert.match(content, /services:/);
|
||||
assert.match(content, /gsd:/);
|
||||
});
|
||||
|
||||
test("docker/docker-compose.yml mounts workspace volume", () => {
|
||||
const content = readFile("docker/docker-compose.yml");
|
||||
test("docker/docker-compose.yaml mounts workspace volume", () => {
|
||||
const content = readFile("docker/docker-compose.yaml");
|
||||
assert.match(content, /\/workspace/);
|
||||
});
|
||||
|
||||
test("docker/docker-compose.yml references Dockerfile.sandbox", () => {
|
||||
const content = readFile("docker/docker-compose.yml");
|
||||
test("docker/docker-compose.yaml references Dockerfile.sandbox", () => {
|
||||
const content = readFile("docker/docker-compose.yaml");
|
||||
assert.match(content, /Dockerfile\.sandbox/);
|
||||
});
|
||||
|
||||
test("docker/docker-compose.yml maps port 3000", () => {
|
||||
const content = readFile("docker/docker-compose.yml");
|
||||
test("docker/docker-compose.yaml maps port 3000", () => {
|
||||
const content = readFile("docker/docker-compose.yaml");
|
||||
assert.match(content, /3000:3000/);
|
||||
});
|
||||
|
||||
test("docker/docker-compose.yaml has no hardcoded user directive", () => {
|
||||
const content = readFile("docker/docker-compose.yaml");
|
||||
assert.doesNotMatch(content, /^\s+user:/m);
|
||||
});
|
||||
|
||||
// ── docker-compose.full.yaml (reference) ──
|
||||
|
||||
test("docker/docker-compose.full.yaml exists with health check", () => {
|
||||
const content = readFile("docker/docker-compose.full.yaml");
|
||||
assert.match(content, /healthcheck:/);
|
||||
});
|
||||
|
||||
test("docker/docker-compose.full.yaml documents PUID/PGID", () => {
|
||||
const content = readFile("docker/docker-compose.full.yaml");
|
||||
assert.match(content, /PUID/);
|
||||
assert.match(content, /PGID/);
|
||||
});
|
||||
|
||||
// ── .env.example ──
|
||||
|
||||
test("docker/.env.example exists and lists ANTHROPIC_API_KEY", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue