Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ seconds forever.
| `--preview-domain` | `SWITCHBOARD_PREVIEW_DOMAIN` | `preview.ephpm.dev` | Suffix appended to the label to form the preview host. |
| `--sites-domain-suffix` | `SWITCHBOARD_SITES_DOMAIN_SUFFIX` | `.<preview-domain>` | ePHPm's `[server] sites_domain_suffix` **on this node**. Decides the site key (see above). Pass `""` for a node that configures no suffix. Must begin with a dot — ePHPm refuses a dotless one (ephpm#397). |
| `--composer` | `SWITCHBOARD_COMPOSER` | `composer` | Composer command or path. |
| `--use-ephpm-composer` | `SWITCHBOARD_EPHPM_COMPOSER` | `false` | Route a preview's Composer calls through the embedded `ephpm composer` (vivacity) fast installer. When on, the leading `composer` token of every `build:`/`seed:` step is rewritten to `ephpm composer` and the implicit install runs as `ephpm composer install …` — PR manifests are **not** edited. Recursion-safe: switchboard rewrites only its own command and never shadows `composer` on `PATH`, so vivacity's fallback (a `PATH` search) still finds the host's real PHP composer. **The host must keep a real PHP `composer` on `PATH`** (and an `ephpm` carrying the `composer` subcommand) for that fallback. |
| `--ephpm-bin` | `SWITCHBOARD_EPHPM_BIN` | `ephpm` | The `ephpm` binary used to run `build:`/`seed:` steps sandboxed (`ephpm exec --site`). Must support `exec` (ephpm#484) — a deploy **refuses** otherwise rather than running steps as root. Set an absolute path if it is not on the daemon's `PATH`. |
| `--ephpm-config` | `SWITCHBOARD_EPHPM_CONFIG` | `/etc/ephpm/ephpm.toml` | The node's `ephpm.toml`, passed to `ephpm exec --config`. Must be the same config the running server uses, so the sandbox resolves the same per-site boundary. |
| `--secrets-file` | `SWITCHBOARD_SECRETS_FILE` | *(none)* | YAML secret store for `${secret.NAME}` references in a manifest's `env:`. |
Expand Down
30 changes: 30 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,26 @@ pub struct Config {
#[arg(long, default_value = "composer", env = "SWITCHBOARD_COMPOSER")]
pub composer: String,

/// Route a preview's Composer invocations through the embedded
/// `ephpm composer` (vivacity) fast Rust installer instead of the PHP
/// `composer` named by `--composer`.
///
/// Off by default (opt-in). When on, the leading `composer` token of every
/// `build:`/`seed:` command is rewritten to `ephpm composer` before it runs
/// in the tenant sandbox, and the implicit `composer install` (run when a
/// manifest declares no build steps) becomes `ephpm composer install …`.
/// PR authors' manifests are **not** touched — only the command switchboard
/// constructs and executes changes.
///
/// Recursion-safe by construction: switchboard rewrites only its **own**
/// constructed command and never shadows `composer` on `PATH`, so vivacity's
/// out-of-scope fallback — a `PATH` search (`Command::new("composer")`) — still
/// resolves to the host's real PHP composer. **The host must therefore keep a
/// real PHP `composer` on `PATH`** (and an `ephpm` that carries the `composer`
/// subcommand) for the fallback to work.
#[arg(long, default_value_t = false, env = "SWITCHBOARD_EPHPM_COMPOSER")]
pub use_ephpm_composer: bool,

/// The `ephpm` binary used to run manifest `build:` / `seed:` steps inside
/// the tenant sandbox (`ephpm exec --site`).
///
Expand Down Expand Up @@ -576,6 +596,8 @@ mod tests {
assert_eq!(c.sites_dir, PathBuf::from("/var/www/sites"));
assert_eq!(c.preview_domain, "preview.ephpm.dev");
assert_eq!(c.composer, "composer");
// Composer routing is opt-in: off unless the operator asks for it.
assert!(!c.use_ephpm_composer);
// Build/seed run through `ephpm exec` — the binary defaults to PATH and
// the config to ePHPm's own default location.
assert_eq!(c.ephpm_bin, PathBuf::from("ephpm"));
Expand Down Expand Up @@ -1076,6 +1098,14 @@ mod tests {
);
assert_eq!(c.health_timeout_secs, 5);
assert_eq!(c.health_interval_secs, 1);
// Not passed above → stays off.
assert!(!c.use_ephpm_composer);
c.validate().unwrap();
}

#[test]
fn use_ephpm_composer_flag_opts_in() {
let c = parse_single_node(&["--use-ephpm-composer"]);
assert!(c.use_ephpm_composer);
}
}
177 changes: 169 additions & 8 deletions src/deployer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,12 @@ pub struct DeployContext<'a> {
pub site_overrides_dir: Option<&'a Path>,
/// Composer command (or path).
pub composer: &'a str,
/// Route Composer invocations through the embedded `ephpm composer`
/// (vivacity) fast installer. When `true`, a `build:`/`seed:` command whose
/// leading token is `composer` is rewritten to `ephpm composer`, and the
/// implicit `composer install` runs as `ephpm composer install …`. See
/// [`route_composer_command`] for the exact (recursion-safe) rewrite.
pub use_ephpm_composer: bool,
/// The `ephpm` binary that runs `build:` / `seed:` steps inside the tenant
/// sandbox (`ephpm exec --site`). A build/seed refuses to run if this binary
/// does not support `exec` — it is never bypassed to run steps as root.
Expand Down Expand Up @@ -685,14 +691,23 @@ pub async fn deploy_preview(
// (7) Run build: commands now that the code lives at `sites_dir/<key>` — the
// vhost `ephpm exec --site` sandboxes. Runs at the container root (where
// `composer.json` lives), not the document root.
run_build(&manifest, &site_dir, ctx.composer, sandbox, &hostname).await;
run_build(
&manifest,
&site_dir,
ctx.composer,
ctx.use_ephpm_composer,
sandbox,
&hostname,
)
.await;

// (8) Run seed: commands now that the site is live and its per-site DB can
// be created on first access.
let preview_url = preview_url(&hostname, Some(manifest.php.as_str()));
run_seed(
&manifest,
&site_dir,
ctx.use_ephpm_composer,
sandbox,
&preview_url,
&hostname,
Expand Down Expand Up @@ -1251,6 +1266,45 @@ async fn ensure_sandboxed_exec(ephpm_bin: &Path) -> anyhow::Result<()> {
Ok(())
}

/// Route a tenant `build:`/`seed:` command's leading `composer` invocation
/// through the embedded `ephpm composer` (vivacity) fast installer, when
/// `enabled`.
///
/// Only a **genuine leading `composer` token** is rewritten:
/// `composer install --no-dev` → `ephpm composer install --no-dev`. Any command
/// whose first whitespace-delimited word is not exactly `composer` is returned
/// verbatim — `my-composer …`, `php composer.phar …`, `echo composer`, or a
/// `composer` appearing only inside a later argument all pass through untouched.
/// Leading whitespace is preserved. When `enabled` is `false` the command is
/// always returned unchanged.
///
/// # Recursion safety
///
/// This rewrites only switchboard's **own** constructed command string; it never
/// installs or shadows a `composer` on `PATH`. `ephpm composer`'s out-of-scope
/// fallback shells out with `Command::new("composer")` — a `PATH` search that
/// ignores shell aliases — which therefore resolves to the host's **real** PHP
/// composer, not back into this rewrite. A command already starting with
/// `ephpm` (e.g. a re-entered `ephpm composer install`) has leading token
/// `ephpm`, not `composer`, so it is left alone. The host must keep a real PHP
/// `composer` on `PATH` for the fallback.
fn route_composer_command(cmd: &str, enabled: bool) -> String {
if !enabled {
return cmd.to_owned();
}
let trimmed = cmd.trim_start();
let lead_ws = &cmd[..cmd.len() - trimmed.len()];
let (first, rest) = match trimmed.find(char::is_whitespace) {
Some(i) => (&trimmed[..i], &trimmed[i..]),
None => (trimmed, ""),
};
if first == "composer" {
format!("{lead_ws}ephpm composer{rest}")
} else {
cmd.to_owned()
}
}

/// Run the manifest's `build:` commands in order, each sandboxed via
/// `ephpm exec --site`. If the manifest declares no build steps, fall back to an
/// implicit `composer install` when a `composer.json` exists (POC
Expand All @@ -1259,19 +1313,32 @@ async fn ensure_sandboxed_exec(ephpm_bin: &Path) -> anyhow::Result<()> {
/// Every step runs at the **container root** (`site_dir`) — where
/// `composer.json` and the project files live — not the document root, as the
/// pre-sandbox path did (it ran `sh -c` with `current_dir(checkout)`).
///
/// When `use_ephpm_composer` is set, each step's leading `composer` token is
/// routed through `ephpm composer` ([`route_composer_command`]) and the implicit
/// install runs as `ephpm composer install …` instead of the single-token
/// `--composer` binary.
async fn run_build(
manifest: &AppManifest,
site_dir: &Path,
composer: &str,
use_ephpm_composer: bool,
sandbox: SandboxExec<'_>,
hostname: &str,
) {
if manifest.build.is_empty() {
if site_dir.join("composer.json").exists() {
tracing::info!(%hostname, "no build steps declared — running implicit composer install (sandboxed)");
let cmd = format!(
"{} install --no-dev --no-interaction --optimize-autoloader --quiet",
// The installer front: the embedded `ephpm composer` (two tokens,
// not posix-quoted as one) when routing is on, else the configured
// single-token PHP composer binary.
let installer = if use_ephpm_composer {
"ephpm composer".to_owned()
} else {
posix_single_quote(composer)
};
tracing::info!(%hostname, %use_ephpm_composer, "no build steps declared — running implicit composer install (sandboxed)");
let cmd = format!(
"{installer} install --no-dev --no-interaction --optimize-autoloader --quiet"
);
let status = sandbox
.command(site_dir, &cmd)
Expand All @@ -1290,10 +1357,11 @@ async fn run_build(
return;
}

for (i, cmd) in manifest.build.iter().enumerate() {
for (i, raw) in manifest.build.iter().enumerate() {
let cmd = route_composer_command(raw, use_ephpm_composer);
tracing::info!(%hostname, step = i + 1, command = %cmd, "running build step (sandboxed)");
let status = sandbox
.command(site_dir, cmd)
.command(site_dir, &cmd)
.env("COMPOSER_NO_INTERACTION", "1")
.stdout(Stdio::null())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -1637,16 +1705,18 @@ fn render_dotenv(env: &BTreeMap<String, String>) -> String {
async fn run_seed(
manifest: &AppManifest,
site_dir: &Path,
use_ephpm_composer: bool,
sandbox: SandboxExec<'_>,
preview_url: &str,
hostname: &str,
pr_number: u64,
) {
let workdir = site_dir.join(&manifest.docroot);
for (i, cmd) in manifest.seed.iter().enumerate() {
for (i, raw) in manifest.seed.iter().enumerate() {
let cmd = route_composer_command(raw, use_ephpm_composer);
tracing::info!(%hostname, step = i + 1, command = %cmd, "running seed step (sandboxed)");
let status = sandbox
.command(&workdir, cmd)
.command(&workdir, &cmd)
.env("PREVIEW_URL", preview_url)
.env("PREVIEW_HOST", hostname)
.env("PR", pr_number.to_string())
Expand Down Expand Up @@ -2225,6 +2295,7 @@ mod tests {
sites_domain_suffix: Some(".preview.ephpm.dev"),
site_overrides_dir: None,
composer: "composer",
use_ephpm_composer: false,
ephpm_bin: Path::new("ephpm"),
ephpm_config: Path::new("/etc/ephpm/ephpm.toml"),
secrets: &secrets,
Expand Down Expand Up @@ -2259,6 +2330,7 @@ mod tests {
sites_domain_suffix: Some(".preview.ephpm.dev"),
site_overrides_dir: overrides,
composer: "composer",
use_ephpm_composer: false,
ephpm_bin: Path::new("ephpm"),
ephpm_config: Path::new("/etc/ephpm/ephpm.toml"),
secrets,
Expand Down Expand Up @@ -2620,6 +2692,95 @@ mod tests {
assert_eq!(argv[8], "cd '/a'\\''b' && true");
}

// ── routing Composer through embedded `ephpm composer` (vivacity) ────

/// Case 1: with routing on, a build step's leading `composer` becomes
/// `ephpm composer`, arguments preserved.
#[test]
fn routing_rewrites_a_leading_composer_token() {
assert_eq!(
route_composer_command("composer install --no-dev", true),
"ephpm composer install --no-dev"
);
// Bare `composer` with no arguments.
assert_eq!(route_composer_command("composer", true), "ephpm composer");
// Leading whitespace is preserved (still a genuine leading token).
assert_eq!(
route_composer_command(" composer update", true),
" ephpm composer update"
);
}

/// Case 1 (end-to-end argv): the routed step, once wrapped by the sandbox,
/// actually executes `ephpm composer install …` inside `ephpm exec … sh -c`.
#[test]
fn routed_build_step_executes_ephpm_composer_in_the_sandbox() {
let routed = route_composer_command("composer install --no-dev", true);
let argv = sandbox().argv(Path::new("/var/www/sites/app-pr-1"), &routed);
assert_eq!(
argv.last().unwrap(),
"cd '/var/www/sites/app-pr-1' && ephpm composer install --no-dev"
);
// The program is still the ephpm binary (never a bare composer/sh).
let cmd = sandbox().command(Path::new("/var/www/sites/app-pr-1"), &routed);
assert_eq!(
cmd.as_std().get_program().to_string_lossy(),
"/usr/local/bin/ephpm"
);
}

/// Case 3: with routing off, the command is byte-for-byte unchanged — no
/// regression against today's behaviour.
#[test]
fn routing_off_leaves_the_command_untouched() {
assert_eq!(
route_composer_command("composer install --no-dev", false),
"composer install --no-dev"
);
let argv = sandbox().argv(
Path::new("/var/www/sites/app-pr-1"),
&route_composer_command("composer install", false),
);
assert_eq!(
argv.last().unwrap(),
"cd '/var/www/sites/app-pr-1' && composer install"
);
}

/// Case 4: `composer` as a **non-leading** token (or a look-alike leading
/// token) is never rewritten, even with routing on.
#[test]
fn routing_ignores_non_leading_and_lookalike_composer() {
// Non-leading occurrences.
assert_eq!(
route_composer_command("php artisan queue:work composer", true),
"php artisan queue:work composer"
);
assert_eq!(
route_composer_command("echo composer install", true),
"echo composer install"
);
// A leading word that merely contains "composer" as a substring.
assert_eq!(
route_composer_command("my-composer install", true),
"my-composer install"
);
assert_eq!(
route_composer_command("composer.phar install", true),
"composer.phar install"
);
assert_eq!(
route_composer_command("php composer.phar install", true),
"php composer.phar install"
);
// Already-routed command: leading token is `ephpm`, so it is left alone
// (no double-rewrite, no `ephpm ephpm composer`).
assert_eq!(
route_composer_command("ephpm composer install", true),
"ephpm composer install"
);
}

#[test]
fn help_text_detects_the_exec_subcommand() {
let with = "Commands:\n serve Run the server\n exec Run in a sandbox\n kv KV\n";
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ async fn handle_deploy(state: &AppState, req: &PreviewRequest) -> anyhow::Result
sites_domain_suffix: suffix.as_deref(),
site_overrides_dir: state.config.site_overrides_dir.as_deref(),
composer: &state.config.composer,
use_ephpm_composer: state.config.use_ephpm_composer,
ephpm_bin: &state.config.ephpm_bin,
ephpm_config: &state.config.ephpm_config,
secrets,
Expand Down
Loading