Skip to content

Commit 7625511

Browse files
bradymillerclaude
andauthored
fix(openemr-cmd): auto-chown bind mount via container before worktree remove (#833)
## Summary Follow-on to #826. The A5 permission probe catches container-uid files cleanly, but still requires the user to run a manual `sudo chown -R` before retry. The container itself has root, so it can do the chown for us before tear-down — eliminating the manual step in the common case. ## Change In `cmd_worktree_remove`, between the dir-exists check and the A5 probe: 1. Find the worktree's openemr container by compose project label (same pattern as `cmd_worktree_exec`). 2. If a container is running, run `docker exec -u root <id> chown -R <host-uid>:<host-gid> /var/www/localhost/htdocs/openemr`. 3. Best-effort: stderr suppressed, `|| true` swallows non-zero exits. The probe is the safety net for anything chown couldn't fix (container not running, root-owned files outside the openemr workdir, chown itself failed). 4. Probe runs as before; usually passes now. 5. Destructive ops proceed. ## Why now This was filed as the natural next step after a user hit the manual-chown requirement during cleanup of an in-flight worktree. The probe surfaced the issue cleanly, but the user had to context-switch into a sudo-capable terminal to fix it. With the container itself doing the chown, the most common case (root-owned files inside the openemr workdir) becomes invisible to the user. ## Tests (`tests/bats/openemr-cmd/remove_graceful.bats`) | Scenario | Assertion | |---|---| | Container running | docker.log shows `exec -u root <id> chown -R <uid>:<gid> /var/www/.../openemr` + "Auto-chowning bind mount via container" message | | No container running | chown step skipped silently (no exec invocation, no log line); remove still completes | | Chown returns non-zero | attempt logged, failure ignored, probe + destructive ops continue, remove still succeeds | ## Test plan - [x] Full hermetic bats suite — 293 tests, 0 failures - [x] `shellcheck utilities/openemr-cmd/openemr-cmd` clean - [ ] CI: BATS openemr-cmd (ubuntu-22.04) green - [ ] CI: BATS openemr-cmd (macos-14) green - [ ] CI: ShellCheck / actionlint green - [ ] CI: e2e jobs green (the lifecycle job's A5 probe step should still pass — the auto-chown runs first but the probe step intentionally re-creates the container-uid state, so the probe contract is unchanged) - [ ] CodeRabbit review Assisted-by: Claude Code <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * `openemr-cmd worktree remove` now attempts an auto-chown inside the running app container (when available) to help prevent permission-related removal failures. * Updated `openemr-cmd --version` to **1.0.48**. * **Bug Fixes** * Removal continues to succeed even if the container auto-chown attempt fails. * Improved failure guidance with two recovery options: start the stack and retry, or manually run `sudo chown -R` on the worktree directory. * **Tests** * Expanded BATS coverage for auto-chown behavior: confirmation abort, container running, container not running, and container chown errors. * Updated test helper behavior to source the correct portion of the command script. * **Chores** * Adjusted CI workflow concurrency to cancel in-progress runs only for pull requests. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 461d44a commit 7625511

4 files changed

Lines changed: 226 additions & 19 deletions

File tree

.github/workflows/test-bats-openemr-cmd-real-docker.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,19 @@ on:
5050
permissions:
5151
contents: read
5252

53-
# Multiple in-flight runs would race on docker daemon resources (image
54-
# pull, container names, port allocation). Serialize per ref.
53+
# On PR pushes: cancel any in-flight run for the same ref so the new SHA's
54+
# workflow registers as a check immediately. This keeps the All Checks
55+
# Passed rollup honest — without it, a queued workflow doesn't register
56+
# its jobs as checks on the new SHA, and the rollup (which waits for
57+
# whatever checks exist) goes green prematurely. On master pushes: keep
58+
# the in-flight run so sequential master history isn't lost. Matches the
59+
# pattern in vendored-contracts-self-test.yml.
60+
#
61+
# Cross-run docker daemon contention isn't a concern here: each workflow
62+
# run gets its own ephemeral GitHub-hosted runner with its own daemon.
5563
concurrency:
5664
group: ${{ github.workflow }}-${{ github.ref }}
57-
cancel-in-progress: false
65+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
5866

5967
jobs:
6068
worktree-lifecycle-e2e:

tests/bats/openemr-cmd/helpers.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ STUB
7979
# If the script is restructured, bump this constant — the test
8080
# 'OC_SCRIPT_FUNCS_END points at end of function defs' in helpers_pure.bats
8181
# will fail loudly when it drifts.
82-
OC_SCRIPT_FUNCS_END=1741
82+
OC_SCRIPT_FUNCS_END=1786
8383

8484
# Source ONLY the function definitions of openemr-cmd into the current shell.
8585
# Caller is responsible for setting OPENEMR_ROOT (and WT_STATE_FILE / others

tests/bats/openemr-cmd/remove_graceful.bats

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,14 @@ setup_full_worktree() {
165165
chmod 0700 "${sub}" 2>/dev/null || true
166166

167167
assert_failure
168-
# Clear, actionable error mentioning the unwritable dir + chown hint.
168+
# Clear, actionable error mentioning the unwritable dir + BOTH
169+
# recovery options (start-stack auto-chown OR manual sudo chown).
169170
assert_output --partial "is not writable by you"
170-
assert_output --partial "sudo chown -R"
171171
assert_output --partial "${sub}"
172+
# Option 1: start the stack first, auto-chown handles it.
173+
assert_output --partial "openemr-cmd worktree up"
174+
# Option 2: manual host-side sudo chown.
175+
assert_output --partial "sudo chown -R"
172176
# Nothing destructive ran: no compose down, no rm of dir, state intact.
173177
if grep -F -e " down " "${STUB_DIR}/docker.log" >/dev/null 2>&1; then
174178
cat "${STUB_DIR}/docker.log"
@@ -202,6 +206,156 @@ setup_full_worktree() {
202206
assert_output "false"
203207
}
204208

209+
# --- Auto-chown via container before destruction --------------------------
210+
# When the openemr container is still running, cmd_worktree_remove asks it
211+
# (as root, in-container) to chown the bind-mounted worktree back to the
212+
# host uid/gid BEFORE the destructive ops. This eliminates the manual
213+
# `sudo chown -R` step that users would otherwise hit when the container
214+
# wrote root-owned files (e.g. after drid). The A5 probe remains the
215+
# safety net for cases where the chown wasn't possible.
216+
217+
@test "remove: aborted at the prompt leaves NO side effects (no auto-chown, no destruction)" {
218+
# The auto-chown sequence runs AFTER the user's y/N confirmation.
219+
# If the user aborts, the container's ownership of bind-mounted files
220+
# must stay exactly as it was — chowning to the host uid would leave
221+
# apache (inside the container) unable to write to its own files on
222+
# hosts where host-uid != container-apache-uid (uid 1000), effectively
223+
# breaking the stack for continued use.
224+
setup_full_worktree feature-rm-abort -b
225+
local wt_dir
226+
wt_dir=$(jq -r '.["feature-rm-abort"].dir' "${STATE_FILE}")
227+
: > "${STUB_DIR}/docker.log"
228+
# Pipe 'n' to the prompt to abort; DOCKER_PS_OUTPUT is set so that
229+
# IF the auto-chown step were misplaced before the prompt, the chown
230+
# invocation would be recorded — its absence below proves the reorder.
231+
run bash -c "echo n | env \
232+
PATH='${STUB_DIR}:${PATH}' \
233+
OPENEMR_ROOT='${TMP_OPENEMR_ROOT}' \
234+
WORKTREE_PARENT='${TMP_WT_PARENT}' \
235+
DOCKER_PS_OUTPUT='fake-openemr-container-id' \
236+
'${SCRIPT}' worktree remove feature-rm-abort"
237+
assert_success
238+
assert_output --partial "Aborted."
239+
# The auto-chown banner must NOT have fired.
240+
refute_output --partial "Auto-chowning bind mount via container"
241+
# The chown docker exec must NOT have been recorded.
242+
if grep -F "exec -u root" "${STUB_DIR}/docker.log" >/dev/null 2>&1; then
243+
cat "${STUB_DIR}/docker.log"
244+
fail "auto-chown fired before the prompt; aborted remove left side effects"
245+
fi
246+
# And nothing destructive: dir still on disk, state entry still present.
247+
[[ -d "${wt_dir}" ]] || fail "worktree dir gone after aborted remove"
248+
run jq -r 'has("feature-rm-abort")' "${STATE_FILE}"
249+
assert_output "true"
250+
}
251+
252+
@test "remove: when container is running, docker exec chown is invoked with host uid:gid" {
253+
setup_full_worktree feature-rm-autochown -b
254+
local wt_dir
255+
wt_dir=$(jq -r '.["feature-rm-autochown"].dir' "${STATE_FILE}")
256+
: > "${STUB_DIR}/docker.log"
257+
# DOCKER_PS_OUTPUT is what the stub returns for any `docker ps ...`
258+
# query; the openemr-cmd auto-chown step uses that filter to find the
259+
# worktree's openemr container.
260+
local uid gid
261+
uid=$(id -u)
262+
gid=$(id -g)
263+
run bash -c "echo y | env \
264+
PATH='${STUB_DIR}:${PATH}' \
265+
OPENEMR_ROOT='${TMP_OPENEMR_ROOT}' \
266+
WORKTREE_PARENT='${TMP_WT_PARENT}' \
267+
DOCKER_PS_OUTPUT='fake-openemr-container-id' \
268+
'${SCRIPT}' worktree remove feature-rm-autochown"
269+
assert_success
270+
# User-facing message confirms the auto-chown ran.
271+
assert_output --partial "Auto-chowning bind mount via container"
272+
assert_output --partial "uid=${uid}"
273+
# The recorded docker invocation is the chown with the right shape:
274+
# exec -u root <id> chown -R <uid>:<gid> /var/www/.../openemr
275+
grep -Fq "exec -u root fake-openemr-container-id chown -R ${uid}:${gid} /var/www/localhost/htdocs/openemr" \
276+
"${STUB_DIR}/docker.log" \
277+
|| { cat "${STUB_DIR}/docker.log"; fail "expected docker exec chown invocation not recorded"; }
278+
# And the core outcome: the worktree directory itself is gone.
279+
[[ ! -e "${wt_dir}" ]] || fail "worktree directory still exists: ${wt_dir}"
280+
}
281+
282+
@test "remove: when container is NOT running, auto-chown step is skipped silently" {
283+
setup_full_worktree feature-rm-no-container -b
284+
local wt_dir
285+
wt_dir=$(jq -r '.["feature-rm-no-container"].dir' "${STATE_FILE}")
286+
: > "${STUB_DIR}/docker.log"
287+
# DOCKER_PS_OUTPUT empty → the chown lookup returns nothing → step skipped.
288+
run bash -c "echo y | env \
289+
PATH='${STUB_DIR}:${PATH}' \
290+
OPENEMR_ROOT='${TMP_OPENEMR_ROOT}' \
291+
WORKTREE_PARENT='${TMP_WT_PARENT}' \
292+
'${SCRIPT}' worktree remove feature-rm-no-container"
293+
assert_success
294+
# No "Auto-chowning" message; no chown invocation in log.
295+
refute_output --partial "Auto-chowning bind mount via container"
296+
if grep -F "exec -u root" "${STUB_DIR}/docker.log" >/dev/null 2>&1; then
297+
cat "${STUB_DIR}/docker.log"
298+
fail "auto-chown ran despite no container being detected"
299+
fi
300+
# And remove still succeeded — state entry gone, dir gone.
301+
run jq -r 'has("feature-rm-no-container")' "${STATE_FILE}"
302+
assert_output "false"
303+
[[ ! -e "${wt_dir}" ]] || fail "worktree directory still exists: ${wt_dir}"
304+
}
305+
306+
@test "remove: auto-chown failure is non-fatal — probe is still the safety net" {
307+
# Use a stub variant where `docker exec` returns non-zero (simulating a
308+
# chown failure inside the container). The remove flow should still
309+
# complete: chown failure is logged + ignored, probe finds no
310+
# unwritable dirs (since the fixture doesn't have any), destructive
311+
# ops proceed.
312+
local failing_stub
313+
failing_stub=$(oc_mktempdir)
314+
cat > "${failing_stub}/docker" <<'STUB'
315+
#!/bin/sh
316+
echo "$@" >> STUBLOG
317+
# Plugin probe must succeed so check_docker_compose_install picks compose.
318+
if [ "$1" = "compose" ] && [ "$#" = "1" ]; then exit 0; fi
319+
# `ps` queries return the fake container id so the auto-chown reaches exec.
320+
case " $* " in
321+
*' ps '*) echo 'fake-id-but-exec-will-fail' ;;
322+
esac
323+
# Fail `docker exec ...` so the auto-chown returns non-zero. The script's
324+
# `|| true` should swallow this and continue.
325+
if [ "$1" = "exec" ]; then exit 17; fi
326+
exit 0
327+
STUB
328+
sed -i.bak "s|STUBLOG|${failing_stub}/docker.log|g" "${failing_stub}/docker" 2>/dev/null \
329+
|| sed -i "" "s|STUBLOG|${failing_stub}/docker.log|g" "${failing_stub}/docker"
330+
rm -f "${failing_stub}/docker.bak"
331+
: > "${failing_stub}/docker.log"
332+
chmod +x "${failing_stub}/docker"
333+
334+
setup_full_worktree feature-rm-chown-fails -b
335+
local wt_dir
336+
wt_dir=$(jq -r '.["feature-rm-chown-fails"].dir' "${STATE_FILE}")
337+
# Re-record the docker log against the failing stub (setup_full_worktree
338+
# used the original STUB_DIR for the `add`; the remove below uses the
339+
# failing stub).
340+
run bash -c "echo y | env \
341+
PATH='${failing_stub}:${PATH}' \
342+
OPENEMR_ROOT='${TMP_OPENEMR_ROOT}' \
343+
WORKTREE_PARENT='${TMP_WT_PARENT}' \
344+
'${SCRIPT}' worktree remove feature-rm-chown-fails"
345+
assert_success
346+
# The chown invocation WAS attempted (proves the failure wasn't a
347+
# silent skip), even though it returned non-zero.
348+
grep -Fq "exec -u root fake-id-but-exec-will-fail chown" "${failing_stub}/docker.log" \
349+
|| { cat "${failing_stub}/docker.log"; fail "auto-chown attempt not recorded"; }
350+
# Remove still succeeded — state gone, dir gone.
351+
run jq -r 'has("feature-rm-chown-fails")' "${STATE_FILE}"
352+
assert_output "false"
353+
[[ ! -e "${wt_dir}" ]] || fail "worktree directory still exists: ${wt_dir}"
354+
rm -rf "${failing_stub}"
355+
}
356+
357+
# --- existing tamper guard ------------------------------------------------
358+
205359
@test "remove: when state dir is outside WORKTREE_PARENT, validation refuses early (no destructive action)" {
206360
# Tamper with the state file so dir points OUTSIDE WORKTREE_PARENT.
207361
# wt_validate_dir (invoked inside wt_compose_cmd before any docker or

utilities/openemr-cmd/openemr-cmd

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ set -euo pipefail
1818
#################################################################################################
1919

2020
# Increment the version when modify script
21-
VERSION="1.0.47"
21+
VERSION="1.0.48"
2222

2323
# ============================================================================
2424
# WORKTREE STATE MANAGEMENT
@@ -835,16 +835,60 @@ cmd_worktree_remove() {
835835
return 0
836836
fi
837837

838-
# Probe for permission issues BEFORE any destructive op. The openemr
839-
# container writes files into the bind-mounted worktree as a non-host
840-
# uid (root/apache), which makes subdirectories unwritable for the host
841-
# user. If we proceed without catching that, we'd:
838+
echo "This will remove worktree '${branch}' at ${dir}."
839+
if ! ${purge}; then echo " --keep-volumes: Docker volumes will be preserved."; fi
840+
read -rp "Continue? [y/N] " confirm
841+
[[ "${confirm}" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
842+
843+
# Best-effort auto-chown: when the openemr container is still running,
844+
# ask it (as root) to chown the bind-mounted worktree back to the host
845+
# uid/gid before we try to remove anything. The container writes files
846+
# as root during install / drid / other admin paths; on hosts where
847+
# the operator's uid != the container's apache uid, those files become
848+
# unreadable + unremovable from the host side. Doing the chown from
849+
# INSIDE the container (where it has root) sidesteps the manual
850+
# `sudo chown -R` step that users would otherwise hit. The probe below
851+
# is still the safety net — it catches anything chown missed (container
852+
# not running, root-owned files outside the openemr workdir, etc.).
853+
#
854+
# Runs AFTER the user's y/N confirmation, not before. The chown is
855+
# observable side effect for hosts where host-uid != container-apache-uid
856+
# (the bind mount's ownership changes to the host user; apache in the
857+
# container then cannot write to those files). On abort, that side
858+
# effect would leave the stack effectively broken for continued use.
859+
# Confirming first means abort genuinely leaves no trace.
860+
local slug=""
861+
slug=$(wt_slug "${branch}")
862+
local autochown_id=""
863+
autochown_id=$(docker ps \
864+
--filter "label=com.docker.compose.project=openemr-${slug}" \
865+
--filter "label=com.docker.compose.service=openemr" \
866+
--format '{{.ID}}' 2>/dev/null | head -n 1 || true)
867+
if [[ -n "${autochown_id}" ]]; then
868+
# Capture uid/gid once (shellcheck SC2312: don't mask command
869+
# substitution return values inside an expanded string).
870+
local host_uid host_gid
871+
host_uid=$(id -u)
872+
host_gid=$(id -g)
873+
wt_info "Auto-chowning bind mount via container (uid=${host_uid} gid=${host_gid})"
874+
# `|| true`: chown failures are non-fatal; the probe below catches
875+
# any unwritable dirs the chown couldn't fix.
876+
docker exec -u root "${autochown_id}" \
877+
chown -R "${host_uid}:${host_gid}" /var/www/localhost/htdocs/openemr \
878+
2>/dev/null || true
879+
fi
880+
881+
# Probe for permission issues before the destructive ops. After the
882+
# auto-chown step above, this usually passes — but it remains as the
883+
# safety net for: (a) container wasn't running, (b) root-owned files
884+
# outside /var/www/localhost/htdocs/openemr, (c) chown itself failed.
885+
# If we proceed without catching that, we'd:
842886
# 1. purge the compose volumes (wt_compose_cmd down --volumes),
843887
# 2. let `git worktree remove --force` unregister the worktree, and
844888
# 3. fail mid-rm with EACCES — leaving partial state on disk + a
845889
# state entry pointing at a no-longer-registered worktree, which
846890
# a chown+retry can't recover from (wt_validate_dir rejects the
847-
# now-orphan dir). Bail early with a clear chown hint instead.
891+
# now-orphan dir). Bail with a clear chown hint instead.
848892
# `[[ -w ${d} ]]` uses access(2) for the current uid; we walk every
849893
# subdir because `rm` needs write+execute on each PARENT to unlink
850894
# children. Not using `find -writable` (GNU-only — BSD/macOS lacks it).
@@ -860,15 +904,16 @@ cmd_worktree_remove() {
860904
# releases the lock — no manual release needed.
861905
wt_die "Cannot remove worktree '${branch}': directory '${unwritable}' is not writable by you.
862906
This usually means the docker container wrote files into the worktree as a
863-
different uid (root/apache). Restore ownership and retry:
864-
sudo chown -R \"\$(id -u):\$(id -g)\" '${dir}'"
907+
different uid (root/apache). Two ways to recover:
908+
(1) Start the stack first; the auto-chown step will fix permissions
909+
from inside the container, then re-run remove:
910+
openemr-cmd worktree up '${branch}'
911+
openemr-cmd worktree remove '${branch}'
912+
(2) Or restore ownership manually on the host (requires sudo):
913+
sudo chown -R \"\$(id -u):\$(id -g)\" '${dir}'
914+
openemr-cmd worktree remove '${branch}'"
865915
fi
866916

867-
echo "This will remove worktree '${branch}' at ${dir}."
868-
if ! ${purge}; then echo " --keep-volumes: Docker volumes will be preserved."; fi
869-
read -rp "Continue? [y/N] " confirm
870-
[[ "${confirm}" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
871-
872917
wt_info "Stopping Docker stack"
873918
if ${purge}; then
874919
wt_compose_cmd "${branch}" down --volumes 2>/dev/null

0 commit comments

Comments
 (0)