zlayer_types/overlay.rs
1//! Overlay-network configuration types shared across `ZLayer` crates.
2//!
3//! [`OverlayMode`] is a per-service data-plane attachment knob. It bundles two
4//! independent decisions — the container-attachment topology (per-service Linux
5//! bridge vs. one shared node-wide bridge + userspace free-port L4 proxy) and
6//! the `WireGuard` transport (the single cluster-wide interface vs. a per-service
7//! interface with isolated crypto) — into a single setting.
8//!
9//! Truth table:
10//!
11//! | mode | shared node bridge + free-port proxy? | per-service `WireGuard` transport? | resulting behavior |
12//! |-----------|----------------------------------------|------------------------------------|--------------------|
13//! | `Auto` | no | no | today's default: veth-per-container on a per-service Linux bridge, carried on the single cluster-wide `WireGuard` interface |
14//! | `Dedicated` | no | yes | veth-per-container on a per-service bridge, with its OWN per-service `WireGuard` transport (isolated crypto) = max isolation |
15//! | `Shared` | yes | no | NO per-service bridge / NO per-service WG: one shared node-wide bridge for all services + a userspace free-port L4 proxy (`host:FREEPORT` -> `container_ip:port`), carried on the cluster-wide `WireGuard` interface = max sharing |
16//! | `Isolated` | no | no | per-service bridge on the cluster-wide `WireGuard` interface (Auto topology), but L3-fenced to its own isolation network (members reach only their own network + node IP + egress) |
17//!
18//! The real decision surface is the three predicate methods
19//! [`OverlayMode::uses_shared_bridge`], [`OverlayMode::uses_per_service_wg`],
20//! and [`OverlayMode::uses_isolation_scope`]; consult those rather than matching
21//! on variants ad hoc.
22
23use serde::{Deserialize, Serialize};
24
25/// Per-service overlay data-plane attachment knob.
26///
27/// See the module docs for the full truth table; each variant bundles a
28/// container-attachment topology with a `WireGuard` transport choice.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)]
30#[serde(rename_all = "lowercase")]
31pub enum OverlayMode {
32 /// Today's default behavior: veth-per-container on a per-service Linux
33 /// bridge, carried on the single cluster-wide `WireGuard` interface. No
34 /// shared node-wide bridge / free-port proxy and no per-service `WireGuard`
35 /// transport.
36 #[default]
37 Auto,
38 /// Max sharing: one shared node-wide bridge for all services plus a
39 /// userspace free-port L4 proxy (`host:FREEPORT` -> `container_ip:port`).
40 /// No per-service bridge and no per-service `WireGuard`; traffic rides the
41 /// cluster-wide `WireGuard` interface.
42 Shared,
43 /// Max isolation: veth-per-container on a per-service bridge, with its OWN
44 /// per-service `WireGuard` transport (isolated crypto context). No shared
45 /// node-wide bridge / free-port proxy.
46 Dedicated,
47 /// Per-service bridge on the cluster-wide `WireGuard` interface (same
48 /// topology + transport as [`OverlayMode::Auto`]), but L3-fenced to its own
49 /// isolation network: members reach their own network's members plus the
50 /// node IP and egress, never other networks' members or arbitrary cluster
51 /// overlay IPs. Sugar for "Auto topology auto-fenced to an isolation network
52 /// named after the service" — it reuses the same `isolation_network`
53 /// membership machinery the named-isolated-networks feature already uses on
54 /// every platform. Distinct from [`OverlayMode::Dedicated`], which isolates
55 /// the crypto *transport* (its own `WireGuard` device), not the L3 scope.
56 Isolated,
57}
58
59impl OverlayMode {
60 /// Identity resolution: each variant resolves to itself. Retained so
61 /// existing `.resolve()` callers keep compiling. `Auto` no longer maps to
62 /// `Shared` — `Auto` now denotes today's default behavior (per-service
63 /// bridge on the cluster-wide `WireGuard` interface) in its own right.
64 #[must_use]
65 pub fn resolve(self) -> OverlayMode {
66 self
67 }
68
69 /// Whether this mode uses the shared node-wide bridge plus the userspace
70 /// free-port L4 proxy. True only for [`OverlayMode::Shared`].
71 #[must_use]
72 pub fn uses_shared_bridge(self) -> bool {
73 matches!(self, OverlayMode::Shared)
74 }
75
76 /// Whether this mode provisions its own per-service `WireGuard` transport
77 /// with an isolated crypto context. True only for
78 /// [`OverlayMode::Dedicated`].
79 #[must_use]
80 pub fn uses_per_service_wg(self) -> bool {
81 matches!(self, OverlayMode::Dedicated)
82 }
83
84 /// Whether this mode is L3-fenced to its own isolation network. True only
85 /// for [`OverlayMode::Isolated`]. The fence reuses the platform-neutral
86 /// `isolation_network` membership channel; the network is named after the
87 /// service.
88 #[must_use]
89 pub fn uses_isolation_scope(self) -> bool {
90 matches!(self, OverlayMode::Isolated)
91 }
92}
93
94/// Reserved container label naming the isolation network a container must join.
95/// Read by every runtime's overlay attach path (via
96/// [`crate::overlay::OverlayMode`] + the agent's `resolve_isolation_network`)
97/// and by the Windows HCS create path. An explicit value always wins over
98/// mode-derived isolation scoping. Canonical definition; per-crate copies in
99/// the api/agent crates carry the same string.
100pub const ISOLATION_NETWORK_LABEL: &str = "com.zlayer.isolation_network";
101
102/// Per-service overlay configuration, populated from the service spec.
103///
104/// `parent` names another overlay this one should nest under. In v0.51 only
105/// `None` / `Some("cluster")` is honored; any other value triggers a warn-
106/// and-fallback (treated as `None`). Future rounds may allow service-of-
107/// service nesting.
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
109pub struct OverlayConfig {
110 #[serde(default)]
111 pub mode: OverlayMode,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub parent: Option<String>,
114}
115
116impl OverlayConfig {
117 /// Returns the resolved parent name for v0.51: `None` if the parent is
118 /// `None` or `Some("cluster")`; logs a warning and falls back to `None`
119 /// for any other value.
120 #[must_use]
121 pub fn resolved_parent_v0_51(&self) -> Option<&str> {
122 match self.parent.as_deref() {
123 None | Some("cluster") => None,
124 Some(other) => {
125 tracing::warn!(
126 parent = other,
127 "OverlayConfig.parent only supports `cluster` (or unset) in v0.51; \
128 service-of-service nesting is reserved for a future round. \
129 Falling back to cluster parent.",
130 );
131 None
132 }
133 }
134 }
135}
136
137/// Egress policy for a workload's outbound connectivity.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum EgressPolicy {
140 /// No outbound network at all.
141 None,
142 /// Full outbound (DNS/HTTP/HTTPS + arbitrary) — normal containers.
143 Full,
144}
145
146/// Resolved network-isolation policy for a workload, derived from its
147/// `OverlayMode` + `NetworkMode` + optional explicit isolation-network label.
148/// Each runtime translates this into its own enforcement (Seatbelt `.sb` ACL,
149/// Linux iptables ISO chain, HCN network, …).
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct NetworkIsolation {
152 /// Isolation network name. `None` = flat cluster mesh (reach any overlay peer).
153 /// `Some(_)` = fenced to this named network's members only.
154 pub scope: Option<String>,
155 /// When true, the workload must NOT be granted broad cluster-overlay reach
156 /// (isolated/dedicated). When false, it may reach the whole overlay (normal).
157 pub fenced: bool,
158 /// Whether the workload may reach the daemon/node overlay IP.
159 pub allow_host: bool,
160 /// Outbound policy.
161 pub egress: EgressPolicy,
162}
163
164/// Resolve the effective [`NetworkIsolation`] for a workload.
165///
166/// Precedence: `NetworkMode` hard cases first (None → no net; Host → full host
167/// opt-in), otherwise derive from `OverlayMode`. `auto`/`shared` = Docker-normal
168/// (unfenced, reachable). `isolated`/`dedicated` = fenced to their own network
169/// (explicit label wins, else the service name). `explicit_isolation_label`
170/// comes from the `ISOLATION_NETWORK_LABEL` container label, if present.
171#[must_use]
172pub fn resolve_network_isolation(
173 mode: OverlayMode,
174 network_mode_is_none: bool,
175 network_mode_is_host: bool,
176 service: &str,
177 explicit_isolation_label: Option<&str>,
178) -> NetworkIsolation {
179 if network_mode_is_none {
180 return NetworkIsolation {
181 scope: None,
182 fenced: true,
183 allow_host: false,
184 egress: EgressPolicy::None,
185 };
186 }
187 if network_mode_is_host {
188 return NetworkIsolation {
189 scope: None,
190 fenced: false,
191 allow_host: true,
192 egress: EgressPolicy::Full,
193 };
194 }
195 let fenced = mode.uses_isolation_scope() || mode.uses_per_service_wg();
196 let scope = if fenced {
197 Some(explicit_isolation_label.map_or_else(|| service.to_string(), str::to_string))
198 } else {
199 explicit_isolation_label.map(str::to_string)
200 };
201 NetworkIsolation {
202 scope,
203 fenced,
204 allow_host: true,
205 egress: EgressPolicy::Full,
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn overlay_mode_default_is_auto() {
215 assert_eq!(OverlayMode::default(), OverlayMode::Auto);
216 }
217
218 #[test]
219 fn isolation_network_label_is_canonical() {
220 assert_eq!(ISOLATION_NETWORK_LABEL, "com.zlayer.isolation_network");
221 }
222
223 #[test]
224 fn overlay_mode_resolve() {
225 assert_eq!(OverlayMode::Auto.resolve(), OverlayMode::Auto);
226 assert_eq!(OverlayMode::Shared.resolve(), OverlayMode::Shared);
227 assert_eq!(OverlayMode::Dedicated.resolve(), OverlayMode::Dedicated);
228 assert_eq!(OverlayMode::Isolated.resolve(), OverlayMode::Isolated);
229 }
230
231 #[test]
232 fn overlay_mode_predicates() {
233 assert_eq!(
234 (
235 OverlayMode::Auto.uses_shared_bridge(),
236 OverlayMode::Auto.uses_per_service_wg()
237 ),
238 (false, false)
239 );
240 assert_eq!(
241 (
242 OverlayMode::Dedicated.uses_shared_bridge(),
243 OverlayMode::Dedicated.uses_per_service_wg()
244 ),
245 (false, true)
246 );
247 assert_eq!(
248 (
249 OverlayMode::Shared.uses_shared_bridge(),
250 OverlayMode::Shared.uses_per_service_wg()
251 ),
252 (true, false)
253 );
254 }
255
256 #[test]
257 fn overlay_mode_isolated_predicates() {
258 assert!(OverlayMode::Isolated.uses_isolation_scope());
259 assert!(!OverlayMode::Isolated.uses_shared_bridge());
260 assert!(!OverlayMode::Isolated.uses_per_service_wg());
261 assert!(!OverlayMode::Auto.uses_isolation_scope());
262 assert!(!OverlayMode::Shared.uses_isolation_scope());
263 assert!(!OverlayMode::Dedicated.uses_isolation_scope());
264 }
265
266 #[test]
267 fn overlay_mode_serde_lowercase() {
268 assert_eq!(
269 serde_json::to_string(&OverlayMode::Auto).unwrap(),
270 "\"auto\""
271 );
272 assert_eq!(
273 serde_json::to_string(&OverlayMode::Shared).unwrap(),
274 "\"shared\""
275 );
276 assert_eq!(
277 serde_json::to_string(&OverlayMode::Dedicated).unwrap(),
278 "\"dedicated\""
279 );
280 assert_eq!(
281 serde_json::to_string(&OverlayMode::Isolated).unwrap(),
282 "\"isolated\""
283 );
284 assert_eq!(
285 serde_json::from_str::<OverlayMode>("\"shared\"").unwrap(),
286 OverlayMode::Shared,
287 );
288 assert_eq!(
289 serde_json::from_str::<OverlayMode>("\"isolated\"").unwrap(),
290 OverlayMode::Isolated,
291 );
292 }
293
294 #[test]
295 fn overlay_config_default_is_auto_no_parent() {
296 let cfg = OverlayConfig::default();
297 assert_eq!(cfg.mode, OverlayMode::Auto);
298 assert_eq!(cfg.parent, None);
299 assert_eq!(cfg.resolved_parent_v0_51(), None);
300 }
301
302 #[test]
303 fn overlay_config_cluster_parent_is_none() {
304 let cfg = OverlayConfig {
305 mode: OverlayMode::Shared,
306 parent: Some("cluster".to_string()),
307 };
308 assert_eq!(cfg.resolved_parent_v0_51(), None);
309 }
310
311 #[test]
312 fn overlay_config_other_parent_warns_and_returns_none() {
313 let cfg = OverlayConfig {
314 mode: OverlayMode::Shared,
315 parent: Some("svc-other".to_string()),
316 };
317 assert_eq!(cfg.resolved_parent_v0_51(), None);
318 }
319
320 #[test]
321 fn resolve_isolation_auto_is_unfenced_full_and_host() {
322 let iso = resolve_network_isolation(OverlayMode::Auto, false, false, "svc", None);
323 assert_eq!(
324 iso,
325 NetworkIsolation {
326 scope: None,
327 fenced: false,
328 allow_host: true,
329 egress: EgressPolicy::Full,
330 }
331 );
332 }
333
334 #[test]
335 fn resolve_isolation_shared_is_unfenced() {
336 let iso = resolve_network_isolation(OverlayMode::Shared, false, false, "svc", None);
337 assert!(!iso.fenced);
338 assert_eq!(iso.scope, None);
339 assert_eq!(iso.egress, EgressPolicy::Full);
340 assert!(iso.allow_host);
341 }
342
343 #[test]
344 fn resolve_isolation_isolated_is_fenced_to_service() {
345 let iso = resolve_network_isolation(OverlayMode::Isolated, false, false, "svc", None);
346 assert!(iso.fenced);
347 assert_eq!(iso.scope.as_deref(), Some("svc"));
348 assert!(iso.allow_host);
349 assert_eq!(iso.egress, EgressPolicy::Full);
350 }
351
352 #[test]
353 fn resolve_isolation_isolated_explicit_label_overrides_service() {
354 let iso =
355 resolve_network_isolation(OverlayMode::Isolated, false, false, "svc", Some("netA"));
356 assert!(iso.fenced);
357 assert_eq!(iso.scope.as_deref(), Some("netA"));
358 }
359
360 #[test]
361 fn resolve_isolation_dedicated_is_fenced() {
362 let iso = resolve_network_isolation(OverlayMode::Dedicated, false, false, "svc", None);
363 assert!(iso.fenced);
364 assert_eq!(iso.scope.as_deref(), Some("svc"));
365 assert_eq!(iso.egress, EgressPolicy::Full);
366 }
367
368 #[test]
369 fn resolve_isolation_unfenced_label_still_sets_scope() {
370 // An explicit label on an unfenced (auto) workload still names a scope,
371 // but the workload is not fenced.
372 let iso = resolve_network_isolation(OverlayMode::Auto, false, false, "svc", Some("netA"));
373 assert!(!iso.fenced);
374 assert_eq!(iso.scope.as_deref(), Some("netA"));
375 }
376
377 #[test]
378 fn resolve_isolation_network_none_has_no_net() {
379 let iso = resolve_network_isolation(OverlayMode::Auto, true, false, "svc", Some("netA"));
380 assert_eq!(
381 iso,
382 NetworkIsolation {
383 scope: None,
384 fenced: true,
385 allow_host: false,
386 egress: EgressPolicy::None,
387 }
388 );
389 }
390
391 #[test]
392 fn resolve_isolation_network_host_is_unfenced_full() {
393 // Host mode wins over an isolated overlay mode: full host opt-in.
394 let iso =
395 resolve_network_isolation(OverlayMode::Isolated, false, true, "svc", Some("netA"));
396 assert_eq!(
397 iso,
398 NetworkIsolation {
399 scope: None,
400 fenced: false,
401 allow_host: true,
402 egress: EgressPolicy::Full,
403 }
404 );
405 }
406}