1use crate::plan::PlanActionRisk;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeSet;
12
13pub const DEPLOYMENT_TARGET_PREFIX: &str = "deployment-target/";
14
15#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
20#[serde(rename_all = "kebab-case")]
21pub enum LifecycleIntent {
22 Observe,
24 BuildOnly,
26 Switch,
28 Boot,
30 Test,
32 Install,
34 Destroy,
36 Convert,
38 Recovery,
40 Exec,
42}
43
44#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, Hash)]
49#[serde(rename_all = "kebab-case")]
50pub enum InstallMode {
51 #[default]
53 Create,
54 Reinstall,
56 Replace,
58}
59
60#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
65#[serde(rename_all = "kebab-case")]
66pub enum HostIdentityAction {
67 Rotate,
70 Import,
73}
74
75impl HostIdentityAction {
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::Rotate => "rotate",
79 Self::Import => "import",
80 }
81 }
82}
83
84impl InstallMode {
85 pub fn as_str(self) -> &'static str {
86 match self {
87 Self::Create => "create",
88 Self::Reinstall => "reinstall",
89 Self::Replace => "replace",
90 }
91 }
92}
93
94#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, Hash)]
106#[serde(rename_all = "kebab-case")]
107pub enum EnrollmentStrategy {
108 #[default]
111 Ephemeral,
112}
113
114impl EnrollmentStrategy {
115 pub fn as_str(self) -> &'static str {
116 match self {
117 Self::Ephemeral => "ephemeral",
118 }
119 }
120}
121
122impl LifecycleIntent {
123 pub fn as_str(self) -> &'static str {
124 match self {
125 Self::Observe => "observe",
126 Self::BuildOnly => "build-only",
127 Self::Switch => "switch",
128 Self::Boot => "boot",
129 Self::Test => "test",
130 Self::Install => "install",
131 Self::Destroy => "destroy",
132 Self::Convert => "convert",
133 Self::Recovery => "recovery",
134 Self::Exec => "exec",
135 }
136 }
137
138 pub fn parse(value: &str) -> Result<Self, String> {
139 match value {
140 "observe" => Ok(Self::Observe),
141 "build-only" => Ok(Self::BuildOnly),
142 "switch" => Ok(Self::Switch),
143 "boot" => Ok(Self::Boot),
144 "test" => Ok(Self::Test),
145 "install" => Ok(Self::Install),
146 "destroy" => Ok(Self::Destroy),
147 "convert" => Ok(Self::Convert),
148 "recovery" => Ok(Self::Recovery),
149 "exec" => Ok(Self::Exec),
150 other => Err(format!(
151 "unsupported lifecycle intent {other:?}; expected observe|build-only|switch|boot|test|install|destroy|convert|recovery|exec"
152 )),
153 }
154 }
155
156 pub fn requires_destructive_approval(self) -> bool {
158 matches!(self, Self::Install | Self::Destroy | Self::Convert | Self::Recovery)
159 }
160
161 pub fn forbids_target_mutation(self) -> bool {
163 matches!(self, Self::Observe | Self::BuildOnly)
164 }
165}
166
167pub fn is_deployment_target_selector(selector: &str) -> bool {
169 selector.starts_with(DEPLOYMENT_TARGET_PREFIX)
170 && selector.len() > DEPLOYMENT_TARGET_PREFIX.len()
171 && !selector[DEPLOYMENT_TARGET_PREFIX.len()..].contains('/')
172}
173
174pub fn reject_invalid_deployment_target_spelling(selector: &str) -> Result<(), String> {
176 if selector.starts_with("deploymentTarget/") {
177 return Err(
178 "deployment target selectors must use deployment-target/<host> (hyphenated), not deploymentTarget/"
179 .to_string(),
180 );
181 }
182 if selector.starts_with("deployment_target/") {
183 return Err(
184 "deployment target selectors must use deployment-target/<host> (hyphenated), not deployment_target/"
185 .to_string(),
186 );
187 }
188 Ok(())
189}
190
191pub fn deployment_target_host_name(resource_id: &str) -> Option<&str> {
192 resource_id.strip_prefix(DEPLOYMENT_TARGET_PREFIX).filter(|name| {
193 !name.is_empty()
194 && !name.contains('/')
195 && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
196 })
197}
198
199#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
202#[serde(rename_all = "camelCase", deny_unknown_fields)]
203pub struct ApprovalRequirement {
204 pub kind: ApprovalKind,
205 pub risk: PlanActionRisk,
206 pub resource_ids: Vec<String>,
207 pub requires_plan_digest_binding: bool,
209}
210
211pub fn approval_requirements_from_actions(
218 actions: &[crate::plan::PlanAction],
219) -> Vec<ApprovalRequirement> {
220 let mut resource_ids = Vec::new();
221 let mut has_destructive = false;
222 let mut has_identity = false;
223 for action in actions {
224 match action.risk {
225 PlanActionRisk::Destructive => {
226 has_destructive = true;
227 if !resource_ids.contains(&action.resource) {
228 resource_ids.push(action.resource.clone());
229 }
230 }
231 PlanActionRisk::IdentityCritical => {
232 has_identity = true;
233 if !resource_ids.contains(&action.resource) {
234 resource_ids.push(action.resource.clone());
235 }
236 }
237 PlanActionRisk::ReadOnly | PlanActionRisk::Reversible | PlanActionRisk::ServiceImpacting => {}
238 }
239 }
240 if resource_ids.is_empty() {
241 return Vec::new();
242 }
243 resource_ids.sort();
244 let (kind, risk) = if has_destructive {
247 (ApprovalKind::Destroy, PlanActionRisk::Destructive)
248 } else if has_identity {
249 (ApprovalKind::Identity, PlanActionRisk::IdentityCritical)
250 } else {
251 return Vec::new();
252 };
253 vec![ApprovalRequirement { kind, risk, resource_ids, requires_plan_digest_binding: true }]
254}
255
256pub fn approval_requirements_for_install(
257 actions: &[crate::plan::PlanAction],
258 mode: InstallMode,
259) -> Vec<ApprovalRequirement> {
260 let kind = match mode {
261 InstallMode::Create | InstallMode::Replace => ApprovalKind::Recreate,
262 InstallMode::Reinstall => ApprovalKind::Overwrite,
263 };
264 approval_requirements_from_actions(actions)
265 .into_iter()
266 .map(|mut requirement| {
267 requirement.kind = kind;
268 requirement
269 })
270 .collect()
271}
272
273#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
274#[serde(rename_all = "kebab-case")]
275pub enum ApprovalKind {
276 Destroy,
277 Recreate,
278 Overwrite,
279 Convert,
280 Identity,
281}
282
283#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
284#[serde(rename_all = "camelCase", deny_unknown_fields)]
285pub struct ApprovalEvidence {
286 pub kind: ApprovalKind,
287 pub risk: PlanActionRisk,
289 pub plan_digest: String,
290 pub resource_ids: Vec<String>,
291 pub expires_at_unix: u64,
292 pub principal: String,
294 #[serde(default, skip_serializing_if = "is_zero_u64")]
296 pub approved_at_unix: u64,
297}
298
299fn is_zero_u64(value: &u64) -> bool {
300 *value == 0
301}
302
303impl ApprovalEvidence {
304 pub fn validate_for_plan(
305 &self,
306 plan_digest: &str,
307 required: &[ApprovalRequirement],
308 now_unix: u64,
309 ) -> Result<(), String> {
310 if self.expires_at_unix < now_unix {
311 return Err(format!(
312 "approval evidence expired at {} (current time {now_unix})",
313 self.expires_at_unix
314 ));
315 }
316 if self.principal.trim().is_empty()
317 || self.principal.len() > 128
318 || self.principal.chars().any(char::is_control)
319 {
320 return Err("approval evidence principal (operator) is invalid".to_string());
321 }
322 if required.is_empty() {
323 return Err("approval evidence supplied without a requirement".to_string());
324 }
325 if self.resource_ids.is_empty() {
326 return Err("approval evidence resource_ids must not be empty".to_string());
327 }
328 let unique: BTreeSet<_> = self.resource_ids.iter().collect();
330 if unique.len() != self.resource_ids.len() {
331 return Err("approval evidence resource_ids must be unique".to_string());
332 }
333 for requirement in required {
334 if requirement.requires_plan_digest_binding && self.plan_digest != plan_digest {
335 return Err(format!(
336 "approval evidence planDigest {} does not match plan {}",
337 self.plan_digest, plan_digest
338 ));
339 }
340 if self.kind != requirement.kind {
341 return Err(format!(
342 "approval evidence kind {:?} does not match required {:?}",
343 self.kind, requirement.kind
344 ));
345 }
346 if risk_rank(self.risk) < risk_rank(requirement.risk) {
347 return Err(format!(
348 "approval evidence risk {:?} is below required {:?}",
349 self.risk, requirement.risk
350 ));
351 }
352 let evidence: BTreeSet<_> = self.resource_ids.iter().collect();
353 let required_ids: BTreeSet<_> = requirement.resource_ids.iter().collect();
354 if evidence != required_ids {
355 return Err(format!(
356 "approval evidence resources must exactly match the plan (evidence=[{}], required=[{}])",
357 self.resource_ids.join(", "),
358 requirement.resource_ids.join(", ")
359 ));
360 }
361 }
362 Ok(())
363 }
364}
365
366fn risk_rank(risk: PlanActionRisk) -> u8 {
367 match risk {
368 PlanActionRisk::ReadOnly => 0,
369 PlanActionRisk::Reversible => 1,
370 PlanActionRisk::ServiceImpacting => 2,
371 PlanActionRisk::Destructive => 3,
372 PlanActionRisk::IdentityCritical => 4,
373 }
374}
375
376#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
378#[serde(rename_all = "camelCase", deny_unknown_fields)]
379pub struct ObservedDeploymentTarget {
380 pub resource_id: String,
381 pub hostname: String,
382 pub system: String,
383 pub class: String,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub declared_address: Option<String>,
387 pub target_contacted: bool,
389 pub observed_digest: String,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub active_system_path: Option<String>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub desired_system_path: Option<String>,
396 #[serde(default)]
398 pub system_path_matches: bool,
399}
400
401#[cfg(test)]
402#[path = "host_lifecycle_tests.rs"]
403mod tests;