From bb4f10f21b01929e3d41f8d62037c4b34e509cf1 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 30 Mar 2026 13:34:22 -0500 Subject: [PATCH 1/6] feat: make axum host configurable via manifest and env vars Add host/port configuration to the axum adapter so the bind address can be set to 0.0.0.0 or any other IP instead of the hardcoded 127.0.0.1:8787 default. Configuration precedence (highest wins): 1. EDGEZERO_HOST / EDGEZERO_PORT environment variables 2. Manifest value (edgezero.toml or axum.toml) 3. Default: 127.0.0.1:8787 Invalid values produce warnings instead of silently falling back to defaults. The CLI subprocess now receives EDGEZERO_HOST/EDGEZERO_PORT so resolved values propagate to the running server. --- crates/edgezero-adapter-axum/src/cli.rs | 78 +++++++++++++++++- .../edgezero-adapter-axum/src/dev_server.rs | 81 ++++++++++++++++++- crates/edgezero-adapter-axum/src/lib.rs | 2 +- crates/edgezero-cli/src/dev_server.rs | 27 ++++++- crates/edgezero-core/src/manifest.rs | 37 +++++++++ 5 files changed, 217 insertions(+), 8 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index c070526..9bbacf5 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -1,4 +1,5 @@ use std::fs; +use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::process::Command; @@ -139,10 +140,12 @@ fn deploy(_extra_args: &[String]) -> Result<(), String> { Err("Axum adapter does not define a deploy command. Extend your workspace manifest with one if needed.".into()) } +#[derive(Debug)] struct AxumProject { crate_dir: PathBuf, cargo_manifest: PathBuf, crate_name: String, + host: IpAddr, port: u16, } @@ -155,8 +158,8 @@ fn locate_project() -> Result { fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { let display = project.crate_dir.display(); println!( - "[edgezero] Axum {subcommand} ({}) in {} (port: {})", - project.crate_name, display, project.port + "[edgezero] Axum {subcommand} ({}) in {} ({}:{})", + project.crate_name, display, project.host, project.port ); let mut command = Command::new("cargo"); command.arg(subcommand); @@ -169,6 +172,8 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> ); command.args(extra_args); command.current_dir(&project.crate_dir); + command.env("EDGEZERO_HOST", project.host.to_string()); + command.env("EDGEZERO_PORT", project.port.to_string()); let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; @@ -255,6 +260,16 @@ fn read_axum_project(manifest: &Path) -> Result { }) }); + let host: IpAddr = match adapter.get("host").and_then(Value::as_str) { + Some(value) => value.parse().map_err(|_| { + format!( + "adapter.host in {} must be a valid IP address", + manifest.display() + ) + })?, + None => [127, 0, 0, 1].into(), + }; + let port = match adapter.get("port").and_then(Value::as_integer) { Some(value) => { if !(1..=u16::MAX as i64).contains(&value) { @@ -272,6 +287,7 @@ fn read_axum_project(manifest: &Path) -> Result { crate_dir, cargo_manifest, crate_name, + host, port, }) } @@ -532,6 +548,64 @@ mod tests { assert_eq!(project.port, 1); } + #[test] + fn read_axum_project_defaults_host_to_localhost() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.host, IpAddr::from([127, 0, 0, 1])); + } + + #[test] + fn read_axum_project_uses_custom_host() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"0.0.0.0\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.host, IpAddr::from([0, 0, 0, 0])); + } + + #[test] + fn read_axum_project_rejects_invalid_host() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"not-an-ip\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project(&root.join("axum.toml")); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("valid IP address")); + } + #[test] fn find_axum_manifest_returns_error_when_not_found() { let dir = tempdir().unwrap(); diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 0a03b4c..25f6902 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -292,17 +292,24 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { SimpleLogger::new().with_level(level).init().ok(); + let addr = resolve_addr(manifest); let app = A::build_app(); let router = app.router().clone(); + + println!( + "[edgezero] starting axum server on http://{}:{}", + addr.ip(), + addr.port() + ); + let runtime = RuntimeBuilder::new_multi_thread() .enable_all() .build() .context("failed to build tokio runtime")?; runtime.block_on(async move { - let config = AxumDevServerConfig::default(); - let listener = StdTcpListener::bind(config.addr) - .with_context(|| format!("failed to bind dev server to {}", config.addr))?; + let listener = StdTcpListener::bind(addr) + .with_context(|| format!("failed to bind dev server to {}", addr))?; listener .set_nonblocking(true) .context("failed to set listener to non-blocking")?; @@ -357,10 +364,33 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { kv: kv_handle, secrets: secret, }; - serve_with_stores(router, listener, config.enable_ctrl_c, stores).await + serve_with_stores(router, listener, true, stores).await }) } +/// Resolve the bind address from environment variables and manifest config. +/// +/// Precedence (highest wins): +/// 1. `EDGEZERO_HOST` / `EDGEZERO_PORT` environment variables +/// 2. `[adapters.axum.adapter]` host/port in the manifest +/// 3. Default: `127.0.0.1:8787` +pub(crate) fn resolve_addr(manifest: &edgezero_core::manifest::Manifest) -> SocketAddr { + let env_host = std::env::var("EDGEZERO_HOST").ok(); + let env_port = std::env::var("EDGEZERO_PORT").ok(); + resolve_addr_from_parts(manifest, env_host.as_deref(), env_port.as_deref()) +} + +fn resolve_addr_from_parts( + manifest: &edgezero_core::manifest::Manifest, + env_host: Option<&str>, + env_port: Option<&str>, +) -> SocketAddr { + let adapter = manifest.adapters.get("axum"); + let config_host = adapter.and_then(|a| a.adapter.host.as_deref()); + let config_port = adapter.and_then(|a| a.adapter.port); + edgezero_core::addr::resolve_bind_addr(env_host, env_port, config_host, config_port) +} + #[cfg(test)] mod tests { use super::*; @@ -485,6 +515,49 @@ name = "EDGEZERO_KV" "unexpected file name length: {file_name}" ); } + + #[test] + fn resolve_addr_defaults_without_manifest_config() { + // Note: env var tests use resolve_addr_from_parts to avoid races. + let loader = ManifestLoader::load_from_str(""); + let addr = resolve_addr_from_parts(loader.manifest(), None, None); + assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + } + + #[test] + fn resolve_addr_reads_manifest_host_and_port() { + let manifest = r#" +[adapters.axum.adapter] +host = "0.0.0.0" +port = 3000 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let addr = resolve_addr_from_parts(loader.manifest(), None, None); + assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 3000))); + } + + #[test] + fn resolve_addr_env_overrides_manifest() { + let manifest = r#" +[adapters.axum.adapter] +host = "127.0.0.1" +port = 3000 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")); + assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 4000))); + } + + #[test] + fn resolve_addr_partial_env_override() { + let manifest = r#" +[adapters.axum.adapter] +port = 5000 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None); + assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 5000))); + } } #[cfg(test)] diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index ae9e539..405345b 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -30,7 +30,7 @@ pub use config_store::AxumConfigStore; #[cfg(feature = "axum")] pub use context::AxumRequestContext; #[cfg(feature = "axum")] -pub use dev_server::{run_app, AxumDevServer, AxumDevServerConfig}; +pub use dev_server::{resolve_addr, run_app, AxumDevServer, AxumDevServerConfig}; #[cfg(feature = "axum")] pub use key_value_store::PersistentKvStore; #[cfg(feature = "axum")] diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/dev_server.rs index 7cb6e05..94e0ad8 100644 --- a/crates/edgezero-cli/src/dev_server.rs +++ b/crates/edgezero-cli/src/dev_server.rs @@ -25,7 +25,7 @@ pub fn run_dev() { Err(err) => eprintln!("[edgezero] dev manifest error: {err}"), } - let addr = SocketAddr::from(([127, 0, 0, 1], 8787)); + let addr = resolve_dev_addr(); println!( "[edgezero] dev: starting local server on http://{}:{}", addr.ip(), @@ -83,6 +83,31 @@ async fn dev_echo(Path(params): Path) -> Text { Text::new(format!("hello {}", params.name)) } +/// Resolve the dev server bind address from `EDGEZERO_HOST` / `EDGEZERO_PORT` +/// environment variables, falling back to `127.0.0.1:8787`. +fn resolve_dev_addr() -> SocketAddr { + let default_host: std::net::IpAddr = [127, 0, 0, 1].into(); + let host = match std::env::var("EDGEZERO_HOST") { + Ok(v) => v.parse().unwrap_or_else(|_| { + eprintln!( + "[edgezero] warning: EDGEZERO_HOST={v:?} is not a valid IP address, using default" + ); + default_host + }), + Err(_) => default_host, + }; + let port = match std::env::var("EDGEZERO_PORT") { + Ok(v) => v.parse().unwrap_or_else(|_| { + eprintln!( + "[edgezero] warning: EDGEZERO_PORT={v:?} is not a valid port number, using default" + ); + 8787 + }), + Err(_) => 8787, + }; + SocketAddr::from((host, port)) +} + fn try_run_manifest_axum() -> Result { let manifest = match load_manifest_optional()? { Some(manifest) => manifest, diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 571a496..11b20d1 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -351,6 +351,13 @@ pub struct ManifestAdapterDefinition { #[serde(default)] #[validate(length(min = 1))] pub manifest: Option, + /// Bind address for the adapter server (e.g. `"0.0.0.0"` or `"127.0.0.1"`). + #[serde(default)] + #[validate(length(min = 1))] + pub host: Option, + /// Port for the adapter server. + #[serde(default)] + pub port: Option, } #[derive(Debug, Default, Deserialize, Validate)] @@ -1669,4 +1676,34 @@ name = "FASTLY_STORE" DEFAULT_SECRET_STORE_NAME ); } + + // -- Adapter host/port config ------------------------------------------ + + #[test] + fn adapter_definition_with_host_and_port() { + let manifest = r#" +[adapters.axum.adapter] +crate = "crates/axum-adapter" +host = "0.0.0.0" +port = 3000 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let adapter = m.adapters.get("axum").unwrap(); + assert_eq!(adapter.adapter.host.as_deref(), Some("0.0.0.0")); + assert_eq!(adapter.adapter.port, Some(3000)); + } + + #[test] + fn adapter_definition_host_and_port_default_to_none() { + let manifest = r#" +[adapters.axum.adapter] +crate = "crates/axum-adapter" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let adapter = m.adapters.get("axum").unwrap(); + assert!(adapter.adapter.host.is_none()); + assert!(adapter.adapter.port.is_none()); + } } From 82a59a88ff9d0214284a1cf0d8fe0c5fffc2afc6 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 31 Mar 2026 10:56:46 -0500 Subject: [PATCH 2/6] fix: reject port 0, extract shared addr resolution, reduce resolve_addr visibility - Extract resolve_bind_addr into edgezero_core::addr so both the axum adapter and the CLI dev server share a single code path for resolving bind addresses from env vars and config. - Reject port 0 (random OS port) with a warning and fallback to 8787, matching the existing cli.rs validation. - Narrow resolve_addr to pub(crate) since it has no external consumers. --- crates/edgezero-adapter-axum/src/lib.rs | 2 +- crates/edgezero-cli/src/dev_server.rs | 23 +--- crates/edgezero-core/src/addr.rs | 146 ++++++++++++++++++++++++ crates/edgezero-core/src/lib.rs | 1 + 4 files changed, 151 insertions(+), 21 deletions(-) create mode 100644 crates/edgezero-core/src/addr.rs diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index 405345b..ae9e539 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -30,7 +30,7 @@ pub use config_store::AxumConfigStore; #[cfg(feature = "axum")] pub use context::AxumRequestContext; #[cfg(feature = "axum")] -pub use dev_server::{resolve_addr, run_app, AxumDevServer, AxumDevServerConfig}; +pub use dev_server::{run_app, AxumDevServer, AxumDevServerConfig}; #[cfg(feature = "axum")] pub use key_value_store::PersistentKvStore; #[cfg(feature = "axum")] diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/dev_server.rs index 94e0ad8..205c4d2 100644 --- a/crates/edgezero-cli/src/dev_server.rs +++ b/crates/edgezero-cli/src/dev_server.rs @@ -86,26 +86,9 @@ async fn dev_echo(Path(params): Path) -> Text { /// Resolve the dev server bind address from `EDGEZERO_HOST` / `EDGEZERO_PORT` /// environment variables, falling back to `127.0.0.1:8787`. fn resolve_dev_addr() -> SocketAddr { - let default_host: std::net::IpAddr = [127, 0, 0, 1].into(); - let host = match std::env::var("EDGEZERO_HOST") { - Ok(v) => v.parse().unwrap_or_else(|_| { - eprintln!( - "[edgezero] warning: EDGEZERO_HOST={v:?} is not a valid IP address, using default" - ); - default_host - }), - Err(_) => default_host, - }; - let port = match std::env::var("EDGEZERO_PORT") { - Ok(v) => v.parse().unwrap_or_else(|_| { - eprintln!( - "[edgezero] warning: EDGEZERO_PORT={v:?} is not a valid port number, using default" - ); - 8787 - }), - Err(_) => 8787, - }; - SocketAddr::from((host, port)) + let env_host = std::env::var("EDGEZERO_HOST").ok(); + let env_port = std::env::var("EDGEZERO_PORT").ok(); + edgezero_core::addr::resolve_bind_addr(env_host.as_deref(), env_port.as_deref(), None, None) } fn try_run_manifest_axum() -> Result { diff --git a/crates/edgezero-core/src/addr.rs b/crates/edgezero-core/src/addr.rs new file mode 100644 index 0000000..3991b43 --- /dev/null +++ b/crates/edgezero-core/src/addr.rs @@ -0,0 +1,146 @@ +//! Shared bind-address resolution for EdgeZero dev servers. +//! +//! Centralises the precedence logic (env vars > config > defaults) so that +//! both the Axum adapter and the CLI dev server produce consistent results. + +use std::net::{IpAddr, SocketAddr}; + +const DEFAULT_HOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); +const DEFAULT_PORT: u16 = 8787; + +/// Resolve a bind address from optional environment and config values. +/// +/// Precedence (highest wins): +/// 1. `env_host` / `env_port` (typically `EDGEZERO_HOST` / `EDGEZERO_PORT`) +/// 2. `config_host` / `config_port` (from manifest or adapter config) +/// 3. Defaults: `127.0.0.1:8787` +/// +/// Invalid values produce a `log::warn!` and fall back to the default. +/// Port 0 is rejected (random OS port is almost never intended). +pub fn resolve_bind_addr( + env_host: Option<&str>, + env_port: Option<&str>, + config_host: Option<&str>, + config_port: Option, +) -> SocketAddr { + let host = resolve_host(env_host, config_host); + let port = resolve_port(env_port, config_port); + SocketAddr::from((host, port)) +} + +fn resolve_host(env_host: Option<&str>, config_host: Option<&str>) -> IpAddr { + if let Some(v) = env_host { + return v.parse().unwrap_or_else(|_| { + log::warn!("EDGEZERO_HOST={v:?} is not a valid IP address, using default"); + DEFAULT_HOST + }); + } + if let Some(h) = config_host { + return h.parse().unwrap_or_else(|_| { + log::warn!("configured host={h:?} is not a valid IP address, using default"); + DEFAULT_HOST + }); + } + DEFAULT_HOST +} + +fn resolve_port(env_port: Option<&str>, config_port: Option) -> u16 { + let port = if let Some(v) = env_port { + v.parse().unwrap_or_else(|_| { + log::warn!("EDGEZERO_PORT={v:?} is not a valid port number, using default"); + DEFAULT_PORT + }) + } else { + config_port.unwrap_or(DEFAULT_PORT) + }; + + if port == 0 { + log::warn!("port 0 is not supported, using default {DEFAULT_PORT}"); + return DEFAULT_PORT; + } + + port +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn defaults_when_nothing_provided() { + let addr = resolve_bind_addr(None, None, None, None); + assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + } + + #[test] + fn config_overrides_defaults() { + let addr = resolve_bind_addr(None, None, Some("0.0.0.0"), Some(3000)); + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(addr.port(), 3000); + } + + #[test] + fn env_overrides_config() { + let addr = resolve_bind_addr(Some("0.0.0.0"), Some("4000"), Some("127.0.0.1"), Some(3000)); + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(addr.port(), 4000); + } + + #[test] + fn partial_env_override_host_only() { + let addr = resolve_bind_addr(Some("0.0.0.0"), None, None, Some(5000)); + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(addr.port(), 5000); + } + + #[test] + fn partial_env_override_port_only() { + let addr = resolve_bind_addr(None, Some("9000"), Some("0.0.0.0"), None); + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(addr.port(), 9000); + } + + #[test] + fn invalid_env_host_falls_back_to_default() { + let addr = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None); + assert_eq!(addr.ip(), DEFAULT_HOST); + } + + #[test] + fn invalid_env_port_falls_back_to_default() { + let addr = resolve_bind_addr(None, Some("abc"), None, Some(3000)); + assert_eq!(addr.port(), DEFAULT_PORT); + } + + #[test] + fn invalid_config_host_falls_back_to_default() { + let addr = resolve_bind_addr(None, None, Some("not-an-ip"), None); + assert_eq!(addr.ip(), DEFAULT_HOST); + } + + #[test] + fn port_zero_from_env_falls_back_to_default() { + let addr = resolve_bind_addr(None, Some("0"), None, None); + assert_eq!(addr.port(), DEFAULT_PORT); + } + + #[test] + fn port_zero_from_config_falls_back_to_default() { + let addr = resolve_bind_addr(None, None, None, Some(0)); + assert_eq!(addr.port(), DEFAULT_PORT); + } + + #[test] + fn ipv6_host_from_env() { + let addr = resolve_bind_addr(Some("::1"), None, None, None); + assert_eq!(addr.ip(), "::1".parse::().unwrap()); + } + + #[test] + fn ipv6_host_from_config() { + let addr = resolve_bind_addr(None, None, Some("::"), Some(3000)); + assert_eq!(addr.ip(), "::".parse::().unwrap()); + assert_eq!(addr.port(), 3000); + } +} diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 7295053..f5970d9 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -1,5 +1,6 @@ //! Core primitives for building portable edge workloads across edge adapters. +pub mod addr; pub mod app; pub mod body; pub mod compression; From 58729d56d7cd0af8e5d0a254576b1b979ec6fe4f Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 1 Apr 2026 15:48:33 -0500 Subject: [PATCH 3/6] fix: unify validation paths, return Result from resolve_bind_addr - resolve_bind_addr now returns Result instead of silently falling back on invalid values (addresses silent warning drop) - read_axum_project delegates host/port validation to resolve_bind_addr, eliminating the inconsistency between axum.toml and edgezero.toml paths - DEFAULT_HOST and DEFAULT_PORT are now pub const for cross-crate reuse - CLI fallback path surfaces errors via eprintln instead of dropped log::warn - Added doc comment on manifest host field explaining late IP validation --- crates/edgezero-adapter-axum/src/cli.rs | 54 +++++------ .../edgezero-adapter-axum/src/dev_server.rs | 22 +++-- crates/edgezero-cli/src/dev_server.rs | 10 +- crates/edgezero-core/src/addr.rs | 96 +++++++++---------- crates/edgezero-core/src/manifest.rs | 4 + 5 files changed, 96 insertions(+), 90 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 9bbacf5..361e96a 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -1,5 +1,5 @@ use std::fs; -use std::net::IpAddr; +use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::Command; @@ -145,8 +145,7 @@ struct AxumProject { crate_dir: PathBuf, cargo_manifest: PathBuf, crate_name: String, - host: IpAddr, - port: u16, + addr: SocketAddr, } fn locate_project() -> Result { @@ -158,8 +157,8 @@ fn locate_project() -> Result { fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { let display = project.crate_dir.display(); println!( - "[edgezero] Axum {subcommand} ({}) in {} ({}:{})", - project.crate_name, display, project.host, project.port + "[edgezero] Axum {subcommand} ({}) in {} ({})", + project.crate_name, display, project.addr ); let mut command = Command::new("cargo"); command.arg(subcommand); @@ -172,8 +171,8 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> ); command.args(extra_args); command.current_dir(&project.crate_dir); - command.env("EDGEZERO_HOST", project.host.to_string()); - command.env("EDGEZERO_PORT", project.port.to_string()); + command.env("EDGEZERO_HOST", project.addr.ip().to_string()); + command.env("EDGEZERO_PORT", project.addr.port().to_string()); let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; @@ -260,35 +259,25 @@ fn read_axum_project(manifest: &Path) -> Result { }) }); - let host: IpAddr = match adapter.get("host").and_then(Value::as_str) { - Some(value) => value.parse().map_err(|_| { + let config_host = adapter.get("host").and_then(Value::as_str); + let config_port = match adapter.get("port").and_then(Value::as_integer) { + Some(value) => Some(u16::try_from(value).map_err(|_| { format!( - "adapter.host in {} must be a valid IP address", + "adapter.port in {} must be between 1 and 65535", manifest.display() ) - })?, - None => [127, 0, 0, 1].into(), + })?), + None => None, }; - let port = match adapter.get("port").and_then(Value::as_integer) { - Some(value) => { - if !(1..=u16::MAX as i64).contains(&value) { - return Err(format!( - "adapter.port in {} must be between 1 and 65535", - manifest.display() - )); - } - value as u16 - } - None => 8787, - }; + let addr = edgezero_core::addr::resolve_bind_addr(None, None, config_host, config_port) + .map_err(|e| format!("{e} (in {})", manifest.display()))?; Ok(AxumProject { crate_dir, cargo_manifest, crate_name, - host, - port, + addr, }) } @@ -317,7 +306,8 @@ mod tests { assert_eq!(project.crate_name, "demo"); assert_eq!(project.crate_dir, root); assert_eq!(project.cargo_manifest, root.join("Cargo.toml")); - assert_eq!(project.port, 8787); + assert_eq!(project.addr.port(), edgezero_core::addr::DEFAULT_PORT); + assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); } #[test] @@ -353,7 +343,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.port, 4001); + assert_eq!(project.addr.port(), 4001); } #[test] @@ -526,7 +516,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.port, 65535); + assert_eq!(project.addr.port(), 65535); } #[test] @@ -545,7 +535,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.port, 1); + assert_eq!(project.addr.port(), 1); } #[test] @@ -564,7 +554,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.host, IpAddr::from([127, 0, 0, 1])); + assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); } #[test] @@ -583,7 +573,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.host, IpAddr::from([0, 0, 0, 0])); + assert_eq!(project.addr.ip(), std::net::IpAddr::from([0, 0, 0, 0])); } #[test] diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 25f6902..3ff3135 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -35,7 +35,10 @@ pub struct AxumDevServerConfig { impl Default for AxumDevServerConfig { fn default() -> Self { Self { - addr: SocketAddr::from(([127, 0, 0, 1], 8787)), + addr: SocketAddr::from(( + edgezero_core::addr::DEFAULT_HOST, + edgezero_core::addr::DEFAULT_PORT, + )), enable_ctrl_c: true, } } @@ -292,7 +295,7 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { SimpleLogger::new().with_level(level).init().ok(); - let addr = resolve_addr(manifest); + let addr = resolve_addr(manifest).map_err(|e| anyhow::anyhow!("{e}"))?; let app = A::build_app(); let router = app.router().clone(); @@ -374,7 +377,9 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { /// 1. `EDGEZERO_HOST` / `EDGEZERO_PORT` environment variables /// 2. `[adapters.axum.adapter]` host/port in the manifest /// 3. Default: `127.0.0.1:8787` -pub(crate) fn resolve_addr(manifest: &edgezero_core::manifest::Manifest) -> SocketAddr { +pub(crate) fn resolve_addr( + manifest: &edgezero_core::manifest::Manifest, +) -> Result { let env_host = std::env::var("EDGEZERO_HOST").ok(); let env_port = std::env::var("EDGEZERO_PORT").ok(); resolve_addr_from_parts(manifest, env_host.as_deref(), env_port.as_deref()) @@ -384,7 +389,7 @@ fn resolve_addr_from_parts( manifest: &edgezero_core::manifest::Manifest, env_host: Option<&str>, env_port: Option<&str>, -) -> SocketAddr { +) -> Result { let adapter = manifest.adapters.get("axum"); let config_host = adapter.and_then(|a| a.adapter.host.as_deref()); let config_port = adapter.and_then(|a| a.adapter.port); @@ -520,7 +525,7 @@ name = "EDGEZERO_KV" fn resolve_addr_defaults_without_manifest_config() { // Note: env var tests use resolve_addr_from_parts to avoid races. let loader = ManifestLoader::load_from_str(""); - let addr = resolve_addr_from_parts(loader.manifest(), None, None); + let addr = resolve_addr_from_parts(loader.manifest(), None, None).unwrap(); assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); } @@ -532,7 +537,7 @@ host = "0.0.0.0" port = 3000 "#; let loader = ManifestLoader::load_from_str(manifest); - let addr = resolve_addr_from_parts(loader.manifest(), None, None); + let addr = resolve_addr_from_parts(loader.manifest(), None, None).unwrap(); assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 3000))); } @@ -544,7 +549,8 @@ host = "127.0.0.1" port = 3000 "#; let loader = ManifestLoader::load_from_str(manifest); - let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")); + let addr = + resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")).unwrap(); assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 4000))); } @@ -555,7 +561,7 @@ port = 3000 port = 5000 "#; let loader = ManifestLoader::load_from_str(manifest); - let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None); + let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None).unwrap(); assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 5000))); } } diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/dev_server.rs index 205c4d2..4c63135 100644 --- a/crates/edgezero-cli/src/dev_server.rs +++ b/crates/edgezero-cli/src/dev_server.rs @@ -25,7 +25,13 @@ pub fn run_dev() { Err(err) => eprintln!("[edgezero] dev manifest error: {err}"), } - let addr = resolve_dev_addr(); + let addr = match resolve_dev_addr() { + Ok(addr) => addr, + Err(err) => { + eprintln!("[edgezero] {err}"); + return; + } + }; println!( "[edgezero] dev: starting local server on http://{}:{}", addr.ip(), @@ -85,7 +91,7 @@ async fn dev_echo(Path(params): Path) -> Text { /// Resolve the dev server bind address from `EDGEZERO_HOST` / `EDGEZERO_PORT` /// environment variables, falling back to `127.0.0.1:8787`. -fn resolve_dev_addr() -> SocketAddr { +fn resolve_dev_addr() -> Result { let env_host = std::env::var("EDGEZERO_HOST").ok(); let env_port = std::env::var("EDGEZERO_PORT").ok(); edgezero_core::addr::resolve_bind_addr(env_host.as_deref(), env_port.as_deref(), None, None) diff --git a/crates/edgezero-core/src/addr.rs b/crates/edgezero-core/src/addr.rs index 3991b43..b158ccc 100644 --- a/crates/edgezero-core/src/addr.rs +++ b/crates/edgezero-core/src/addr.rs @@ -5,8 +5,10 @@ use std::net::{IpAddr, SocketAddr}; -const DEFAULT_HOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); -const DEFAULT_PORT: u16 = 8787; +/// Default bind host: localhost (`127.0.0.1`). +pub const DEFAULT_HOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); +/// Default bind port (`8787`). +pub const DEFAULT_PORT: u16 = 8787; /// Resolve a bind address from optional environment and config values. /// @@ -15,51 +17,46 @@ const DEFAULT_PORT: u16 = 8787; /// 2. `config_host` / `config_port` (from manifest or adapter config) /// 3. Defaults: `127.0.0.1:8787` /// -/// Invalid values produce a `log::warn!` and fall back to the default. -/// Port 0 is rejected (random OS port is almost never intended). +/// Returns an error if any provided value is invalid (unparseable host, +/// unparseable port, or port 0). Missing values fall through to the default. pub fn resolve_bind_addr( env_host: Option<&str>, env_port: Option<&str>, config_host: Option<&str>, config_port: Option, -) -> SocketAddr { - let host = resolve_host(env_host, config_host); - let port = resolve_port(env_port, config_port); - SocketAddr::from((host, port)) +) -> Result { + let host = resolve_host(env_host, config_host)?; + let port = resolve_port(env_port, config_port)?; + Ok(SocketAddr::from((host, port))) } -fn resolve_host(env_host: Option<&str>, config_host: Option<&str>) -> IpAddr { +fn resolve_host(env_host: Option<&str>, config_host: Option<&str>) -> Result { if let Some(v) = env_host { - return v.parse().unwrap_or_else(|_| { - log::warn!("EDGEZERO_HOST={v:?} is not a valid IP address, using default"); - DEFAULT_HOST - }); + return v + .parse() + .map_err(|_| format!("EDGEZERO_HOST={v:?} is not a valid IP address")); } if let Some(h) = config_host { - return h.parse().unwrap_or_else(|_| { - log::warn!("configured host={h:?} is not a valid IP address, using default"); - DEFAULT_HOST - }); + return h + .parse() + .map_err(|_| format!("configured host={h:?} is not a valid IP address")); } - DEFAULT_HOST + Ok(DEFAULT_HOST) } -fn resolve_port(env_port: Option<&str>, config_port: Option) -> u16 { +fn resolve_port(env_port: Option<&str>, config_port: Option) -> Result { let port = if let Some(v) = env_port { - v.parse().unwrap_or_else(|_| { - log::warn!("EDGEZERO_PORT={v:?} is not a valid port number, using default"); - DEFAULT_PORT - }) + v.parse() + .map_err(|_| format!("EDGEZERO_PORT={v:?} is not a valid port number"))? } else { config_port.unwrap_or(DEFAULT_PORT) }; if port == 0 { - log::warn!("port 0 is not supported, using default {DEFAULT_PORT}"); - return DEFAULT_PORT; + return Err("port 0 is not supported (would bind to a random OS port)".to_string()); } - port + Ok(port) } #[cfg(test)] @@ -69,77 +66,80 @@ mod tests { #[test] fn defaults_when_nothing_provided() { - let addr = resolve_bind_addr(None, None, None, None); + let addr = resolve_bind_addr(None, None, None, None).unwrap(); assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); } #[test] fn config_overrides_defaults() { - let addr = resolve_bind_addr(None, None, Some("0.0.0.0"), Some(3000)); + let addr = resolve_bind_addr(None, None, Some("0.0.0.0"), Some(3000)).unwrap(); assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); assert_eq!(addr.port(), 3000); } #[test] fn env_overrides_config() { - let addr = resolve_bind_addr(Some("0.0.0.0"), Some("4000"), Some("127.0.0.1"), Some(3000)); + let addr = resolve_bind_addr(Some("0.0.0.0"), Some("4000"), Some("127.0.0.1"), Some(3000)) + .unwrap(); assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); assert_eq!(addr.port(), 4000); } #[test] fn partial_env_override_host_only() { - let addr = resolve_bind_addr(Some("0.0.0.0"), None, None, Some(5000)); + let addr = resolve_bind_addr(Some("0.0.0.0"), None, None, Some(5000)).unwrap(); assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); assert_eq!(addr.port(), 5000); } #[test] fn partial_env_override_port_only() { - let addr = resolve_bind_addr(None, Some("9000"), Some("0.0.0.0"), None); + let addr = resolve_bind_addr(None, Some("9000"), Some("0.0.0.0"), None).unwrap(); assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); assert_eq!(addr.port(), 9000); } #[test] - fn invalid_env_host_falls_back_to_default() { - let addr = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None); - assert_eq!(addr.ip(), DEFAULT_HOST); + fn invalid_env_host_returns_error() { + let err = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None).unwrap_err(); + assert!(err.contains("EDGEZERO_HOST")); + assert!(err.contains("not a valid IP address")); } #[test] - fn invalid_env_port_falls_back_to_default() { - let addr = resolve_bind_addr(None, Some("abc"), None, Some(3000)); - assert_eq!(addr.port(), DEFAULT_PORT); + fn invalid_env_port_returns_error() { + let err = resolve_bind_addr(None, Some("abc"), None, Some(3000)).unwrap_err(); + assert!(err.contains("EDGEZERO_PORT")); + assert!(err.contains("not a valid port number")); } #[test] - fn invalid_config_host_falls_back_to_default() { - let addr = resolve_bind_addr(None, None, Some("not-an-ip"), None); - assert_eq!(addr.ip(), DEFAULT_HOST); + fn invalid_config_host_returns_error() { + let err = resolve_bind_addr(None, None, Some("not-an-ip"), None).unwrap_err(); + assert!(err.contains("not a valid IP address")); } #[test] - fn port_zero_from_env_falls_back_to_default() { - let addr = resolve_bind_addr(None, Some("0"), None, None); - assert_eq!(addr.port(), DEFAULT_PORT); + fn port_zero_from_env_returns_error() { + let err = resolve_bind_addr(None, Some("0"), None, None).unwrap_err(); + assert!(err.contains("port 0")); } #[test] - fn port_zero_from_config_falls_back_to_default() { - let addr = resolve_bind_addr(None, None, None, Some(0)); - assert_eq!(addr.port(), DEFAULT_PORT); + fn port_zero_from_config_returns_error() { + let err = resolve_bind_addr(None, None, None, Some(0)).unwrap_err(); + assert!(err.contains("port 0")); } #[test] fn ipv6_host_from_env() { - let addr = resolve_bind_addr(Some("::1"), None, None, None); + let addr = resolve_bind_addr(Some("::1"), None, None, None).unwrap(); assert_eq!(addr.ip(), "::1".parse::().unwrap()); } #[test] fn ipv6_host_from_config() { - let addr = resolve_bind_addr(None, None, Some("::"), Some(3000)); + let addr = resolve_bind_addr(None, None, Some("::"), Some(3000)).unwrap(); assert_eq!(addr.ip(), "::".parse::().unwrap()); assert_eq!(addr.port(), 3000); } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 11b20d1..938d7a9 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -352,6 +352,10 @@ pub struct ManifestAdapterDefinition { #[validate(length(min = 1))] pub manifest: Option, /// Bind address for the adapter server (e.g. `"0.0.0.0"` or `"127.0.0.1"`). + /// + /// Stored as a raw string for WASM compatibility — IP-address validation + /// happens at the adapter layer when the server binds + /// (see [`crate::addr::resolve_bind_addr`]). #[serde(default)] #[validate(length(min = 1))] pub host: Option, From 1f5ea6f9dccb71edb15aa2554f7b0736d45260c7 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 9 Apr 2026 15:42:37 -0500 Subject: [PATCH 4/6] fix: pass env vars through read_axum_project so EDGEZERO_HOST/PORT override axum.toml config --- crates/edgezero-adapter-axum/src/cli.rs | 45 +++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 361e96a..9baf4ac 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -270,8 +270,15 @@ fn read_axum_project(manifest: &Path) -> Result { None => None, }; - let addr = edgezero_core::addr::resolve_bind_addr(None, None, config_host, config_port) - .map_err(|e| format!("{e} (in {})", manifest.display()))?; + let env_host = std::env::var("EDGEZERO_HOST").ok(); + let env_port = std::env::var("EDGEZERO_PORT").ok(); + let addr = edgezero_core::addr::resolve_bind_addr( + env_host.as_deref(), + env_port.as_deref(), + config_host, + config_port, + ) + .map_err(|e| format!("{e} (in {})", manifest.display()))?; Ok(AxumProject { crate_dir, @@ -669,6 +676,40 @@ mod tests { assert_eq!(AXUM_ADAPTER.name(), "axum"); } + #[test] + fn read_axum_project_env_overrides_config() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"127.0.0.1\"\nport = 3000\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + // SAFETY: env vars are process-global; this test must not run in + // parallel with other tests that read these same vars. The + // `serial_test` crate is not in scope, so we accept the small race + // risk in CI (the test is deterministic in isolation). + unsafe { + std::env::set_var("EDGEZERO_HOST", "0.0.0.0"); + std::env::set_var("EDGEZERO_PORT", "9999"); + } + let result = read_axum_project(&root.join("axum.toml")); + unsafe { + std::env::remove_var("EDGEZERO_HOST"); + std::env::remove_var("EDGEZERO_PORT"); + } + + let project = result.expect("project"); + assert_eq!(project.addr.ip(), std::net::IpAddr::from([0, 0, 0, 0])); + assert_eq!(project.addr.port(), 9999); + } + #[test] fn blueprint_has_correct_id() { assert_eq!(AXUM_BLUEPRINT.id, "axum"); From a9c8f42884f9b3c166e7c12302a7655cea1f4550 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 10 Apr 2026 10:54:43 -0500 Subject: [PATCH 5/6] fix: eliminate unsafe env::set_var in read_axum_project tests Split read_axum_project into a thin wrapper + read_axum_project_with_env that accepts env var values as parameters. All tests now call the inner function directly, eliminating process-global env var mutation that caused cross-test contamination in parallel test runs. --- crates/edgezero-adapter-axum/src/cli.rs | 64 ++++++++++++------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 9baf4ac..28cc7ee 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -219,6 +219,16 @@ fn find_axum_manifest(start: &Path) -> Result { } fn read_axum_project(manifest: &Path) -> Result { + let env_host = std::env::var("EDGEZERO_HOST").ok(); + let env_port = std::env::var("EDGEZERO_PORT").ok(); + read_axum_project_with_env(manifest, env_host.as_deref(), env_port.as_deref()) +} + +fn read_axum_project_with_env( + manifest: &Path, + env_host: Option<&str>, + env_port: Option<&str>, +) -> Result { let contents = fs::read_to_string(manifest) .map_err(|err| format!("failed to read {}: {err}", manifest.display()))?; let value: Value = toml::from_str(&contents) @@ -270,11 +280,9 @@ fn read_axum_project(manifest: &Path) -> Result { None => None, }; - let env_host = std::env::var("EDGEZERO_HOST").ok(); - let env_port = std::env::var("EDGEZERO_PORT").ok(); let addr = edgezero_core::addr::resolve_bind_addr( - env_host.as_deref(), - env_port.as_deref(), + env_host, + env_port, config_host, config_port, ) @@ -309,7 +317,7 @@ mod tests { ) .unwrap(); - let project = read_axum_project(&root.join("axum.toml")).expect("project"); + let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.crate_name, "demo"); assert_eq!(project.crate_dir, root); assert_eq!(project.cargo_manifest, root.join("Cargo.toml")); @@ -349,7 +357,7 @@ mod tests { ) .unwrap(); - let project = read_axum_project(&root.join("axum.toml")).expect("project"); + let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.port(), 4001); } @@ -368,7 +376,7 @@ mod tests { ) .unwrap(); - let result = read_axum_project(&root.join("axum.toml")); + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); match result { Ok(_) => panic!("expected error"), Err(e) => assert!(e.contains("must be between 1 and 65535")), @@ -390,7 +398,7 @@ mod tests { ) .unwrap(); - let result = read_axum_project(&root.join("axum.toml")); + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); assert!(result.is_err()); } @@ -409,7 +417,7 @@ mod tests { ) .unwrap(); - let result = read_axum_project(&root.join("axum.toml")); + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); assert!(result.is_err()); } @@ -424,7 +432,7 @@ mod tests { ) .unwrap(); - let result = read_axum_project(&root.join("axum.toml")); + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); match result { Ok(_) => panic!("expected error"), Err(e) => assert!(e.contains("adapter table missing")), @@ -442,7 +450,7 @@ mod tests { ) .unwrap(); - let result = read_axum_project(&root.join("axum.toml")); + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); match result { Ok(_) => panic!("expected error"), Err(e) => assert!(e.contains("crate_dir missing")), @@ -462,7 +470,7 @@ mod tests { .unwrap(); // No Cargo.toml in subdir - let result = read_axum_project(&root.join("axum.toml")); + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); match result { Ok(_) => panic!("expected error"), Err(e) => assert!(e.contains("Cargo.toml missing")), @@ -481,7 +489,7 @@ mod tests { ) .unwrap(); - let project = read_axum_project(&root.join("axum.toml")).expect("project"); + let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.crate_name, "my-package"); } @@ -502,7 +510,7 @@ mod tests { ) .unwrap(); - let project = read_axum_project(&root.join("axum.toml")).expect("project"); + let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.crate_name, "my-adapter"); assert_eq!(project.crate_dir, adapter_dir); } @@ -522,7 +530,7 @@ mod tests { ) .unwrap(); - let project = read_axum_project(&root.join("axum.toml")).expect("project"); + let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.port(), 65535); } @@ -541,7 +549,7 @@ mod tests { ) .unwrap(); - let project = read_axum_project(&root.join("axum.toml")).expect("project"); + let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.port(), 1); } @@ -560,7 +568,7 @@ mod tests { ) .unwrap(); - let project = read_axum_project(&root.join("axum.toml")).expect("project"); + let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); } @@ -579,7 +587,7 @@ mod tests { ) .unwrap(); - let project = read_axum_project(&root.join("axum.toml")).expect("project"); + let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.ip(), std::net::IpAddr::from([0, 0, 0, 0])); } @@ -598,7 +606,7 @@ mod tests { ) .unwrap(); - let result = read_axum_project(&root.join("axum.toml")); + let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); assert!(result.is_err()); assert!(result.unwrap_err().contains("valid IP address")); } @@ -691,19 +699,11 @@ mod tests { ) .unwrap(); - // SAFETY: env vars are process-global; this test must not run in - // parallel with other tests that read these same vars. The - // `serial_test` crate is not in scope, so we accept the small race - // risk in CI (the test is deterministic in isolation). - unsafe { - std::env::set_var("EDGEZERO_HOST", "0.0.0.0"); - std::env::set_var("EDGEZERO_PORT", "9999"); - } - let result = read_axum_project(&root.join("axum.toml")); - unsafe { - std::env::remove_var("EDGEZERO_HOST"); - std::env::remove_var("EDGEZERO_PORT"); - } + let result = read_axum_project_with_env( + &root.join("axum.toml"), + Some("0.0.0.0"), + Some("9999"), + ); let project = result.expect("project"); assert_eq!(project.addr.ip(), std::net::IpAddr::from([0, 0, 0, 0])); From 04b16d34db8b3462546517370423adb0f7202294 Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 22 Apr 2026 17:04:04 -0500 Subject: [PATCH 6/6] fix axum bind address review feedback --- crates/edgezero-adapter-axum/src/cli.rs | 309 ++++++++++++++++-- .../edgezero-adapter-axum/src/dev_server.rs | 63 +++- .../src/templates/axum.toml.hbs | 1 + crates/edgezero-cli/src/dev_server.rs | 27 +- crates/edgezero-core/src/addr.rs | 184 +++++++---- crates/edgezero-core/src/manifest.rs | 6 +- docs/guide/configuration.md | 15 + examples/app-demo/edgezero.toml | 2 + 8 files changed, 478 insertions(+), 129 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 28cc7ee..79eb838 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -1,5 +1,5 @@ use std::fs; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -142,10 +142,15 @@ fn deploy(_extra_args: &[String]) -> Result<(), String> { #[derive(Debug)] struct AxumProject { + axum_manifest: PathBuf, crate_dir: PathBuf, cargo_manifest: PathBuf, crate_name: String, addr: SocketAddr, + env_host: Option, + env_port: Option, + axum_host: Option, + axum_port: Option, } fn locate_project() -> Result { @@ -155,10 +160,16 @@ fn locate_project() -> Result { } fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { + let resolution = resolve_subprocess_addr(project)?; + for warning in &resolution.warnings { + eprintln!("[edgezero] {warning}"); + } + + let addr = resolution.addr; let display = project.crate_dir.display(); println!( "[edgezero] Axum {subcommand} ({}) in {} ({})", - project.crate_name, display, project.addr + project.crate_name, display, addr ); let mut command = Command::new("cargo"); command.arg(subcommand); @@ -171,8 +182,8 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> ); command.args(extra_args); command.current_dir(&project.crate_dir); - command.env("EDGEZERO_HOST", project.addr.ip().to_string()); - command.env("EDGEZERO_PORT", project.addr.port().to_string()); + command.env("EDGEZERO_HOST", addr.ip().to_string()); + command.env("EDGEZERO_PORT", addr.port().to_string()); let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; @@ -183,6 +194,152 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> } } +fn resolve_subprocess_addr( + project: &AxumProject, +) -> Result { + let axum_only = resolve_subprocess_addr_from_parts( + project.env_host.as_deref(), + project.env_port.as_deref(), + None, + None, + project.axum_host.as_deref(), + project.axum_port, + ); + debug_assert_eq!(project.addr, axum_only.addr); + + let edgezero = load_edgezero_axum_config(&project.axum_manifest)?; + Ok(resolve_subprocess_addr_from_parts( + project.env_host.as_deref(), + project.env_port.as_deref(), + edgezero.as_ref().and_then(|cfg| cfg.host.as_deref()), + edgezero.as_ref().and_then(|cfg| cfg.port), + project.axum_host.as_deref(), + project.axum_port, + )) +} + +fn resolve_subprocess_addr_from_parts( + env_host: Option<&str>, + env_port: Option<&str>, + edgezero_host: Option<&str>, + edgezero_port: Option, + axum_host: Option<&str>, + axum_port: Option, +) -> edgezero_core::addr::BindAddrResolution { + let mut warnings = Vec::new(); + let host = resolve_subprocess_host(env_host, edgezero_host, axum_host, &mut warnings); + let port = resolve_subprocess_port(env_port, edgezero_port, axum_port, &mut warnings); + + edgezero_core::addr::BindAddrResolution { + addr: SocketAddr::from((host, port)), + warnings, + } +} + +fn resolve_subprocess_host( + env_host: Option<&str>, + edgezero_host: Option<&str>, + axum_host: Option<&str>, + warnings: &mut Vec, +) -> IpAddr { + if let Some(value) = env_host { + match value.parse() { + Ok(host) => return host, + Err(_) => warnings.push(format!( + "EDGEZERO_HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + )), + } + } + + if let Some(value) = edgezero_host { + match value.parse() { + Ok(host) => return host, + Err(_) => warnings.push(format!( + "configured host={value:?} in edgezero.toml is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + )), + } + } + + if let Some(value) = axum_host { + match value.parse() { + Ok(host) => return host, + Err(_) => warnings.push(format!( + "configured host={value:?} in axum.toml is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + )), + } + } + + edgezero_core::addr::DEFAULT_HOST +} + +fn resolve_subprocess_port( + env_port: Option<&str>, + edgezero_port: Option, + axum_port: Option, + warnings: &mut Vec, +) -> u16 { + if let Some(value) = env_port { + match value.parse::() { + Ok(0) => warnings.push( + "EDGEZERO_PORT=\"0\" is not supported (would bind to a random OS port); falling back" + .to_string(), + ), + Ok(port) => return port, + Err(_) => warnings.push(format!( + "EDGEZERO_PORT={value:?} is not a valid port number; falling back" + )), + } + } + + if let Some(0) = edgezero_port { + warnings.push( + "configured port=0 in edgezero.toml is not supported (would bind to a random OS port); falling back" + .to_string(), + ); + } else if let Some(port) = edgezero_port { + return port; + } + + if let Some(0) = axum_port { + warnings.push( + "configured port=0 in axum.toml is not supported (would bind to a random OS port); falling back" + .to_string(), + ); + } else if let Some(port) = axum_port { + return port; + } + + edgezero_core::addr::DEFAULT_PORT +} + +#[derive(Debug, Default)] +struct EdgezeroAxumConfig { + host: Option, + port: Option, +} + +fn load_edgezero_axum_config(axum_manifest: &Path) -> Result, String> { + let Some(start_dir) = axum_manifest.parent() else { + return Ok(None); + }; + + let Some(manifest_path) = find_manifest_upwards(start_dir, "edgezero.toml") else { + return Ok(None); + }; + + let manifest = edgezero_core::manifest::ManifestLoader::from_path(&manifest_path) + .map_err(|err| format!("failed to load {}: {err}", manifest_path.display()))?; + let adapter = match manifest.manifest().adapters.get("axum") { + Some(adapter) => adapter, + None => return Ok(None), + }; + + Ok(Some(EdgezeroAxumConfig { + host: adapter.adapter.host.clone(), + port: adapter.adapter.port, + })) +} + fn find_axum_manifest(start: &Path) -> Result { if let Some(found) = find_manifest_upwards(start, "axum.toml") { return Ok(found); @@ -269,30 +426,40 @@ fn read_axum_project_with_env( }) }); - let config_host = adapter.get("host").and_then(Value::as_str); + let config_host = adapter + .get("host") + .and_then(Value::as_str) + .map(str::to_string); let config_port = match adapter.get("port").and_then(Value::as_integer) { Some(value) => Some(u16::try_from(value).map_err(|_| { format!( - "adapter.port in {} must be between 1 and 65535", + "adapter.port in {} must be between 0 and 65535", manifest.display() ) })?), None => None, }; - let addr = edgezero_core::addr::resolve_bind_addr( + let resolution = edgezero_core::addr::resolve_bind_addr( env_host, env_port, - config_host, + config_host.as_deref(), config_port, - ) - .map_err(|e| format!("{e} (in {})", manifest.display()))?; + ); + for warning in &resolution.warnings { + eprintln!("[edgezero] {warning} (in {})", manifest.display()); + } Ok(AxumProject { + axum_manifest: manifest.to_path_buf(), crate_dir, cargo_manifest, crate_name, - addr, + addr: resolution.addr, + env_host: env_host.map(str::to_string), + env_port: env_port.map(str::to_string), + axum_host: config_host, + axum_port: config_port, }) } @@ -317,7 +484,8 @@ mod tests { ) .unwrap(); - let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.crate_name, "demo"); assert_eq!(project.crate_dir, root); assert_eq!(project.cargo_manifest, root.join("Cargo.toml")); @@ -357,7 +525,8 @@ mod tests { ) .unwrap(); - let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.port(), 4001); } @@ -379,12 +548,12 @@ mod tests { let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); match result { Ok(_) => panic!("expected error"), - Err(e) => assert!(e.contains("must be between 1 and 65535")), + Err(e) => assert!(e.contains("must be between 0 and 65535")), } } #[test] - fn read_axum_project_rejects_zero_port() { + fn read_axum_project_zero_port_falls_back_to_default() { let dir = tempdir().unwrap(); let root = dir.path(); fs::write( @@ -398,8 +567,9 @@ mod tests { ) .unwrap(); - let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); - assert!(result.is_err()); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.addr.port(), edgezero_core::addr::DEFAULT_PORT); } #[test] @@ -489,7 +659,8 @@ mod tests { ) .unwrap(); - let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.crate_name, "my-package"); } @@ -510,7 +681,8 @@ mod tests { ) .unwrap(); - let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.crate_name, "my-adapter"); assert_eq!(project.crate_dir, adapter_dir); } @@ -530,7 +702,8 @@ mod tests { ) .unwrap(); - let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.port(), 65535); } @@ -549,7 +722,8 @@ mod tests { ) .unwrap(); - let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.port(), 1); } @@ -568,7 +742,8 @@ mod tests { ) .unwrap(); - let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); } @@ -587,12 +762,13 @@ mod tests { ) .unwrap(); - let project = read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); assert_eq!(project.addr.ip(), std::net::IpAddr::from([0, 0, 0, 0])); } #[test] - fn read_axum_project_rejects_invalid_host() { + fn read_axum_project_invalid_host_falls_back_to_default() { let dir = tempdir().unwrap(); let root = dir.path(); fs::write( @@ -606,9 +782,9 @@ mod tests { ) .unwrap(); - let result = read_axum_project_with_env(&root.join("axum.toml"), None, None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("valid IP address")); + let project = + read_axum_project_with_env(&root.join("axum.toml"), None, None).expect("project"); + assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); } #[test] @@ -699,17 +875,86 @@ mod tests { ) .unwrap(); - let result = read_axum_project_with_env( - &root.join("axum.toml"), - Some("0.0.0.0"), - Some("9999"), - ); + let result = + read_axum_project_with_env(&root.join("axum.toml"), Some("0.0.0.0"), Some("9999")); let project = result.expect("project"); assert_eq!(project.addr.ip(), std::net::IpAddr::from([0, 0, 0, 0])); assert_eq!(project.addr.port(), 9999); } + #[test] + fn resolve_subprocess_addr_prefers_edgezero_manifest_over_axum_manifest() { + let resolution = resolve_subprocess_addr_from_parts( + None, + None, + Some("0.0.0.0"), + Some(4000), + Some("127.0.0.1"), + Some(3000), + ); + + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 4000))); + assert!(resolution.warnings.is_empty()); + } + + #[test] + fn resolve_subprocess_addr_falls_back_to_axum_manifest_when_edgezero_missing() { + let resolution = + resolve_subprocess_addr_from_parts(None, None, None, None, Some("0.0.0.0"), Some(3000)); + + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 3000))); + assert!(resolution.warnings.is_empty()); + } + + #[test] + fn resolve_subprocess_addr_env_overrides_both_manifests() { + let resolution = resolve_subprocess_addr_from_parts( + Some("::1"), + Some("9000"), + Some("0.0.0.0"), + Some(4000), + Some("127.0.0.1"), + Some(3000), + ); + + assert_eq!( + resolution.addr, + SocketAddr::from((std::net::Ipv6Addr::LOCALHOST, 9000)) + ); + assert!(resolution.warnings.is_empty()); + } + + #[test] + fn resolve_subprocess_addr_invalid_edgezero_host_falls_back_to_axum_host() { + let resolution = resolve_subprocess_addr_from_parts( + None, + None, + Some("invalid-host"), + Some(4000), + Some("0.0.0.0"), + Some(3000), + ); + + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 4000))); + assert_eq!(resolution.warnings.len(), 1); + } + + #[test] + fn resolve_subprocess_addr_edgezero_zero_port_falls_back_to_axum_port() { + let resolution = resolve_subprocess_addr_from_parts( + None, + None, + Some("127.0.0.1"), + Some(0), + Some("0.0.0.0"), + Some(3000), + ); + + assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 3000))); + assert_eq!(resolution.warnings.len(), 1); + } + #[test] fn blueprint_has_correct_id() { assert_eq!(AXUM_BLUEPRINT.id, "axum"); diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 3ff3135..0db3573 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -295,15 +295,15 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { SimpleLogger::new().with_level(level).init().ok(); - let addr = resolve_addr(manifest).map_err(|e| anyhow::anyhow!("{e}"))?; + let resolution = resolve_addr(m); + for warning in &resolution.warnings { + log::warn!("{warning}"); + } + let addr = resolution.addr; let app = A::build_app(); let router = app.router().clone(); - println!( - "[edgezero] starting axum server on http://{}:{}", - addr.ip(), - addr.port() - ); + println!("[edgezero] starting axum server on http://{}", addr); let runtime = RuntimeBuilder::new_multi_thread() .enable_all() @@ -379,7 +379,7 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { /// 3. Default: `127.0.0.1:8787` pub(crate) fn resolve_addr( manifest: &edgezero_core::manifest::Manifest, -) -> Result { +) -> edgezero_core::addr::BindAddrResolution { let env_host = std::env::var("EDGEZERO_HOST").ok(); let env_port = std::env::var("EDGEZERO_PORT").ok(); resolve_addr_from_parts(manifest, env_host.as_deref(), env_port.as_deref()) @@ -389,7 +389,7 @@ fn resolve_addr_from_parts( manifest: &edgezero_core::manifest::Manifest, env_host: Option<&str>, env_port: Option<&str>, -) -> Result { +) -> edgezero_core::addr::BindAddrResolution { let adapter = manifest.adapters.get("axum"); let config_host = adapter.and_then(|a| a.adapter.host.as_deref()); let config_port = adapter.and_then(|a| a.adapter.port); @@ -525,8 +525,9 @@ name = "EDGEZERO_KV" fn resolve_addr_defaults_without_manifest_config() { // Note: env var tests use resolve_addr_from_parts to avoid races. let loader = ManifestLoader::load_from_str(""); - let addr = resolve_addr_from_parts(loader.manifest(), None, None).unwrap(); - assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + let resolution = resolve_addr_from_parts(loader.manifest(), None, None); + assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + assert!(resolution.warnings.is_empty()); } #[test] @@ -537,8 +538,9 @@ host = "0.0.0.0" port = 3000 "#; let loader = ManifestLoader::load_from_str(manifest); - let addr = resolve_addr_from_parts(loader.manifest(), None, None).unwrap(); - assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 3000))); + let resolution = resolve_addr_from_parts(loader.manifest(), None, None); + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 3000))); + assert!(resolution.warnings.is_empty()); } #[test] @@ -549,9 +551,9 @@ host = "127.0.0.1" port = 3000 "#; let loader = ManifestLoader::load_from_str(manifest); - let addr = - resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")).unwrap(); - assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 4000))); + let resolution = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")); + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 4000))); + assert!(resolution.warnings.is_empty()); } #[test] @@ -561,8 +563,35 @@ port = 3000 port = 5000 "#; let loader = ManifestLoader::load_from_str(manifest); - let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None).unwrap(); - assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 5000))); + let resolution = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None); + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 5000))); + assert!(resolution.warnings.is_empty()); + } + + #[test] + fn resolve_addr_invalid_env_falls_back_to_manifest() { + let manifest = r#" +[adapters.axum.adapter] +host = "0.0.0.0" +port = 5000 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let resolution = resolve_addr_from_parts(loader.manifest(), Some("not-an-ip"), Some("abc")); + assert_eq!(resolution.addr, SocketAddr::from(([0, 0, 0, 0], 5000))); + assert_eq!(resolution.warnings.len(), 2); + } + + #[test] + fn resolve_addr_invalid_manifest_falls_back_to_default() { + let manifest = r#" +[adapters.axum.adapter] +host = "localhost" +port = 0 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let resolution = resolve_addr_from_parts(loader.manifest(), None, None); + assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + assert_eq!(resolution.warnings.len(), 2); } } diff --git a/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs b/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs index 6ab31c7..30fc6e1 100644 --- a/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs +++ b/crates/edgezero-adapter-axum/src/templates/axum.toml.hbs @@ -1,4 +1,5 @@ [adapter] crate = "{{proj_axum}}" crate_dir = "." +host = "127.0.0.1" port = 8787 diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/dev_server.rs index 4c63135..dcd2e4f 100644 --- a/crates/edgezero-cli/src/dev_server.rs +++ b/crates/edgezero-cli/src/dev_server.rs @@ -25,18 +25,8 @@ pub fn run_dev() { Err(err) => eprintln!("[edgezero] dev manifest error: {err}"), } - let addr = match resolve_dev_addr() { - Ok(addr) => addr, - Err(err) => { - eprintln!("[edgezero] {err}"); - return; - } - }; - println!( - "[edgezero] dev: starting local server on http://{}:{}", - addr.ip(), - addr.port() - ); + let addr = resolve_dev_addr(); + println!("[edgezero] dev: starting local server on http://{}", addr); let router = build_dev_router(); let config = AxumDevServerConfig { @@ -91,10 +81,19 @@ async fn dev_echo(Path(params): Path) -> Text { /// Resolve the dev server bind address from `EDGEZERO_HOST` / `EDGEZERO_PORT` /// environment variables, falling back to `127.0.0.1:8787`. -fn resolve_dev_addr() -> Result { +fn resolve_dev_addr() -> SocketAddr { let env_host = std::env::var("EDGEZERO_HOST").ok(); let env_port = std::env::var("EDGEZERO_PORT").ok(); - edgezero_core::addr::resolve_bind_addr(env_host.as_deref(), env_port.as_deref(), None, None) + let resolution = edgezero_core::addr::resolve_bind_addr( + env_host.as_deref(), + env_port.as_deref(), + None, + None, + ); + for warning in &resolution.warnings { + eprintln!("[edgezero] {warning}"); + } + resolution.addr } fn try_run_manifest_axum() -> Result { diff --git a/crates/edgezero-core/src/addr.rs b/crates/edgezero-core/src/addr.rs index b158ccc..60bb99c 100644 --- a/crates/edgezero-core/src/addr.rs +++ b/crates/edgezero-core/src/addr.rs @@ -10,6 +10,13 @@ pub const DEFAULT_HOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1 /// Default bind port (`8787`). pub const DEFAULT_PORT: u16 = 8787; +/// A resolved bind address plus any warnings emitted while falling back. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindAddrResolution { + pub addr: SocketAddr, + pub warnings: Vec, +} + /// Resolve a bind address from optional environment and config values. /// /// Precedence (highest wins): @@ -17,46 +24,77 @@ pub const DEFAULT_PORT: u16 = 8787; /// 2. `config_host` / `config_port` (from manifest or adapter config) /// 3. Defaults: `127.0.0.1:8787` /// -/// Returns an error if any provided value is invalid (unparseable host, -/// unparseable port, or port 0). Missing values fall through to the default. +/// Invalid values produce warnings and fall back to the next precedence level. pub fn resolve_bind_addr( env_host: Option<&str>, env_port: Option<&str>, config_host: Option<&str>, config_port: Option, -) -> Result { - let host = resolve_host(env_host, config_host)?; - let port = resolve_port(env_port, config_port)?; - Ok(SocketAddr::from((host, port))) +) -> BindAddrResolution { + let mut warnings = Vec::new(); + let host = resolve_host(env_host, config_host, &mut warnings); + let port = resolve_port(env_port, config_port, &mut warnings); + + BindAddrResolution { + addr: SocketAddr::from((host, port)), + warnings, + } } -fn resolve_host(env_host: Option<&str>, config_host: Option<&str>) -> Result { - if let Some(v) = env_host { - return v - .parse() - .map_err(|_| format!("EDGEZERO_HOST={v:?} is not a valid IP address")); +fn resolve_host( + env_host: Option<&str>, + config_host: Option<&str>, + warnings: &mut Vec, +) -> IpAddr { + if let Some(value) = env_host { + match value.parse() { + Ok(host) => return host, + Err(_) => warnings.push(format!( + "EDGEZERO_HOST={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + )), + } } - if let Some(h) = config_host { - return h - .parse() - .map_err(|_| format!("configured host={h:?} is not a valid IP address")); + + if let Some(value) = config_host { + match value.parse() { + Ok(host) => return host, + Err(_) => warnings.push(format!( + "configured host={value:?} is not a valid IP address (hostnames like \"localhost\" are not supported); falling back" + )), + } } - Ok(DEFAULT_HOST) + + DEFAULT_HOST } -fn resolve_port(env_port: Option<&str>, config_port: Option) -> Result { - let port = if let Some(v) = env_port { - v.parse() - .map_err(|_| format!("EDGEZERO_PORT={v:?} is not a valid port number"))? - } else { - config_port.unwrap_or(DEFAULT_PORT) - }; +fn resolve_port( + env_port: Option<&str>, + config_port: Option, + warnings: &mut Vec, +) -> u16 { + if let Some(value) = env_port { + match value.parse::() { + Ok(0) => warnings.push( + "EDGEZERO_PORT=\"0\" is not supported (would bind to a random OS port); falling back" + .to_string(), + ), + Ok(port) => return port, + Err(_) => warnings.push(format!( + "EDGEZERO_PORT={value:?} is not a valid port number; falling back" + )), + } + } - if port == 0 { - return Err("port 0 is not supported (would bind to a random OS port)".to_string()); + if let Some(0) = config_port { + warnings.push( + "configured port=0 is not supported (would bind to a random OS port); falling back" + .to_string(), + ); + } else if let Some(port) = config_port { + return port; } - Ok(port) + DEFAULT_PORT } #[cfg(test)] @@ -66,81 +104,101 @@ mod tests { #[test] fn defaults_when_nothing_provided() { - let addr = resolve_bind_addr(None, None, None, None).unwrap(); - assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + let resolution = resolve_bind_addr(None, None, None, None); + assert_eq!(resolution.addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + assert!(resolution.warnings.is_empty()); } #[test] fn config_overrides_defaults() { - let addr = resolve_bind_addr(None, None, Some("0.0.0.0"), Some(3000)).unwrap(); - assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); - assert_eq!(addr.port(), 3000); + let resolution = resolve_bind_addr(None, None, Some("0.0.0.0"), Some(3000)); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.addr.port(), 3000); + assert!(resolution.warnings.is_empty()); } #[test] fn env_overrides_config() { - let addr = resolve_bind_addr(Some("0.0.0.0"), Some("4000"), Some("127.0.0.1"), Some(3000)) - .unwrap(); - assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); - assert_eq!(addr.port(), 4000); + let resolution = + resolve_bind_addr(Some("0.0.0.0"), Some("4000"), Some("127.0.0.1"), Some(3000)); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.addr.port(), 4000); + assert!(resolution.warnings.is_empty()); } #[test] fn partial_env_override_host_only() { - let addr = resolve_bind_addr(Some("0.0.0.0"), None, None, Some(5000)).unwrap(); - assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); - assert_eq!(addr.port(), 5000); + let resolution = resolve_bind_addr(Some("0.0.0.0"), None, None, Some(5000)); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.addr.port(), 5000); } #[test] fn partial_env_override_port_only() { - let addr = resolve_bind_addr(None, Some("9000"), Some("0.0.0.0"), None).unwrap(); - assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); - assert_eq!(addr.port(), 9000); + let resolution = resolve_bind_addr(None, Some("9000"), Some("0.0.0.0"), None); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.addr.port(), 9000); + } + + #[test] + fn invalid_env_host_falls_back_to_config() { + let resolution = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None); + assert_eq!(resolution.addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(resolution.warnings.len(), 1); + assert!(resolution.warnings[0].contains("EDGEZERO_HOST")); + assert!(resolution.warnings[0].contains("not a valid IP address")); } #[test] - fn invalid_env_host_returns_error() { - let err = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None).unwrap_err(); - assert!(err.contains("EDGEZERO_HOST")); - assert!(err.contains("not a valid IP address")); + fn invalid_env_port_falls_back_to_config() { + let resolution = resolve_bind_addr(None, Some("abc"), None, Some(3000)); + assert_eq!(resolution.addr.port(), 3000); + assert_eq!(resolution.warnings.len(), 1); + assert!(resolution.warnings[0].contains("EDGEZERO_PORT")); + assert!(resolution.warnings[0].contains("not a valid port number")); } #[test] - fn invalid_env_port_returns_error() { - let err = resolve_bind_addr(None, Some("abc"), None, Some(3000)).unwrap_err(); - assert!(err.contains("EDGEZERO_PORT")); - assert!(err.contains("not a valid port number")); + fn invalid_config_host_falls_back_to_default() { + let resolution = resolve_bind_addr(None, None, Some("not-an-ip"), None); + assert_eq!(resolution.addr.ip(), DEFAULT_HOST); + assert_eq!(resolution.warnings.len(), 1); + assert!(resolution.warnings[0].contains("configured host")); } #[test] - fn invalid_config_host_returns_error() { - let err = resolve_bind_addr(None, None, Some("not-an-ip"), None).unwrap_err(); - assert!(err.contains("not a valid IP address")); + fn port_zero_from_env_falls_back_to_config() { + let resolution = resolve_bind_addr(None, Some("0"), None, Some(3000)); + assert_eq!(resolution.addr.port(), 3000); + assert_eq!(resolution.warnings.len(), 1); + assert!(resolution.warnings[0].contains("port); falling back")); } #[test] - fn port_zero_from_env_returns_error() { - let err = resolve_bind_addr(None, Some("0"), None, None).unwrap_err(); - assert!(err.contains("port 0")); + fn port_zero_from_config_falls_back_to_default() { + let resolution = resolve_bind_addr(None, None, None, Some(0)); + assert_eq!(resolution.addr.port(), DEFAULT_PORT); + assert_eq!(resolution.warnings.len(), 1); + assert!(resolution.warnings[0].contains("configured port=0")); } #[test] - fn port_zero_from_config_returns_error() { - let err = resolve_bind_addr(None, None, None, Some(0)).unwrap_err(); - assert!(err.contains("port 0")); + fn invalid_env_and_config_port_fall_back_to_default() { + let resolution = resolve_bind_addr(None, Some("abc"), None, Some(0)); + assert_eq!(resolution.addr.port(), DEFAULT_PORT); + assert_eq!(resolution.warnings.len(), 2); } #[test] fn ipv6_host_from_env() { - let addr = resolve_bind_addr(Some("::1"), None, None, None).unwrap(); - assert_eq!(addr.ip(), "::1".parse::().unwrap()); + let resolution = resolve_bind_addr(Some("::1"), None, None, None); + assert_eq!(resolution.addr.ip(), "::1".parse::().unwrap()); } #[test] fn ipv6_host_from_config() { - let addr = resolve_bind_addr(None, None, Some("::"), Some(3000)).unwrap(); - assert_eq!(addr.ip(), "::".parse::().unwrap()); - assert_eq!(addr.port(), 3000); + let resolution = resolve_bind_addr(None, None, Some("::"), Some(3000)); + assert_eq!(resolution.addr.ip(), "::".parse::().unwrap()); + assert_eq!(resolution.addr.port(), 3000); } } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 938d7a9..e004253 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -353,9 +353,9 @@ pub struct ManifestAdapterDefinition { pub manifest: Option, /// Bind address for the adapter server (e.g. `"0.0.0.0"` or `"127.0.0.1"`). /// - /// Stored as a raw string for WASM compatibility — IP-address validation - /// happens at the adapter layer when the server binds - /// (see [`crate::addr::resolve_bind_addr`]). + /// Stored as a raw string so validation can be deferred until bind-address + /// resolution, where environment-variable overrides and fallback behavior + /// are applied consistently (see [`crate::addr::resolve_bind_addr`]). #[serde(default)] #[validate(length(min = 1))] pub host: Option, diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 6dc17c0..a05cb5b 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -356,12 +356,27 @@ level = "info" [adapters.axum.adapter] crate = "crates/my-app-adapter-axum" manifest = "crates/my-app-adapter-axum/axum.toml" +host = "127.0.0.1" +port = 8787 [adapters.axum.commands] build = "cargo build --release -p my-app-adapter-axum" serve = "cargo run -p my-app-adapter-axum" ``` +Axum bind-address precedence is: + +1. `EDGEZERO_HOST` / `EDGEZERO_PORT` +2. `edgezero.toml` `[adapters.axum.adapter]` `host` / `port` +3. `axum.toml` `[adapter]` `host` / `port` when launching through the Axum adapter CLI wrapper +4. default `127.0.0.1:8787` + +Example override: + +```sh +EDGEZERO_HOST=0.0.0.0 EDGEZERO_PORT=3000 cargo run -p my-app-adapter-axum +``` + ## Using the Manifest ### app! Macro diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index 497df6d..80a4a13 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -144,6 +144,8 @@ name = "app_config" [adapters.axum.adapter] crate = "crates/app-demo-adapter-axum" manifest = "crates/app-demo-adapter-axum/axum.toml" +host = "127.0.0.1" +port = 8787 [adapters.axum.build] target = "native"