diff --git a/crates/edgezero-adapter-cloudflare/src/cli.rs b/crates/edgezero-adapter-cloudflare/src/cli.rs index f858021c..7ae79376 100644 --- a/crates/edgezero-adapter-cloudflare/src/cli.rs +++ b/crates/edgezero-adapter-cloudflare/src/cli.rs @@ -7,7 +7,8 @@ use std::process::Command; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, + find_manifest_upwards, find_workspace_root, read_package_name, run_native_cli, + select_nearest_manifest, }; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, @@ -38,7 +39,7 @@ static CLOUDFLARE_BLUEPRINT: AdapterBlueprint = AdapterBlueprint { build_features: &["cloudflare"], }, commands: CommandTemplates { - build: "wrangler build --cwd {crate_dir}", + build: "wrangler deploy --dry-run --cwd {crate_dir}", deploy: "wrangler deploy --cwd {crate_dir}", serve: "wrangler dev --cwd {crate_dir}", }, @@ -919,7 +920,7 @@ fn read_wrangler_kv_key( } /// # Errors -/// Returns an error if the Cloudflare wrangler build command fails. +/// Returns an error if the Cloudflare Cargo build command fails. #[inline] pub fn build(extra_args: &[String]) -> Result { let manifest = @@ -1024,7 +1025,7 @@ fn find_wrangler_manifest(start: &Path) -> Result { } let root = find_workspace_root(start); - let mut candidates: Vec = WalkDir::new(&root) + let candidates: Vec = WalkDir::new(&root) .follow_links(true) .max_depth(8) .into_iter() @@ -1038,16 +1039,7 @@ fn find_wrangler_manifest(start: &Path) -> Result { }) .collect(); - if candidates.is_empty() { - return Err("could not locate wrangler.toml".to_owned()); - } - - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) + select_nearest_manifest(start, candidates, "wrangler.toml") } fn locate_artifact( @@ -1183,6 +1175,37 @@ mod tests { } } + #[test] + fn cloudflare_scaffold_does_not_emit_removed_wrangler_build_command() { + let command = CLOUDFLARE_BLUEPRINT.commands.build; + assert!(!command.contains("wrangler build"), "{command}"); + assert_eq!( + command, "wrangler deploy --dry-run --cwd {crate_dir}", + "the validation build must execute Wrangler's real custom-build pipeline" + ); + } + + #[test] + fn rejects_equidistant_wrangler_manifests() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + + let first = root.join("apps/first"); + fs::create_dir_all(&first).unwrap(); + fs::write(first.join("Cargo.toml"), "[package]\nname=\"first\"").unwrap(); + fs::write(first.join("wrangler.toml"), "name=\"first\"").unwrap(); + + let second = root.join("apps/second"); + fs::create_dir_all(&second).unwrap(); + fs::write(second.join("Cargo.toml"), "[package]\nname=\"second\"").unwrap(); + fs::write(second.join("wrangler.toml"), "name=\"second\"").unwrap(); + + let error = find_wrangler_manifest(root).expect_err("equidistant manifests are ambiguous"); + assert!(error.contains(first.join("wrangler.toml").to_string_lossy().as_ref())); + assert!(error.contains(second.join("wrangler.toml").to_string_lossy().as_ref())); + } + // ---------- extract_namespace_id ---------- #[test] diff --git a/crates/edgezero-adapter-fastly/src/cli.rs b/crates/edgezero-adapter-fastly/src/cli.rs index a3de1cba..20844e74 100644 --- a/crates/edgezero-adapter-fastly/src/cli.rs +++ b/crates/edgezero-adapter-fastly/src/cli.rs @@ -8,7 +8,8 @@ use std::process::Stdio; use crate::chunked_config::{prepare_fastly_config_entries, resolve_fastly_config_value}; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, + find_manifest_upwards, find_workspace_root, read_package_name, run_native_cli, + select_nearest_manifest, }; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, @@ -1284,7 +1285,7 @@ fn find_fastly_manifest(start: &Path) -> Result { } let root = find_workspace_root(start); - let mut candidates: Vec = WalkDir::new(&root) + let candidates: Vec = WalkDir::new(&root) .follow_links(true) .max_depth(8) .into_iter() @@ -1298,16 +1299,7 @@ fn find_fastly_manifest(start: &Path) -> Result { }) .collect(); - if candidates.is_empty() { - return Err("could not locate fastly.toml".to_owned()); - } - - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) + select_nearest_manifest(start, candidates, "fastly.toml") } fn locate_artifact( @@ -1441,6 +1433,27 @@ mod tests { } } + #[test] + fn rejects_equidistant_fastly_manifests() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + + let first = root.join("apps/first"); + fs::create_dir_all(&first).unwrap(); + fs::write(first.join("Cargo.toml"), "[package]\nname=\"first\"").unwrap(); + fs::write(first.join("fastly.toml"), "name=\"first\"").unwrap(); + + let second = root.join("apps/second"); + fs::create_dir_all(&second).unwrap(); + fs::write(second.join("Cargo.toml"), "[package]\nname=\"second\"").unwrap(); + fs::write(second.join("fastly.toml"), "name=\"second\"").unwrap(); + + let error = find_fastly_manifest(root).expect_err("equidistant manifests are ambiguous"); + assert!(error.contains(first.join("fastly.toml").to_string_lossy().as_ref())); + assert!(error.contains(second.join("fastly.toml").to_string_lossy().as_ref())); + } + #[test] fn finds_closest_manifest_when_multiple_exist() { let dir = tempdir().unwrap(); diff --git a/crates/edgezero-adapter-spin/src/cli.rs b/crates/edgezero-adapter-spin/src/cli.rs index 11c1d6a2..e1d8ef23 100644 --- a/crates/edgezero-adapter-spin/src/cli.rs +++ b/crates/edgezero-adapter-spin/src/cli.rs @@ -14,7 +14,8 @@ use std::process::Command; use ctor::ctor; use edgezero_adapter::cli_support::{ - find_manifest_upwards, find_workspace_root, path_distance, read_package_name, run_native_cli, + find_manifest_upwards, find_workspace_root, read_package_name, run_native_cli, + select_nearest_manifest, }; use edgezero_adapter::registry::{ register_adapter, Adapter, AdapterAction, AdapterPushContext, ProvisionStores, ReadConfigEntry, @@ -1115,7 +1116,7 @@ fn find_spin_manifest(start: &Path) -> Result { } let root = find_workspace_root(start); - let mut candidates: Vec = WalkDir::new(&root) + let candidates: Vec = WalkDir::new(&root) .follow_links(true) .max_depth(8) .into_iter() @@ -1129,16 +1130,7 @@ fn find_spin_manifest(start: &Path) -> Result { }) .collect(); - if candidates.is_empty() { - return Err("could not locate spin.toml".to_owned()); - } - - candidates.sort_by_key(|path| { - let parent = path.parent().unwrap_or(Path::new("")); - path_distance(start, parent) - }); - - Ok(candidates.remove(0)) + select_nearest_manifest(start, candidates, "spin.toml") } fn locate_artifact( @@ -1436,6 +1428,27 @@ mod tests { assert_eq!(SpinCliAdapter.single_store_kinds(), &["secrets"]); } + #[test] + fn rejects_equidistant_spin_manifests() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Cargo.toml"), "[workspace]").unwrap(); + + let first = root.join("apps/first"); + fs::create_dir_all(&first).unwrap(); + fs::write(first.join("Cargo.toml"), "[package]\nname=\"first\"").unwrap(); + fs::write(first.join("spin.toml"), "spin_manifest_version = 2").unwrap(); + + let second = root.join("apps/second"); + fs::create_dir_all(&second).unwrap(); + fs::write(second.join("Cargo.toml"), "[package]\nname=\"second\"").unwrap(); + fs::write(second.join("spin.toml"), "spin_manifest_version = 2").unwrap(); + + let error = find_spin_manifest(root).expect_err("equidistant manifests are ambiguous"); + assert!(error.contains(first.join("spin.toml").to_string_lossy().as_ref())); + assert!(error.contains(second.join("spin.toml").to_string_lossy().as_ref())); + } + #[test] fn finds_closest_manifest_when_multiple_exist() { let dir = tempdir().unwrap(); diff --git a/crates/edgezero-adapter/src/cli_support.rs b/crates/edgezero-adapter/src/cli_support.rs index 94ace713..4e33b3f8 100644 --- a/crates/edgezero-adapter/src/cli_support.rs +++ b/crates/edgezero-adapter/src/cli_support.rs @@ -66,6 +66,59 @@ pub fn path_distance(left: &Path, right: &Path) -> usize { .saturating_add(right_components.len().saturating_sub(common)) } +/// Select the unique nearest provider manifest instead of making an +/// order-dependent choice. +/// +/// # Errors +/// Returns an error when no candidate exists or multiple candidates have the +/// same minimum distance from `start`. +#[inline] +pub fn select_nearest_manifest( + start: &Path, + mut candidates: Vec, + manifest_name: &str, +) -> Result { + if candidates.is_empty() { + return Err(format!("could not locate {manifest_name}")); + } + + candidates.sort(); + let mut ranked: Vec<(usize, PathBuf)> = candidates + .into_iter() + .map(|path| { + let parent = path.parent().unwrap_or(Path::new("")); + (path_distance(start, parent), path) + }) + .collect(); + ranked.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + + let nearest_distance = ranked + .first() + .map(|(distance, _)| *distance) + .ok_or_else(|| format!("could not select {manifest_name}"))?; + let mut nearest: Vec<_> = ranked + .into_iter() + .filter(|(distance, _)| *distance == nearest_distance) + .map(|(_, path)| path) + .collect(); + + if nearest.len() == 1 { + return nearest + .pop() + .ok_or_else(|| format!("could not select {manifest_name}")); + } + + let rendered = nearest + .iter() + .map(|path| format!(" - {}", path.display())) + .collect::>() + .join("\n"); + Err(format!( + "ambiguous {manifest_name}: multiple equally near manifests found:\n{rendered}\n\ + set the working directory to the intended adapter crate or define an explicit adapter command" + )) +} + /// Spawn `program args…` inheriting parent stdio, returning a /// human-readable error message. /// @@ -193,6 +246,32 @@ mod tests { assert_eq!(path_distance(left, right), 3); } + #[test] + fn nearest_manifest_returns_unique_closest_candidate() { + let start = Path::new("/repo/apps/a"); + let candidates = vec![ + PathBuf::from("/repo/apps/a/fastly.toml"), + PathBuf::from("/repo/apps/b/fastly.toml"), + ]; + let selected = + select_nearest_manifest(start, candidates, "fastly.toml").expect("unique manifest"); + assert_eq!(selected, PathBuf::from("/repo/apps/a/fastly.toml")); + } + + #[test] + fn nearest_manifest_rejects_equal_distance_candidates() { + let start = Path::new("/repo"); + let candidates = vec![ + PathBuf::from("/repo/apps/a/fastly.toml"), + PathBuf::from("/repo/apps/b/fastly.toml"), + ]; + let error = + select_nearest_manifest(start, candidates, "fastly.toml").expect_err("ambiguous"); + assert!(error.contains("ambiguous fastly.toml")); + assert!(error.contains("/repo/apps/a/fastly.toml")); + assert!(error.contains("/repo/apps/b/fastly.toml")); + } + #[test] fn read_package_prefers_package_table() { let dir = tempdir().unwrap(); diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index ce42d628..89bb3469 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -103,12 +103,18 @@ edgezero build --adapter axum The command executes the `build` command from `[adapters..commands]` in `edgezero.toml`, or falls back to the built-in adapter helper. -Any arguments after `--` are forwarded to the adapter command: +Any arguments after `--` are forwarded unchanged to the manifest-defined or +built-in adapter build command: ```bash -edgezero build --adapter fastly -- --flag value +edgezero build --adapter -- ``` +Use passthrough arguments only for non-secret options. EdgeZero omits them from +its own command log, but the provider CLI, shell, process list, or CI runner may +still expose them. Pass credentials through the provider's supported +environment variables or credential store instead. + ### edgezero serve Run the provider-specific local server: @@ -169,6 +175,16 @@ edgezero deploy --adapter cloudflare edgezero deploy --adapter spin ``` +Any arguments after `--` are forwarded unchanged to the manifest-defined or +built-in adapter deploy command: + +```bash +edgezero deploy --adapter -- +``` + +As with build arguments, passthrough deploy arguments must not contain secrets. +Use environment variables or the provider's credential store for credentials. + **Provider behavior:** - **Fastly**: Runs `fastly compute deploy` @@ -179,6 +195,29 @@ edgezero deploy --adapter spin The `axum` adapter doesn't support `deploy` - use standard container/binary deployment instead. ::: +#### Command dispatch and provider-manifest resolution + +For `build` and `deploy`, a command explicitly configured in +`[adapters..commands]` takes precedence over the built-in adapter helper. +EdgeZero runs that command from the directory containing `edgezero.toml` and +appends any passthrough arguments after shell-escaping them. + +When no explicit command is configured, EdgeZero delegates to the registered +built-in adapter. The Fastly, Cloudflare, and Spin helpers resolve their +provider manifest (`fastly.toml`, `wrangler.toml`, or `spin.toml`) as follows: + +1. Search the current working directory and then its ancestors. The first + matching provider manifest wins. +2. If the upward search finds nothing, scan the Cargo workspace and select the + unique manifest whose parent directory is nearest to the current working + directory. +3. If multiple manifests are equally near, fail with an error listing the + candidates. Run the command from the intended adapter crate or define the + explicit `[adapters..commands]` entry to remove the ambiguity. + +This avoids silently deploying a different application from a multi-adapter or +multi-application workspace. + ### edgezero config validate Validate `edgezero.toml` together with the typed `.toml` app diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index ce283df6..14b0d83c 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -462,7 +462,7 @@ target = "wasm32-unknown-unknown" profile = "release" [adapters.cloudflare.commands] -build = "wrangler build --cwd crates/my-app-adapter-cloudflare" +build = "wrangler deploy --dry-run --cwd crates/my-app-adapter-cloudflare" deploy = "wrangler deploy --cwd crates/my-app-adapter-cloudflare" serve = "wrangler dev --cwd crates/my-app-adapter-cloudflare" diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index bd8b93cd..23ee473c 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -172,7 +172,7 @@ profile = "release" features = ["cloudflare"] [adapters.cloudflare.commands] -build = "wrangler build --cwd crates/app-demo-adapter-cloudflare" +build = "wrangler deploy --dry-run --cwd crates/app-demo-adapter-cloudflare" deploy = "wrangler deploy --cwd crates/app-demo-adapter-cloudflare" serve = "wrangler dev --cwd crates/app-demo-adapter-cloudflare"