Skip to main content

nxd_core/domain/selection/
mod.rs

1//! Pure expansion of canonical resource selections.
2
3use 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;