diff --git a/VERSION b/VERSION index c4475d3..24ff855 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.26 +0.0.27 diff --git a/docs/concepts/ddev-integration.md b/docs/concepts/ddev-integration.md index 7374ced..42cb691 100644 --- a/docs/concepts/ddev-integration.md +++ b/docs/concepts/ddev-integration.md @@ -97,6 +97,37 @@ prevents. Ways out for those scripts: sudo -u opencode /usr/local/lib/opencode-permissions-kit/bin/ddev-as-opencode start ``` +### Unreadable working directories (issue #51) + +`sudo` keeps your working directory, so ddev — running as `opencode` — +inherits whatever directory you invoked it from, including ones the +opencode user cannot read, like your `$HOME` (mode 0750 on Ubuntu 24.04). +ddev resolves its embedded compose schema against the process working +directory, so registry-driven runs from such a directory +(`ddev poweroff`, `ddev stop `) used to print one warning per +project: + +``` +Failed to load compose project for down: validating …: error in parsing "compose-spec.json": stat .: permission denied +``` + +The stop itself still worked — noise, not breakage. + +The helper now probes the inherited working directory, prints a one-line +note when the opencode user cannot read it, and runs ddev from the +opencode home instead: + +``` +ddev-as-opencode: current directory is not accessible to the opencode user - continuing in /home/opencode +``` + +Commands that address a project by name or walk the registry +(`ddev poweroff`, `ddev stop `, `ddev list`) are unaffected by +the working directory. Commands without a project name (`ddev start`, +`ddev describe`) need the project directory as their working directory — +run them from inside the project (the [sharing group](sharing-group.md) +keeps it readable for opencode) or pass the project name. + ## ddev-managed paths (the EPERM fixes) ddev chmods `.ddev/` and the app-type's **settings directories diff --git a/docs/design/ddev-url-transports.md b/docs/design/ddev-url-transports.md new file mode 100644 index 0000000..59e5488 --- /dev/null +++ b/docs/design/ddev-url-transports.md @@ -0,0 +1,152 @@ +# DDEV-URL-TRANSPORTS: how browser commands get their URL + +> Status: **IMPLEMENTED (since 0.0.26, PR #50).** The kit reads browser-command +> URLs from `ddev describe -j`. This record documents that transport, the +> removed one it replaced, the upstream alternatives considered, and where +> each fact lives in the ddev source (pinned v1.25.3) so it can be +> re-verified on ddev updates. Where this record conflicts with the code, +> the code wins. + +## 1. Problem + +ddev browser commands — `launch` itself and the wrappers (`mailpit`, the +phpmyadmin/adminer add-ons, `xhgui`) — cannot open a browser as the +`opencode` user: the browser open needs WSL interop (`explorer.exe` / +`xdg-open` → `wslview`), which `opencode` deliberately has not. The URL +must be computed **as opencode** (only that side sees the rootless daemon +— as the developer, ddev would decide "not running" and internally +`ddev start` on every call) and then opened **as the developer**. The +`ddev()` shell function routes the whole class through +`_opk_ddev_browser` (ddev-as-opencode.sh); the user-facing story is in +[ddev integration](../concepts/ddev-integration.md). + +## 2. Current transport: `ddev describe -j` (issue #20 rework) + +Upstream-recommended scripting interface +([ddev/ddev#8771](https://github.com/ddev/ddev/issues/8771)). The arm runs +`ddev describe -j` as opencode via the sudoers helper and maps the command +to a field: + +| Command | Field(s) | +|---|---| +| `launch` | `raw.primary_url` + argument forms (below) | +| `mailpit` | `raw.mailpit_https_url` / `raw.mailpit_url` (scheme matched to `primary_url`) | +| `xhgui` | `raw.xhgui_https_url` / `raw.xhgui_url`, only when `raw.xhgui_status` = `enabled` | +| `phpmyadmin`, `adminer`, conf-registered | `raw.services..https_url` / `.http_url` | + +Properties that made it the choice: + +- **Works while stopped.** All mapped fields are config-derived; ddev even + lists compose-defined-but-stopped services with their URLs (from the + compose `HTTP(S)_EXPOSE` env). No ddev invocation side effects needed + before the URL is known. +- **Stable contract.** The JSON is a single line (logrus + `{"raw":{...},"msg":...}`) — parsed with python3, already a kit + prerequisite. +- **No logging side effects** — unlike the debug transport it replaced. + +`launch` argument handling mirrors ddev's launch script: `-m`/`--mailpit`/ +`--mailhog` switch to the Mailpit URL, `-p`/`--phpmyadmin` declines to a +plain run (ddev prints its add-on hint there), `--` ends flags; one +positional then applies — full URL as-is, `:` replaces the primary +URL's port (keeping its scheme — the launch script additionally probes +describe for the scheme; both agree in standard setups), anything else is +appended as a path (`${base%/}/${1#/}`). + +Behavior parity kept by the arm: + +- A stopped project is **started first** (like the launch script), with + the same hints a direct `ddev start` prints: `_opk_bootstrap_hint` + before, `_opk_hosts_hint` (the `opk ddev-hosts-add` bridge) after. +- Commands whose URL describe does not carry — the built-in phpmyadmin + installer prompt (add-on not yet installed), custom commands without a + matching service, invocations outside a project — **plain-run as + opencode**: output, prompts and exit code pass through; only the + browser open is skipped. +- The URL is printed on stdout and opened as the developer + (`explorer.exe`, fallback `xdg-open`). + +## 3. Removed transport: `DDEV_DEBUG` / `FULLURL` (0.0.19 – 0.0.25) + +The launch script prints `FULLURL ` and exits 0 instead of opening a +browser when `DDEV_DEBUG=true`/`DDEV_VERBOSE=true`. The old arm ran the +browser command as opencode under `DDEV_DEBUG`, captured the `FULLURL` +lines, filtered them from the visible output and opened the last one. +Removed because debug logging is global to the whole invocation: on a +stopped project the launch script's internal `ddev start` produced a wall +of timestamped debug output before the URL appeared (the original +complaint in ddev/ddev#8771). Also dropped: `DDEV_DEBUG` from the sudoers +`env_keep` it needed to survive `sudo -u opencode`. + +## 4. Alternatives considered (deferred / upstream) + +### `ddev launch --print-url` + `DDEV_LAUNCH_PRINT_URL` — our upstream PR + +[ddev/ddev#8772](https://github.com/ddev/ddev/pull/8772) adds a +`--print-url` flag (and env var, so nested launch children of wrapper +commands inherit it) that prints the composed URL and exits 0. **Kept +open** after maintainer feedback: rfay would prefer this usage over the +`DDEV_DEBUG=true ddev launch` trick ddev uses in its own tests. If it +merges, it is attractive wherever the **composed** URL matters — ddev +itself would apply the `launch` argument handling we currently replicate +in shell. The kit does not depend on it (describe works on released +ddev); a later transport switch or combination is an open follow-up. + +### Global host-command env — rfay's suggestion + +Host commands can be global (`~/.ddev/commands/host/` — ddev's own +`launch`/`mailpit`/`phpmyadmin` ship exactly there; for the kit: +`/home/opencode/.ddev/commands/host/`, inside the ddev home we already +provision). Dispatching one injects `DDEV_PRIMARY_URL`, +`DDEV_PRIMARY_URL_PORT`/`_WITHOUT_PORT`, `DDEV_SCHEME`, +`DDEV_MAILPIT_*`, `DDEV_XHGUI_HTTP(S)_PORT`, … — enough to compute the +primary, Mailpit and xhgui URLs without JSON parsing (and without the +python3 dependency). Deferred because: + +- **No per-service env.** The phpMyAdmin add-on's URL exists only in + describe (`raw.services.phpmyadmin.https_url`); the add-on hardcodes + its ports (8036/8037) inside its own command — hardcoding them kit-side + breaks on custom ports. +- Still one ddev invocation from the wrapper (the dispatch); "no `ddev` + invocation" only means the command itself would not call ddev + internally, replacing the `describe` call. +- A kit-shipped global command appears in every project's `ddev --help` + and is another file the kit must deploy/update. + +Combining both (env for the built-ins, describe for services) was +considered and rejected for now: one interface, one code path. + +## 5. Upstream source map (pinned v1.25.3) + +For re-verification when a new ddev release lands — paths refer to the +read-only checkout in the dev workspace (`github/ddev`, tag v1.25.3; not +shipped with the kit): + +- `pkg/ddevapp/ddevapp.go` — `Describe()` (~line 223): the `raw` map. + `primary_url`, `mailpit_https_url`/`mailpit_url`, + `xhgui_https_url`/`xhgui_url` are config-derived (present when + stopped); `services` is filled from running containers **and** from + compose-defined stopped services (URLs built from their + `HTTP(S)_EXPOSE` env; mailpit ports excluded). +- `pkg/ddevapp/ddevapp.go` (~line 2910) — the env block injected into + host-command dispatch (`DDEV_PRIMARY_URL`, `DDEV_SCHEME`, mailpit and + xhgui ports, …; no per-service variables). +- `pkg/ddevapp/global_dotddev_assets/commands/host/launch` — the launch + script: auto-start when not running, the argument forms above, and the + `FULLURL`-under-debug contract at the end. +- Same directory: built-in `mailpit` (just `ddev launch -m`) and built-in + `phpmyadmin` (interactive add-on installer — the launcher is the + add-on's own project-level command, which calls `ddev launch :`; + its Gitpod branch even uses the `DDEV_DEBUG`/`FULLURL` grep trick). +- `cmd/ddev/cmd/describe.go` — JSON emission: + `output.UserOut.WithField("raw", desc)` through logrus' JSONFormatter: + one line, `raw` nested at the top level. + +## 6. Tests + +- Unit: functional describe→URL mapping cases (fixtures mirroring the + ddev field set) in `tests/test-ddev-as-opencode.sh` §3a. +- e2e: the fake ddev implements the describe contract, records callers, + and models the stopped→running start flip; a planted `/mnt/c` hosts + file + custom-tld fixture proves the hint parity — `tests/e2e/run.sh` + section 4b. diff --git a/files/opencode-permissions-kit-lib/bin/ddev-as-opencode b/files/opencode-permissions-kit-lib/bin/ddev-as-opencode index d561585..795bed7 100755 --- a/files/opencode-permissions-kit-lib/bin/ddev-as-opencode +++ b/files/opencode-permissions-kit-lib/bin/ddev-as-opencode @@ -75,5 +75,25 @@ case "${CONTAINER_BACKEND:-}" in ;; esac +# Working-directory fallback (issue #51): sudo keeps the caller's cwd, so +# the opencode process can inherit a directory it cannot search — the +# developer's $HOME is 0750 on Ubuntu 24.04, and `ddev poweroff` is a +# command you naturally run from there. ddev's embedded compose schema is +# registered under a RELATIVE resource name, so every compose load +# resolves it against the process cwd (os.Getwd -> stat "."), which fails +# with 'error in parsing "compose-spec.json": stat .: permission denied' +# there — a warning per project on cwd-independent runs (`ddev poweroff`, +# `ddev stop `), while the stop itself still succeeds. Probe the +# inherited cwd from the process itself (not $PWD) and fall back to the +# opencode home: registry-driven commands then run clean, and commands +# that need the cwd (bare `ddev start`) would fail in such a directory +# anyway — the stderr note keeps that failure explainable. Never aborts: +# when even the fallback cd fails, ddev runs with the inherited cwd as +# before. +if ! [ -x . ]; then + echo "ddev-as-opencode: current directory is not accessible to the opencode user - continuing in $HOME" >&2 + cd "$HOME" 2>/dev/null || true +fi + umask 002 exec "$DDEV" "$@" diff --git a/tests/e2e/run-ddev.sh b/tests/e2e/run-ddev.sh index cbe9ac1..e6bd352 100755 --- a/tests/e2e/run-ddev.sh +++ b/tests/e2e/run-ddev.sh @@ -538,15 +538,28 @@ if [ "$_daemon_ok" = true ]; then echo "--- DD7. lifecycle + suite-wide EPERM sweep ---" check "DD7: ddev restart" \ OC_DD2 'ddev restart >/tmp/dd7.log 2>&1' - check "DD7: ddev stop" \ - OC_DD2 'ddev stop >/tmp/dd7.log 2>&1' + # Issue #51: registry-driven ddev runs from a directory the opencode + # user cannot search — the developer's $HOME (0750 dev-owned) being the + # natural one for `ddev poweroff` — made ddev's compose loader die on + # os.Getwd() -> stat(".") with 'error in parsing "compose-spec.json": + # stat .: permission denied' (warning per project, stop still worked). + # The ddev() function inherits that cwd via sudo; bin/ddev-as-opencode + # now probes it and falls back to the opencode home with a stderr + # note. This is the real transport: dev shell -> ddev() -> sudoers + # helper -> real ddev, against the live dd2 project. + check "DD7: ddev stop by name from the dev's unreadable home (#51)" \ + DEVSH 'cd ~ && ddev stop dd2 >/tmp/dd7-stop.log 2>&1' + check "DD7: no compose-spec warning from the unreadable cwd (#51)" \ + E 'test -z "$(grep -s "compose-spec.json" /tmp/dd7-stop.log)"' + check "DD7: the fallback note names the run directory (#51)" \ + E 'grep -q "not accessible to the opencode user" /tmp/dd7-stop.log' check "DD7: ddev delete -Oy" \ OC_DD2 'ddev delete -Oy >/tmp/dd7.log 2>&1' # EXCLUDED logs hold EXPECTED EPERM text: dd13-start1 (the DD13 # tripwire asserts that exact bootstrap EPERM), dd2-config (the burn-in # Finding-1 chmod warning from `ddev config`). check "DD7: zero 'operation not permitted' across the suite log" \ - E '_s=$(grep -il "operation not permitted" /tmp/dd2-start.log /tmp/dd2-restart.log /tmp/dd2-handover.log /tmp/dd7.log /tmp/dd9.log /tmp/dd10-*.log /tmp/dd11-*.log /tmp/dd12-*.log 2>/dev/null); test -z "$_s"' + E '_s=$(grep -il "operation not permitted" /tmp/dd2-start.log /tmp/dd2-restart.log /tmp/dd2-handover.log /tmp/dd7.log /tmp/dd7-stop.log /tmp/dd9.log /tmp/dd10-*.log /tmp/dd11-*.log /tmp/dd12-*.log 2>/dev/null); test -z "$_s"' fi if [ "$SITE_TIER" = "camino" ] && E 'test -d /opt/e2e/fixtures/camino' 2>/dev/null; then diff --git a/tests/test-ddev-as-opencode.sh b/tests/test-ddev-as-opencode.sh index 311dde5..9da801e 100755 --- a/tests/test-ddev-as-opencode.sh +++ b/tests/test-ddev-as-opencode.sh @@ -123,6 +123,53 @@ check "helper execs the resolved ddev binary" \ check_fail "helper never references the removed legacy bin/ddev shim" \ sh -c "grep -qE 'opencode-permissions-kit/bin/ddev(\$|[^-])' \"\$1\"" _ "$HELPER" +# --- 2b. working-directory fallback (issue #51) --------------------------------- +# sudo keeps the caller's cwd, so the opencode process can inherit a +# directory it cannot search (the developer's $HOME is 0750 on Ubuntu +# 24.04). ddev's compose loader resolves its embedded schema against the +# process cwd (os.Getwd -> stat "."), which dies with 'error in parsing +# "compose-spec.json": stat .: permission denied' there — the issue #51 +# warning on cwd-independent runs like `ddev poweroff`. The helper must +# probe the inherited cwd and fall back to the opencode home, noting it +# on stderr and never aborting the run. Functional with stubs: a fake id +# (constant uid — the guard passes for any user) and a fake ddev (args + +# cwd marker). The unreadable cwd is built with the owner-bit trick: a +# process can SIT in a directory it cannot search (exactly what sudo's +# cwd inheritance produces) but [ -x . ] is false there — the condition +# behind Go's stat(".") EACCES. +check "helper probes the inherited cwd for search permission (issue #51)" \ + sh -c "grep -qF 'if ! [ -x . ]; then' \"\$1\"" _ "$HELPER" +check "helper falls back to the opencode home on an unreadable cwd" \ + sh -c "grep -qF 'cd \"\$HOME\" 2>/dev/null || true' \"\$1\"" _ "$HELPER" +check "helper notes the fallback on stderr and never aborts the run" \ + sh -c "grep -q 'not accessible to the opencode user - continuing in ' \"\$1\" && grep -qF '>&2' \"\$1\"" _ "$HELPER" + +WF=$(mktemp -d) +mkdir -p "$WF/stub" +printf '#!/bin/sh\necho 4242\n' > "$WF/stub/id" +printf '#!/bin/sh\necho "FAKE_DDEV_ARGS:$*"\necho "FAKE_DDEV_CWD:$(pwd)"\n' > "$WF/stub/ddev" +chmod +x "$WF/stub/id" "$WF/stub/ddev" +mkdir "$WF/locked" + +WF_OUT=$(cd "$WF" && PATH="$WF/stub:$PATH" sh "$HELPER" poweroff 2>&1) +check "fallback: readable cwd keeps the directory (ddev runs right there)" \ + sh -c "printf '%s\n' \"\$1\" | grep -qF 'FAKE_DDEV_CWD:$WF'" _ "$WF_OUT" +check "fallback: readable cwd stays silent (no note)" \ + sh -c "if printf '%s\n' \"\$1\" | grep -q 'not accessible'; then exit 1; fi" _ "$WF_OUT" +check "fallback: arguments pass through to ddev untouched" \ + sh -c "printf '%s\n' \"\$1\" | grep -qF 'FAKE_DDEV_ARGS:poweroff'" _ "$WF_OUT" + +set +e +WF_OUT2=$(cd "$WF/locked" && chmod 007 "$WF/locked" && PATH="$WF/stub:$PATH" sh "$HELPER" stop myproject 2>&1) +WF_RC=$? +set -e +chmod 700 "$WF/locked" +check "fallback: unreadable cwd prints the explanatory note (issue #51)" \ + sh -c "printf '%s\n' \"\$1\" | grep -q 'not accessible to the opencode user'" _ "$WF_OUT2" +check "fallback: unreadable cwd still execs ddev (exit code passes through)" \ + sh -c "printf '%s\n' \"\$1\" | grep -qF 'FAKE_DDEV_ARGS:stop myproject'" _ "$WF_OUT2" && [ "$WF_RC" = 0 ] +rm -rf "$WF" + # --- 3. function file: sourcing + both branches ------------------------------- # Fixture: a fake ddev on PATH so the already-opencode branch runs something # observable instead of the (absent) real ddev.