Skip to content

Commit 82bf49f

Browse files
authored
fix(openemr-cmd): pre-create volume mount-point dirs + loosen remove probe (#838)
## Summary Two complementary fixes for the bug surfaced in #836's multi-3 e2e job ("ccdaservice/packages/oe-cqm-service/node_modules is not writable by you"). Defense in depth against the same root cause. ### (1) Pre-create volume mount-point dirs New helper `wt_precreate_volume_mountpoints` parses each env's `docker-compose.yml` for volume mounts targeting paths under `/var/www/localhost/htdocs/openemr/` and pre-creates the host-side dirs as the current user before docker first attaches the volumes. **Without this:** docker creates missing mount-point dirs on the host as `root:root 0755`. After `compose down --volumes`, those empty root-owned dirs reappear on the host and trip the writability probe on the next `worktree remove`. **With this:** docker sees the dirs already exist (owned by host user) and just attaches the volume — host ownership preserved across the volume's lifetime. Called from both `cmd_worktree_add` and `cmd_worktree_regen` so both fresh and env-switched worktrees are covered. Idempotent (mkdir -p is safe on existing dirs, including the ones already checked into the repo like `public/assets`, `sites`). ### (2) Loosen the writability probe `cmd_worktree_remove`'s probe now accepts the case where a non-writable dir is **empty** AND its parent is writable — because `rm` only needs write+execute on the PARENT to rmdir an empty child, not write on the child itself. Catches any drift where pre-create misses a mount point (e.g., openemr/openemr adds a new volume and the regex doesn't update). Non-empty non-writable dirs still fail the probe since rm there would genuinely fail mid-walk. ### Coverage matrix | Ownership case | Handler | |---|---| | Container-written files in bind mount (apache uid) | `#833` auto-chown via `docker exec` | | Empty docker-daemon-created mount-point dirs (root) | Pre-create (option 1) | | Pre-create drift / future new volumes | Probe loosening (option 2) | | Non-empty unwritable dirs | Probe still fails (correct — rm would too) | The remaining apache-uid mismatch (apache inside container chowns the bind mount to uid=1000 on hosts where uid != 1000) is orthogonal and tracked separately as the HOST_UID-passthrough work. ### CI workaround removed The "Pre-remove host-side chown" step in `worktree-multi-concurrent-e2e` (added in #836 commit `cf99562`) is no longer needed. This PR removes that step from the workflow file as part of the fix — proving the change works end-to-end in CI. ## Test plan - [ ] New bats file `tests/bats/openemr-cmd/mountpoint_precreate.bats` exercises 5 precreate cases (creates expected dirs, skips out-of-webroot mounts, skips the bind mount itself, runs from regen path, idempotent) + 2 probe-loosening cases (empty-removable passes, non-empty fails) - [ ] OC_SCRIPT_FUNCS_END bumped 1786 → 1861 to track the new function's landing position - [ ] CI's multi-3 e2e job passes without the sudo-chown workaround that was just removed - [ ] Other e2e jobs (lifecycle, prek, functional, nonworktree) still pass — pre-create is additive, doesn't change existing behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced worktree setup to pre-create named-volume webroot mount-point directories from compose targets, and to repeat the same behavior during `worktree regen`. * **Bug Fixes** * Updated worktree removal to allow removing empty, non-writable directories when the parent is writable; non-empty non-writable directories still block removal. * **Tests** * Added BATS coverage for pre-create behavior (idempotent regen, symlink-safe traversal, webroot-only creation, and correct handling of bind mounts) and the adjusted removal permission checks. * **CI** * Streamlined teardown by removing a prior host-side ownership workaround and relying on existing auto-ownership behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent e4a1e46 commit 82bf49f

4 files changed

Lines changed: 346 additions & 34 deletions

File tree

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

Lines changed: 8 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -670,44 +670,19 @@ jobs:
670670
fi
671671
done
672672
673-
- name: Pre-remove host-side chown (covers volume-shadowed mount-point dirs)
674-
run: |
675-
# #833's auto-chown via `docker exec` chowns files INSIDE the
676-
# bind mount, but it cannot reach the host-side dirs that
677-
# docker created as root when first mounting named volumes
678-
# OVER bind-mount paths (ccdaservice/node_modules,
679-
# ccdaservice/packages/oe-cqm-service/node_modules — both
680-
# volumes shadow worktree dirs). Those dirs persist on the
681-
# host owned by root; when `git worktree remove --force`
682-
# runs, the writability probe correctly catches them and
683-
# refuses to proceed.
684-
#
685-
# Fix: pre-emptively sudo chown each worktree dir before
686-
# remove. This handles both the volume-shadow case AND any
687-
# apache-uid-owned bind-mount files (the latter is what
688-
# auto-chown also handles; the two paths are complementary
689-
# but harmless overlap). The lifecycle job sidesteps this
690-
# by sudo-chowning between env switches (line ~325); multi-3
691-
# doesn't have a natural env-switch hook, so we do it here.
692-
#
693-
# Longer-term fix is the HOST_UID-passthrough work tracked
694-
# against the openemr container — once apache inside the
695-
# container adopts the runner's uid, the volume mount points
696-
# would be created with runner ownership in the first place.
697-
for dir in openemr-wt-multi-easy openemr-wt-multi-light openemr-wt-multi-redis; do
698-
sudo chown -R "$(id -u):$(id -g)" "${{ github.workspace }}/${dir}"
699-
done
700-
701673
- name: Down + remove all three worktrees (auto-chown handles container-written files)
702674
working-directory: openemr
703675
run: |
704676
# Stacks still running → openemr-cmd's auto-chown step from
705677
# #833 fires before each compose down to handle any apache-
706-
# written files that the pre-remove sudo chown missed (the
707-
# interval between that step and this one is short, but the
708-
# container could have written log/cache files in that
709-
# window). Volume-shadowed host dirs were already handled
710-
# by the pre-remove chown above.
678+
# written files in the bind mount.
679+
#
680+
# Volume mount-point dirs (ccdaservice/node_modules etc.)
681+
# are now host-owned from the start: `worktree add` pre-
682+
# creates them via wt_precreate_volume_mountpoints so docker
683+
# attaches the named volumes without creating root-owned
684+
# host dirs. The previous CI workaround (a pre-remove
685+
# `sudo chown -R`) is no longer needed.
711686
echo y | openemr-cmd worktree remove multi-easy
712687
echo y | openemr-cmd worktree remove multi-light
713688
echo y | openemr-cmd worktree remove multi-redis

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=1786
82+
OC_SCRIPT_FUNCS_END=1882
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
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# BATS: volume-mount-point pre-create + probe-loosening behavior.
2+
#
3+
# Covers two related changes to cmd_worktree_add / cmd_worktree_set_env /
4+
# cmd_worktree_remove:
5+
#
6+
# (1) wt_precreate_volume_mountpoints parses each env's
7+
# docker-compose.yml for volume mounts that target paths under
8+
# /var/www/localhost/htdocs/openemr/ and pre-creates the host-side
9+
# dirs as the current user. Without this, docker creates the missing
10+
# mount-point dirs on the host as root:root 0755 when first
11+
# attaching a named volume — those empty root-owned dirs then trip
12+
# the writability probe in cmd_worktree_remove.
13+
#
14+
# (2) cmd_worktree_remove's writability probe correctly accepts the
15+
# case where a non-writable dir is EMPTY and its parent is writable
16+
# (rm only needs to rmdir, which uses the parent's write+execute,
17+
# not the child's write bit). The probe still fails for non-empty
18+
# non-writable dirs since rm there would genuinely fail mid-walk.
19+
#
20+
# These tests don't exercise docker — the stub script accepts every call
21+
# and emits nothing — so the precreate dirs come from openemr-cmd's own
22+
# pre-create step, not from the docker daemon.
23+
24+
load '../test_helper/bats-support/load'
25+
load '../test_helper/bats-assert/load'
26+
load 'helpers'
27+
28+
# Custom fixture: realistic compose files with volume mounts that mirror
29+
# the actual openemr/openemr docker-compose.yml shape. Required because
30+
# the default oc_init_repo_with_fixtures writes placeholder compose
31+
# files with no volume entries — those would make precreate a no-op and
32+
# leave the tests unable to assert anything.
33+
mp_init_repo() {
34+
local dir=$1
35+
oc_init_repo "${dir}"
36+
mkdir -p "${dir}/docker/library"
37+
: > "${dir}/docker/library/.placeholder"
38+
local env
39+
for env in easy easy-light easy-redis; do
40+
mkdir -p "${dir}/docker/development-${env}"
41+
cat > "${dir}/docker/development-${env}/docker-compose.yml" <<'COMPOSE'
42+
services:
43+
openemr:
44+
volumes:
45+
- ${OPENEMR_DIR:-../..}:/openemr:ro
46+
- ${OPENEMR_DIR:-../..}:/var/www/localhost/htdocs/openemr:rw
47+
- assetvolume:/var/www/localhost/htdocs/openemr/public/assets:rw
48+
- themevolume:/var/www/localhost/htdocs/openemr/public/themes:rw
49+
- sitesvolume:/var/www/localhost/htdocs/openemr/sites:rw
50+
- nodemodules:/var/www/localhost/htdocs/openemr/node_modules:rw
51+
- vendordir:/var/www/localhost/htdocs/openemr/vendor:rw
52+
- phpstanvolume:/var/www/localhost/htdocs/openemr/tmp-phpstan:rw
53+
- webpackcachevolume:/var/www/localhost/htdocs/openemr/.webpack-cache:rw
54+
- ccdanodemodules:/var/www/localhost/htdocs/openemr/ccdaservice/node_modules:rw
55+
- ccdanodemodules2:/var/www/localhost/htdocs/openemr/ccdaservice/packages/oe-cqm-service/node_modules:rw
56+
- logvolume:/var/log
57+
- couchdbvolume:/couchdb/data
58+
COMPOSE
59+
done
60+
git -C "${dir}" add docker
61+
git -C "${dir}" commit --quiet -m "fixture: compose with realistic volume mounts"
62+
}
63+
64+
setup() {
65+
SCRIPT="$(oc_script_path)"
66+
[[ -x "$SCRIPT" ]] || skip "openemr-cmd script not found"
67+
TMP_PARENT=$(oc_mktempdir)
68+
TMP_ROOT="${TMP_PARENT}/primary"
69+
mkdir -p "${TMP_ROOT}"
70+
mp_init_repo "${TMP_ROOT}"
71+
STUB_DIR=$(oc_make_docker_stub_dir)
72+
STATE_FILE="${TMP_ROOT}/.worktrees.json"
73+
export TMP_PARENT TMP_ROOT STUB_DIR STATE_FILE
74+
}
75+
76+
teardown() {
77+
# Restore user write on any chmod-0555 dirs (the probe-loosening tests
78+
# below create those to simulate root-owned dirs without needing sudo).
79+
# Without this, rm -rf in cleanup would fail on the non-empty 0555 case.
80+
[[ -n "${TMP_PARENT:-}" ]] && find "${TMP_PARENT}" -type d -exec chmod u+rwx {} + 2>/dev/null
81+
[[ -n "${TMP_PARENT:-}" ]] && rm -rf "${TMP_PARENT}"
82+
[[ -n "${STUB_DIR:-}" ]] && rm -rf "${STUB_DIR}"
83+
return 0
84+
}
85+
86+
oc_add() {
87+
env \
88+
PATH="${STUB_DIR}:${PATH}" \
89+
OPENEMR_ROOT="${TMP_ROOT}" \
90+
WORKTREE_PARENT="${TMP_PARENT}" \
91+
WT_CANONICAL_URL="file://${TMP_ROOT}" \
92+
"${SCRIPT}" worktree add "$@"
93+
}
94+
95+
oc_remove() {
96+
env \
97+
PATH="${STUB_DIR}:${PATH}" \
98+
OPENEMR_ROOT="${TMP_ROOT}" \
99+
WORKTREE_PARENT="${TMP_PARENT}" \
100+
"${SCRIPT}" worktree remove "$@"
101+
}
102+
103+
# --- pre-create behavior ----------------------------------------------------
104+
105+
@test "precreate: worktree add creates host-side dirs for all volume mounts under the webroot" {
106+
oc_add pc-add -b --env easy >/dev/null
107+
local wt="${TMP_PARENT}/openemr-wt-pc-add"
108+
# Every named-volume target under /var/www/localhost/htdocs/openemr/
109+
# in the fixture compose should have a matching pre-created host dir.
110+
for sub in \
111+
public/assets \
112+
public/themes \
113+
sites \
114+
node_modules \
115+
vendor \
116+
tmp-phpstan \
117+
.webpack-cache \
118+
ccdaservice/node_modules \
119+
ccdaservice/packages/oe-cqm-service/node_modules; do
120+
[[ -d "${wt}/${sub}" ]] || fail "expected pre-created dir missing: ${sub}"
121+
done
122+
}
123+
124+
@test "precreate: does NOT create dirs for volumes targeting paths OUTSIDE the webroot" {
125+
oc_add pc-out -b --env easy >/dev/null
126+
local wt="${TMP_PARENT}/openemr-wt-pc-out"
127+
# logvolume → /var/log and couchdbvolume → /couchdb/data are NOT
128+
# under the webroot, so no host-side dir should be created for them.
129+
[[ ! -d "${wt}/var" ]] || fail "should not pre-create host dir for /var/log mount"
130+
[[ ! -d "${wt}/couchdb" ]] || fail "should not pre-create host dir for /couchdb/data mount"
131+
}
132+
133+
@test "precreate: does NOT match the bind mount line itself (target is /var/www/.../openemr with no subpath)" {
134+
oc_add pc-bind -b --env easy >/dev/null
135+
local wt="${TMP_PARENT}/openemr-wt-pc-bind"
136+
# The bind mount `${OPENEMR_DIR:-../..}:/var/www/localhost/htdocs/openemr:rw`
137+
# has no subpath after openemr — our regex requires `/openemr/` + at
138+
# least one non-`:` char. Confirm we didn't try to mkdir something
139+
# weird at the worktree root level.
140+
[[ ! -d "${wt}/openemr" ]] || fail "should not create stray dir from bind mount line"
141+
}
142+
143+
@test "precreate: regen also pre-creates dirs (env switch via regen path)" {
144+
oc_add pc-regen -b --env easy >/dev/null
145+
local wt="${TMP_PARENT}/openemr-wt-pc-regen"
146+
# Delete a precreate dir to simulate user/tooling cleanup, then regen
147+
# to confirm the precreate runs from the regen path too.
148+
rm -rf "${wt}/node_modules"
149+
[[ ! -d "${wt}/node_modules" ]] || fail "fixture: node_modules should be gone before regen"
150+
run env PATH="${STUB_DIR}:${PATH}" OPENEMR_ROOT="${TMP_ROOT}" \
151+
WORKTREE_PARENT="${TMP_PARENT}" \
152+
"${SCRIPT}" worktree regen pc-regen
153+
assert_success
154+
[[ -d "${wt}/node_modules" ]] || fail "regen did not re-pre-create node_modules"
155+
}
156+
157+
@test "precreate: idempotent — re-running on existing dirs is a no-op" {
158+
oc_add pc-idem -b --env easy >/dev/null
159+
local wt="${TMP_PARENT}/openemr-wt-pc-idem"
160+
# Add a marker file inside a pre-created dir, then regen, then
161+
# confirm the marker survived (idempotent: mkdir -p doesn't reset
162+
# existing dirs).
163+
echo "marker" > "${wt}/vendor/marker.txt"
164+
run env PATH="${STUB_DIR}:${PATH}" OPENEMR_ROOT="${TMP_ROOT}" \
165+
WORKTREE_PARENT="${TMP_PARENT}" \
166+
"${SCRIPT}" worktree regen pc-idem
167+
assert_success
168+
[[ -f "${wt}/vendor/marker.txt" ]] || fail "regen reset existing dir content"
169+
[[ "$(cat "${wt}/vendor/marker.txt")" = "marker" ]] || fail "marker content changed"
170+
}
171+
172+
@test "precreate: refuses to descend through a symlinked path component (no traversal outside worktree)" {
173+
# Simulate a malicious-branch scenario where one of the volume
174+
# mount target's path components is committed as a symlink. The
175+
# naive `mkdir -p $dir/public/assets` would follow the symlink
176+
# and create `assets` at the symlink target — outside the worktree.
177+
# Pre-create's component-walk must refuse to descend through the
178+
# symlink and abandon that mount target instead.
179+
oc_add pc-symlink -b --env easy >/dev/null
180+
local wt="${TMP_PARENT}/openemr-wt-pc-symlink"
181+
# Stage a sentinel target outside the worktree so we can detect
182+
# any traversal.
183+
local outside="${TMP_PARENT}/outside-victim"
184+
mkdir -p "${outside}"
185+
186+
# Replace the existing `public` dir (created by precreate) with a
187+
# symlink pointing outside the worktree. Then re-run precreate via
188+
# regen to trigger the walk against the now-symlinked component.
189+
rm -rf "${wt}/public"
190+
ln -s "${outside}" "${wt}/public"
191+
192+
run env PATH="${STUB_DIR}:${PATH}" OPENEMR_ROOT="${TMP_ROOT}" \
193+
WORKTREE_PARENT="${TMP_PARENT}" \
194+
"${SCRIPT}" worktree regen pc-symlink
195+
assert_success
196+
197+
# Sentinel: pre-create must NOT have created `assets` (or anything
198+
# else from the symlinked component) at the symlink target.
199+
[[ ! -e "${outside}/assets" ]] \
200+
|| fail "traversal: precreate created '${outside}/assets' via the symlinked 'public' component"
201+
[[ ! -e "${outside}/themes" ]] \
202+
|| fail "traversal: precreate created '${outside}/themes' via the symlinked 'public' component"
203+
}
204+
205+
# --- probe loosening -------------------------------------------------------
206+
207+
@test "remove probe: passes when non-writable dir is EMPTY and parent is writable (rmdir-able case)" {
208+
oc_add probe-empty -b --env easy >/dev/null
209+
local wt="${TMP_PARENT}/openemr-wt-probe-empty"
210+
# Simulate the docker-daemon-creates-empty-root-owned-dir case. Can't
211+
# actually chown to root without sudo, but chmod 0555 removes the
212+
# write bit even for the owner — so `[[ -w "${dir}" ]]` correctly
213+
# returns false. Parent (`${wt}`) is still writable; dir is empty.
214+
# Probe should accept (rm could rmdir this).
215+
mkdir -p "${wt}/probe-blocker"
216+
chmod 0555 "${wt}/probe-blocker"
217+
218+
run oc_remove probe-empty <<< 'y'
219+
# Restore perms before any assertion failure so teardown can clean up.
220+
chmod 0755 "${wt}/probe-blocker" 2>/dev/null || true
221+
assert_success
222+
refute_output --partial "is not writable by you"
223+
}
224+
225+
@test "remove probe: still fails when non-writable dir is NON-EMPTY (rm would actually fail)" {
226+
oc_add probe-full -b --env easy >/dev/null
227+
local wt="${TMP_PARENT}/openemr-wt-probe-full"
228+
# Non-empty 0555 dir: rm can't unlink the file inside without write
229+
# on the dir itself. Probe MUST still flag this — silently passing
230+
# would let `git worktree remove --force` fail mid-walk and leave
231+
# partial state on disk.
232+
mkdir -p "${wt}/probe-blocker"
233+
touch "${wt}/probe-blocker/contents"
234+
chmod 0555 "${wt}/probe-blocker"
235+
236+
run oc_remove probe-full <<< 'y'
237+
chmod 0755 "${wt}/probe-blocker" 2>/dev/null || true
238+
assert_failure
239+
assert_output --partial "is not writable by you"
240+
assert_output --partial "probe-blocker"
241+
}

0 commit comments

Comments
 (0)