From b601ec7d33bc07f745dd42794dae9b37e5732ae8 Mon Sep 17 00:00:00 2001 From: Erik Nilsen Date: Tue, 20 Oct 2026 07:51:56 -0700 Subject: [PATCH] feat(cli): add validate command for model-level findings Signed-off-by: Erik Nilsen --- docs/reference/trogon-atlas-exit-codes.md | 2 +- rsworkspace/crates/trogon-atlas-cli/README.md | 8 + .../crates/trogon-atlas-cli/src/main.rs | 42 +++- .../tests/local_validation_before_connect.rs | 11 + .../tests/validate_cli_integration.rs | 194 ++++++++++++++++++ .../crates/trogon-atlas-client/src/lib.rs | 1 + .../trogon-atlas-client/src/validate.rs | 85 ++++++++ 7 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 rsworkspace/crates/trogon-atlas-cli/tests/validate_cli_integration.rs create mode 100644 rsworkspace/crates/trogon-atlas-client/src/validate.rs diff --git a/docs/reference/trogon-atlas-exit-codes.md b/docs/reference/trogon-atlas-exit-codes.md index 98de7174..db2a373f 100644 --- a/docs/reference/trogon-atlas-exit-codes.md +++ b/docs/reference/trogon-atlas-exit-codes.md @@ -10,7 +10,7 @@ it. | Code | Meaning | | --- | --- | | `0` | Success. | -| `1` | The command ran to completion but reports a finding rather than an error: a diff found drift, `fmt --check` found files that would change, a merge or rebase left conflicts. | +| `1` | The command ran to completion but reports a finding rather than an error: a diff found drift, `fmt --check` found files that would change, a merge or rebase left conflicts, `validate` found an error. | | `2` | Usage error: clap rejected the arguments before any command ran. | | `3` | `stale_state` -- a plan was made against a revision the server no longer holds. Read fresh state and plan again. | | `4` | `precondition` -- an etag or referrer check refused the request without staling a plan. | diff --git a/rsworkspace/crates/trogon-atlas-cli/README.md b/rsworkspace/crates/trogon-atlas-cli/README.md index ee69321c..017cfd10 100644 --- a/rsworkspace/crates/trogon-atlas-cli/README.md +++ b/rsworkspace/crates/trogon-atlas-cli/README.md @@ -30,6 +30,7 @@ trogon-atlas describe command_slice/shop/place-order-slice # title, doc, fields, trogon-atlas tree shop # domains, storyboards, slices and what each slice connects trogon-atlas tree # the same for every namespace that holds entities trogon-atlas namespaces # namespaces you can see, with ids and entity counts +trogon-atlas validate shop/checkout # model-level findings, exit 1 on any error ``` `get`, `describe`, `tree` and `namespaces` are read-only. They take the same kind names @@ -37,6 +38,13 @@ as manifests (`read-model`, `read_model`, `ReadModel`). A missing entity exits `6` (`not_found`). They also take the ids that `describe` prints, such as `event:shop/order.placed@1`. +`validate /[@version]` runs the model-level rules on a +stored event model: storyboard entries, projection order, field provenance. +`apply --dry-run` runs only the per-entity checks, so run `validate` after an +apply to see these findings. With `--branch`, it validates the branch's view +of the model. It exits `1` when any finding is an error and `6` when the +model does not exist. + Connection flags (or environment): | Flag | Env | Default | diff --git a/rsworkspace/crates/trogon-atlas-cli/src/main.rs b/rsworkspace/crates/trogon-atlas-cli/src/main.rs index b76f20af..2cbcee9f 100644 --- a/rsworkspace/crates/trogon-atlas-cli/src/main.rs +++ b/rsworkspace/crates/trogon-atlas-cli/src/main.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; use trogon_atlas_client::{ apply, branch, client, compat, export, failure::Failure, fmt, manifest, manifest_schema, openslo, openslo_bindings::BindingsConfig, outcome::CommandOutcome, - precondition::BranchEntryState, view, + precondition::BranchEntryState, validate, view, }; use trogon_atlas_proto as pb; @@ -136,6 +136,15 @@ enum Cmd { }, /// List the namespaces the caller can see, with their ids and entity counts. Namespaces, + /// Run model-level validation on a stored event model: the rules that + /// need the whole model in view, which `apply --dry-run` does not run. + /// Exits 1 when any finding is an error. With --branch, validates the + /// branch's view of the model. + Validate { + /// `/[@version]`, e.g. `shop/checkout`. Omit + /// `@version` to validate the latest version. + model: String, + }, /// Manage branch lifecycle (Phase 1: Isolation). Branch scope for /// Apply/Diff/Export is set via the top-level --branch flag, not here. Branch { @@ -740,6 +749,37 @@ async fn run( } Ok((ExitCode::SUCCESS, Some(view::namespaces_json(&namespaces)))) } + Cmd::Validate { model } => { + let address = validate::parse_model_address(&model)?; + let mut client = + connect_client(&endpoint, auth_token.as_deref(), rpc_timeout_secs, branch).await?; + let (id, issues) = validate::validate_event_model(&mut client, &address).await?; + let model = pb::canonical::id_string(pb::EntityKind::EventModel, &id); + let severities = branch::SeverityCounts::of(&issues); + if text { + println!("{model}: {severities}"); + for issue in &issues { + println!("{}", apply::format_issue(issue)); + } + } + let data = json!({ + "event_model": model, + "validation_summary": { + "errors": severities.errors, + "warnings": severities.warnings, + "info": severities.infos, + }, + "validation": issues.iter().map(apply::issue_to_json).collect::>(), + }); + Ok(( + if severities.errors > 0 { + ExitCode::from(EXIT_FINDINGS) + } else { + ExitCode::SUCCESS + }, + Some(data), + )) + } Cmd::Tree { namespace } => { if let Some(namespace) = &namespace { trogon_atlas_core::validate_id_component(namespace).map_err(|e| { diff --git a/rsworkspace/crates/trogon-atlas-cli/tests/local_validation_before_connect.rs b/rsworkspace/crates/trogon-atlas-cli/tests/local_validation_before_connect.rs index eb55b68e..eb00c2c7 100644 --- a/rsworkspace/crates/trogon-atlas-cli/tests/local_validation_before_connect.rs +++ b/rsworkspace/crates/trogon-atlas-cli/tests/local_validation_before_connect.rs @@ -169,3 +169,14 @@ fn apply_missing_manifest_errors_without_connecting() { "must not attempt gRPC connect before local path validation; got: {stderr}" ); } + +#[test] +fn validate_malformed_model_address_errors_without_connecting() { + let out = trogon_atlas(&["validate", "no-slash"]); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(5), "{stderr}"); + assert!( + !stderr.contains("connecting to"), + "must not attempt gRPC connect before parsing the address; got: {stderr}" + ); +} diff --git a/rsworkspace/crates/trogon-atlas-cli/tests/validate_cli_integration.rs b/rsworkspace/crates/trogon-atlas-cli/tests/validate_cli_integration.rs new file mode 100644 index 00000000..91ad5195 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-cli/tests/validate_cli_integration.rs @@ -0,0 +1,194 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +// `trogon-atlas validate` against an in-process gRPC server, spawning the +// compiled binary like views_cli_integration.rs. A blocking +// Command::output on the current_thread runtime deadlocks the in-process +// server, so every test here uses the multi_thread flavor. + +use std::{process::Command, sync::Arc}; + +use tokio::net::TcpListener; +use trogon_atlas_proto as pb; +use trogon_atlas_proto::event_model_service_server::EventModelServiceServer; +use trogon_atlas_server::{metrics::ServerMetrics, service::EventModelServiceImpl}; + +async fn start_server() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let store = trogon_atlas_testsupport::shared().await.store().await; + let svc = EventModelServiceImpl::try_new(Arc::new(store), ServerMetrics::default()).unwrap(); + let grpc = EventModelServiceServer::new(svc); + tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(grpc) + .serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener)) + .await + .unwrap(); + }); + format!("http://{addr}") +} + +fn id(ns: &str, slug: &str) -> pb::Id { + pb::Id { + namespace: ns.into(), + slug: slug.into(), + version: 1, + } +} + +async fn put(client: &mut trogon_atlas_client::client::Client, kind: pb::entity::Kind) { + client + .put_entity(pb::PutEntityRequest { + entity: Some(pb::Entity { + system: None, + kind: Some(kind), + }), + create_only: false, + if_match: String::new(), + validate_only: false, + force: false, + operation_id: String::new(), + }) + .await + .unwrap(); +} + +/// A model whose only member is an event with no swimlane: per-entity +/// checks accept it, but the model-level rule reports an Error. +async fn seed_model_with_laneless_event( + client: &mut trogon_atlas_client::client::Client, + ns: &str, +) { + put( + client, + pb::entity::Kind::Event(pb::Event { + id: Some(id(ns, "order.placed")), + title: "Order placed".into(), + ..Default::default() + }), + ) + .await; + put( + client, + pb::entity::Kind::EventModel(pb::EventModel { + id: Some(id(ns, "shop")), + title: "Shop".into(), + members: vec![pb::EntityRef { + kind: pb::EntityKind::Event as i32, + id: Some(id(ns, "order.placed")), + }], + ..Default::default() + }), + ) + .await; +} + +fn trogon_atlas(endpoint: &str, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_trogon-atlas")) + .arg("--endpoint") + .arg(endpoint) + .args(args) + .output() + .expect("trogon-atlas must spawn") +} + +fn stdout(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn validate_reports_model_level_errors_and_exits_with_findings() { + let endpoint = start_server().await; + let mut client = trogon_atlas_client::client::connect(&endpoint, None, 30) + .await + .unwrap(); + seed_model_with_laneless_event(&mut client, "cli-validate-err").await; + + let text = trogon_atlas(&endpoint, &["validate", "cli-validate-err/shop"]); + assert_eq!(text.status.code(), Some(1), "{text:?}"); + let out = stdout(&text); + assert!(out.contains("error EVENT_NO_SWIMLANE"), "{out}"); + assert!(out.contains("1 errors"), "{out}"); + + let json = trogon_atlas( + &endpoint, + &["--format", "json", "validate", "cli-validate-err/shop@1"], + ); + assert_eq!(json.status.code(), Some(1), "{json:?}"); + let outcome: serde_json::Value = serde_json::from_str(&stdout(&json)).unwrap(); + assert_eq!(outcome["ok"], true, "{outcome}"); + assert_eq!( + outcome["data"]["event_model"], + "event_model:cli-validate-err/shop@1" + ); + assert_eq!( + outcome["data"]["validation_summary"]["errors"], 1, + "{outcome}" + ); + let codes: Vec<_> = outcome["data"]["validation"] + .as_array() + .unwrap() + .iter() + .map(|issue| issue["code"].as_str().unwrap()) + .collect(); + assert!(codes.contains(&"EVENT_NO_SWIMLANE"), "{outcome}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn validate_exits_zero_for_a_clean_model() { + let endpoint = start_server().await; + let mut client = trogon_atlas_client::client::connect(&endpoint, None, 30) + .await + .unwrap(); + put( + &mut client, + pb::entity::Kind::EventModel(pb::EventModel { + id: Some(id("cli-validate-ok", "empty")), + title: "Empty".into(), + ..Default::default() + }), + ) + .await; + + let out = trogon_atlas(&endpoint, &["validate", "cli-validate-ok/empty"]); + assert!(out.status.success(), "{out:?}"); + assert!(stdout(&out).contains("0 errors"), "{}", stdout(&out)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn validate_reads_the_model_from_the_given_branch() { + let endpoint = start_server().await; + let branch = format!( + "cli-validate-{}", + trogon_atlas_testsupport::unique_suffix_for_test() + ); + let created = trogon_atlas(&endpoint, &["branch", "create", &branch]); + assert!(created.status.success(), "{created:?}"); + let mut branched = trogon_atlas_client::client::connect_with_options( + &endpoint, + None, + 30, + trogon_atlas_client::client::ConnectOptions { + branch: Some(branch.clone()), + ..Default::default() + }, + ) + .await + .unwrap(); + seed_model_with_laneless_event(&mut branched, "cli-validate-br").await; + + let on_branch = trogon_atlas( + &endpoint, + &["--branch", &branch, "validate", "cli-validate-br/shop"], + ); + assert_eq!(on_branch.status.code(), Some(1), "{on_branch:?}"); + assert!( + stdout(&on_branch).contains("EVENT_NO_SWIMLANE"), + "{}", + stdout(&on_branch) + ); + + // Baseline never saw the branch's model. + let on_baseline = trogon_atlas(&endpoint, &["validate", "cli-validate-br/shop"]); + assert_eq!(on_baseline.status.code(), Some(6), "{on_baseline:?}"); +} diff --git a/rsworkspace/crates/trogon-atlas-client/src/lib.rs b/rsworkspace/crates/trogon-atlas-client/src/lib.rs index 2476636a..b8f50e88 100644 --- a/rsworkspace/crates/trogon-atlas-client/src/lib.rs +++ b/rsworkspace/crates/trogon-atlas-client/src/lib.rs @@ -23,4 +23,5 @@ pub mod operation; pub mod outcome; pub mod precondition; pub mod tenant_types; +pub mod validate; pub mod view; diff --git a/rsworkspace/crates/trogon-atlas-client/src/validate.rs b/rsworkspace/crates/trogon-atlas-client/src/validate.rs new file mode 100644 index 00000000..831a1d70 --- /dev/null +++ b/rsworkspace/crates/trogon-atlas-client/src/validate.rs @@ -0,0 +1,85 @@ +//! Model-level validation (`ValidateEventModel`) of a stored EventModel. +//! Per-entity checks run on every write; the rules that need the whole +//! model in view (storyboard entries, projection order, field provenance) +//! run only here. + +use anyhow::{Context as _, Result}; +use trogon_atlas_proto as pb; + +use crate::{ + client::Client, + view::{self, EntityAddress}, +}; + +/// Parse `/[@version]`; `version: None` means latest. +pub fn parse_model_address(s: &str) -> Result { + if s.matches('/').count() != 1 { + crate::invalid_input!("expected /[@version], got {s:?}"); + } + view::parse_address(&format!("event_model/{s}")) +} + +/// Validate the addressed EventModel and return the stored id it resolved +/// to along with the findings. Both reads carry the client's branch header, +/// so a branch-scoped client validates the branch's view of the model. +pub async fn validate_event_model( + client: &mut Client, + address: &EntityAddress, +) -> Result<(pb::Id, Vec)> { + // The RPC needs a concrete version, so resolve "latest" first. + let id = if let Some(version) = address.version { + pb::Id { + namespace: address.namespace.clone(), + slug: address.slug.clone(), + version, + } + } else { + let entity = view::fetch_entity(client, address).await?; + trogon_atlas_core::entity_id(&entity) + .cloned() + .ok_or_else(|| anyhow::anyhow!("stored event model has no id"))? + }; + let resp = client + .validate_event_model(pb::ValidateEventModelRequest { + event_model: None, + event_model_id: Some(id.clone()), + }) + .await + .context("ValidateEventModel failed")? + .into_inner(); + Ok((id, resp.issues)) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn parses_namespace_model_and_optional_version() { + let latest = parse_model_address("shop/checkout").unwrap(); + assert_eq!(latest.kind, pb::EntityKind::EventModel); + assert_eq!( + ( + latest.namespace.as_str(), + latest.slug.as_str(), + latest.version + ), + ("shop", "checkout", None) + ); + let pinned = parse_model_address("shop/checkout@3").unwrap(); + assert_eq!(pinned.version, Some(3)); + } + + #[test] + fn rejects_addresses_that_are_not_namespace_slash_model() { + for bad in [ + "checkout", + "event_model/shop/checkout", + "shop/", + "shop/checkout@0", + ] { + assert!(parse_model_address(bad).is_err(), "{bad} should not parse"); + } + } +}