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)]
99#[serde(rename_all = "kebab-case")]
100pub enum EnrollmentStrategy {
101 Ephemeral,
104 #[default]
107 DurableStaged,
108}
109
110impl EnrollmentStrategy {
111 pub fn as_str(self) -> &'static str {
112 match self {
113 Self::Ephemeral => "ephemeral",
114 Self::DurableStaged => "durable-staged",
115 }
116 }
117}
118
119impl LifecycleIntent {
120 pub fn as_str(self) -> &'static str {
121 match self {
122 Self::Observe => "observe",
123 Self::BuildOnly => "build-only",
124 Self::Switch => "switch",
125 Self::Boot => "boot",
126 Self::Test => "test",
127 Self::Install => "install",
128 Self::Destroy => "destroy",
129 Self::Convert => "convert",
130 Self::Recovery => "recovery",
131 Self::Exec => "exec",
132 }
133 }
134
135 pub fn parse(value: &str) -> Result<Self, String> {
136 match value {
137 "observe" => Ok(Self::Observe),
138 "build-only" => Ok(Self::BuildOnly),
139 "switch" => Ok(Self::Switch),
140 "boot" => Ok(Self::Boot),
141 "test" => Ok(Self::Test),
142 "install" => Ok(Self::Install),
143 "destroy" => Ok(Self::Destroy),
144 "convert" => Ok(Self::Convert),
145 "recovery" => Ok(Self::Recovery),
146 "exec" => Ok(Self::Exec),
147 other => Err(format!(
148 "unsupported lifecycle intent {other:?}; expected observe|build-only|switch|boot|test|install|destroy|convert|recovery|exec"
149 )),
150 }
151 }
152
153 pub fn requires_destructive_approval(self) -> bool {
155 matches!(self, Self::Install | Self::Destroy | Self::Convert | Self::Recovery)
156 }
157
158 pub fn forbids_target_mutation(self) -> bool {
160 matches!(self, Self::Observe | Self::BuildOnly)
161 }
162}
163
164pub fn is_deployment_target_selector(selector: &str) -> bool {
166 selector.starts_with(DEPLOYMENT_TARGET_PREFIX)
167 && selector.len() > DEPLOYMENT_TARGET_PREFIX.len()
168 && !selector[DEPLOYMENT_TARGET_PREFIX.len()..].contains('/')
169}
170
171pub fn reject_invalid_deployment_target_spelling(selector: &str) -> Result<(), String> {
173 if selector.starts_with("deploymentTarget/") {
174 return Err(
175 "deployment target selectors must use deployment-target/<host> (hyphenated), not deploymentTarget/"
176 .to_string(),
177 );
178 }
179 if selector.starts_with("deployment_target/") {
180 return Err(
181 "deployment target selectors must use deployment-target/<host> (hyphenated), not deployment_target/"
182 .to_string(),
183 );
184 }
185 Ok(())
186}
187
188pub fn deployment_target_host_name(resource_id: &str) -> Option<&str> {
189 resource_id.strip_prefix(DEPLOYMENT_TARGET_PREFIX).filter(|name| {
190 !name.is_empty()
191 && !name.contains('/')
192 && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
193 })
194}
195
196#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
199#[serde(rename_all = "camelCase", deny_unknown_fields)]
200pub struct ApprovalRequirement {
201 pub kind: ApprovalKind,
202 pub risk: PlanActionRisk,
203 pub resource_ids: Vec<String>,
204 pub requires_plan_digest_binding: bool,
206}
207
208pub fn approval_requirements_from_actions(
215 actions: &[crate::plan::PlanAction],
216) -> Vec<ApprovalRequirement> {
217 let mut resource_ids = Vec::new();
218 let mut has_destructive = false;
219 let mut has_identity = false;
220 for action in actions {
221 match action.risk {
222 PlanActionRisk::Destructive => {
223 has_destructive = true;
224 if !resource_ids.contains(&action.resource) {
225 resource_ids.push(action.resource.clone());
226 }
227 }
228 PlanActionRisk::IdentityCritical => {
229 has_identity = true;
230 if !resource_ids.contains(&action.resource) {
231 resource_ids.push(action.resource.clone());
232 }
233 }
234 PlanActionRisk::ReadOnly | PlanActionRisk::Reversible | PlanActionRisk::ServiceImpacting => {}
235 }
236 }
237 if resource_ids.is_empty() {
238 return Vec::new();
239 }
240 resource_ids.sort();
241 let (kind, risk) = if has_destructive {
244 (ApprovalKind::Destroy, PlanActionRisk::Destructive)
245 } else if has_identity {
246 (ApprovalKind::Identity, PlanActionRisk::IdentityCritical)
247 } else {
248 return Vec::new();
249 };
250 vec![ApprovalRequirement { kind, risk, resource_ids, requires_plan_digest_binding: true }]
251}
252
253pub fn approval_requirements_for_install(
254 actions: &[crate::plan::PlanAction],
255 mode: InstallMode,
256) -> Vec<ApprovalRequirement> {
257 let kind = match mode {
258 InstallMode::Create | InstallMode::Replace => ApprovalKind::Recreate,
259 InstallMode::Reinstall => ApprovalKind::Overwrite,
260 };
261 approval_requirements_from_actions(actions)
262 .into_iter()
263 .map(|mut requirement| {
264 requirement.kind = kind;
265 requirement
266 })
267 .collect()
268}
269
270#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
271#[serde(rename_all = "kebab-case")]
272pub enum ApprovalKind {
273 Destroy,
274 Recreate,
275 Overwrite,
276 Convert,
277 Identity,
278}
279
280#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
281#[serde(rename_all = "camelCase", deny_unknown_fields)]
282pub struct ApprovalEvidence {
283 pub kind: ApprovalKind,
284 pub risk: PlanActionRisk,
286 pub plan_digest: String,
287 pub resource_ids: Vec<String>,
288 pub expires_at_unix: u64,
289 pub principal: String,
291 #[serde(default, skip_serializing_if = "is_zero_u64")]
293 pub approved_at_unix: u64,
294}
295
296fn is_zero_u64(value: &u64) -> bool {
297 *value == 0
298}
299
300impl ApprovalEvidence {
301 pub fn validate_for_plan(
302 &self,
303 plan_digest: &str,
304 required: &[ApprovalRequirement],
305 now_unix: u64,
306 ) -> Result<(), String> {
307 if self.expires_at_unix < now_unix {
308 return Err(format!(
309 "approval evidence expired at {} (current time {now_unix})",
310 self.expires_at_unix
311 ));
312 }
313 if self.principal.trim().is_empty()
314 || self.principal.len() > 128
315 || self.principal.chars().any(char::is_control)
316 {
317 return Err("approval evidence principal (operator) is invalid".to_string());
318 }
319 if required.is_empty() {
320 return Err("approval evidence supplied without a requirement".to_string());
321 }
322 if self.resource_ids.is_empty() {
323 return Err("approval evidence resource_ids must not be empty".to_string());
324 }
325 let unique: BTreeSet<_> = self.resource_ids.iter().collect();
327 if unique.len() != self.resource_ids.len() {
328 return Err("approval evidence resource_ids must be unique".to_string());
329 }
330 for requirement in required {
331 if requirement.requires_plan_digest_binding && self.plan_digest != plan_digest {
332 return Err(format!(
333 "approval evidence planDigest {} does not match plan {}",
334 self.plan_digest, plan_digest
335 ));
336 }
337 if self.kind != requirement.kind {
338 return Err(format!(
339 "approval evidence kind {:?} does not match required {:?}",
340 self.kind, requirement.kind
341 ));
342 }
343 if risk_rank(self.risk) < risk_rank(requirement.risk) {
344 return Err(format!(
345 "approval evidence risk {:?} is below required {:?}",
346 self.risk, requirement.risk
347 ));
348 }
349 let evidence: BTreeSet<_> = self.resource_ids.iter().collect();
350 let required_ids: BTreeSet<_> = requirement.resource_ids.iter().collect();
351 if evidence != required_ids {
352 return Err(format!(
353 "approval evidence resources must exactly match the plan (evidence=[{}], required=[{}])",
354 self.resource_ids.join(", "),
355 requirement.resource_ids.join(", ")
356 ));
357 }
358 }
359 Ok(())
360 }
361}
362
363fn risk_rank(risk: PlanActionRisk) -> u8 {
364 match risk {
365 PlanActionRisk::ReadOnly => 0,
366 PlanActionRisk::Reversible => 1,
367 PlanActionRisk::ServiceImpacting => 2,
368 PlanActionRisk::Destructive => 3,
369 PlanActionRisk::IdentityCritical => 4,
370 }
371}
372
373#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
375#[serde(rename_all = "camelCase", deny_unknown_fields)]
376pub struct ObservedDeploymentTarget {
377 pub resource_id: String,
378 pub hostname: String,
379 pub system: String,
380 pub class: String,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub declared_address: Option<String>,
384 pub target_contacted: bool,
386 pub observed_digest: String,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub active_system_path: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub desired_system_path: Option<String>,
393 #[serde(default)]
395 pub system_path_matches: bool,
396}
397
398#[cfg(test)]
399#[path = "host_lifecycle_tests.rs"]
400mod tests;