Skip to content

chore(dev): match the dev container user to the host uid/gid and fix Air builds - #1293

Merged
IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-158
Oct 25, 2026
Merged

IAmJSD merged 3 commits into
LeanerCloud:mainfrom
IAmJSD:fix/issue-158

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Root cause

Two separate problems both stop Air hot reload in the app dev container. Both are already on main.

  1. The uid is fixed at build time (the bug reported in chore(dev): dev container uid cannot match host, breaking Air hot reload on Linux bind mounts #158). docker-compose.yml bind-mounts the checkout over /app, which hides the image-layer chown. On Linux the mounted files keep the host user's uid/gid. Dockerfile.dev pinned the dev user to 1001:1001, so whenever the host user has a different uid, Air dies at startup with failed to mkdir, error: mkdir /app/tmp: permission denied. The old RUN line also ended in || true. That was needed because /root/go does not exist in the image, so the chown failed on every build, but it also hid any addgroup/adduser failure.
  2. Workspace-mode module fetch (found while verifying). Even with a writable /app (host uid == container uid), Air's go build failed before compiling. go mod download runs before go.work is copied in, so it fills the module cache for the root module only. Once go.work is present at /app, go build switches to workspace mode and asks for modules that download did not fetch (github.com/envoyproxy/go-control-plane, github.com/golang-jwt/jwt). The cache is root-owned, and so is the go_modules volume seeded from it, so the non-root user gets 29 × permission denied and Air never produces tmp/main. The issue's acceptance criterion is that reload succeeds on a source edit, and that cannot pass without this fix too.

Fix

  • Dockerfile.dev: new DEV_UID/DEV_GID build args, both defaulting to 1000 (the usual first Linux account). They drive addgroup/adduser, the /app chown and the numeric USER. If the gid already names a group in the base image, that group is reused (macOS's 20 is dialout in Alpine). || true is gone, so a taken uid now fails the build. The /root/go and /root/.cache chown targets are dropped: /root is 0700, so the dev user could never reach them. A comment next to USER records the bind-mount constraint, as the issue asks.
  • docker-compose.yml: build.args passes DEV_UID: ${DEV_UID:-1000} / DEV_GID: ${DEV_GID:-1000} from the shell or .env. A comment explains why.
  • Dockerfile.dev: ENV GOWORK=off, so the server builds offline from the baked module cache in single-module mode. tests/e2e is a separate stdlib-only module and still works from its own directory.
  • docs/DEVELOPMENT.md: new "Linux: match your uid" subsection under Hot Reload: when to set the ids, the one-liner that writes them to .env, the rebuild command, why macOS needs nothing, and a note on GOWORK=off.

macOS default behavior is unchanged: Docker Desktop remaps bind-mount ownership, so the default 1000:1000 works for any account.

Why build args and not the issue's named-volume alternative for /app/tmp: a named volume freezes its ownership when it is first created, so it goes stale as soon as the image's uid changes. It also hides tmp/main from the host, and it does nothing for other writes into the checkout (docker compose exec app go mod tidy, migrate create ...). With matched ids, those writes succeed and the files are owned by the developer. A plain user: override was rejected too: with a uid that has no passwd entry, HOME becomes /, and Go's build cache ($HOME/.cache/go-build) is not writable.

Closes #158

Verification

All evidence comes from local Docker runs on a Linux VM (Docker 29.1.3, legacy builder). The host user is uid=1001 gid=1002, so the pre-fix image (1001:1001) already mismatches the host gid. To reproduce a host whose uid differs from the container's, one throwaway copy of the source was chowned to 1000:1000. docker compose is not installed on this machine, so compose was emulated with docker build --build-arg + docker run -v <copy>:/app. The compose file itself was only checked as parsed YAML (build.args = {'DEV_UID': '${DEV_UID:-1000}', 'DEV_GID': '${DEV_GID:-1000}'}), not interpolated by compose. No real database was started, so the server exits at DB_HOST must be set after each successful build. That is expected and shows Air reached running....

Images:

docker build -f <origin/main Dockerfile.dev> -t platform158-dev-before:local .
docker build -f Dockerfile.dev --build-arg DEV_UID=$(id -u) --build-arg DEV_GID=$(id -g) -t platform158-dev-after:local .   # id -> uid=1001 gid=1002, GOWORK=off
docker build -f Dockerfile.dev -t platform158-dev-default:local .                                                          # id -> uid=1000 gid=1000
docker build -f Dockerfile.dev --build-arg DEV_UID=501 --build-arg DEV_GID=20 ...   # macOS ids -> uid=501 gid=20(dialout): gid reuse works
docker build -f Dockerfile.dev --build-arg DEV_UID=0 --build-arg DEV_GID=0 ...      # -> "adduser: uid '0' in use", build exits 1 (no silent || true)

Fail-before (pre-fix image):

Scenario Result
Checkout owned by 1000:1000, container 1001:1001 failed to mkdir, error: mkdir /app/tmp: permission denied, container exits 1
Checkout owned by the host user (1001:1002); uid matches, so /app is writable building... → 29 × mkdir /go/pkg/mod/cache/download/...: permission denied → failed to build; /app/tmp/main: not found; tmp/build-errors.log owned 1001:1001 (not the host gid)
go build -n -o /tmp/x ./cmd/server in workspace mode vs GOWORK=off GOPROXY=off exit 1 vs exit 0 (same on the pre-fix and fixed images, so this is independent of the uid)

Pass-after (fixed images):

Scenario Result
platform158-dev-after (built with host ids) on a host-owned checkout building... → running...; host ls -ln tmp/main → 1001 1002
Host-side edit: echo '// marker' >> cmd/server/main.go docker exec ... tail -1 /app/cmd/server/main.go shows the marker; Air logs cmd/server/main.go has changed → building... → running...; tmp/main mtime advanced (07:19:24 → 07:19:44)
docker exec ... touch /app/written-by-container owned 1001 1002 on the host
platform158-dev-default (no build args, 1000:1000) on the 1000:1000 checkout builds and runs; tmp/main owned 1000 1000
Compose-style seeded go_modules volume (fresh named volume at /go/pkg/mod, root-owned) GOWORK=off GOPROXY=off go build -n exit 0; workspace mode exit 1 with 29 × permission denied

Linters: no Go code or shell scripts changed, so Go build/vet/golangci-lint/gocyclo/shellcheck do not apply. hadolint is not installed on this machine (CI runs it through the pinned pre-commit image) and was not run. markdownlint is not installed either. All images, containers and the volume created for verification were removed.

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope / notes

  • Existing go_modules volumes are root-owned and the dev user cannot write to them. With GOWORK=off the build needs nothing beyond the baked cache, but adding a new dependency inside the container (go get) still cannot write the cache. That was already true before this PR; docker compose down -v plus a rebuild reseeds the volume.
  • Not tested on macOS. The macOS claims rely on Docker Desktop's documented ownership remap (the issue author confirmed it) and on the fact that GOWORK=off does not depend on the host OS.
  • Dockerfile.dev still downloads the upstream migrate v4.17.0 tarball (audit finding in docs/audits/codebase-audit-2026-09-02.md). Unrelated, left alone.
  • The frontend nginx service only bind-mounts read-only, so it is unaffected. No frontend change needed.

Maintainer: please mirror the issue's triage labels onto this PR: impact/internal, type/chore, triaged, priority/p3, severity/low, urgency/eventually, effort/s.

🤖 Generated with Claude Code

IAmJSD and others added 2 commits October 23, 2026 07:04
docker-compose.yml bind-mounts the checkout over /app, which hides the
image-layer chown. On Linux the mounted files keep the host user's
uid/gid, so with the user pinned to 1001:1001 at build time Air cannot
write ./tmp/main whenever the host ids differ, and hot reload stops.

Dockerfile.dev now takes DEV_UID/DEV_GID build args (default 1000:1000,
the usual first Linux account) and docker-compose.yml passes them from
the environment or .env. A gid that already names a group in the base
image (macOS's 20 is `dialout` in Alpine) is reused rather than
re-created. The old `|| true` is gone: it existed because /root/go does
not exist in the image (so chown always failed), but it also hid
addgroup/adduser failures; a taken uid now fails the build instead of
leaving USER pointing at a missing account. The /root/go and
/root/.cache chown targets are dropped since /root is 0700 and the
dev user could never reach them.

macOS is unchanged: Docker Desktop remaps bind-mount ownership.

Refs LeanerCloud#158

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…compile

Even with a writable /app, Air's `go build` in the dev container failed
before compiling: `go mod download` runs before go.work is copied in,
so it fills the root-owned module cache for the root module only, and
once go.work is present at /app the build switches to workspace mode,
which asks for modules that download did not fetch
(github.com/envoyproxy/go-control-plane, github.com/golang-jwt/jwt).
The non-root dev user cannot write the cache (nor the go_modules volume
seeded from it), so every build ended in `permission denied` and hot
reload never produced a binary, on Linux and macOS alike.

Set GOWORK=off in Dockerfile.dev. The server builds offline from the
baked cache in single-module mode. tests/e2e is a separate stdlib-only
module and is still usable from its own directory.

Closes LeanerCloud#158

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline effort/s Hours labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 503eb2e and a786c69.

Files selected for processing: 3
  • Dockerfile.dev
  • docker-compose.yml
  • docs/DEVELOPMENT.md
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE a786c69ec4d02395eb7d092d0a3db97bc12ec059

Blocking:

  • none

Non-blocking:

  • Dockerfile.dev:42 and docker-compose go_modules: the module cache at /go/pkg/mod is still root-owned, and the named volume is seeded from it only once. GOWORK=off fixes today's build. But as soon as someone adds a dependency to go.mod, Air's build in the container fails again with permission denied until the volume is deleted. Chowning /go/pkg/mod to ${DEV_UID}:${DEV_GID} in the same RUN would close that gap. Alternatively, docs/DEVELOPMENT.md could tell developers to run docker volume rm after a go.mod change.
  • docs/DEVELOPMENT.md: under rootless Docker or Podman, the host user maps to container root. There, setting DEV_UID=$(id -u) makes things worse, not better. That is a niche case and the problem predates this PR; one sentence in the docs would cover it.

Attacked:

  • Host-id build: built Dockerfile.dev at this head with --build-arg DEV_UID=1001 DEV_GID=1002, my host ids, and ran it with a git archive copy bind-mounted at /app. Inside the container, id showed uid=1001(devuser) gid=1002(devuser) and go env GOWORK printed off. touch /app/written and go build -o /app/tmp/main ./cmd/server both succeeded. On the host, both files are owned 1001 1002.
  • macOS ids: DEV_UID=501 DEV_GID=20 reuses dialout (uid=501 gid=20(dialout)).
  • Taken uid: DEV_UID=0 fails the build with adduser: uid '0' in use. The old || true no longer masks failures.
  • Workspace and blast radius: go.work uses only . and ./tests/e2e, so GOWORK=off drops nothing the server needs. No workflow or Makefile builds Dockerfile.dev, and .dockerignore excludes it, so the production image and CI are unaffected. Hadolint (pinned v2.15.1) passes in the pre-commit job on this head.
  • Acceptance criteria: the PR meets both of chore(dev): dev container uid cannot match host, breaking Air hot reload on Linux bind mounts #158's criteria. Ids are matched through build args, and the constraint is documented next to USER and in DEVELOPMENT.md.
  • Not run: compose itself, because it is not installed on this VM. The ${DEV_UID:-1000} interpolation is standard compose syntax.

Body-stale:

  • none

Reviewer: rB5. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths.

This was referenced Oct 25, 2026
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at a786c69ec4d02395eb7d092d0a3db97bc12ec059 merged onto main (5aa3df5b), together with #1155, #1219, #1221, #1223, #1224, #1225, #1226, #1227, #1256, #1262, #1266, #1270, #1272, #1275, #1283, #1286, #1293, #1297, #1299, #1300, #1301, #1304, #1307, #1311, #1313, #1325 in that order (combined tree f3a3331f, Linux, Go 1.26.10): go build ./..., go vet with and without -tags integration, the full go test -race -short -count=1 ./..., and go test -race -tags integration -count=1 on every changed package, against Docker Postgres. All pass. CI Success and pre-commit are green on this head, with 1 approving independent review(s) on it. Nothing was run against any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 25, 2026 20:29
@IAmJSD
IAmJSD merged commit ad2eb20 into LeanerCloud:main Oct 25, 2026
34 checks passed
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 1 approving independent review(s) on a786c69e, green CI, and the combined-tree verification above. Squash-merged at exactly a786c69ec4d02395eb7d092d0a3db97bc12ec059.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 26, 2026
Brings the branch up to date with main so the PR is mergeable again.
Dockerfile.dev conflicted with LeanerCloud#1236 (Go 1.26.10) and LeanerCloud#1293 (GOWORK=off):
take main's golang:1.26.10 FROM line and refresh comment, keep this PR's
longer sync comment, and keep both the from-source golang-migrate build
and main's ENV GOWORK=off block. TestDockerfilesBuildMigrateIdentically
passes against the 1.26.10 digest that Dockerfile's builder stage uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(dev): dev container uid cannot match host, breaking Air hot reload on Linux bind mounts

1 participant