Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/trogon-atlas-exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
8 changes: 8 additions & 0 deletions rsworkspace/crates/trogon-atlas-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,21 @@ 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
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 <namespace>/<model>[@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 |
Expand Down
42 changes: 41 additions & 1 deletion rsworkspace/crates/trogon-atlas-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
/// `<namespace>/<model>[@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 {
Expand Down Expand Up @@ -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::<Vec<_>>(),
});
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| {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
194 changes: 194 additions & 0 deletions rsworkspace/crates/trogon-atlas-cli/tests/validate_cli_integration.rs
Original file line number Diff line number Diff line change
@@ -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:?}");
}
1 change: 1 addition & 0 deletions rsworkspace/crates/trogon-atlas-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ pub mod operation;
pub mod outcome;
pub mod precondition;
pub mod tenant_types;
pub mod validate;
pub mod view;
85 changes: 85 additions & 0 deletions rsworkspace/crates/trogon-atlas-client/src/validate.rs
Original file line number Diff line number Diff line change
@@ -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 `<namespace>/<model>[@version]`; `version: None` means latest.
pub fn parse_model_address(s: &str) -> Result<EntityAddress> {
if s.matches('/').count() != 1 {
crate::invalid_input!("expected <namespace>/<model>[@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<pb::ValidationIssue>)> {
// 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");
}
}
}
Loading