From 53dc80559300e53575ce7555f4a9f9ae70e5b909 Mon Sep 17 00:00:00 2001 From: Sebastian Gieseler Date: Fri, 30 Oct 2026 23:53:33 +0100 Subject: [PATCH] feat: the ports of a machine: /v1/ports, /hub/ports, doctor (ADR-0082) The first of three steps of review card C. A daemon reports every port its stack holds on this machine, per lane: fixed ports behind their proxy or forward and the lane they lead to, instance ports, containers' published and held ports, and the forwards of lanes on nodes. The hub joins every registered stack's, a stopped one's as its stack file declares them, and lists where two stacks meet; `devstack doctor` warns of each overlap. The mock hub answers both routes. ADR-0082 records the whole card; the graph and the port shift follow. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 + crates/devstack-compose/src/lib.rs | 3 + crates/devstack-daemon/src/api.rs | 16 + crates/devstack-daemon/src/lanes.rs | 41 + crates/devstack-daemon/src/lib.rs | 1 + crates/devstack-daemon/src/ports.rs | 834 ++++++++++++++++++ crates/devstack-daemon/src/remote.rs | 11 + crates/devstack-daemon/src/state.rs | 4 + .../devstack-daemon/src/supervisor/infra.rs | 68 ++ crates/devstack-daemon/src/supervisor/mod.rs | 1 + .../devstack-daemon/src/supervisor/ports.rs | 111 +++ crates/devstack-daemon/src/tcpfwd.rs | 18 +- crates/devstack-hub/src/lib.rs | 2 + crates/devstack-hub/src/ports.rs | 96 ++ crates/devstack/src/doctor.rs | 81 ++ crates/devstack/tests/daemon_lifecycle.rs | 32 + docs/adr/0082-the-ports-of-a-machine.md | 96 ++ docs/adr/README.md | 1 + docs/dashboard-api.md | 47 + ui/src/api/mock/data.ts | 3 +- ui/src/api/mock/index.ts | 25 + ui/src/api/mock/ports.test.ts | 26 + ui/src/api/mock/ports.ts | 102 +++ ui/src/api/types.ts | 45 + 24 files changed, 1670 insertions(+), 3 deletions(-) create mode 100644 crates/devstack-daemon/src/ports.rs create mode 100644 crates/devstack-daemon/src/supervisor/ports.rs create mode 100644 crates/devstack-hub/src/ports.rs create mode 100644 docs/adr/0082-the-ports-of-a-machine.md create mode 100644 ui/src/api/mock/ports.test.ts create mode 100644 ui/src/api/mock/ports.ts 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[] };