use serde_json::Value;
use crate::errors::OrionError;
use crate::storage::repositories::channels::{ChannelFilter, ChannelRepository};
use crate::storage::repositories::workflows::{WorkflowFilter, WorkflowRepository};
const PAGE_SIZE: i64 = 500;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Finding {
pub check: &'static str,
pub entity: String,
pub problem: String,
pub remedy: String,
}
impl std::fmt::Display for Finding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}] {}\n {}\n fix: {}",
self.check, self.entity, self.problem, self.remedy
)
}
}
pub async fn scan(
channels: &dyn ChannelRepository,
workflows: &dyn WorkflowRepository,
) -> Result<Vec<Finding>, OrionError> {
let mut findings = scan_channels(channels).await?;
findings.extend(scan_workflows(workflows).await?);
Ok(findings)
}
async fn scan_channels(repo: &dyn ChannelRepository) -> Result<Vec<Finding>, OrionError> {
let mut findings = Vec::new();
let mut names: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
let mut offset = 0i64;
loop {
let page = repo
.list_paginated(&ChannelFilter {
limit: Some(PAGE_SIZE),
offset: Some(offset),
..Default::default()
})
.await?;
let page_len = page.data.len() as i64;
for channel in &page.data {
findings.extend(check_channel_config(&channel.name, &channel.config_json));
names
.entry(channel.name.clone())
.or_default()
.push(channel.channel_id.clone());
}
if page_len < PAGE_SIZE {
findings.extend(duplicate_name_findings(&names));
return Ok(findings);
}
offset += page_len;
}
}
fn duplicate_name_findings(
names: &std::collections::BTreeMap<String, Vec<String>>,
) -> Vec<Finding> {
names
.iter()
.filter(|(_, ids)| ids.len() > 1)
.map(|(name, ids)| Finding {
check: "channel-names",
entity: format!("channel '{name}'"),
problem: format!(
"{} channels share this name (ids: {}) — the data plane and \
channel_call address channels by name, so only one of them can \
serve, and 1.0 refuses to create or activate the collision",
ids.len(),
ids.join(", ")
),
remedy: "rename all but one (create a new version with a distinct name, \
activate it), or delete the redundant channels"
.to_string(),
})
.collect()
}
async fn scan_workflows(repo: &dyn WorkflowRepository) -> Result<Vec<Finding>, OrionError> {
let mut findings = Vec::new();
let mut offset = 0i64;
loop {
let page = repo
.list(&WorkflowFilter {
limit: Some(PAGE_SIZE),
offset: Some(offset),
..Default::default()
})
.await?;
let page_len = page.len() as i64;
for workflow in &page {
findings.extend(check_workflow_tasks(&workflow.name, &workflow.tasks_json));
}
if page_len < PAGE_SIZE {
return Ok(findings);
}
offset += page_len;
}
}
pub fn check_channel_config(name: &str, config_json: &str) -> Vec<Finding> {
let parsed: Result<Value, _> = serde_json::from_str(config_json);
let Ok(value) = parsed else {
return vec![Finding {
check: "3",
entity: format!("channel '{name}'"),
problem: "its stored config is not valid JSON".to_string(),
remedy: "repair the config_json column, or re-create the channel".to_string(),
}];
};
if value.as_object().is_some_and(|o| o.is_empty()) {
return Vec::new();
}
match serde_json::from_value::<crate::channel::ChannelConfig>(value) {
Ok(_) => Vec::new(),
Err(e) => vec![Finding {
check: "3",
entity: format!("channel '{name}'"),
problem: format!("its stored config no longer parses: {e}"),
remedy: pick_config_remedy(config_json),
}],
}
}
fn pick_config_remedy(config_json: &str) -> String {
if config_json.contains("\"cors\"") {
"replace `\"cors\": {\"allowed_origins\": [...]}` with \
`\"origin_allow_list\": [...]` (upgrading.md, \"A channel's `cors` is now \
`origin_allow_list`\")"
.to_string()
} else if config_json.contains("\"max_concurrent\"") {
"rename `backpressure.max_concurrent` to `max_concurrent_per_node` — and \
check the value, which now means per replica rather than per cluster"
.to_string()
} else {
"remove or correct the key the error names; unknown keys are refused \
because a guard Orion does not recognise is a guard that never runs"
.to_string()
}
}
pub fn check_workflow_tasks(name: &str, tasks_json: &str) -> Vec<Finding> {
let Ok(tasks) = serde_json::from_str::<Value>(tasks_json) else {
return vec![Finding {
check: "14",
entity: format!("workflow '{name}'"),
problem: "its stored tasks are not valid JSON".to_string(),
remedy: "repair the tasks_json column, or re-create the workflow".to_string(),
}];
};
let mut findings: Vec<Finding> = crate::validation::validate_workflow_tasks_schema(&tasks)
.into_iter()
.map(|e| Finding {
check: "14",
entity: format!("workflow '{name}' {}", e.path),
problem: e.message,
remedy: "fix the task and PUT the workflow; it is refused at create \
and update until then"
.to_string(),
})
.collect();
findings.extend(check_dialect_schemas(name, &tasks));
findings
}
fn check_dialect_schemas(workflow: &str, tasks: &Value) -> Vec<Finding> {
let Some(arr) = tasks.as_array() else {
return Vec::new();
};
let mut findings = Vec::new();
for (i, task) in arr.iter().enumerate() {
let Some(function) = task.get("function") else {
continue;
};
let Some(fname) = function.get("name").and_then(Value::as_str) else {
continue;
};
if fname != "data_query" && fname != "data_write" {
continue;
}
let input = function.get("input");
if input.and_then(|i| i.get("schema")).is_some() {
continue;
}
let task_id = task
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("tasks[{i}]"));
findings.push(Finding {
check: "14",
entity: format!("workflow '{workflow}' task '{task_id}' ({fname})"),
problem: "declares no `schema`, so it will fail at its first request. \
Before 1.0 an absent schema meant `unmapped: identity` — every \
name passed through to the physical one, reaching every table \
the connector could see. The default is now `reject`"
.to_string(),
remedy: "add a `schema` declaring the entities and columns this task \
uses, or `\"schema\": {\"unmapped\": \"identity\"}` to restore \
the 0.x behaviour exactly"
.to_string(),
});
}
findings
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_clean_channel_config_reports_nothing() {
assert!(
check_channel_config(
"ok",
r#"{"origin_allow_list": ["https://app.example"],
"backpressure": {"max_concurrent_per_node": 10}}"#
)
.is_empty()
);
assert!(check_channel_config("empty", "{}").is_empty());
}
#[test]
fn the_pre_1_0_cors_spelling_is_reported_with_its_rename() {
let found =
check_channel_config("orders", r#"{"cors": {"allowed_origins": ["https://a"]}}"#);
assert_eq!(found.len(), 1);
assert_eq!(found[0].check, "3");
assert!(found[0].entity.contains("orders"));
assert!(
found[0].remedy.contains("origin_allow_list"),
"the remedy must name the new key: {}",
found[0].remedy
);
}
#[test]
fn the_pre_1_0_backpressure_spelling_is_reported_with_its_rename() {
let found = check_channel_config("bulk", r#"{"backpressure": {"max_concurrent": 50}}"#);
assert_eq!(found.len(), 1);
assert!(
found[0].remedy.contains("max_concurrent_per_node"),
"the remedy must name the new key: {}",
found[0].remedy
);
assert!(
found[0].remedy.contains("per replica"),
"renaming alone is not the whole fix — the value changes meaning: {}",
found[0].remedy
);
}
#[test]
fn a_misspelled_guard_key_is_reported() {
let found = check_channel_config("typo", r#"{"deduplicaton": {"header": "Idem"}}"#);
assert_eq!(found.len(), 1);
assert!(
found[0].problem.contains("deduplicaton"),
"the serde message names the key: {}",
found[0].problem
);
}
#[test]
fn unparseable_stored_json_is_reported_rather_than_panicking() {
let found = check_channel_config("broken", "{not json");
assert_eq!(found.len(), 1);
assert!(found[0].problem.contains("not valid JSON"));
}
#[test]
fn a_dialect_task_without_a_schema_is_reported() {
let tasks = json!([{
"id": "read", "name": "Read",
"function": { "name": "data_query", "input": {
"connector": "db", "query": { "source": "orders" }, "output": "data.o"
}}
}]);
let found = check_workflow_tasks("orders-wf", &tasks.to_string());
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].check, "14");
assert!(found[0].entity.contains("read"));
assert!(
found[0].remedy.contains("unmapped"),
"the remedy must offer the one-line escape hatch: {}",
found[0].remedy
);
}
#[test]
fn a_dialect_task_declaring_a_schema_is_clean() {
let tasks = json!([{
"id": "read", "name": "Read",
"function": { "name": "data_query", "input": {
"connector": "db",
"query": { "source": "orders" },
"schema": { "entities": { "orders": { "columns": { "id": {} } } } },
"output": "data.o"
}}
}]);
assert!(check_workflow_tasks("orders-wf", &tasks.to_string()).is_empty());
}
#[test]
fn the_identity_escape_hatch_counts_as_declared() {
let tasks = json!([{
"id": "read", "name": "Read",
"function": { "name": "data_query", "input": {
"connector": "db",
"query": { "source": "orders" },
"schema": { "unmapped": "identity" },
"output": "data.o"
}}
}]);
assert!(check_workflow_tasks("orders-wf", &tasks.to_string()).is_empty());
}
#[test]
fn a_non_dialect_task_is_not_asked_for_a_schema() {
let tasks = json!([
{
"id": "call", "name": "Call",
"function": { "name": "http_call", "input": {
"connector": "api", "method": "GET", "path": "/orders"
}}
},
{
"id": "note", "name": "Note",
"function": { "name": "log", "input": { "message": "noted" } }
},
]);
let found = check_workflow_tasks("wf", &tasks.to_string());
assert!(found.is_empty(), "{found:?}");
}
#[test]
fn the_shared_task_validator_findings_are_reported() {
let tasks = json!([
{ "id": "a", "name": "A", "function": { "name": "log", "input": {} } },
{ "id": "a", "name": "B", "function": { "name": "log", "input": {} } },
]);
let found = check_workflow_tasks("dupes", &tasks.to_string());
assert!(
found
.iter()
.any(|f| f.problem.contains("Duplicate task id")),
"{found:?}"
);
}
#[test]
fn a_flat_data_write_envelope_is_reported() {
let tasks = json!([{
"id": "w", "name": "W",
"function": { "name": "data_write", "input": {
"connector": "db",
"schema": { "unmapped": "identity" },
"op": "insert", "target": "orders", "values": { "id": 1 },
"output": "data.w"
}}
}]);
let found = check_workflow_tasks("writer", &tasks.to_string());
assert!(
found.iter().any(|f| f.problem.contains("write")),
"the missing envelope must be reported: {found:?}"
);
}
#[test]
fn findings_render_with_their_checklist_row() {
let f = Finding {
check: "14",
entity: "workflow 'w' task 't'".to_string(),
problem: "declares no schema".to_string(),
remedy: "add one".to_string(),
};
let rendered = f.to_string();
assert!(
rendered.starts_with("[14] workflow 'w' task 't'"),
"{rendered}"
);
assert!(rendered.contains("fix: add one"), "{rendered}");
}
}