diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d1fe211..56f91ea 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -65,6 +65,7 @@ jobs: --test lifecycle \ --test volumes \ --test sidecars \ + --test networks \ -- --test-threads=1 - name: Stop daemon diff --git a/Cargo.lock b/Cargo.lock index 3f1acfc..6be6142 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "age" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a371ea05d36dce79871e0ff524bc35e7b6320f2f73718e7175915ed1b5af5ce5" +checksum = "fd290633c2482479f70f6d1d96ae0e9f52c6a26cd5859edd47ee1fe33fc89f26" dependencies = [ "age-core", "base64", @@ -2736,7 +2736,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rooz" -version = "0.152.0" +version = "0.153.0" dependencies = [ "age", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 093368b..f8cde01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rooz" -version = "0.152.0" +version = "0.153.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 4ceeac8..70eec42 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,36 @@ rooz enter secrets-test image: my:tools ``` - All containers within a workspace are connected to a workspace-wide network. They can *talk* to each other using sidecar names. In the above examples that would be `sql` and `tools`. Also the usual container ID and IP works too, but it is not as convenient. + `image` may be omitted in individual config layers (e.g. an overlay extending a sidecar defined in a base via `bases:`) but must be set for each sidecar once all layers are merged, otherwise creating the workspace fails. + +### Networking + +Workspace networking follows a hub-and-spoke model: + +* the work container can reach every sidecar — it shares a dedicated pair network with each one +* a sidecar can always reach the work container (pair-network reachability is symmetric; keep listeners in the work container minimal) +* sidecars cannot reach each other unless explicitly connected via `peers:` +* workspaces are isolated from each other + +`peers:` lists other sidecar names a sidecar may talk to. Each declared relation gets its own network, `a: peers [b]` and `b: peers [a]` are equivalent, and peer names must refer to sidecars defined in the same workspace. Sidecars resolve each other by name over their peer network: + + ```yaml + sidecars: + claude: + peers: [proxy] # claude's only egress path is the proxy + proxy: + egress: true # reachable only by the work container and claude + dkr: + peers: [images] # docker-in-docker pulling via the mirror + images: + egress: true # registry pull-through cache + ``` + + Unlike other list fields (replaced by higher config layers), `peers` merge as a deduplicated union across layers: an overlay can add reachability edges but cannot remove inherited ones. + + :warning: rooz creates one network per sidecar plus one per peer relation. Docker's default address pools allow only ~31 networks host-wide; if network creation fails with an address-pool error, configure `default-address-pools` with a smaller subnet size (e.g. `"size": 24`) in `daemon.json`. Podman (netavark) is not affected. + + The work container addresses sidecars by their names (`sql` and `tools` in the first example above). The usual container ID and IP work too, but are not as convenient. * the `enter` command lets you specify `--container` to enter (otherwise it enters the work container). @@ -291,6 +320,7 @@ Supported keywords: ``` * `ports` - port bindings in the `"8080:8080"` format +* `peers` - names of other sidecars this sidecar may reach (see [Networking](#networking); merged as a union across config layers) * `work_dir` - set working directory * `mount_work` (`bool`) - if true then the work volume is mounted at `/work` diff --git a/scripts/test-daemon.sh b/scripts/test-daemon.sh index d919fb3..0c50d29 100755 --- a/scripts/test-daemon.sh +++ b/scripts/test-daemon.sh @@ -7,7 +7,7 @@ # # After "up", eval the printed exports to configure the test environment: # eval "$(scripts/test-daemon.sh up docker)" -# cargo test --test smoke --test lifecycle --test volumes --test sidecars -- --test-threads=1 +# cargo test --test smoke --test lifecycle --test volumes --test sidecars --test networks -- --test-threads=1 # # Requires: # - docker available on PATH diff --git a/src/api/container.rs b/src/api/container.rs index 9a13e9e..3187c6e 100644 --- a/src/api/container.rs +++ b/src/api/container.rs @@ -123,6 +123,7 @@ impl<'a> ContainerApi<'a> { { Ok(_) => { log::debug!("Removed container: {}{}", &container_id, &force_display); + self.wait_removed(container_id).await; Ok(()) } Err(DockerResponseServerError { @@ -143,6 +144,7 @@ impl<'a> ContainerApi<'a> { &container_id, &force_display ); + self.wait_removed(container_id).await; Ok(()) } Err(DockerResponseServerError { @@ -158,6 +160,22 @@ impl<'a> ContainerApi<'a> { } } + async fn wait_removed(&self, container_id: &str) { + let _ = timeout(Duration::from_secs(30), async { + loop { + match self + .client + .inspect_container(container_id, None::) + .await + { + Ok(_) => sleep(Duration::from_millis(100)).await, + Err(_) => break, + } + } + }) + .await; + } + pub async fn kill(&self, container_id: &str, wait_for_remove: bool) -> Result<(), AnyError> { match self .client diff --git a/src/api/sidecar.rs b/src/api/sidecar.rs index febc176..df97bd2 100644 --- a/src/api/sidecar.rs +++ b/src/api/sidecar.rs @@ -24,6 +24,7 @@ impl<'a> WorkspaceApi<'a> { ) -> Result { let mut cfg = config.clone(); let labels = Labels::from(&[Labels::workspace(workspace_key)]); + let (_, peer_relations) = RuntimeConfig::workspace_networks(&config.sidecars); for (name, s) in &mut cfg.sidecars { log::debug!("Process sidecar: {}", name); @@ -79,8 +80,16 @@ impl<'a> WorkspaceApi<'a> { .map(|x| x.to_string()) .unwrap_or(constants::ROOT_UID.to_string()); - let internal_network = &constants::internal_network(workspace_key); + let pair_network = &constants::pair_network(workspace_key, name); let egress_network = &constants::egress_network(workspace_key); + let mut extra_networks = peer_relations + .iter() + .filter(|(a, b)| a == name || b == name) + .map(|(a, b)| constants::peer_network(workspace_key, a, b)) + .collect::>(); + if s.egress { + extra_networks.push(egress_network.to_string()); + } let run_spec = RunSpec { reason: &container_name, container_name: &container_name, @@ -90,11 +99,11 @@ impl<'a> WorkspaceApi<'a> { workspace_key: &workspace_key, labels: labels.clone(), env: Some(s.env.clone()), - default_network: Some(internal_network), - additional_networks: if s.egress { - Some(vec![egress_network]) - } else { + default_network: Some(pair_network), + additional_networks: if extra_networks.is_empty() { None + } else { + Some(extra_networks.iter().map(|n| n.as_str()).collect()) }, network_aliases: Some(vec![name.into()]), command: if cmd.is_empty() { @@ -128,6 +137,7 @@ impl<'a> WorkspaceApi<'a> { .create(RunSpec { run_mode: RunMode::SidecarInstall, default_network: Some(egress_network), + additional_networks: None, // IMPORTANT: do not inject the sidecar env so it doesn't get baked into // the runtime image. It also ensures unaltered behavior of the base image // during the installation stage diff --git a/src/api/volume.rs b/src/api/volume.rs index 977385e..39eafb6 100644 --- a/src/api/volume.rs +++ b/src/api/volume.rs @@ -627,7 +627,7 @@ mod tests { let mut mounts = HashMap::new(); mounts.insert( - ("main".to_string(), "/mydir".to_string()), + ("work".to_string(), "/mydir".to_string()), MountSource::DataEntryReference(DataEntryKey("mydir".to_string())), ); @@ -640,7 +640,7 @@ mod tests { fn inline_file_mount_gets_inline_fallback_volume() { let mut mounts = HashMap::new(); mounts.insert( - ("main".to_string(), "/config".to_string()), + ("work".to_string(), "/config".to_string()), MountSource::InlineDataValue(inline("hello")), ); @@ -654,11 +654,11 @@ mod tests { fn multiple_inline_files_share_inline_volume() { let mut mounts = HashMap::new(); mounts.insert( - ("main".to_string(), "/file-a".to_string()), + ("work".to_string(), "/file-a".to_string()), MountSource::InlineDataValue(inline("aaa")), ); mounts.insert( - ("main".to_string(), "/file-b".to_string()), + ("work".to_string(), "/file-b".to_string()), MountSource::InlineDataValue(inline("bbb")), ); diff --git a/src/cli.rs b/src/cli.rs index 64090d2..69c4e66 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -208,7 +208,7 @@ pub struct StartParams { } #[derive(Parser, Debug)] -#[command(about = "Restarts a workspace's main container")] +#[command(about = "Restarts a workspace's work container")] pub struct RestartParams { pub name: String, #[arg(long, default_value = "false", help = "")] diff --git a/src/cmd/new.rs b/src/cmd/new.rs index 1944fd1..8fd7b8f 100644 --- a/src/cmd/new.rs +++ b/src/cmd/new.rs @@ -22,6 +22,44 @@ use std::collections::HashMap; use std::fs; impl<'a> WorkspaceApi<'a> { + async fn ensure_network( + &self, + name: &str, + internal: bool, + labels: &Labels, + ) -> Result<(), AnyError> { + match self + .api + .client + .create_network(NetworkCreateRequest { + name: name.to_string(), + internal: if internal { Some(true) } else { None }, + labels: Some(labels.clone().into()), + ..Default::default() + }) + .await + { + Ok(_) => Ok(()), + Err(Error::DockerResponseServerError { + status_code: 409, .. + }) => { + log::debug!("Network already exists: {}. Skipping", name); + Ok(()) + } + Err(e) if e.to_string().contains("non-overlapping IPv4 address pool") => { + Err(format!( + "Could not create network '{}': the daemon ran out of address pools. \ + Rooz creates one network per sidecar plus one per peer relation. \ + Configure 'default-address-pools' with a smaller subnet size (e.g. \"size\": 24) \ + in the daemon config to allow more networks. Original error: {}", + name, e + ) + .into()) + } + Err(e) => Err(e.into()), + } + } + async fn new_core( &self, cfg_builder: &mut RoozCfg, @@ -45,7 +83,7 @@ impl<'a> WorkspaceApi<'a> { .await?; cfg_builder.expand_vars()?; - let cfg = RuntimeConfig::from(&*cfg_builder); + let cfg = RuntimeConfig::try_from(&*cfg_builder)?; self.api .image @@ -92,48 +130,21 @@ impl<'a> WorkspaceApi<'a> { let mut labels = work_spec.labels.clone(); - let internal_network = &constants::internal_network(workspace_key); let egress_network = &constants::egress_network(workspace_key); - match self - .api - .client - .create_network(NetworkCreateRequest { - name: egress_network.to_string(), - labels: Some(labels.clone().into()), - ..Default::default() - }) - .await - { - Ok(_) => {} - Err(Error::DockerResponseServerError { - status_code: 409, .. - }) => { - log::debug!("Network already exists: {}. Skipping", egress_network); - } - Err(e) => return Err(e.into()), - }; + self.ensure_network(egress_network, false, &labels).await?; - if !cfg2.sidecars.is_empty() { - match self - .api - .client - .create_network(NetworkCreateRequest { - name: internal_network.to_string(), - internal: Some(true), - labels: Some(labels.clone().into()), - ..Default::default() - }) - .await - { - Ok(_) => {} - Err(Error::DockerResponseServerError { - status_code: 409, .. - }) => { - log::debug!("Network already exists: {}. Skipping", internal_network); - } - Err(e) => return Err(e.into()), - }; + let (pair_keys, peer_keys) = RuntimeConfig::workspace_networks(&cfg2.sidecars); + let pair_networks = pair_keys + .iter() + .map(|s| constants::pair_network(workspace_key, s)) + .collect::>(); + for n in &pair_networks { + self.ensure_network(n, true, &labels).await?; + } + for (a, b) in &peer_keys { + self.ensure_network(&constants::peer_network(workspace_key, a, b), true, &labels) + .await?; } let cfg2 = self @@ -157,8 +168,8 @@ impl<'a> WorkspaceApi<'a> { .map(|r| r.dir) .unwrap_or(constants::WORK_DIR.to_string()), default_network: Some(egress_network.as_str()), - additional_networks: if !cfg2.sidecars.is_empty() { - Some(vec![internal_network.as_str()]) + additional_networks: if !pair_networks.is_empty() { + Some(pair_networks.iter().map(|n| n.as_str()).collect()) } else { None }, @@ -435,10 +446,10 @@ impl<'a> WorkspaceApi<'a> { .map(|v| (&v).dir.to_string()) .or(Some(workspace.working_dir)); - let cfg = RuntimeConfig::from(&RoozCfg { + let cfg = RuntimeConfig::try_from(&RoozCfg { shell: Some(vec![shell.into()]), ..config - }); + })?; let container_id = self .enter( diff --git a/src/config/config.rs b/src/config/config.rs index d5cb51b..f29719e 100644 --- a/src/config/config.rs +++ b/src/config/config.rs @@ -148,7 +148,7 @@ impl InstallSpec { #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(deny_unknown_fields)] pub struct RoozSidecar { - pub image: String, + pub image: Option, pub env: Option>, pub command: Option>, pub args: Option>, @@ -162,6 +162,7 @@ pub struct RoozSidecar { pub uid: Option, pub egress: Option, pub shell: Option>, + pub peers: Option>, } impl RoozSidecar { @@ -171,12 +172,13 @@ impl RoozSidecar { vars: &IndexMap, ) -> Result { Ok(Self { - image: render_str(reg, &self.image, vars)?, + image: render_opt(reg, &self.image, vars)?, env: render_map(reg, &self.env, vars)?, command: render_vec(reg, &self.command, vars)?, args: render_vec(reg, &self.args, vars)?, shell: render_vec(reg, &self.shell, vars)?, ports: render_vec(reg, &self.ports, vars)?, + peers: render_vec(reg, &self.peers, vars)?, install: render_install(reg, &self.install, vars)?, work_dir: render_opt(reg, &self.work_dir, vars)?, user: render_opt(reg, &self.user, vars)?, @@ -520,7 +522,7 @@ fn merge_sidecars( impl RoozSidecar { pub fn merge_from(&mut self, other: &RoozSidecar) { - self.image = other.image.clone(); + self.image = other.image.clone().or(self.image.take()); self.env = extend_if_any(self.env.clone(), other.env.clone()); self.command = other.command.clone().or(self.command.clone()); self.args = other.args.clone().or(self.args.clone()); @@ -534,6 +536,20 @@ impl RoozSidecar { self.user = other.user.clone().or(self.user.clone()); self.uid = other.uid.or(self.uid); self.egress = other.egress.or(self.egress); + self.peers = union_sorted(self.peers.take(), other.peers.clone()); + } +} + +fn union_sorted(target: Option>, other: Option>) -> Option> { + match (target, other) { + (Some(mut t), Some(o)) => { + t.extend(o); + t.sort(); + t.dedup(); + Some(t) + } + (t, None) => t, + (None, o) => o, } } @@ -898,6 +914,97 @@ mod tests { ); } + #[test] + fn sidecar_without_image_parses() { + let yaml = "sidecars:\n svc:\n env:\n A: b\n"; + let cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(cfg.sidecars.unwrap()["svc"].image, None); + } + + #[test] + fn sidecar_merge_overlay_without_image_keeps_base_image() { + let base_yaml = "sidecars:\n svc:\n image: alpine\n"; + let overlay_yaml = "sidecars:\n svc:\n env:\n A: b\n"; + let mut base: RoozCfg = serde_yaml::from_str(base_yaml).unwrap(); + let overlay: RoozCfg = serde_yaml::from_str(overlay_yaml).unwrap(); + base.from_config(&overlay); + let svc = &base.sidecars.unwrap()["svc"]; + assert_eq!(svc.image, Some("alpine".to_string())); + assert_eq!(svc.env.as_ref().unwrap()["A"], "b"); + } + + #[test] + fn sidecar_merge_overlay_image_wins() { + let base_yaml = "sidecars:\n svc:\n image: alpine\n"; + let overlay_yaml = "sidecars:\n svc:\n image: debian\n"; + let mut base: RoozCfg = serde_yaml::from_str(base_yaml).unwrap(); + let overlay: RoozCfg = serde_yaml::from_str(overlay_yaml).unwrap(); + base.from_config(&overlay); + assert_eq!( + base.sidecars.unwrap()["svc"].image, + Some("debian".to_string()) + ); + } + + #[test] + fn sidecar_none_image_not_serialized() { + let cfg: RoozCfg = serde_yaml::from_str("sidecars:\n svc:\n uid: 1000\n").unwrap(); + let yaml = cfg.to_string(FileFormat::Yaml).unwrap(); + assert!(!yaml.contains("image"), "unexpected image key in: {}", yaml); + let reparsed = RoozCfg::from_string(&yaml, FileFormat::Yaml).unwrap(); + assert_eq!(reparsed.sidecars.unwrap()["svc"].image, None); + } + + #[test] + fn sidecar_peers_parse_and_render() { + let yaml = "vars:\n mirror: images\nsidecars:\n dkr:\n image: a\n peers: [\"{{ mirror }}\"]\n images:\n image: b\n"; + let mut cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + cfg.expand_vars().unwrap(); + assert_eq!( + cfg.sidecars.unwrap()["dkr"].peers, + Some(vec!["images".to_string()]) + ); + } + + #[test] + fn sidecar_peers_merge_as_union() { + let base_yaml = "sidecars:\n dkr:\n image: a\n peers: [images]\n"; + let overlay_yaml = "sidecars:\n dkr:\n peers: [cache]\n"; + let mut base: RoozCfg = serde_yaml::from_str(base_yaml).unwrap(); + let overlay: RoozCfg = serde_yaml::from_str(overlay_yaml).unwrap(); + base.from_config(&overlay); + assert_eq!( + base.sidecars.unwrap()["dkr"].peers, + Some(vec!["cache".to_string(), "images".to_string()]) + ); + } + + #[test] + fn sidecar_peers_deduped_across_layers() { + let base_yaml = "sidecars:\n dkr:\n image: a\n peers: [images, cache]\n"; + let overlay_yaml = "sidecars:\n dkr:\n peers: [images]\n"; + let mut base: RoozCfg = serde_yaml::from_str(base_yaml).unwrap(); + let overlay: RoozCfg = serde_yaml::from_str(overlay_yaml).unwrap(); + base.from_config(&overlay); + assert_eq!( + base.sidecars.unwrap()["dkr"].peers, + Some(vec!["cache".to_string(), "images".to_string()]) + ); + } + + #[test] + fn sidecar_peers_preserved_when_overlay_has_none() { + let base_yaml = "sidecars:\n dkr:\n image: a\n peers: [images]\n"; + let overlay_yaml = "sidecars:\n dkr:\n env:\n A: b\n"; + let mut base: RoozCfg = serde_yaml::from_str(base_yaml).unwrap(); + let overlay: RoozCfg = serde_yaml::from_str(overlay_yaml).unwrap(); + base.from_config(&overlay); + assert_eq!( + base.sidecars.unwrap()["dkr"].peers, + Some(vec!["images".to_string()]) + ); + } + #[test] fn install_serde_bare_string() { let yaml = "install: |\n echo hi\n"; diff --git a/src/config/runtime.rs b/src/config/runtime.rs index 60f00f6..a9fef61 100644 --- a/src/config/runtime.rs +++ b/src/config/runtime.rs @@ -25,12 +25,22 @@ pub struct RoozSidecarRuntime { pub uid: Option, pub egress: bool, pub install: Option, + #[serde(default)] + pub peers: Vec, } -impl<'a> From<&'a RoozSidecar> for RoozSidecarRuntime { - fn from(value: &'a RoozSidecar) -> Self { - RoozSidecarRuntime { - image: value.image.clone(), +impl<'a> TryFrom<(&'a str, &'a RoozSidecar)> for RoozSidecarRuntime { + type Error = AnyError; + + fn try_from((name, value): (&'a str, &'a RoozSidecar)) -> Result { + Ok(RoozSidecarRuntime { + image: value.image.clone().ok_or_else(|| -> AnyError { + format!( + "sidecar '{}': 'image' is required after merging all config layers", + name + ) + .into() + })?, env: value .env .clone() @@ -57,7 +67,8 @@ impl<'a> From<&'a RoozSidecar> for RoozSidecarRuntime { egress: value.egress.clone().unwrap_or(false), install: value.install.clone(), uid: value.uid.clone(), - } + peers: value.peers.clone().unwrap_or_default(), + }) } } #[serde_with::skip_serializing_none] @@ -126,10 +137,40 @@ impl RuntimeConfig { } } + pub fn workspace_networks( + sidecars: &HashMap, + ) -> (Vec, Vec<(String, String)>) { + let mut pairs: Vec = sidecars.keys().cloned().collect(); + pairs.sort(); + let mut peers = sidecars + .iter() + .flat_map(|(name, s)| { + s.peers.iter().map(move |p| { + if name < p { + (name.clone(), p.clone()) + } else { + (p.clone(), name.clone()) + } + }) + }) + .collect::>(); + peers.sort(); + peers.dedup(); + (pairs, peers) + } + pub fn all_mounts(&self) -> HashMap<(String, String), MountSource> { self.mounts .iter() - .map(|(target, source)| (("main".to_string(), target.clone()), source.clone())) + .map(|(target, source)| { + ( + ( + constants::DEFAULT_CONTAINER_NAME.to_string(), + target.clone(), + ), + source.clone(), + ) + }) .chain(self.sidecars.iter().flat_map(|(sidecar_name, sidecar)| { sidecar.mounts.iter().map(|(target, source)| { ((sidecar_name.clone(), target.clone()), source.clone()) @@ -154,6 +195,111 @@ mod tests { )); } + #[test] + fn sidecar_with_image_converts() { + let yaml = "sidecars:\n svc:\n image: alpine\n"; + let cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + let runtime = RuntimeConfig::try_from(&cfg).unwrap(); + assert_eq!(runtime.sidecars["svc"].image, "alpine"); + } + + #[test] + fn sidecar_without_image_fails_conversion_naming_sidecar() { + let yaml = "sidecars:\n svc:\n env:\n A: b\n"; + let cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + let err = RuntimeConfig::try_from(&cfg).unwrap_err().to_string(); + assert!(err.contains("sidecar 'svc'"), "unexpected error: {}", err); + assert!( + err.contains("'image' is required"), + "unexpected error: {}", + err + ); + } + + #[test] + fn unknown_peer_fails_conversion() { + let yaml = "sidecars:\n dkr:\n image: a\n peers: [bogus]\n"; + let cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + let err = RuntimeConfig::try_from(&cfg).unwrap_err().to_string(); + assert!(err.contains("sidecar 'dkr'"), "unexpected error: {}", err); + assert!( + err.contains("unknown peer 'bogus'"), + "unexpected error: {}", + err + ); + } + + #[test] + fn self_peer_fails_conversion() { + let yaml = "sidecars:\n dkr:\n image: a\n peers: [dkr]\n"; + let cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + let err = RuntimeConfig::try_from(&cfg).unwrap_err().to_string(); + assert!( + err.contains("sidecar 'dkr'") && err.contains("itself"), + "unexpected error: {}", + err + ); + } + + #[test] + fn old_persisted_sidecar_without_peers_parses() { + let yaml = "sidecars:\n svc:\n image: alpine\n"; + let cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + let runtime = RuntimeConfig::try_from(&cfg).unwrap(); + let mut persisted = runtime.to_string().unwrap(); + persisted = persisted + .lines() + .filter(|l| !l.contains("peers")) + .collect::>() + .join("\n"); + let parsed = RuntimeConfig::from_string(persisted).unwrap(); + assert!(parsed.sidecars["svc"].peers.is_empty()); + } + + #[test] + fn workspace_networks_no_peers() { + let yaml = "sidecars:\n a:\n image: x\n b:\n image: x\n"; + let cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + let runtime = RuntimeConfig::try_from(&cfg).unwrap(); + let (pairs, peers) = RuntimeConfig::workspace_networks(&runtime.sidecars); + assert_eq!(pairs, vec!["a".to_string(), "b".to_string()]); + assert!(peers.is_empty()); + } + + #[test] + fn workspace_networks_peer_dedup_bidirectional() { + let yaml = + "sidecars:\n a:\n image: x\n peers: [b]\n b:\n image: x\n peers: [a]\n"; + let cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + let runtime = RuntimeConfig::try_from(&cfg).unwrap(); + let (_, peers) = RuntimeConfig::workspace_networks(&runtime.sidecars); + assert_eq!(peers, vec![("a".to_string(), "b".to_string())]); + } + + #[test] + fn workspace_networks_example_topology() { + let yaml = "sidecars:\n claude:\n image: x\n peers: [proxy]\n proxy:\n image: x\n egress: true\n dkr:\n image: x\n peers: [images]\n images:\n image: x\n egress: true\n"; + let cfg: RoozCfg = serde_yaml::from_str(yaml).unwrap(); + let runtime = RuntimeConfig::try_from(&cfg).unwrap(); + let (pairs, peers) = RuntimeConfig::workspace_networks(&runtime.sidecars); + assert_eq!( + pairs, + vec![ + "claude".to_string(), + "dkr".to_string(), + "images".to_string(), + "proxy".to_string() + ] + ); + assert_eq!( + peers, + vec![ + ("claude".to_string(), "proxy".to_string()), + ("dkr".to_string(), "images".to_string()) + ] + ); + } + #[test] fn step_map_install_roundtrips() { let mut steps = indexmap::IndexMap::new(); @@ -168,14 +314,34 @@ mod tests { } } -impl<'a> From<&'a RoozCfg> for RuntimeConfig { - fn from(value: &'a RoozCfg) -> Self { +impl<'a> TryFrom<&'a RoozCfg> for RuntimeConfig { + type Error = AnyError; + + fn try_from(value: &'a RoozCfg) -> Result { let default = RuntimeConfig::default(); + let sidecar_cfgs = value.sidecars.clone().unwrap_or_default(); + for (name, s) in &sidecar_cfgs { + for peer in s.peers.iter().flatten() { + if peer == name { + return Err( + format!("sidecar '{}': cannot declare itself as a peer", name).into(), + ); + } + if !sidecar_cfgs.contains_key(peer) { + return Err(format!( + "sidecar '{}': unknown peer '{}' (peers must name sidecars defined in this workspace)", + name, peer + ) + .into()); + } + } + } + let mut ports = HashMap::>::new(); RoozCfg::parse_ports(&mut ports, value.clone().ports.unwrap_or_default()); - RuntimeConfig { + Ok(RuntimeConfig { git_ssh_url: value.git_ssh_url.clone(), extra_repos: value .extra_repos @@ -190,13 +356,10 @@ impl<'a> From<&'a RoozCfg> for RuntimeConfig { val.dedup(); val }, - sidecars: value - .sidecars - .clone() - .unwrap_or_default() + sidecars: sidecar_cfgs .into_iter() - .map(|(k, v)| (k, RoozSidecarRuntime::from(&v))) - .collect(), + .map(|(k, v)| Ok((k.clone(), (k.as_str(), &v).try_into()?))) + .collect::, AnyError>>()?, env: value.env.clone().unwrap_or_default().into_iter().collect(), ports, privileged: value.privileged.unwrap_or(default.privileged), @@ -216,6 +379,6 @@ impl<'a> From<&'a RoozCfg> for RuntimeConfig { .collect(), install: value.install.clone(), ..default - } + }) } } diff --git a/src/constants.rs b/src/constants.rs index 4eab94e..a341c4f 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -14,6 +14,53 @@ pub fn default_command<'a>() -> Option> { pub fn egress_network(workspace_key: &str) -> String { format!("{}-egress", workspace_key) } -pub fn internal_network(workspace_key: &str) -> String { - format!("{}-internal", workspace_key) +pub fn pair_network(workspace_key: &str, sidecar: &str) -> String { + fit_network_name(format!("{}-net-{}", workspace_key, sidecar)) +} +pub fn peer_network(workspace_key: &str, a: &str, b: &str) -> String { + let (a, b) = if a <= b { (a, b) } else { (b, a) }; + fit_network_name(format!("{}-peer-{}-{}", workspace_key, a, b)) +} + +const NETWORK_NAME_MAX: usize = 63; + +fn fit_network_name(name: String) -> String { + if name.len() <= NETWORK_NAME_MAX { + return name; + } + let mut hash: u64 = 0xcbf29ce484222325; + for b in name.as_bytes() { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + let prefix: String = name.chars().take(NETWORK_NAME_MAX - 17).collect(); + format!("{}-{:016x}", prefix, hash) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn peer_network_sorts_args() { + assert_eq!( + peer_network("ws", "dkr", "images"), + peer_network("ws", "images", "dkr") + ); + assert_eq!(peer_network("ws", "a", "b"), "ws-peer-a-b"); + } + + #[test] + fn long_network_names_truncated_with_hash() { + let ws = "w".repeat(50); + let name = pair_network(&ws, &"s".repeat(50)); + assert_eq!(name.len(), NETWORK_NAME_MAX); + assert_eq!(name, pair_network(&ws, &"s".repeat(50))); + assert_ne!(name, pair_network(&ws, &"x".repeat(50))); + } + + #[test] + fn short_network_names_unchanged() { + assert_eq!(pair_network("ws", "svc"), "ws-net-svc"); + } } diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index 48819ca..9b0c26b 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -1,10 +1,12 @@ use assert_cmd::Command; use bollard::{ API_DEFAULT_VERSION, Docker, + exec::{CreateExecOptions, StartExecResults}, models::{ContainerCreateBody, HostConfig, Mount, VolumeCreateRequest}, query_parameters::{ - CreateContainerOptions, ListContainersOptions, ListVolumesOptions, LogsOptions, - RemoveContainerOptions, RemoveVolumeOptions, StartContainerOptions, WaitContainerOptions, + CreateContainerOptions, ListContainersOptions, ListNetworksOptions, ListVolumesOptions, + LogsOptions, RemoveContainerOptions, RemoveVolumeOptions, StartContainerOptions, + WaitContainerOptions, }, }; use bollard_stubs::models::{ContainerSummary, ContainerSummaryStateEnum, MountType, Volume}; @@ -223,6 +225,59 @@ impl TestEnv { .collect() } + pub async fn exec_code(&self, container: &str, cmd: Vec<&str>) -> i64 { + let exec = self + .docker + .create_exec( + container, + CreateExecOptions { + cmd: Some(cmd.iter().map(|s| s.to_string()).collect()), + user: Some("0".to_string()), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }, + ) + .await + .unwrap(); + if let StartExecResults::Attached { mut output, .. } = + self.docker.start_exec(&exec.id, None).await.unwrap() + { + while output.next().await.is_some() {} + } + self.docker + .inspect_exec(&exec.id) + .await + .unwrap() + .exit_code + .unwrap_or(-1) + } + + pub async fn ping(&self, from: &str, to: &str) -> i64 { + self.exec_code(from, vec!["ping", "-c1", "-w2", to]).await + } + + pub async fn network_names_by_workspace(&self, key: &str) -> Vec { + let mut filters = HashMap::new(); + filters.insert( + "label".to_string(), + vec![format!("dev.rooz.workspace={}", key)], + ); + let opts = ListNetworksOptions { + filters: Some(filters), + }; + let mut names = self + .docker + .list_networks(Some(opts)) + .await + .unwrap_or_default() + .into_iter() + .filter_map(|n| n.name) + .collect::>(); + names.sort(); + names + } + pub async fn all_rooz_volumes(&self) -> Vec { let mut filters = HashMap::new(); filters.insert("label".to_string(), vec!["dev.rooz=true".to_string()]); diff --git a/tests/networks.rs b/tests/networks.rs new file mode 100644 index 0000000..02e892c --- /dev/null +++ b/tests/networks.rs @@ -0,0 +1,156 @@ +mod harness; + +use harness::{TestEnv, unique_key}; +use std::{fs, io::Write}; + +fn hub_spoke_cfg(key: &str) -> String { + let path = format!("/tmp/rooz-test-networks-{}.yaml", key); + let yaml = "\ +image: alpine:latest +sidecars: + claude: + image: alpine:latest + command: [sleep] + args: [infinity] + peers: [proxy] + proxy: + image: alpine:latest + command: [sleep] + args: [infinity] + egress: true + dkr: + image: alpine:latest + command: [sleep] + args: [infinity] + peers: [images] + images: + image: alpine:latest + command: [sleep] + args: [infinity] + egress: true +"; + let mut f = fs::File::create(&path).expect("write networks config"); + f.write_all(yaml.as_bytes()).unwrap(); + path +} + +#[tokio::test] +async fn hub_and_spoke_topology() { + let Some(env) = TestEnv::from_env() else { + return; + }; + let key = unique_key("net"); + let cfg_path = hub_spoke_cfg(&key); + + env.rooz() + .args(["system", "init", "--force"]) + .assert() + .success(); + env.rooz() + .args(["new", &key, "--config", &cfg_path]) + .assert() + .success(); + + let networks = env.network_names_by_workspace(&key).await; + let expected = { + let mut v = vec![ + format!("{}-egress", key), + format!("{}-net-claude", key), + format!("{}-net-dkr", key), + format!("{}-net-images", key), + format!("{}-net-proxy", key), + format!("{}-peer-claude-proxy", key), + format!("{}-peer-dkr-images", key), + ]; + v.sort(); + v + }; + assert_eq!(networks, expected, "unexpected workspace network set"); + + for sidecar in ["claude", "proxy", "dkr", "images"] { + assert_eq!( + env.ping(&key, sidecar).await, + 0, + "work container cannot reach sidecar '{}'", + sidecar + ); + } + + let dkr = format!("{}-dkr", key); + let claude = format!("{}-claude", key); + + assert_eq!( + env.ping(&dkr, "images").await, + 0, + "dkr cannot reach its declared peer 'images'" + ); + assert_eq!( + env.ping(&claude, "proxy").await, + 0, + "claude cannot reach its declared peer 'proxy'" + ); + + assert_ne!( + env.ping(&dkr, "proxy").await, + 0, + "dkr must not reach 'proxy' (no peer declared)" + ); + assert_ne!( + env.ping(&dkr, "claude").await, + 0, + "dkr must not reach 'claude' (no peer declared)" + ); + assert_ne!( + env.ping(&claude, "images").await, + 0, + "claude must not reach 'images' (no peer declared)" + ); + + env.rooz().args(["rm", &key, "--force"]).assert().success(); + + assert!( + env.network_names_by_workspace(&key).await.is_empty(), + "workspace networks remain after rm" + ); + + let _ = fs::remove_file(&cfg_path); +} + +#[tokio::test] +async fn unknown_peer_rejected_at_create() { + let Some(env) = TestEnv::from_env() else { + return; + }; + let key = unique_key("net-bad"); + let cfg_path = format!("/tmp/rooz-test-networks-{}.yaml", key); + let yaml = "\ +image: alpine:latest +sidecars: + dkr: + image: alpine:latest + peers: [bogus] +"; + { + let mut f = fs::File::create(&cfg_path).expect("write config"); + f.write_all(yaml.as_bytes()).unwrap(); + } + + env.rooz() + .args(["system", "init", "--force"]) + .assert() + .success(); + let assert = env + .rooz() + .args(["new", &key, "--config", &cfg_path]) + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr).to_string(); + assert!( + stderr.contains("unknown peer 'bogus'"), + "unexpected stderr: {}", + stderr + ); + + env.rooz().args(["rm", &key, "--force"]).assert().success(); + let _ = fs::remove_file(&cfg_path); +}