use orion::config;
#[derive(Clone, Copy, clap::ValueEnum)]
pub(crate) enum ConfigFormat {
Toml,
Json,
Summary,
}
pub(crate) fn handle_validate_config(
config: &config::AppConfig,
format: ConfigFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
ConfigFormat::Summary => print_config_summary(config),
ConfigFormat::Toml => {
eprintln!("Configuration is valid.");
let masked = masked_effective_config(config)?;
print!(
"{}",
toml::to_string_pretty(&toml::Value::try_from(&masked)?)?
);
}
ConfigFormat::Json => {
eprintln!("Configuration is valid.");
let masked = masked_effective_config(config)?;
println!("{}", serde_json::to_string_pretty(&masked)?);
}
}
Ok(())
}
fn masked_effective_config(
config: &config::AppConfig,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let without_unset = toml::Value::try_from(config)?;
let mut tree = serde_json::to_value(&without_unset)?;
orion::connector::mask_secrets(&mut tree);
Ok(tree)
}
fn redacted(value: &str) -> String {
orion::connector::redact_url_secrets_or_raw(value)
}
fn print_config_summary(config: &config::AppConfig) {
println!("Configuration is valid.\n");
println!(" environment: {}", config.environment);
println!(
" server: {}:{}",
config.server.host, config.server.port
);
println!(
" tls: {}",
if config.server.tls.enabled {
format!("enabled (cert={})", config.server.tls.cert_path)
} else {
"disabled".to_string()
}
);
println!(" storage: {}", redacted(&config.storage.url));
println!(
" logging: level={}, format={}",
config.logging.level,
match config.logging.format {
config::LogFormat::Json => "json",
config::LogFormat::Pretty => "pretty",
}
);
println!(
" admin_auth: {}",
if config.admin_auth.enabled {
"enabled"
} else {
"disabled"
}
);
println!(
" cors: {}{}",
config.cors.allowed_origins.join(", "),
if config.cors.allow_credentials {
" (credentials allowed)"
} else {
""
}
);
println!(
" rate_limiting: {}",
if config.rate_limit.enabled {
format!(
"enabled (rps={}, burst={})",
config.rate_limit.default_rps, config.rate_limit.default_burst
)
} else {
"disabled".to_string()
}
);
println!(
" queue: workers={}, buffer={}",
config.trace_queue.workers, config.trace_queue.buffer_size
);
println!(
" metrics: {}",
if config.metrics.enabled {
"enabled"
} else {
"disabled"
}
);
println!(
" tracing: {}",
if config.tracing.enabled {
format!(
"enabled (endpoint={})",
redacted(&config.tracing.otlp_endpoint)
)
} else {
"disabled".to_string()
}
);
println!(
" cluster: {}",
if config.cluster.enabled {
format!("enabled (instance_id={})", config.cluster.instance_id)
} else {
"disabled".to_string()
}
);
println!(
" kafka: {}",
if config.kafka.enabled {
let brokers: Vec<String> = config.kafka.brokers.iter().map(|b| redacted(b)).collect();
format!("enabled (brokers={})", brokers.join(","))
} else {
"disabled".to_string()
}
);
}
pub(crate) async fn handle_migrate(
config: &config::AppConfig,
dry_run: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let pool = orion::storage::init_pool_no_migrate(&config.storage).await?;
let backend = pool.backend();
let pending = orion::storage::pending_migrations(&pool).await?;
if pending.is_empty() {
println!("No pending migrations ({backend}).");
return Ok(());
}
if dry_run {
println!("Pending migrations on {backend} ({}):", pending.len());
} else {
println!("Applying {} migration(s) on {backend}...", pending.len());
}
for (version, description) in &pending {
println!(" {backend} {version:03} — {description}");
}
if dry_run {
println!(
"\nMigration numbers are per-backend and are not comparable across \
sqlite/postgres/mysql. Refer to a migration by its name."
);
} else {
orion::storage::run_migrations(&pool).await?;
println!("Migrations applied successfully.");
}
Ok(())
}
pub(crate) fn run_lint(
workflow_path: &str,
deny_warnings: bool,
boundary: orion::definitions::Boundary,
definitions: Option<&str>,
plugin_dirs: &[String],
model_dirs: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
use orion::storage::repositories::workflows::CreateWorkflowRequest;
if std::path::Path::new(workflow_path).is_dir() {
return run_lint_set(
workflow_path,
deny_warnings,
boundary,
plugin_dirs,
model_dirs,
);
}
let catalog = Catalog::load_opt(definitions, plugin_dirs, model_dirs)?;
let doc = read_expanded_workflow(workflow_path, catalog.as_ref())?;
let req: CreateWorkflowRequest = serde_json::from_value(doc)
.map_err(|e| format!("'{workflow_path}' is not a valid workflow JSON: {e}"))?;
let registry = offline_registry(catalog.as_ref())?;
let manifests = catalog
.as_ref()
.map(|c| c.plugins.as_slice())
.unwrap_or(&[]);
let unverifiable = unverifiable_functions(&req.tasks, ®istry, manifests);
let registry = registry.with_entries(placeholder_entries(&unverifiable))?;
let loop_cap = orion::config::EngineConfig::default().max_loop_iterations;
if let Err(err) = orion::validation::validate_create_workflow(&req, loop_cap, ®istry) {
return Err(format_lint_error(workflow_path, err).into());
}
for name in &unverifiable {
eprintln!(
"{}",
orion::definitions::Diagnostic::note(
"plugin.unverifiable",
format!("workflow '{}'", req.name),
format!(
"names plugin function '{name}', and no manifest for its plugin was given, \
so its input cannot be checked here; the admin API validates it against \
the active plugin"
),
)
.with_remedy("pass --plugin-dir <dir> with the plugin's plugin.toml")
);
}
let manifests = catalog.as_ref().map(|c| c.models.as_slice()).unwrap_or(&[]);
let mut model_errors = 0usize;
for finding in model_findings(&req.tasks, &req.name, manifests) {
if finding.is_error() {
model_errors += 1;
}
eprintln!("{finding}");
}
if model_errors > 0 {
return Err(format!(
"'{workflow_path}' names {model_errors} model(s) the given manifests do not describe"
)
.into());
}
let mut warnings: Vec<orion::definitions::Diagnostic> =
orion::validation::unresolvable_logic_warnings(&req.tasks, ®istry)
.into_iter()
.map(|(path, message)| {
orion::definitions::Diagnostic::warning(
"logic.unresolvable",
format!("workflow '{}' {path}", req.name),
message,
)
})
.collect();
warnings.extend(
orion::validation::engine_advisories(&req.tasks, ®istry)
.into_iter()
.map(|advisory| {
orion::definitions::Diagnostic::warning(
advisory.check,
format!("workflow '{}' {}", req.name, advisory.path),
advisory.message,
)
}),
);
for finding in &warnings {
eprintln!("{finding}");
}
if deny_warnings && !warnings.is_empty() {
return Err(format!(
"'{workflow_path}' has {} warning(s) and --deny-warnings is set",
warnings.len()
)
.into());
}
println!("'{workflow_path}' is valid.");
Ok(())
}
pub(crate) fn read_expanded_workflow(
path: &str,
definitions: Option<&Catalog>,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let raw = std::fs::read_to_string(path).map_err(|e| format!("Failed to read '{path}': {e}"))?;
let mut doc: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("'{path}' is not valid JSON: {e}"))?;
let Some(catalog) = definitions.filter(|c| c.dir.is_some()) else {
if let Some(reference) = orion::definitions::first_reference(&doc) {
return Err(format!(
"'{path}' contains {reference}, but no --definitions directory was \
given to resolve it against"
)
.into());
}
return Ok(doc);
};
let mut findings = Vec::new();
catalog.shared.expand(&mut doc, path, &mut findings);
let errors = findings.iter().filter(|f| f.is_error()).count();
for finding in &findings {
eprintln!("{finding}");
}
if errors > 0 {
return Err(format!(
"{errors} unresolved reference(s) expanding '{path}' against '{}'",
catalog.dir.as_deref().unwrap_or_default()
)
.into());
}
Ok(doc)
}
pub(crate) struct Catalog {
dir: Option<String>,
shared: orion::definitions::SharedDefinitions,
plugins: Vec<orion::definitions::PluginDefinition>,
sandbox: std::sync::OnceLock<Result<OfflineSandbox, String>>,
models: Vec<orion::definitions::ModelDefinition>,
model_host: std::sync::OnceLock<std::sync::Arc<orion::model::InferenceHost>>,
}
fn offline_models_config() -> orion::config::ModelsConfig {
orion::config::ModelsConfig {
enabled: true,
..orion::config::ModelsConfig::default()
}
}
pub(crate) struct OfflineSandbox {
runtime: std::sync::Arc<orion::plugin::WasmRuntime>,
loaded: std::collections::HashMap<String, std::sync::Arc<orion::plugin::LoadedComponent>>,
config: orion::config::PluginsConfig,
}
impl Catalog {
pub(crate) fn load(
dir: Option<&str>,
plugin_dirs: &[String],
model_dirs: &[String],
) -> Result<Self, Box<dyn std::error::Error>> {
let mut findings = Vec::new();
let mut shared = orion::definitions::SharedDefinitions::default();
let mut set = orion::definitions::DefinitionSet::default();
if let Some(dir) = dir {
let (loaded, shared_findings) =
orion::definitions::SharedDefinitions::from_directory(std::path::Path::new(dir))?;
shared = loaded;
findings.extend(shared_findings);
findings.extend(set.add_plugin_dirs(&[dir.to_string()])?);
findings.extend(set.add_model_dirs(&[dir.to_string()])?);
}
findings.extend(set.add_plugin_dirs(plugin_dirs)?);
findings.extend(set.add_model_dirs(model_dirs)?);
let errors = findings.iter().filter(|f| f.is_error()).count();
for finding in &findings {
eprintln!("{finding}");
}
if errors > 0 {
return Err(format!(
"{errors} error(s) in the definitions under '{}'",
dir.unwrap_or(if plugin_dirs.is_empty() {
"--model-dir"
} else {
"--plugin-dir"
})
)
.into());
}
Ok(Self {
dir: dir.map(str::to_string),
shared,
plugins: set.plugins,
sandbox: std::sync::OnceLock::new(),
models: set.models,
model_host: std::sync::OnceLock::new(),
})
}
pub(crate) fn load_opt(
dir: Option<&str>,
plugin_dirs: &[String],
model_dirs: &[String],
) -> Result<Option<Self>, Box<dyn std::error::Error>> {
if dir.is_none() && plugin_dirs.is_empty() && model_dirs.is_empty() {
return Ok(None);
}
Self::load(dir, plugin_dirs, model_dirs).map(Some)
}
pub(crate) fn registry(
&self,
) -> Result<orion::engine::FunctionRegistry, Box<dyn std::error::Error>> {
let set = orion::definitions::DefinitionSet {
plugins: self.plugins.clone(),
..orion::definitions::DefinitionSet::default()
};
Ok(set.function_registry()?)
}
pub(crate) fn model_handler(
&self,
) -> (dataflow_rs::BoxedFunctionHandler, Vec<(String, String)>) {
let mut entries = Vec::new();
let mut unavailable = Vec::new();
for model in &self.models {
match model.manifest_entry() {
Some(entry) => entries.push(entry),
None => unavailable.push((model.manifest.name.clone(), model.origin.clone())),
}
}
let config = std::sync::Arc::new(offline_models_config());
let offline =
std::sync::Arc::new(orion::model::OfflineModels::new(entries, config.clone()));
let artifacts: std::sync::Arc<dyn orion::model::ArtifactSource> =
std::sync::Arc::new(offline.artifacts());
let host = self
.model_host
.get_or_init(|| {
std::sync::Arc::new(orion::model::InferenceHost::offline(&config, artifacts))
})
.clone();
let handler = orion::model::ModelInferHandler {
source: orion::model::ModelSource::Offline(offline),
host: Some(host),
config,
};
(Box::new(handler), unavailable)
}
fn sandbox(&self) -> Result<&OfflineSandbox, Box<dyn std::error::Error>> {
self.sandbox
.get_or_init(|| {
let config = orion::config::PluginsConfig {
enabled: true,
..orion::config::PluginsConfig::default()
};
let runtime = orion::plugin::WasmRuntime::new(&config)
.map_err(|e| format!("the plugin sandbox could not be created: {e}"))?;
let mut loaded = std::collections::HashMap::new();
for plugin in &self.plugins {
let Some(path) = &plugin.component_path else {
continue;
};
let bytes = std::fs::read(path)
.map_err(|e| format!("reading component '{}': {e}", path.display()))?;
let component = runtime.load_blocking(&bytes).map_err(|e| {
format!(
"plugin '{}': component '{}' does not load: {e}",
plugin.manifest.name,
path.display()
)
})?;
loaded.insert(plugin.manifest.name.clone(), component);
}
Ok(OfflineSandbox {
runtime,
loaded,
config,
})
})
.as_ref()
.map_err(|e| e.clone().into())
}
pub(crate) fn plugin_handlers(
&self,
) -> Result<OfflinePluginHandlers, Box<dyn std::error::Error>> {
let mut out = OfflinePluginHandlers::default();
if self.plugins.is_empty() {
return Ok(out);
}
let sandbox = self.sandbox()?;
let handlers = &mut out.handlers;
let unavailable = &mut out.unavailable;
for plugin in &self.plugins {
match sandbox.loaded.get(&plugin.manifest.name) {
Some(component) => {
let limits =
orion::plugin::Limits::effective(&sandbox.config, &plugin.manifest.name);
for entry in plugin.entries() {
let name = entry.name.clone();
let handler = orion::plugin::PluginFunctionHandler::new(
std::sync::Arc::new(entry),
component.clone(),
sandbox.runtime.clone(),
limits,
);
handlers.push((name, Box::new(handler)));
}
}
None => {
for name in plugin.manifest.function_names() {
unavailable.push((name.to_string(), plugin.origin.clone()));
}
}
}
}
Ok(out)
}
}
#[derive(Default)]
pub(crate) struct OfflinePluginHandlers {
pub(crate) handlers: Vec<(String, dataflow_rs::BoxedFunctionHandler)>,
pub(crate) unavailable: Vec<(String, String)>,
}
fn offline_registry(
catalog: Option<&Catalog>,
) -> Result<orion::engine::FunctionRegistry, Box<dyn std::error::Error>> {
match catalog {
Some(catalog) => catalog.registry(),
None => Ok(orion::engine::FunctionRegistry::builtin()
.with_entries(Vec::new())
.expect("the built-in registry extends by nothing")),
}
}
fn unverifiable_functions(
tasks: &serde_json::Value,
registry: &orion::engine::FunctionRegistry,
manifests: &[orion::definitions::PluginDefinition],
) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for task in orion::engine::leaf_tasks(tasks) {
let Some(name) = task
.get("function")
.and_then(|f| f.get("name"))
.and_then(serde_json::Value::as_str)
else {
continue;
};
let covered = manifests
.iter()
.any(|p| name.starts_with(&format!("{}.", p.manifest.name)));
if name.contains('.')
&& !covered
&& !registry.contains(name)
&& !out.iter().any(|n| n == name)
{
out.push(name.to_string());
}
}
out
}
fn placeholder_entries(names: &[String]) -> Vec<orion::engine::FunctionEntry> {
names
.iter()
.map(|name| orion::engine::FunctionEntry {
name: name.clone(),
description: "plugin function with no manifest in this run".to_string(),
category: "plugin".to_string(),
source: orion::engine::functions::schema::Source::Plugin,
aliases: Vec::new(),
input_fields: None,
writes: orion::engine::functions::schema::WriteShape::OutputPath { default_root: None },
retry_safety: orion::engine::functions::schema::RetrySafety::Pure,
deny_unknown: false,
validate_static: None,
connector: None,
plugin: None,
})
.collect()
}
struct InferTask {
path: String,
id: String,
model: Option<String>,
}
fn model_infer_tasks(tasks: &serde_json::Value) -> Vec<InferTask> {
let literal = orion::model::literal_references(tasks);
let mut out = Vec::new();
for (path, task) in orion::engine::walk_steps(tasks).tasks {
let Some(function) = task.get("function") else {
continue;
};
if function.get("name").and_then(serde_json::Value::as_str)
!= Some(orion::model::handler::NAME)
{
continue;
}
let id = task
.get("id")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| path.clone());
let model = literal
.iter()
.find(|(task_id, _)| *task_id == id)
.map(|(_, model)| model.clone());
out.push(InferTask {
path: format!("{path}.function.input.model"),
id,
model,
});
}
out
}
fn model_findings(
tasks: &serde_json::Value,
workflow: &str,
manifests: &[orion::definitions::ModelDefinition],
) -> Vec<orion::definitions::Diagnostic> {
let entity = format!("workflow '{workflow}'");
let mut out = Vec::new();
for InferTask {
id: task, model, ..
} in model_infer_tasks(tasks)
{
match model {
Some(model) if manifests.iter().any(|m| m.manifest.name == model) => {}
Some(model) if manifests.is_empty() => out.push(
orion::definitions::Diagnostic::note(
"model.unverifiable",
&entity,
format!(
"task '{task}' names model '{model}', and no manifest for it was given, \
so the reference cannot be checked here; a node that does not serve \
the model quarantines the workflow"
),
)
.with_remedy("pass --model-dir <dir> with the model's manifest"),
),
Some(model) => out.push(orion::definitions::Diagnostic::error(
"closure.model",
&entity,
format!(
"task '{task}' names model '{model}', which none of the given manifests \
describes"
),
)),
None => out.push(orion::definitions::Diagnostic::note(
"model.unverifiable",
&entity,
format!(
"task '{task}' computes its model, so the model it names is decided per \
message and cannot be checked here"
),
)),
}
}
out
}
fn run_lint_set(
dir: &str,
deny_warnings: bool,
boundary: orion::definitions::Boundary,
plugin_dirs: &[String],
model_dirs: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
load_and_gate(dir, boundary, false, deny_warnings, plugin_dirs, model_dirs)?;
Ok(())
}
fn load_and_gate(
dir: &str,
boundary: orion::definitions::Boundary,
require_ids: bool,
deny_warnings: bool,
plugin_dirs: &[String],
model_dirs: &[String],
) -> Result<orion::definitions::DefinitionSet, Box<dyn std::error::Error>> {
let report = orion::definitions::gate_directory(
std::path::Path::new(dir),
&boundary,
orion::definitions::GateOpts {
require_ids,
want_raw: false,
},
plugin_dirs,
model_dirs,
)?;
for notice in report.notices() {
eprintln!("{notice}");
}
if report.set.is_empty() && report.set.models.is_empty() {
return Err(format!(
"no definitions found under '{dir}'. A definition is a JSON object with \
'tasks' (workflow), 'channel_type' (channel), 'connector_type' (connector) or \
an 'abi' of orion:model@… (model manifest)."
)
.into());
}
let errors = report.errors();
let warnings = report.warnings();
for finding in &report.findings {
eprintln!("{finding}");
}
for (pass, count) in &report.compiled {
println!("compiled: {pass} rewrote {count} document(s)");
}
use orion::definitions::Entity;
let shared = if report.shared.is_empty() {
String::new()
} else {
format!(
", {} shared value(s), {} fragment(s)",
report
.shared
.namespaces
.values()
.map(|n| n.len())
.sum::<usize>(),
report.shared.fragments.len(),
)
};
let models = if report.set.models.is_empty() {
String::new()
} else {
format!(", {} model(s)", report.set.models.len())
};
println!(
"{dir}: {} connector(s), {} workflow(s), {} channel(s){models}{shared} — {errors} \
error(s), {warnings} warning(s)",
report.set.count(Entity::Connector),
report.set.count(Entity::Workflow),
report.set.count(Entity::Channel),
);
if errors > 0 {
return Err(format!("{errors} error(s) in '{dir}'").into());
}
if deny_warnings && warnings > 0 {
return Err(format!("{warnings} warning(s) in '{dir}' and --deny-warnings is set").into());
}
Ok(report.set)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum CompileFormat {
Artifact,
Dir,
Bulk,
}
pub(crate) struct CompileRequest<'a> {
pub(crate) dir: &'a str,
pub(crate) output: Option<&'a str>,
pub(crate) format: CompileFormat,
pub(crate) name: Option<&'a str>,
pub(crate) version: Option<&'a str>,
pub(crate) boundary: orion::definitions::Boundary,
pub(crate) deny_warnings: bool,
pub(crate) no_activate: bool,
pub(crate) plugin_dirs: &'a [String],
pub(crate) model_dirs: &'a [String],
}
pub(crate) fn run_compile(req: CompileRequest<'_>) -> Result<(), Box<dyn std::error::Error>> {
let requires_ids = req.format == CompileFormat::Artifact;
let (name, version) = match req.format {
CompileFormat::Artifact => match (req.name, req.version) {
(Some(n), Some(v)) => (n, v),
_ => return Err("--name and --version are required for --format artifact".into()),
},
_ => ("", ""),
};
let requires = req.boundary.clone();
let set = load_and_gate(
req.dir,
req.boundary,
requires_ids,
req.deny_warnings,
req.plugin_dirs,
req.model_dirs,
)?;
match req.format {
CompileFormat::Artifact => emit_artifact(
&set,
req.dir,
name,
version,
requires,
req.no_activate,
req.output,
),
CompileFormat::Dir => emit_dir(&set, req.dir, require_output(req.output, "--format dir")?),
CompileFormat::Bulk => emit_bulk(&set, require_output(req.output, "--format bulk")?),
}
}
fn require_output<'a>(
output: Option<&'a str>,
what: &str,
) -> Result<&'a str, Box<dyn std::error::Error>> {
output.ok_or_else(|| format!("-o <DIR> is required for {what}").into())
}
fn emit_artifact(
set: &orion::definitions::DefinitionSet,
dir: &str,
name: &str,
version: &str,
requires: orion::definitions::Boundary,
no_activate: bool,
output: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
use orion::definitions::Entity;
let collect = |kind: Entity| -> Vec<serde_json::Value> {
set.iter(kind).map(|d| d.doc.clone()).collect()
};
let mut workflows = collect(Entity::Workflow);
let mut channels = collect(Entity::Channel);
if !no_activate {
for entity in workflows.iter_mut().chain(channels.iter_mut()) {
if let Some(obj) = entity.as_object_mut() {
obj.entry("activate")
.or_insert_with(|| serde_json::Value::Bool(true));
}
}
}
let plugins = plugin_import_entries(set, no_activate)?;
let models = model_import_entries(set, no_activate)?;
let connectors = collect(Entity::Connector);
let carried: Vec<&str> = connectors
.iter()
.filter_map(|c| c["name"].as_str())
.collect();
let mut storage: Vec<String> = Vec::new();
for entry in &models {
if let Some(name) = entry["artifact"]["connector"].as_str()
&& !carried.contains(&name)
&& !storage.iter().any(|s| s == name)
{
storage.push(name.to_string());
}
}
let mut artifact = crate::package_cli::PackageArtifact {
package: crate::package_cli::PackageMeta {
name: name.to_string(),
version: version.to_string(),
orion: env!("CARGO_PKG_VERSION").to_string(),
content_hash: String::new(),
exported_from: dir.to_string(),
exported_at: chrono::Utc::now().to_rfc3339(),
},
requires: crate::package_cli::Requires {
channels: requires.channels,
connectors: requires.connectors,
plugins: Vec::new(),
models: Vec::new(),
storage,
},
plugins,
models,
connectors,
workflows,
channels,
};
artifact.package.content_hash = crate::package_cli::artifact_content_hash(&artifact)?;
let rendered = serde_json::to_string_pretty(&artifact)?;
match output {
Some(path) => {
if let Some(parent) = std::path::Path::new(path).parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.map_err(|e| format!("create '{}': {e}", parent.display()))?;
}
std::fs::write(path, rendered).map_err(|e| format!("write '{path}': {e}"))?;
println!(
"wrote {}@{} ({}) to {path}",
artifact.package.name,
artifact.package.version,
crate::package_cli::member_counts(&artifact),
);
}
None => println!("{rendered}"),
}
Ok(())
}
fn plugin_import_entries(
set: &orion::definitions::DefinitionSet,
no_activate: bool,
) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
use base64::Engine as _;
let mut plugins = Vec::with_capacity(set.plugins.len());
for plugin in &set.plugins {
let Some(path) = &plugin.component_path else {
return Err(format!(
"plugin '{}' ({}): no component beside the manifest, and an artifact must \
carry the bytes — build it, or name it with `component = …`",
plugin.manifest.name, plugin.origin
)
.into());
};
let bytes =
std::fs::read(path).map_err(|e| format!("reading '{}': {e}", path.display()))?;
let mut entry = serde_json::json!({
"plugin_id": plugin.manifest.name,
"manifest": serde_json::to_value(&plugin.manifest)?,
"component": base64::engine::general_purpose::STANDARD.encode(&bytes),
"digest": orion::plugin::WasmRuntime::digest(&bytes),
"tags": [],
});
if !no_activate {
entry["activate"] = serde_json::Value::Bool(true);
}
plugins.push(entry);
}
Ok(plugins)
}
fn model_import_entries(
set: &orion::definitions::DefinitionSet,
no_activate: bool,
) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let mut models = Vec::with_capacity(set.models.len());
for model in &set.models {
let name = &model.manifest.name;
let Some(digest) = &model.digest else {
return Err(format!(
"model '{name}' ({}): no artifact beside the manifest, and an artifact must be \
reachable by the target — put the file beside the manifest and name it with \
`artifact`, so its digest can be computed",
model.origin
)
.into());
};
let Some(reference) = &model.manifest.reference else {
return Err(format!(
"model '{name}' ({}): the manifest names no `reference`, and an artifact must be \
reachable by the target — put the bytes in the bucket and name them with \
`reference = {{ connector, key }}`",
model.origin
)
.into());
};
let mut manifest = serde_json::to_value(&model.manifest)?;
if let Some(obj) = manifest.as_object_mut() {
obj.remove("artifact");
}
let mut entry = serde_json::json!({
"model_id": name,
"manifest": manifest,
"artifact": {
"connector": reference.connector,
"key": reference.key,
"digest": digest,
},
"tags": [],
});
if !no_activate {
entry["activate"] = serde_json::Value::Bool(true);
}
models.push(entry);
}
Ok(models)
}
fn emit_dir(
set: &orion::definitions::DefinitionSet,
dir: &str,
out: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let root = std::path::Path::new(dir);
let out_root = std::path::Path::new(out);
for def in &set.definitions {
let origin = std::path::Path::new(&def.origin);
let relative = origin.strip_prefix(root).unwrap_or(origin);
let target = out_root.join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create '{}': {e}", parent.display()))?;
}
std::fs::write(&target, serde_json::to_string_pretty(&def.doc)?)
.map_err(|e| format!("write '{}': {e}", target.display()))?;
}
for plugin in &set.plugins {
let origin = std::path::Path::new(&plugin.origin);
let Ok(relative) = origin.strip_prefix(root) else {
continue;
};
let target = out_root.join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create '{}': {e}", parent.display()))?;
}
std::fs::copy(origin, &target).map_err(|e| format!("copy '{}': {e}", target.display()))?;
if let (Some(component), Some(rel)) =
(&plugin.component_path, plugin.manifest.component.as_deref())
&& let Some(parent) = target.parent()
{
std::fs::copy(component, parent.join(rel))
.map_err(|e| format!("copy '{}': {e}", component.display()))?;
}
}
for model in &set.models {
let origin = std::path::Path::new(&model.origin);
let Ok(relative) = origin.strip_prefix(root) else {
continue;
};
let target = out_root.join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create '{}': {e}", parent.display()))?;
}
std::fs::copy(origin, &target).map_err(|e| format!("copy '{}': {e}", target.display()))?;
if let (Some(artifact), Some(rel)) =
(&model.artifact_path, model.manifest.artifact.as_deref())
&& let Some(parent) = target.parent()
{
std::fs::copy(artifact, parent.join(rel))
.map_err(|e| format!("copy '{}': {e}", artifact.display()))?;
}
}
println!(
"wrote {} compiled definition(s){}{} to {out}",
set.definitions.len(),
if set.plugins.is_empty() {
String::new()
} else {
format!(" and {} plugin manifest(s)", set.plugins.len())
},
if set.models.is_empty() {
String::new()
} else {
format!(" and {} model manifest(s)", set.models.len())
}
);
Ok(())
}
fn emit_bulk(
set: &orion::definitions::DefinitionSet,
out: &str,
) -> Result<(), Box<dyn std::error::Error>> {
use orion::definitions::Entity;
std::fs::create_dir_all(out).map_err(|e| format!("create '{out}': {e}"))?;
for (kind, file) in [
(Entity::Connector, "connectors.json"),
(Entity::Workflow, "workflows.json"),
(Entity::Channel, "channels.json"),
] {
let entries: Vec<&serde_json::Value> = set.iter(kind).map(|d| &d.doc).collect();
let path = std::path::Path::new(out).join(file);
std::fs::write(&path, serde_json::to_string_pretty(&entries)?)
.map_err(|e| format!("write '{}': {e}", path.display()))?;
println!(
"wrote {} {}(s) to {}",
entries.len(),
kind.as_str(),
path.display()
);
}
if !set.plugins.is_empty() {
let entries = plugin_import_entries(set, true)?;
let path = std::path::Path::new(out).join("plugins.json");
std::fs::write(&path, serde_json::to_string_pretty(&entries)?)
.map_err(|e| format!("write '{}': {e}", path.display()))?;
println!("wrote {} plugin(s) to {}", entries.len(), path.display());
}
if !set.models.is_empty() {
let entries = model_import_entries(set, true)?;
let path = std::path::Path::new(out).join("models.json");
std::fs::write(&path, serde_json::to_string_pretty(&entries)?)
.map_err(|e| format!("write '{}': {e}", path.display()))?;
println!("wrote {} model(s) to {}", entries.len(), path.display());
}
Ok(())
}
pub(crate) fn run_fmt(
paths: &[String],
check: bool,
stdin: bool,
) -> Result<i32, Box<dyn std::error::Error>> {
use orion::definitions::fmt::{FmtError, Outcome, format_str};
if stdin {
return run_fmt_stdin();
}
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut errors = 0usize;
for path in paths {
let path = std::path::Path::new(path);
if path.is_dir() {
match orion::definitions::json_files(path) {
Ok(found) => files.extend(found),
Err(e) => {
eprintln!("error: {e}");
errors += 1;
}
}
} else if path.is_file() {
files.push(path.to_path_buf());
} else {
eprintln!("error: '{}' is not a file or directory", path.display());
errors += 1;
}
}
let mut changed = 0usize;
let mut unchanged = 0usize;
for file in &files {
let shown = file.display();
let text = match std::fs::read(file) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => text,
Err(e) => {
eprintln!(
"error: {shown}: not valid UTF-8 at byte {}",
e.utf8_error().valid_up_to()
);
errors += 1;
continue;
}
},
Err(e) => {
eprintln!("error: {shown}: {e}");
errors += 1;
continue;
}
};
match format_str(&text, &shown.to_string()) {
Ok(Outcome::Unchanged) => unchanged += 1,
Ok(Outcome::Changed(formatted)) => {
changed += 1;
if check {
eprint!("{}", unified_diff(&shown.to_string(), &text, &formatted));
} else if let Err(e) = write_atomically(file, &formatted) {
eprintln!("error: {shown}: {e}");
errors += 1;
}
}
Err(FmtError::Parse(e)) => {
eprintln!("error: {shown}: {e}");
errors += 1;
}
Err(e) => {
eprintln!("error: {e}");
errors += 1;
}
}
}
let verb = if check {
"would be reformatted"
} else {
"reformatted"
};
println!(
"{changed} file(s) {verb}, {unchanged} unchanged{}",
if errors > 0 {
format!(", {errors} error(s)")
} else {
String::new()
}
);
Ok(if errors > 0 {
2
} else if check && changed > 0 {
1
} else {
0
})
}
fn run_fmt_stdin() -> Result<i32, Box<dyn std::error::Error>> {
use orion::definitions::fmt::{Outcome, format_str};
use std::io::{Read, Write};
let mut text = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut text) {
eprintln!("error: <stdin>: {e}");
return Ok(2);
}
match format_str(&text, "<stdin>") {
Ok(Outcome::Unchanged) => {
std::io::stdout().write_all(text.as_bytes())?;
Ok(0)
}
Ok(Outcome::Changed(formatted)) => {
std::io::stdout().write_all(formatted.as_bytes())?;
Ok(0)
}
Err(e) => {
eprintln!("error: <stdin>: {e}");
Ok(2)
}
}
}
fn unified_diff(path: &str, before: &str, after: &str) -> String {
let path = path.trim_start_matches('/');
similar::TextDiff::from_lines(before, after)
.unified_diff()
.context_radius(3)
.header(&format!("a/{path}"), &format!("b/{path}"))
.to_string()
}
fn write_atomically(path: &std::path::Path, content: &str) -> std::io::Result<()> {
let target = path.canonicalize()?;
let dir = target
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
let tmp = dir.join(format!(".{name}.fmt-tmp-{}", std::process::id()));
let attempt = (|| {
std::fs::write(&tmp, content)?;
let permissions = std::fs::metadata(&target)?.permissions();
std::fs::set_permissions(&tmp, permissions)?;
std::fs::rename(&tmp, &target)
})();
if attempt.is_err() {
let _ = std::fs::remove_file(&tmp);
}
attempt
}
#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum ClippyFormat {
Text,
Json,
}
pub(crate) struct ClippyRequest<'a> {
pub(crate) path: &'a str,
pub(crate) deny_warnings: bool,
pub(crate) format: ClippyFormat,
pub(crate) definitions: Option<&'a str>,
pub(crate) plugin_dirs: &'a [String],
pub(crate) model_dirs: &'a [String],
pub(crate) boundary: orion::definitions::Boundary,
pub(crate) config: Option<&'a orion::config::AppConfig>,
}
pub(crate) fn run_clippy_list() -> Result<i32, Box<dyn std::error::Error>> {
print!("{}", orion::definitions::clippy::list_table());
Ok(0)
}
pub(crate) fn run_clippy_explain(rule: &str) -> Result<i32, Box<dyn std::error::Error>> {
match orion::definitions::clippy::find(rule) {
Some(found) => {
println!(
"{} — {} ({}, {})\n\n{}",
found.id(),
found.summary(),
found.level().as_str(),
found.scope().as_str(),
found.explain()
);
Ok(0)
}
None => {
eprintln!("error: no rule named '{rule}' — `clippy --list` names them");
Ok(2)
}
}
}
pub(crate) fn run_clippy(req: ClippyRequest<'_>) -> Result<i32, Box<dyn std::error::Error>> {
use orion::definitions::clippy::Diagnostic;
use orion::definitions::{DefinitionSet, Entity, SharedDefinitions};
let path = std::path::Path::new(req.path);
let (raw, compiled, shared, mut findings) = if path.is_dir() {
let report = orion::definitions::gate_directory(
path,
&req.boundary,
orion::definitions::GateOpts {
require_ids: false,
want_raw: true,
},
req.plugin_dirs,
req.model_dirs,
)?;
for notice in report.notices() {
eprintln!("{notice}");
}
if report.set.is_empty() {
eprintln!("error: no definitions found under '{}'", req.path);
return Ok(2);
}
let raw = report
.raw
.unwrap_or_else(|| DefinitionSet::from_entries([]));
(raw, report.set, report.shared, report.findings)
} else if path.is_file() {
let text =
std::fs::read_to_string(path).map_err(|e| format!("read '{}': {e}", req.path))?;
let doc: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!("'{}' is not valid JSON: {e}", req.path))?;
let Some(entity) = Entity::classify(&doc) else {
eprintln!(
"error: '{}' is not a channel, workflow or connector (no 'tasks', 'channel_type' \
or 'connector_type')",
req.path
);
return Ok(2);
};
let catalog = Catalog::load_opt(req.definitions, req.plugin_dirs, req.model_dirs)?;
let (shared, plugins, models) = catalog
.map(|c| (c.shared, c.plugins, c.models))
.unwrap_or_else(|| (SharedDefinitions::default(), Vec::new(), Vec::new()));
let mut findings = Vec::new();
let mut compiled_doc = doc.clone();
orion::definitions::compile::compile(
&mut compiled_doc,
&orion::definitions::Cx {
shared: &shared,
origin: req.path,
},
&mut findings,
);
let raw = DefinitionSet::from_entries([(entity, req.path.to_string(), doc)]);
let mut compiled =
DefinitionSet::from_entries([(entity, req.path.to_string(), compiled_doc)]);
compiled.plugins = plugins;
compiled.models = models;
let registry = compiled.function_registry()?;
findings.extend(orion::definitions::check(
&compiled,
&req.boundary,
false,
®istry,
));
(raw, compiled, shared, findings)
} else {
eprintln!("error: '{}' is not a file or directory", req.path);
return Ok(2);
};
let mut diagnostics: Vec<Diagnostic> = std::mem::take(&mut findings);
let lint_errors = diagnostics.iter().filter(|d| d.is_error()).count();
let mut skipped: Vec<&str> = Vec::new();
if lint_errors == 0 {
let registry = compiled.function_registry()?;
let analysis = orion::definitions::analysis::Analysis::new(
&raw, &compiled, &shared, req.config, ®istry,
);
let report = orion::definitions::clippy::run(&analysis);
diagnostics.extend(report.diagnostics);
skipped = report.skipped;
}
let errors = diagnostics.iter().filter(|d| d.is_error()).count();
let warnings = diagnostics.iter().filter(|d| d.is_warning()).count();
match req.format {
ClippyFormat::Json => {
for d in &diagnostics {
println!("{}", d.render_json());
}
}
ClippyFormat::Text => {
for d in &diagnostics {
eprintln!("{}", d.render_text());
}
for rule in &skipped {
eprintln!("note: [{rule}] skipped — needs the serving config (-c <config.toml>)");
}
if lint_errors > 0 {
println!(
"{}: {lint_errors} lint error(s) — fix those first; clippy's rules did not run",
req.path
);
} else {
println!(
"{}: {} workflow(s), {} channel(s), {} connector(s) — {errors} error(s), \
{warnings} warning(s) from {} rule(s)",
req.path,
compiled.count(Entity::Workflow),
compiled.count(Entity::Channel),
compiled.count(Entity::Connector),
orion::definitions::clippy::registry().len() - skipped.len()
);
}
}
}
Ok(if errors > 0 || (req.deny_warnings && warnings > 0) {
1
} else {
0
})
}
pub(crate) fn run_dump_openapi() -> Result<(), Box<dyn std::error::Error>> {
println!("{}", orion::server::routes::openapi::pretty_json());
Ok(())
}
fn format_lint_error(workflow_path: &str, err: orion::errors::OrionError) -> String {
use orion::errors::OrionError;
match err {
OrionError::Validation {
code: _,
message,
details,
} => {
let mut out = format!("'{workflow_path}' is invalid: {message}\n");
for d in &details {
out.push_str(&format!(" - {} [{}]: {}\n", d.path, d.code, d.message));
}
out
}
other => format!("'{workflow_path}' is invalid: {other}"),
}
}
pub(crate) fn build_dry_run_engine(
workflow_path: &str,
stubs_path: Option<&str>,
definitions: Option<&Catalog>,
secrets: &orion::engine::ResolvedSecrets,
) -> Result<OfflineRun, Box<dyn std::error::Error>> {
let stubs = match stubs_path {
Some(path) => {
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read stubs '{path}': {e}"))?;
orion::engine::functions::stub::parse_stubs(&raw, path)?
}
None => orion::engine::functions::stub::StubTable::new(),
};
build_dry_run_engine_with_stubs(workflow_path, stubs, definitions, secrets)
}
pub(crate) fn offline_secrets(
value: &serde_json::Value,
source: &str,
) -> Result<orion::engine::ResolvedSecrets, String> {
match value {
serde_json::Value::Object(map) => {
for (name, value) in map {
if !value.is_string() {
return Err(format!(
"{source}: secrets.{name} must be a string, got {}",
orion::engine::utils::json_kind(value)
));
}
}
Ok(orion::engine::ResolvedSecrets::from_values(map.clone()))
}
other => Err(format!(
"{source}: secrets must be a JSON object of name -> value, got {}",
orion::engine::utils::json_kind(other)
)),
}
}
pub(crate) struct OfflineRun {
pub engine: dataflow_rs::Engine,
pub log: std::sync::Arc<orion::engine::functions::stub::CallLog>,
}
pub(crate) fn build_dry_run_engine_with_stubs(
workflow_path: &str,
stubs: orion::engine::functions::stub::StubTable,
definitions: Option<&Catalog>,
secrets: &orion::engine::ResolvedSecrets,
) -> Result<OfflineRun, Box<dyn std::error::Error>> {
use orion::storage::repositories::workflows::{CreateWorkflowRequest, workflow_to_dataflow};
let doc = read_expanded_workflow(workflow_path, definitions)?;
let req: CreateWorkflowRequest = serde_json::from_value(doc)
.map_err(|e| format!("'{workflow_path}' is not a valid workflow JSON: {e}"))?;
let registry = offline_registry(definitions)?;
let manifests = definitions.map(|c| c.plugins.as_slice()).unwrap_or(&[]);
let unverifiable = unverifiable_functions(&req.tasks, ®istry, manifests);
if let Some(name) = unverifiable.first() {
return Err(format!(
"PLUGIN_ARTIFACT_UNAVAILABLE: '{workflow_path}' names plugin function '{name}', \
and no manifest for its plugin was given — an offline run executes plugin \
functions for real, so pass --plugin-dir <dir> holding the plugin's plugin.toml \
and its component"
)
.into());
}
orion::validation::validate_create_workflow(
&req,
orion::config::EngineConfig::default().max_loop_iterations,
®istry,
)
.map_err(|e| format_lint_error(workflow_path, e))?;
let synthetic = orion::storage::repositories::workflows::synthetic_workflow(
&req,
req.workflow_id.as_deref().unwrap_or("dry-run"),
)?;
let df_workflow = workflow_to_dataflow(&synthetic, "__dry_run__")?;
let log = std::sync::Arc::new(orion::engine::functions::stub::CallLog::new());
let stubs_name_model_infer = stubs.contains_key(orion::model::handler::NAME);
let mut functions =
orion::engine::functions::stub::build_stub_functions_with_log(stubs, log.clone());
if let Some(catalog) = definitions {
let OfflinePluginHandlers {
handlers,
unavailable,
} = catalog.plugin_handlers()?;
for task in orion::engine::leaf_tasks(&req.tasks) {
let Some(name) = task
.get("function")
.and_then(|f| f.get("name"))
.and_then(serde_json::Value::as_str)
else {
continue;
};
if let Some((_, origin)) = unavailable.iter().find(|(f, _)| f == name) {
return Err(format!(
"PLUGIN_ARTIFACT_UNAVAILABLE: '{workflow_path}' names plugin function \
'{name}', whose manifest ({origin}) has no component beside it — build \
the component, or name it with `component = …`, so the run can execute \
it rather than stub it"
)
.into());
}
}
for (name, handler) in handlers {
functions.insert(name, handler);
}
}
let stubs_model = stubs_name_model_infer;
let infer_tasks = model_infer_tasks(&req.tasks);
match definitions.filter(|c| !c.models.is_empty()) {
Some(catalog) if !infer_tasks.is_empty() => {
let (handler, unavailable) = catalog.model_handler();
for InferTask {
path,
id: task,
model,
} in &infer_tasks
{
let Some(model) = model else {
continue;
};
if let Some((_, origin)) = unavailable.iter().find(|(id, _)| id == model) {
return Err(format_lint_error(
workflow_path,
orion::errors::OrionError::invalid_field(
path.clone(),
"MODEL_ARTIFACT_UNAVAILABLE",
format!(
"task '{task}' names model '{model}', whose manifest ({origin}) \
has no artifact beside it — put the file where the manifest's \
`artifact` names it, so the run can execute the model rather \
than stub it"
),
),
)
.into());
}
if !catalog.models.iter().any(|m| m.manifest.name == *model) {
return Err(format_lint_error(
workflow_path,
orion::errors::OrionError::invalid_field(
path.clone(),
"MODEL_ARTIFACT_UNAVAILABLE",
format!(
"task '{task}' names model '{model}', and no manifest for it \
was given — an offline run executes model_infer for real, so \
pass --model-dir <dir> holding the model's manifest and its \
artifact"
),
),
)
.into());
}
}
functions.insert(orion::model::handler::NAME.to_string(), handler);
}
Some(_) | None if infer_tasks.is_empty() || stubs_model => {}
_ => {
let InferTask {
path,
id: task,
model,
} = &infer_tasks[0];
return Err(format_lint_error(
workflow_path,
orion::errors::OrionError::invalid_field(
path.clone(),
"MODEL_ARTIFACT_UNAVAILABLE",
format!(
"task '{task}' calls model_infer{} and no --model-dir was given — pass \
--model-dir <dir> holding the model's manifest and its artifact to run \
it for real, or answer it from the stubs file with \
{{\"model_infer\": {{\"*\": <result>}}}}",
match model {
Some(model) => format!(" on model '{model}'"),
None => " with a computed model".to_string(),
}
),
),
)
.into());
}
}
let engine = orion::engine::build_single(df_workflow, functions, secrets, 0)?;
Ok(OfflineRun { engine, log })
}
pub(crate) struct DryRunRequest<'a> {
pub(crate) workflow: &'a str,
pub(crate) input: &'a str,
pub(crate) stubs: Option<&'a str>,
pub(crate) metadata: Option<&'a str>,
pub(crate) secrets: Option<&'a str>,
pub(crate) definitions: Option<&'a str>,
pub(crate) plugin_dirs: &'a [String],
pub(crate) model_dirs: &'a [String],
}
pub(crate) async fn run_dry_run(req: DryRunRequest<'_>) -> Result<(), Box<dyn std::error::Error>> {
let DryRunRequest {
workflow: workflow_path,
input: input_path,
stubs: stubs_path,
metadata: metadata_path,
secrets: secrets_path,
definitions,
plugin_dirs,
model_dirs,
} = req;
let input_raw = std::fs::read_to_string(input_path)
.map_err(|e| format!("Failed to read input '{input_path}': {e}"))?;
let input: serde_json::Value = serde_json::from_str(&input_raw)
.map_err(|e| format!("'{input_path}' is not valid JSON: {e}"))?;
let metadata = match metadata_path {
Some(path) => {
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read metadata '{path}': {e}"))?;
serde_json::from_str(&raw)
.map_err(|e| format!("'{path}' is not valid JSON: {e}"))
.and_then(|v| {
orion::engine::utils::prepare_offline_metadata(v)
.map_err(|e| format!("'{path}': {e}"))
})?
}
None => serde_json::json!({}),
};
let secrets = match secrets_path {
Some(path) => {
let raw = std::fs::read_to_string(path)
.map_err(|e| format!("Failed to read secrets '{path}': {e}"))?;
let value: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("'{path}' is not valid JSON: {e}"))?;
offline_secrets(&value, path)?
}
None => orion::engine::ResolvedSecrets::empty(),
};
let catalog = Catalog::load_opt(definitions, plugin_dirs, model_dirs)?;
let run = build_dry_run_engine(workflow_path, stubs_path, catalog.as_ref(), &secrets)?;
let mut message = dataflow_rs::Message::builder()
.payload_json(&input)
.metadata_json(&metadata)
.build();
let mut trace = dataflow_rs::ExecutionTrace::new();
let run_error = run
.engine
.process_message_tracing(&mut message, &mut trace)
.await
.err();
let mut output = serde_json::json!({
"matched": !trace.steps.is_empty(),
"trace": trace,
"output": message.data(),
"errors": message.errors().iter().filter_map(|e| serde_json::to_value(e).ok()).collect::<Vec<_>>(),
});
for (name, document) in orion::engine::functions::stub::run_documents(&message, &run.log) {
output[name] = document;
}
if let Some(ref e) = run_error {
output["error"] = serde_json::json!(e.to_string());
}
println!("{}", serde_json::to_string_pretty(&output)?);
match run_error {
Some(e) => Err(orion::errors::OrionError::Engine(e).into()),
None => Ok(()),
}
}
pub(crate) async fn run_preflight(
config: &config::AppConfig,
) -> Result<(), Box<dyn std::error::Error>> {
use orion::storage::repositories::channels::SqlChannelRepository;
use orion::storage::repositories::plugins::SqlPluginRepository;
use orion::storage::repositories::workflows::SqlWorkflowRepository;
let pool = orion::storage::init_pool_no_migrate(&config.storage)
.await
.map_err(|e| format!("storage: connection failed: {e}"))?;
println!("Config and environment: OK (checked while loading).");
eprintln!("Scanning stored channels and workflows ...");
let channels = SqlChannelRepository::new(pool.clone());
let workflows = SqlWorkflowRepository::new(pool.clone());
let plugins = SqlPluginRepository::new(pool);
let findings = orion::preflight::scan_with(&channels, &workflows, &plugins).await?;
let (breaks, advisories): (Vec<_>, Vec<_>) = findings
.into_iter()
.partition(orion::definitions::Diagnostic::is_error);
if breaks.is_empty() {
println!("Stored channels and workflows: OK — nothing to migrate.");
} else {
println!(
"\n{} item(s) need attention before upgrading. Numbers in brackets are \
checklist rows at https://docs.goplasmatic.io/operate/upgrading-to-1.0.html.\n",
breaks.len()
);
for finding in &breaks {
println!("{}\n", finding.render_preflight());
}
}
if !advisories.is_empty() {
println!(
"\n{} advisory finding(s). None of these blocks the upgrade or the exit code — \
each names a workflow that serves correctly and says less than its author \
meant it to. `orion-server lint` reports the same ids.\n",
advisories.len()
);
for finding in &advisories {
println!("{}\n", finding.render_preflight());
}
}
if breaks.is_empty() {
return Ok(());
}
Err(format!("preflight found {} item(s) to fix", breaks.len()).into())
}
pub(crate) async fn run_test_connectivity(
config: &config::AppConfig,
) -> Result<(), Box<dyn std::error::Error>> {
eprintln!("Probing storage at {} ...", redacted(&config.storage.url));
let pool = orion::storage::init_pool_no_migrate(&config.storage)
.await
.map_err(|e| format!("storage: connection failed: {e}"))?;
let pending = orion::storage::pending_migrations(&pool)
.await
.map_err(|e| format!("storage: pending_migrations query failed: {e}"))?;
println!(
" storage: OK ({} pending migrations)",
pending.len()
);
if config.kafka.enabled {
let broker_list: Vec<String> = config.kafka.brokers.iter().map(|b| redacted(b)).collect();
eprintln!("Probing Kafka brokers {} ...", broker_list.join(","));
let kafka_config = config.kafka.clone();
let brokers = tokio::task::spawn_blocking(move || {
orion::kafka::probe_brokers(&kafka_config, std::time::Duration::from_secs(5))
})
.await
.map_err(|e| format!("kafka: probe task failed: {e}"))?
.map_err(|e| format!("kafka: {e}"))?;
println!(" kafka: OK ({brokers} brokers visible)");
} else {
println!(" kafka: disabled");
}
Ok(())
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct TestCase {
#[serde(default)]
name: Option<String>,
workflow: String,
input: serde_json::Value,
#[serde(default)]
metadata: serde_json::Value,
#[serde(default)]
stubs: Option<serde_json::Value>,
#[serde(default)]
secrets: Option<serde_json::Value>,
#[serde(default)]
stubs_file: Option<String>,
#[serde(default)]
expect: std::collections::BTreeMap<String, serde_json::Value>,
#[serde(default)]
expect_errors: Vec<String>,
#[serde(default)]
expect_calls: std::collections::BTreeMap<String, Vec<serde_json::Value>>,
#[serde(default)]
expect_tasks: Option<Vec<String>>,
}
struct CaseResult {
name: String,
failures: Vec<String>,
}
pub(crate) async fn run_test(
path: &str,
definitions: Option<&str>,
plugin_dirs: &[String],
model_dirs: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let cases = collect_case_files(path)?;
if cases.is_empty() {
return Err(format!(
"no test cases found under '{path}' (looking for *{CASE_SUFFIX}). \
Name a case file explicitly to run one that does not follow the convention."
)
.into());
}
let catalog = Catalog::load_opt(definitions, plugin_dirs, model_dirs)?;
let mut results = Vec::new();
for case_path in &cases {
results.push(run_case(case_path, catalog.as_ref()).await);
}
let failed: Vec<&CaseResult> = results.iter().filter(|r| !r.failures.is_empty()).collect();
for result in &results {
if result.failures.is_empty() {
println!(" ok {}", result.name);
} else {
println!(" FAIL {}", result.name);
for failure in &result.failures {
println!(" {failure}");
}
}
}
println!(
"\n{} passed, {} failed ({} case(s))",
results.len() - failed.len(),
failed.len(),
results.len()
);
if failed.is_empty() {
Ok(())
} else {
Err(format!("{} test case(s) failed", failed.len()).into())
}
}
pub(crate) const CASE_SUFFIX: &str = ".case.json";
fn collect_case_files(path: &str) -> Result<Vec<std::path::PathBuf>, Box<dyn std::error::Error>> {
let p = std::path::Path::new(path);
if p.is_file() {
return Ok(vec![p.to_path_buf()]);
}
if !p.is_dir() {
return Err(format!("'{path}' is neither a file nor a directory").into());
}
let mut out: Vec<std::path::PathBuf> = std::fs::read_dir(p)
.map_err(|e| format!("Failed to read '{path}': {e}"))?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(CASE_SUFFIX))
})
.collect();
out.sort();
Ok(out)
}
async fn run_case(case_path: &std::path::Path, definitions: Option<&Catalog>) -> CaseResult {
let display = case_path.display().to_string();
let stem = case_path
.file_name()
.and_then(|s| s.to_str())
.map(|n| n.strip_suffix(CASE_SUFFIX).unwrap_or(n).to_string())
.unwrap_or_else(|| display.clone());
let fail = |name: &str, message: String| CaseResult {
name: name.to_string(),
failures: vec![message],
};
let raw = match std::fs::read_to_string(case_path) {
Ok(raw) => raw,
Err(e) => return fail(&stem, format!("cannot read case: {e}")),
};
let case: TestCase = match serde_json::from_str(&raw) {
Ok(case) => case,
Err(e) => return fail(&stem, format!("not a valid test case: {e}")),
};
let name = case.name.clone().unwrap_or(stem);
let base = case_path.parent().unwrap_or(std::path::Path::new("."));
let workflow_path = base.join(&case.workflow);
let workflow_path = workflow_path.to_string_lossy().to_string();
let stubs = match (&case.stubs, &case.stubs_file) {
(Some(inline), _) => orion::engine::functions::stub::parse_stub_value(inline, "stubs"),
(None, Some(file)) => match std::fs::read_to_string(base.join(file)) {
Ok(raw) => orion::engine::functions::stub::parse_stubs(&raw, file),
Err(e) => Err(format!("cannot read stubs '{file}': {e}")),
},
(None, None) => Ok(orion::engine::functions::stub::StubTable::new()),
};
let stubs = match stubs {
Ok(stubs) => stubs,
Err(e) => return fail(&name, e),
};
let unrooted: Vec<String> = case
.expect
.keys()
.filter(|path| !orion::engine::functions::stub::is_rooted(path))
.map(|path| unrooted_message(path))
.collect();
if !unrooted.is_empty() {
return CaseResult {
name,
failures: unrooted,
};
}
let metadata = match orion::engine::utils::prepare_offline_metadata(case.metadata.clone()) {
Ok(metadata) => metadata,
Err(e) => return fail(&name, e),
};
let secrets = match case.secrets.as_ref() {
Some(value) => match offline_secrets(value, &name) {
Ok(secrets) => secrets,
Err(e) => return fail(&name, e),
},
None => orion::engine::ResolvedSecrets::empty(),
};
let run = match build_dry_run_engine_with_stubs(&workflow_path, stubs, definitions, &secrets) {
Ok(run) => run,
Err(e) => return fail(&name, e.to_string()),
};
let mut message = dataflow_rs::Message::builder()
.payload_json(&case.input)
.metadata_json(&metadata)
.build();
let mut trace = dataflow_rs::ExecutionTrace::new();
let run_error = run
.engine
.process_message_tracing(&mut message, &mut trace)
.await
.err();
let mut failures = Vec::new();
if let Some(e) = run_error {
if case.expect_errors.is_empty() {
failures.push(format!("workflow failed: {e}"));
}
}
let roots = serde_json::Value::Object(orion::engine::functions::stub::run_documents(
&message, &run.log,
));
for (path, expected) in &case.expect {
let actual = lookup_path(&roots, path);
let matched = match actual {
None => expected.is_null(),
Some(ref actual) => actual == expected,
};
if !matched {
failures.push(format!(
"{path}: expected {expected}, got {}",
actual.map_or("<absent>".to_string(), |v| v.to_string())
));
}
}
failures.extend(check_expected_calls(&case.expect_calls, &run.log));
if let Some(ref expected) = case.expect_tasks {
let actual = executed_task_ids(&trace, &message);
if &actual != expected {
failures.push(format!("tasks: expected {expected:?}, ran {actual:?}"));
}
}
let actual_errors: Vec<String> = message
.errors()
.iter()
.map(|e| e.code.to_string())
.collect();
if actual_errors != case.expect_errors {
failures.push(format!(
"task errors: expected {:?}, got {:?}",
case.expect_errors, actual_errors
));
}
CaseResult { name, failures }
}
fn executed_task_ids(
trace: &dataflow_rs::ExecutionTrace,
message: &dataflow_rs::Message,
) -> Vec<String> {
let mut ids: Vec<String> = trace
.steps
.iter()
.filter(|step| matches!(step.result, dataflow_rs::StepResult::Executed))
.filter_map(|step| step.task_id.clone())
.collect();
for id in message.errors().iter().filter_map(|e| e.task_id.as_ref()) {
if !ids.iter().any(|seen| seen == id) {
ids.push(id.clone());
}
}
ids
}
fn unrooted_message(path: &str) -> String {
format!(
"expect path '{path}' has no root — did you mean 'data.{path}'? \
roots: {}",
orion::engine::functions::stub::RUN_DOCUMENTS.join(", ")
)
}
enum Segment<'a> {
Key(&'a str),
Index(usize),
}
fn path_segments(path: &str) -> Vec<Segment<'_>> {
let mut out = Vec::new();
for part in path.split('.') {
let (head, mut rest) = match part.find('[') {
Some(i) => (&part[..i], &part[i..]),
None => (part, ""),
};
if let Ok(index) = head.parse::<usize>() {
out.push(Segment::Index(index));
} else if !head.is_empty() {
out.push(Segment::Key(head));
}
while let Some(close) = rest.find(']') {
if let Ok(index) = rest[1..close].parse::<usize>() {
out.push(Segment::Index(index));
}
rest = &rest[close + 1..];
}
}
out
}
fn lookup_path(roots: &serde_json::Value, path: &str) -> Option<serde_json::Value> {
path_segments(path)
.into_iter()
.try_fold(roots, |acc, segment| match segment {
Segment::Key(key) => acc.get(key),
Segment::Index(i) => acc.get(i),
})
.cloned()
}
fn check_expected_calls(
expected: &std::collections::BTreeMap<String, Vec<serde_json::Value>>,
log: &orion::engine::functions::stub::CallLog,
) -> Vec<String> {
if expected.is_empty() {
return Vec::new();
}
let recorded = log.calls();
let mut failures = Vec::new();
for (function, expected_calls) in expected {
let actual: Vec<&orion::engine::functions::stub::RecordedCall> = recorded
.iter()
.filter(|call| call.function == function.as_str())
.collect();
if actual.len() != expected_calls.len() {
failures.push(format!(
"calls.{function}: expected {} call(s), recorded {}",
expected_calls.len(),
actual.len()
));
continue;
}
for (i, want) in expected_calls.iter().enumerate() {
failures.extend(subset_mismatch(
want,
&actual[i].input,
&format!("calls.{function}[{i}].input"),
));
}
}
failures
}
fn subset_mismatch(
expected: &serde_json::Value,
actual: &serde_json::Value,
path: &str,
) -> Vec<String> {
match (expected, actual) {
(serde_json::Value::Object(want), serde_json::Value::Object(got)) => want
.iter()
.flat_map(|(key, want_value)| match got.get(key) {
Some(got_value) => subset_mismatch(want_value, got_value, &format!("{path}.{key}")),
None => vec![format!("{path}.{key}: expected {want_value}, not written")],
})
.collect(),
(serde_json::Value::Array(want), serde_json::Value::Array(got))
if want.len() == got.len() =>
{
want.iter()
.zip(got)
.enumerate()
.flat_map(|(i, (want_value, got_value))| {
subset_mismatch(want_value, got_value, &format!("{path}[{i}]"))
})
.collect()
}
_ if expected == actual => Vec::new(),
_ => vec![format!("{path}: expected {expected}, got {actual}")],
}
}