diff --git a/CHANGELOG.md b/CHANGELOG.md index 178f17e0..7206ac44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,15 @@ and listed with their assets on the shows its start and its worktrees. - **`ServiceView.singleton`** (ADR-0080, ADR-0081): whether a service runs once per machine, in the main working tree only. +- **The ports of a machine** (ADR-0082): `GET /v1/ports` lists every port + a stack holds or uses on this machine, per lane: port, protocol, lane, + what it is (a service's fixed port, an instance's, a container's + published or held port, a lane on a node's forward), what holds it, how + (proxy, forward, the process itself, Docker), where it leads and whether + it listens. The main tree's daemon includes its lanes'. The hub's `GET + /hub/ports` joins every registered stack's (a stopped stack's from what + its stack file declares) and marks the ports two stacks share; + `devstack doctor` lists them. ## [0.32.0] - 2026-10-30 diff --git a/crates/devstack-compose/src/lib.rs b/crates/devstack-compose/src/lib.rs index 4cc4aedc..331d4d31 100644 --- a/crates/devstack-compose/src/lib.rs +++ b/crates/devstack-compose/src/lib.rs @@ -270,6 +270,9 @@ pub struct Publisher { pub target_port: u16, #[serde(default, deserialize_with = "nullable")] pub published_port: u16, + /// `tcp` or `udp`; empty when Compose does not say. + #[serde(default, deserialize_with = "nullable")] + pub protocol: String, } // What a published port does when this machine connects to it. diff --git a/crates/devstack-daemon/src/api.rs b/crates/devstack-daemon/src/api.rs index b6e83cbd..1e3bcca4 100644 --- a/crates/devstack-daemon/src/api.rs +++ b/crates/devstack-daemon/src/api.rs @@ -73,6 +73,7 @@ pub fn router(state: AppState) -> axum::Router { fn routes(state: AppState) -> axum::Router { axum::Router::new() .route("/v1/stack", get(stack)) + .route("/v1/ports", get(ports)) .route("/v1/journal", get(journal)) .route("/v1/telemetry/take", post(telemetry_take)) .route("/v1/warm", get(warm_view)) @@ -297,6 +298,21 @@ async fn stack(State(s): State) -> Response { Json(s.sup.view()).into_response() } +/// `GET /v1/ports`: every port this tree holds or uses on this machine +/// (ADR-0082), in the main tree its lanes' too: those here from their +/// supervisors, those on a node by their forwards here. +async fn ports(State(s): State) -> Response { + let entries = tokio::task::spawn_blocking(move || match &s.lanes { + Some(l) => l.port_entries(), + None => crate::ports::tree_entries(&s.sup.port_facts()), + }) + .await; + match entries { + Ok(ports) => Json(serde_json::json!({ "ports": ports })).into_response(), + Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + } +} + /// `GET /v1/warm`: in a warm lease's container on a node, whether its /// `prepare` runs are done and what each did (ADR-0065 point 5). async fn warm_view() -> Response { diff --git a/crates/devstack-daemon/src/lanes.rs b/crates/devstack-daemon/src/lanes.rs index 02d9233d..264bdceb 100644 --- a/crates/devstack-daemon/src/lanes.rs +++ b/crates/devstack-daemon/src/lanes.rs @@ -1488,6 +1488,47 @@ impl Lanes { }) } + /// Every port the stack holds on this machine (ADR-0082): the main + /// tree's, each started lane's here from its supervisor, and each lane + /// on a node's forwards here. + pub fn port_entries(&self) -> Vec { + let mut out = crate::ports::tree_entries(&self.main.port_facts()); + let local: Vec> = self.map.lock().unwrap().values().cloned().collect(); + for l in local { + out.extend(crate::ports::tree_entries(&l.sup.port_facts())); + } + let remote: Vec> = self.remote.lock().unwrap().values().cloned().collect(); + if remote.is_empty() { + return out; + } + let step = self.main.stack.lanes.port_step; + let numbers: BTreeMap = self + .known + .lock() + .unwrap() + .iter() + .map(|(n, k)| (n.clone(), k.entry.number)) + .collect(); + let published = self.main.published_fixed(); + for r in remote { + let (tcp, udp, listening) = r.forwarded(); + let holders = numbers + .get(&r.name) + .and_then(|n| lane_offset(*n, step)) + .map(|off| crate::ports::lane_holders(&self.main.stack, &published, off)) + .unwrap_or_default(); + out.extend(crate::ports::node_entries(&crate::ports::NodeFacts { + lane: r.name.clone(), + node: r.node.name.clone(), + tcp, + udp, + listening, + holders, + })); + } + out + } + /// Stop a lane, if it runs, and drop it with its state. Not while it is /// being started (its supervisor is being set up from that state): the /// next sync forgets it, once the start is over. diff --git a/crates/devstack-daemon/src/lib.rs b/crates/devstack-daemon/src/lib.rs index 9b8315d6..acfc311c 100644 --- a/crates/devstack-daemon/src/lib.rs +++ b/crates/devstack-daemon/src/lib.rs @@ -11,6 +11,7 @@ pub mod health; pub mod journal; pub mod lanes; pub mod logfiles; +pub mod ports; pub mod proxy; pub mod rebuild; pub mod registry; diff --git a/crates/devstack-daemon/src/ports.rs b/crates/devstack-daemon/src/ports.rs new file mode 100644 index 00000000..016cbe0c --- /dev/null +++ b/crates/devstack-daemon/src/ports.rs @@ -0,0 +1,834 @@ +//! The ports a stack holds or uses on this machine (ADR-0082 point 1): +//! what `GET /v1/ports` answers, one [`PortEntry`] per port, and what the +//! hub's `GET /hub/ports` joins over every stack and marks overlaps in. +//! +//! What a tree holds is gathered from its supervisor +//! ([`crate::supervisor::Supervisor::port_facts`]) and turned into entries +//! here ([`tree_entries`]), so the rules are tested without a daemon. A +//! stack whose daemon does not run is known by what its stack file declares +//! ([`declared`]). + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// The fixed port of a service: the one the stack file gives it, in a lane +/// moved by the lane's offset. In the main tree, the main slot's. +pub const FIXED: &str = "fixed"; +/// The port a running instance listens on behind devstack's proxy or a +/// dev server's front. +pub const INSTANCE: &str = "instance"; +/// A host port Docker publishes for a container. +pub const PUBLISHED: &str = "published"; +/// A `main_ports` container's fixed port, which devstack holds and +/// forwards (ADR-0048). +pub const HELD: &str = "held"; +/// A port of a lane on a node, forwarded (TCP) or relayed (UDP) here. +pub const NODE: &str = "node"; +/// A port the stack file of a stack whose daemon does not run names. +pub const DECLARED: &str = "declared"; + +/// devstack's HTTP proxy listens on it (ADR-0010). +pub const VIA_PROXY: &str = "proxy"; +/// devstack listens on it and passes the bytes on: a dev server's front +/// (ADR-0043), a held port (ADR-0048), the main slot's forward to a lane, +/// a lane on a node. +pub const VIA_FORWARD: &str = "forward"; +/// The process itself listens on it. +pub const VIA_DIRECT: &str = "direct"; +/// Docker publishes it. +pub const VIA_DOCKER: &str = "docker"; + +/// One port a stack holds or uses on this machine. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PortEntry { + pub port: u16, + /// `tcp` or `udp`. + pub proto: String, + /// `main` for the main working tree, else the lane's name. + pub lane: String, + /// [`FIXED`], [`INSTANCE`], [`PUBLISHED`], [`HELD`], [`NODE`] or + /// [`DECLARED`]. + pub kind: String, + /// A service, or a container as `.`; absent when it is + /// not known (a forward of a lane on a node no service accounts for). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub holder: Option, + /// [`VIA_PROXY`], [`VIA_FORWARD`], [`VIA_DIRECT`] or [`VIA_DOCKER`]. + pub via: String, + /// Where it leads: the instance's port behind a proxy or front, the + /// lane's port the main slot forwards to, the container's own port, + /// the port on the node. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + /// The lane `target` belongs to when it is not this entry's own: the + /// lane that serves the main slot's port. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target_lane: Option, + /// The node of a lane on a node. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub node: Option, + /// Something listens on it now, as devstack knows it (not probed). + pub listening: bool, +} + +impl PortEntry { + fn new(port: u16, proto: &str, lane: &str, kind: &str, via: &str) -> Self { + Self { + port, + proto: proto.into(), + lane: lane.into(), + kind: kind.into(), + holder: None, + via: via.into(), + target: None, + target_lane: None, + node: None, + listening: false, + } + } + + fn held_by(mut self, holder: impl Into) -> Self { + self.holder = Some(holder.into()); + self + } + + fn to(mut self, target: Option) -> Self { + self.target = target; + self + } + + fn listening(mut self, on: bool) -> Self { + self.listening = on; + self + } +} + +/// Port pairs `(on the node, here)` of a lane on a node. +pub type PortPairs = Vec<(u16, u16)>; +/// A container's held ports: the fixed port, where it forwards now. +pub type HeldPorts = Vec<(u16, Option)>; + +/// A host port a container publishes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Published { + /// On this machine. + pub port: u16, + /// In the container. + pub target: u16, + /// `tcp` or `udp`. + pub proto: String, +} + +/// The host ports `publishers` (`compose ps`) name, each port and protocol +/// once (Docker lists one per address family). +pub fn bound(publishers: &[devstack_compose::Publisher]) -> Vec { + let mut out: Vec = Vec::new(); + for p in publishers.iter().filter(|p| p.published_port != 0) { + let proto = match p.protocol.as_str() { + "" => "tcp", + other => other, + }; + if !out + .iter() + .any(|o| o.port == p.published_port && o.proto == proto) + { + out.push(Published { + port: p.published_port, + target: p.target_port, + proto: proto.into(), + }); + } + } + out +} + +/// What a tree's supervisor knows of a service's ports. +#[derive(Debug, Clone, Default)] +pub struct ServiceFacts { + pub name: String, + /// Its fixed port in this tree (moved by a lane's offset). + pub port: Option, + /// devstack's HTTP proxy on the fixed port: where it forwards now. + pub proxy: Option>, + /// A dev server's front on the fixed port (ADR-0043): where it + /// forwards now. + pub front: Option>, + /// The main tree's TCP forward of the fixed port to a lane's. + pub forward: Option>, + /// The fixed port leads to the lane that serves the main slot, not to + /// this tree's instance. + pub to_lane: bool, + /// The port its running instance listens on. + pub instance: Option, + /// It runs (builds, starts, runs). + pub running: bool, +} + +/// What a tree's supervisor knows of a container's ports. +#[derive(Debug, Clone, Default)] +pub struct ContainerFacts { + pub group: String, + pub service: String, + pub running: bool, + pub published: Vec, + /// Its fixed ports devstack holds (ADR-0048): the port, where it + /// forwards now. + pub held: HeldPorts, +} + +/// What a tree's supervisor knows of the ports it holds. +#[derive(Debug, Clone, Default)] +pub struct TreeFacts { + /// `main`, or the lane's name. + pub lane: String, + /// The lane that serves the main slot's ports, in the main tree. + pub served_by: Option, + /// The held ports forward to that lane. + pub held_to_lane: bool, + pub services: Vec, + pub containers: Vec, +} + +/// The entries of one tree: its services' fixed ports and instance ports, +/// its containers' published and held ports. +pub fn tree_entries(f: &TreeFacts) -> Vec { + let lane = f.lane.as_str(); + let mut out = Vec::new(); + for s in &f.services { + let fixed = |via: &str, target: Option| { + let mut e = PortEntry::new(s.port.unwrap_or_default(), "tcp", lane, FIXED, via) + .held_by(&s.name) + .to(target) + .listening(true); + if s.to_lane { + e.target_lane = f.served_by.clone(); + } + e + }; + match (s.port, s.proxy, s.front, s.forward) { + (Some(_), Some(up), _, _) => out.push(fixed(VIA_PROXY, up)), + (Some(_), None, Some(up), _) => out.push(fixed(VIA_FORWARD, up)), + (Some(_), None, None, Some(to)) => out.push(fixed(VIA_FORWARD, to)), + (Some(p), None, None, None) => out.push( + PortEntry::new(p, "tcp", lane, FIXED, VIA_DIRECT) + .held_by(&s.name) + .listening(s.running && s.instance == Some(p)), + ), + (None, ..) => {} + } + if let Some(i) = s.instance.filter(|i| Some(*i) != s.port) { + out.push( + PortEntry::new(i, "tcp", lane, INSTANCE, VIA_DIRECT) + .held_by(&s.name) + .listening(s.running), + ); + } + } + for c in &f.containers { + let holder = format!("{}.{}", c.group, c.service); + for (port, up) in &c.held { + let mut e = PortEntry::new(*port, "tcp", lane, HELD, VIA_FORWARD) + .held_by(&holder) + .to(*up) + .listening(true); + if f.held_to_lane { + e.target_lane = f.served_by.clone(); + } + out.push(e); + } + for p in &c.published { + out.push( + PortEntry::new(p.port, &p.proto, lane, PUBLISHED, VIA_DOCKER) + .held_by(&holder) + .to(Some(p.target)) + .listening(c.running), + ); + } + } + out +} + +/// What the main tree's daemon knows of a lane on a node: the ports it +/// forwards or relays here. +#[derive(Debug, Clone, Default)] +pub struct NodeFacts { + pub lane: String, + pub node: String, + /// `(on the node, here)`. + pub tcp: PortPairs, + pub udp: PortPairs, + /// This machine listens on them for the lane now. + pub listening: bool, + /// What each port here is, by port: a service or `.`. + pub holders: BTreeMap, +} + +/// The entries of a lane on a node, as this machine holds them. +pub fn node_entries(f: &NodeFacts) -> Vec { + let one = |(there, here): &(u16, u16), proto: &str| { + let mut e = PortEntry::new(*here, proto, &f.lane, NODE, VIA_FORWARD) + .to(Some(*there)) + .listening(f.listening); + e.holder = f.holders.get(here).cloned(); + e.node = Some(f.node.clone()); + e + }; + f.tcp + .iter() + .map(|p| one(p, "tcp")) + .chain(f.udp.iter().map(|p| one(p, "udp"))) + .collect() +} + +/// What each port of a lane moved by `offset` is: the main tree's +/// services' fixed ports and its containers' host ports (`published`: +/// `(group, service, container port) -> host:port`, held ports as their +/// fixed one), each moved. +pub fn lane_holders( + stack: &devstack_config::Stack, + published: &BTreeMap<(String, String, u16), String>, + offset: u16, +) -> BTreeMap { + let mut out = BTreeMap::new(); + for (name, s) in &stack.services { + if let Some(p) = s.port.and_then(|p| p.checked_add(offset)) { + out.entry(p).or_insert_with(|| name.clone()); + } + } + for ((group, service, _), addr) in published { + let Some(p) = addr + .rsplit(':') + .next() + .and_then(|p| p.parse::().ok()) + .and_then(|p| p.checked_add(offset)) + else { + continue; + }; + out.entry(p).or_insert_with(|| format!("{group}.{service}")); + } + out +} + +/// The ports the main tree of `stack` declares, for a stack whose daemon +/// does not run: its services' fixed ports, its Compose groups' announced +/// ports (ADR-0057) and the ports on this machine their `urls` name. Other +/// published ports of the Compose files are not read (that takes `docker +/// compose config`). +pub fn declared(stack: &devstack_config::Stack) -> Vec { + let mut out: Vec = Vec::new(); + let mut add = |port: u16, proto: &str, holder: String| { + if !out.iter().any(|e| e.port == port && e.proto == proto) { + out.push(PortEntry::new(port, proto, "main", DECLARED, VIA_DIRECT).held_by(holder)); + } + }; + for (name, s) in &stack.services { + if let Some(p) = s.port { + add(p, "tcp", name.clone()); + } + } + for (group, g) in &stack.compose { + for a in g.announced() { + add( + a.port, + if a.udp { "udp" } else { "tcp" }, + format!("{group}.{}", a.service), + ); + } + for (service, url) in &g.urls { + if let Some(p) = local_port(url) { + add(p, "tcp", format!("{group}.{service}")); + } + } + } + for e in &mut out { + e.via = if e.holder.as_deref().is_some_and(|h| h.contains('.')) { + VIA_DOCKER.into() + } else { + VIA_DIRECT.into() + }; + } + out +} + +/// The port of a link on this machine (`http://localhost:8025/ui`). +fn local_port(url: &str) -> Option { + let (_, rest) = url.split_once("://")?; + let authority = rest.find(['/', '?', '#']).map_or(rest, |i| &rest[..i]); + let (host, port) = authority.rsplit_once(':')?; + matches!(host, "localhost" | "127.0.0.1" | "[::1]") + .then(|| port.parse().ok()) + .flatten() +} + +/// One stack's ports, as `GET /hub/ports` lists it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct StackPorts { + pub id: String, + pub name: String, + /// Its daemon runs; else `ports` is what its stack file declares. + pub running: bool, + pub ports: Vec, +} + +/// One stack's use of an overlapping port. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OverlapUse { + pub id: String, + pub name: String, + pub lane: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub holder: Option, + pub kind: String, +} + +/// A port and protocol more than one stack holds or declares. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Overlap { + pub port: u16, + pub proto: String, + /// Who uses it, by stack, each use once. + pub stacks: Vec, +} + +/// The ports and protocols more than one of `stacks` uses, by port. An +/// instance port does not count: the OS hands it out, so two running +/// processes never share one, and a stopped service's last one says +/// nothing about tomorrow. +pub fn overlaps(stacks: &[StackPorts]) -> Vec { + let mut by: BTreeMap<(u16, String), Vec> = BTreeMap::new(); + for s in stacks { + for e in s.ports.iter().filter(|e| e.kind != INSTANCE) { + let uses = by.entry((e.port, e.proto.clone())).or_default(); + let u = OverlapUse { + id: s.id.clone(), + name: s.name.clone(), + lane: e.lane.clone(), + holder: e.holder.clone(), + kind: e.kind.clone(), + }; + if !uses.contains(&u) { + uses.push(u); + } + } + } + by.into_iter() + .filter(|(_, uses)| { + let first = &uses[0].id; + uses.iter().any(|u| &u.id != first) + }) + .map(|((port, proto), stacks)| Overlap { + port, + proto, + stacks, + }) + .collect() +} + +/// An overlap in one line: `4200/tcp: shop (main, web), docs (declared, site)`. +pub fn overlap_text(o: &Overlap) -> String { + let uses: Vec = o + .stacks + .iter() + .map(|u| { + let place = if u.kind == DECLARED { + "declared".to_string() + } else { + u.lane.clone() + }; + match &u.holder { + Some(h) => format!("{} ({place}, {h})", u.name), + None => format!("{} ({place})", u.name), + } + }) + .collect(); + format!("{}/{}: {}", o.port, o.proto, uses.join(", ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn by_port(entries: &[PortEntry], port: u16) -> &PortEntry { + entries + .iter() + .find(|e| e.port == port) + .unwrap_or_else(|| panic!("no entry for {port}: {entries:?}")) + } + + #[test] + fn a_proxied_service_holds_its_fixed_port_and_its_instance_one() { + let f = TreeFacts { + lane: "main".into(), + services: vec![ServiceFacts { + name: "api".into(), + port: Some(5000), + proxy: Some(Some(49152)), + instance: Some(49152), + running: true, + ..Default::default() + }], + ..Default::default() + }; + let e = tree_entries(&f); + assert_eq!(e.len(), 2); + let fixed = by_port(&e, 5000); + assert_eq!( + (fixed.kind.as_str(), fixed.via.as_str(), fixed.target), + (FIXED, VIA_PROXY, Some(49152)) + ); + assert_eq!(fixed.holder.as_deref(), Some("api")); + assert!(fixed.listening); + assert_eq!(fixed.lane, "main"); + let inst = by_port(&e, 49152); + assert_eq!( + (inst.kind.as_str(), inst.via.as_str()), + (INSTANCE, VIA_DIRECT) + ); + assert!(inst.listening); + } + + #[test] + fn a_dev_servers_front_forwards_to_its_instance() { + let f = TreeFacts { + lane: "main".into(), + services: vec![ServiceFacts { + name: "web".into(), + port: Some(4200), + front: Some(Some(51000)), + instance: Some(51000), + running: true, + ..Default::default() + }], + ..Default::default() + }; + let e = tree_entries(&f); + let fixed = by_port(&e, 4200); + assert_eq!( + (fixed.kind.as_str(), fixed.via.as_str(), fixed.target), + (FIXED, VIA_FORWARD, Some(51000)) + ); + assert_eq!(by_port(&e, 51000).kind, INSTANCE); + } + + #[test] + fn a_dev_server_without_a_front_listens_on_its_port_itself() { + let f = TreeFacts { + lane: "main".into(), + services: vec![ + ServiceFacts { + name: "web".into(), + port: Some(4200), + instance: Some(4200), + running: true, + ..Default::default() + }, + ServiceFacts { + name: "docs".into(), + port: Some(8080), + ..Default::default() + }, + ], + ..Default::default() + }; + let e = tree_entries(&f); + assert_eq!(e.len(), 2, "no instance entry for the fixed port: {e:?}"); + let web = by_port(&e, 4200); + assert_eq!((web.via.as_str(), web.listening), (VIA_DIRECT, true)); + // Not running: its port is the stack's all the same. + assert!(!by_port(&e, 8080).listening); + } + + #[test] + fn the_main_slot_served_by_a_lane_leads_there() { + let f = TreeFacts { + lane: "main".into(), + served_by: Some("feat".into()), + held_to_lane: true, + services: vec![ + ServiceFacts { + name: "api".into(), + port: Some(5000), + proxy: Some(Some(49300)), + to_lane: true, + ..Default::default() + }, + ServiceFacts { + name: "web".into(), + port: Some(4200), + forward: Some(Some(4300)), + to_lane: true, + ..Default::default() + }, + ], + containers: vec![ContainerFacts { + group: "app".into(), + service: "ui".into(), + running: true, + published: vec![Published { + port: 51234, + target: 80, + proto: "tcp".into(), + }], + held: vec![(8081, Some(8181))], + }], + }; + let e = tree_entries(&f); + let api = by_port(&e, 5000); + assert_eq!(api.target_lane.as_deref(), Some("feat")); + let web = by_port(&e, 4200); + assert_eq!( + (web.via.as_str(), web.target, web.target_lane.as_deref()), + (VIA_FORWARD, Some(4300), Some("feat")) + ); + assert!( + !e.iter().any(|x| x.kind == INSTANCE), + "the main tree's instances are stopped: {e:?}" + ); + // A `main_ports` container: its fixed port held, its instance + // published by Docker on another. + let held = by_port(&e, 8081); + assert_eq!( + ( + held.kind.as_str(), + held.via.as_str(), + held.holder.as_deref(), + held.target, + held.target_lane.as_deref() + ), + (HELD, VIA_FORWARD, Some("app.ui"), Some(8181), Some("feat")) + ); + let published = by_port(&e, 51234); + assert_eq!( + ( + published.kind.as_str(), + published.via.as_str(), + published.target + ), + (PUBLISHED, VIA_DOCKER, Some(80)) + ); + assert!(published.listening); + } + + #[test] + fn a_lanes_entries_carry_its_name_and_moved_ports() { + let f = TreeFacts { + lane: "feat".into(), + services: vec![ServiceFacts { + name: "api".into(), + port: Some(5100), + proxy: Some(Some(49400)), + instance: Some(49400), + running: true, + ..Default::default() + }], + containers: vec![ContainerFacts { + group: "data".into(), + service: "db".into(), + running: false, + published: vec![ + Published { + port: 5532, + target: 5432, + proto: "tcp".into(), + }, + Published { + port: 5453, + target: 53, + proto: "udp".into(), + }, + ], + held: Vec::new(), + }], + ..Default::default() + }; + let e = tree_entries(&f); + assert!(e.iter().all(|x| x.lane == "feat")); + assert_eq!(by_port(&e, 5100).kind, FIXED); + let db = by_port(&e, 5532); + assert_eq!(db.holder.as_deref(), Some("data.db")); + assert!(!db.listening, "its container does not run"); + assert_eq!(by_port(&e, 5453).proto, "udp"); + } + + #[test] + fn a_lane_on_a_node_is_its_forwards_here() { + let mut holders = BTreeMap::new(); + holders.insert(5100, "api".to_string()); + let e = node_entries(&NodeFacts { + lane: "feat".into(), + node: "build-server".into(), + tcp: vec![(5100, 5100), (5532, 5532)], + udp: vec![(5453, 5453)], + listening: true, + holders, + }); + assert_eq!(e.len(), 3); + let api = by_port(&e, 5100); + assert_eq!( + ( + api.kind.as_str(), + api.via.as_str(), + api.node.as_deref(), + api.holder.as_deref() + ), + (NODE, VIA_FORWARD, Some("build-server"), Some("api")) + ); + assert_eq!(by_port(&e, 5532).holder, None); + assert_eq!(by_port(&e, 5453).proto, "udp"); + } + + #[test] + fn a_lanes_holders_are_the_main_trees_ports_moved() { + let stack = devstack_config::from_str( + r#" +version = 1 +[stack] +name = "shop" +[services.api] +kind = "service" +run = "x" +port = 5000 +"#, + std::path::Path::new("t"), + ) + .unwrap(); + let mut published = BTreeMap::new(); + published.insert( + ("data".to_string(), "db".to_string(), 5432), + "127.0.0.1:15432".to_string(), + ); + let h = lane_holders(&stack, &published, 100); + assert_eq!(h.get(&5100).map(String::as_str), Some("api")); + assert_eq!(h.get(&15532).map(String::as_str), Some("data.db")); + } + + #[test] + fn publishers_are_each_port_and_protocol_once() { + let c = devstack_compose::parse_ps( + r#"{"Service":"db","Name":"x-db-1","State":"running","Health":"","ExitCode":0,"Publishers":[{"URL":"0.0.0.0","TargetPort":5432,"PublishedPort":15432,"Protocol":"tcp"},{"URL":"::","TargetPort":5432,"PublishedPort":15432,"Protocol":"tcp"},{"URL":"0.0.0.0","TargetPort":53,"PublishedPort":5353,"Protocol":"udp"},{"URL":"","TargetPort":9000,"PublishedPort":0}]}"#, + ) + .unwrap() + .remove(0); + let b = bound(&c.publishers); + assert_eq!( + b, + [ + Published { + port: 15432, + target: 5432, + proto: "tcp".into() + }, + Published { + port: 5353, + target: 53, + proto: "udp".into() + } + ] + ); + } + + #[test] + fn a_stack_file_declares_its_services_announced_and_linked_ports() { + let stack = devstack_config::from_str( + r#" +version = 1 +[stack] +name = "shop" +[services.api] +kind = "service" +run = "x" +port = 5000 +[services.worker] +kind = "service" +run = "x" +[compose.data] +project = "shop" +files = ["compose.yml"] +services = ["db", "dns", "mail"] +announced_ports = ["db:5432", "dns:53/udp"] +[compose.data.urls] +mail = "http://localhost:8025" +db = "" +"#, + std::path::Path::new("t"), + ) + .unwrap(); + let mut d = declared(&stack); + d.sort_by_key(|e| (e.port, e.proto.clone())); + let got: Vec<(u16, &str, &str, &str)> = d + .iter() + .map(|e| { + ( + e.port, + e.proto.as_str(), + e.holder.as_deref().unwrap(), + e.via.as_str(), + ) + }) + .collect(); + assert_eq!( + got, + [ + (53, "udp", "data.dns", VIA_DOCKER), + (5000, "tcp", "api", VIA_DIRECT), + (5432, "tcp", "data.db", VIA_DOCKER), + (8025, "tcp", "data.mail", VIA_DOCKER), + ] + ); + assert!( + d.iter() + .all(|e| e.kind == DECLARED && e.lane == "main" && !e.listening) + ); + } + + fn entry(port: u16, proto: &str, lane: &str, holder: &str) -> PortEntry { + PortEntry::new(port, proto, lane, FIXED, VIA_PROXY).held_by(holder) + } + + #[test] + fn an_overlap_is_one_port_and_protocol_in_more_than_one_stack() { + let stacks = vec![ + StackPorts { + id: "a".into(), + name: "shop".into(), + running: true, + ports: vec![ + entry(4200, "tcp", "main", "web"), + entry(5000, "tcp", "main", "api"), + entry(5353, "udp", "main", "data.dns"), + ], + }, + StackPorts { + id: "b".into(), + name: "crm".into(), + running: true, + ports: vec![ + entry(4200, "tcp", "feat", "portal"), + entry(5353, "tcp", "main", "gateway"), + // The OS hands instance ports out: never an overlap. + PortEntry::new(5000, "tcp", "main", INSTANCE, VIA_DIRECT).held_by("api"), + ], + }, + StackPorts { + id: "c".into(), + name: "docs".into(), + running: false, + ports: vec![ + PortEntry::new(4200, "tcp", "main", DECLARED, VIA_DIRECT).held_by("site"), + // Twice in one stack is no overlap. + entry(9000, "tcp", "main", "one"), + entry(9000, "tcp", "feat", "one"), + ], + }, + ]; + let o = overlaps(&stacks); + assert_eq!(o.len(), 1, "{o:?}"); + assert_eq!((o[0].port, o[0].proto.as_str()), (4200, "tcp")); + let names: Vec<&str> = o[0].stacks.iter().map(|u| u.name.as_str()).collect(); + assert_eq!(names, ["shop", "crm", "docs"]); + assert_eq!( + overlap_text(&o[0]), + "4200/tcp: shop (main, web), crm (feat, portal), docs (declared, site)" + ); + } +} diff --git a/crates/devstack-daemon/src/remote.rs b/crates/devstack-daemon/src/remote.rs index ab694f55..a3ae4af6 100644 --- a/crates/devstack-daemon/src/remote.rs +++ b/crates/devstack-daemon/src/remote.rs @@ -1686,6 +1686,17 @@ impl RemoteLane { } } + /// The ports it forwards (TCP) and relays (UDP) here, each `(on the + /// node, here)`, and whether this machine listens on them now + /// (ADR-0082). + pub fn forwarded(&self) -> (crate::ports::PortPairs, crate::ports::PortPairs, bool) { + ( + self.ports.clone(), + self.udp_ports.clone(), + *self.listening.borrow(), + ) + } + /// Stop listening on the lane's ports here; open connections stay. /// Returns once the ports are free (or after two seconds). pub fn stop_forwarding(&self) { diff --git a/crates/devstack-daemon/src/state.rs b/crates/devstack-daemon/src/state.rs index b2267942..e4e0de15 100644 --- a/crates/devstack-daemon/src/state.rs +++ b/crates/devstack-daemon/src/state.rs @@ -186,6 +186,10 @@ pub struct ContainerView { /// `ports` then names the fixed port. #[serde(skip_serializing_if = "Vec::is_empty")] pub held: Vec, + /// Its published host ports with their protocol, as `compose ps` + /// reports them: what `GET /v1/ports` lists (ADR-0082). Not in the view. + #[serde(skip)] + pub bound: Vec, /// The Compose group starts it (its service is in the group's /// `services`). `false` for any other container of the project that /// `compose ps` reports (started by hand, a service of a profile, an diff --git a/crates/devstack-daemon/src/supervisor/infra.rs b/crates/devstack-daemon/src/supervisor/infra.rs index 603cceb2..ecc60bbf 100644 --- a/crates/devstack-daemon/src/supervisor/infra.rs +++ b/crates/devstack-daemon/src/supervisor/infra.rs @@ -20,6 +20,9 @@ pub(crate) const NODE_DID_NOT_ACT: &str = "the node runs this lane's containers /// is created or restarts (ADR-0048). const HELD_WAIT: Duration = Duration::from_secs(60); +/// The held ports by container `(group, service)`. +pub(crate) type HeldByContainer = BTreeMap<(String, String), crate::ports::HeldPorts>; + /// A fixed port of a `main_ports` container that devstack holds (ADR-0048). pub(crate) struct HeldFront { fixed: u16, @@ -170,6 +173,22 @@ impl Supervisor { } } + /// The fixed ports devstack holds (ADR-0048) by container `(group, + /// service)`, each with where it forwards now. + pub(crate) fn held_now(&self) -> HeldByContainer { + let mut out = HeldByContainer::new(); + for ((g, s, _), h) in self.held_ports.lock().unwrap().iter() { + let to = match h.front.state() { + crate::tcpfwd::FrontState::To(p) => Some(p), + _ => None, + }; + out.entry((g.clone(), s.clone())) + .or_default() + .push((h.fixed, to)); + } + out + } + /// The held ports forward to the lane `offset` names (`promote --lane`), /// or to the main tree's containers again (`None`). pub(crate) fn hold_for_lane(&self, offset: Option) { @@ -609,6 +628,7 @@ impl Supervisor { .map(|(_, h)| h.front.view()) .collect(); drop(fronts); + let bound = crate::ports::bound(&c.publishers); ContainerView { managed: self.starts(group, &c.service), service: c.service.clone(), @@ -621,6 +641,7 @@ impl Supervisor { unreachable: Vec::new(), not_forwarded: Vec::new(), held, + bound, } } @@ -1343,6 +1364,7 @@ impl Supervisor { unreachable: Vec::new(), not_forwarded: s.not_forwarded(ext.udp), held: Vec::new(), + bound: Vec::new(), managed: true, }); } @@ -1730,6 +1752,7 @@ mod tests { unreachable: Vec::new(), not_forwarded: Vec::new(), held: Vec::new(), + bound: Vec::new(), managed, } } @@ -1825,6 +1848,51 @@ mod tests { assert_eq!(g.env["DEVSTACK_LANE_SUFFIX"], ""); } + #[test] + fn a_lanes_ports_are_its_own_containers_by_their_moved_host_ports() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let (_d, sup) = rtc("feat-x", &rt); + let c = devstack_compose::parse_ps( + r#"{"Service":"media","Name":"rtc-feat-x-media-1","State":"running","Publishers":[{"URL":"127.0.0.1","TargetPort":7981,"PublishedPort":7981,"Protocol":"tcp"},{"URL":"0.0.0.0","TargetPort":7882,"PublishedPort":7982,"Protocol":"udp"}]}"#, + ) + .unwrap() + .remove(0); + let mut published = BTreeMap::new(); + let view = sup.container_view("app", c, &mut published); + sup.infra.lock().unwrap().views = vec![InfraView { + group: "app".into(), + project: "rtc-feat-x".into(), + containers: vec![view], + error: None, + }]; + let e = crate::ports::tree_entries(&sup.port_facts()); + type Row<'a> = (u16, &'a str, &'a str, &'a str, Option, bool); + let got: Vec = e + .iter() + .map(|x| { + ( + x.port, + x.proto.as_str(), + x.lane.as_str(), + x.holder.as_deref().unwrap_or(""), + x.target, + x.listening, + ) + }) + .collect(); + assert_eq!( + got, + [ + (7981, "tcp", "feat-x", "app.media", Some(7981), true), + (7982, "udp", "feat-x", "app.media", Some(7882), true), + ] + ); + assert!(e.iter().all(|x| x.kind == crate::ports::PUBLISHED)); + } + #[test] fn an_explicit_container_name_is_addressed_by_its_service() { // `container_name: shop-blobs` on service `blobs`: the view keys it diff --git a/crates/devstack-daemon/src/supervisor/mod.rs b/crates/devstack-daemon/src/supervisor/mod.rs index 40c8660f..b8bf7160 100644 --- a/crates/devstack-daemon/src/supervisor/mod.rs +++ b/crates/devstack-daemon/src/supervisor/mod.rs @@ -33,6 +33,7 @@ mod handover; mod infra; pub use infra::ContainerTarget; mod lifecycle; +mod ports; mod rebuilds; mod replace; diff --git a/crates/devstack-daemon/src/supervisor/ports.rs b/crates/devstack-daemon/src/supervisor/ports.rs new file mode 100644 index 00000000..7e209810 --- /dev/null +++ b/crates/devstack-daemon/src/supervisor/ports.rs @@ -0,0 +1,111 @@ +//! What this tree holds of the machine's ports (ADR-0082 point 1), as +//! [`crate::ports::tree_entries`] reads it. + +use super::Supervisor; +use crate::ports::{ContainerFacts, ServiceFacts, TreeFacts}; +use crate::tcpfwd::FrontState; +use std::collections::{BTreeMap, BTreeSet}; + +impl Supervisor { + /// This tree's services' and containers' ports as it holds them now. + /// Each lock is taken on its own. + pub fn port_facts(&self) -> TreeFacts { + let served_by = self.served_by(); + let proxies: BTreeMap> = self + .proxies + .lock() + .unwrap() + .iter() + .map(|(n, p)| (n.clone(), p.upstream().map(|u| u.port))) + .collect(); + let fronts: BTreeMap> = self + .fronts + .lock() + .unwrap() + .iter() + .map(|(n, f)| { + let to = match f.state() { + FrontState::To(p) => Some(p), + _ => None, + }; + (n.clone(), to) + }) + .collect(); + let forwards: BTreeMap> = self + .forwards + .lock() + .unwrap() + .iter() + .map(|(n, f)| (n.clone(), f.target())) + .collect(); + let fronts_to_lane: BTreeSet = self.fronts_to_lane.lock().unwrap().clone(); + let moved: BTreeSet = if served_by.is_some() { + self.proxied_services().into_iter().collect() + } else { + BTreeSet::new() + }; + let services = self + .slots + .lock() + .unwrap() + .iter() + .map(|(name, slot)| { + let running = slot.view.status.is_active(); + ServiceFacts { + name: name.clone(), + port: self.stack.services.get(name).and_then(|s| s.port), + proxy: proxies.get(name).copied(), + front: fronts.get(name).copied(), + forward: forwards.get(name).copied(), + to_lane: moved.contains(name) + || fronts_to_lane.contains(name) + || forwards.contains_key(name), + instance: slot + .main + .as_ref() + .and_then(|i| i.port) + .or(slot.view.instance_port) + .filter(|_| running), + running, + } + }) + .collect(); + let held_to_lane = self.held_to_lane.lock().unwrap().is_some(); + let mut held = self.held_now(); + let mut containers = Vec::new(); + for v in self.infra.lock().unwrap().views.iter() { + for c in &v.containers { + containers.push(ContainerFacts { + group: v.group.clone(), + service: c.service.clone(), + running: c.state == "running", + published: c.bound.clone(), + held: held + .remove(&(v.group.clone(), c.service.clone())) + .unwrap_or_default(), + }); + } + } + // A held port stays held while its container is gone. + containers.extend( + held.into_iter() + .map(|((group, service), held)| ContainerFacts { + group, + service, + held, + ..Default::default() + }), + ); + TreeFacts { + lane: if self.lane.is_empty() { + "main".into() + } else { + self.lane.clone() + }, + served_by, + held_to_lane, + services, + containers, + } + } +} diff --git a/crates/devstack-daemon/src/tcpfwd.rs b/crates/devstack-daemon/src/tcpfwd.rs index 7d9d6aeb..fad58fb4 100644 --- a/crates/devstack-daemon/src/tcpfwd.rs +++ b/crates/devstack-daemon/src/tcpfwd.rs @@ -14,9 +14,17 @@ use tokio::sync::watch; /// `close` before it returns (open connections end on their own). pub struct Forward { tasks: Vec>, + /// The port on this machine it forwards to (`None`: an address + /// elsewhere, [`start_to`]). + target: Option, } impl Forward { + /// The port on this machine it forwards to, if it forwards to one. + pub fn target(&self) -> Option { + self.target + } + /// Stop listening and wait until the listeners are gone, so the port can /// be bound again right away (an aborted task drops its listener only /// when the runtime gets to it). @@ -62,7 +70,10 @@ pub fn start(port: u16, target: u16, rt: &tokio::runtime::Handle) -> std::io::Re if let Some(l) = v6 { tasks.push(rt.spawn(accept(l, port, target))); } - Ok(Forward { tasks }) + Ok(Forward { + tasks, + target: Some(target), + }) } /// Listen on `port` (127.0.0.1) and forward every connection to `addr` @@ -88,7 +99,10 @@ pub fn start_to(port: u16, addr: String, rt: &tokio::runtime::Handle) -> std::io }); } }); - Ok(Forward { tasks: vec![task] }) + Ok(Forward { + tasks: vec![task], + target: None, + }) } /// What a [`Front`] does with a new connection (ADR-0043). diff --git a/crates/devstack-hub/src/lib.rs b/crates/devstack-hub/src/lib.rs index 6f860f16..6f277567 100644 --- a/crates/devstack-hub/src/lib.rs +++ b/crates/devstack-hub/src/lib.rs @@ -44,6 +44,7 @@ mod open; mod pool; mod poolfeed; mod poolskills; +mod ports; mod prompts; pub mod release; pub mod secrets; @@ -520,6 +521,7 @@ fn router(state: AppState) -> axum::Router { .route("/hub/ping", get(ping).options(ping_preflight)) .route("/hub/stacks", get(stacks)) .route("/hub/summary", get(summary::get)) + .route("/hub/ports", get(ports::get)) .route("/hub/docker", get(docker)) .route("/hub/docker/start", post(docker_start)) .route("/hub/events", get(events)) diff --git a/crates/devstack-hub/src/ports.rs b/crates/devstack-hub/src/ports.rs new file mode 100644 index 00000000..ac11e2bc --- /dev/null +++ b/crates/devstack-hub/src/ports.rs @@ -0,0 +1,96 @@ +//! `GET /hub/ports` (ADR-0082 point 1): the ports of every registered stack +//! on this machine, and where two stacks meet on one. Computed on request: +//! each running stack's daemon is asked (`GET /v1/ports`), a stopped +//! stack's stack file is read. + +use axum::Json; +use axum::body::Body; +use axum::extract::State; +use axum::http::header; +use axum::response::{IntoResponse, Response}; +use devstack_daemon::ports::{PortEntry, StackPorts, declared, overlaps}; +use http_body_util::BodyExt; +use std::time::Duration; + +use crate::{AppState, StackEntry}; + +/// How long one daemon may take to say its ports. +const ASK_FOR: Duration = Duration::from_secs(5); + +pub(crate) async fn get(State(s): State) -> Response { + let entries = s.stacks.borrow().clone(); + let mut asks = tokio::task::JoinSet::new(); + for (i, e) in entries.into_iter().enumerate() { + let s = s.clone(); + asks.spawn(async move { (i, of(&s, e).await) }); + } + let mut stacks: Vec<(usize, StackPorts)> = Vec::new(); + while let Some(r) = asks.join_next().await { + if let Ok((i, Some(p))) = r { + stacks.push((i, p)); + } + } + stacks.sort_by_key(|(i, _)| *i); + let stacks: Vec = stacks.into_iter().map(|(_, p)| p).collect(); + let overlaps = overlaps(&stacks); + Json(serde_json::json!({ "stacks": stacks, "overlaps": overlaps })).into_response() +} + +/// One stack's ports: its daemon's, or (stopped, or a daemon that does +/// not say) what its stack file declares. `None`: a stopped stack whose +/// stack file is gone. +async fn of(s: &AppState, e: StackEntry) -> Option { + if e.running + && let Some(ports) = ask(s, &e.id).await + { + return Some(StackPorts { + id: e.id, + name: e.name, + running: true, + ports, + }); + } + let file = e.file.clone(); + if !e.running && !file.is_file() { + return None; + } + let ports = tokio::task::spawn_blocking(move || { + devstack_config::load(&file) + .map(|st| declared(&st)) + .unwrap_or_default() + }) + .await + .unwrap_or_default(); + Some(StackPorts { + id: e.id, + name: e.name, + running: e.running, + ports, + }) +} + +/// The daemon of stack `id`'s `GET /v1/ports`; `None` when it does not +/// answer it (an older daemon, one going away). +async fn ask(s: &AppState, id: &str) -> Option> { + #[derive(serde::Deserialize)] + struct Answer { + ports: Vec, + } + let req = hyper::Request::get("/v1/ports") + .header(header::AUTHORIZATION, format!("Bearer {}", s.token)) + .body(Body::empty()) + .ok()?; + let go = async { + let resp = crate::daemon_send(|| crate::daemon_now(s, id), req) + .await + .ok()?; + if resp.status() != 200 { + return None; + } + let body = resp.into_body().collect().await.ok()?.to_bytes(); + serde_json::from_slice::(&body) + .ok() + .map(|a| a.ports) + }; + tokio::time::timeout(ASK_FOR, go).await.ok().flatten() +} diff --git a/crates/devstack/src/doctor.rs b/crates/devstack/src/doctor.rs index d35105a6..7439d368 100644 --- a/crates/devstack/src/doctor.rs +++ b/crates/devstack/src/doctor.rs @@ -81,6 +81,7 @@ pub fn run(stack_file: &Path) -> Vec { out.push(f(Level::Warn, n.trim_start_matches("note: ").to_string())); } } + out.extend(overlaps()); out.push(hub()); out.extend(preview()); out.extend(release()); @@ -334,6 +335,86 @@ fn ports(stack: &devstack_config::Stack, stack_file: &Path) -> Vec { } } +/// Ports two stacks on this machine both use (ADR-0082): the hub's +/// `/hub/ports` when it runs (it is not started for this), else the same +/// read here, from each running daemon and each stopped stack's file. +fn overlaps() -> Vec { + use devstack_daemon::ports::{StackPorts, overlap_text, overlaps}; + let from_hub = || -> Option> { + let port = devstack_hub::port(); + if devstack_hub::who_answers(port) != devstack_hub::Listener::Ours { + return None; + } + let v = get_json(port, "/hub/ports")?; + serde_json::from_value(v.get("overlaps")?.clone()).ok() + }; + let here = || { + let stacks: Vec = devstack_daemon::registry::list() + .into_iter() + .filter_map(|e| { + let daemon = devstack_daemon::read_info(&e.repo.join(".devstack")) + .filter(|i| !i.stopping && devstack_proc::pid_alive(i.pid)); + let asked = daemon + .and_then(|d| get_json(d.port, "/v1/ports")) + .and_then(|v| serde_json::from_value(v.get("ports")?.clone()).ok()); + let running = asked.is_some(); + let ports = match asked { + Some(p) => p, + None if e.file.is_file() => devstack_config::load(&e.file) + .map(|st| devstack_daemon::ports::declared(&st)) + .unwrap_or_default(), + None => return None, + }; + Some(StackPorts { + id: e.id, + name: e.name, + running, + ports, + }) + }) + .collect(); + overlaps(&stacks) + }; + let found = from_hub().unwrap_or_else(here); + if found.is_empty() { + return vec![f( + Level::Ok, + "ports: no two stacks on this machine share one", + )]; + } + found + .iter() + .map(|o| { + f( + Level::Warn, + format!( + "ports: two stacks use {}; only one of them can hold it at a time", + overlap_text(o) + ), + ) + }) + .collect() +} + +/// `GET path` on `127.0.0.1:port` with this user's token, as JSON. +fn get_json(port: u16, path: &str) -> Option { + let token = devstack_daemon::token::load_or_create().ok()?; + let agent = ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(5))) + .http_status_as_error(false) + .build() + .new_agent(); + let mut r = agent + .get(&format!("http://127.0.0.1:{port}{path}")) + .header("Authorization", &format!("Bearer {token}")) + .call() + .ok()?; + if r.status() != 200 { + return None; + } + serde_json::from_str(&r.body_mut().read_to_string().ok()?).ok() +} + /// A newer release, if GitHub answers within a few seconds (skipped with /// `DEVSTACK_NO_UPDATE_CHECK`). fn release() -> Option { diff --git a/crates/devstack/tests/daemon_lifecycle.rs b/crates/devstack/tests/daemon_lifecycle.rs index 2f19be6d..c4628af9 100644 --- a/crates/devstack/tests/daemon_lifecycle.rs +++ b/crates/devstack/tests/daemon_lifecycle.rs @@ -2307,6 +2307,38 @@ fn a_worktree_runs_as_a_lane() { as_main.is_some_and(|st| st != "stopped"), "the tray runs in main" ); + // Every port the stack holds here, its lane's too (ADR-0082): the main + // tree's fixed ports and the lane's, moved by its offset, each with what + // listens on it (the HTTP proxy, a dev server's front). + let ports = s.daemon_get("/v1/ports"); + let fixed = |ports: &Value, lane: &str, port: u16, holder: &str, via: &str| { + ports["ports"].as_array().is_some_and(|a| { + a.iter().any(|e| { + e["lane"] == lane + && e["port"] == port + && e["holder"] == holder + && e["kind"] == "fixed" + && e["via"] == via + && e["listening"] == true + }) + }) + }; + assert!(fixed(&ports, "main", s.port, "api", "proxy"), "{ports}"); + assert!(fixed(&ports, "main", web_port, "web", "forward"), "{ports}"); + assert!(fixed(&ports, &lane, lane_port, "api", "proxy"), "{ports}"); + assert!( + fixed(&ports, &lane, web_port + offset, "web", "forward"), + "{ports}" + ); + // A lane's own `/v1/ports` is its entries only. + let own = s.daemon_get(&format!("/v1/lanes/{lane}/v1/ports")); + assert!(fixed(&own, &lane, lane_port, "api", "proxy"), "{own}"); + assert!( + own["ports"] + .as_array() + .is_some_and(|a| a.iter().all(|e| e["lane"] == lane.as_str())), + "{own}" + ); // 3. An edit in the worktree reaches the lane only. let main_build = s.info()["build"].as_str().unwrap().to_string(); diff --git a/docs/adr/0082-the-ports-of-a-machine.md b/docs/adr/0082-the-ports-of-a-machine.md new file mode 100644 index 00000000..aa0fac2b --- /dev/null +++ b/docs/adr/0082-the-ports-of-a-machine.md @@ -0,0 +1,96 @@ +# ADR-0082: The ports of a machine: every stack says what it holds, a stack can run shifted + +- **Status:** Accepted (card in the pull request); built in steps +- **Date:** 2026-10-30 +- **Deciders:** project owner +- **Related:** [ADR-0010](0010-promotion-mechanism.md) (the proxy on a service's fixed port), [ADR-0021](0021-a-hub-serves-the-dashboard.md) (the registry of stacks), [ADR-0024](0024-a-lane-is-a-supervisor-in-the-main-trees-daemon.md) (a lane's ports moved by its offset), [ADR-0029](0029-source-sync-and-port-forwarding-for-remote-lanes.md) and [ADR-0056](0056-a-lane-on-a-node-relays-its-udp-ports-through-a-tunnel-per-session.md) (a lane on a node, its ports forwarded here), [ADR-0030](0030-each-lane-has-its-own-infrastructure.md) (a lane's containers on moved host ports), [ADR-0043](0043-a-dev-servers-fixed-port-is-devstacks.md) (a dev server's front), [ADR-0048](0048-a-containers-main-port-can-move-with-the-main-slot.md) (`main_ports`), [ADR-0057](0057-a-port-a-container-announces-moves-inside-it-too.md) (announced ports), [ADR-0070](0070-a-review-card-comes-before-the-code-an-adr-records-what-was-built.md), [ADR-0080](0080-the-main-tree-answers-as-a-lane-shared-is-deprecated.md) (the main tree answers as the lane `main`), [ADR-0081](0081-a-stack-has-a-page-every-lane-the-same-view.md) (the stack's page), [#387](https://github.com/nuetzliches/devstack/issues/387) (several stacks on one machine) + +## Context + +A developer with several projects, each its own repository and stack +file, often has them declare the same ports (5000, 4200, 5432). Started +side by side, the second stack fails at a service's start with "port N is +already in use". Nothing in devstack compares ports across stacks: the +registry of stacks holds no ports, a lane's number avoids only its own +stack's ports, and what a stack holds is spread over the proxies, the dev +servers' fronts, the held ports of `main_ports` containers, the +containers' published ports and the forwards of lanes on nodes. Lanes +solve the same problem inside one repository with an offset; nothing does +for a whole stack. + +## Decision + +The owner approved the points in the conversation on **2026-10-30**: + +| # | Category | Point | Beats | Reversing costs | +|---|---|---|---|---| +| 1 | contract | A daemon reports every port its stack holds on this machine, per lane (`GET /v1/ports`): port, protocol, lane, what holds it (service, container, proxy, forward) and where it leads. The hub joins every stack's (`GET /hub/ports`) and marks overlaps. | Port knowledge scattered over proxies, fronts, held ports, containers and forwards, with no view across stacks. | Low: two read-only routes. | +| 2 | concept | The stack page shows the port world as a graph: fixed ports (main slot) → the lane serving them → its services and containers with instance and published ports; lanes on nodes with their forwards; overlaps with other stacks in red. | Ports read off a table per service, and collisions found at a failed start. | UI only. | +| 3 | concept | A stack can run on this machine with a port shift: everything a lane offset moves (services, Compose host ports, announced ports, `urls`, the lanes too) moves by it; Compose project, volumes and data stay. | Editing every repository's ports by hand, on every machine, against the other machines' choices. | Medium: one more state of a main tree that `promote --lane`, `main_ports` and the dashboard know. | +| 4 | contract | The shift is machine-local, in the stack's registry entry, never in the stack file. | A committed `port_offset`: the same on every machine, so it only moves the collision. | Low: an entry field with a default. | +| 5 | contract | Every daemon writes the ports it claims into its registry entry (main slot after shift plus its lanes' ranges); every allocation (shift, lane number) avoids all other registered stacks' claims, stopped ones too. | Today's OS probe, which misses a stopped stack and lets one stack's lane range land on another's main ports. | Low to medium: the registry grows a field older readers ignore. | +| 6 | term | Nobody moves silently: `up` colliding with a running stack starts nothing, names it and offers `--shift auto` / `--shift ` (and a stack-page button); a stopped stack's collision only warns. Whoever was first keeps its ports. | Shifting the second stack by itself, which breaks bookmarks and hard-coded ports without a word. | Low: an automatic shift later is additive. | +| 7 | concept | A chosen shift sticks to the stack on this machine until `--shift none`. | A shift recomputed per start: another port every day. | Low. | + +Decided by the agent alone (not asked): + +- The shift is a multiple of 1000. `auto` takes the lowest one whose + ports, lanes 1–3 included, are free of every claim. +- `devstack doctor` lists overlaps between stacks. +- A shifted stack's lanes take their offset from the shifted ports. +- Ports hard-coded outside placeholders do not follow; this is documented. +- Registry entries whose folder or stack file is gone are no claim. +- The stack file does not change. + +## Built + +Only point 1 is built so far (0.33.0): + +1. **`GET /v1/ports`** answers `{ports: [PortEntry]}`, one entry per port + and protocol a tree holds or uses: `port`, `proto` (`tcp`, `udp`), + `lane` (`main` or the lane's name), `kind`, `holder` (a service, or a + container as `.`), `via`, `target`, `target_lane`, + `node`, `listening`. The kinds: `fixed` (a service's port; the main + slot's in `main`, moved by the offset in a lane), `instance` (an + instance behind the proxy or a front), `published` (a container's host + port), `held` (a `main_ports` container's fixed port), `node` (a port of + a lane on a node forwarded or relayed here). `via`: `proxy` (the HTTP + proxy), `forward` (a front, a held port, the main slot's forward to a + lane, a lane on a node), `direct` (the process itself), `docker`. While + a lane serves the main slot, the main tree's fixed and held ports name + it in `target_lane`. `listening` is what devstack knows, not a probe. + The main tree's daemon includes its lanes' entries (a lane here from its + supervisor, a lane on a node from its forwards here, each port named by + the main tree's service or container it moves); + `/v1/lanes//v1/ports` is that lane's own. A container's protocol + comes from `compose ps` (`Protocol`). +2. **`GET /hub/ports`** asks every running stack's daemon on request (no + polling) and answers `{stacks: [{id, name, running, ports}], overlaps: + [{port, proto, stacks: [{id, name, lane, holder, kind}]}]}`. A stack + whose daemon does not run, or does not answer (an older devstack), has + what its stack file declares for the main tree, `kind: "declared"`: its + services' `port`, its Compose groups' `announced_ports` and the ports on + this machine their `urls` name. Other published ports of the Compose + files are not read: that takes `docker compose config`. A stopped + stack whose stack file is gone is left out. An overlap is one port and + protocol in more than one stack; an instance port does not count (the + OS hands it out, so two running processes never share one). +3. **`devstack doctor`** warns of each overlap with its port and the + stacks, lanes and holders, from the hub when it runs, else read the same + way without it (each running daemon, each stopped stack's file). It + does not start the hub for that. +4. The dashboard's mock answers both routes, with one overlap; the types + are in `ui/src/api/types.ts`. No view yet. + +What follows: the graph on the stack's page (point 2); then the shift with +the claims in the registry, `up --shift`, the stack page's button and the +start check (points 3 to 7). + +## Consequences + +- One place says which ports a stack holds and why; the graph, the claims + and `doctor` read it instead of each collecting it again. +- An overlap with a stopped stack is visible before either starts, as far + as its stack file declares its ports. +- A port only a Compose file publishes, without an announcement or a + `urls` entry, is not seen in a stopped stack. diff --git a/docs/adr/README.md b/docs/adr/README.md index 95b2807b..7e8df29a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -164,3 +164,4 @@ stay. | [0079](0079-skills-local-to-a-working-tree.md) | Skills local to a working tree: a pool's project skills bound to their repository, devstack's own untracked | Accepted (card in the pull request) | | [0080](0080-the-main-tree-answers-as-a-lane-shared-is-deprecated.md) | The main working tree answers as the lane `main`; `singleton` replaces `lanes = false`; `shared` is deprecated | Accepted (card in the pull request) | | [0081](0081-a-stack-has-a-page-every-lane-the-same-view.md) | A stack has a page of its own; every lane, `main` included, has the same view | Accepted (card in the pull request) | +| [0082](0082-the-ports-of-a-machine.md) | The ports of a machine: every stack says what it holds, a stack can run shifted | Accepted (card in the pull request); built in steps: point 1 (`/v1/ports`, `/hub/ports`, `doctor`) in 0.33.0 | diff --git a/docs/dashboard-api.md b/docs/dashboard-api.md index 21e7253f..531a514e 100644 --- a/docs/dashboard-api.md +++ b/docs/dashboard-api.md @@ -52,6 +52,7 @@ and `Referrer-Policy: no-referrer`. The dashboard has no inline script. | `GET /hub/session` | cookie | `200 {"version": "", "link_unavailable": string\|null}`, or `401` (not paired: show "run `devstack ui`"). `link_unavailable` says why this platform has no `devstack://` link (macOS: "not on macOS yet", ADR-0054): the page keeps it and offers `devstack hub start` instead of its "Start hub" link while the hub is down. Older hubs leave it out | | `GET /hub/stacks` | cookie | `[StackEntry]` | | `GET /hub/summary` | cookie or token | every registered stack at a glance, for the tray ([ADR-0060](adr/0060-the-tray-is-a-client-of-the-hub.md)): `{"state", "version", "stacks": [{id, name, repo, running, state, ready, total, lanes, attention: [{lane, service, text, detail}]}]}`. A stack's `state`: `stopped`, `starting`, `stopping` (a start or stop the hub runs), `ok` or `attention`; the whole `state` is the worst: `attention`, `starting`, `ok` (a stack runs) or `idle`. `ready` of `total`: the main tree's services running, healthy or done. `lanes`: started lanes. `attention`: what notifies (ADR-0045) and is still so, in the main tree and every started lane (`lane` names it): a failed service (not one the user stopped, not one held back by another's failure), a candidate `held` or whose build `failed`; and a failed start from the dashboard (`service` `null`). Containers are not in it. `version`: the hub's devstack | +| `GET /hub/ports` | cookie or token | the ports of every registered stack on this machine and where two stacks meet ([ADR-0082](adr/0082-the-ports-of-a-machine.md)): `{"stacks": [{id, name, running, ports: [PortEntry]}], "overlaps": [{port, proto, stacks: [{id, name, lane, holder?, kind}]}]}`. A running stack's `ports` are its daemon's `GET /v1/ports` (see [Ports](#ports)); a stack whose daemon does not run, or does not answer it (an older devstack), has what its stack file declares for the main working tree, each `kind: "declared"`, `lane: "main"`: its services' `port`, its Compose groups' `announced_ports` and the ports on this machine their `urls` name (other published ports of the Compose files are not read). A stopped stack whose stack file is gone is left out; one whose file does not load has no ports. An overlap is one `port` and `proto` in more than one stack, each use listed once; an `instance` port does not count (the OS hands it out). Computed on request, nothing is polled | | `GET /hub/ws?path=` | cookie + Origin | WebSocket that carries the server-sent event stream at `` (`/hub/events`, `/hub/stacks/{id}/api/v1/...`, or `/hub/pool/shared/{lease}/api/v1/...[?pool=]` for a lane shared with this machine), one text message per event's `data`; how the dashboard reads every stream (a browser allows six HTTP/1.1 connections per host across all its tabs, WebSockets do not count; ADR-0021). The upstream is opened before the upgrade: `503` for a stack not running, `400` for another path. When the stream ends the socket closes | | `GET /hub/events` | cookie | SSE: `{"type":"stacks","stacks":[StackEntry]}` and `{"type":"docker","docker":DockerEngine}` and `{"type":"release","current","newer":string|null,"error":string|null}` (a newer devstack; checked at start, every 12 h by the clock and when a dashboard connects and the last answer is over an hour old, unless `DEVSTACK_NO_UPDATE_CHECK`; `error` why the last check failed, no network or GitHub's rate limit, tried again after an hour and `null` once one succeeds, while a `newer` found before stays; older hubs leave it out) and `{"type":"update","update":{"running":bool,"error":string|null,"installed":string|null,"finished":ms|null}}` (the dashboard's update; `finished` is when the last one ended, `installed` the version it installed, told by the hub it started) and `{"type":"guests","guests":[Guest]}` (lanes of other members on this machine, every pool: `{pool, lease, member, owner, repo, lane, state: "offered"\|"running", since: unix s, cpus, memory}`, the leases whose node is this machine; read when the pool's stream says its leases or nodes changed) and `{"type":"worktrees","worktrees":{"":""}}` (a version of each registered stack's worktrees, running or not, ADR-0078: the hub looks at their folder once a second; a stack's lanes are read again when its version changes; older hubs never send it) and `{"type":"pools","pools":{"":{"live":"connecting"\|"live"\|"lost"\|"old","error"?,"versions":{"members","nodes","leases","upstreams","telemetry"}}}}` (the pools this machine follows over their coordinators' streams, ADR-0073: `old` for a coordinator without one, whose parts are read when the pool area opens; `versions` of each part, read again when one is not the one read last; older hubs never send it), each first and on every change | | `POST /hub/update` | cookie + Origin | `202`: `devstack update` in the background (output in `hub/update.log`); on success it restarts the hub on the new version, so the dashboard reconnects to it, and the new hub's `update` event names the installed version (the old hub leaves `hub/update.json` for it; a hub that starts on the old version while the updater runs waits for it and reports a failure); on failure the `update` event carries the end of its output. `409` while one runs (checked and claimed in one step). Running stacks switch at their next restart | @@ -355,6 +356,52 @@ type UsageReport = { }; ``` +### Ports + +`GET /v1/ports` -> `{"ports": [PortEntry]}`: every port the tree holds or +uses on this machine ([ADR-0082](adr/0082-the-ports-of-a-machine.md)). The +main tree's daemon includes its lanes': a lane here from its supervisor, a +lane on a node by the ports this machine forwards for it. +`/v1/lanes//v1/ports` is that lane's own (of a lane on a node: what +its daemon there holds, on the node). `listening` is what devstack knows, +not a probe. + +```ts +type PortEntry = { + port: number; + proto: "tcp" | "udp"; + lane: string; // "main" for the main working tree, else the lane's name + kind: + | "fixed" // a service's port: the main slot's in main, moved by the offset in a lane + | "instance" // a running instance's port behind the proxy or a dev server's front + | "published" // a container's host port, published by Docker + | "held" // a `main_ports` container's fixed port devstack holds (ADR-0048) + | "node" // a port of a lane on a node, forwarded (TCP) or relayed (UDP) here + | "declared"; // GET /hub/ports only: named by a stopped stack's stack file + holder?: string; // a service, or a container as "."; absent: not known + via: "proxy" | "forward" | "direct" | "docker"; + // devstack's HTTP proxy (ADR-0010); a devstack forward (a dev + // server's front, a held port, the main slot's forward to a + // lane, a lane on a node); the process itself; Docker + target?: number; // where it leads: the instance's port, the lane's port, the + // container's own port, the port on the node + target_lane?: string;// the lane `target` is in when another lane serves the main slot + node?: string; // the node of a lane on a node + listening: boolean; // something listens on it now +}; +``` + +One entry of each kind: + +```json +{"port": 5000, "proto": "tcp", "lane": "main", "kind": "fixed", "holder": "api", "via": "proxy", "target": 49821, "listening": true} +{"port": 4200, "proto": "tcp", "lane": "main", "kind": "fixed", "holder": "web", "via": "forward", "target": 4300, "target_lane": "feat", "listening": true} +{"port": 49821, "proto": "tcp", "lane": "main", "kind": "instance", "holder": "api", "via": "direct", "listening": true} +{"port": 15432, "proto": "tcp", "lane": "main", "kind": "published", "holder": "data.db", "via": "docker", "target": 5432, "listening": true} +{"port": 8081, "proto": "tcp", "lane": "main", "kind": "held", "holder": "app.ui", "via": "forward", "target": 51234, "listening": true} +{"port": 5200, "proto": "tcp", "lane": "review", "kind": "node", "holder": "api", "via": "forward", "target": 5200, "node": "build-server", "listening": true} +``` + ### The store of what `prepare` makes One per repository and machine diff --git a/ui/src/api/mock/data.ts b/ui/src/api/mock/data.ts index 77942701..4130e138 100644 --- a/ui/src/api/mock/data.ts +++ b/ui/src/api/mock/data.ts @@ -229,7 +229,8 @@ export function docsSeed(now: number): MockStackSeed { const repo = 'C:\\work\\docs'; const last = now - 2 * 60 * MIN - 14 * MIN; const services: ServiceView[] = [ - service({ name: 'site', kind: 'devserver', groups: ['docs'], port: 8080, since: last - 40 * MIN }, repo, now), + // 4200, as shop's `web`: the overlap `/hub/ports` shows (ADR-0082). + service({ name: 'site', kind: 'devserver', groups: ['docs'], port: 4200, since: last - 40 * MIN }, repo, now), service({ name: 'search-api', kind: 'service', groups: ['docs'], port: 8090, since: last - 40 * MIN }, repo, now), ]; const infra: InfraView[] = [ diff --git a/ui/src/api/mock/index.ts b/ui/src/api/mock/index.ts index 45c17507..22e74820 100644 --- a/ui/src/api/mock/index.ts +++ b/ui/src/api/mock/index.ts @@ -19,9 +19,28 @@ import { persist } from '@/lib/persist'; import { crmSeed, docsSeed, failedReason, shopSeed, stamp } from './data'; import { Abort, MockStack, sleep, type Listener } from './stack'; import { MockPool } from './pool'; +import { daemonPorts, hubPorts, type MockLane } from './ports'; type Route = { stop(): void } | null; +/** The started lanes of a mock stack, with their offsets and nodes, for its ports (ADR-0082). */ +function portLanes(s: MockStack): MockLane[] { + if (s.view.name !== 'shop') return [...s.startedLanes].map((name, i) => ({ name, port_offset: 100 * (i + 1) })); + const numbered: [string, number][] = [ + ['agent-login-fix', 100], + ['review-cart', 200], + ['1242', 300], + ['1333', 400], + ['bisect', 500], + ]; + return numbered + .filter(([name]) => name === 'agent-login-fix' || s.startedLanes.has(name)) + .map(([name, port_offset]) => { + const placed = laneNodes.get(name) ?? (name === 'review-cart' ? 'build-server' : 'local'); + return placed === 'local' ? { name, port_offset } : { name, port_offset, node: placed }; + }); +} + /** Where a mock lane was moved to (`local` or a node). */ const laneNodes = new Map(); /** Where lanes start without a place named (ADR-0025): `null` here. */ @@ -439,6 +458,7 @@ export class MockTransport implements Transport { if (p === '/hub/session') return hub.unpaired ? json(401, { error: 'not paired' }) : json(200, { version: MOCK_VERSION, link_unavailable: null }); if (hub.unpaired) return json(401, { error: 'not paired' }); if (p === '/hub/stacks' && method === 'GET') return json(200, hub.entries()); + if (p === '/hub/ports' && method === 'GET') return json(200, hubPorts(hub.empty ? [] : hub.stacks, portLanes)); if (p === '/hub/docker' && method === 'GET') return json(200, hub.docker); if (p === '/hub/pool/telemetry') return json(200, { @@ -999,6 +1019,11 @@ export class MockTransport implements Transport { const fw = /^\/v1\/lanes\/([^/]+)(\/v1\/.*)$/.exec(p); const inLaneName = fw && fw[1] !== 'main' ? decodeURIComponent(fw[1]) : null; if (fw) p = fw[2]; + if (p === '/v1/ports' && method === 'GET') { + // The main tree's answer has every lane's; a lane's only its own. + const all = daemonPorts(s, portLanes(s)); + return json(200, { ports: inLaneName ? all.filter((e) => e.lane === inLaneName) : all }); + } const view = () => json(200, s.view); if (p === '/v1/stack' && method === 'GET') return view(); if (p === '/v1/usage' && method === 'GET') { diff --git a/ui/src/api/mock/ports.test.ts b/ui/src/api/mock/ports.test.ts new file mode 100644 index 00000000..a975ac7a --- /dev/null +++ b/ui/src/api/mock/ports.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { docsSeed, shopSeed } from './data'; +import { daemonPorts, hubPorts } from './ports'; +import { MockStack } from './stack'; + +describe('mock ports (ADR-0082)', () => { + const now = Date.now(); + const shop = new MockStack(shopSeed(now)); + const docs = new MockStack(docsSeed(now)); + const lanes = [{ name: 'agent-login-fix', port_offset: 100 }]; + + it('lists the main tree and its lanes, moved by their offset', () => { + const p = daemonPorts(shop, lanes); + expect(p).toContainEqual(expect.objectContaining({ port: 5080, lane: 'main', kind: 'fixed', holder: 'api', via: 'proxy' })); + expect(p).toContainEqual(expect.objectContaining({ port: 5180, lane: 'agent-login-fix', kind: 'fixed', holder: 'api' })); + expect(p).toContainEqual(expect.objectContaining({ port: 15432, kind: 'published', holder: 'data.db', via: 'docker', target: 5432 })); + }); + + it('marks the port a stopped stack declares and a running one holds', () => { + const h = hubPorts([shop, docs], (s) => (s === shop ? lanes : [])); + expect(h.stacks.find((s) => s.name === 'docs')?.ports.every((e) => e.kind === 'declared')).toBe(true); + expect(h.overlaps).toHaveLength(1); + expect(h.overlaps[0]).toMatchObject({ port: 4200, proto: 'tcp' }); + expect(h.overlaps[0].stacks.map((u) => u.name).sort()).toEqual(['docs', 'shop']); + }); +}); diff --git a/ui/src/api/mock/ports.ts b/ui/src/api/mock/ports.ts new file mode 100644 index 00000000..884daa89 --- /dev/null +++ b/ui/src/api/mock/ports.ts @@ -0,0 +1,102 @@ +// The ports of the mock stacks (ADR-0082): `GET /v1/ports` of a daemon and +// `GET /hub/ports`, derived from each stack's view. The docs stack is +// stopped and declares 4200, which shop's `web` holds: one overlap. +import type { HubPorts, PortEntry, PortOverlap, StackPorts } from '../types'; +import type { MockStack } from './stack'; + +/** A started lane of a mock stack: its offset, and its node if it runs on one. */ +export type MockLane = { name: string; port_offset: number; node?: string }; + +function hostPort(shown: string): { port: number; target: number } | null { + // `5432->127.0.0.1:15432` + const [target, addr] = shown.split('->'); + const port = Number(addr?.split(':').pop()); + return Number.isFinite(port) && port > 0 ? { port, target: Number(target) } : null; +} + +/** One tree's entries: `main`, or a lane here moved by its offset. */ +function tree(s: MockStack, lane: string, offset: number): PortEntry[] { + const out: PortEntry[] = []; + const servedBy = lane === 'main' ? s.view.main_served?.lane : undefined; + for (const v of s.view.services) { + if (v.port === null || (lane !== 'main' && v.singleton)) continue; + const port = v.port + offset; + const running = v.status.state === 'healthy' || v.status.state === 'running'; + const toLane = servedBy && !v.singleton ? { target_lane: servedBy } : {}; + if (v.proxy) { + const instance = (v.proxy.upstream ?? 49000) + offset; + out.push({ port, proto: 'tcp', lane, kind: 'fixed', holder: v.name, via: 'proxy', target: instance, ...toLane, listening: true }); + if (!toLane.target_lane) out.push({ port: instance, proto: 'tcp', lane, kind: 'instance', holder: v.name, via: 'direct', listening: running }); + } else if (v.kind === 'devserver') { + const instance = 51000 + port - 4000; + const target = toLane.target_lane ? port + 100 : instance; + out.push({ port, proto: 'tcp', lane, kind: 'fixed', holder: v.name, via: 'forward', target, ...toLane, listening: true }); + if (!toLane.target_lane) out.push({ port: instance, proto: 'tcp', lane, kind: 'instance', holder: v.name, via: 'direct', listening: running }); + } else { + out.push({ port, proto: 'tcp', lane, kind: 'fixed', holder: v.name, via: 'direct', listening: running }); + } + } + for (const g of s.view.infra) { + for (const c of g.containers) { + for (const shown of c.ports) { + const p = hostPort(shown); + if (!p) continue; + out.push({ port: p.port + offset, proto: 'tcp', lane, kind: 'published', holder: `${g.group}.${c.service}`, via: 'docker', target: p.target, listening: c.state === 'running' }); + } + } + } + return out; +} + +/** A lane on a node: its fixed and container ports, forwarded here. */ +function onNode(s: MockStack, l: MockLane): PortEntry[] { + return tree(s, l.name, l.port_offset) + .filter((e) => e.kind === 'fixed' || e.kind === 'published') + .map((e) => ({ port: e.port, proto: e.proto, lane: l.name, kind: 'node', holder: e.holder, via: 'forward', target: e.port, node: l.node, listening: true })); +} + +/** What a stopped stack's stack file declares: its services' ports, a linked container. */ +function declared(s: MockStack): PortEntry[] { + const out: PortEntry[] = s.view.services + .filter((v) => v.port !== null) + .map((v) => ({ port: v.port!, proto: 'tcp', lane: 'main', kind: 'declared', holder: v.name, via: 'direct', listening: false })); + for (const g of s.view.infra) { + for (const c of g.containers) { + const p = c.ports.map(hostPort).find((x) => x !== null); + if (p) out.push({ port: p.port, proto: 'tcp', lane: 'main', kind: 'declared', holder: `${g.group}.${c.service}`, via: 'docker', listening: false }); + } + } + return out; +} + +/** `GET /v1/ports` of the stack's daemon: the main tree's and every started lane's. */ +export function daemonPorts(s: MockStack, lanes: MockLane[]): PortEntry[] { + const out = tree(s, 'main', 0); + for (const l of lanes) out.push(...(l.node ? onNode(s, l) : tree(s, l.name, l.port_offset))); + return out; +} + +export function overlapsOf(stacks: StackPorts[]): PortOverlap[] { + const by = new Map(); + for (const s of stacks) { + // An instance port the OS hands out is no overlap (as the daemon's). + for (const e of s.ports.filter((x) => x.kind !== 'instance')) { + const key = `${e.port}/${e.proto}`; + const o = by.get(key) ?? { port: e.port, proto: e.proto, stacks: [] }; + o.stacks.push({ id: s.id, name: s.name, lane: e.lane, holder: e.holder, kind: e.kind }); + by.set(key, o); + } + } + return [...by.values()].filter((o) => new Set(o.stacks.map((u) => u.id)).size > 1).sort((a, b) => a.port - b.port); +} + +/** `GET /hub/ports`. */ +export function hubPorts(stacks: MockStack[], lanesOf: (s: MockStack) => MockLane[]): HubPorts { + const list: StackPorts[] = stacks.map((s) => ({ + id: s.entry.id, + name: s.entry.name, + running: s.running, + ports: s.running ? daemonPorts(s, lanesOf(s)) : declared(s), + })); + return { stacks: list, overlaps: overlapsOf(list) }; +} diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 8b4a2478..413caec2 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -886,3 +886,48 @@ export type PoolUpstream = { /** `GET /hub/upstreams`. `file`: no keychain on this machine (Linux without a Secret Service). */ export type McpUpstreams = { store: 'keychain' | 'file'; upstreams: McpUpstream[] }; + +/** + * One port a stack holds or uses on this machine (ADR-0082): `GET /v1/ports` + * of a daemon (`{ports: PortEntry[]}`), and the entries of `GET /hub/ports`. + */ +export type PortEntry = { + port: number; + proto: 'tcp' | 'udp'; + /** `main` for the main working tree, else the lane's name. */ + lane: string; + /** + * `fixed`: a service's port (the main slot's in `main`, moved by the offset + * in a lane); `instance`: a running instance's port behind the proxy or a + * front; `published`: a container's host port; `held`: a `main_ports` + * container's fixed port devstack holds (ADR-0048); `node`: a port of a lane + * on a node, forwarded here; `declared`: named by the stack file of a stack + * whose daemon does not run. + */ + kind: 'fixed' | 'instance' | 'published' | 'held' | 'node' | 'declared'; + /** A service, or a container as `.`; absent: not known. */ + holder?: string; + /** Who listens on it: devstack's HTTP proxy, a devstack forward, the process itself, Docker. */ + via: 'proxy' | 'forward' | 'direct' | 'docker'; + /** Where it leads: the instance's port, the lane's port, the container's port, the port on the node. */ + target?: number; + /** The lane `target` belongs to when it is not this entry's (the lane serving the main slot). */ + target_lane?: string; + /** The node of a lane on a node. */ + node?: string; + /** Something listens on it now, as devstack knows it. */ + listening: boolean; +}; + +/** One stack in `GET /hub/ports`: its daemon's entries, or its stack file's (`declared`) while it does not run. */ +export type StackPorts = { id: string; name: string; running: boolean; ports: PortEntry[] }; + +/** A port and protocol more than one stack uses: each stack's use of it. */ +export type PortOverlap = { + port: number; + proto: 'tcp' | 'udp'; + stacks: { id: string; name: string; lane: string; holder?: string; kind: PortEntry['kind'] }[]; +}; + +/** `GET /hub/ports`: the ports of every registered stack on this machine, and their overlaps. */ +export type HubPorts = { stacks: StackPorts[]; overlaps: PortOverlap[] };