nxd_core/domain/selection/
mod.rs1use crate::domain::contract::{CanonicalConfig, Resource};
4use crate::domain::host_lifecycle::LifecycleIntent;
5use std::collections::BTreeSet;
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct ResolvedSelection {
9 pub resource_ids: BTreeSet<String>,
10 pub lifecycle_intent: Option<LifecycleIntent>,
11 pub artifact_set: Option<String>,
12}
13
14pub fn resolve(
15 config: &CanonicalConfig,
16 selectors: &[String],
17) -> Result<ResolvedSelection, String> {
18 if selectors.is_empty() {
19 return Err("selection cannot be empty".to_string());
20 }
21 let mut resource_ids = BTreeSet::new();
22 let mut lifecycle_intent = None;
23 let mut artifact_set = None;
24 for selector in selectors {
25 if let Some(name) = selector.strip_prefix("operation:") {
26 let set = config
27 .spec
28 .operation_sets
29 .get(name)
30 .ok_or_else(|| format!("operation set {name} was not found"))?;
31 if let Some(intent) = set.lifecycle_intent {
32 match &lifecycle_intent {
33 Some(existing) if *existing != intent => {
34 return Err("selected operation sets have conflicting lifecycle intents".to_string());
35 }
36 None => lifecycle_intent = Some(intent),
37 _ => {}
38 }
39 }
40 if let Some(ref art) = set.artifact_set {
41 match &artifact_set {
42 Some(existing) if existing != art => {
43 return Err("selected operation sets have conflicting artifact sets".to_string());
44 }
45 None => artifact_set = Some(art.clone()),
46 _ => {}
47 }
48 }
49 for nested in &set.selectors {
50 if nested.starts_with("operation:") {
51 return Err(format!("operation set {name} may not include another operation set"));
52 }
53 expand_one(config, nested, &mut resource_ids)?;
54 }
55 } else {
56 expand_one(config, selector, &mut resource_ids)?;
57 }
58 }
59 Ok(ResolvedSelection { resource_ids, lifecycle_intent, artifact_set })
60}
61
62fn expand_one(
63 config: &CanonicalConfig,
64 selector: &str,
65 result: &mut BTreeSet<String>,
66) -> Result<(), String> {
67 let mut matches = BTreeSet::new();
68 if let Some(id) = selector.strip_prefix("resource:") {
69 if config.spec.resources.iter().any(|resource| resource_id(resource) == id) {
70 matches.insert(id.to_string());
71 }
72 } else if let Some(host) = selector.strip_prefix("target:") {
73 let id = format!("deployment-target/{host}");
74 if config.spec.resources.iter().any(|resource| resource_id(resource) == id) {
75 matches.insert(id);
76 }
77 } else if let Some(pattern) = selector.strip_prefix("glob:") {
78 for resource in &config.spec.resources {
79 if let Resource::DeploymentTarget(target) = resource {
80 let host = target.id.strip_prefix("deployment-target/").unwrap_or(&target.id);
81 if glob_matches(pattern, host) {
82 matches.insert(target.id.clone());
83 }
84 }
85 }
86 } else if let Some(role) = selector.strip_prefix("role:") {
87 for resource in &config.spec.resources {
88 if let Resource::DeploymentTarget(target) = resource
89 && target.metadata.role.as_deref() == Some(role)
90 {
91 matches.insert(target.id.clone());
92 }
93 }
94 } else if let Some(kind) = selector.strip_prefix("kind:") {
95 for resource in &config.spec.resources {
96 let value = serde_json::to_value(resource)
97 .map_err(|error| format!("failed to inspect resource kind: {error}"))?;
98 if value.get("kind").and_then(serde_json::Value::as_str) == Some(kind) {
99 matches.insert(resource_id(resource).to_string());
100 }
101 }
102 } else if let Some(provider) = selector.strip_prefix("provider:") {
103 for resource in &config.spec.resources {
104 let value = serde_json::to_value(resource)
105 .map_err(|error| format!("failed to inspect resource provider: {error}"))?;
106 if value.get("provider").and_then(serde_json::Value::as_str) == Some(provider) {
107 matches.insert(resource_id(resource).to_string());
108 }
109 }
110 } else if let Some(label) = selector.strip_prefix("label:") {
111 let (key, expected) =
112 label.split_once('=').map_or((label, None), |(key, value)| (key, Some(value)));
113 if key.is_empty() || expected == Some("") {
114 return Err(format!("invalid label selector {selector}"));
115 }
116 for resource in &config.spec.resources {
117 let labels = match resource {
118 Resource::Machine(value) => Some(&value.labels),
119 Resource::DeploymentTarget(value) => Some(&value.labels),
120 _ => None,
121 };
122 if labels.is_some_and(|labels| {
123 labels.get(key).is_some_and(|value| expected.is_none_or(|expected| value == expected))
124 }) {
125 matches.insert(resource_id(resource).to_string());
126 }
127 }
128 } else {
129 if selector.contains(':') {
130 return Err(format!("unsupported selector {selector}"));
131 }
132 let bare_matches = config
133 .spec
134 .resources
135 .iter()
136 .filter(|resource| {
137 let id = resource_id(resource);
138 id == selector || id.rsplit('/').next() == Some(selector)
139 })
140 .map(|resource| resource_id(resource).to_string())
141 .collect::<BTreeSet<_>>();
142 if bare_matches.len() > 1 {
143 return Err(format!("selector {selector} is ambiguous; use resource: or target:"));
144 }
145 matches.extend(bare_matches);
146 }
147 if matches.is_empty() {
148 return Err(format!("selector {selector} matched no resources"));
149 }
150 result.extend(matches);
151 Ok(())
152}
153
154fn resource_id(resource: &Resource) -> &str {
155 crate::contract::resource_id(resource)
156}
157
158fn glob_matches(pattern: &str, value: &str) -> bool {
159 fn matches(pattern: &[u8], value: &[u8]) -> bool {
160 match pattern {
161 [] => value.is_empty(),
162 [b'*', rest @ ..] => {
163 matches(rest, value) || (!value.is_empty() && matches(pattern, &value[1..]))
164 }
165 [b'?', rest @ ..] => !value.is_empty() && matches(rest, &value[1..]),
166 [head, rest @ ..] => !value.is_empty() && *head == value[0] && matches(rest, &value[1..]),
167 }
168 }
169 !pattern.is_empty() && matches(pattern.as_bytes(), value.as_bytes())
170}
171
172#[cfg(test)]
173mod tests;