Skip to main content

dynamo_runtime/
namespace.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4pub const GLOBAL_NAMESPACE: &str = "dynamo";
5
6/// Determines how namespaces are filtered during model discovery.
7///
8/// This supports the hierarchical model architecture where multiple WorkerSets
9/// with different namespaces (e.g., during rolling updates) should be discovered
10/// together under the same Model.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum NamespaceFilter {
13    /// Discover models from all namespaces (no filtering)
14    Global,
15    /// Discover models only from an exact namespace match
16    Exact(String),
17    /// Discover models from namespaces starting with the given prefix
18    /// (e.g., prefix "ns" matches "ns", "ns-abc123", "ns-def456")
19    Prefix(String),
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum NamespacePrefixMode {
24    Literal,
25    WorkerGeneration,
26}
27
28impl NamespacePrefixMode {
29    pub fn from_env() -> Self {
30        if crate::config::env_is_truthy("DYN_NAMESPACE_PREFIX_STRICT") {
31            Self::WorkerGeneration
32        } else {
33            Self::Literal
34        }
35    }
36}
37
38impl NamespaceFilter {
39    /// Create a NamespaceFilter from optional namespace and namespace_prefix.
40    /// If prefix is provided, it takes precedence over exact namespace.
41    pub fn from_namespace_and_prefix(
42        namespace: Option<&str>,
43        namespace_prefix: Option<&str>,
44    ) -> Self {
45        // Prefix takes precedence if both are specified
46        if let Some(prefix) = namespace_prefix {
47            if prefix.is_empty() || is_global_namespace(prefix) {
48                return NamespaceFilter::Global;
49            }
50            return NamespaceFilter::Prefix(prefix.to_string());
51        }
52
53        if let Some(ns) = namespace {
54            if ns.is_empty() || is_global_namespace(ns) {
55                return NamespaceFilter::Global;
56            }
57            return NamespaceFilter::Exact(ns.to_string());
58        }
59
60        NamespaceFilter::Global
61    }
62
63    /// Check if a given namespace matches this filter.
64    pub fn matches(&self, namespace: &str) -> bool {
65        match self {
66            NamespaceFilter::Global => true,
67            NamespaceFilter::Exact(target) => namespace == target,
68            NamespaceFilter::Prefix(prefix) => namespace.starts_with(prefix),
69        }
70    }
71
72    /// Match operator worker generations when requested, preserving exact and global scopes.
73    pub fn matches_with_prefix_mode(&self, namespace: &str, mode: NamespacePrefixMode) -> bool {
74        match (self, mode) {
75            (NamespaceFilter::Prefix(prefix), NamespacePrefixMode::WorkerGeneration) => {
76                if namespace == prefix {
77                    return true;
78                }
79                let Some(suffix) = namespace
80                    .strip_prefix(prefix.as_str())
81                    .and_then(|rest| rest.strip_prefix('-'))
82                else {
83                    return false;
84                };
85                // The operator uses `legacy` while migrating pre-generation workers.
86                suffix == "legacy"
87                    || (suffix.len() == 8
88                        && suffix
89                            .bytes()
90                            .all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f')))
91            }
92            _ => self.matches(namespace),
93        }
94    }
95
96    /// Returns true if this is global namespace filtering (no filtering).
97    pub fn is_global(&self) -> bool {
98        matches!(self, NamespaceFilter::Global)
99    }
100}
101
102pub fn is_global_namespace(namespace: &str) -> bool {
103    namespace == GLOBAL_NAMESPACE || namespace.is_empty()
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn test_from_namespace_and_prefix_global() {
112        assert_eq!(
113            NamespaceFilter::from_namespace_and_prefix(None, None),
114            NamespaceFilter::Global
115        );
116        assert_eq!(
117            NamespaceFilter::from_namespace_and_prefix(Some(""), None),
118            NamespaceFilter::Global
119        );
120        assert_eq!(
121            NamespaceFilter::from_namespace_and_prefix(Some(GLOBAL_NAMESPACE), None),
122            NamespaceFilter::Global
123        );
124    }
125
126    #[test]
127    fn test_from_namespace_and_prefix_exact() {
128        assert_eq!(
129            NamespaceFilter::from_namespace_and_prefix(Some("my-namespace"), None),
130            NamespaceFilter::Exact("my-namespace".to_string())
131        );
132    }
133
134    #[test]
135    fn test_from_namespace_and_prefix_prefix_takes_precedence() {
136        assert_eq!(
137            NamespaceFilter::from_namespace_and_prefix(Some("exact"), Some("prefix")),
138            NamespaceFilter::Prefix("prefix".to_string())
139        );
140    }
141
142    #[test]
143    fn test_matches_global() {
144        let filter = NamespaceFilter::Global;
145        assert!(filter.matches("anything"));
146        assert!(filter.matches(""));
147        assert!(filter.matches("default"));
148        assert!(filter.matches("ns-abc123"));
149    }
150
151    #[test]
152    fn test_matches_exact() {
153        let filter = NamespaceFilter::Exact("my-namespace".to_string());
154        assert!(filter.matches("my-namespace"));
155        assert!(!filter.matches("my-namespace-abc123"));
156        assert!(!filter.matches("other"));
157        assert!(!filter.matches(""));
158    }
159
160    #[test]
161    fn test_matches_prefix() {
162        let filter = NamespaceFilter::Prefix("ns".to_string());
163        assert!(filter.matches("ns"));
164        assert!(filter.matches("ns-abc123"));
165        assert!(filter.matches("ns-def456"));
166        assert!(!filter.matches("other-ns"));
167        assert!(!filter.matches(""));
168    }
169
170    #[test]
171    fn test_is_global() {
172        assert!(NamespaceFilter::Global.is_global());
173        assert!(!NamespaceFilter::Exact("ns".to_string()).is_global());
174        assert!(!NamespaceFilter::Prefix("ns".to_string()).is_global());
175    }
176
177    #[test]
178    fn operator_prefix_excludes_sibling_deployments() {
179        let literal = NamespaceFilter::from_namespace_and_prefix(None, Some("default-foo"));
180        for namespace in ["default-foo", "default-foo-1a2b3c4d", "default-foo-legacy"] {
181            assert!(
182                literal.matches_with_prefix_mode(namespace, NamespacePrefixMode::WorkerGeneration),
183                "{namespace}"
184            );
185        }
186        for namespace in [
187            "default-foo-bar",
188            "default-foo-bar-1a2b3c4d",
189            "default-foobar",
190            "default-foo-DEADBEEF",
191            "default-foo-1a2b3c4g",
192            "default-foo-1a2b3c4",
193        ] {
194            assert!(literal.matches(namespace), "manual prefix: {namespace}");
195            assert!(
196                !literal.matches_with_prefix_mode(namespace, NamespacePrefixMode::WorkerGeneration),
197                "operator prefix: {namespace}"
198            );
199        }
200    }
201
202    #[test]
203    fn strict_prefix_preserves_exact_and_global_scopes() {
204        for (namespace, prefix) in [
205            (None, None),
206            (Some("default-foo"), None),
207            (None, Some("dynamo")),
208            (None, Some("")),
209        ] {
210            let filter = NamespaceFilter::from_namespace_and_prefix(namespace, prefix);
211            for candidate in ["default-foo", "default-foo-1a2b3c4d", "default-foo-bar"] {
212                assert_eq!(
213                    filter
214                        .matches_with_prefix_mode(candidate, NamespacePrefixMode::WorkerGeneration),
215                    filter.matches(candidate)
216                );
217            }
218        }
219    }
220
221    #[test]
222    fn prefix_mode_uses_canonical_environment_flag() {
223        for (value, expected) in [
224            (None, NamespacePrefixMode::Literal),
225            (Some("false"), NamespacePrefixMode::Literal),
226            (Some(" TRUE "), NamespacePrefixMode::WorkerGeneration),
227            (Some("on"), NamespacePrefixMode::WorkerGeneration),
228        ] {
229            temp_env::with_var("DYN_NAMESPACE_PREFIX_STRICT", value, || {
230                assert_eq!(NamespacePrefixMode::from_env(), expected);
231            });
232        }
233    }
234}