1use crate::contract::{
2 RuntimeActionRequest, RuntimeApplyRequest, RuntimeInspection, RuntimeObservation,
3 RuntimeRemoval, RuntimeUnitClass, RuntimeUnitState,
4};
5use crate::{RuntimeClient, RuntimeError, RuntimeResult};
6use std::collections::BTreeSet;
7
8mod profiles;
9
10pub use profiles::{
11 required_runtime_profiles, runtime_profile_requirements, verify_runtime_profiles,
12 RuntimeConformanceFixture, RuntimeConformanceInventory, RuntimeConformanceProfile,
13 RuntimeConformanceProfileEvidence, RuntimeConformanceProfileRequirements,
14 RuntimeConformanceSuiteReport,
15};
16
17#[derive(Debug, Clone)]
23pub struct RuntimeConformanceCase {
24 pub task_apply: RuntimeApplyRequest,
25 pub task_remove: RuntimeActionRequest,
26 pub service_apply: RuntimeApplyRequest,
27 pub service_stop: RuntimeActionRequest,
28 pub service_remove: RuntimeActionRequest,
29}
30
31impl RuntimeConformanceCase {
32 pub fn validate(&self) -> Result<(), String> {
33 self.task_apply.validate()?;
34 self.task_remove.validate()?;
35 self.service_apply.validate()?;
36 self.service_stop.validate()?;
37 self.service_remove.validate()?;
38 if self.task_apply.spec.class != RuntimeUnitClass::Task {
39 return Err("conformance task_apply must describe a Task".into());
40 }
41 if self.service_apply.spec.class != RuntimeUnitClass::Service {
42 return Err("conformance service_apply must describe a Service".into());
43 }
44 if self.task_apply.spec.unit_id == self.service_apply.spec.unit_id {
45 return Err("conformance Task and Service must use different unit IDs".into());
46 }
47 validate_action(&self.task_remove, &self.task_apply)?;
48 validate_action(&self.service_stop, &self.service_apply)?;
49 validate_action(&self.service_remove, &self.service_apply)?;
50 let mut request_ids = [
51 self.task_apply.request_id.as_str(),
52 self.task_remove.request_id.as_str(),
53 self.service_apply.request_id.as_str(),
54 self.service_stop.request_id.as_str(),
55 self.service_remove.request_id.as_str(),
56 ];
57 request_ids.sort_unstable();
58 if request_ids.windows(2).any(|pair| pair[0] == pair[1]) {
59 return Err("conformance requests must use unique request IDs".into());
60 }
61 Ok(())
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct RuntimeConformanceReport {
68 pub task: RuntimeObservation,
69 pub task_removal: RuntimeRemoval,
70 pub service: RuntimeObservation,
71 pub stopped_service: RuntimeObservation,
72 pub service_removal: RuntimeRemoval,
73}
74
75#[derive(Debug, Clone)]
81pub struct RuntimeBaseConformanceCase {
82 pub lifecycle: RuntimeConformanceCase,
83 pub task_failure_apply: RuntimeApplyRequest,
84 pub task_failure_remove: RuntimeActionRequest,
85 pub task_timeout_apply: RuntimeApplyRequest,
86 pub task_timeout_remove: RuntimeActionRequest,
87 pub generation_apply: RuntimeApplyRequest,
88 pub generation_conflict_apply: RuntimeApplyRequest,
89 pub generation_remove: RuntimeActionRequest,
90}
91
92impl RuntimeBaseConformanceCase {
93 pub fn validate(&self) -> Result<(), String> {
94 self.lifecycle.validate()?;
95 for (label, apply, remove) in [
96 (
97 "failure",
98 &self.task_failure_apply,
99 &self.task_failure_remove,
100 ),
101 (
102 "timeout",
103 &self.task_timeout_apply,
104 &self.task_timeout_remove,
105 ),
106 ] {
107 apply.validate()?;
108 remove.validate()?;
109 if apply.spec.class != RuntimeUnitClass::Task {
110 return Err(format!("conformance {label} fixture must describe a Task"));
111 }
112 validate_action(remove, apply)?;
113 }
114
115 self.generation_apply.validate()?;
116 self.generation_conflict_apply.validate()?;
117 self.generation_remove.validate()?;
118 if self.generation_apply.spec.class != RuntimeUnitClass::Service {
119 return Err("conformance generation fixture must describe a Service".into());
120 }
121 if self.generation_apply.spec.unit_id != self.generation_conflict_apply.spec.unit_id
122 || self.generation_apply.spec.generation
123 != self.generation_conflict_apply.spec.generation
124 {
125 return Err(
126 "conformance generation-conflict fixtures must target one generation".into(),
127 );
128 }
129 if self.generation_apply.spec.digest()? == self.generation_conflict_apply.spec.digest()? {
130 return Err(
131 "conformance generation-conflict fixtures must have different content".into(),
132 );
133 }
134 validate_action(&self.generation_remove, &self.generation_apply)?;
135
136 let unit_ids = [
137 self.lifecycle.task_apply.spec.unit_id.as_str(),
138 self.lifecycle.service_apply.spec.unit_id.as_str(),
139 self.task_failure_apply.spec.unit_id.as_str(),
140 self.task_timeout_apply.spec.unit_id.as_str(),
141 self.generation_apply.spec.unit_id.as_str(),
142 ];
143 if unit_ids.iter().copied().collect::<BTreeSet<_>>().len() != unit_ids.len() {
144 return Err("Base conformance fixtures must use distinct unit IDs".into());
145 }
146
147 let request_ids = [
148 self.lifecycle.task_apply.request_id.as_str(),
149 self.lifecycle.task_remove.request_id.as_str(),
150 self.lifecycle.service_apply.request_id.as_str(),
151 self.lifecycle.service_stop.request_id.as_str(),
152 self.lifecycle.service_remove.request_id.as_str(),
153 self.task_failure_apply.request_id.as_str(),
154 self.task_failure_remove.request_id.as_str(),
155 self.task_timeout_apply.request_id.as_str(),
156 self.task_timeout_remove.request_id.as_str(),
157 self.generation_apply.request_id.as_str(),
158 self.generation_conflict_apply.request_id.as_str(),
159 self.generation_remove.request_id.as_str(),
160 ];
161 if request_ids.iter().copied().collect::<BTreeSet<_>>().len() != request_ids.len() {
162 return Err("Base conformance fixtures must use unique request IDs".into());
163 }
164 Ok(())
165 }
166
167 pub(crate) fn specifications(&self) -> [&crate::contract::RuntimeUnitSpec; 6] {
168 [
169 &self.lifecycle.task_apply.spec,
170 &self.lifecycle.service_apply.spec,
171 &self.task_failure_apply.spec,
172 &self.task_timeout_apply.spec,
173 &self.generation_apply.spec,
174 &self.generation_conflict_apply.spec,
175 ]
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct RuntimeBaseConformanceReport {
182 pub lifecycle: RuntimeConformanceReport,
183 pub failed_task: RuntimeObservation,
184 pub failed_task_removal: RuntimeRemoval,
185 pub timed_out_task: RuntimeObservation,
186 pub timed_out_task_removal: RuntimeRemoval,
187 pub generation: RuntimeObservation,
188 pub generation_removal: RuntimeRemoval,
189}
190
191pub async fn verify_runtime_provider(
197 client: &dyn RuntimeClient,
198 case: &RuntimeConformanceCase,
199) -> RuntimeResult<RuntimeConformanceReport> {
200 case.validate().map_err(RuntimeError::InvalidRequest)?;
201 let capabilities = client.capabilities().await?;
202 capabilities.validate().map_err(RuntimeError::Protocol)?;
203 for spec in [&case.task_apply.spec, &case.service_apply.spec] {
204 let missing = capabilities
205 .missing_for(spec)
206 .map_err(RuntimeError::Protocol)?;
207 if !missing.is_empty() {
208 return Err(RuntimeError::UnsupportedCapabilities(missing));
209 }
210 }
211
212 let task = client.apply(&case.task_apply).await?;
213 task.validate_against(&case.task_apply.spec)
214 .map_err(RuntimeError::Protocol)?;
215 if !task.converges(&case.task_apply.spec) {
216 return Err(RuntimeError::Protocol(
217 "conformance Task did not reach succeeded".into(),
218 ));
219 }
220 require_equal(
221 "duplicate Task apply",
222 &task,
223 &client.apply(&case.task_apply).await?,
224 )?;
225 let inspected_task = require_found("inspect Task", client.inspect(&task.unit_id).await?)?;
226 require_equal("terminal Task inspection", &task, &inspected_task)?;
227 let task_removal = client.remove(&case.task_remove).await?;
228 require_equal(
229 "duplicate Task remove",
230 &task_removal,
231 &client.remove(&case.task_remove).await?,
232 )?;
233 require_absent(
234 "removed Task inspection",
235 client.inspect(&task.unit_id).await?,
236 task.generation,
237 )?;
238
239 let service = client.apply(&case.service_apply).await?;
240 service
241 .validate_against(&case.service_apply.spec)
242 .map_err(RuntimeError::Protocol)?;
243 if !service.converges(&case.service_apply.spec) {
244 return Err(RuntimeError::Protocol(
245 "conformance Service did not reach running and healthy".into(),
246 ));
247 }
248 require_equal(
249 "duplicate Service apply",
250 &service,
251 &client.apply(&case.service_apply).await?,
252 )?;
253 let inspected_service =
254 require_found("inspect Service", client.inspect(&service.unit_id).await?)?;
255 inspected_service
256 .validate_against(&case.service_apply.spec)
257 .map_err(RuntimeError::Protocol)?;
258 let stopped_service = require_found("stop Service", client.stop(&case.service_stop).await?)?;
259 if stopped_service.state != RuntimeUnitState::Stopped {
260 return Err(RuntimeError::Protocol(
261 "conformance Service stop did not reach stopped".into(),
262 ));
263 }
264 let duplicate_stop = require_found(
265 "duplicate Service stop",
266 client.stop(&case.service_stop).await?,
267 )?;
268 require_equal("duplicate Service stop", &stopped_service, &duplicate_stop)?;
269 let service_removal = client.remove(&case.service_remove).await?;
270 require_equal(
271 "duplicate Service remove",
272 &service_removal,
273 &client.remove(&case.service_remove).await?,
274 )?;
275 require_absent(
276 "removed Service inspection",
277 client.inspect(&service.unit_id).await?,
278 service.generation,
279 )?;
280
281 Ok(RuntimeConformanceReport {
282 task,
283 task_removal,
284 service,
285 stopped_service,
286 service_removal,
287 })
288}
289
290pub async fn verify_runtime_base(
293 client: &dyn RuntimeClient,
294 case: &RuntimeBaseConformanceCase,
295) -> RuntimeResult<RuntimeBaseConformanceReport> {
296 case.validate().map_err(RuntimeError::InvalidRequest)?;
297 let capabilities = client.capabilities().await?;
298 capabilities.validate().map_err(RuntimeError::Protocol)?;
299 for spec in case.specifications() {
300 let missing = capabilities
301 .missing_for(spec)
302 .map_err(RuntimeError::Protocol)?;
303 if !missing.is_empty() {
304 return Err(RuntimeError::UnsupportedCapabilities(missing));
305 }
306 }
307
308 let lifecycle = verify_runtime_provider(client, &case.lifecycle).await?;
309 let (failed_task, failed_task_removal) = verify_failed_task(
310 client,
311 "failed Task",
312 &case.task_failure_apply,
313 &case.task_failure_remove,
314 )
315 .await?;
316 let (timed_out_task, timed_out_task_removal) = verify_failed_task(
317 client,
318 "timed-out Task",
319 &case.task_timeout_apply,
320 &case.task_timeout_remove,
321 )
322 .await?;
323
324 let generation = client.apply(&case.generation_apply).await?;
325 generation
326 .validate_against(&case.generation_apply.spec)
327 .map_err(RuntimeError::Protocol)?;
328 if !generation.converges(&case.generation_apply.spec) {
329 return Err(RuntimeError::Protocol(
330 "Base generation fixture did not converge".into(),
331 ));
332 }
333 require_equal(
334 "duplicate generation apply",
335 &generation,
336 &client.apply(&case.generation_apply).await?,
337 )?;
338 match client.apply(&case.generation_conflict_apply).await {
339 Err(RuntimeError::GenerationConflict {
340 unit_id,
341 generation: rejected_generation,
342 }) if unit_id == case.generation_apply.spec.unit_id
343 && rejected_generation == case.generation_apply.spec.generation => {}
344 Err(error) => return Err(error),
345 Ok(_) => {
346 return Err(RuntimeError::Protocol(
347 "Base generation conflict unexpectedly succeeded".into(),
348 ));
349 }
350 }
351 let generation_removal = client.remove(&case.generation_remove).await?;
352 require_equal(
353 "duplicate generation removal",
354 &generation_removal,
355 &client.remove(&case.generation_remove).await?,
356 )?;
357 require_absent(
358 "removed generation fixture inspection",
359 client.inspect(&generation.unit_id).await?,
360 generation.generation,
361 )?;
362
363 Ok(RuntimeBaseConformanceReport {
364 lifecycle,
365 failed_task,
366 failed_task_removal,
367 timed_out_task,
368 timed_out_task_removal,
369 generation,
370 generation_removal,
371 })
372}
373
374async fn verify_failed_task(
375 client: &dyn RuntimeClient,
376 label: &str,
377 apply: &RuntimeApplyRequest,
378 remove: &RuntimeActionRequest,
379) -> RuntimeResult<(RuntimeObservation, RuntimeRemoval)> {
380 let observation = client.apply(apply).await?;
381 observation
382 .validate_against(&apply.spec)
383 .map_err(RuntimeError::Protocol)?;
384 if observation.state != RuntimeUnitState::Failed {
385 return Err(RuntimeError::Protocol(format!(
386 "conformance {label} did not reach failed"
387 )));
388 }
389 require_equal(
390 &format!("duplicate {label} apply"),
391 &observation,
392 &client.apply(apply).await?,
393 )?;
394 require_equal(
395 &format!("terminal {label} inspection"),
396 &observation,
397 &require_found(label, client.inspect(&observation.unit_id).await?)?,
398 )?;
399 let removal = client.remove(remove).await?;
400 require_equal(
401 &format!("duplicate {label} removal"),
402 &removal,
403 &client.remove(remove).await?,
404 )?;
405 require_absent(
406 &format!("removed {label} inspection"),
407 client.inspect(&observation.unit_id).await?,
408 observation.generation,
409 )?;
410 Ok((observation, removal))
411}
412
413fn validate_action(
414 action: &RuntimeActionRequest,
415 apply: &RuntimeApplyRequest,
416) -> Result<(), String> {
417 if action.unit_id != apply.spec.unit_id || action.generation != apply.spec.generation {
418 return Err(format!(
419 "conformance action {:?} does not target apply request {:?}",
420 action.request_id, apply.request_id
421 ));
422 }
423 Ok(())
424}
425
426fn require_found(label: &str, inspection: RuntimeInspection) -> RuntimeResult<RuntimeObservation> {
427 match inspection {
428 RuntimeInspection::Found { observation, .. } => Ok(*observation),
429 RuntimeInspection::NotFound { .. } => Err(RuntimeError::Protocol(format!(
430 "{label} unexpectedly returned not found"
431 ))),
432 }
433}
434
435fn require_absent(
436 label: &str,
437 inspection: RuntimeInspection,
438 generation: u64,
439) -> RuntimeResult<()> {
440 match inspection {
441 RuntimeInspection::NotFound {
442 last_generation: Some(last_generation),
443 ..
444 } if last_generation == generation => Ok(()),
445 _ => Err(RuntimeError::Protocol(format!(
446 "{label} did not preserve the removed generation"
447 ))),
448 }
449}
450
451fn require_equal<T>(label: &str, expected: &T, actual: &T) -> RuntimeResult<()>
452where
453 T: PartialEq,
454{
455 if expected != actual {
456 return Err(RuntimeError::Protocol(format!(
457 "{label} returned a different durable result"
458 )));
459 }
460 Ok(())
461}