Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 37 additions & 2 deletions crates/cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand All @@ -226,6 +237,7 @@ impl Session {
pid,
ready,
log: path,
port,
})
}

Expand All @@ -245,6 +257,29 @@ impl Session {
}
}

fn lease(target: &Target) -> Result<Option<u16>, 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<String, Value>) -> Option<String> {
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() => {
Expand Down
50 changes: 28 additions & 22 deletions crates/cli/src/commands/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
pub(super) health: Option<String>,
}

pub(super) struct Listing<'a> {
pub(super) namespace: &'a str,
pub(super) hits: &'a [Stamped],
Expand All @@ -13,7 +19,7 @@ pub(super) struct Listing<'a> {

pub(super) fn status(
namespace: &str,
rows: &[(String, Vec<u32>)],
rows: &[Row],
broker: &Status,
format: Format,
) -> Result<(), String> {
Expand Down Expand Up @@ -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<u32>)],
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);
Expand Down Expand Up @@ -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<u32>)],
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::<Vec<_>>(),
});
println!(
Expand Down
2 changes: 2 additions & 0 deletions crates/cli/src/commands/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub(crate) struct Launch {
pub(crate) pid: u32,
pub(crate) ready: Option<Ready>,
pub(crate) log: std::path::PathBuf,
pub(crate) port: Option<u16>,
}

pub(crate) struct Broker<'a> {
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions crates/core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ pub struct App {
pub inherits: Vec<Inherit>,
#[serde(default, rename = "inspect_socket")]
pub socket: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default, rename = "health_url")]
pub health: Option<String>,
#[serde(default)]
Expand All @@ -66,6 +68,8 @@ pub struct Sidecar {
pub inherits: Vec<Inherit>,
#[serde(default, rename = "inspect_socket")]
pub socket: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default, rename = "health_url")]
pub health: Option<String>,
#[serde(default)]
Expand Down
7 changes: 7 additions & 0 deletions crates/core/src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub struct App {
pub env: BTreeMap<String, String>,
pub inherits: Vec<Inherit>,
pub socket: Option<String>,
pub port: Option<u16>,
pub health: Option<String>,
pub ready: Option<Ready>,
}
Expand All @@ -40,6 +41,7 @@ pub struct Sidecar {
pub env: BTreeMap<String, String>,
pub inherits: Vec<Inherit>,
pub socket: Option<String>,
pub port: Option<u16>,
pub health: Option<String>,
pub ready: Option<Ready>,
}
Expand All @@ -55,6 +57,7 @@ pub struct Target {
pub env: BTreeMap<String, String>,
pub inherits: Vec<Inherit>,
pub socket: Option<String>,
pub port: Option<u16>,
pub health: Option<String>,
pub ready: Option<Ready>,
}
Expand Down Expand Up @@ -120,6 +123,7 @@ impl Manifest {
env: plan.env,
inherits: plan.inherits,
socket: plan.socket,
port: plan.port,
health: plan.health,
ready: plan.ready,
}
Expand All @@ -136,6 +140,7 @@ impl Manifest {
env: plan.env,
inherits: plan.inherits,
socket: plan.socket,
port: plan.port,
health: plan.health,
ready: plan.ready,
});
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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),
}
Expand Down
43 changes: 43 additions & 0 deletions crates/core/tests/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading