diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 072ae42..5eb831a 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,42 +1,141 @@ +# syntax=docker/dockerfile:1 +# +# Dockerfile-only devcontainer (no devcontainer `features:`). +# +# Why: Coder builds workspaces with envbuilder, and envbuilder's prebuilt-image +# cache probe (the `envbuilder_cached_image` Terraform resource) cannot reproduce +# devcontainer *feature* layers -- features install from `/.envbuilder/features/...` +# during the build but from `/tmp/...` during the probe, so the probe always misses +# and the workspace rebuilds. See coder/terraform-provider-envbuilder#68. +# +# Folding every tool into plain Dockerfile RUN layers lets the probe reproduce the +# build byte-for-byte, so a cached workspace can boot directly from the prebuilt +# image (no build, no layer extraction). The toolset mirrors the previous +# feature-based devcontainer.json. FROM mcr.microsoft.com/devcontainers/typescript-node:24-bookworm -# Wire up the tmux configuration for the default container user (node) -COPY tmux.conf /home/node/.tmux.conf -RUN chown node:node /home/node/.tmux.conf +# All install steps run as root; the final USER directive drops back to `node`. +USER root +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +ENV DEBIAN_FRONTEND=noninteractive + +# --------------------------------------------------------------------------- +# APT packages: the requested toolbox plus Ruby build dependencies and sshd. +# --------------------------------------------------------------------------- +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential git postgresql-client curl htop tmux vim \ + ca-certificates gnupg lsb-release wget unzip \ + openssh-server \ + autoconf bison libssl-dev libyaml-dev libreadline-dev zlib1g-dev \ + libncurses-dev libffi-dev libgdbm-dev libdb-dev uuid-dev \ + && rm -rf /var/lib/apt/lists/* -# Also wire up tmux configuration for root (used when connecting via `coder ssh`) +# --------------------------------------------------------------------------- +# tmux configuration for both `node` (default) and `root` (used by `coder ssh`). +# --------------------------------------------------------------------------- +COPY tmux.conf /home/node/.tmux.conf COPY tmux.conf /root/.tmux.conf -RUN chown root:root /root/.tmux.conf +RUN chown node:node /home/node/.tmux.conf && chown root:root /root/.tmux.conf + +# --------------------------------------------------------------------------- +# Go (was ghcr.io/devcontainers/features/go, version 1.26.3). System-wide. +# --------------------------------------------------------------------------- +ENV GO_VERSION=1.26.3 +ENV GOROOT=/usr/local/go GOPATH=/go +ENV PATH=${GOROOT}/bin:${GOPATH}/bin:${PATH} +RUN curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tgz \ + && tar -C /usr/local -xzf /tmp/go.tgz \ + && rm /tmp/go.tgz \ + && mkdir -p "${GOPATH}/bin" "${GOPATH}/src" "${GOPATH}/pkg" \ + && chmod -R a+rwX "${GOPATH}" + +# --------------------------------------------------------------------------- +# Rust (was ghcr.io/devcontainers/features/rust). System-wide via rustup. +# --------------------------------------------------------------------------- +ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo +ENV PATH=${CARGO_HOME}/bin:${PATH} +RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --profile minimal --default-toolchain stable \ + && rustup component add clippy rustfmt \ + && chmod -R a+rwX "${RUSTUP_HOME}" "${CARGO_HOME}" + +# --------------------------------------------------------------------------- +# Ruby (was ghcr.io/devcontainers-extra/features/ruby-asdf, version 4.0.5). +# Built with ruby-build and installed system-wide to /usr/local/ruby. +# --------------------------------------------------------------------------- +ENV RUBY_VERSION=4.0.5 +ENV PATH=/usr/local/ruby/bin:${PATH} +RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git /tmp/ruby-build \ + && PREFIX=/usr/local /tmp/ruby-build/install.sh \ + && ruby-build "${RUBY_VERSION}" /usr/local/ruby \ + && rm -rf /tmp/ruby-build \ + && /usr/local/ruby/bin/gem install bundler --no-document \ + && chmod -R a+rwX /usr/local/ruby/lib/ruby/gems -# Install the Namespace (nsc) CLI - https://namespace.so/docs/reference/cli/installation -# Install system-wide so it is on PATH for both the `node` and `root` users. +# --------------------------------------------------------------------------- +# kubectl, helm, minikube (was ghcr.io/devcontainers/features/kubectl-helm-minikube). +# --------------------------------------------------------------------------- +RUN KUBECTL_VERSION="$(curl -fsSL https://dl.k8s.io/release/stable.txt)" \ + && curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl \ + && chmod 0755 /usr/local/bin/kubectl \ + && curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash \ + && curl -fsSL https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 -o /usr/local/bin/minikube \ + && chmod 0755 /usr/local/bin/minikube + +# --------------------------------------------------------------------------- +# GitHub CLI (was ghcr.io/devcontainers/features/github-cli). +# --------------------------------------------------------------------------- +RUN mkdir -p -m 755 /etc/apt/keyrings \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* + +# --------------------------------------------------------------------------- +# Azure CLI (was ghcr.io/devcontainers/features/azure-cli, latest). +# --------------------------------------------------------------------------- +RUN curl -sL https://aka.ms/InstallAzureCLIDeb | bash \ + && rm -rf /var/lib/apt/lists/* + +# --------------------------------------------------------------------------- +# sshd (was ghcr.io/devcontainers/features/sshd). Listens on 2222. +# --------------------------------------------------------------------------- +RUN mkdir -p /var/run/sshd \ + && ssh-keygen -A \ + && sed -i 's/^#\?Port .*/Port 2222/' /etc/ssh/sshd_config \ + && sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication yes/' /etc/ssh/sshd_config + +# --------------------------------------------------------------------------- +# Namespace (nsc) CLI - https://namespace.so/docs/reference/cli/installation +# --------------------------------------------------------------------------- RUN curl -fsSL https://get.namespace.so/cloud/install.sh | NS_INSTALL_DIR=/usr/local/bin sh -# Install Playwright with the Chromium browser and its required OS dependencies. -# Browsers are stored in a shared, system-wide location (PLAYWRIGHT_BROWSERS_PATH) -# so both the `node` and `root` users can run Playwright without re-downloading. +# --------------------------------------------------------------------------- +# Playwright + Chromium in a shared, system-wide browser location. +# --------------------------------------------------------------------------- ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright RUN npm install -g playwright \ && playwright install --with-deps chromium \ && chmod -R a+rx /ms-playwright -# Tell dev container features where the target user's home directory is. -# The devcontainer CLI passes `_REMOTE_USER_HOME`/`_CONTAINER_USER_HOME` to every -# feature's install.sh, but envbuilder (used by Coder) only sets `_REMOTE_USER` -# and `_CONTAINER_USER` -- it leaves the *_HOME variables empty. Features that -# rely on them then break: e.g. the claude-code feature runs -# `cp "$_REMOTE_USER_HOME/.local/bin/claude" /usr/local/bin/claude`, which under -# envbuilder expands to `cp /.local/bin/claude ...` and fails the whole build. -# Supplying the values here (as build ARGs, so they don't leak into the running -# container's environment) keeps feature installs working on the envbuilder path. -# ARGs declared here remain in scope for the feature install steps envbuilder -# appends after this Dockerfile, but reset for the devcontainer CLI (which sets -# these variables itself), so this is a no-op there. -ARG _REMOTE_USER_HOME=/home/node -ARG _CONTAINER_USER_HOME=/home/node - -# Default to the non-root `node` user. envbuilder (used by Coder) picks its target -# user from the last `USER` directive when `containerUser`/`remoteUser` are not -# applied from image metadata, so make the intended user explicit here to avoid -# dropping into a root shell. +# --------------------------------------------------------------------------- +# Global npm CLIs (replacing the claude-code, opencode, copilot-cli and +# npm-package features). Installed system-wide via the base image's Node. +# - @anthropic-ai/claude-code -> `claude` +# - @github/copilot -> `copilot` +# - opencode-ai -> `opencode` +# - @openprose/prose-cli -> prose CLI +# --------------------------------------------------------------------------- +RUN npm install -g \ + @anthropic-ai/claude-code \ + @github/copilot \ + opencode-ai \ + @openprose/prose-cli + +# Default to the non-root `node` user. envbuilder (used by Coder) picks its +# target user from the last `USER` directive when `containerUser`/`remoteUser` +# are not applied from image metadata, so make the intended user explicit. USER node diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json deleted file mode 100644 index fc2ad44..0000000 --- a/.devcontainer/devcontainer-lock.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "features": { - "ghcr.io/devcontainers-extra/features/apt-packages:1": { - "version": "1.0.6", - "resolved": "ghcr.io/devcontainers-extra/features/apt-packages@sha256:55c54412112da81b9381e470cdbbe55278564950d1ff536ce925b1e8e096babd", - "integrity": "sha256:55c54412112da81b9381e470cdbbe55278564950d1ff536ce925b1e8e096babd" - }, - "ghcr.io/devcontainers-extra/features/claude-code:2": { - "version": "2.0.0", - "resolved": "ghcr.io/devcontainers-extra/features/claude-code@sha256:37b0d444a704021ee5f6d24242a4621bf337867d110e4e3c06b863a3a78122ac", - "integrity": "sha256:37b0d444a704021ee5f6d24242a4621bf337867d110e4e3c06b863a3a78122ac" - }, - "ghcr.io/devcontainers-extra/features/npm-package:1": { - "version": "1.0.4", - "resolved": "ghcr.io/devcontainers-extra/features/npm-package@sha256:ee315c064588643e1178eec33275efa55739f43761fc965b95a5d05eed70c76d", - "integrity": "sha256:ee315c064588643e1178eec33275efa55739f43761fc965b95a5d05eed70c76d" - }, - "ghcr.io/devcontainers-extra/features/opencode:1": { - "version": "1.0.0", - "resolved": "ghcr.io/devcontainers-extra/features/opencode@sha256:7c15617cc9e501bc218097adc4910a00f81ed52fc5a5eef463aa1cc01317f9ba", - "integrity": "sha256:7c15617cc9e501bc218097adc4910a00f81ed52fc5a5eef463aa1cc01317f9ba" - }, - "ghcr.io/devcontainers-extra/features/ruby-asdf:0": { - "version": "0.0.2", - "resolved": "ghcr.io/devcontainers-extra/features/ruby-asdf@sha256:2109262319dcbc83232919ec0b15bc5336b60999d9c6231498ff59f48f81f670", - "integrity": "sha256:2109262319dcbc83232919ec0b15bc5336b60999d9c6231498ff59f48f81f670" - }, - "ghcr.io/devcontainers/features/azure-cli:1": { - "version": "1.3.0", - "resolved": "ghcr.io/devcontainers/features/azure-cli@sha256:d98f1066c077be0fa9d115b718f458bd803e415181b4a96f82a6f5d9f77241ac", - "integrity": "sha256:d98f1066c077be0fa9d115b718f458bd803e415181b4a96f82a6f5d9f77241ac" - }, - "ghcr.io/devcontainers/features/copilot-cli:1": { - "version": "1.1.3", - "resolved": "ghcr.io/devcontainers/features/copilot-cli@sha256:e10d091ae7ef9b8d2ed5f601d75f5a090bc04acbac1a26d4cd3c0d5edde4ea10", - "integrity": "sha256:e10d091ae7ef9b8d2ed5f601d75f5a090bc04acbac1a26d4cd3c0d5edde4ea10" - }, - "ghcr.io/devcontainers/features/github-cli:1": { - "version": "1.1.0", - "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671", - "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671" - }, - "ghcr.io/devcontainers/features/go:1": { - "version": "1.3.4", - "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", - "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032" - }, - "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { - "version": "1.3.1", - "resolved": "ghcr.io/devcontainers/features/kubectl-helm-minikube@sha256:bbe8adf6b37fff8c67412ab0a4579f4c2f30bbaba1d9a5cebd9e38bade54025b", - "integrity": "sha256:bbe8adf6b37fff8c67412ab0a4579f4c2f30bbaba1d9a5cebd9e38bade54025b" - }, - "ghcr.io/devcontainers/features/rust:1": { - "version": "1.5.0", - "resolved": "ghcr.io/devcontainers/features/rust@sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c", - "integrity": "sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c" - }, - "ghcr.io/devcontainers/features/sshd:1": { - "version": "1.1.0", - "resolved": "ghcr.io/devcontainers/features/sshd@sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee", - "integrity": "sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee" - } - } -} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 92b0684..4f0251e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,31 +5,14 @@ "build": { "dockerfile": "Dockerfile" }, - "features": { - "ghcr.io/devcontainers-extra/features/apt-packages:1": { - "packages": "build-essential,git,postgresql-client,curl,htop,tmux,vim" - }, - "ghcr.io/devcontainers/features/go:1": { - "version": "1.26.3" - }, - "ghcr.io/devcontainers-extra/features/ruby-asdf:0": { - "version": "4.0.5" - }, - "ghcr.io/devcontainers/features/rust:1": {}, - "ghcr.io/devcontainers/features/sshd:1": {}, - "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/azure-cli:1": { - "version": "latest" - }, - "ghcr.io/devcontainers/features/copilot-cli:1": {}, - "ghcr.io/devcontainers-extra/features/claude-code:2": {}, - "ghcr.io/devcontainers-extra/features/opencode:1": {}, - "ghcr.io/devcontainers-extra/features/npm-package:1": { - "package": "@openprose/prose-cli" - } - }, - "overrideFeatureInstallOrder": ["ghcr.io/devcontainers/features/rust"], + + // This devcontainer intentionally installs its whole toolchain in the + // Dockerfile instead of using devcontainer `features:`. Coder's envbuilder + // prebuilt-image cache probe cannot reproduce feature layers (they install + // from /.envbuilder/features/... during the build but /tmp/... during the + // probe), so a feature-based devcontainer never gets a cache hit and always + // rebuilds. A pure-Dockerfile build is reproducible by the probe, enabling a + // direct boot from the cached image. See coder/terraform-provider-envbuilder#68. // Connect as the `node` user. Set this explicitly rather than relying on the // base image's `remoteUser` metadata: envbuilder (used by Coder) does not honor @@ -38,15 +21,15 @@ "remoteUser": "node", "containerUser": "node", - // Don't remap the container user's UID/GID to match the host user. The sshd - // feature creates an `ssh` group at GID 1001, which collides with the host - // UID/GID (1001) used by CI runners. The CLI's automatic remap then fails with - // `groupmod: GID '1001' already exists`, breaking `devcontainer up`. - // (envbuilder ignores this key; it only affects the devcontainer CLI.) + // Don't remap the container user's UID/GID to match the host user. The + // openssh-server package creates an `ssh` group at GID 1001, which collides + // with the host UID/GID (1001) used by CI runners. The CLI's automatic remap + // then fails with `groupmod: GID '1001' already exists`, breaking + // `devcontainer up`. (envbuilder ignores this key; it only affects the CLI.) "updateRemoteUserUID": false, - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, + // Tooling is installed in the Dockerfile (see .devcontainer/Dockerfile), not + // via devcontainer features. // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], diff --git a/README.md b/README.md index 03bc461..3732681 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,27 @@ My personal devcontainer for VSCode. - `mcr.microsoft.com/devcontainers/typescript-node:24-bookworm` -## Devcontainer features - -- Go -- Ruby (via asdf) -- Rust -- sshd +## Toolchain + +The toolchain is installed directly in [`.devcontainer/Dockerfile`](.devcontainer/Dockerfile) +rather than via devcontainer `features:`. Coder's envbuilder prebuilt-image cache +probe cannot reproduce feature layers (features install from +`/.envbuilder/features/...` during the build but `/tmp/...` during the probe), so a +feature-based devcontainer never gets a cache hit and rebuilds on every start. A +pure-Dockerfile build is reproducible by the probe, so a cached workspace can boot +directly from the prebuilt image. See +[coder/terraform-provider-envbuilder#68](https://github.com/coder/terraform-provider-envbuilder/issues/68). + +- Go (`1.26.3`) +- Ruby (`4.0.5`, built with ruby-build) +- Rust (stable, via rustup) +- sshd (port 2222) - kubectl / helm / minikube - GitHub CLI (`gh`) - Azure CLI (`az`, latest) -- GitHub Copilot CLI -- Anthropic Claude Code -- opencode +- GitHub Copilot CLI (`@github/copilot`) +- Anthropic Claude Code (`@anthropic-ai/claude-code`) +- opencode (`opencode-ai`) - `@openprose/prose-cli` (npm) - Namespace CLI (`nsc`) - Playwright with the Chromium browser (and required OS dependencies)