diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 84f1c19..4ea6f28 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -18,9 +18,11 @@ use omnigraph_compiler::{ use omnigraph_server::api::{ BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput, BranchMergeOutput, BranchMergeRequest, ChangeOutput, ChangeRequest, CommitListOutput, - CommitOutput, ErrorOutput, ExportRequest, IngestOutput, IngestRequest, ReadOutput, ReadRequest, - SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, SnapshotTableOutput, - commit_output, ingest_output, read_output, schema_apply_output, snapshot_payload, + CommitOutput, ErrorOutput, ExportRequest, GraphCreateRequest, GraphCreateResponse, + GraphListResponse, GraphPolicySpec, GraphSchemaSpec, IngestOutput, IngestRequest, ReadOutput, + ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, + SnapshotTableOutput, commit_output, ingest_output, read_output, schema_apply_output, + snapshot_payload, }; use omnigraph_server::{ AliasCommand, OmnigraphConfig, PolicyAction, PolicyDecision, PolicyEngine, PolicyRequest, @@ -253,6 +255,69 @@ enum Command { #[arg(long)] json: bool, }, + /// Manage graphs on a multi-graph server (MR-668) + Graphs { + #[command(subcommand)] + command: GraphsCommand, + }, +} + +/// Operations on the graph registry of a multi-graph server (MR-668). +/// +/// All operations target a remote multi-graph server URL (http:// or +/// https://). Local-URI invocations return a clear error; for local +/// graphs operators add/remove entries by editing `omnigraph.yaml` +/// directly and restarting. +/// +/// `Delete` is intentionally omitted in v0.7.0 — server-side DELETE +/// was deferred to bound the release scope. Operators remove graphs +/// by stopping the server, editing `omnigraph.yaml`, then restarting. +#[derive(Debug, Subcommand)] +enum GraphsCommand { + /// List every graph registered with the multi-graph server. + List { + /// Remote server URL (e.g. `https://server.example.com`). + #[arg(long)] + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + #[arg(long)] + json: bool, + }, + /// Create a new graph at runtime via `POST /graphs`. + /// + /// The schema file is read locally and the bytes are inlined as + /// `schema.source` in the request body. The server runs + /// `Omnigraph::init` at the supplied `uri` and atomically rewrites + /// `omnigraph.yaml` to include the new entry. + Create { + /// Remote server URL (e.g. `https://server.example.com`). + #[arg(long)] + uri: Option, + #[arg(long)] + target: Option, + #[arg(long)] + config: Option, + /// New graph identifier. Must satisfy `^[a-zA-Z0-9-]{1,64}$`. + #[arg(long)] + graph_id: String, + /// Storage URI for the new graph (local path or `s3://...`). + /// Operator-supplied; the server `Omnigraph::init`s here. + #[arg(long = "graph-uri")] + graph_uri: String, + /// Local path to the schema `.pg` file. CLI reads the file + /// and inlines its contents as `schema.source` in the body. + #[arg(long)] + schema: PathBuf, + /// Optional per-graph policy file path. Sent verbatim to the + /// server, where it must be readable at request time. + #[arg(long)] + policy_file: Option, + #[arg(long)] + json: bool, + }, } #[derive(Debug, Subcommand)] @@ -2556,6 +2621,94 @@ async fn main() -> Result<()> { ); } } + Command::Graphs { command } => match command { + GraphsCommand::List { + uri, + target, + config, + json, + } => { + let config = load_cli_config(config.as_ref())?; + let bearer_token = + resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; + let uri = resolve_uri(&config, uri, target.as_deref())?; + if !is_remote_uri(&uri) { + bail!( + "`omnigraph graphs list` requires a remote multi-graph server URL \ + (http:// or https://). To enumerate local graphs, read `omnigraph.yaml` \ + directly." + ); + } + let payload = remote_json::( + &http_client, + Method::GET, + remote_url(&uri, "/graphs"), + None, + bearer_token.as_deref(), + ) + .await?; + if json { + print_json(&payload)?; + } else { + for entry in payload.graphs { + println!("{}\t{}", entry.graph_id, entry.uri); + } + } + } + GraphsCommand::Create { + uri, + target, + config, + graph_id, + graph_uri, + schema, + policy_file, + json, + } => { + let config = load_cli_config(config.as_ref())?; + let bearer_token = + resolve_remote_bearer_token(&config, uri.as_deref(), target.as_deref())?; + let uri = resolve_uri(&config, uri, target.as_deref())?; + if !is_remote_uri(&uri) { + bail!( + "`omnigraph graphs create` requires a remote multi-graph server URL \ + (http:// or https://). To add a graph to a local config, edit \ + `omnigraph.yaml` and restart the server." + ); + } + let schema_source = fs::read_to_string(&schema).map_err(|err| { + color_eyre::eyre::eyre!( + "reading schema file '{}': {err}", + schema.display() + ) + })?; + let request_body = GraphCreateRequest { + graph_id: graph_id.clone(), + uri: graph_uri.clone(), + schema: GraphSchemaSpec { + source: schema_source, + }, + policy: policy_file + .as_ref() + .map(|file| GraphPolicySpec { + file: Some(file.clone()), + }), + }; + let payload = remote_json::( + &http_client, + Method::POST, + remote_url(&uri, "/graphs"), + Some(serde_json::to_value(&request_body)?), + bearer_token.as_deref(), + ) + .await?; + if json { + print_json(&payload)?; + } else { + println!("created graph {} at {}", payload.graph_id, payload.uri); + } + } + }, } Ok(()) } diff --git a/crates/omnigraph-cli/tests/cli.rs b/crates/omnigraph-cli/tests/cli.rs index f7238b6..7bb6a16 100644 --- a/crates/omnigraph-cli/tests/cli.rs +++ b/crates/omnigraph-cli/tests/cli.rs @@ -2040,3 +2040,91 @@ fn schema_plan_parity_cli_and_sdk() { ); assert_eq!(cli_payload["supported"], plan.supported); } + +// ─── MR-668 PR 8 — omnigraph graphs subcommand ───────────────────────────── + +/// `omnigraph graphs --help` lists the two subcommands shipped in +/// v0.7.0 (`list`, `create`). `delete` is intentionally NOT in the +/// help — DELETE was deferred to bound the v0.7.0 scope. +#[test] +fn graphs_subcommand_help_lists_list_and_create() { + let output = output_success(cli().arg("graphs").arg("--help")); + let stdout = stdout_string(&output); + assert!( + stdout.contains("list"), + "expected `list` subcommand in help output:\n{stdout}" + ); + assert!( + stdout.contains("create"), + "expected `create` subcommand in help output:\n{stdout}" + ); + // Pin the deferral: `delete` must not appear yet (catches an + // accidental scope expansion). + assert!( + !stdout.to_lowercase().contains("delete a graph"), + "graph delete should not be in v0.7.0 help; got:\n{stdout}" + ); +} + +/// `omnigraph graphs list` against a local URI errors with a clear +/// message — the CLI only operates against remote multi-graph servers. +#[test] +fn graphs_list_against_local_uri_errors_with_remote_only_message() { + let output = output_failure(cli().arg("graphs").arg("list").arg("--uri").arg("/tmp/local")); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("remote multi-graph server URL"), + "expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}" + ); +} + +/// `omnigraph graphs create` against a local URI errors the same way. +#[test] +fn graphs_create_against_local_uri_errors_with_remote_only_message() { + let temp = tempdir().unwrap(); + let schema = temp.path().join("schema.pg"); + fs::write(&schema, "node Person { name: String @key }\n").unwrap(); + let output = output_failure( + cli() + .arg("graphs") + .arg("create") + .arg("--uri") + .arg("/tmp/local") + .arg("--graph-id") + .arg("alpha") + .arg("--graph-uri") + .arg("/tmp/alpha.omni") + .arg("--schema") + .arg(&schema), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("remote multi-graph server URL"), + "expected 'remote multi-graph server URL' rejection in stderr; got:\n{stderr}" + ); +} + +/// `omnigraph graphs create` with a missing `--schema` file errors +/// with the IO context. Catches a regression where the CLI silently +/// sent an empty body. +#[test] +fn graphs_create_with_missing_schema_file_errors() { + let output = output_failure( + cli() + .arg("graphs") + .arg("create") + .arg("--uri") + .arg("http://127.0.0.1:0") + .arg("--graph-id") + .arg("alpha") + .arg("--graph-uri") + .arg("/tmp/alpha.omni") + .arg("--schema") + .arg("/this/path/does/not/exist.pg"), + ); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + assert!( + stderr.contains("reading schema file"), + "expected 'reading schema file' error in stderr; got:\n{stderr}" + ); +} diff --git a/crates/omnigraph-cli/tests/system_remote.rs b/crates/omnigraph-cli/tests/system_remote.rs index 7131bec..f532564 100644 --- a/crates/omnigraph-cli/tests/system_remote.rs +++ b/crates/omnigraph-cli/tests/system_remote.rs @@ -888,3 +888,134 @@ query insert_person($name: String, $age: I32) { assert_eq!(verify["row_count"], 1); assert_eq!(verify["rows"][0]["p.name"], "PolicyRemote"); } + +// ─── MR-668 PR 8 — omnigraph graphs end-to-end ────────────────────────────── + +/// Multi-graph server + CLI `omnigraph graphs list` / `create` end-to-end. +/// +/// Steps: +/// 1. Init a graph `alpha` on disk and write an `omnigraph.yaml` +/// whose `graphs:` map references it. +/// 2. Spawn the server with `--config `. +/// 3. `omnigraph graphs list` — expect to see `alpha`. +/// 4. `omnigraph graphs create --graph-id beta --schema ...` — +/// expect 201 and stdout reflecting the new graph. +/// 5. `omnigraph graphs list` again — expect both `alpha` and `beta`. +/// +/// Ignored by default — spawning servers needs loopback socket +/// permissions some sandboxes lack. +#[test] +#[ignore = "requires loopback socket permissions in sandboxed runners"] +fn graphs_list_and_create_against_multi_graph_server() { + let cfg_dir = tempfile::tempdir().unwrap(); + let schema_path = fixture("test.pg"); + + // Init `alpha` on disk. + let alpha_uri = cfg_dir.path().join("alpha.omni"); + tokio::runtime::Runtime::new().unwrap().block_on(async { + Omnigraph::init( + alpha_uri.to_str().unwrap(), + &fs::read_to_string(&schema_path).unwrap(), + ) + .await + .unwrap(); + }); + + // Server config with `graphs:` map and no `server.graph` selector + // — multi mode (rule 4 of the inference matrix). + let server_config_path = cfg_dir.path().join("omnigraph.yaml"); + fs::write( + &server_config_path, + format!( + "\ +graphs: + alpha: + uri: {} +", + yaml_string(&alpha_uri.to_string_lossy()) + ), + ) + .unwrap(); + + let server = spawn_server_with_config(&server_config_path); + + // Client config — the CLI's `--target dev` resolves to `server.base_url`. + let client_config_path = cfg_dir.path().join("client.yaml"); + fs::write( + &client_config_path, + format!( + "\ +graphs: + dev: + uri: {} +cli: + graph: dev +", + yaml_string(&server.base_url) + ), + ) + .unwrap(); + + // 1. `graphs list` lists `alpha`. + let payload = parse_stdout_json(&output_success( + cli() + .arg("graphs") + .arg("list") + .arg("--config") + .arg(&client_config_path) + .arg("--json"), + )); + let initial_ids: Vec<&str> = payload["graphs"] + .as_array() + .unwrap() + .iter() + .map(|g| g["graph_id"].as_str().unwrap()) + .collect(); + assert_eq!(initial_ids, vec!["alpha"]); + + // 2. `graphs create` adds `beta`. + let beta_uri = cfg_dir.path().join("beta.omni"); + let created = parse_stdout_json(&output_success( + cli() + .arg("graphs") + .arg("create") + .arg("--config") + .arg(&client_config_path) + .arg("--graph-id") + .arg("beta") + .arg("--graph-uri") + .arg(beta_uri.to_str().unwrap()) + .arg("--schema") + .arg(&schema_path) + .arg("--json"), + )); + assert_eq!(created["graph_id"], "beta"); + + // 3. `graphs list` now lists both, sorted alphabetically. + let payload = parse_stdout_json(&output_success( + cli() + .arg("graphs") + .arg("list") + .arg("--config") + .arg(&client_config_path) + .arg("--json"), + )); + let final_ids: Vec<&str> = payload["graphs"] + .as_array() + .unwrap() + .iter() + .map(|g| g["graph_id"].as_str().unwrap()) + .collect(); + assert_eq!(final_ids, vec!["alpha", "beta"]); + + // 4. The new graph is reachable via its cluster snapshot route. + let client = Client::new(); + let snap_status = client + .get(format!("{}/graphs/beta/snapshot?branch=main", server.base_url)) + .send() + .unwrap() + .status(); + assert_eq!(snap_status.as_u16(), 200); + + drop(server); +}