diff --git a/code-server/Dockerfile b/code-server/Dockerfile index a579cc8..caf173a 100644 --- a/code-server/Dockerfile +++ b/code-server/Dockerfile @@ -4,10 +4,19 @@ USER root # Extra system packages. RUN apt-get update \ - && apt-get install -y --no-install-recommends bubblewrap unzip zip \ + && apt-get install -y --no-install-recommends \ + build-essential bubblewrap unzip zip \ + libffi-dev libssl-dev libyaml-dev zlib1g-dev \ && rm -rf /var/lib/apt/lists/* +# zsh as the container user's login shell. +RUN chsh -s /usr/bin/zsh coder + # Workspace directory, owned by the container user. RUN mkdir -p /workspace && chown 1000:1000 /workspace +# Startup wrapper: joins the Docker socket's group, then runs the image's entrypoint. +COPY entrypoint.sh /usr/local/bin/entrypoint.sh USER 1000 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh", "--bind-addr", "0.0.0.0:8080", "."] diff --git a/code-server/README.md b/code-server/README.md index 1c1e0e6..37e6435 100644 --- a/code-server/README.md +++ b/code-server/README.md @@ -4,8 +4,10 @@ official `codercom/code-server` image. The image ships code-server on Debian with `git`, `zsh`, `curl`, `sudo` and a -few editors. The `Dockerfile` adds `bubblewrap`, `zip` and `unzip`. Language -toolchains and CLIs are installed into home, below. +few editors. The `Dockerfile` adds `build-essential`, `bubblewrap`, `zip`, +`unzip` and the headers Ruby builds against, makes zsh the login shell, and +wraps the entrypoint so `coder` can use the Docker socket. Language toolchains +and CLIs are installed into home, below. ## Toolchains @@ -40,6 +42,48 @@ curl -LsSf https://astral.sh/uv/install.sh | sh uv python install 3.13 ``` +Ruby, with rbenv and its ruby-build plugin, each cloned with git as their +READMEs document ([rbenv](https://github.com/rbenv/rbenv#basic-git-checkout), +[ruby-build](https://github.com/rbenv/ruby-build#clone-as-rbenv-plugin-using-git)). +Debian's `ruby` package is 3.3, several releases behind. Rubies are compiled into +`~/.rbenv/versions` against the headers the `Dockerfile` installs. +`rbenv init` adds itself to the login shell's startup file, `~/.zprofile`: + +```sh +git clone https://github.com/rbenv/rbenv.git ~/.rbenv +~/.rbenv/bin/rbenv init +eval "$(~/.rbenv/bin/rbenv init - zsh)" +git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build +V=$(rbenv install -l 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | tail -1) +rbenv install "$V" && rbenv global "$V" +``` + +To get newer Ruby versions listed, `git -C "$(rbenv root)"/plugins/ruby-build pull`. + +Docker Compose and Buildx, as CLI plugins in `~/.docker/cli-plugins`, the +manual install from the +[Compose docs](https://docs.docker.com/compose/install/linux/#install-the-plugin-manually) +and the [Buildx README](https://github.com/docker/buildx#manual-download). +Nothing installs them together with the client: Docker's static archive holds +only the client and daemon, and the packages that bundle all three are apt +packages, which land outside home. The client itself is below: + +```sh +DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker} +mkdir -p "$DOCKER_CONFIG/cli-plugins" +ARCH=$(dpkg --print-architecture) + +V=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/docker/compose/releases/latest | sed 's|.*/||') +curl -fsSL "https://github.com/docker/compose/releases/download/$V/docker-compose-linux-$(uname -m)" -o "$DOCKER_CONFIG/cli-plugins/docker-compose" + +V=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/docker/buildx/releases/latest | sed 's|.*/||') +curl -fsSL "https://github.com/docker/buildx/releases/download/$V/buildx-$V.linux-$ARCH" -o "$DOCKER_CONFIG/cli-plugins/docker-buildx" + +chmod +x "$DOCKER_CONFIG/cli-plugins/docker-compose" "$DOCKER_CONFIG/cli-plugins/docker-buildx" +``` + +To upgrade them, run the same commands again. + ### Suggested by AI, may not be the optimal way These use each project's official download, but no official doc covers @@ -62,8 +106,8 @@ echo 'export PATH="$HOME/.local/go/bin:$HOME/go/bin:$PATH"' >> ~/.bashrc To upgrade Go, `rm -rf ~/.local/go` and run the same commands again, without the `echo` line. -Docker CLI, GitHub CLI and GitLab CLI, as the release binaries each project -publishes, into `~/.local/bin`: +Docker CLI, GitHub CLI, GitLab CLI and jq, as the release binaries each +project publishes, into `~/.local/bin`: - Docker: [static binaries](https://docs.docker.com/engine/install/binaries/), documented for `/usr/bin`. Only the client is taken; the daemon is the @@ -73,6 +117,9 @@ publishes, into `~/.local/bin`: - GitLab CLI: the binary from the [releases page](https://gitlab.com/gitlab-org/cli/-/releases), with no documented location. +- jq: the binary from the + [releases page](https://github.com/jqlang/jq/releases), with no documented + location. ```sh mkdir -p ~/.local/bin @@ -87,6 +134,9 @@ curl -fsSL "https://github.com/cli/cli/releases/download/v$V/gh_${V}_linux_$ARCH V=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://gitlab.com/gitlab-org/cli/-/releases/permalink/latest | sed 's|.*/v||') curl -fsSL "https://gitlab.com/gitlab-org/cli/-/releases/v$V/downloads/glab_${V}_linux_$ARCH.tar.gz" | tar -C ~/.local/bin -xzf - --strip-components=1 bin/glab + +V=$(curl -fsSLI -o /dev/null -w '%{url_effective}' https://github.com/jqlang/jq/releases/latest | sed 's|.*/||') +curl -fsSL "https://github.com/jqlang/jq/releases/download/$V/jq-linux-$ARCH" -o ~/.local/bin/jq && chmod +x ~/.local/bin/jq ``` To upgrade one, run the `ARCH=` line and that tool's two lines again; the @@ -123,10 +173,24 @@ ships no Docker CLI; install the client into home as above. Containers started through it are siblings on the host, not children, so bind mounts in them resolve against host paths. -The socket belongs to the host's `docker` group, which `coder` is not in. -Run it as `sudo ~/.local/bin/docker`: sudo needs no password in this image, -but it resets `PATH`, hence the full path. Access to the socket is root on the -host, which is accepted here because this is a single-user dev box. +The socket belongs to the host's `docker` group, whose GID differs from host +to host and is not exported anywhere a compose file could read it, so a fixed +`group_add:` would break on another host. Instead `entrypoint.sh` runs first +at startup: it reads the GID off the socket, adds `coder` to a group with that +GID (creating `docker-host` if none exists) and restarts the image's own +entrypoint under the new group. `docker` then works for `coder` without sudo, +in the editor's terminal and in `docker exec` shells alike. Access to the +socket is root on the host, which is accepted here because this is a +single-user dev box. + +The wrapper keeps the image's `USER 1000`, so `docker exec` still opens a +shell as `coder`, and gets root through the image's passwordless sudo. A +running process cannot join a group, so it re-execs through `sudo -E setpriv +--init-groups`, which loads the new group; `PATH` and `USER` are passed +explicitly because sudo resets both. sudo stays as PID 1 and forwards stop +signals to code-server. On a container restart `coder` is already in the +group, and the wrapper hands straight to the image's entrypoint. Without a +socket mounted it does nothing. The `:ro` flag is not a security boundary. It marks the socket file read-only, but clients reach the Docker API by connecting to the socket, which a @@ -154,9 +218,18 @@ seeds an empty named volume from the image's directory, ownership included. The home volume needs no such step, because the image already ships `/home/coder` owned by `coder`. -`bubblewrap`, `zip` and `unzip` are in the `Dockerfile` rather than home -because their projects publish no standalone binaries; Debian's packages are -the install method, and they land outside home. +`build-essential`, `bubblewrap`, `zip` and `unzip` are in the `Dockerfile` +rather than home because their projects publish no standalone binaries; +Debian's packages are the install method, and they land outside home. +`build-essential` supplies the `gcc` and `make` that native builds expect: +node-gyp addons, Python sdists, Rust crates using `cc`, and Ruby built by +rbenv. `libffi-dev`, `libssl-dev`, `libyaml-dev` and `zlib1g-dev` are the +headers a rbenv-built Ruby needs for its `fiddle`, `openssl`, `psych` and +`zlib` extensions; without them `rbenv install` fails or leaves those out. + +The image leaves `coder` with `/bin/bash` as its login shell. The editor's +terminal opens zsh regardless, but tools that read the login shell from +`/etc/passwd` or `$SHELL` get bash, so the `Dockerfile` sets it to zsh. ## Image diff --git a/code-server/entrypoint.sh b/code-server/entrypoint.sh new file mode 100755 index 0000000..8cf0565 --- /dev/null +++ b/code-server/entrypoint.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +# Add coder to the group that owns the mounted Docker socket, then restart +# under that group. +if [ -S /var/run/docker.sock ]; then + gid=$(stat -c %g /var/run/docker.sock) + if ! id -G | tr ' ' '\n' | grep -qx "$gid"; then + group=$(getent group "$gid" | cut -d: -f1) + if [ -z "$group" ]; then + group=docker-host + sudo groupadd -g "$gid" "$group" + fi + sudo usermod -aG "$group" coder + exec sudo -E setpriv --reuid=coder --regid=coder --init-groups \ + env PATH="$PATH" USER=coder /usr/bin/entrypoint.sh "$@" + fi +fi + +# Start the image's own entrypoint. +exec /usr/bin/entrypoint.sh "$@"