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:
Iouri Goussev 2026-03-26 18:10:49 -04:00 committed by GitHub
parent a952391b33
commit 0e07c647c5
11 changed files with 299 additions and 76 deletions

View file

@ -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 \

View file

@ -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)

View 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

View file

@ -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"]

View file

@ -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
View 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."

View 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

View 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

View file

@ -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
View 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}" "$@"

View file

@ -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", () => {