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