diff --git a/CHANGELOG.md b/CHANGELOG.md index 423e34e1..c58a31f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and listed with their assets on the ## [Unreleased] +### Added + +- **Several stacks on one machine** (ADR-0082 points 3 to 7): a stack can + run on this machine with a port shift, `devstack up --shift auto` (the + lowest free multiple of 1000), `--shift ` or `--shift none`. Its + services, its containers' host ports, its announced ports, its `urls` + and its lanes move by it; its Compose project, volumes and data stay. + The shift is this machine's, in the stack's registry entry, never in + the stack file, and it sticks until `--shift none`. `up` starts nothing + when a running stack holds its ports, names that stack and offers the + shift; a stopped stack's ports only warn. Every daemon writes the ports + it claims (the main slot after the shift, each lane's range) into the + registry, and a lane's number avoids the other stacks' claims. The MCP + `up` tool takes `shift`; `POST /hub/stacks/{id}/up` takes `{shift}`; the + stack page shows "shifted +1000" and, for a stack that collides, a + *Start with shift* button. + ## [0.33.0] - 2026-10-31 A stack has a page of its own, above its lanes: its daemon, who serves the diff --git a/crates/devstack-compose/src/lib.rs b/crates/devstack-compose/src/lib.rs index 331d4d31..a59ccb7c 100644 --- a/crates/devstack-compose/src/lib.rs +++ b/crates/devstack-compose/src/lib.rs @@ -968,28 +968,38 @@ pub struct HeldPort { pub host_ip: Option, } -/// The main tree's overlay for a group's `main_ports` (ADR-0048): each TCP -/// port of those services that the Compose files publish on a fixed host -/// port is published on a loopback port Docker assigns instead, which -/// devstack forwards the fixed one to. Their UDP ports, port ranges, ports -/// Docker assigns and `announced` ports (ADR-0057: a client learns those -/// from the server it signalled with) stay as they are. `None` when -/// nothing is held. +/// The main tree's overlay (ADR-0048, ADR-0082): for a group's +/// `main_ports`, each TCP port of those services that the Compose files +/// publish on a fixed host port is published on a loopback port Docker +/// assigns instead, which devstack forwards the fixed one to. Their UDP +/// ports, port ranges, ports Docker assigns and `announced` ports (ADR-0057: +/// a client learns those from the server it signalled with) stay as they +/// are. With a `shift` (the stack runs shifted on this machine), every +/// published host port of every service moves by it, a held one too, and +/// an announced one inside the container as well, as a lane's do +/// ([`lane_overlay`]), but the project, the container names, the networks +/// and the volumes stay the main tree's. `None` when nothing is held and +/// nothing moves. pub fn main_overlay( model: &Model, services: &[String], announced: &[AnnouncedPort], + shift: u16, ) -> std::result::Result)>, String> { let mut o = Overlay::default(); let mut held = Vec::new(); - for name in services { - let Some(svc) = model.services.get(name) else { - return Err(format!( - "`main_ports` names `{name}`, which the Compose files do not have" - )); - }; + if let Some(name) = services.iter().find(|n| !model.services.contains_key(*n)) { + return Err(format!( + "`main_ports` names `{name}`, which the Compose files do not have" + )); + } + for (name, svc) in &model.services { + let holds = services.contains(name); + if svc.ports.is_empty() || (!holds && shift == 0) { + continue; + } let mut body = vec![" ports: !override".to_string()]; - let mut moved = false; + let mut moved = shift > 0; for port in &svc.ports { let tcp = port.protocol.as_deref().is_none_or(|p| p == "tcp"); let fixed = port @@ -997,16 +1007,19 @@ pub fn main_overlay( .as_deref() .and_then(|p| p.trim().parse::().ok()); let spec = match fixed { - Some(fixed) if tcp && !is_announced(announced, name, port) => { + Some(fixed) if holds && tcp && !is_announced(announced, name, port) => { moved = true; held.push(HeldPort { service: name.clone(), target: port.target, - fixed, + fixed: fixed + .checked_add(shift) + .ok_or_else(|| format!("port {fixed} + {shift} is beyond 65535"))?, host_ip: port.host_ip.clone().filter(|ip| !ip.is_empty()), }); format!("127.0.0.1::{}", port.target) } + _ if shift > 0 => moved_spec(name, svc, port, shift, announced, "on this machine")?, _ => port_spec(port), }; body.push(format!(" - {}", quote(&spec))); @@ -1015,14 +1028,94 @@ pub fn main_overlay( o.services.insert(name.clone(), body); } } - if held.is_empty() { + if o.services.is_empty() { return Ok(None); } - let text = o.render_with( + let header = if shift > 0 { + format!( + "# Written by devstack for the main working tree, shifted by {shift} on this\n# machine (ADR-0082){}. Do not edit; it is written again at every start.\n", + if held.is_empty() { + "" + } else { + ", and the fixed ports devstack holds (ADR-0048)" + } + ) + } else { "# Written by devstack for the main working tree (ADR-0048): devstack holds\n# these services' fixed ports. Do not edit; it is written again at every start.\n" - .to_string(), - ); - Ok(Some((text, held))) + .to_string() + }; + Ok(Some((o.render_with(header), held))) +} + +/// A published port of `service` (`svc`) moved by `by` (ADR-0030, +/// ADR-0082), in Compose's short syntax: its host port, a range moved as a +/// whole; an `announced` one (ADR-0057) 1:1 and moved on both sides, so the +/// server listens on, and announces, the moved number, which nothing else +/// of it may have inside (`where_` says where it moves, for that error). A +/// port Docker assigns stays so. +fn moved_spec( + name: &str, + svc: &ServiceModel, + port: &PortModel, + by: u16, + announced: &[AnnouncedPort], + where_: &str, +) -> std::result::Result { + let shift = |p: &str| -> std::result::Result { + let one = |n: &str| -> std::result::Result { + let n: u16 = n + .trim() + .parse() + .map_err(|_| format!("port `{p}` is not a number"))?; + n.checked_add(by) + .map(|m| m.to_string()) + .ok_or_else(|| format!("port {n} + {by} is beyond 65535")) + }; + match p.split_once('-') { + Some((a, b)) => Ok(format!("{}-{}", one(a)?, one(b)?)), + None => one(p), + } + }; + let mut spec = String::new(); + let mut target = port.target; + if is_announced(announced, name, port) { + let moved = port + .target + .checked_add(by) + .ok_or_else(|| format!("port {} + {by} is beyond 65535", port.target))?; + let proto = |p: &PortModel| p.protocol.as_deref().unwrap_or("tcp").to_ascii_lowercase(); + if by != 0 + && let Some(other) = svc + .ports + .iter() + .find(|p| p.target == moved && proto(p) == proto(port)) + { + return Err(format!( + "`{name}` announces {}, which moves to {moved} {where_}, and has \"{}\" inside already", + port.target, + port_spec(other) + )); + } + if let Some(ip) = port.host_ip.as_deref().filter(|ip| !ip.is_empty()) { + spec.push_str(ip); + spec.push(':'); + } + spec.push_str(&format!("{moved}:")); + target = moved; + } else if let Some(published) = &port.published { + if let Some(ip) = port.host_ip.as_deref().filter(|ip| !ip.is_empty()) { + spec.push_str(ip); + spec.push(':'); + } + spec.push_str(&shift(published)?); + spec.push(':'); + } + spec.push_str(&target.to_string()); + if let Some(proto) = port.protocol.as_deref().filter(|p| *p != "tcp") { + spec.push('/'); + spec.push_str(proto); + } + Ok(spec) } /// A published port in Compose's short syntax, as the files have it. @@ -1189,21 +1282,6 @@ fn overlay( announced: &[AnnouncedPort], ) -> std::result::Result { let by = offset.unwrap_or(0); - let shift = |p: &str| -> std::result::Result { - let one = |n: &str| -> std::result::Result { - let n: u16 = n - .trim() - .parse() - .map_err(|_| format!("port `{p}` is not a number"))?; - n.checked_add(by) - .map(|m| m.to_string()) - .ok_or_else(|| format!("port {n} + {by} is beyond 65535")) - }; - match p.split_once('-') { - Some((a, b)) => Ok(format!("{}-{}", one(a)?, one(b)?)), - None => one(p), - } - }; let mut o = Overlay::default(); for (name, svc) in model.services.iter().filter(|(n, _)| !keep.contains(n)) { let mut body = Vec::new(); @@ -1212,48 +1290,7 @@ fn overlay( } else if !svc.ports.is_empty() { body.push(" ports: !override".to_string()); for port in &svc.ports { - let mut spec = String::new(); - let mut target = port.target; - if is_announced(announced, name, port) { - // 1:1, moved on both sides (ADR-0057): the server listens - // on the lane's number, which nothing else of it may have. - let moved = port - .target - .checked_add(by) - .ok_or_else(|| format!("port {} + {by} is beyond 65535", port.target))?; - let proto = - |p: &PortModel| p.protocol.as_deref().unwrap_or("tcp").to_ascii_lowercase(); - if by != 0 - && let Some(other) = svc - .ports - .iter() - .find(|p| p.target == moved && proto(p) == proto(port)) - { - return Err(format!( - "`{name}` announces {}, which moves to {moved} in this lane, and has \"{}\" inside already", - port.target, - port_spec(other) - )); - } - if let Some(ip) = port.host_ip.as_deref().filter(|ip| !ip.is_empty()) { - spec.push_str(ip); - spec.push(':'); - } - spec.push_str(&format!("{moved}:")); - target = moved; - } else if let Some(published) = &port.published { - if let Some(ip) = port.host_ip.as_deref().filter(|ip| !ip.is_empty()) { - spec.push_str(ip); - spec.push(':'); - } - spec.push_str(&shift(published)?); - spec.push(':'); - } - spec.push_str(&target.to_string()); - if let Some(proto) = port.protocol.as_deref().filter(|p| *p != "tcp") { - spec.push('/'); - spec.push_str(proto); - } + let spec = moved_spec(name, svc, port, by, announced, "in this lane")?; body.push(format!(" - {}", quote(&spec))); } } @@ -1718,7 +1755,7 @@ mod tests { }"#, ) .unwrap(); - let (y, held) = main_overlay(&model, &["web".to_string(), "worker".to_string()], &[]) + let (y, held) = main_overlay(&model, &["web".to_string(), "worker".to_string()], &[], 0) .unwrap() .unwrap(); assert!( @@ -1746,10 +1783,10 @@ mod tests { ); // Nothing to hold: no overlay. assert_eq!( - main_overlay(&model, &["worker".to_string()], &[]).unwrap(), + main_overlay(&model, &["worker".to_string()], &[], 0).unwrap(), None ); - assert!(main_overlay(&model, &["nope".to_string()], &[]).is_err()); + assert!(main_overlay(&model, &["nope".to_string()], &[], 0).is_err()); } #[test] @@ -1862,6 +1899,7 @@ mod tests { &model, &["media".to_string()], &announced(&["media:7881", "media:7882/udp"]), + 0, ) .unwrap() .unwrap(); @@ -1879,11 +1917,88 @@ mod tests { ) .unwrap(); assert_eq!( - main_overlay(&only, &["media".to_string()], &announced(&["media:7881"])).unwrap(), + main_overlay( + &only, + &["media".to_string()], + &announced(&["media:7881"]), + 0 + ) + .unwrap(), None ); } + #[test] + fn a_shifted_main_tree_moves_host_and_announced_ports_and_keeps_its_names() { + let model = parse_config( + r#"{ + "services": { + "db": {"container_name": "shop-db", + "ports": [{"target": 5432, "published": "5432", "host_ip": "127.0.0.1"}]}, + "web": {"ports": [{"target": 80, "published": "8290"}, + {"target": 9000}, + {"target": 53, "published": "5353", "protocol": "udp"}, + {"target": 7000, "published": "7000-7002"}]}, + "media": {"ports": [{"target": 7881, "published": "7881"}]}, + "worker": {} + }, + "networks": {"edge": {"name": "edge"}}, + "volumes": {"data": {"name": "shop-data"}} + }"#, + ) + .unwrap(); + let (y, held) = main_overlay( + &model, + &["web".to_string()], + &announced(&["media:7881"]), + 1000, + ) + .unwrap() + .unwrap(); + // Host ports move; the container's own stay, but an announced one. + assert!( + y.contains("\"db\":\n ports: !override\n - \"127.0.0.1:6432:5432\""), + "{y}" + ); + assert!( + y.contains("\"media\":\n ports: !override\n - \"8881:8881\""), + "{y}" + ); + // A held port is held on its shifted number; the rest moves. + assert!( + y.contains("- \"127.0.0.1::80\"\n - \"9000\"\n - \"6353:53/udp\"\n - \"8000-8002:7000\""), + "{y}" + ); + assert_eq!( + held, + vec![HeldPort { + service: "web".into(), + target: 80, + fixed: 9290, + host_ip: None + }] + ); + // The main tree's names stay: no rename, no container name reset. + assert!(!y.contains("container_name") && !y.contains("name:"), "{y}"); + assert!(!y.contains("worker") && !y.contains("networks"), "{y}"); + assert!(y.contains("shifted by 1000"), "{y}"); + // Without `main_ports`, a shift alone writes it as well. + let (y, held) = main_overlay(&model, &[], &[], 2000).unwrap().unwrap(); + assert!(held.is_empty()); + assert!(y.contains("- \"10290:80\""), "{y}"); + // An announced port that would move onto another port inside it. + let clash = parse_config( + r#"{"services":{"media":{"ports":[{"target":7881,"published":"7881"},{"target":8881}]}}}"#, + ) + .unwrap(); + let e = main_overlay(&clash, &[], &announced(&["media:7881"]), 1000).unwrap_err(); + assert!(e.contains("on this machine"), "{e}"); + assert!( + main_overlay(&model, &[], &[], 60000).is_err(), + "beyond 65535" + ); + } + #[test] fn an_announced_port_is_published_one_to_one_on_a_fixed_port() { let model = parse_config( diff --git a/crates/devstack-daemon/src/lanes.rs b/crates/devstack-daemon/src/lanes.rs index 264bdceb..7c356273 100644 --- a/crates/devstack-daemon/src/lanes.rs +++ b/crates/devstack-daemon/src/lanes.rs @@ -280,6 +280,33 @@ fn lane_offset(number: u16, step: u16) -> Option { number.checked_mul(step) } +/// The first lane number not in `numbers` whose range (`base` moved by +/// number x `step`) meets none of `base`, the ranges of `numbers`, the +/// `foreign` claims of other stacks (ADR-0082) and no port `in_use` says. +fn free_number_in( + base: &[u16], + numbers: &[u16], + step: u16, + foreign: &BTreeSet, + in_use: impl Fn(u16) -> bool, +) -> Option { + let mut taken: BTreeSet = base.iter().copied().collect(); + taken.extend(foreign); + for n in numbers { + if let Some(off) = lane_offset(*n, step) { + taken.extend(base.iter().filter_map(|p| p.checked_add(off))); + } + } + (1..200u16).filter(|n| !numbers.contains(n)).find(|n| { + lane_offset(*n, step).is_some_and(|off| { + base.iter().all(|p| { + p.checked_add(off) + .is_some_and(|q| !taken.contains(&q) && !in_use(q)) + }) + }) + }) +} + /// Whom each lane is shared with, from `.devstack/lane-shares.json`. The /// separate viewers devstack 0.11 kept (`lane-views.json`, ADR-0040 before /// its amendment of 2026-10-20) could use the lane as well: they are added @@ -1463,29 +1490,101 @@ impl Lanes { } /// The first lane number whose port range collides with nothing: the main - /// lane, another lane and, with `probe`, a port in use on this machine. + /// lane, another lane, what the other stacks registered on this machine + /// claim (ADR-0082 point 5) and, with `probe`, a port in use on this + /// machine. fn free_number(&self, known: &BTreeMap, probe: bool) -> Option { - let step = self.main.stack.lanes.port_step; - // A lane's containers publish the main tree's ports moved by the same - // step (ADR-0030): those must be free as well. + let numbers: Vec = known.values().map(|k| k.entry.number).collect(); + let foreign = crate::registry::foreign_claims(&self.own_id()); + free_number_in( + &self.lane_base(), + &numbers, + self.main.stack.lanes.port_step, + &foreign, + |q| probe && crate::supervisor::port_in_use(q), + ) + } + + /// This stack's id in the registry of stacks. + fn own_id(&self) -> String { + crate::registry::id_for(self.main.stack_file()) + } + + /// The main tree's ports a lane moves by its offset: its services' + /// fixed ports and its containers' host ports, which a lane's + /// containers publish moved by the same step (ADR-0030). + fn lane_base(&self) -> Vec { let mut base = ports(&self.main.stack); base.extend(self.main.infra_host_ports()); - let used: BTreeSet = known.values().map(|k| k.entry.number).collect(); - let mut taken: BTreeSet = base.iter().copied().collect(); - for k in known.values() { - if let Some(off) = lane_offset(k.entry.number, step) { - taken.extend(base.iter().filter_map(|p| p.checked_add(off))); - } + base + } + + /// What a lane numbered `number` moves its stack file's ports by: its + /// offset and the main tree's shift on this machine (ADR-0082), as its + /// own stack file, its containers and a node running it start from the + /// files' ports. The main tree's ports are shifted already. + fn files_offset(&self, number: u16) -> Option { + lane_offset(number, self.main.stack.lanes.port_step)?.checked_add(self.main.shift()) + } + + /// What the stack claims on this machine (ADR-0082 point 5): the main + /// slot's ports after the shift (its services', its containers' host + /// ports, its announced ports and the ports its `urls` name), and the + /// range of each lane it numbered, started or not. + pub fn claims(&self) -> crate::registry::Claims { + let mut main: BTreeSet = self.lane_base().into_iter().collect(); + let shift = self.main.shift(); + for g in self.main.stack.compose.values() { + main.extend( + g.announced() + .iter() + .filter_map(|a| a.port.checked_add(shift)), + ); + // `urls` are moved already. + main.extend(g.urls.values().filter_map(|u| url_port(u).map(|(_, p)| p))); } - (1..200u16).filter(|n| !used.contains(n)).find(|n| { - lane_offset(*n, step).is_some_and(|off| { - base.iter().all(|p| { - p.checked_add(off).is_some_and(|q| { - !taken.contains(&q) && !(probe && crate::supervisor::port_in_use(q)) - }) - }) + let step = self.main.stack.lanes.port_step; + let lanes = self + .known + .lock() + .unwrap() + .iter() + .filter_map(|(name, k)| { + let off = lane_offset(k.entry.number, step)?; + let range: BTreeSet = main.iter().filter_map(|p| p.checked_add(off)).collect(); + Some((name.clone(), range.into_iter().collect())) }) - }) + .collect(); + crate::registry::Claims { + main: main.into_iter().collect(), + lanes, + } + } + + /// Write what the stack claims into its registry entry `id` now, and + /// again whenever it changes (a lane numbered, a container published) + /// while the daemon runs. Kept when the daemon stops: whoever was first + /// keeps its ports. Not while it stops, when its containers go. + pub fn follow_claims(self: &Arc, id: String) { + let _ = crate::registry::set_claims(&id, &self.claims()); + let weak = Arc::downgrade(self); + let _ = std::thread::Builder::new() + .name("claims".into()) + .spawn(move || { + loop { + std::thread::sleep(std::time::Duration::from_secs(5)); + let Some(me) = weak.upgrade() else { return }; + if me.closing.load(std::sync::atomic::Ordering::SeqCst) { + return; + } + if let Err(e) = crate::registry::set_claims(&id, &me.claims()) { + crate::diag::warn( + "claims_not_written", + serde_json::json!({ "error": e.to_string() }), + ); + } + } + }); } /// Every port the stack holds on this machine (ADR-0082): the main @@ -1948,8 +2047,10 @@ impl Lanes { })?, None => stack, }; - // The main stack's step: the free ranges were chosen with it. - let offset = lane_offset(entry.number, self.main.stack.lanes.port_step) + // The main stack's step: the free ranges were chosen with it. Moved + // from the files' ports: the main tree's shift too (ADR-0082). + let offset = self + .files_offset(entry.number) .ok_or_else(|| format!("lane number {} is beyond the port range", entry.number))?; let stack = lane_stack(&stack, offset)?; let state = self.state_dir(&entry.name); @@ -1968,6 +2069,7 @@ impl Lanes { // build server along that the main tree's build uses. Some(self.main.build_job()), )?; + sup.set_shift(self.main.shift()); sup.refresh_infra(); let usage = crate::usage::Sampler::start(sup.clone()); let router = api::router(AppState { @@ -3476,6 +3578,10 @@ impl Lanes { let worktree = devstack_proc::long_path(&entry.path); let offset = lane_offset(entry.number, self.main.stack.lanes.port_step) .ok_or_else(|| format!("lane `{}`: its ports are beyond 65535", entry.name))?; + // The node starts from the files' ports: the shift too (ADR-0082). + let node_offset = self + .files_offset(entry.number) + .ok_or_else(|| format!("lane `{}`: its ports are beyond 65535", entry.name))?; f(crate::remote::RemoteSpec { name: &entry.name, repo: &self.main.stack.stack.name, @@ -3495,7 +3601,7 @@ impl Lanes { .filter_map(|p| p.checked_add(offset)) .map(|p| (p, p)) .collect(), - port_offset: offset, + port_offset: node_offset, forward, step, profile: profile.as_deref(), @@ -3922,6 +4028,29 @@ port = 8080 assert!(!w[2].prunable && w[3].prunable, "{w:?}"); } + #[test] + fn a_lane_number_avoids_what_other_stacks_claim() { + let none = BTreeSet::new(); + let free = |_| false; + assert_eq!( + free_number_in(&[5000, 5432], &[], 100, &none, free), + Some(1) + ); + // Lane 1 is taken; another stack claims lane 2's 5200 and 3's 5732. + let foreign = BTreeSet::from([5200, 5732]); + assert_eq!( + free_number_in(&[5000, 5432], &[1], 100, &foreign, free), + Some(4) + ); + // And what the OS says is in use. + assert_eq!( + free_number_in(&[5000, 5432], &[1], 100, &foreign, |p| p == 5400), + Some(5) + ); + // Nothing fits below 65536. + assert_eq!(free_number_in(&[65000], &[], 1000, &none, free), None); + } + #[test] fn lane_offsets_never_wrap() { assert_eq!(lane_offset(3, 100), Some(300)); diff --git a/crates/devstack-daemon/src/lib.rs b/crates/devstack-daemon/src/lib.rs index acfc311c..18871349 100644 --- a/crates/devstack-daemon/src/lib.rs +++ b/crates/devstack-daemon/src/lib.rs @@ -260,12 +260,27 @@ pub fn serve(stack_file: &Path) -> Result<(), String> { // A lane on a node runs on the ports it has on its developer's machine // (`DEVSTACK_PORT_OFFSET`): links, `{service.x.port}` and the forwarded // ports there are the same (ADR-0029 point 4). - let stack = match std::env::var("DEVSTACK_PORT_OFFSET") + let node_offset = std::env::var("DEVSTACK_PORT_OFFSET") .ok() .and_then(|v| v.trim().parse::().ok()) - .filter(|o| *o > 0) - { - Some(offset) => lanes::lane_stack(&stack, offset)?, + .filter(|o| *o > 0); + // The main tree on this machine runs moved by the shift its registry + // entry holds (`devstack up --shift`, ADR-0082), the same way. + let id = registry::id_for(&abs_file); + let shift = match node_offset { + None if !remote_lane() => registry::read(&id).map_or(0, |e| e.shift), + _ => 0, + }; + let stack = match node_offset.or((shift > 0).then_some(shift)) { + Some(offset) => match lanes::lane_stack(&stack, offset) { + Ok(s) => s, + Err(e) => { + let e = + format!("the shift {offset}: {e}; `devstack up --shift none` or another one"); + diag::warn("daemon_start_failed", serde_json::json!({ "error": e })); + return Err(e); + } + }, None => stack, }; telemetry::set_repository(&stack.stack.name); @@ -273,10 +288,11 @@ pub fn serve(stack_file: &Path) -> Result<(), String> { let token = token::load_or_create().map_err(|e| format!("token: {e}"))?; // Known to the hub from now on (ADR-0021); a failure costs only the listing. let _ = registry::register(®istry::Entry { - id: registry::id_for(&abs_file), + id: id.clone(), name: stack.stack.name.clone(), file: abs_file.clone(), repo: repo.clone(), + ..registry::Entry::default() }); let exe = std::env::current_exe().map_err(|e| e.to_string())?; let proc_opts = devstack_proc::Options { @@ -292,6 +308,10 @@ pub fn serve(stack_file: &Path) -> Result<(), String> { proc_opts.clone(), rt.handle().clone(), )?; + sup.set_shift(shift); + if shift > 0 { + diag::info("stack_shifted", serde_json::json!({ "shift": shift })); + } sup.refresh_infra(); // A lane on a node runs its background workers as its requester asked // (`--workers`, `DEVSTACK_LANE_WORKERS`), or as its profile says, as a @@ -352,6 +372,11 @@ pub fn serve(stack_file: &Path) -> Result<(), String> { token.clone(), port, ); + // What it claims on this machine (ADR-0082 point 5), now and + // whenever that changes. + if !remote_lane() { + lanes.follow_claims(id.clone()); + } // What the pool's collector gets every minute, if this machine // agreed (ADR-0037): lanes and services running. { diff --git a/crates/devstack-daemon/src/ports.rs b/crates/devstack-daemon/src/ports.rs index 016cbe0c..5b5b6534 100644 --- a/crates/devstack-daemon/src/ports.rs +++ b/crates/devstack-daemon/src/ports.rs @@ -350,6 +350,19 @@ pub fn declared(stack: &devstack_config::Stack) -> Vec { out } +/// `entries` (what a stack file declares) as a stack shifted by `shift` on +/// this machine has them (ADR-0082): every one moves; one beyond 65535 is +/// left out (such a stack does not start). +pub fn shifted(entries: Vec, shift: u16) -> Vec { + entries + .into_iter() + .filter_map(|mut e| { + e.port = e.port.checked_add(shift)?; + Some(e) + }) + .collect() +} + /// The port of a link on this machine (`http://localhost:8025/ui`). fn local_port(url: &str) -> Option { let (_, rest) = url.split_once("://")?; diff --git a/crates/devstack-daemon/src/registry.rs b/crates/devstack-daemon/src/registry.rs index 3c1663ea..755aa7b1 100644 --- a/crates/devstack-daemon/src/registry.rs +++ b/crates/devstack-daemon/src/registry.rs @@ -1,17 +1,77 @@ //! The registry of stacks the hub knows (ADR-0021): every daemon registers its //! stack when it starts, one file per stack next to the user's token, so //! daemons starting at the same time never write the same file. +//! +//! An entry also holds what is this machine's own of the stack (ADR-0082): +//! its port shift (`shift`, set by `devstack up --shift`) and the ports its +//! daemon claims (`claims`, written while it runs and kept after it stops). +//! Every write keeps what it does not set, fields of a newer devstack too. use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct Entry { pub id: String, pub name: String, /// Absolute path of the stack file. pub file: PathBuf, pub repo: PathBuf, + /// What the main tree's ports are moved by on this machine (ADR-0082 + /// point 4): a multiple of [`SHIFT_STEP`]; 0, or absent, runs it on the + /// ports its stack file gives. + #[serde(default, skip_serializing_if = "is_zero")] + pub shift: u16, + /// The ports its daemon claims (ADR-0082 point 5). + #[serde(default, skip_serializing_if = "Claims::is_empty")] + pub claims: Claims, +} + +fn is_zero(n: &u16) -> bool { + *n == 0 +} + +/// What a shift is a multiple of. +pub const SHIFT_STEP: u16 = 1000; + +/// The ports a stack claims on this machine: its main slot's after the +/// shift (services, held and published container ports, announced ports, +/// `urls`), and the range of each lane it numbered. A stopped stack keeps +/// them: whoever was first keeps its ports. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct Claims { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub main: Vec, + /// By lane name. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub lanes: BTreeMap>, +} + +impl Claims { + pub fn is_empty(&self) -> bool { + self.main.is_empty() && self.lanes.is_empty() + } + + /// Every port claimed, the lanes' too. + pub fn ports(&self) -> BTreeSet { + self.main + .iter() + .chain(self.lanes.values().flatten()) + .copied() + .collect() + } + + /// Who claims `port`: `main` or the lane's name. + pub fn holder(&self, port: u16) -> Option<&str> { + if self.main.contains(&port) { + return Some("main"); + } + self.lanes + .iter() + .find(|(_, ps)| ps.contains(&port)) + .map(|(l, _)| l.as_str()) + } } pub fn dir() -> PathBuf { @@ -31,19 +91,111 @@ pub fn id_for(file: &Path) -> String { blake3::hash(s.as_bytes()).to_hex()[..12].to_string() } -pub fn register(entry: &Entry) -> std::io::Result<()> { - let d = dir(); - std::fs::create_dir_all(&d)?; - let tmp = d.join(format!("{}.json.tmp", entry.id)); +fn path_of(d: &Path, id: &str) -> PathBuf { + d.join(format!("{id}.json")) +} + +/// The entry file as it is, as JSON: what a write keeps. +fn read_raw(d: &Path, id: &str) -> Option> { + match serde_json::from_slice(&std::fs::read(path_of(d, id)).ok()?).ok()? { + serde_json::Value::Object(m) => Some(m), + _ => None, + } +} + +/// Change the entry `id` in `d` by `f`, keeping every field `f` does not +/// touch. Written to a file of this process first, then renamed over the +/// entry: a reader never sees half of it, and two writers never share the +/// temporary file. +fn change( + d: &Path, + id: &str, + f: impl FnOnce(&mut serde_json::Map), +) -> std::io::Result<()> { + if !id.chars().all(|c| c.is_ascii_hexdigit()) || id.is_empty() { + return Err(std::io::Error::other(format!("not a stack id: {id}"))); + } + std::fs::create_dir_all(d)?; + let mut m = read_raw(d, id).unwrap_or_default(); + f(&mut m); + let tmp = d.join(format!("{id}.json.{}.tmp", std::process::id())); std::fs::write( &tmp, - serde_json::to_vec_pretty(entry).expect("entry serialises"), + serde_json::to_vec_pretty(&serde_json::Value::Object(m)).expect("entry serialises"), )?; - std::fs::rename(tmp, d.join(format!("{}.json", entry.id))) + std::fs::rename(&tmp, path_of(d, id)).inspect_err(|_| { + let _ = std::fs::remove_file(&tmp); + }) +} + +/// Register the stack: its id, name, stack file and repository. Its shift +/// and claims, and whatever else the entry holds, stay. +pub fn register(entry: &Entry) -> std::io::Result<()> { + register_in(&dir(), entry) +} + +fn register_in(d: &Path, entry: &Entry) -> std::io::Result<()> { + let v = serde_json::to_value(entry).expect("entry serialises"); + change(d, &entry.id, |m| { + for k in ["id", "name", "file", "repo"] { + m.insert(k.into(), v[k].clone()); + } + }) +} + +/// Set the shift of the stack `entry` (registered with it if it is not yet). +pub fn set_shift(entry: &Entry, shift: u16) -> std::io::Result<()> { + set_shift_in(&dir(), entry, shift) +} + +fn set_shift_in(d: &Path, entry: &Entry, shift: u16) -> std::io::Result<()> { + let v = serde_json::to_value(entry).expect("entry serialises"); + change(d, &entry.id, |m| { + for k in ["id", "name", "file", "repo"] { + m.entry(k).or_insert_with(|| v[k].clone()); + } + if shift == 0 { + m.remove("shift"); + } else { + m.insert("shift".into(), shift.into()); + } + }) +} + +/// Write what the stack `id` claims; nothing when it claims that already. +pub fn set_claims(id: &str, claims: &Claims) -> std::io::Result<()> { + set_claims_in(&dir(), id, claims) +} + +fn set_claims_in(d: &Path, id: &str, claims: &Claims) -> std::io::Result<()> { + if read_in(d, id).is_some_and(|e| &e.claims == claims) { + return Ok(()); + } + let v = serde_json::to_value(claims).expect("claims serialise"); + change(d, id, |m| { + if claims.is_empty() { + m.remove("claims"); + } else { + m.insert("claims".into(), v); + } + }) +} + +/// The entry of the stack `id`, if it is registered. +pub fn read(id: &str) -> Option { + read_in(&dir(), id) +} + +fn read_in(d: &Path, id: &str) -> Option { + serde_json::from_slice(&std::fs::read(path_of(d, id)).ok()?).ok() } pub fn list() -> Vec { - let mut out: Vec = std::fs::read_dir(dir()) + list_in(&dir()) +} + +fn list_in(d: &Path) -> Vec { + let mut out: Vec = std::fs::read_dir(d) .into_iter() .flatten() .flatten() @@ -64,8 +216,41 @@ pub fn forget(id: &str) -> std::io::Result<()> { } } +/// Another registered stack, as an allocation on this machine sees it. +#[derive(Debug, Clone)] +pub struct Other { + pub entry: Entry, + /// Its daemon runs. + pub running: bool, +} + +/// Every registered stack but `own_id` that still has its folder and its +/// stack file (one whose files are gone claims nothing), with whether its +/// daemon runs. +pub fn others(own_id: &str) -> Vec { + list() + .into_iter() + .filter(|e| e.id != own_id && e.repo.is_dir() && e.file.is_file()) + .map(|entry| { + let running = crate::read_info(&entry.repo.join(".devstack")) + .is_some_and(|i| !i.stopping && devstack_proc::pid_alive(i.pid)); + Other { entry, running } + }) + .collect() +} + +/// Every port the stacks other than `own_id` claim. +pub fn foreign_claims(own_id: &str) -> BTreeSet { + others(own_id) + .iter() + .flat_map(|o| o.entry.claims.ports()) + .collect() +} + #[cfg(test)] mod tests { + use super::*; + #[test] fn ids_are_stable_and_case_insensitive_on_windows() { let a = super::id_for(std::path::Path::new("C:\\Work\\Shop\\devstack.toml")); @@ -81,4 +266,75 @@ mod tests { ); } } + + fn entry(id: &str) -> Entry { + Entry { + id: id.into(), + name: "shop".into(), + file: PathBuf::from("/work/shop/devstack.toml"), + repo: PathBuf::from("/work/shop"), + ..Entry::default() + } + } + + #[test] + fn an_entry_of_an_older_devstack_reads_as_unshifted_without_claims() { + let old = r#"{"id":"abc","name":"shop","file":"/w/devstack.toml","repo":"/w"}"#; + let e: Entry = serde_json::from_str(old).unwrap(); + assert_eq!(e.shift, 0); + assert!(e.claims.is_empty()); + // Written back, it has no new fields an older devstack would see. + let text = serde_json::to_string(&e).unwrap(); + assert!( + !text.contains("shift") && !text.contains("claims"), + "{text}" + ); + } + + #[test] + fn shift_and_claims_round_trip_and_survive_a_registration() { + let d = tempfile::tempdir().unwrap(); + let d = d.path(); + register_in(d, &entry("abc")).unwrap(); + assert_eq!(read_in(d, "abc").unwrap(), entry("abc")); + set_shift_in(d, &entry("abc"), 1000).unwrap(); + let claims = Claims { + main: vec![6000, 6200], + lanes: BTreeMap::from([("fix".to_string(), vec![6100, 6300])]), + }; + set_claims_in(d, "abc", &claims).unwrap(); + // A field of a newer devstack, written by hand here. + let path = path_of(d, "abc"); + let mut v: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + v["future"] = "kept".into(); + std::fs::write(&path, serde_json::to_vec(&v).unwrap()).unwrap(); + // The daemon registers again at its start: nothing else goes. + let mut renamed = entry("abc"); + renamed.name = "shop-renamed".into(); + register_in(d, &renamed).unwrap(); + let e = read_in(d, "abc").unwrap(); + assert_eq!(e.name, "shop-renamed"); + assert_eq!(e.shift, 1000); + assert_eq!(e.claims, claims); + assert_eq!(e.claims.holder(6300), Some("fix")); + assert_eq!(e.claims.holder(6000), Some("main")); + assert_eq!(e.claims.ports().len(), 4); + let raw = read_raw(d, "abc").unwrap(); + assert_eq!(raw["future"], "kept"); + // `--shift none` takes the field away again. + set_shift_in(d, &entry("abc"), 0).unwrap(); + assert_eq!(read_in(d, "abc").unwrap().shift, 0); + assert!(!read_raw(d, "abc").unwrap().contains_key("shift")); + assert_eq!(list_in(d).len(), 1, "no temporary file is left"); + } + + #[test] + fn a_shift_registers_a_stack_not_registered_yet() { + let d = tempfile::tempdir().unwrap(); + set_shift_in(d.path(), &entry("def"), 2000).unwrap(); + let e = read_in(d.path(), "def").unwrap(); + assert_eq!((e.name.as_str(), e.shift), ("shop", 2000)); + assert!(change(d.path(), "../x", |_| {}).is_err()); + } } diff --git a/crates/devstack-daemon/src/state.rs b/crates/devstack-daemon/src/state.rs index e4e0de15..3d0bb11f 100644 --- a/crates/devstack-daemon/src/state.rs +++ b/crates/devstack-daemon/src/state.rs @@ -259,9 +259,17 @@ pub enum NoticeLevel { Error, } +fn is_zero(n: &u16) -> bool { + *n == 0 +} + #[derive(Debug, Clone, Serialize)] pub struct StackView { pub name: String, + /// What the main tree's ports are moved by on this machine (ADR-0082); + /// 0: the stack file's ports. + #[serde(skip_serializing_if = "is_zero")] + pub shift: u16, pub repo: PathBuf, /// Version of the daemon. pub version: String, diff --git a/crates/devstack-daemon/src/supervisor/infra.rs b/crates/devstack-daemon/src/supervisor/infra.rs index ecc60bbf..aaecbb17 100644 --- a/crates/devstack-daemon/src/supervisor/infra.rs +++ b/crates/devstack-daemon/src/supervisor/infra.rs @@ -76,23 +76,25 @@ impl Supervisor { .join(format!("{group}.main.yml")) } - /// The main tree's overlay for `group`'s `main_ports` (ADR-0048), written - /// from its Compose files as they stand now: the ports it holds. Its - /// announced ports (ADR-0057) are checked on the same model. + /// The main tree's overlay for `group`'s `main_ports` (ADR-0048) and + /// its shift on this machine (ADR-0082), written from its Compose files + /// as they stand now: the ports it holds, every host port moved by the + /// shift. Its announced ports (ADR-0057) are checked on the same model. fn write_main_overlay(&self, group: &str) -> Result, String> { let file = self.main_overlay_file(group); let _ = std::fs::remove_file(&file); let cfg = &self.stack.compose[group]; let names = &cfg.main_ports; let announced = cfg.announced(); - if names.is_empty() && announced.is_empty() { + let shift = self.shift(); + if names.is_empty() && announced.is_empty() && shift == 0 { return Ok(Vec::new()); } let g = self.compose_group(group); let model = self.docker.config(&g).map_err(|e| e.to_string())?; devstack_compose::check_announced(&model, &announced) .map_err(|e| format!("compose group `{group}`: {e}"))?; - if names.is_empty() { + if names.is_empty() && shift == 0 { return Ok(Vec::new()); } // The main slot moves TCP only (ADR-0048 point 6; a lane on a node @@ -110,7 +112,7 @@ impl Supervisor { } } } - let Some((text, held)) = devstack_compose::main_overlay(&model, names, &announced) + let Some((text, held)) = devstack_compose::main_overlay(&model, names, &announced, shift) .map_err(|e| format!("compose group `{group}`: {e}"))? else { return Ok(Vec::new()); @@ -251,7 +253,7 @@ impl Supervisor { g.env = self.group_variables(name); if self.lane.is_empty() { let overlay = self.main_overlay_file(name); - if !cfg.main_ports.is_empty() && overlay.is_file() { + if (!cfg.main_ports.is_empty() || self.shift() > 0) && overlay.is_file() { g.files.push(overlay); } return g; @@ -293,7 +295,21 @@ impl Supervisor { } else { &cfg.shared }; - cfg.variables(&lane, self.port_offset, fixed) + let mut env = cfg.variables(&lane, self.files_offset(), fixed); + // A lane's `shared` containers are the main tree's: on its shifted + // ports when it runs shifted (ADR-0082). + if !fixed.is_empty() && self.shift() > 0 { + let announced = cfg.announced(); + for (k, v) in cfg.port_variables(self.shift(), &[]) { + if announced + .iter() + .any(|a| a.variable() == k && fixed.contains(&a.service)) + { + env.insert(k, v); + } + } + } + env } /// What the `docker compose` calls of every group get as this tree @@ -316,16 +332,17 @@ impl Supervisor { /// announced port is moved inside the container too, and is known by /// the files' number all the same. `None`: `target` is the files' own. fn files_port(&self, group: &str, service: &str, target: u16) -> Option { - if self.lane.is_empty() || self.port_offset == 0 { + let offset = self.files_offset(); + if offset == 0 { return None; } let cfg = self.stack.compose.get(group)?; - if cfg.shared.iter().any(|s| s == service) { + if !self.lane.is_empty() && cfg.shared.iter().any(|s| s == service) { return None; } cfg.announced() .into_iter() - .find(|a| a.service == service && a.port.checked_add(self.port_offset) == Some(target)) + .find(|a| a.service == service && a.port.checked_add(offset) == Some(target)) .map(|a| a.port) } diff --git a/crates/devstack-daemon/src/supervisor/mod.rs b/crates/devstack-daemon/src/supervisor/mod.rs index b8bf7160..94210cff 100644 --- a/crates/devstack-daemon/src/supervisor/mod.rs +++ b/crates/devstack-daemon/src/supervisor/mod.rs @@ -174,6 +174,11 @@ pub struct Supervisor { /// What a lane's ports are moved by (0 in the main tree): its own /// services' and its own containers' (ADR-0030). port_offset: u16, + /// What the main tree's ports are moved by on this machine (ADR-0082), + /// from the stack's registry entry: the main tree's own in the main + /// tree, the main tree's in a lane (its `shared` containers have + /// them). Set once, before anything starts ([`Self::set_shift`]). + shift: std::sync::atomic::AtomicU16, /// `devstack up --workers` in a lane: its main instances run their /// background workers although the lane does not own the main slot; its /// coordinators are its own (ADR-0030). @@ -513,6 +518,7 @@ impl Supervisor { preparing: Mutex::new(()), branch: Mutex::new(None), port_offset, + shift: std::sync::atomic::AtomicU16::new(0), lane_workers: std::sync::atomic::AtomicBool::new(false), force_build: Mutex::new(BTreeSet::new()), stack_file_hash: stack_file_hash(&stack_file), @@ -567,6 +573,30 @@ impl Supervisor { &self.stack_file } + /// The main tree's port shift on this machine (ADR-0082): its stack + /// was moved by it when the daemon read it. + pub fn shift(&self) -> u16 { + self.shift.load(Ordering::SeqCst) + } + + /// Tell the tree the main tree's shift, before anything of it starts: + /// its Compose calls and overlays move by it (a lane's `shared` + /// containers, the main tree's own). + pub fn set_shift(&self, shift: u16) { + self.shift.store(shift, Ordering::SeqCst); + } + + /// What this tree's Compose ports are moved by against its Compose + /// files: a lane's offset (the shift included), the shift in the main + /// tree. + pub(crate) fn files_offset(&self) -> u16 { + if self.lane.is_empty() { + self.shift() + } else { + self.port_offset + } + } + /// The lane this tree presents to the stack (ADR-0064 and its addendum /// of 2026-10-26): what `{lane}` and `{lane_suffix}` stand for, what a /// native process gets as `DEVSTACK_LANE`, and what every Compose call @@ -743,6 +773,7 @@ impl Supervisor { }) .collect(); StackView { + shift: self.shift(), profile: self.stack.profile.as_ref().map(|p| p.name.clone()), profiles, name: self.stack.stack.name.clone(), diff --git a/crates/devstack-hub/src/lib.rs b/crates/devstack-hub/src/lib.rs index 2e14cfc8..717c955a 100644 --- a/crates/devstack-hub/src/lib.rs +++ b/crates/devstack-hub/src/lib.rs @@ -118,6 +118,9 @@ pub struct StackEntry { /// up`'s output), while the stack is not running (#11). #[serde(skip_serializing_if = "Option::is_none")] pub start_error: Option, + /// What its main tree's ports are moved by on this machine (ADR-0082); + /// 0: the stack file's ports. + pub shift: u16, } /// Start failures by stack id, from `stack_up` to `scan`. @@ -214,6 +217,7 @@ fn scan(last_seen: &mut HashMap, errors: &StartErrors) -> Vec Option { daemon: None, last_seen: None, start_error: None, + shift: e.shift, }) }) } @@ -2185,6 +2190,7 @@ fn register_folder( name: stack.stack.name.clone(), file: file.to_path_buf(), repo: repo.to_path_buf(), + ..registry::Entry::default() }; registry::register(&entry).map_err(|e| e.to_string())?; diag::info( @@ -2330,10 +2336,49 @@ async fn stack_scan(Json(q): Json) -> Response { } } -async fn stack_up(State(s): State, Path(id): Path) -> Response { +/// `POST /hub/stacks/{id}/up`, optionally with `{"shift": "auto" | N | +/// "none"}`: `devstack up --shift` (ADR-0082), which sets the stack's shift +/// on this machine before it starts. +#[derive(Deserialize, Default)] +struct UpBody { + #[serde(default)] + shift: Option, +} + +/// The `--shift` value of an [`UpBody`]: `auto`, `none` or a number. +fn shift_arg(body: &[u8]) -> Result, String> { + if body.iter().all(u8::is_ascii_whitespace) { + return Ok(None); + } + let b: UpBody = serde_json::from_slice(body).map_err(|e| e.to_string())?; + match b.shift { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Number(n)) => n + .as_u64() + .filter(|n| *n <= u64::from(u16::MAX)) + .map(|n| Some(n.to_string())) + .ok_or_else(|| format!("shift {n}: a multiple of 1000")), + Some(serde_json::Value::String(t)) + if t == "auto" || t == "none" || t.parse::().is_ok() => + { + Ok(Some(t)) + } + Some(v) => Err(format!("shift {v}: `auto`, `none` or a multiple of 1000")), + } +} + +async fn stack_up( + State(s): State, + Path(id): Path, + body: axum::body::Bytes, +) -> Response { let Some(e) = entry(&s, &id) else { return err(StatusCode::NOT_FOUND, format!("no stack {id}")); }; + let shift = match shift_arg(&body) { + Ok(v) => v, + Err(m) => return err(StatusCode::BAD_REQUEST, m), + }; let Ok(exe) = devstack_exe() else { return err(StatusCode::INTERNAL_SERVER_ERROR, "cannot find devstack"); }; @@ -2341,7 +2386,7 @@ async fn stack_up(State(s): State, Path(id): Path) -> Response let stopping = s.phases.lock().unwrap().get(&id) == Some(&Phase::Stopping); if !stopping { s.phases.lock().unwrap().insert(id.clone(), Phase::Starting); - return match start_up(&s, &id, &exe, &e) { + return match start_up(&s, &id, &exe, &e, shift.as_deref()) { Ok(()) => StatusCode::ACCEPTED.into_response(), Err(msg) => { end_phase(&s.phases, &id, Phase::Starting); @@ -2361,7 +2406,7 @@ async fn stack_up(State(s): State, Path(id): Path) -> Response tokio::time::sleep(Duration::from_millis(200)).await; } s.phases.lock().unwrap().insert(id.clone(), Phase::Starting); - if let Err(msg) = start_up(&s, &id, &exe, &e) { + if let Err(msg) = start_up(&s, &id, &exe, &e, shift.as_deref()) { end_phase(&s.phases, &id, Phase::Starting); s.start_errors.lock().unwrap().insert(id, msg); } @@ -2381,9 +2426,15 @@ fn end_phase(phases: &Phases, id: &str, phase: Phase) { } } -/// Run `devstack up` for stack `e` in the background; the stack is -/// `Starting` until it ends. -fn start_up(s: &AppState, id: &str, exe: &std::path::Path, e: &StackEntry) -> Result<(), String> { +/// Run `devstack up` for stack `e` in the background, with `--shift` when +/// one is asked (ADR-0082); the stack is `Starting` until it ends. +fn start_up( + s: &AppState, + id: &str, + exe: &std::path::Path, + e: &StackEntry, + shift: Option<&str>, +) -> Result<(), String> { // What `up` says goes to a file: when it fails before the daemon runs // (a stack file that needs a newer devstack, a port taken), its last // lines are what the dashboard shows instead of "Starting…" (#11). @@ -2392,10 +2443,11 @@ fn start_up(s: &AppState, id: &str, exe: &std::path::Path, e: &StackEntry) -> Re .and_then(|f| Ok((f.try_clone()?, f))) .map_err(|e| e.to_string())?; let mut cmd = std::process::Command::new(exe); - cmd.arg("up") - .arg("-f") - .arg(&e.file) - .current_dir(&e.repo) + cmd.arg("up").arg("-f").arg(&e.file); + if let Some(shift) = shift { + cmd.arg("--shift").arg(shift); + } + cmd.current_dir(&e.repo) .env("NO_COLOR", "1") .stdin(std::process::Stdio::null()) .stdout(out) @@ -3913,3 +3965,24 @@ mod hung_hub_tests { assert!(!is_hung(&info, 4251, at(61), true)); } } + +#[cfg(test)] +mod shift_tests { + use super::shift_arg; + + #[test] + fn a_start_takes_a_shift_as_auto_none_or_a_number() { + assert_eq!(shift_arg(b""), Ok(None)); + assert_eq!(shift_arg(b"{}"), Ok(None)); + assert_eq!(shift_arg(br#"{"shift":null}"#), Ok(None)); + assert_eq!(shift_arg(br#"{"shift":"auto"}"#), Ok(Some("auto".into()))); + assert_eq!(shift_arg(br#"{"shift":"none"}"#), Ok(Some("none".into()))); + assert_eq!(shift_arg(br#"{"shift":2000}"#), Ok(Some("2000".into()))); + assert_eq!(shift_arg(br#"{"shift":"3000"}"#), Ok(Some("3000".into()))); + // Nothing that could pass as another argument of `devstack up`. + assert!(shift_arg(br#"{"shift":"--yes"}"#).is_err()); + assert!(shift_arg(br#"{"shift":-1}"#).is_err()); + assert!(shift_arg(br#"{"shift":70000}"#).is_err()); + assert!(shift_arg(b"nope").is_err()); + } +} diff --git a/crates/devstack-hub/src/ports.rs b/crates/devstack-hub/src/ports.rs index ac11e2bc..6b35791a 100644 --- a/crates/devstack-hub/src/ports.rs +++ b/crates/devstack-hub/src/ports.rs @@ -8,7 +8,7 @@ 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 devstack_daemon::ports::{PortEntry, StackPorts, declared, overlaps, shifted}; use http_body_util::BodyExt; use std::time::Duration; @@ -54,9 +54,11 @@ async fn of(s: &AppState, e: StackEntry) -> Option { if !e.running && !file.is_file() { return None; } + // On the ports its shift on this machine moves them to (ADR-0082). + let shift = e.shift; let ports = tokio::task::spawn_blocking(move || { devstack_config::load(&file) - .map(|st| declared(&st)) + .map(|st| shifted(declared(&st), shift)) .unwrap_or_default() }) .await diff --git a/crates/devstack-hub/src/summary.rs b/crates/devstack-hub/src/summary.rs index efe6b258..b2df7f94 100644 --- a/crates/devstack-hub/src/summary.rs +++ b/crates/devstack-hub/src/summary.rs @@ -220,6 +220,7 @@ mod tests { daemon: None, last_seen: None, start_error: None, + shift: 0, } } diff --git a/crates/devstack/src/client.rs b/crates/devstack/src/client.rs index aa8cb141..77e3b769 100644 --- a/crates/devstack/src/client.rs +++ b/crates/devstack/src/client.rs @@ -773,6 +773,12 @@ pub fn status_text(stack: &Value) -> String { if let Some(p) = stack["profile"].as_str() { let _ = writeln!(out, "profile: {p} (ADR-0041)\n"); } + if let Some(n) = stack["shift"].as_u64().filter(|n| *n > 0) { + let _ = writeln!( + out, + "ports: shifted +{n} on this machine (ADR-0082; `devstack up --shift none` takes it back)\n" + ); + } if stack["config_changed"].as_bool() == Some(true) { let _ = writeln!( out, @@ -946,9 +952,11 @@ pub fn up( profile: Option<&str>, run: bool, yes: bool, + shift: Option<&str>, ) -> Result<()> { // Resolves whether this is a lane's worktree. let sd = state_dir(stack_file)?; + let shift = shift.map(crate::shift::parse).transpose()?; let wanted = profile_start(stack_file, profile, run)?; // In a worktree, no `--on`: where this machine starts lanes by default // (ADR-0025); `--on local` keeps it here. The main tree runs here. @@ -989,6 +997,15 @@ pub fn up( p.name ); } + // Several stacks on this machine (ADR-0082): the main tree's ports + // against the others' claims, at the shift it runs with. + for line in shift_before_up(stack_file, &sd, shift)? { + if line.starts_with("warning:") { + eprintln!("{line}"); + } else { + println!("{line}"); + } + } let mut c = ensure_daemon_with( &daemon_file(stack_file), &sd, @@ -1100,6 +1117,26 @@ no node of the pool can run this lane: name the Linux image it runs in there in } } +/// What `up` checks and sets of the main tree's port shift before its +/// daemon starts (ADR-0082, [`crate::shift::before_up`]); in a worktree, +/// whose lane follows the main tree's shift, `--shift` is refused. +pub fn shift_before_up( + stack_file: &Path, + sd: &Path, + asked: Option, +) -> Result> { + if worktree().is_some() { + if asked.is_some() { + bail!( + "--shift moves the main working tree's ports, and its lanes with them: run it in the main working tree" + ); + } + return Ok(Vec::new()); + } + let running = Client::probe(sd).ok().flatten().is_some(); + crate::shift::before_up(stack_file, running, asked) +} + pub enum UpOutcome { /// Every autostart service and every container of the stack is ready; /// the stack view at that moment. diff --git a/crates/devstack/src/dispatch/stack.rs b/crates/devstack/src/dispatch/stack.rs index 6b820d65..639889b2 100644 --- a/crates/devstack/src/dispatch/stack.rs +++ b/crates/devstack/src/dispatch/stack.rs @@ -15,6 +15,7 @@ pub(crate) fn run(file: &Path, cmd: Cmd) -> Result<()> { profile, run, yes, + shift, } => client::up( file, workers, @@ -23,6 +24,7 @@ pub(crate) fn run(file: &Path, cmd: Cmd) -> Result<()> { profile.as_deref(), run, yes, + shift.as_deref(), ), Cmd::Down { keep_infra, all, .. diff --git a/crates/devstack/src/doctor.rs b/crates/devstack/src/doctor.rs index 7439d368..f6b0eaf9 100644 --- a/crates/devstack/src/doctor.rs +++ b/crates/devstack/src/doctor.rs @@ -361,7 +361,12 @@ fn overlaps() -> Vec { 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)) + .map(|st| { + devstack_daemon::ports::shifted( + devstack_daemon::ports::declared(&st), + e.shift, + ) + }) .unwrap_or_default(), None => return None, }; diff --git a/crates/devstack/src/main.rs b/crates/devstack/src/main.rs index d27aa821..4760d282 100644 --- a/crates/devstack/src/main.rs +++ b/crates/devstack/src/main.rs @@ -16,6 +16,7 @@ mod integration; mod mcp; mod poolcmd; mod selfcmd; +mod shift; mod tray; mod upstream; use devstack_skills as skills; @@ -72,6 +73,12 @@ enum Cmd { /// throws its data away) and what its command asks. #[arg(long)] yes: bool, + /// In the main working tree: run the stack on this machine with its + /// ports moved by a shift (ADR-0082): `auto` (the lowest free + /// multiple of 1000), a multiple of 1000, or `none` (the stack + /// file's ports). It sticks to the stack on this machine. + #[arg(long, value_name = "auto|N|none")] + shift: Option, }, /// Stop the services, remove the containers (volumes stay) and end the /// daemon. In the main working tree, lanes that still run keep the daemon, @@ -986,6 +993,10 @@ mod tests { }; assert!(workers && !force); assert_eq!(on.as_deref(), Some("pool")); + let Cmd::Up { shift, .. } = parse(&["up", "--shift", "auto"]).cmd else { + panic!("not up"); + }; + assert_eq!(shift.as_deref(), Some("auto")); } #[test] diff --git a/crates/devstack/src/mcp.rs b/crates/devstack/src/mcp.rs index 135f0960..38f4e7ef 100644 --- a/crates/devstack/src/mcp.rs +++ b/crates/devstack/src/mcp.rs @@ -289,6 +289,7 @@ pub fn tools() -> Vec { "profile": { "type": "string", "description": "A profile of the stack file (ADR-0041): how it runs for a purpose, e.g. an acceptance run. In the main tree only when the daemon starts; a profile with fresh data there needs `yes`" }, "run": { "type": "boolean", "description": "With `profile`: once ready, run the profile's command and return its result" }, "yes": { "type": "boolean", "description": "Confirm what the profile asks; ask the user first" }, + "shift": { "type": ["string", "integer"], "description": "Main working tree only (ADR-0082): run the stack on this machine with its ports moved by a shift, which sticks to it here until `none`: `auto` (the lowest free multiple of 1000), a multiple of 1000, or `none` (the stack file's ports). Without it, `up` refuses to start when another running stack claims its ports and names that stack: ask the user before shifting (bookmarks and ports hard-coded outside devstack's placeholders do not move)" }, }), &[], false, @@ -593,6 +594,16 @@ fn call(name: &str, args: &Value, file: &Path) -> Result { }) .map_err(|e| anyhow::anyhow!("{notes}{e:#}"))?; } + // Several stacks on this machine (ADR-0082), as `devstack up`. + let shift = match &args["shift"] { + Value::String(t) if !t.trim().is_empty() => Some(crate::shift::parse(t)?), + Value::Number(n) => Some(crate::shift::parse(&n.to_string())?), + _ => None, + }; + for line in client::shift_before_up(file, &sd, shift)? { + notes.push_str(&line); + notes.push('\n'); + } let mut c = client::ensure_daemon_with( &client::daemon_file(file), &sd, diff --git a/crates/devstack/src/shift.rs b/crates/devstack/src/shift.rs new file mode 100644 index 00000000..46640d12 --- /dev/null +++ b/crates/devstack/src/shift.rs @@ -0,0 +1,424 @@ +//! Several stacks on one machine (ADR-0082 points 3 to 7): before the main +//! tree's daemon starts, `devstack up` compares the stack's ports, after +//! its shift on this machine, with what the other registered stacks claim. +//! A running stack's claim refuses the start, naming it, and offers a +//! shift; a stopped stack's only warns. `--shift auto||none` sets the +//! shift in the stack's registry entry, where it sticks; the daemon moves +//! the stack by it when it starts. + +use anyhow::{Result, anyhow, bail}; +use devstack_daemon::registry::{self, Entry, Other, SHIFT_STEP}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +/// What `--shift` asks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Asked { + /// The lowest free multiple of [`SHIFT_STEP`]. + Auto, + /// This shift; 0 is `none`, the stack file's own ports. + To(u16), +} + +/// `auto`, `none` (or `0`), or a multiple of 1000. +pub fn parse(text: &str) -> Result { + let t = text.trim().trim_start_matches('+'); + match t { + "auto" => Ok(Asked::Auto), + "none" | "0" => Ok(Asked::To(0)), + _ => { + let n: u16 = t.parse().map_err(|_| { + anyhow!( + "--shift {text}: `auto`, `none` or a multiple of {SHIFT_STEP} (1000, 2000, ...)" + ) + })?; + if !n.is_multiple_of(SHIFT_STEP) { + bail!("--shift {text}: a shift is a multiple of {SHIFT_STEP} (1000, 2000, ...)"); + } + Ok(Asked::To(n)) + } + } +} + +/// The ports of the main tree at shift 0: what its stack file declares +/// (services, announced ports, the ports its `urls` name) and what its +/// daemon claimed for the main slot last time, moved back by the shift it +/// had (its containers' host ports, which only Compose knows). +pub fn base_ports(stack: &devstack_config::Stack, own: Option<&Entry>) -> BTreeSet { + let mut out: BTreeSet = devstack_daemon::ports::declared(stack) + .iter() + .map(|e| e.port) + .collect(); + if let Some(e) = own { + out.extend(e.claims.main.iter().filter_map(|p| p.checked_sub(e.shift))); + } + out +} + +/// `base` moved by `shift`; `None` beyond 65535. +fn moved(base: &BTreeSet, shift: u16) -> Option> { + base.iter().map(|p| p.checked_add(shift)).collect() +} + +/// Another stack that claims some of the ports. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Collision { + pub name: String, + pub repo: String, + pub running: bool, + /// Each port, with who of it claims it (`main` or a lane). + pub ports: Vec<(u16, String)>, +} + +impl Collision { + fn ports_text(&self) -> String { + let mut by: BTreeMap<&str, Vec> = BTreeMap::new(); + for (p, who) in &self.ports { + by.entry(who).or_default().push(p.to_string()); + } + by.iter() + .map(|(who, ps)| { + if *who == "main" { + ps.join(", ") + } else { + format!("{} (its lane {who})", ps.join(", ")) + } + }) + .collect::>() + .join("; ") + } +} + +/// The other stacks that claim one of `ports`. +pub fn collisions(ports: &BTreeSet, others: &[Other]) -> Vec { + others + .iter() + .filter_map(|o| { + let hit: Vec<(u16, String)> = ports + .iter() + .filter_map(|p| Some((*p, o.entry.claims.holder(*p)?.to_string()))) + .collect(); + (!hit.is_empty()).then(|| Collision { + name: o.entry.name.clone(), + repo: o.entry.repo.display().to_string(), + running: o.running, + ports: hit, + }) + }) + .collect() +} + +/// How many lanes `auto` keeps room for above the main ports. +const AUTO_LANES: u16 = 3; + +/// The lowest shift, a multiple of [`SHIFT_STEP`] above 0, whose ports +/// (lanes 1 to 3 included, `step` apart) are free of every claim in +/// `claimed` and of what `in_use` (the OS) reports. +pub fn auto_shift( + base: &BTreeSet, + step: u16, + claimed: &BTreeSet, + in_use: impl Fn(u16) -> bool, +) -> Option { + (1..=65u16).map(|k| k * SHIFT_STEP).find(|shift| { + (0..=AUTO_LANES).all(|n| { + let Some(off) = n.checked_mul(step).and_then(|o| o.checked_add(*shift)) else { + return false; + }; + base.iter().all(|p| { + p.checked_add(off) + .is_some_and(|q| !claimed.contains(&q) && !in_use(q)) + }) + }) + }) +} + +/// What `up` does about the collisions at the shift it starts with. +#[derive(Debug, PartialEq, Eq)] +pub enum Decision { + /// Start; say these warnings first (stopped stacks' claims). + Go(Vec), + /// Start nothing: a running stack holds some of the ports. + Refuse(String), +} + +/// Refuse when a running stack claims one of the ports, naming it, and +/// offer the shifts (`suggested`: what `auto` would take); a stopped +/// stack's claim is a warning (ADR-0082 point 6). +pub fn decide(collisions: &[Collision], shift: u16, suggested: Option) -> Decision { + let running: Vec<&Collision> = collisions.iter().filter(|c| c.running).collect(); + if !running.is_empty() { + let who: Vec = running + .iter() + .map(|c| { + format!( + "the stack `{}` ({}) runs on {}", + c.name, + c.repo, + c.ports_text() + ) + }) + .collect(); + let at = if shift == 0 { + "its ports".to_string() + } else { + format!("its ports shifted by {shift}") + }; + let auto = suggested + .map(|s| format!(" (the lowest free one now: {s})")) + .unwrap_or_default(); + return Decision::Refuse(format!( + "this stack would start on {at}, and {}. Nothing started; whoever was first keeps its ports.\n\ + Start this stack moved by a port shift on this machine (its services, containers and links move; its data stays):\n \ + devstack up --shift auto{auto}\n \ + devstack up --shift (a multiple of {SHIFT_STEP})\n\ + The shift sticks to this stack on this machine until `devstack up --shift none`.", + who.join("; ") + )); + } + Decision::Go( + collisions + .iter() + .map(|c| { + format!( + "warning: the stopped stack `{}` ({}) claims {} too; whichever starts second cannot have them", + c.name, + c.repo, + c.ports_text() + ) + }) + .collect(), + ) +} + +/// The registry entry of the main tree's `stack_file` as it would be +/// registered, and the one registered now. +fn entries(stack_file: &Path, name: &str) -> Result<(Entry, Option)> { + let abs = devstack_proc::long_path(&std::path::absolute(stack_file)?); + let repo = abs.parent().map(Path::to_path_buf).unwrap_or(abs.clone()); + let id = registry::id_for(&abs); + let base = Entry { + id: id.clone(), + name: name.to_string(), + file: abs.clone(), + repo, + ..Entry::default() + }; + Ok((base, registry::read(&id))) +} + +fn shift_flag(shift: u16) -> String { + if shift == 0 { + "none".into() + } else { + shift.to_string() + } +} + +/// Before `up` starts the main tree's daemon (`running`: it runs +/// already): check the ports at the shift it will run with, set an asked +/// shift, and say what changed. Errors refuse the start. +pub fn before_up(stack_file: &Path, running: bool, asked: Option) -> Result> { + let stack = devstack_config::load(stack_file)?; + let (base_entry, own) = entries(stack_file, &stack.stack.name)?; + let now = own.as_ref().map_or(0, |e| e.shift); + if running { + // Its ports are its own already; another shift needs a new daemon. + return match asked { + None => Ok(Vec::new()), + Some(Asked::Auto) if now > 0 => Ok(vec![format!( + "the main working tree runs shifted by {now} already" + )]), + Some(Asked::To(n)) if n == now => Ok(Vec::new()), + Some(a) => bail!( + "the main working tree runs {}: `devstack down` first, then `devstack up --shift {}`", + if now == 0 { + "on its own ports".to_string() + } else { + format!("shifted by {now}") + }, + match a { + Asked::Auto => "auto".to_string(), + Asked::To(n) => shift_flag(n), + } + ), + }; + } + let base = base_ports(&stack, own.as_ref()); + let others = registry::others(&base_entry.id); + let claimed: BTreeSet = others.iter().flat_map(|o| o.entry.claims.ports()).collect(); + let step = stack.lanes.port_step; + let auto = || { + auto_shift( + &base, + step, + &claimed, + devstack_daemon::supervisor::port_in_use, + ) + }; + let shift = match asked { + None => now, + Some(Asked::To(n)) => n, + Some(Asked::Auto) => auto().ok_or_else(|| { + anyhow!( + "no shift up to 65000 moves this stack's ports, and three lanes' above them, to ports free of every other stack's and of what runs on this machine" + ) + })?, + }; + let ports = moved(&base, shift).ok_or_else(|| { + anyhow!( + "the shift {shift} moves a port of this stack beyond 65535: `devstack up --shift none`, or a smaller one" + ) + })?; + let found = collisions(&ports, &others); + let mut notes = match decide(&found, shift, None) { + Decision::Go(w) => w, + Decision::Refuse(_) => { + let suggested = if asked == Some(Asked::Auto) { + None + } else { + auto() + }; + match decide(&found, shift, suggested) { + Decision::Refuse(why) => bail!("{why}"), + Decision::Go(w) => w, + } + } + }; + if shift != now { + registry::set_shift(&base_entry, shift) + .map_err(|e| anyhow!("could not keep the shift in the registry of stacks: {e}"))?; + } + if shift != now || asked.is_some() { + notes.push(if shift == 0 { + "the main working tree runs on the ports its stack file gives".to_string() + } else { + format!( + "the main working tree runs shifted by {shift} on this machine (`devstack up --shift none` takes it back)" + ) + }); + } + Ok(notes) +} + +#[cfg(test)] +mod tests { + use super::*; + use devstack_daemon::registry::Claims; + + fn other(name: &str, running: bool, main: &[u16], lane: &[u16]) -> Other { + Other { + entry: Entry { + id: format!("{name}-id"), + name: name.into(), + repo: format!("/work/{name}").into(), + claims: Claims { + main: main.to_vec(), + lanes: if lane.is_empty() { + BTreeMap::new() + } else { + BTreeMap::from([("fix".to_string(), lane.to_vec())]) + }, + }, + ..Entry::default() + }, + running, + } + } + + #[test] + fn shifts_parse_as_auto_none_or_a_multiple_of_1000() { + assert_eq!(parse("auto").unwrap(), Asked::Auto); + assert_eq!(parse("none").unwrap(), Asked::To(0)); + assert_eq!(parse("0").unwrap(), Asked::To(0)); + assert_eq!(parse("+2000").unwrap(), Asked::To(2000)); + assert!(parse("1500").is_err()); + assert!(parse("lots").is_err()); + assert!(parse("70000").is_err()); + } + + #[test] + fn auto_takes_the_lowest_free_multiple_of_1000_with_room_for_three_lanes() { + let base = BTreeSet::from([5000, 5432]); + let none = BTreeSet::new(); + assert_eq!(auto_shift(&base, 100, &none, |_| false), Some(1000)); + // Another stack's lane 2 of +1000 is claimed: +2000. + let claimed = BTreeSet::from([6200]); + assert_eq!(auto_shift(&base, 100, &claimed, |_| false), Some(2000)); + // A port the OS reports in use counts the same. + assert_eq!(auto_shift(&base, 100, &none, |p| p == 7732), Some(1000)); + assert_eq!(auto_shift(&base, 100, &none, |p| p == 6732), Some(2000)); + // Nothing fits below 65536. + assert_eq!( + auto_shift(&BTreeSet::from([64600]), 100, &none, |_| false), + None + ); + } + + #[test] + fn a_running_stack_refuses_the_start_and_a_stopped_one_warns() { + let ports = BTreeSet::from([5000, 5432]); + let others = vec![ + other("shop", true, &[5000], &[]), + other("docs", false, &[], &[5432]), + other("blog", true, &[4000], &[]), + ]; + let found = collisions(&ports, &others); + assert_eq!(found.len(), 2, "{found:?}"); + match decide(&found, 0, Some(1000)) { + Decision::Refuse(why) => { + assert!(why.contains("`shop`") && why.contains("5000"), "{why}"); + assert!( + why.contains("--shift auto (the lowest free one now: 1000)"), + "{why}" + ); + assert!( + why.contains("--shift ") && why.contains("--shift none"), + "{why}" + ); + assert!(!why.contains("docs"), "only who runs: {why}"); + } + d => panic!("{d:?}"), + } + // Only the stopped one: a warning, then go on. + let found = collisions(&ports, &others[1..]); + match decide(&found, 0, None) { + Decision::Go(w) => { + assert_eq!(w.len(), 1); + assert!( + w[0].contains("`docs`") && w[0].contains("its lane fix"), + "{w:?}" + ); + } + d => panic!("{d:?}"), + } + // Nobody else: go on, nothing to say. + assert_eq!(decide(&[], 1000, None), Decision::Go(Vec::new())); + } + + #[test] + fn the_base_ports_add_what_the_daemon_claimed_moved_back() { + let stack = devstack_config::from_str( + r#"version = 1 +[stack] +name = "shop" +[services.api] +run = "x" +port = 5000 +"#, + Path::new("devstack.toml"), + ) + .unwrap(); + let own = Entry { + shift: 1000, + claims: Claims { + main: vec![6000, 6432], + lanes: BTreeMap::new(), + }, + ..Entry::default() + }; + assert_eq!(base_ports(&stack, None), BTreeSet::from([5000])); + assert_eq!(base_ports(&stack, Some(&own)), BTreeSet::from([5000, 5432])); + assert_eq!(moved(&BTreeSet::from([65000]), 1000), None); + } +} diff --git a/crates/devstack/tests/daemon_lifecycle.rs b/crates/devstack/tests/daemon_lifecycle.rs index c4628af9..63ba6e31 100644 --- a/crates/devstack/tests/daemon_lifecycle.rs +++ b/crates/devstack/tests/daemon_lifecycle.rs @@ -41,7 +41,9 @@ fn free_port() -> u16 { struct Stack { dir: tempfile::TempDir, - token_home: tempfile::TempDir, + /// Shared by the stacks of one test that run side by side + /// ([`Stack::beside`]): one user's registry of stacks. + token_home: std::sync::Arc, port: u16, /// The `kind = "static"` site. site_port: u16, @@ -50,6 +52,19 @@ struct Stack { impl Stack { fn new(with_compose: bool) -> Self { + Self::new_in( + with_compose, + std::sync::Arc::new(tempfile::tempdir().unwrap()), + ) + } + + /// Another stack of the same user (token, registry of stacks) beside + /// this one, in a folder of its own. + fn beside(&self) -> Self { + Self::new_in(false, self.token_home.clone()) + } + + fn new_in(with_compose: bool, token_home: std::sync::Arc) -> Self { let dir = tempfile::tempdir().unwrap(); let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/fixture-stack"); let api = dir.path().join("src/Api"); @@ -157,7 +172,7 @@ members = [\"api\", \"command.ping\"] assert!(ok, "git init"); Self { dir, - token_home: tempfile::tempdir().unwrap(), + token_home, port, site_port, project, @@ -1500,6 +1515,118 @@ command = "check" s.ok(&["down", "--all"]); } +/// Several stacks on one machine (ADR-0082): two copies of one stack, in +/// two folders, on the same ports. The second's `up` starts nothing while +/// the first runs and names it; `--shift auto` runs it on its ports + 1000, +/// a restart keeps that, and `--shift none` takes it back (the first one +/// stopped by then: its claim only warns). Needs no SDK. +#[test] +fn a_second_stack_on_the_same_ports_runs_shifted() { + if !enabled("DEVSTACK_INTEGRATION") { + eprintln!("skipped: set DEVSTACK_INTEGRATION=1"); + return; + } + let first = Stack::new(false); + let second = first.beside(); + let port = first.site_port; + let stack_file = |name: &str| { + format!( + r#"version = 1 +[stack] +name = "{name}" +[services.site] +kind = "static" +build = "git --version" +output = "src/Site" +watch = ["src/Site/**"] +port = {port} +"# + ) + }; + std::fs::write(first.file(), stack_file("first")).unwrap(); + std::fs::write(second.file(), stack_file("second")).unwrap(); + std::fs::write( + second.dir.path().join("src/Site/index.html"), + "

second site

", + ) + .unwrap(); + let text = |o: &Output| { + format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ) + }; + let daemon_runs = |s: &Stack| s.dir.path().join(".devstack/daemon.json").is_file(); + + first.ok(&["up"]); + assert!(get_on(port, "/").unwrap().contains("static site")); + + // Nobody moves silently: the second starts nothing and names the first. + let refused = second.cli(&["up"], None); + let said = text(&refused); + assert!(!refused.status.success(), "{said}"); + assert!( + said.contains("`first`") && said.contains(&port.to_string()), + "{said}" + ); + assert!( + said.contains("--shift auto") && said.contains("--shift "), + "{said}" + ); + assert!(!daemon_runs(&second), "no daemon started: {said}"); + assert!( + get_on(port, "/").unwrap().contains("static site"), + "the first keeps its port" + ); + + // `--shift auto`: the lowest free multiple of 1000. + let up = second.ok(&["up", "--shift", "auto"]); + assert!(up.contains("shifted by 1000"), "{up}"); + assert_eq!(second.stack_view()["shift"], 1000); + assert_eq!(second.service_view("site")["port"], port + 1000); + assert!( + get_on(port + 1000, "/").unwrap().contains("second site"), + "the second answers on its port + 1000" + ); + assert!(get_on(port, "/").unwrap().contains("static site")); + + // It sticks: a restart without `--shift` keeps it. + second.ok(&["down"]); + assert!(wait_for(|| !daemon_runs(&second), Duration::from_secs(30))); + second.ok(&["up"]); + assert_eq!(second.stack_view()["shift"], 1000); + assert!(get_on(port + 1000, "/").unwrap().contains("second site")); + // Another shift needs the daemon stopped first. + let busy = second.cli(&["up", "--shift", "none"], None); + assert!( + !busy.status.success() && text(&busy).contains("down"), + "{}", + text(&busy) + ); + + // `--shift none` takes it back; the first, stopped now, only warns. + second.ok(&["down"]); + first.ok(&["down"]); + assert!(wait_for( + || !daemon_runs(&first) && !daemon_runs(&second), + Duration::from_secs(30) + )); + let back = second.ok(&["up", "--shift", "none"]); + assert!( + back.contains("warning: the stopped stack `first`"), + "{back}" + ); + assert!(second.stack_view().get("shift").is_none_or(|s| s == 0)); + assert_eq!(second.service_view("site")["port"], port); + assert!(get_on(port, "/").unwrap().contains("second site")); + assert!( + get_on(port + 1000, "/").is_none(), + "nothing on the shifted port" + ); + second.ok(&["down"]); +} + /// A long-running service that exits by itself, with any code, is started /// again after a backoff, five times in a row at most; a tool stays exited /// (#103: a lane's dev server exited with 0 and nothing brought it back). @@ -3334,3 +3461,166 @@ skip = ["compose.heavy", "compose.app.scanner"] running(&app) ); } + +/// ADR-0082: a stack shifted on this machine moves its containers' host +/// ports by the shift, an announced port inside the container too, and +/// keeps its Compose project and container names; `{compose.…port}` and +/// `DEVSTACK_PORT_*` follow. `--shift none` takes it back. Docker only. +#[test] +#[ignore = "needs Docker: DEVSTACK_INTEGRATION_COMPOSE=1, --include-ignored"] +fn a_shifted_stacks_containers_move_with_it() { + if !enabled("DEVSTACK_INTEGRATION_COMPOSE") { + eprintln!("skipped: set DEVSTACK_INTEGRATION_COMPOSE=1 (needs Docker)"); + return; + } + let dir = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let p = free_port(); + let q = free_port(); + let project = format!("devstack-it-shift-{}-{p}", std::process::id()); + let name = format!("{project}-media"); + let var = format!("DEVSTACK_PORT_MEDIA_{p}"); + // One announced port (1:1, the server says its number) and one plain + // published port, on a fixed container name. + std::fs::write( + dir.path().join("compose.yml"), + format!( + r#"services: + media: + image: busybox:1.36 + container_name: {name} + environment: + PORT: ${{{var}:-{p}}} + command: ["sh", "-c", "mkdir -p /www && echo announces-$$PORT > /www/index.html && httpd -p 80 -h /www && exec httpd -f -p $$PORT -h /www"] + ports: ["127.0.0.1:{p}:{p}", "127.0.0.1:{q}:80"] +"# + ), + ) + .unwrap(); + let echo = if cfg!(windows) { + format!( + "echo port={{compose.app.media.{p}.port}} web={{compose.app.media.80.port}} var=%{var}%" + ) + } else { + format!( + "echo port={{compose.app.media.{p}.port}} web={{compose.app.media.80.port}} var=${var}" + ) + }; + std::fs::write( + dir.path().join("devstack.toml"), + format!( + r#"version = 1 +[stack] +name = "shifted" +[compose.app] +project = "{project}" +files = ["compose.yml"] +services = ["media"] +announced_ports = ["media:{p}"] +[commands.port] +description = "the ports" +steps = [{{ exec = "{echo}" }}] +"# + ), + ) + .unwrap(); + let file = dir.path().join("devstack.toml"); + let cli = |args: &[&str]| { + let out = Command::new(BIN) + .args(args) + .arg("-f") + .arg(&file) + .env("LOCALAPPDATA", home.path()) + .env("XDG_STATE_HOME", home.path()) + .env("DEVSTACK_NO_HUB", "1") + .output() + .unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + (out.status.success(), text) + }; + let says = |port: u16| -> String { + use std::io::{Read, Write}; + let Ok(mut s) = std::net::TcpStream::connect(("127.0.0.1", port)) else { + return String::new(); + }; + let _ = s.set_read_timeout(Some(Duration::from_secs(5))); + let _ = s.write_all(b"GET / HTTP/1.0\r\n\r\n"); + let mut body = String::new(); + let _ = s.read_to_string(&mut body); + body + }; + struct Cleanup<'a>(&'a dyn Fn()); + impl Drop for Cleanup<'_> { + fn drop(&mut self) { + (self.0)(); + } + } + let clean = || { + let _ = cli(&["down", "--all"]); + let _ = Command::new("docker") + .args(["compose", "-p", &project, "down", "--volumes"]) + .output(); + }; + let _cleanup = Cleanup(&clean); + + let (ok, up) = cli(&["up", "--shift", "1000"]); + assert!(ok, "{up}"); + assert!(up.contains("shifted by 1000"), "{up}"); + let (sp, sq) = (p + 1000, q + 1000); + let moved = format!("announces-{sp}"); + assert!( + wait_for(|| says(sp).contains(&moved), Duration::from_secs(30)), + "the announced port moves inside the container: {:?}\n{up}", + says(sp) + ); + assert!( + says(sq).contains(&moved), + "the plain port moves: {:?}", + says(sq) + ); + assert!( + says(p).is_empty() && says(q).is_empty(), + "nothing on the files' ports" + ); + // Its project and container name stay the main tree's. + let ps = compose_ps(&project); + assert!( + ps.iter() + .any(|c| c.0 == "media" && c.1 == name && c.2.contains(&sp) && c.2.contains(&sq)), + "{ps:?}" + ); + let shown = format!("{p}->127.0.0.1:{sp}"); + assert!( + wait_for( + || cli(&["status"]).1.contains(&shown), + Duration::from_secs(30) + ), + "{}", + cli(&["status"]).1 + ); + assert!(cli(&["status"]).1.contains("shifted +1000")); + let (_, run) = cli(&["run", "port"]); + assert!( + run.lines() + .any(|l| l.trim_end() == format!("port={sp} web={sq} var={sp}")), + "{run}" + ); + + // Back on the files' ports. + let (ok, down) = cli(&["down"]); + assert!(ok, "{down}"); + let (ok, up) = cli(&["up", "--shift", "none"]); + assert!(ok, "{up}"); + let back = format!("announces-{p}"); + assert!( + wait_for(|| says(p).contains(&back), Duration::from_secs(30)), + "{:?}\n{up}", + says(p) + ); + assert!(says(q).contains(&back)); + assert!(says(sp).is_empty(), "nothing on the shifted port"); +} diff --git a/docs/adr/0082-the-ports-of-a-machine.md b/docs/adr/0082-the-ports-of-a-machine.md index 0b1b5e89..2f6b8b32 100644 --- a/docs/adr/0082-the-ports-of-a-machine.md +++ b/docs/adr/0082-the-ports-of-a-machine.md @@ -1,6 +1,6 @@ # 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 +- **Status:** Accepted (card in the pull request); built - **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) @@ -35,16 +35,26 @@ The owner approved the points in the conversation on **2026-10-30**: 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. + ports, lanes 1–3 included, are free of every claim and of what the OS + reports in use. - `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. +- Decided while building points 3 to 7: `auto` never answers 0 (a + shift asked for moves the stack), and `--shift ` takes multiples of + 1000 only. While the main tree's daemon runs, `up` with another shift + is refused (`devstack down` first), as `up` with another profile is: a + restart would also move its started lanes. `--shift` in a worktree is + refused: a lane follows the main tree. The claims are written at the + daemon's start and every 5 s when they changed, not while it stops (its + containers go then), and stay after it. A lane keeps the number it has + across starts; only a new number avoids the other stacks' claims. ## Built -Points 1 (0.33.0) and 2 are built: +All seven points are built: 1 and 2 in 0.33.0, 3 to 7 after it. 1. **`GET /v1/ports`** answers `{ports: [PortEntry]}`, one entry per port and protocol a tree holds or uses: `port`, `proto` (`tcp`, `udp`), @@ -100,8 +110,60 @@ Points 1 (0.33.0) and 2 are built: the same. The mock's instance ports are stable, and its shop has a lane on a node (`review-cart` on `build-server`) from the start. -What follows: the shift with the claims in the registry, `up --shift`, the -stack page's button and the start check (points 3 to 7). +6. **The registry entry** (`/devstack/stacks/.json`) + grows two fields, both absent when empty, so an entry of an older + devstack reads as unshifted without claims: `shift` (a multiple of + 1000) and `claims: {main: [port], lanes: {: [port]}}`. Every + write keeps what it does not set, a field of a newer devstack too (a + registration at the daemon's start keeps the shift and the claims). + An entry whose folder or stack file is gone claims nothing. +7. **The shift** (points 3, 4, 7): the main tree's daemon reads it from + the entry when it starts and moves the stack by it the way a lane on a + node is moved (`lanes::lane_stack`): every service's `port`, so its + proxy, a dev server's front, `{service.x.port}` and `{service.x.url}`, + and the ports on this machine its `urls` name. The main tree's Compose + overlay (`.main.yml`, `devstack_compose::main_overlay`) moves + every published host port of every service by it, an announced port + inside the container too (as a lane's does), holds a `main_ports` + container's fixed port on its shifted number, and keeps the project, + the container names, the networks and the volumes; the + `DEVSTACK_PORT_*` variables and `{compose.g.s.port}` follow. A lane + takes its offset from the shifted ports: its own stack file, its + containers and a node running it start from the files' ports moved by + shift + number × `port_step`; a lane's `shared` containers are the main + tree's, on the shifted ports. `/v1/stack` and the hub's stack list + carry `shift`; `/hub/ports` and `doctor` move a stopped stack's + declared ports by it. Ports written outside devstack's placeholders + (an app's config file) do not follow. +8. **The claims** (point 5): the main tree's daemon writes them when it + starts and every 5 s while they change (a lane numbered, a container + published), not while it stops: the main slot's ports (services, + containers' host ports, held ports, announced ports and `urls`, after + the shift) and, for each lane it numbered, started or not, those moved + by its offset. They stay when the daemon stops. A new lane number + avoids the other stacks' claims as well as its own stack's ranges and + what the OS reports in use. +9. **`devstack up` in the main tree** (point 6), before the daemon starts: + the stack's ports at shift 0 (what its stack file declares and what + its daemon claimed for the main slot last time, moved back by the + shift it had) moved by the shift it will run with, against every other + stack's claims. A running stack's (its daemon lives) refuses the start + with that stack's name, folder and ports and offers `devstack up + --shift auto` (with the shift it would take) and `--shift `; a + stopped stack's is a warning, and the start goes on. `--shift + auto||none` sets the entry's shift, checked the same way, then + starts. While the daemon runs, a shift other than the one it runs + with is refused (`devstack down` first), as another profile is; + `auto` while it runs shifted already keeps that shift. In a worktree + `--shift` is refused: a lane follows the main tree. The MCP `up` tool + takes `shift` the same way. +10. **The hub and the dashboard**: `POST /hub/stacks/{id}/up` takes + `{"shift"}` and passes it on as `--shift`; the stack page shows + "shifted +N" beside the daemon line, and a *Start with shift* button + (`auto`) beside *Start stack* while the stack is stopped and a running + stack holds one of its ports (from `/hub/ports`), or its last start + was refused for that. The mock hub refuses a start of `docs` while + `shop` holds 4200 and starts it shifted with the button. ## Consequences diff --git a/docs/adr/README.md b/docs/adr/README.md index 0b95923c..a05d7222 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -164,4 +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: points 1 (`/v1/ports`, `/hub/ports`, `doctor`) and 2 (the graph) in 0.33.0; the shift follows | +| [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: points 1 (`/v1/ports`, `/hub/ports`, `doctor`) and 2 (the graph) in 0.33.0, points 3 to 7 (the shift, the claims, `up --shift`) after it | diff --git a/docs/dashboard-api.md b/docs/dashboard-api.md index 531a514e..132d8591 100644 --- a/docs/dashboard-api.md +++ b/docs/dashboard-api.md @@ -52,7 +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/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"`, moved by its shift on this machine: 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 | @@ -77,7 +77,7 @@ and `Referrer-Policy: no-referrer`. The dashboard has no inline script. | `POST /hub/stacks/add {"path"}` | cookie + Origin | registers a folder that has a `devstack.toml`; `{id, name}` | | `GET /hub/docker` | cookie | `DockerEngine`: `{"state":"running","version"}`, `{"state":"stopped","starter":"Docker Desktop"\|"OrbStack"\|null,"hint"}` (OrbStack on macOS without Docker Desktop) or `{"state":"missing"}`; the hub looks every 10 s | | `POST /hub/docker/start` | cookie + Origin | `202 {"started":"Docker Desktop"}` (or `"OrbStack"`), `200` when it already runs, `409 {"error"}` where devstack cannot start it (Docker Engine as a system service on Linux needs root) | -| `POST /hub/stacks/{id}/up` | cookie + Origin | `202`; the stack starts (daemon, infrastructure, autostart services). Right after a `down` from the hub, the start waits until the old daemon is gone (at most 2 minutes) | +| `POST /hub/stacks/{id}/up {"shift"?}` | cookie + Origin | `202`; the stack starts (daemon, infrastructure, autostart services), as `devstack up` does. Right after a `down` from the hub, the start waits until the old daemon is gone (at most 2 minutes). `shift` (`"auto"`, `"none"` or a multiple of 1000, a number or a string) is `devstack up --shift` ([ADR-0082](adr/0082-the-ports-of-a-machine.md)): the stack runs on this machine with its ports moved by it, and keeps it until `"none"`; `400` for anything else. A start that a running stack's ports refuse (see [Several stacks on one machine](stack-file.md#several-stacks-on-one-machine)) is the stack's `start_error`, which names that stack and offers `--shift auto` | | `POST /hub/stacks/{id}/down?infra=true&all=false&restart=false` | cookie + Origin | `202`; the containers are removed first (`POST /v1/down?infra=true`, volumes stay; the default since 0.25.0, [ADR-0068](adr/0068-a-stop-takes-the-containers-too.md)), then the daemon is told to shut down (`POST /v1/shutdown`): from then on it takes no requests, stops its services, and exits. With `infra=false` the containers keep running and the daemon shuts down at once (`POST /v1/shutdown?unless_lanes=true`). While a lane runs, only the main tree's services and its containers stop and the daemon stays for the lanes, with what serves them and the `shared` containers they use (ADR-0024, ADR-0030, ADR-0068); `all=true` stops the lanes too and exits, and with `infra=true` their containers and the main tree's go as well (`POST /v1/down?infra=true&all=true`). `restart=true` (the dashboard's "Restart stack", followed by `up`; `infra` does not apply) exits while lanes run too and leaves them to the next daemon (`POST /v1/shutdown?restart=true`, [ADR-0052](adr/0052-a-restart-carries-the-lanes-over.md)); a daemon without `carries_lanes` stops them | | `DELETE /hub/stacks/{id}` | cookie + Origin | `204`; forget a stack (it registers again when it starts) | | `ANY /hub/stacks/{id}/api/` | cookie (+ Origin for non-GET) | the daemon's `/`, streams passed through; the daemon is the one its `daemon.json` names at that moment. `503 {"error":"stack not running…"}` when it is down or does not take the connection within 5 s; `503` with `Retry-After: 2` and an `error` that says so when the stack is starting, stopping or restarting (see below). The daemon's `Set-Cookie` and hop-by-hop headers are not passed on | @@ -148,6 +148,7 @@ type StackEntry = { carries_lanes: boolean } | null; // carries_lanes: a restart leaves its lanes to the next daemon (ADR-0052) last_seen: number | null; // when the hub last saw it running start_error?: string; // why the last start from the dashboard failed (end of `devstack up`'s output; hub/up-.log), until it runs + shift: number; // what its main tree's ports are moved by on this machine (ADR-0082), 0: the stack file's; older hubs omit it }; ``` @@ -223,6 +224,7 @@ type StackView = { name: string; repo: string; version: string; services: ServiceView[]; infra: InfraView[]; groups: GroupView[]; commands: CommandView[]; config_changed: boolean; // the stack file differs from what the daemon read at start; applies with the next start + shift?: number; // what the main tree's ports are moved by on this machine (ADR-0082); absent: none lane?: string; // in a lane's view: its name branch?: string; // the checked-out branch of this tree main_served_by?: string; // the lane that serves the main ports, if not the main tree diff --git a/docs/stack-file.md b/docs/stack-file.md index 83999102..dae41e55 100644 --- a/docs/stack-file.md +++ b/docs/stack-file.md @@ -805,6 +805,48 @@ stop the lane first: see `prepare`). Ignored files are not in the snapshot: (ADR-0038); with `--older-than`, also entries of the store of `prepare` that no copy used for as long (ADR-0062). +## Several stacks on one machine + +Stacks of different repositories often give the same ports (5000, 4200, +5432). Nothing in the stack file is about that: a stack can run on one +machine moved by a **port shift**, which is that machine's own +([ADR-0082](adr/0082-the-ports-of-a-machine.md)). + +- **Claims.** Every daemon writes the ports it claims into its stack's + entry in the user's registry of stacks: the main slot's (its services' + ports, its containers' host ports, its announced ports and the ports its + `urls` name, after its shift) and the range of each lane it numbered. + They stay when the stack stops: whoever was first keeps its ports. A new + lane number avoids every other stack's claims too. A stack whose folder + or stack file is gone claims nothing. +- **`devstack up` checks them** in the main working tree before the daemon + starts. When a running stack claims one of its ports, it starts nothing, + names that stack and its ports, and offers a shift; when only a stopped + stack does, it warns and starts. `devstack doctor` lists every port two + stacks share. +- **`devstack up --shift auto`** runs the stack moved by the lowest + multiple of 1000 whose ports, and three lanes' above them, are free of + every claim and of what runs on this machine; `--shift ` (a multiple + of 1000) by that one; `--shift none` on the stack file's ports again. + The shift sticks to the stack on this machine until `--shift none`, and + `devstack status` and the stack's page show it (`shifted +1000`). A + daemon that runs keeps its shift: `devstack down` first. The dashboard's + *Start with shift* and the MCP `up` tool's `shift` do the same. +- **What moves:** everything a lane's offset moves. Each service's `port` + (its proxy, a dev server's front, `{service..port}` and + `{service..url}`), every host port the Compose files publish (a + `main_ports` container's held port too), each announced port, inside the + container as well (its `DEVSTACK_PORT_*` variable follows), the ports on + this machine a group's `urls` name, and the lanes, whose offset is added + to the shifted ports. +- **What stays:** the Compose project, container names, networks, volumes + and their data: a shifted stack is the same stack on other ports. The + stack file is not changed. +- **What does not follow:** a port written outside devstack's placeholders, + in an app's own configuration file or a hard-coded URL, and bookmarks. + Use `{service..port}`, `{service..url}` and + `{compose....port}` where a port matters. + ## `[commands.]`: operations ```toml diff --git a/skills/devstack/SKILL.md b/skills/devstack/SKILL.md index b48bba6a..8110cdf6 100644 --- a/skills/devstack/SKILL.md +++ b/skills/devstack/SKILL.md @@ -55,6 +55,7 @@ Run from the repository root (devstack looks for `{{devstack:stack_file}}` there | `devstack logs ` | a Compose container's output by its service name (`docker compose logs`, timestamped; `--grep`, `--since 30m`) | | `devstack builds ` | the service's builds, newest first: live, failed (with the reason), superseded by a newer change, held; answers "did my change ever go live" and "since when is it broken" | | `devstack hot-reload on\|off` | for a debugging session: the toolchain's watcher (e.g. `dotnet watch`) from the source tree instead of builds and candidates; needs `hot_reload` in the stack file. Switch it off again afterwards | +| `devstack up` refuses: "the stack `` … runs on " | another repository's stack on this machine runs on the same ports. Nothing started. Ask the user, then `devstack up --shift auto` (MCP `up` with `shift: "auto"`): this stack runs on its ports + 1000 (2000, ...) on this machine, services, containers and links alike, its data unchanged, and keeps the shift until `devstack up --shift none`. Ports written into app config outside devstack's placeholders do not move. A stopped stack on the same ports only warns. `devstack status` shows `shifted +1000` | | `devstack up` in a `git worktree` | starts that worktree's **lane** (every worktree is one) in the main tree's daemon: its own builds, ports moved by +100 (+200, ...), its own containers (a Compose project per lane, ports moved alike; the deprecated `shared` services come from the main tree), no background workers (`--workers` turns them on; `--force` skips the memory check). Every command in the worktree then acts on the lane (`devstack lane list` shows all) | | `git worktree add -b ` | a new worktree is a lane at once, not started (devstack never creates or removes worktrees; use git or your tool); tell the user its dashboard page (`devstack ui --link` in it). A lane runs from a copy of its worktree, so `git worktree remove` works while it runs and stops it. `devstack lane stop ` stops a lane and keeps the worktree | | a directory in a worktree that `prepare` links (`node_modules`, see `devstack lane list`: `linked`) | it is **the lane's copy**, not the worktree's own: on Windows a junction, which looks like a folder. Do not reinstall, delete or clean it there (`npm ci`, `rm -rf`, `git clean -x`, an IDE's "delete node_modules"): that empties what the lane runs from (for a lane on a node, its copy from the store), and a running dev server then fails. To install your own, ask the user, then stop the lane (`devstack lane stop ` removes the link) or remove only the link (`cmd /c rmdir ` on Windows, without `/s`; `rm ` elsewhere, no trailing slash), then install. devstack prepares a copy emptied this way again (a notice says so), but the lane's dev server fails until then | @@ -148,7 +149,7 @@ node), `down`, `start_service`, `stop_service`, `restart_service`, `hub_status`, `hub_start`. `status`, `up` and `lane_start` end with the `dashboard:` page, `lanes` names each lane's. `lane_start` and `lane_stop` work from the main tree too: an agent there runs another worktree's lane without moving to it. `up` also -takes `profile`, `run` and `yes` (as `--profile`, `--run`, `--yes`) and +takes `profile`, `run`, `yes` and `shift` (as `--profile`, `--run`, `--yes`, `--shift`) and `timeout_s` (how long it waits for the services and containers; the stack keeps starting); `lane_start` takes `profile`, `run` and `timeout_s` too; `logs` takes `file` (one kept log, from its list); `diagnostics` takes `source: "hub"` for the hub's log. Each tool diff --git a/ui/src/api/mock/index.ts b/ui/src/api/mock/index.ts index 617ba3c0..a0715129 100644 --- a/ui/src/api/mock/index.ts +++ b/ui/src/api/mock/index.ts @@ -19,7 +19,8 @@ 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'; +import { autoShift, daemonPorts, hubPorts, refusal, type MockLane } from './ports'; +import { runningHolders } from '@/lib/shift'; type Route = { stop(): void } | null; @@ -846,7 +847,24 @@ export class MockTransport implements Transport { return json(200, { registered, files: registered ? [`${stack.entry.repo}\\.mcp.json`] : [] }); } if (rest === '/up' && method === 'POST') { - if (!stack.running) void stack.goUp(); + if (stack.running) return empty(202); + // `devstack up --shift` (ADR-0082): set it, then start. + const asked = body?.shift; + if (asked !== undefined && asked !== null) { + stack.setShift(asked === 'auto' ? autoShift(stack, hub.stacks) : asked === 'none' ? 0 : Number(asked)); + } else { + // Nobody moves silently: a running stack on its ports refuses the start. + const holders = runningHolders(stack.entry.id, hubPorts(hub.stacks, portLanes).overlaps); + if (holders.length) { + setTimeout(() => { + stack.entry = { ...stack.entry, start_error: refusal(holders) }; + hub.publish(); + }, 600); + return empty(202); + } + } + stack.entry = { ...stack.entry, start_error: undefined }; + void stack.goUp(); return empty(202); } // A stopped stack's worktrees, from the hub (ADR-0078): one for each. diff --git a/ui/src/api/mock/ports.test.ts b/ui/src/api/mock/ports.test.ts index a975ac7a..5d12aeb0 100644 --- a/ui/src/api/mock/ports.test.ts +++ b/ui/src/api/mock/ports.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { docsSeed, shopSeed } from './data'; -import { daemonPorts, hubPorts } from './ports'; +import { autoShift, daemonPorts, hubPorts, refusal } from './ports'; +import { runningHolders } from '@/lib/shift'; import { MockStack } from './stack'; describe('mock ports (ADR-0082)', () => { @@ -23,4 +24,21 @@ describe('mock ports (ADR-0082)', () => { expect(h.overlaps[0]).toMatchObject({ port: 4200, proto: 'tcp' }); expect(h.overlaps[0].stacks.map((u) => u.name).sort()).toEqual(['docs', 'shop']); }); + + it('starts a colliding stack moved by a shift (ADR-0082)', () => { + const other = new MockStack(docsSeed(now)); + const h = hubPorts([shop, other], () => []); + const holders = runningHolders(other.entry.id, h.overlaps); + expect(holders).toEqual([{ name: 'shop', ports: [4200] }]); + expect(refusal(holders)).toContain('devstack up --shift auto'); + const shift = autoShift(other, [shop, other]); + expect(shift).toBe(1000); + other.setShift(shift); + expect(other.entry.shift).toBe(1000); + expect(other.view.services.find((v) => v.name === 'site')?.port).toBe(5200); + expect(hubPorts([shop, other], () => []).overlaps).toHaveLength(0); + other.setShift(0); + expect(other.entry.shift).toBeUndefined(); + expect(other.view.services.find((v) => v.name === 'site')?.port).toBe(4200); + }); }); diff --git a/ui/src/api/mock/ports.ts b/ui/src/api/mock/ports.ts index 431576cc..5e73f8c9 100644 --- a/ui/src/api/mock/ports.ts +++ b/ui/src/api/mock/ports.ts @@ -91,6 +91,36 @@ export function overlapsOf(stacks: StackPorts[]): PortOverlap[] { return [...by.values()].filter((o) => new Set(o.stacks.map((u) => u.id)).size > 1).sort((a, b) => a.port - b.port); } +/** Every port the stack's main tree and its first three lanes would use, moved by `by`. */ +function footprint(s: MockStack, by: number): number[] { + const base = declared(s).map((e) => e.port); + return [0, 100, 200, 300].flatMap((lane) => base.map((p) => p + by + lane)); +} + +/** `--shift auto` (ADR-0082): the lowest multiple of 1000 above its current shift's ports free of every other stack's. */ +export function autoShift(s: MockStack, stacks: MockStack[]): number { + const taken = new Set( + stacks.filter((o) => o !== s).flatMap((o) => (o.running ? daemonPorts(o, []) : declared(o)).map((e) => e.port)), + ); + const now = s.entry.shift ?? 0; + for (let shift = 1000; shift <= 60000; shift += 1000) { + if (footprint(s, shift - now).every((p) => p < 65536 && !taken.has(p))) return shift; + } + return 1000; +} + +/** What `devstack up` says when a running stack holds the ports. */ +export function refusal(holders: { name: string; ports: number[] }[]): string { + const who = holders.map((h) => `the stack \`${h.name}\` runs on ${h.ports.join(', ')}`).join('; '); + return [ + `this stack would start on its ports, and ${who}. Nothing started; whoever was first keeps its ports.`, + 'Start this stack moved by a port shift on this machine (its services, containers and links move; its data stays):', + ' devstack up --shift auto', + ' devstack up --shift (a multiple of 1000)', + 'The shift sticks to this stack on this machine until `devstack up --shift none`.', + ].join('\n'); +} + /** `GET /hub/ports`. */ export function hubPorts(stacks: MockStack[], lanesOf: (s: MockStack) => MockLane[]): HubPorts { const list: StackPorts[] = stacks.map((s) => ({ diff --git a/ui/src/api/mock/stack.ts b/ui/src/api/mock/stack.ts index d76a5ba9..2c4a8834 100644 --- a/ui/src/api/mock/stack.ts +++ b/ui/src/api/mock/stack.ts @@ -124,6 +124,27 @@ export class MockStack { this.onEntryChange(); } + /** + * Its ports moved by `shift` on this machine (ADR-0082): its services' + * ports and links and its containers' host ports, from where they are now. + */ + setShift(shift: number): void { + const by = shift - (this.entry.shift ?? 0); + if (!by) return; + for (const s of this.view.services) { + if (s.port === null) continue; + s.port += by; + if (s.url) s.url = s.url.replace(/:(\d+)$/, `:${s.port}`); + } + for (const g of this.view.infra) { + for (const c of g.containers) { + c.ports = c.ports.map((p) => p.replace(/:(\d+)$/, (_, n: string) => `:${Number(n) + by}`)); + } + } + this.entry = { ...this.entry, shift: shift || undefined }; + this.view.shift = shift || undefined; + } + /** `main: false`: the daemon only, as a lane's start brings it up (ADR-0078). */ async goUp(opts: { main?: boolean } = {}): Promise { const gen = this.generation; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 413caec2..34b56049 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -22,6 +22,8 @@ export type StackEntry = { last_seen: number | null; /** Why the last start from the dashboard failed, while the stack is down. Older hubs omit it. */ start_error?: string; + /** What its main tree's ports are moved by on this machine (ADR-0082); 0: the stack file's ports. Older hubs omit it. */ + shift?: number; }; export type HubSession = { @@ -452,6 +454,8 @@ export type StackView = { name: string; repo: string; version: string; + /** What the main tree's ports are moved by on this machine (ADR-0082); absent: none. */ + shift?: number; services: ServiceView[]; infra: InfraView[]; groups: GroupView[]; diff --git a/ui/src/lib/shift.test.ts b/ui/src/lib/shift.test.ts new file mode 100644 index 00000000..ced6ab42 --- /dev/null +++ b/ui/src/lib/shift.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import type { PortOverlap } from '@/api/types'; +import { refusedForPorts, runningHolders, shiftLabel } from './shift'; + +describe('port shift (ADR-0082)', () => { + const overlaps: PortOverlap[] = [ + { + port: 4200, + proto: 'tcp', + stacks: [ + { id: 'docs', name: 'docs', lane: 'main', holder: 'site', kind: 'declared' }, + { id: 'shop', name: 'shop', lane: 'main', holder: 'web', kind: 'fixed' }, + ], + }, + { + port: 8090, + proto: 'tcp', + stacks: [ + { id: 'docs', name: 'docs', lane: 'main', holder: 'search-api', kind: 'declared' }, + { id: 'blog', name: 'blog', lane: 'main', holder: 'api', kind: 'declared' }, + ], + }, + ]; + + it('names the running stacks that hold a port of a stack, not the stopped ones', () => { + expect(runningHolders('docs', overlaps)).toEqual([{ name: 'shop', ports: [4200] }]); + expect(runningHolders('blog', overlaps)).toEqual([]); + expect(runningHolders('crm', overlaps)).toEqual([]); + }); + + it('tells a start refused for its ports and labels a shift', () => { + expect(refusedForPorts({ start_error: 'Nothing started.\n devstack up --shift auto' })).toBe(true); + expect(refusedForPorts({ start_error: 'port 4000 is taken' })).toBe(false); + expect(refusedForPorts(null)).toBe(false); + expect(shiftLabel(1000)).toBe('shifted +1000'); + expect(shiftLabel(0)).toBeNull(); + expect(shiftLabel(undefined)).toBeNull(); + }); +}); diff --git a/ui/src/lib/shift.ts b/ui/src/lib/shift.ts new file mode 100644 index 00000000..3c8328fb --- /dev/null +++ b/ui/src/lib/shift.ts @@ -0,0 +1,36 @@ +// Several stacks on one machine (ADR-0082 points 3 to 7): a stopped stack +// whose ports a running stack holds does not start (`devstack up` refuses +// and names it); the stack page offers to start it with a port shift. +import type { PortOverlap, StackEntry } from '@/api/types'; + +/** A running stack that holds a port the stack `id` uses too. */ +export type Holder = { name: string; ports: number[] }; + +/** + * The running stacks in `overlaps` (`GET /hub/ports`) that hold a port of + * the stack `id`. A stopped stack's ports are `declared`: its collision + * only warns, so it is not listed. + */ +export function runningHolders(id: string, overlaps: PortOverlap[]): Holder[] { + const by = new Map(); + for (const o of overlaps) { + if (!o.stacks.some((u) => u.id === id)) continue; + for (const u of o.stacks) { + if (u.id === id || u.kind === 'declared' || u.kind === 'instance') continue; + const h = by.get(u.id) ?? { name: u.name, ports: [] }; + if (!h.ports.includes(o.port)) h.ports.push(o.port); + by.set(u.id, h); + } + } + return [...by.values()]; +} + +/** The last start failed because a running stack holds its ports (`devstack up` offers `--shift`). */ +export function refusedForPorts(entry: Pick | null | undefined): boolean { + return !!entry?.start_error?.includes('--shift auto'); +} + +/** "shifted +1000", or null for a stack on its own ports. */ +export function shiftLabel(shift: number | undefined): string | null { + return shift ? `shifted +${shift}` : null; +} diff --git a/ui/src/stores/stack.ts b/ui/src/stores/stack.ts index f869989b..8933c419 100644 --- a/ui/src/stores/stack.ts +++ b/ui/src/stores/stack.ts @@ -1146,7 +1146,11 @@ export const useStack = defineStore('stack', () => { return containerAction(group, service, 'restart'); } - async function startStack() { + /** + * Start the stack (`devstack up`); with `shift`, moved by a port shift on + * this machine (`--shift`, ADR-0082), which sticks to it until `none`. + */ + async function startStack(shift?: 'auto' | 'none' | number) { if (refuseReadOnly()) return; if (!id.value) return; const stackId = id.value; @@ -1161,7 +1165,12 @@ export const useStack = defineStore('stack', () => { return; } hub.starting[stackId] = true; - const res = await act({ label: `Start ${entry.value?.name ?? 'stack'}`, path: paths.up(stackId), success: 'Starting the stack…' }); + const res = await act({ + label: `Start ${entry.value?.name ?? 'stack'}`, + path: paths.up(stackId), + body: shift === undefined ? undefined : { shift }, + success: shift === undefined ? 'Starting the stack…' : 'Starting the stack with a port shift…', + }); if (!res.ok) delete hub.starting[stackId]; } diff --git a/ui/src/views/StackPage.vue b/ui/src/views/StackPage.vue index 26743ea2..5ca03f34 100644 --- a/ui/src/views/StackPage.vue +++ b/ui/src/views/StackPage.vue @@ -22,6 +22,7 @@ import { useStack } from '@/stores/stack'; import AgentPrompt from '@/components/AgentPrompt.vue'; import Icon from '@/components/icons/Icon.vue'; import PortGraph from '@/components/PortGraph.vue'; +import { refusedForPorts, runningHolders, shiftLabel } from '@/lib/shift'; import StatusPill from '@/components/StatusPill.vue'; const route = useRoute(); @@ -180,6 +181,20 @@ watch( { immediate: true }, ); const portLanes = computed(() => lanes.value.map((l) => ({ name: l.name, node: l.node ? nodeLabel(l) : null }))); + +// ------------------------------------------------------------ port shift + +/** Its shift on this machine (ADR-0082): "shifted +1000". */ +const shifted = computed(() => shiftLabel(stack.entry?.shift ?? stack.mainView?.shift)); +/** Running stacks that hold its ports: `up` would refuse, naming them. */ +const holders = computed(() => (stack.id && ports.value?.of === stack.id ? runningHolders(stack.id, ports.value.overlaps) : [])); +/** A stopped stack that collides with a running one, or whose start was refused for it: offer the shift. */ +const collides = computed(() => !stack.running && (holders.value.length > 0 || refusedForPorts(stack.entry))); +const collisionText = computed(() => { + const h = holders.value; + if (!h.length) return 'Another running stack holds its ports.'; + return `${h.map((x) => `${x.name} holds ${x.ports.join(', ')}`).join('; ')}.`; +}); function openLane(name: string) { if (stack.id) void router.push(laneView(stack.id, name, lastTab(route.name))); } @@ -228,6 +243,13 @@ const singletons = computed(() => services.value.filter((s) => s.singleton)); devstack {{ daemon.version }} last seen {{ ago(stack.entry.last_seen, now) }} + {{ shifted }}

@@ -235,6 +257,16 @@ const singletons = computed(() => services.value.filter((s) => s.singleton)); +