Skip to main content

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}