From 3a7473f66734077425f0a5ae2711308dc4d36248 Mon Sep 17 00:00:00 2001
From: Sebastian Gieseler
Date: Sat, 31 Oct 2026 11:33:56 +0100
Subject: [PATCH] feat: a stack can run shifted on this machine (#387,
ADR-0082)
The last step of review card C. A stack's registry entry holds a port
shift (machine-local, never in the stack file) and the ports its daemon
claims. `devstack up` refuses to start on ports a running stack holds,
names it and offers `--shift auto|`; a stopped stack's claims only
warn. The shift moves every port a lane offset moves (services and their
proxies and forwards, container host and announced ports, `urls`,
placeholders, lanes) and keeps the Compose project, containers and
volumes; it sticks until `--shift none`. New lane numbers avoid other
stacks' claims. MCP `up`, `POST /hub/stacks/{id}/up {shift}` and a
"Start with shift" button on the stack page take it too.
Closes #387
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 17 +
crates/devstack-compose/src/lib.rs | 279 ++++++++----
crates/devstack-daemon/src/lanes.rs | 171 ++++++-
crates/devstack-daemon/src/lib.rs | 35 +-
crates/devstack-daemon/src/ports.rs | 13 +
crates/devstack-daemon/src/registry.rs | 272 ++++++++++-
crates/devstack-daemon/src/state.rs | 8 +
.../devstack-daemon/src/supervisor/infra.rs | 39 +-
crates/devstack-daemon/src/supervisor/mod.rs | 31 ++
crates/devstack-hub/src/lib.rs | 93 +++-
crates/devstack-hub/src/ports.rs | 6 +-
crates/devstack-hub/src/summary.rs | 1 +
crates/devstack/src/client.rs | 37 ++
crates/devstack/src/dispatch/stack.rs | 2 +
crates/devstack/src/doctor.rs | 7 +-
crates/devstack/src/main.rs | 11 +
crates/devstack/src/mcp.rs | 11 +
crates/devstack/src/shift.rs | 424 ++++++++++++++++++
crates/devstack/tests/daemon_lifecycle.rs | 294 +++++++++++-
docs/adr/0082-the-ports-of-a-machine.md | 72 ++-
docs/adr/README.md | 2 +-
docs/dashboard-api.md | 6 +-
docs/stack-file.md | 42 ++
skills/devstack/SKILL.md | 3 +-
ui/src/api/mock/index.ts | 22 +-
ui/src/api/mock/ports.test.ts | 20 +-
ui/src/api/mock/ports.ts | 30 ++
ui/src/api/mock/stack.ts | 21 +
ui/src/api/types.ts | 4 +
ui/src/lib/shift.test.ts | 39 ++
ui/src/lib/shift.ts | 36 ++
ui/src/stores/stack.ts | 13 +-
ui/src/views/StackPage.vue | 32 ++
33 files changed, 1937 insertions(+), 156 deletions(-)
create mode 100644 crates/devstack/src/shift.rs
create mode 100644 ui/src/lib/shift.test.ts
create mode 100644 ui/src/lib/shift.ts
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));
{{ starting ? 'Starting…' : 'Start stack' }}
+
+ Start with shift
+