Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.0.26
0.0.27
31 changes: 31 additions & 0 deletions docs/concepts/ddev-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>`) 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 <project>`, `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
Expand Down
152 changes: 152 additions & 0 deletions docs/design/ddev-url-transports.md
Original file line number Diff line number Diff line change
@@ -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.<name>.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, `:<port>` 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 <url>` 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/<name>` — 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 :<port>`;
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.
20 changes: 20 additions & 0 deletions files/opencode-permissions-kit-lib/bin/ddev-as-opencode
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project>`), 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" "$@"
19 changes: 16 additions & 3 deletions tests/e2e/run-ddev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions tests/test-ddev-as-opencode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down