Skip to content

useruid: portable shell rewrite (Alpine + Debian)#34

Merged
bilby91 merged 2 commits into
mainfrom
uid-alpine-portable
May 10, 2026
Merged

useruid: portable shell rewrite (Alpine + Debian)#34
bilby91 merged 2 commits into
mainfrom
uid-alpine-portable

Conversation

@bilby91
Copy link
Copy Markdown
Member

@bilby91 bilby91 commented May 10, 2026

Summary

Drops the `usermod`/`groupmod`/`getent` dependency chain in favor of a portable script that mutates `/etc/passwd` and `/etc/group` directly via `awk` + `sed`. Works on BusyBox/Alpine the same as on shadow-utils Debian, so the Alpine "skip with warning" guard from #30 goes away.

Closes #29 §2 (Alpine portability) and §4 (integration coverage).

Approach

The reconciliation step is now: write a self-contained shell script (`uidReconcileScript`) into the build context, COPY it, and `/bin/sh` it. This keeps the Dockerfile minimal and the script readable — no escaping nightmares from embedding multi-line shell into a Dockerfile RUN.

Script logic:

  1. Read the user's row from `/etc/passwd` via awk; bail if absent.
  2. Compare `(CUR_UID, CUR_GID)` to target — exit 0 if equal (no-op rebuild).
  3. If GID differs and the target GID isn't already taken by another group, sed-rewrite the user's primary group entry in `/etc/group`.
  4. sed-rewrite the user's row in `/etc/passwd` to swap UID and GID.
  5. `chown -R` the home dir.

Limitations

  • Doesn't touch `/etc/shadow` / `/etc/gshadow` — those key by username, not UID, so password login still works. Only `su ` semantics on hardened images would notice. No real devcontainer flow depends on this.
  • The script assumes POSIX `sed -i` and `awk -F:`, both present on BusyBox and shadow-utils. No GNU-specific extensions.

Test plan

Unit (`useruid_test.go`):

  • `TestGenerateUIDDockerfile_ContainsKeyDirectives` — Dockerfile shape (FROM/ARG/COPY/RUN).
  • `TestUIDReconcileScript_PortableShape` — guards against regressions: fails the build if `usermod`/`groupmod`/`getent` reappear in the script, and asserts the portable primitives are present.

Integration (`test/integration/uid_reconcile_test.go`, Linux-only):

  • `TestUpdateRemoteUserUID_ReconcilesUIDOnDebianAndAlpine` — parametrized over both distros. Builds a base image with vscode at UID 12345, runs Up against a workspace owned by the test runner's UID (different by construction in CI), and asserts `id -u vscode` inside the container matches the host UID. Skips on macOS (Docker Desktop ownership remap) and when runner UID happens to match the baked UID.

  • `TestUpdateRemoteUserUID_FalseLeavesUIDUnchanged` — negative path: `updateRemoteUserUID: false` keeps the image-default UID.

  • `go test ./...`

  • `go vet ./...` (incl. `-tags=integration`)

  • `gofmt` clean

  • `go build -tags=integration ./test/integration/...`

CI cost

Adds two integration tests building a small derivative each — ~30s total for the matrix entries (Alpine pull is cached after the first run). Acceptable given we're now actually exercising the feature on the path consumers use.

Summary by CodeRabbit

  • Tests

    • Added integration tests verifying UID reconciliation on Debian and Alpine and that disabling reconciliation preserves baked UIDs.
    • Added a unit test validating the portability/shape of the embedded UID-fix script.
  • Refactor

    • Rewrote UID reconciliation to use a portable, script-driven image build for more reliable cross-distro fixes and to ensure user home ownership is adjusted.
    • Reconciliation now applies on Linux only.

Review Change Stack

Replaces the usermod/groupmod/getent dependency chain with a script
that mutates /etc/passwd and /etc/group directly via awk + sed,
chowns \$HOME, and works on BusyBox/Alpine the same as on shadow-utils
Debian. Drops the previous \"Alpine: skip with warning\" guard since
the script no longer cares which toolchain shipped in the base image.

Limitations: doesn't touch /etc/shadow or /etc/gshadow. Those key by
username, not UID, so password-based login still works — only su(1)
semantics on hardened images would be affected, and no real
devcontainer flow depends on that.

Integration coverage:
- Parametrized test (debian + alpine) builds a base image with vscode
  at a fixed UID, runs Up against a workspace owned by the test
  runner UID, and asserts id -u vscode inside the container == host
  UID. Linux-only (macOS Docker Desktop remaps ownership).
- Negative path: updateRemoteUserUID=false leaves UID at the image
  default.

Closes #29 §2 (Alpine portability) and §4 (integration coverage).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 10, 2026

📝 Walkthrough

Walkthrough

Replaces inline Dockerfile UID edits with a standalone uid-fix.sh (awk/sed-based) run during image build, gates reconciliation to Linux hosts, updates Dockerfile generation to COPY and execute the script, and adds unit and Linux-only integration tests for Debian and Alpine and a disabled path.

Changes

UID Reconciliation Refactor

Layer / File(s) Summary
Host OS / Skip Criteria
useruid.go
Reconciliation now only runs on Linux; non-Linux hosts return early.
Script Implementation
useruid.go
Rewrites uidReconcileScript to parse /etc/passwd and /etc/group with awk/sed, be idempotent, and avoid usermod/groupmod/getent.
Docker Build Integration
useruid.go
Writes uid-fix.sh into temp build context (executable); generated Dockerfile COPYs and runs the script, then removes it.
Unit Test Updates
useruid_test.go
Dockerfile generation test updated to expect COPY uid-fix.sh + /bin/sh /tmp/uid-fix.sh; new test asserts script avoids banned commands and includes portable primitives.
Integration Test Helpers
test/integration/uid_reconcile_test.go
Adds buildVscodeUserImage and hostWorkspaceUID helpers for building distro images with a fixed vscode UID and reading host workspace owner UID.
Integration Tests
test/integration/uid_reconcile_test.go
Adds two Linux-only integration tests: reconcile-success on Debian+Alpine (container vscode UID matches host workspace owner) and updateRemoteUserUID:false preserves baked UID.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • crunchloop/devcontainer#30: Implements the initial updateRemoteUserUID reconciliation feature that this PR refactors for portability and adds integration test coverage for.

Poem

🐰 A shell script I wrote with a hop and a wink,
It edits passwd and group with awk and sed ink,
Debian and Alpine now dance in a line,
Tests build the images and check each UID fine,
Hooray — the little rabbit cheers, UID aligned!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: rewriting the UID reconciliation logic to use a portable shell script (awk/sed) instead of Debian-specific tools, supporting both Alpine and Debian.
Linked Issues check ✅ Passed All primary objectives from issue #29 are addressed: Alpine/BusyBox portability via portable awk/sed rewrite [§2], macOS skip gate [§3], and integration tests for Debian and Alpine [§4]. Re-attach path [§1] is documented as a known gap.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the linked issue requirements: portable shell script implementation, UID reconciliation logic, test coverage, and documentation. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch uid-alpine-portable

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

@bilby91
Copy link
Copy Markdown
Member Author

bilby91 commented May 10, 2026

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 10, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
useruid.go (1)

39-40: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Skip UID reconciliation on Darwin too.

The new integration coverage explicitly treats Linux as the only host where the workspace owner UID matches what the container bind mount sees. Leaving darwin enabled here can still rebuild the image to the macOS host UID/GID even though Docker Desktop remaps ownership differently, so updateRemoteUserUID becomes incorrect instead of a harmless no-op.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@useruid.go` around lines 39 - 40, The OS check currently allows Darwin to
proceed and can trigger updateRemoteUserUID incorrectly; change the runtime.GOOS
guard in useruid.go so only Linux proceeds (i.e., replace the condition that
checks != "linux" && != "darwin" with a single check for != "linux") so the
function returns early on all non-Linux hosts and avoids rebuilding images or
calling updateRemoteUserUID on Darwin/macOS; verify the code paths around
finalImage and updateRemoteUserUID still behave as no-ops when the guard returns
early.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/integration/uid_reconcile_test.go`:
- Around line 188-199: The test currently asserts on res.Stdout without
verifying the command succeeded; update the negative-path block after eng.Exec
(the call that runs "id -u vscode" with devcontainer.ExecOptions and returns
res) to first check res.ExitCode (and err) and fail fast with a clear t.Fatalf
including res.ExitCode and res.Stderr when the exit code is non-zero, then trim
and compare res.Stdout to strconv.Itoa(imageUID) only after confirming success —
mirror the positive-path check pattern used elsewhere so failures report
stderr/exit status.

In `@useruid.go`:
- Around line 173-199: The current script uses interpolated sed regexes and
literal comparisons that mis-handle usernames like "1000" or "vscode:users" and
will break on names with regex metacharacters; normalize and validate
_REMOTE_USER (reject or canonicalize forms containing ':' or all-digits) and
replace the sed-based rewrites with field-aware awk edits: use awk to locate and
print/modify the matching /etc/passwd row (matching $1==user) and the /etc/group
row (matching $3==gid or $1==old_group) to change only the UID/GID fields, or if
you must use sed, properly escape _REMOTE_USER and OLD_GROUP_NAME before
interpolation; update the places referencing _REMOTE_USER, CUR_UID, CUR_GID,
OLD_GROUP_NAME and the two sed invocations to use the safe awk-based
replacements and add tests for numeric and "user:group" inputs.

---

Outside diff comments:
In `@useruid.go`:
- Around line 39-40: The OS check currently allows Darwin to proceed and can
trigger updateRemoteUserUID incorrectly; change the runtime.GOOS guard in
useruid.go so only Linux proceeds (i.e., replace the condition that checks !=
"linux" && != "darwin" with a single check for != "linux") so the function
returns early on all non-Linux hosts and avoids rebuilding images or calling
updateRemoteUserUID on Darwin/macOS; verify the code paths around finalImage and
updateRemoteUserUID still behave as no-ops when the guard returns early.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dc2c3390-bcd2-4d55-99b9-36cb34df365a

📥 Commits

Reviewing files that changed from the base of the PR and between ad340b0 and 323d9c9.

📒 Files selected for processing (3)
  • test/integration/uid_reconcile_test.go
  • useruid.go
  • useruid_test.go

Comment thread test/integration/uid_reconcile_test.go
Comment thread useruid.go Outdated
- Skip UID reconciliation on Darwin: macOS Docker Desktop remaps
  bind-mount ownership in the VM, so reconciling to the host UID is
  wrong, not a no-op. Linux-only matches the integration test guard.
- Replace interpolated sed regexes with field-aware awk rewrites for
  /etc/passwd and /etc/group. Avoids silent skips and wrong-row
  rewrites when remoteUser contains regex metacharacters or unusual
  forms (e.g. "foo.bar"). rewrite_file preserves inode/owner/mode.
- Negative-path integration test now asserts ExitCode == 0 before
  comparing stdout, so a failed `id -u vscode` no longer disguises
  itself as a UID mismatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
useruid.go (1)

212-221: 💤 Low value

Consider defensive check for pipeline failures.

POSIX set -e does not catch failures in non-last pipeline commands. If awk were to fail or produce empty output, rewrite_file would proceed and could truncate /etc/passwd or /etc/group.

The risk is low in practice (these awk programs are simple and awk is reliable), and set -o pipefail isn't portable to BusyBox ash. A defensive approach would validate the temp file isn't empty before overwriting:

🛡️ Optional: add empty-file guard in rewrite_file
 rewrite_file() {
     # $1 = path. Reads stdin, writes back to $1 in place.
     tmp=$1.dc-uid-tmp
     cat > "$tmp"
+    if [ ! -s "$tmp" ]; then
+        echo "updateRemoteUserUID: rewrite produced empty output for $1; aborting" >&2
+        rm "$tmp"
+        exit 1
+    fi
     cat "$tmp" > "$1"
     rm "$tmp"
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@useruid.go` around lines 212 - 221, The awk pipelines that rewrite /etc/group
and /etc/passwd can produce empty output on failure and then feed rewrite_file,
so add a defensive check: after each awk pipeline (the two blocks that call
rewrite_file) verify the generated temporary file is non-empty and valid before
letting rewrite_file replace the target; alternatively harden the rewrite_file
function to abort if its input file is empty or below a minimal size/line count
and return a non-zero exit code so the script fails instead of truncating
/etc/passwd or /etc/group. Reference the awk blocks operating on /etc/group and
/etc/passwd and the rewrite_file helper when applying this guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@useruid.go`:
- Around line 212-221: The awk pipelines that rewrite /etc/group and /etc/passwd
can produce empty output on failure and then feed rewrite_file, so add a
defensive check: after each awk pipeline (the two blocks that call rewrite_file)
verify the generated temporary file is non-empty and valid before letting
rewrite_file replace the target; alternatively harden the rewrite_file function
to abort if its input file is empty or below a minimal size/line count and
return a non-zero exit code so the script fails instead of truncating
/etc/passwd or /etc/group. Reference the awk blocks operating on /etc/group and
/etc/passwd and the rewrite_file helper when applying this guard.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0d19d17d-b6c5-4f6f-bcf7-d33081674a04

📥 Commits

Reviewing files that changed from the base of the PR and between 323d9c9 and 1f464a0.

📒 Files selected for processing (3)
  • test/integration/uid_reconcile_test.go
  • useruid.go
  • useruid_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/integration/uid_reconcile_test.go
  • useruid_test.go

@bilby91 bilby91 merged commit 7222bb3 into main May 10, 2026
5 checks passed
@bilby91 bilby91 deleted the uid-alpine-portable branch May 11, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

updateRemoteUserUID: re-attach reconciliation + Alpine/BusyBox compat

1 participant