1use std::collections::BTreeMap;
2use std::path::Path;
3
4use byteor_app_kit::approval::{
5 approval_spec_hash_from_canonical_spec_kv, required_approval_capabilities_for_spec_kv,
6};
7use byteor_app_kit::capabilities::{
8 ExecutionTarget, PolicyClassId, ReplaySupport, RuntimeCapabilityDocument, RuntimeEnvironment,
9 StageCatalogEntry, StageKeyKind,
10};
11use byteor_core::config::Environment;
12use serde::Serialize;
13
14use crate::{canonical_encode_spec_kv_v1, default_runtime_capability_document, read_spec_kv_v1};
15
16pub(crate) const COMPATIBILITY_REPORT_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum StageRequirementSource {
23 BuiltIn,
25 CatalogStable,
27 CatalogPrefix,
29 Unknown,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct StageCompatibilityRequirement {
37 pub key: String,
39 pub source: StageRequirementSource,
41 pub supported: bool,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub policy_class: Option<PolicyClassId>,
46 pub replay_support: ReplaySupport,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub approval_capabilities: Vec<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct CompatibilityReport {
57 pub schema_version: u32,
59 pub product: String,
61 pub environment: RuntimeEnvironment,
63 pub spec_hash: String,
65 pub target: ExecutionTarget,
67 pub compatible: bool,
69 pub requires_approval: bool,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub required_approval_capabilities: Vec<String>,
74 pub replay_support: ReplaySupport,
76 pub stage_requirements: Vec<StageCompatibilityRequirement>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub unknown_stage_keys: Vec<String>,
81 pub supported_run_modes: Vec<String>,
83 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub reasons: Vec<String>,
86}
87
88pub fn compatibility_report_for_spec(
90 spec_path: &Path,
91 env: Environment,
92) -> Result<CompatibilityReport, String> {
93 let spec = read_spec_kv_v1(spec_path)?;
94 let canonical_spec_kv = canonical_encode_spec_kv_v1(spec_path)?;
95 Ok(compatibility_report_for_document(
96 &default_runtime_capability_document("byteor-runtime"),
97 &spec,
98 &canonical_spec_kv,
99 env,
100 ))
101}
102
103pub fn compatibility_report_for_document(
105 document: &RuntimeCapabilityDocument,
106 spec: &byteor_pipeline_spec::PipelineSpecV1,
107 canonical_spec_kv: &str,
108 env: Environment,
109) -> CompatibilityReport {
110 let target = match spec {
111 byteor_pipeline_spec::PipelineSpecV1::SingleRing(_) => ExecutionTarget::SingleRing,
112 byteor_pipeline_spec::PipelineSpecV1::LaneGraph(_) => ExecutionTarget::LaneGraph,
113 };
114 let environment = runtime_environment(env);
115 let required_approval_capabilities = required_approval_capabilities_for_spec_kv(canonical_spec_kv);
116 let requires_approval = !required_approval_capabilities.is_empty()
117 && matches!(environment, RuntimeEnvironment::Staging | RuntimeEnvironment::Prod);
118
119 let mut requirements = collect_stage_requirements(document, spec);
120 requirements.sort_by(|left, right| left.key.cmp(&right.key));
121
122 let unknown_stage_keys: Vec<String> = requirements
123 .iter()
124 .filter(|requirement| !requirement.supported)
125 .map(|requirement| requirement.key.clone())
126 .collect();
127
128 let mut reasons = Vec::new();
129 if !document.supported_targets.contains(&target) {
130 reasons.push(format!(
131 "target {:?} is not supported by {}",
132 target, document.product
133 ));
134 }
135 if !unknown_stage_keys.is_empty() {
136 reasons.push(format!(
137 "unknown stage keys: {}",
138 unknown_stage_keys.join(", ")
139 ));
140 }
141 if requires_approval {
142 reasons.push(format!(
143 "approval required in {} for capabilities: {}",
144 environment_label(environment),
145 required_approval_capabilities.join(", ")
146 ));
147 }
148
149 let replay_support = requirements.iter().fold(ReplaySupport::Allowed, |current, requirement| {
150 max_replay_support(current, requirement.replay_support)
151 });
152
153 CompatibilityReport {
154 schema_version: COMPATIBILITY_REPORT_VERSION,
155 product: document.product.clone(),
156 environment,
157 spec_hash: approval_spec_hash_from_canonical_spec_kv(canonical_spec_kv),
158 target,
159 compatible: document.supported_targets.contains(&target) && unknown_stage_keys.is_empty(),
160 requires_approval,
161 required_approval_capabilities,
162 replay_support,
163 stage_requirements: requirements,
164 unknown_stage_keys,
165 supported_run_modes: supported_run_modes(target),
166 reasons,
167 }
168}
169
170fn collect_stage_requirements(
171 document: &RuntimeCapabilityDocument,
172 spec: &byteor_pipeline_spec::PipelineSpecV1,
173) -> Vec<StageCompatibilityRequirement> {
174 let mut requirements = BTreeMap::<String, StageCompatibilityRequirement>::new();
175
176 match spec {
177 byteor_pipeline_spec::PipelineSpecV1::SingleRing(single_ring) => {
178 for stage in &single_ring.stages {
179 match &stage.op {
180 byteor_pipeline_spec::StageOpV1::Identity => {
181 requirements.entry("builtin:identity".to_string()).or_insert_with(|| {
182 builtin_requirement("builtin:identity")
183 });
184 }
185 byteor_pipeline_spec::StageOpV1::AddU8 { .. } => {
186 requirements.entry("builtin:add_u8".to_string()).or_insert_with(|| {
187 builtin_requirement("builtin:add_u8")
188 });
189 }
190 byteor_pipeline_spec::StageOpV1::ResolverKey { stage } => {
191 requirements
192 .entry(stage.clone())
193 .or_insert_with(|| catalog_requirement(document, stage));
194 }
195 }
196 }
197 }
198 byteor_pipeline_spec::PipelineSpecV1::LaneGraph(lane_graph) => {
199 for role in &lane_graph.roles {
200 if let byteor_pipeline_spec::RoleCfgV1::Stage(stage_role) = role {
201 let stage = &stage_role.stage;
202 requirements
203 .entry(stage.clone())
204 .or_insert_with(|| catalog_requirement(document, stage));
205 }
206 }
207 }
208 }
209
210 requirements.into_values().collect()
211}
212
213fn builtin_requirement(key: &str) -> StageCompatibilityRequirement {
214 StageCompatibilityRequirement {
215 key: key.to_string(),
216 source: StageRequirementSource::BuiltIn,
217 supported: true,
218 policy_class: Some(PolicyClassId::PureTransform),
219 replay_support: ReplaySupport::Allowed,
220 approval_capabilities: Vec::new(),
221 }
222}
223
224fn catalog_requirement(
225 document: &RuntimeCapabilityDocument,
226 stage_key: &str,
227) -> StageCompatibilityRequirement {
228 match match_stage_catalog_entry(document, stage_key) {
229 Some((entry, source)) => StageCompatibilityRequirement {
230 key: stage_key.to_string(),
231 source,
232 supported: true,
233 policy_class: Some(entry.policy_class),
234 replay_support: entry.replay_support,
235 approval_capabilities: entry.approval_capabilities.clone(),
236 },
237 None => StageCompatibilityRequirement {
238 key: stage_key.to_string(),
239 source: StageRequirementSource::Unknown,
240 supported: false,
241 policy_class: None,
242 replay_support: ReplaySupport::Unsupported,
243 approval_capabilities: Vec::new(),
244 },
245 }
246}
247
248fn match_stage_catalog_entry<'a>(
249 document: &'a RuntimeCapabilityDocument,
250 stage_key: &str,
251) -> Option<(&'a StageCatalogEntry, StageRequirementSource)> {
252 document.stage_catalog.iter().find_map(|entry| match entry.key_kind {
253 StageKeyKind::Stable if entry.key == stage_key => {
254 Some((entry, StageRequirementSource::CatalogStable))
255 }
256 StageKeyKind::Prefix => {
257 let prefix = entry.key.trim_end_matches('*');
258 if stage_key.starts_with(prefix) {
259 Some((entry, StageRequirementSource::CatalogPrefix))
260 } else {
261 None
262 }
263 }
264 _ => None,
265 })
266}
267
268fn runtime_environment(env: Environment) -> RuntimeEnvironment {
269 match env {
270 Environment::Dev => RuntimeEnvironment::Dev,
271 Environment::Staging => RuntimeEnvironment::Staging,
272 Environment::Prod => RuntimeEnvironment::Prod,
273 }
274}
275
276fn environment_label(env: RuntimeEnvironment) -> &'static str {
277 match env {
278 RuntimeEnvironment::Dev => "dev",
279 RuntimeEnvironment::Staging => "staging",
280 RuntimeEnvironment::Prod => "prod",
281 }
282}
283
284fn supported_run_modes(target: ExecutionTarget) -> Vec<String> {
285 match target {
286 ExecutionTarget::SingleRing => vec![
287 "shm_supervised".to_string(),
288 "one_shot".to_string(),
289 "adapter_loop".to_string(),
290 ],
291 ExecutionTarget::LaneGraph => vec![
292 "shm_supervised".to_string(),
293 "multi_process".to_string(),
294 ],
295 }
296}
297
298fn max_replay_support(left: ReplaySupport, right: ReplaySupport) -> ReplaySupport {
299 match (left, right) {
300 (ReplaySupport::Unsupported, _) | (_, ReplaySupport::Unsupported) => ReplaySupport::Unsupported,
301 (ReplaySupport::DryRunOnly, _) | (_, ReplaySupport::DryRunOnly) => ReplaySupport::DryRunOnly,
302 _ => ReplaySupport::Allowed,
303 }
304}