Skip to content

fix(openemr-cmd): pre-create volume mount-point dirs + loosen remove probe#838

Merged
bradymiller merged 4 commits into
openemr:masterfrom
bradymiller:fix/worktree-volume-mountpoint-precreate
Jun 26, 2026
Merged

fix(openemr-cmd): pre-create volume mount-point dirs + loosen remove probe#838
bradymiller merged 4 commits into
openemr:masterfrom
bradymiller:fix/worktree-volume-mountpoint-precreate

Conversation

@bradymiller

@bradymiller bradymiller commented Jun 25, 2026

Copy link
Copy Markdown
Member

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

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.

…probe

Two complementary fixes for the same root cause — defense in depth
against the writability-probe failure that surfaced in openemr#836's multi-3
e2e job.

(1) 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 — those empty root-owned dirs accumulate in
worktrees and trip the writability probe on the next
worktree remove. Called from cmd_worktree_add and cmd_worktree_regen
so both fresh worktrees and env-switched/regenerated ones are
covered. Idempotent (mkdir -p safe on existing dirs).

(2) cmd_worktree_remove's writability 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.

Auto-chown via docker exec (openemr#833) handles the orthogonal case of
container-written files INSIDE the bind mount. Together with the
two changes here, all three host-side ownership scenarios are
covered:
  - container-written files in bind mount → openemr#833 auto-chown
  - empty docker-daemon-created mount-point dirs → pre-create + probe
  - non-empty root-owned dirs → probe still fails (correct)

The remaining apache-uid mismatch (apache inside container chowns
the bind mount to uid=1000) is a separate concern, tracked as the
HOST_UID-passthrough work in openemr/openemr + openemr-devops.

CI workaround removed: the "Pre-remove host-side chown" step in
the worktree-multi-concurrent-e2e job (added in openemr#836 commit cf99562)
is no longer needed — pre-create handles the volume mount-point dirs
upfront, and auto-chown handles the rest.

Tests: tests/bats/openemr-cmd/mountpoint_precreate.bats pins both
behaviors (5 precreate cases + 2 probe-loosening cases). OC_SCRIPT_FUNCS_END
bumped 1786 → 1861 to match the new function landing position.

Assisted-by: Claude Code
@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 69033d69-bd82-4bdb-a6e3-904aafea2075

📥 Commits

Reviewing files that changed from the base of the PR and between c3eef03 and ca10561.

📒 Files selected for processing (1)
  • tests/bats/openemr-cmd/helpers.bash

📝 Walkthrough

Walkthrough

The PR precreates webroot volume mount directories during worktree add and worktree regen, loosens worktree remove for empty unwritable directories, adds BATS coverage for both behaviors, and removes a workflow chown workaround in concurrent teardown.

Changes

OpenEMR worktree lifecycle changes

Layer / File(s) Summary
Mountpoint precreation
utilities/openemr-cmd/openemr-cmd, tests/bats/openemr-cmd/helpers.bash, tests/bats/openemr-cmd/mountpoint_precreate.bats
openemr-cmd adds wt_precreate_volume_mountpoints, wires it into worktree add and worktree regen, and the BATS harness updates its sourced-function boundary for the new tests.
Removal probe loosening
utilities/openemr-cmd/openemr-cmd, tests/bats/openemr-cmd/mountpoint_precreate.bats
worktree remove now skips empty unwritable directories when the parent is writable, and the new BATS cases cover the empty and non-empty directory paths.
Concurrent teardown cleanup
.github/workflows/test-bats-openemr-cmd-real-docker.yml
The concurrent real-docker teardown workflow removes the pre-remove host-side chown step and updates the inline teardown comments.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • openemr/openemr-devops#818: Shares the same real-docker teardown path that drops the host-side recursive chown workaround in favor of openemr-cmd auto-chown.
  • openemr/openemr-devops#826: Modifies cmd_worktree_remove permission checks and covers the same unwritable-directory removal edge case.
  • openemr/openemr-devops#836: Adds the concurrent teardown chown step that this PR removes from the workflow.

Poem

I hopped through worktrees under moonlit bytes,
and prebuilt burrows for Docker delights.
Empty doors now open when the parent says yes,
while carrots stay tidy in their filesystem nest.
🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% 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 clearly summarizes the two main openemr-cmd changes and matches the pull request scope.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

…w|ro)

CI bats run revealed sed: -e expression openemr#1, char 96: unknown option to s.
The s|...|...|p form parsed the | inside (rw|ro) as the s-delimiter,
making the substitute invalid. The function silently failed (no paths
extracted → no dirs pre-created) because the surrounding `|| true`
masked the sed error.

Switch to # as the delimiter so | retains its regex-alternation
meaning. Verified locally with a quick sed test: the bind mount line
(no subpath after openemr) is correctly excluded; volume mount lines
with a subpath are correctly extracted.

Assisted-by: Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@utilities/openemr-cmd/openemr-cmd`:
- Around line 651-677: The wt_precreate_volume_mountpoints helper currently
blocks textual .. but still uses mkdir -p on "${dir}/${rel_path}", which can
traverse existing symlink components inside the worktree. Update this pre-create
logic to reject or safely skip any mount path whose intermediate components
resolve through symlinks before directory creation, using the existing
wt_precreate_volume_mountpoints flow and the rel_path/host_path handling to keep
the worktree boundary intact. Ensure the fix preserves the best-effort behavior
while preventing symlinked paths from escaping the worktree.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a9a1edd4-1646-43da-b8a3-95fecaa6c33b

📥 Commits

Reviewing files that changed from the base of the PR and between e4a1e46 and d1b0b09.

📒 Files selected for processing (4)
  • .github/workflows/test-bats-openemr-cmd-real-docker.yml
  • tests/bats/openemr-cmd/helpers.bash
  • tests/bats/openemr-cmd/mountpoint_precreate.bats
  • utilities/openemr-cmd/openemr-cmd

Comment thread utilities/openemr-cmd/openemr-cmd Outdated
… (symlink defense)

CodeRabbit caught a symlink-traversal vector in
wt_precreate_volume_mountpoints: plain `mkdir -p ${dir}/${rel_path}`
follows existing symlink components in the worktree. A malicious
branch that committed e.g. `public -> /etc` would cause us to create
`/etc/assets` on the host when bringing the stack up.

The existing wt_write_override symlink guards only cover docker/ and
the env's compose subdir — they don't extend to the volume mount
targets we now pre-create. Extend the same defense by walking
rel_path component-by-component: refuse to descend through any
symlink or non-directory file, and on any mismatch abandon that
mount target (continue to the next one) without aborting the rest
of pre-create.

Tradeoff vs `mkdir -p`: marginally more bash code, single test added
to pin the symlink-skip behavior. The performance cost is negligible
— rel_paths are 1-4 components and we only iterate the volume mount
list (~9 entries per env).

Assisted-by: Claude Code
…efactor

The component-walk hardening in c3eef03 added ~21 lines to
wt_precreate_volume_mountpoints, shifting the end-of-functions
anchor from 1861 to 1882. The hermetic bats suite sources only
the first OC_SCRIPT_FUNCS_END lines for pure-function tests; when
the constant is stale, the sourced view ends mid-function and
every wt_slug/wt_compose_subdir/state/lock test fails because the
helpers it depends on are never defined.

The "OC_SCRIPT_FUNCS_END points at end of function defs" drift
test in helpers_pure.bats fires when the anchor doesn't land at
end-of-functions; bumping in lockstep with the function add was
missed in c3eef03.

Assisted-by: Claude Code
@bradymiller bradymiller merged commit 82bf49f into openemr:master Jun 26, 2026
12 checks passed
@bradymiller bradymiller deleted the fix/worktree-volume-mountpoint-precreate branch June 26, 2026 04:02
bradymiller added a commit that referenced this pull request Jun 26, 2026
…reproducer (#839)

## Summary

Hermetic bats coverage for the permission scenarios that have been the
most active bug surface in the recent worktree-cmd PRs (#833, #836,
#838). Pure test scaffolding — no production code changes.

## What's added

Four cases in `tests/bats/openemr-cmd/mountpoint_precreate.bats`:

1. **Empty non-writable + non-writable parent → probe fails.** The
probe-loosening rule from #838 ("empty + parent writable = rmdir-able")
requires BOTH conditions; guards against accidentally accepting a case
where the parent's perms would block the rmdir too.

2. **Unreadable dir (mode 0000) → probe fails conservatively.** When we
can't `[[ -r && -x ]]` the dir, we can't verify emptiness via `find`;
the probe must treat it as blocking (rm could silently leave content
behind otherwise).

3. **Multiple unwritable dirs (one empty-removable, one non-empty) →
probe fails on the non-empty one.** Early-termination correctness: the
probe must evaluate each unwritable dir against the empty-removable rule
rather than failing on the first unwritable it sees regardless of
removability.

4. **Reproducer for the multi-3 CI failure in #836:** stage
`ccdaservice/packages/oe-cqm-service/node_modules` as empty 0555 — the
exact path + state that broke pre-loosening. Confirms BOTH that
pre-create populates the path AND that the loosened probe accepts the
simulated post-volume-purge state. Pins the bug as a regression guard
against future drift.

## Why now

The permission-bug class has surfaced repeatedly across recent PRs
(apache uid mismatch, docker daemon root-owned mount-points, probe
over-strictness). Each fix added defensive logic; this PR turns those
scenarios into bats that fail loudly if any future refactor breaks the
invariants. The reproducer in particular locks down the specific bug
from #836 so it can't silently regress.

## What this PR can't pin

The bats simulates the FILESYSTEM STATE (root-owned-empty via chmod
0555/0000) but not the CAUSE (docker daemon creating dirs as root,
apache chowning bind mount). The real-docker e2e job in
`test-bats-openemr-cmd-real-docker.yml` covers the cause-side
end-to-end. The two are complementary.

## Test plan

- [ ] BATS openemr-cmd (ubuntu-22.04) and (macos-14) pass — the new 4
tests + existing 12 in mountpoint_precreate.bats
- [ ] No other workflow effects (no production code changes; the
existing real-docker e2e jobs run unchanged)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of `worktree remove` when mount points or
directories are not writable.
* Added coverage ensuring the “empty directory” exemption is not used
when the parent is also non-writable.
* Added conservative behavior checks for effectively
unreadable/unenterable directories, preventing unsafe verification.
* Strengthened multi-candidate evaluation when multiple unwritable
directories exist, ensuring the correct failure is surfaced.
* Reproduced and protected a regression so removal succeeds for an
empty, non-writable mount point.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
bradymiller added a commit to bradymiller/openemr-devops that referenced this pull request Jun 26, 2026
…urce-only guard; bump VERSION

Two cleanups bundled:

(1) The hermetic bats suite used to source openemr-cmd's function
definitions via `eval "$(head -n OC_SCRIPT_FUNCS_END script)"` with
a magic line-number constant that had to be bumped every time the
script grew/shrank near end-of-functions. A drift sentinel test
caught stale constants but the bump itself was easy to forget —
which broke CI on openemr#838 and again now on this branch, distracting
from the actual review surface.

Replace with a __OPENEMR_CMD_SOURCE_FUNCS_ONLY=1 env-var guard:
the script returns early from sourcing right after the last
function def when the flag is set; direct invocation (./openemr-cmd)
is unaffected since the flag isn't in the runtime environment. Tests
that want only function defs set the flag and source — no magic
number, no drift, no maintenance.

- openemr-cmd: source guard added before USAGE_EXIT_CODE=13
- helpers.bash: oc_source_funcs uses the env var; OC_SCRIPT_FUNCS_END
  constant removed
- helpers_pure.bats: oc_run_in_funcs drops the funcs_end arg; the
  drift sentinel test replaced with two guard-behavior tests (sourcing
  with the flag defines funcs but skips dispatch; direct execution
  is unaffected by the flag)
- copy_base_env.bats: oc_run_in_funcs drops the funcs_end arg
- state.bats, state_lock.bats, state_lock_crash_recovery.bats,
  worktree_add_base.bats: inline eval-of-head replaced with
  source-with-flag pattern

(2) Bump VERSION 1.0.48 → 1.0.49 for the HOST_UID passthrough +
this refactor. Was missed in the initial openemr#840 push.

Assisted-by: Claude Code
bradymiller added a commit that referenced this pull request Jun 26, 2026
…container (#840)

## Summary

Completes the cross-repo HOST_UID story started in openemr/openemr#12642
(entrypoint side, already merged + new flex image SHA pinned via
dependabot #12644). This PR wires the openemr-devops side:
`wt_write_override` emits `HOST_UID`/`HOST_GID` env vars in the openemr
service block, sourced from `id -u` / `id -g` at worktree-add / regen
time.

## Result

For **non-uid=1000 hosts** (CI runners at uid=1001, multi-user dev
boxes, distros that start uids at 1001+, macOS via Docker Desktop):
apache inside the container now writes bind-mount files with the host's
uid. Host-side writes (`git commit`, IDE edits, `worktree regen`,
`worktree set-env`) work without any chown dance. The uid-mismatch class
of bugs that dominated #833 / #836 / #838 review cycles is eliminated
end-to-end.

For **uid=1000 hosts** (most Linux desktop developers): zero functional
change. The env vars are emitted unconditionally because the entrypoint
adoption block is a sub-millisecond no-op on uid=1000 and the consistent
behavior across hosts is worth more than the savings.

**Backwards compatible:** pre-HOST_UID-aware images silently ignore the
env vars.

## CI workarounds removed (now obsolete)

Both `sudo chown -R "$(id -u):$(id -g)"` steps in
`test-bats-openemr-cmd-real-docker.yml`:
1. **`worktree-lifecycle-e2e`** "One-shot chown back to runner (enables
host-side regen + set-env)" — added in #834
2. **`prek-workflow-e2e`** "One-shot chown back to runner (enables
host-side writes + git commits)" — added in #836

Both replaced with `NOTE:` comments documenting what used to live there
and why the chown is no longer needed — for archaeology by future
contributors.

## Kept as back-compat shim

The `#833` auto-chown block in `cmd_worktree_remove` stays in place.
Inline comment updated to reflect its new status (no-op when the image
honors HOST_UID; still useful for users on pre-HOST_UID images and for
any drift the entrypoint adoption might miss).

## Tests

New bats file `tests/bats/openemr-cmd/host_uid_compose.bats` pins three
behaviors:
1. `wt_write_override` emits `HOST_UID` and `HOST_GID` env vars in the
override file
2. Values match `id -u` / `id -g` at write time
3. Env vars are correctly nested under `services.openemr.environment`
(structural check via yaml.safe_load)
4. Emitted for all three env variants (easy / easy-light / easy-redis)

## Test plan

- [ ] BATS openemr-cmd (ubuntu-22.04 + macos-14) pass — new tests +
existing suite
- [ ] e2e — full worktree lifecycle: passes WITHOUT the sudo chown step
(proves apache now adopts runner uid; regen + set-env writes work as
runner)
- [ ] e2e — prek install + real git commit: passes WITHOUT the sudo
chown step (proves `git commit` from the worktree works as runner)
- [ ] Other e2e jobs unchanged
- [ ] FUNCS_END drift sentinel still passes (no new function added —
wt_write_override grew inline, last function still at line 1882)

🤖 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**
* Generated OpenEMR docker-compose override files now include the
current host user and group IDs (`HOST_UID`/`HOST_GID`) in the container
environment.
* **Bug Fixes**
* Worktree lifecycle and pre-install commit workflows no longer require
host-side ownership adjustment before regeneration and environment
setup.
* Permission recovery/chown behavior is more consistent, with
backward-compatible fallback for older images or ownership drift.
* **Tests**
* Added BATS coverage validating `HOST_UID`/`HOST_GID` emission and
stability of golden comparisons by masking host-specific values.
  * Improved BATS harness sourcing for hermetic function-only execution.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
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.

1 participant