Skip to main content

a3s_runtime/
conformance.rs

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/// Provider-owned inputs for the destructive Runtime conformance suite.
18///
19/// Unit and request IDs must be unique to the suite invocation. Providers may
20/// create real resources, so callers should use disposable artifacts and an
21/// isolated provider namespace.
22#[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/// Evidence returned after a provider passes the common Task and Service path.
66#[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/// Complete provider-owned inputs for the mandatory Base profile.
76///
77/// Failure and timeout Tasks must deterministically return a `failed`
78/// observation. The generation-conflict pair must target the same unit and
79/// generation with different canonical specifications.
80#[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/// Evidence returned after the complete mandatory Base profile passes.
180#[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
191/// Runs the destructive provider-neutral lifecycle conformance suite.
192///
193/// Provider-specific tests remain responsible for crash injection and resource
194/// reconstruction. This suite establishes the shared protocol semantics those
195/// fault tests must preserve.
196pub 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
299/// Runs every mandatory Base-profile oracle, including negative Task results
300/// and a same-generation content conflict.
301pub 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}