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
25 changes: 25 additions & 0 deletions docs-mintlify/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> --help` for the full options of any command.
Expand All @@ -148,6 +172,7 @@ Run `cube <command> --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` |
Expand Down
1 change: 1 addition & 0 deletions rust/cube-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>` 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 |
Expand Down
1 change: 1 addition & 0 deletions rust/cube-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
205 changes: 205 additions & 0 deletions rust/cube-cli/src/commands/validate.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// 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 `<file>: <message>`, 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");
}
}
4 changes: 4 additions & 0 deletions rust/cube-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -219,6 +221,7 @@ impl Command {
Deployments(_) => "deployments",
Regions(_) => "regions",
Deploy(_) => "deploy",
Validate(_) => "validate",
Logs(_) => "logs",
Github(_) => "github",
DataModel(_) => "data-model",
Expand Down Expand Up @@ -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,
Expand Down
Loading