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(format!(
217 "conformance Task did not reach succeeded: state={:?}, provider_resource_id={:?}, failure={:?}",
218 task.state, task.provider_resource_id, task.failure
219 )));
220 }
221 require_equal(
222 "duplicate Task apply",
223 &task,
224 &client.apply(&case.task_apply).await?,
225 )?;
226 let inspected_task = require_found("inspect Task", client.inspect(&task.unit_id).await?)?;
227 require_equal("terminal Task inspection", &task, &inspected_task)?;
228 let task_removal = client.remove(&case.task_remove).await?;
229 require_equal(
230 "duplicate Task remove",
231 &task_removal,
232 &client.remove(&case.task_remove).await?,
233 )?;
234 require_absent(
235 "removed Task inspection",
236 client.inspect(&task.unit_id).await?,
237 task.generation,
238 )?;
239
240 let service = client.apply(&case.service_apply).await?;
241 service
242 .validate_against(&case.service_apply.spec)
243 .map_err(RuntimeError::Protocol)?;
244 if !service.converges(&case.service_apply.spec) {
245 return Err(RuntimeError::Protocol(format!(
246 "conformance Service did not converge: state={:?}, provider_resource_id={:?}, readiness={:?}, liveness={:?}, failure={:?}",
247 service.state,
248 service.provider_resource_id,
249 service.health,
250 service.liveness,
251 service.failure
252 )));
253 }
254 require_equal(
255 "duplicate Service apply",
256 &service,
257 &client.apply(&case.service_apply).await?,
258 )?;
259 let inspected_service =
260 require_found("inspect Service", client.inspect(&service.unit_id).await?)?;
261 inspected_service
262 .validate_against(&case.service_apply.spec)
263 .map_err(RuntimeError::Protocol)?;
264 let stopped_service = require_found("stop Service", client.stop(&case.service_stop).await?)?;
265 if stopped_service.state != RuntimeUnitState::Stopped {
266 return Err(RuntimeError::Protocol(format!(
267 "conformance Service stop did not reach stopped: state={:?}, provider_resource_id={:?}, failure={:?}",
268 stopped_service.state,
269 stopped_service.provider_resource_id,
270 stopped_service.failure
271 )));
272 }
273 let duplicate_stop = require_found(
274 "duplicate Service stop",
275 client.stop(&case.service_stop).await?,
276 )?;
277 require_equal("duplicate Service stop", &stopped_service, &duplicate_stop)?;
278 let service_removal = client.remove(&case.service_remove).await?;
279 require_equal(
280 "duplicate Service remove",
281 &service_removal,
282 &client.remove(&case.service_remove).await?,
283 )?;
284 require_absent(
285 "removed Service inspection",
286 client.inspect(&service.unit_id).await?,
287 service.generation,
288 )?;
289
290 Ok(RuntimeConformanceReport {
291 task,
292 task_removal,
293 service,
294 stopped_service,
295 service_removal,
296 })
297}
298
299pub async fn verify_runtime_base(
302 client: &dyn RuntimeClient,
303 case: &RuntimeBaseConformanceCase,
304) -> RuntimeResult<RuntimeBaseConformanceReport> {
305 case.validate().map_err(RuntimeError::InvalidRequest)?;
306 let capabilities = client.capabilities().await?;
307 capabilities.validate().map_err(RuntimeError::Protocol)?;
308 for spec in case.specifications() {
309 let missing = capabilities
310 .missing_for(spec)
311 .map_err(RuntimeError::Protocol)?;
312 if !missing.is_empty() {
313 return Err(RuntimeError::UnsupportedCapabilities(missing));
314 }
315 }
316
317 let lifecycle = verify_runtime_provider(client, &case.lifecycle).await?;
318 let (failed_task, failed_task_removal) = verify_failed_task(
319 client,
320 "failed Task",
321 &case.task_failure_apply,
322 &case.task_failure_remove,
323 )
324 .await?;
325 let (timed_out_task, timed_out_task_removal) = verify_failed_task(
326 client,
327 "timed-out Task",
328 &case.task_timeout_apply,
329 &case.task_timeout_remove,
330 )
331 .await?;
332
333 let generation = client.apply(&case.generation_apply).await?;
334 generation
335 .validate_against(&case.generation_apply.spec)
336 .map_err(RuntimeError::Protocol)?;
337 if !generation.converges(&case.generation_apply.spec) {
338 return Err(RuntimeError::Protocol(format!(
339 "Base generation fixture did not converge: state={:?}, provider_resource_id={:?}, readiness={:?}, liveness={:?}, failure={:?}",
340 generation.state,
341 generation.provider_resource_id,
342 generation.health,
343 generation.liveness,
344 generation.failure
345 )));
346 }
347 require_equal(
348 "duplicate generation apply",
349 &generation,
350 &client.apply(&case.generation_apply).await?,
351 )?;
352 match client.apply(&case.generation_conflict_apply).await {
353 Err(RuntimeError::GenerationConflict {
354 unit_id,
355 generation: rejected_generation,
356 }) if unit_id == case.generation_apply.spec.unit_id
357 && rejected_generation == case.generation_apply.spec.generation => {}
358 Err(error) => return Err(error),
359 Ok(_) => {
360 return Err(RuntimeError::Protocol(
361 "Base generation conflict unexpectedly succeeded".into(),
362 ));
363 }
364 }
365 let generation_removal = client.remove(&case.generation_remove).await?;
366 require_equal(
367 "duplicate generation removal",
368 &generation_removal,
369 &client.remove(&case.generation_remove).await?,
370 )?;
371 require_absent(
372 "removed generation fixture inspection",
373 client.inspect(&generation.unit_id).await?,
374 generation.generation,
375 )?;
376
377 Ok(RuntimeBaseConformanceReport {
378 lifecycle,
379 failed_task,
380 failed_task_removal,
381 timed_out_task,
382 timed_out_task_removal,
383 generation,
384 generation_removal,
385 })
386}
387
388async fn verify_failed_task(
389 client: &dyn RuntimeClient,
390 label: &str,
391 apply: &RuntimeApplyRequest,
392 remove: &RuntimeActionRequest,
393) -> RuntimeResult<(RuntimeObservation, RuntimeRemoval)> {
394 let observation = client.apply(apply).await?;
395 observation
396 .validate_against(&apply.spec)
397 .map_err(RuntimeError::Protocol)?;
398 if observation.state != RuntimeUnitState::Failed {
399 return Err(RuntimeError::Protocol(format!(
400 "conformance {label} did not reach failed: state={:?}, provider_resource_id={:?}, failure={:?}",
401 observation.state, observation.provider_resource_id, observation.failure
402 )));
403 }
404 require_equal(
405 &format!("duplicate {label} apply"),
406 &observation,
407 &client.apply(apply).await?,
408 )?;
409 require_equal(
410 &format!("terminal {label} inspection"),
411 &observation,
412 &require_found(label, client.inspect(&observation.unit_id).await?)?,
413 )?;
414 let removal = client.remove(remove).await?;
415 require_equal(
416 &format!("duplicate {label} removal"),
417 &removal,
418 &client.remove(remove).await?,
419 )?;
420 require_absent(
421 &format!("removed {label} inspection"),
422 client.inspect(&observation.unit_id).await?,
423 observation.generation,
424 )?;
425 Ok((observation, removal))
426}
427
428fn validate_action(
429 action: &RuntimeActionRequest,
430 apply: &RuntimeApplyRequest,
431) -> Result<(), String> {
432 if action.unit_id != apply.spec.unit_id || action.generation != apply.spec.generation {
433 return Err(format!(
434 "conformance action {:?} does not target apply request {:?}",
435 action.request_id, apply.request_id
436 ));
437 }
438 Ok(())
439}
440
441fn require_found(label: &str, inspection: RuntimeInspection) -> RuntimeResult<RuntimeObservation> {
442 match inspection {
443 RuntimeInspection::Found { observation, .. } => Ok(*observation),
444 RuntimeInspection::NotFound { .. } => Err(RuntimeError::Protocol(format!(
445 "{label} unexpectedly returned not found"
446 ))),
447 }
448}
449
450fn require_absent(
451 label: &str,
452 inspection: RuntimeInspection,
453 generation: u64,
454) -> RuntimeResult<()> {
455 match inspection {
456 RuntimeInspection::NotFound {
457 last_generation: Some(last_generation),
458 ..
459 } if last_generation == generation => Ok(()),
460 _ => Err(RuntimeError::Protocol(format!(
461 "{label} did not preserve the removed generation"
462 ))),
463 }
464}
465
466fn require_equal<T>(label: &str, expected: &T, actual: &T) -> RuntimeResult<()>
467where
468 T: PartialEq,
469{
470 if expected != actual {
471 return Err(RuntimeError::Protocol(format!(
472 "{label} returned a different durable result"
473 )));
474 }
475 Ok(())
476}