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