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(
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
290/// Runs every mandatory Base-profile oracle, including negative Task results
291/// and a same-generation content conflict.
292pub 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}