Skip to content

fix(deploy): deliver env: via ePHPm's per-site auto_prepend_file (#4) - #27

Merged
luthermonson merged 1 commit into
mainfrom
fix/auto-prepend-file-per-site
Sep 8, 2026
Merged

luthermonson merged 1 commit into
mainfrom
fix/auto-prepend-file-per-site

Conversation

@luthermonson

Copy link
Copy Markdown
Contributor

Closes #4.

A manifest's env: never reached PHP for an app declaring docroot: ".".
switchboard wrote .ephpm-preview-prepend.php into the checkout and announced it
in .switchboard-preview.json — a file ePHPm does not read — so
ini_get('auto_prepend_file') on a live preview returned ''. Apps had to
require_once it by hand, which docroot: "." apps mostly did not, so
EPHPM_SEED_TOKEN never arrived and ephpm/wordpress-sample came up as stock
WordPress instead of the magazine showcase.

ephpm#463 (PR #472, c3cdcff on ephpm main) made auto_prepend_file a typed,
enforced
key in the same per-site override file switchboard already writes for
document_root. This is switchboard's half.

The mechanism

  • The override is written for every preview, not only for a subdirectory
    docroot. It used to be removed for docroot: "." on the reasoning that an
    absent file and "the container is the web root" are the same thing to ePHPm.
    They are — for routing. They stopped being the same when the file gained
    auto_prepend_file, and docroot: "." is precisely the shape with nowhere
    else to put a bootstrap hook. docroot: "." is now spelled by omitting
    document_root (absent and "." are identical to ePHPm, and absent is the
    spelling every release ever shipped agrees on).

    # <site_overrides_dir>/<site-key>.toml
    document_root     = "public"                      # omitted when docroot is "."
    auto_prepend_file = ".ephpm-preview-prepend.php"
  • validate_prepend mirrors ePHPm's validate_declared_prepend: relative
    only, Component::Normal segments only, no backslash, charset-gated
    ([A-Za-z0-9._/-], because we hand-write the TOML), canonically contained in
    the container, and a regular file. Resolved against the staging tree —
    the tree that becomes the container. A value ePHPm would refuse fails the
    deploy, because since #472 a refusal is a 503 for that site, not a no-op.

  • write_override is now atomic — temp file, sync_all, rename. It was
    tokio::fs::write, which truncates first. ePHPm re-reads this file every
    couple of seconds, and under #472 an unparseable read takes the site out of
    service, so the truncate window went from "a warning" to "a down preview". The
    sync_all matters separately: a rename durable before its data leaves a
    zero-length override, which is valid TOML declaring nothing — the site would
    come back serving its whole container with no error anywhere. The temporary is
    dot-prefixed and .tmp-suffixed so it can never be read as <key>.toml, and
    carries the pid so two writers cannot share one.

  • The prepend is unlinked before it is written. write follows a symlink, so
    a repository shipping one at that name would have the preview's resolved
    secrets written through it to wherever it points. Removing the entry replaces
    the link, not its target.

  • The prepend no longer overwrites $_SERVER['DB_*'] / $_SERVER['EPHPM_*'].
    This is a new hazard the fix itself introduces: the prepend now runs on every
    request, after ePHPm's register_server_variables injects this vhost's live
    per-site credentials — which rotate on every host restart. A manifest
    declaring env: { DB_HOST: ... } would have replaced a working credential
    with a stale literal. The declared value still lands in $_ENV/putenv(), so
    an app wanting an external database can read it there, and the deploy warns
    naming the keys.

$_SERVER vs getenv()

docs/preview-app-guide.md §2 tells app authors to read $_SERVER: ePHPm has no
sapi_module.getenv handler, everything arrives via register_server_variables,
and phpdotenv's DEFAULT_ADAPTERS consults ServerConstAdapter first. The
prepend was already consistent with that — it sets all three, $_SERVER
included — and stays so. The one change is the guard above, which the guide now
documents.

What happened to the .env from #14 — kept, as a different mechanism

Two mechanisms doing one job drift; these do two jobs.

auto_prepend_file exists only inside an ePHPm request. build: and seed:
run as shell commands outside the server (guide §8: no $_SERVER['DB_*'], no
prepend), and a wp-cli or artisan step there can only read env: from a
file. That is .env, and no auto-prepend can ever cover it. .env is also what
carries env: on a host predating #472.

They cannot disagree: both are rendered from the same resolved BTreeMap in the
same function. And there is no precedence to reason about — the prepend runs
before the front controller, and phpdotenv/symfony-dotenv are immutable by
default, so a $_SERVER value set first is not overwritten.

The filename .ephpm-preview-prepend.php is deliberately unchanged, so every
app that adopted the documented require_once keeps working. Keeping that line
is harmless — the prepend only assigns values, so twice and once are the same
outcome.

Deploy ordering, and the skew window

Either side may ship first; switchboard-first is safe. Confirmed read-only on
the live cluster today: ephpm 0.9.0, site_overrides_dir wired at
/var/lib/ephpm-web/site-overrides, currently empty (every deployed preview is
docroot: ".", which today's code deletes the override for — which is the bug).

v0.9.0 predates #472, so it treats auto_prepend_file as an unknown key:
ignored, with a warning, site keeps serving. #472 kept unknown keys lenient
for exactly this reason. In the window:

  • document_root is still honoured — unchanged behaviour for Laravel-shaped
    previews;
  • env: arrives via .env and the documented manual require_once, exactly as
    it does today;
  • the deploy log says so on every deploy (the "wrote per-site override" line
    names the ePHPm issue).

The feature turns on the moment the nodes run an ePHPm carrying #472. No
coordinated deploy, no flag day.

What an operator must do

  1. Nothing, to merge and deploy this — it is inert on v0.9.0.
  2. To actually get env: into PHP: upgrade the three preview nodes to an ePHPm
    containing #472
    (c3cdcff or a release cut after it). Redeploy a preview
    (or wait for the next PR event) so the override is rewritten with the key.
  3. Verify: ini_get('auto_prepend_file') on a preview should be an absolute path
    inside the site container, not ''. ephpm/wordpress-sample should come up
    as the magazine showcase.
  4. --site-overrides-dir was already required for docroot:; it is now also
    what delivers env:. Unset, the deploy warns about both consequences in
    one line instead of only the docroot one.
  5. Worker mode ([php] mode = "worker") has no per-request prepend position;
    ePHPm ignores the key there with a warning. Documented in the guide's gaps
    table. The preview fleet is per-request.

Tests

210 passed, cargo fmt --all -- --check (stable, what CI runs) and
cargo clippy --all-targets -- -D warnings clean.

New coverage, in src/site_override.rs and src/deployer.rs:

  • the_generated_prepend_name_satisfies_ephpms_containment_rules — asserts the
    shipped constant against each of ePHPm's rules by name (relative, Normal
    components, no backslash, dot-prefixed) and then through validate_prepend.
    This is the "getting it wrong is a 503" test.
  • prepend_escaping_the_container_is_refused — traversal, absolute, UNC, drive,
    empty and ..
  • prepend_symlinked_out_of_the_checkout_is_refused.
  • prepend_naming_a_directory_or_a_missing_file_is_refused,
    prepend_toml_injection_attempts_are_refused.
  • a_prepend_above_the_document_root_is_accepted — containment is against the
    container, and the file is genuinely outside the served root.
  • docroot_dot_still_gets_an_override_carrying_the_prepend and
    a_root_docroot_preview_gets_an_auto_loaded_prepend — the env: never reaches PHP for docroot: "." — the generated prepend is not auto-loaded #4 shape end to end.
  • the_override_is_replaced_atomically_leaving_no_temp_files — a rewrite
    replaces rather than accumulates, and no .tmp litter survives.
  • container_docroot_clears_a_stale_document_root_but_keeps_the_override
    replaces the old "clears a stale override" test, which asserted the behaviour
    this PR deliberately changes.
  • the_prepend_never_clobbers_a_host_injected_credential.

Also

  • teardown.rs now derives the override path through site_override::override_path
    / remove_override instead of re-joining <key>.toml itself, so one module
    owns the one filename ePHPm reads.
  • The sidecar's "prepend_auto_loaded": false is replaced by
    "prepend_declared_as". false is no longer true on a current host, and
    true would be a claim about a server version this daemon has no channel to
    ask about. What it records is that the declaration was made, and as what.
  • Guide §3 rewritten, §11 gaps table updated (including the worker-mode
    limitation), README updated in three places.

A manifest's `env:` never reached PHP for an app declaring `docroot: "."`.
switchboard wrote `.ephpm-preview-prepend.php` into the checkout and announced
it in a sidecar ePHPm does not read, so `ini_get('auto_prepend_file')` on a
live preview returned `''`. Apps had to `require_once` it by hand — which
`docroot: "."` apps mostly did not, so `EPHPM_SEED_TOKEN` never arrived and
wordpress-sample came up as stock WordPress instead of the magazine showcase.

ephpm#463 (PR #472) made `auto_prepend_file` a typed, enforced key in the same
per-site override file switchboard already writes for `document_root`. This
writes it.

The mechanism

* The override is now written for **every** preview, not only for a
  subdirectory docroot. `docroot: "."` is spelled by omitting `document_root`
  (absent and `"."` are identical to ePHPm, and absent is the spelling every
  release agrees on) — the file still has to exist, because it carries the
  prepend, and that shape is exactly the one with nowhere else to put a
  bootstrap hook.
* `validate_prepend` mirrors ePHPm's `validate_declared_prepend`: relative,
  `Component::Normal` only, no backslash, charset-gated, canonically contained,
  and a regular file. The deploy fails loudly on a value ePHPm would refuse —
  since #472 a refusal is a 503 for that site, not a silent no-op.
* `write_override` is now atomic (temp + `sync_all` + rename). A plain `write`
  truncates first; interrupted, it leaves valid-looking or unparseable TOML
  live, and ePHPm re-reads the file every couple of seconds. Under #472 that
  window is a down site rather than a warning.
* The prepend is unlinked before it is written, so a repository shipping a
  symlink at that name cannot have the preview's resolved secrets written
  through it.
* The prepend refuses to overwrite `$_SERVER['DB_*']` / `$_SERVER['EPHPM_*']`.
  It now runs on every request, after ePHPm injects this vhost's live,
  rotating credentials — an `env:` entry with a colliding name would have
  replaced a working credential with a stale literal. It still lands in
  `$_ENV`/`putenv()`, and the deploy warns naming the keys.

The `.env` from #14 stays, as a distinct mechanism rather than a duplicate

`auto_prepend_file` exists only inside an ePHPm request. `build:` and `seed:`
run as shell commands outside the server (guide §8), where a `wp-cli` or
`artisan` step can only read `env:` from a file. `.env` is also what carries
`env:` on a host predating #472. Both are rendered from the same resolved map
in one function, so they cannot disagree about values, and the prepend wins
where both apply (it runs first, and phpdotenv/symfony-dotenv are immutable by
default). The filename is deliberately unchanged, so the documented manual
`require_once` keeps working and is a no-op rather than a conflict.

Deploy ordering

The nodes run ePHPm 0.9.0, which predates #472 and ignores `auto_prepend_file`
with a warning — by design, so a switchboard deploy cannot take every preview
site down. So switchboard may ship first: in the window, `document_root` is
still honoured, sites keep serving, and `env:` arrives via `.env` and the
manual require exactly as today. The feature turns on when the nodes upgrade
to an ePHPm carrying #472. Nothing here requires a coordinated deploy.

Closes #4
@luthermonson
luthermonson force-pushed the fix/auto-prepend-file-per-site branch from a0f35f2 to 2513c76 Compare September 8, 2026 03:03
@ephpm

ephpm Bot commented Sep 8, 2026

Copy link
Copy Markdown

ePHPm Preview — removed

Preview deployment has been torn down.

@ephpm
ephpm Bot temporarily deployed to preview-pr-27 September 8, 2026 03:04 Inactive
@ephpm
ephpm Bot temporarily deployed to preview-pr-27 September 8, 2026 03:04 Inactive
@luthermonson
luthermonson merged commit aff524b into main Sep 8, 2026
4 checks passed
@luthermonson
luthermonson deleted the fix/auto-prepend-file-per-site branch September 8, 2026 03:09
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.

env: never reaches PHP for docroot: "." — the generated prepend is not auto-loaded

1 participant