diff --git a/docs-mintlify/reference/cli.mdx b/docs-mintlify/reference/cli.mdx index 8f991fcae6872..8c1d6f676cc17 100644 --- a/docs-mintlify/reference/cli.mdx +++ b/docs-mintlify/reference/cli.mdx @@ -139,6 +139,30 @@ cube github connect DEPLOYMENT_ID REPO --installation INSTALLATION_ID --branch m Connecting clones the repository into the deployment and triggers the first build. +## Validate the data model + +`cube validate` compiles a deployment's data model and reports the compiler's +errors, exiting non-zero when there are any — so it works as a CI gate: + +```bash +cube validate DEPLOYMENT_ID # the deploy branch (production) +cube validate DEPLOYMENT_ID --branch my-branch # a specific branch +cube validate DEPLOYMENT_ID --dev-mode # your active dev-mode branch +``` + +The compile runs where the model runs: the command asks the branch's own Cube +API for its metadata, the same call the Cube Cloud UI makes. So the model is +checked against that environment's real variables and drivers, and a branch is +validated by the environment serving it — with `--dev-mode`, against your +uncommitted working copy, before you commit it. + +``` +✓ Data model on master is valid (12 cubes) +``` + +Pass `--json` for a machine-readable report (`valid`, `errors[]` with the file +each was reported against, `cubesCount`); the exit code is the same either way. + ## Command reference Run `cube --help` for the full options of any command. @@ -148,6 +172,7 @@ Run `cube --help` for the full options of any command. | `login`, `logout`, `whoami`, `context` | Authentication and saved contexts | | `deployments` | List, get, create, update, delete deployments; `settings`, `versions`, `token`, `build-status`, `advance-step`, `reset-step` | | `deploy` | Upload a local project directory and build it | +| `validate` | Compile a deployment's data model and report compilation errors (`--branch`, `--dev-mode`) | | `logs` | Tail deployment pod logs (`--pod`, `-c/--container`, `--source production\|dev`) | | `regions` | List available deployment regions | | `github` (`gh`) | GitHub integration: `status`, `installations`, `repos`, `branches`, `connect` | diff --git a/rust/cube-cli/README.md b/rust/cube-cli/README.md index 015090e438c39..4f5528cf0d804 100644 --- a/rust/cube-cli/README.md +++ b/rust/cube-cli/README.md @@ -139,6 +139,7 @@ Every endpoint of the Console Server public API is covered: |---|---| | `deployments` | list, get, create (`--bootstrap` scaffolds + builds a serving deployment), update (`--release-channel`, `--release-channel-version`), settings, versions, delete, token, advance-step, reset-step | | `regions` | list available deployment regions | +| `validate` | compile a deployment's data model and report the compiler's errors; exits non-zero so it gates CI. `--branch` picks a branch, `--dev-mode` your active dev-mode working copy; neither validates the deploy branch | | `logs` | tail deployment pod logs (`--pod`, `-c/--container`; defaults to the Cube API container) | | `github` (`gh`) | status, installations, repos, branches, connect (import a repo into a deployment + first build) | | `data-model` (`dm`) | list, get, put, delete, rename files; branches, create-branch, enable-branch, disable-branch, dev-mode, exit-dev-mode, commit, pull. File writes only land on a **dev-mode branch**: `dev-mode ` forks a personal `dev-…` branch and prints its name — pass that via `--branch` (or omit `--branch` to use your active dev-mode branch); puts to any other branch are rejected by the API. `enable-branch` / `disable-branch` toggle whether a shared branch's staging environment stays always active (vs. only while viewed in the UI); `branches` reports it as `ENABLED` and `environments list --type staging` lists the enabled ones | diff --git a/rust/cube-cli/src/commands/mod.rs b/rust/cube-cli/src/commands/mod.rs index 001e7b4375a06..e8c68cc125f46 100644 --- a/rust/cube-cli/src/commands/mod.rs +++ b/rust/cube-cli/src/commands/mod.rs @@ -28,6 +28,7 @@ pub mod spec; pub mod tenant; pub mod update; pub mod users; +pub mod validate; pub mod variables; pub mod whoami; pub mod workbooks; diff --git a/rust/cube-cli/src/commands/validate.rs b/rust/cube-cli/src/commands/validate.rs new file mode 100644 index 0000000000000..7509b134ec67c --- /dev/null +++ b/rust/cube-cli/src/commands/validate.rs @@ -0,0 +1,205 @@ +use anyhow::{bail, Result}; +use owo_colors::OwoColorize; +use serde_json::Value; + +use crate::client::Query; +use crate::{output, util, Ctx}; + +/// Validate a deployment's data model in Cube Cloud. +/// +/// The compile happens where the model actually runs: the API asks the +/// branch's own Cube runtime for `/meta`, exactly as the console does in dev +/// mode. So this validates the model against the deployment's real environment +/// variables, drivers and dependencies — what a local compile cannot do — and +/// `--branch` / `--dev-mode` pick which of those runtimes answers. +#[derive(clap::Args)] +pub struct Args { + /// Deployment id + deployment: i64, + /// Branch to validate (defaults to the deployment's deploy branch) + #[arg(long, conflicts_with = "dev_mode")] + branch: Option, + /// Validate your active dev-mode branch — the uncommitted working copy + /// `cube data-model dev-mode` put you on + #[arg(long)] + dev_mode: bool, +} + +/// Render one compilation error as `: `, or just the message +/// when the compiler didn't attribute it to a file. +/// +/// The endpoint reports each error as `{ fileName?, message }`, but the CLI and +/// the server ship from separate repos on separate cadences, so an entry that +/// doesn't match that shape still has to print as something: a blank line is +/// the one output this command must never produce — printing the errors IS the +/// command. So a bare string renders as itself, a half-filled object renders as +/// whichever half it has, and anything else falls back to its own JSON. +fn format_error(error: &Value) -> String { + if let Value::String(message) = error { + return message.clone(); + } + + let message = output::field(error, "message"); + let file = output::field(error, "fileName"); + + match (file.is_empty(), message.is_empty()) { + (false, false) => format!("{file}: {message}"), + (true, false) => message, + (false, true) => file, + (true, true) => error.to_string(), + } +} + +/// What to call the validated branch in user-facing output. +/// +/// The server echoes the branch it resolved, which is the only source for the +/// `--dev-mode` case (the personal `dev-…` name is server-side). A +/// differently-versioned one that doesn't echo it must not turn every message +/// into "on is valid" — fall back to what the caller asked for, and to a +/// generic label when the caller named nothing either. +fn branch_label(res: &Value, args: &Args) -> String { + let echoed = output::field(res, "branchName"); + if !echoed.is_empty() { + return echoed; + } + + match (&args.branch, args.dev_mode) { + (Some(branch), _) => branch.clone(), + (None, true) => "the dev-mode branch".to_string(), + (None, false) => "the deploy branch".to_string(), + } +} + +pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { + let mut query: Query = Vec::new(); + util::push(&mut query, "branchName", &args.branch); + if args.dev_mode { + query.push(("devMode".into(), "true".into())); + } + + let res = ctx + .api()? + .get( + &format!( + "/build/api/v1/deployments/{}/data-model/validate", + args.deployment + ), + &query, + ) + .await?; + + let branch = branch_label(&res, &args); + // Absent `valid` fails closed: a report this command can't read is not + // evidence the model compiles, and the whole point is gating CI on it. + let valid = res.get("valid").and_then(Value::as_bool).unwrap_or(false); + let errors = res + .get("errors") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + if ctx.json { + output::print_json(&res); + } else if valid { + let cubes = res.get("cubesCount").and_then(Value::as_u64); + match cubes { + Some(n) => output::success(&format!("Data model on {branch} is valid ({n} cubes)")), + None => output::success(&format!("Data model on {branch} is valid")), + } + } else if !errors.is_empty() { + // Compilation errors go to stderr so `cube validate --json` stays + // machine-readable on stdout and a human run stays readable when + // stdout is piped. + eprintln!("{} Data model on {branch} failed to compile:", "✗".red()); + for error in &errors { + eprintln!(" {}", format_error(error)); + } + } + + if !valid { + // Non-zero exit is the point of the command in CI, so it holds in + // --json mode too, where the report above was printed as JSON. + // + // With nothing to list there is no stderr header above it: the header + // exists to introduce a list, and repeating the verdict on two lines + // says less than the one line that also says what to look at. + if errors.is_empty() { + bail!( + "data model on {branch} could not be validated \ + (the API reported a failure without any compilation errors)" + ); + } + bail!( + "data model on {branch} has {} compilation error(s)", + errors.len() + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn an_error_is_prefixed_with_the_file_only_when_the_compiler_named_one() { + assert_eq!( + format_error(&json!({"fileName": "model/cubes/orders.yml", "message": "no sql"})), + "model/cubes/orders.yml: no sql" + ); + // Errors the compiler couldn't attribute carry no file; a bare `": "` + // in front of them would read as an empty filename. + assert_eq!(format_error(&json!({"message": "no sql"})), "no sql"); + assert_eq!( + format_error(&json!({"fileName": null, "message": "no sql"})), + "no sql" + ); + } + + #[test] + fn an_entry_that_is_not_the_expected_object_still_prints_something() { + // A blank line is the one output this command must never produce, so + // every shape a differently-versioned server could send has to render. + assert_eq!( + format_error(&json!("Orders cube: no sql")), + "Orders cube: no sql" + ); + assert_eq!( + format_error(&json!({"fileName": "model/cubes/orders.yml"})), + "model/cubes/orders.yml" + ); + assert_eq!(format_error(&json!({"code": 7})), r#"{"code":7}"#); + assert_eq!(format_error(&Value::Null), "null"); + } + + fn args(branch: Option<&str>, dev_mode: bool) -> Args { + Args { + deployment: 1, + branch: branch.map(str::to_string), + dev_mode, + } + } + + #[test] + fn the_branch_the_server_echoes_wins() { + // The `--dev-mode` name only exists server-side, so the echo is the + // only way to report which branch was actually validated. + let res = json!({"branchName": "dev-pavel-feature", "valid": true}); + assert_eq!(branch_label(&res, &args(None, true)), "dev-pavel-feature"); + assert_eq!( + branch_label(&res, &args(Some("feature"), false)), + "dev-pavel-feature" + ); + } + + #[test] + fn a_response_without_a_branch_never_leaves_a_hole_in_the_message() { + // "Data model on is valid" is the failure this guards against. + let res = json!({"valid": true}); + assert_eq!(branch_label(&res, &args(Some("feature"), false)), "feature"); + assert_eq!(branch_label(&res, &args(None, true)), "the dev-mode branch"); + assert_eq!(branch_label(&res, &args(None, false)), "the deploy branch"); + } +} diff --git a/rust/cube-cli/src/main.rs b/rust/cube-cli/src/main.rs index e6cf069e8c10f..5db51d789f2a6 100644 --- a/rust/cube-cli/src/main.rs +++ b/rust/cube-cli/src/main.rs @@ -125,6 +125,8 @@ enum Command { Regions(commands::regions::Args), /// Upload a local project directory to a deployment and build it Deploy(commands::deploy::Args), + /// Compile a deployment's data model and report compilation errors + Validate(commands::validate::Args), /// Tail a deployment's pod logs Logs(commands::logs::Args), /// GitHub integration: link status, installations, repos, and connect @@ -219,6 +221,7 @@ impl Command { Deployments(_) => "deployments", Regions(_) => "regions", Deploy(_) => "deploy", + Validate(_) => "validate", Logs(_) => "logs", Github(_) => "github", DataModel(_) => "data-model", @@ -328,6 +331,7 @@ async fn run(global: GlobalArgs, command: Command) -> Result<()> { Deployments(args) => commands::deployments::command(args, &ctx).await, Regions(args) => commands::regions::command(args, &ctx).await, Deploy(args) => commands::deploy::command(args, &ctx).await, + Validate(args) => commands::validate::command(args, &ctx).await, Logs(args) => commands::logs::command(args, &ctx).await, Github(args) => commands::github::command(args, &ctx).await, DataModel(args) => commands::data_model::command(args, &ctx).await,