diff --git a/Dockerfile.dev b/Dockerfile.dev index d1d973cc..a9991094 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -41,6 +41,16 @@ COPY go.mod go.sum ./ # Download dependencies RUN go mod download +# Build in single-module mode. `go mod download` above runs before go.work is +# copied in, so it fills the module cache for the root module only. With the +# repo's go.work in /app, `go build` would switch to workspace mode, which loads +# the module graph differently and asks for modules that download did not fetch +# (e.g. github.com/envoyproxy/go-control-plane). The cache is root-owned (and +# so is the go_modules volume seeded from it), so the non-root dev user cannot +# fetch them and Air's build fails before compiling. tests/e2e is a separate +# stdlib-only module; work on it from its own directory. +ENV GOWORK=off + # Copy source code COPY . . @@ -49,16 +59,30 @@ RUN if [ ! -f .air.toml ]; then air init; fi EXPOSE 8080 -# Create non-root user for security; grant ownership of app dir and Go paths. -# uid/gid are pinned explicitly rather than left to `adduser -S`, which picks -# the first free system id and so varies with the base image's existing users. -# A numeric USER below needs a value that is known here, not discovered later. -RUN addgroup -g 1001 devuser && \ - adduser -D -u 1001 -G devuser devuser && \ - chown -R devuser:devuser /app /root/go /root/.cache 2>/dev/null || true +# Create the non-root dev user and give it the app dir. +# +# BIND-MOUNT CONSTRAINT: docker-compose.yml mounts the checkout over /app, which +# hides the chown below. On Linux the mounted files keep the HOST user's +# uid/gid, and Air must write ./tmp/main into that mount on every reload, so +# this user's uid/gid have to match the host user's or hot reload stops +# (issue #158). Hence build args instead of a fixed id: docker-compose.yml +# passes DEV_UID/DEV_GID (set them to `id -u`/`id -g`, see docs/DEVELOPMENT.md). +# The 1000:1000 default is the usual first Linux account; Docker Desktop on +# macOS remaps bind-mount ownership, so macOS works with any value. +# +# The ids are pinned explicitly rather than left to `adduser -S`, which picks +# the first free system id. A host gid that already names a group in the base +# image (e.g. macOS's 20, `dialout` here) is reused instead of re-created. No +# `|| true`: a uid that is already taken must fail the build, not leave USER +# pointing at an account that does not exist. +ARG DEV_UID=1000 +ARG DEV_GID=1000 +RUN if ! getent group "${DEV_GID}" >/dev/null; then addgroup -g "${DEV_GID}" devuser; fi && \ + adduser -D -u "${DEV_UID}" -G "$(getent group "${DEV_GID}" | cut -d: -f1)" devuser && \ + chown -R "${DEV_UID}:${DEV_GID}" /app # Numeric form so the identity is resolvable without the image's /etc/passwd. -USER 1001:1001 +USER ${DEV_UID}:${DEV_GID} # Start with Air for hot reload CMD ["air", "-c", ".air.toml"] diff --git a/docker-compose.yml b/docker-compose.yml index d9e45a85..9d210f45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,15 @@ services: build: context: . dockerfile: Dockerfile.dev + # The `.:/app` bind mount below keeps the host's file ownership on Linux, + # and Air writes ./tmp/main into it, so the container user must have the + # host user's uid/gid (issue #158). On Linux, when `id -u`/`id -g` are not + # 1000, set DEV_UID/DEV_GID to them in .env and rebuild with + # `docker compose build app`. macOS needs nothing: Docker Desktop remaps + # bind-mount ownership. See docs/DEVELOPMENT.md "Linux: match your uid". + args: + DEV_UID: ${DEV_UID:-1000} + DEV_GID: ${DEV_GID:-1000} container_name: cudly-app depends_on: postgres: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c411e69d..4b71fdf8 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -66,6 +66,30 @@ The development environment uses **Air** for hot reload. Any changes to `.go` fi docker-compose logs -f app ``` +#### Linux: match your uid + +The `app` service bind-mounts the checkout at `/app`, and Air writes its build +output to `./tmp/` inside that mount. On Linux the mounted files keep your +host uid/gid, so the container user must have the same ids or Air cannot write +`tmp/main` and hot reload stops. `Dockerfile.dev` therefore takes `DEV_UID` and +`DEV_GID` build args, which `docker-compose.yml` passes through. Both default +to `1000`, the usual first Linux account. If `id -u` or `id -g` prints something +else, record your ids in `.env` (gitignored) and rebuild: + +```bash +printf 'DEV_UID=%s\nDEV_GID=%s\n' "$(id -u)" "$(id -g)" >> .env +docker-compose build app && docker-compose up -d +``` + +Files the container writes into the checkout are then owned by you. macOS +needs none of this: Docker Desktop remaps bind-mount ownership, so the +defaults work for any account. If you change `USER` in `Dockerfile.dev`, keep +it driven by these args, because the bind mount is the constraint. + +The dev image also sets `GOWORK=off`, so `go` commands run in the container +build the root module alone from the module cache baked into the image. To +work on the separate `tests/e2e` module there, `cd tests/e2e` first. + ### Database Migrations ```bash