diff --git a/README.md b/README.md index c35a94b..fef672e 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,19 @@ Top-level shape: - `[project]`: `name`, `namespace`, optional `root`, optional `data_dir` - `[[sidecars]]`: background service targets, launched in declaration order - `[app]`: optional foreground app target, launched after sidecars -- per target: `name`, `command`, `args`, `cwd`, `mode`, `env`, `inspect_socket`, `inherits_env`, `ready` +- per target: `name`, `command`, `args`, `cwd`, `mode`, `env`, `inspect_socket`, `inherits_env`, `port`, `health_url`, `ready` `inspect_socket` supports `{project}`, `{namespace}`, and `{name}` templates. +`port` puts the target's listen port under sidecar's control: `port = 0` leases +a free loopback port at every start; any other value pins it. The resolved port +is injected into the target's env as `SIDECAR_PORT`, recorded in target state, +and substituted into the `{port}` template of `health_url`. The target stays +business-unaware: it reads one env var and binds. `status` prints the resolved +`health_url` next to each running target (text) and as `healthUrl` (JSON), so +consumers discover the live address from `sidecar status --format json` instead +of hardcoding it. + ## Broker Runtime `sidecar start` ensures one local broker for the resolved project and namespace diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index 09af255..0071a3f 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -3,15 +3,17 @@ mod runtime; use crate::cli::Format; use runtime::{Broker, Chain, Launch}; -use serde_json::Value; +use serde_json::{Map, Value}; use sidecar_core::plan::{Plan, Target}; use sidecar_core::{inspect, process, socket, Paths, State}; use std::fs::{self, OpenOptions}; +use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::Duration; const SOCKET: &str = "SIDECAR_INSPECT_SOCKET"; +const PORT: &str = "SIDECAR_PORT"; pub(crate) struct Session { pub(crate) state: State, @@ -89,10 +91,15 @@ impl Session { pub(crate) fn status(&self, format: Format) -> Result<(), String> { let plan = self.state.plan(); + let state = runtime::state::load(&self.paths)?; let mut rows = Vec::new(); for target in &plan.targets { let pids = runtime::running(&self.paths, target)?; - rows.push((target.name.clone(), pids)); + rows.push(render::Row { + name: target.name.clone(), + pids, + health: health(target, &state), + }); } let broker = Broker::new(&plan).status()?; render::status(&plan.namespace, &rows, &broker, format) @@ -208,6 +215,10 @@ impl Session { if let Some(socket) = &target.socket { command.env(SOCKET, socket); } + let port = lease(target)?; + if let Some(port) = port { + command.env(PORT, port.to_string()); + } runtime::detach(&mut command); let mut child = command .spawn() @@ -226,6 +237,7 @@ impl Session { pid, ready, log: path, + port, }) } @@ -245,6 +257,29 @@ impl Session { } } +fn lease(target: &Target) -> Result, String> { + match target.port { + Some(0) => { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .map_err(|err| format!("failed to lease a port for `{}`: {err}", target.name))?; + let local = listener + .local_addr() + .map_err(|err| format!("failed to read the leased port: {err}"))?; + Ok(Some(local.port())) + } + other => Ok(other), + } +} + +fn health(target: &Target, state: &Map) -> Option { + let template = target.health.as_ref()?; + if !template.contains("{port}") { + return Some(template.clone()); + } + let port = state.get(&target.name)?.get("port")?.as_u64()?; + Some(template.replace("{port}", &port.to_string())) +} + fn purge(path: &Path, label: &str) -> Result<(), String> { match fs::metadata(path) { Ok(meta) if meta.is_dir() => { diff --git a/crates/cli/src/commands/render.rs b/crates/cli/src/commands/render.rs index 55202ef..d3af819 100644 --- a/crates/cli/src/commands/render.rs +++ b/crates/cli/src/commands/render.rs @@ -4,6 +4,12 @@ use serde_json::{Map, Value}; use sidecar_core::inspect; use sidecar_core::process::Stamped; +pub(super) struct Row { + pub(super) name: String, + pub(super) pids: Vec, + pub(super) health: Option, +} + pub(super) struct Listing<'a> { pub(super) namespace: &'a str, pub(super) hits: &'a [Stamped], @@ -13,7 +19,7 @@ pub(super) struct Listing<'a> { pub(super) fn status( namespace: &str, - rows: &[(String, Vec)], + rows: &[Row], broker: &Status, format: Format, ) -> Result<(), String> { @@ -43,30 +49,33 @@ pub(super) fn inspect( } mod text { - use super::{Listing, Status}; + use super::{Listing, Row, Status}; use serde_json::Value; use sidecar_core::inspect; - pub(super) fn status( - namespace: &str, - rows: &[(String, Vec)], - broker: &Status, - ) -> Result<(), String> { + pub(super) fn status(namespace: &str, rows: &[Row], broker: &Status) -> Result<(), String> { println!("namespace: {namespace}"); runtime(broker); - for (name, pids) in rows { - if let Some(first) = pids.first() { - println!("- {name}: running (pid {})", first); - for extra in pids.iter().skip(1) { + for row in rows { + if let Some(first) = row.pids.first() { + println!("{}", line(row, *first)); + for extra in row.pids.iter().skip(1) { println!(" + duplicate (pid {})", extra); } } else { - println!("- {name}: stopped"); + println!("- {}: stopped", row.name); } } Ok(()) } + fn line(row: &Row, pid: u32) -> String { + match &row.health { + Some(health) => format!("- {}: running (pid {pid}) {health}", row.name), + None => format!("- {}: running (pid {pid})", row.name), + } + } + pub(super) fn list(listing: &Listing) -> Result<(), String> { println!("namespace: {}", listing.namespace); runtime(listing.broker); @@ -112,22 +121,19 @@ mod text { } mod json { - use super::{Listing, Status}; + use super::{Listing, Row, Status}; use serde_json::Value; use sidecar_core::inspect; - pub(super) fn status( - namespace: &str, - rows: &[(String, Vec)], - broker: &Status, - ) -> Result<(), String> { + pub(super) fn status(namespace: &str, rows: &[Row], broker: &Status) -> Result<(), String> { let value = serde_json::json!({ "namespace": namespace, "runtime": runtime(broker), - "targets": rows.iter().map(|(name, pids)| serde_json::json!({ - "name": name, - "running": !pids.is_empty(), - "pids": pids, + "targets": rows.iter().map(|row| serde_json::json!({ + "name": row.name, + "running": !row.pids.is_empty(), + "pids": row.pids, + "healthUrl": row.health, })).collect::>(), }); println!( diff --git a/crates/cli/src/commands/runtime.rs b/crates/cli/src/commands/runtime.rs index 094d6a5..ca10ff5 100644 --- a/crates/cli/src/commands/runtime.rs +++ b/crates/cli/src/commands/runtime.rs @@ -25,6 +25,7 @@ pub(crate) struct Launch { pub(crate) pid: u32, pub(crate) ready: Option, pub(crate) log: std::path::PathBuf, + pub(crate) port: Option, } pub(crate) struct Broker<'a> { @@ -306,6 +307,7 @@ pub(crate) mod state { "mode": target.stamp.mode, "source": target.stamp.source, "inspectSocket": target.socket, + "port": launch.port, "logPath": launch.log.display().to_string(), "ready": launch.ready.as_ref().map(|ready| serde_json::json!({ "role": ready.role, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 79e85a6..761e12a 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -43,6 +43,8 @@ pub struct App { pub inherits: Vec, #[serde(default, rename = "inspect_socket")] pub socket: Option, + #[serde(default)] + pub port: Option, #[serde(default, rename = "health_url")] pub health: Option, #[serde(default)] @@ -66,6 +68,8 @@ pub struct Sidecar { pub inherits: Vec, #[serde(default, rename = "inspect_socket")] pub socket: Option, + #[serde(default)] + pub port: Option, #[serde(default, rename = "health_url")] pub health: Option, #[serde(default)] diff --git a/crates/core/src/plan.rs b/crates/core/src/plan.rs index 7922660..cb493b3 100644 --- a/crates/core/src/plan.rs +++ b/crates/core/src/plan.rs @@ -26,6 +26,7 @@ pub struct App { pub env: BTreeMap, pub inherits: Vec, pub socket: Option, + pub port: Option, pub health: Option, pub ready: Option, } @@ -40,6 +41,7 @@ pub struct Sidecar { pub env: BTreeMap, pub inherits: Vec, pub socket: Option, + pub port: Option, pub health: Option, pub ready: Option, } @@ -55,6 +57,7 @@ pub struct Target { pub env: BTreeMap, pub inherits: Vec, pub socket: Option, + pub port: Option, pub health: Option, pub ready: Option, } @@ -120,6 +123,7 @@ impl Manifest { env: plan.env, inherits: plan.inherits, socket: plan.socket, + port: plan.port, health: plan.health, ready: plan.ready, } @@ -136,6 +140,7 @@ impl Manifest { env: plan.env, inherits: plan.inherits, socket: plan.socket, + port: plan.port, health: plan.health, ready: plan.ready, }); @@ -166,6 +171,7 @@ impl config::App { .socket .as_ref() .map(|value| expand(value, project, &self.name)), + port: self.port, health: self.health.clone(), ready: self.ready.as_ref().map(config::Ready::plan), } @@ -194,6 +200,7 @@ impl config::Sidecar { .socket .as_ref() .map(|value| expand(value, project, &self.name)), + port: self.port, health: self.health.clone(), ready: self.ready.as_ref().map(config::Ready::plan), } diff --git a/crates/core/tests/state.rs b/crates/core/tests/state.rs index 6a0233a..756f08f 100644 --- a/crates/core/tests/state.rs +++ b/crates/core/tests/state.rs @@ -187,6 +187,49 @@ fn endpoint() { assert!(stamp.contains(";e=tcp%3A%2F%2F127.0.0.1%3A4100")); } +#[test] +fn leased() { + let state = seed( + r#" + [project] + name = "site" + + [app] + name = "web" + command = "pnpm" + port = 0 + health_url = "http://127.0.0.1:{port}" + "#, + ); + + let plan = state.plan(); + let app = plan.app.expect("app should plan"); + assert_eq!(app.port, Some(0)); + assert_eq!(app.health.as_deref(), Some("http://127.0.0.1:{port}")); + let target = plan.targets.first().expect("target should exist"); + assert_eq!(target.port, Some(0)); +} + +#[test] +fn pinned() { + let state = seed( + r#" + [project] + name = "site" + + [[sidecars]] + name = "api" + command = "cargo" + port = 3901 + "#, + ); + + let plan = state.plan(); + let target = plan.targets.first().expect("target should exist"); + assert_eq!(target.port, Some(3901)); + assert_eq!(target.health, None); +} + fn seed(text: &str) -> State { State { path: PathBuf::from("inline.toml"),