diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index c070526..361e96a 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::SocketAddr; use std::path::{Path, PathBuf}; use std::process::Command; @@ -139,11 +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, - port: u16, + addr: SocketAddr, } fn locate_project() -> Result { @@ -155,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 {} (port: {})", - project.crate_name, display, project.port + "[edgezero] Axum {subcommand} ({}) in {} ({})", + project.crate_name, display, project.addr ); let mut command = Command::new("cargo"); command.arg(subcommand); @@ -169,6 +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.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}"))?; @@ -255,24 +259,25 @@ fn read_axum_project(manifest: &Path) -> Result { }) }); - 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 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.port in {} must be between 1 and 65535", + manifest.display() + ) + })?), + None => None, }; + 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, - port, + addr, }) } @@ -301,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] @@ -337,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] @@ -510,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] @@ -529,7 +535,65 @@ 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] + 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.addr.ip(), edgezero_core::addr::DEFAULT_HOST); + } + + #[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.addr.ip(), std::net::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] diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index a984cdb..5daecbc 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -31,7 +31,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, } } @@ -253,18 +256,24 @@ 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 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")?; @@ -294,10 +303,35 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { } } }; - serve_with_listener_and_kv_handle(router, listener, config.enable_ctrl_c, kv_handle).await + serve_with_listener_and_kv_handle(router, listener, true, kv_handle).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, +) -> 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()) +} + +fn resolve_addr_from_parts( + manifest: &edgezero_core::manifest::Manifest, + env_host: Option<&str>, + env_port: Option<&str>, +) -> 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); + edgezero_core::addr::resolve_bind_addr(env_host, env_port, config_host, config_port) +} + #[cfg(test)] mod tests { use super::*; @@ -422,6 +456,50 @@ 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).unwrap(); + 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).unwrap(); + 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")).unwrap(); + 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).unwrap(); + assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 5000))); + } } #[cfg(test)] diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/dev_server.rs index 7cb6e05..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 = SocketAddr::from(([127, 0, 0, 1], 8787)); + 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(), @@ -83,6 +89,14 @@ 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() -> 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) +} + fn try_run_manifest_axum() -> Result { let manifest = match load_manifest_optional()? { Some(manifest) => manifest, diff --git a/crates/edgezero-core/src/addr.rs b/crates/edgezero-core/src/addr.rs new file mode 100644 index 0000000..b158ccc --- /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}; + +/// 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. +/// +/// 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` +/// +/// 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, +) -> 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>) -> Result { + if let Some(v) = env_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() + .map_err(|_| format!("configured host={h:?} is not a valid IP address")); + } + Ok(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) + }; + + if port == 0 { + return Err("port 0 is not supported (would bind to a random OS port)".to_string()); + } + + Ok(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).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)).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)) + .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)).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).unwrap(); + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(addr.port(), 9000); + } + + #[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")); + } + + #[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")); + } + + #[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")); + } + + #[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")); + } + + #[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")); + } + + #[test] + fn ipv6_host_from_env() { + 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)).unwrap(); + 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 bc6fe81..0cb30cb 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; diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 0efb690..de7c497 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -306,6 +306,17 @@ 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"`). + /// + /// 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, + /// Port for the adapter server. + #[serde(default)] + pub port: Option, } #[derive(Debug, Default, Deserialize, Validate)] @@ -1239,4 +1250,34 @@ name = "FASTLY_STORE" assert_eq!(manifest.kv_store_name("fastly"), "FASTLY_STORE"); assert_eq!(manifest.kv_store_name("FASTLY"), "FASTLY_STORE"); } + + // -- 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()); + } }