mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-30 02:49:39 +02:00
feat(cli): no-default-graph errors list candidate graphs (RFC-011 D7) (#245)
When a server/cluster scope resolves with no --graph and no default_graph, the CLI auto-uses a sole graph (cluster) or errors listing the candidate graph ids (cluster catalog; multi-graph server via best-effort GET /graphs), never a silent pick. GraphClient::resolve becomes async; flat/single-graph servers and happy paths are unaffected.
This commit is contained in:
parent
b395757e21
commit
1bc0ea6b51
10 changed files with 262 additions and 62 deletions
|
|
@ -42,7 +42,7 @@ use crate::helpers::{
|
||||||
ResolvedCliGraph, apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri,
|
ResolvedCliGraph, apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri,
|
||||||
legacy_change_request_body, open_local_db_with_policy, query_params_from_json,
|
legacy_change_request_body, open_local_db_with_policy, query_params_from_json,
|
||||||
remote_json, remote_url, resolve_cli_actor, resolve_cli_graph, resolve_remote_bearer_token,
|
remote_json, remote_url, resolve_cli_actor, resolve_cli_graph, resolve_remote_bearer_token,
|
||||||
select_named_query,
|
resolve_server_flag, select_named_query,
|
||||||
};
|
};
|
||||||
use crate::output::{LoadOutput, load_output_from_result, load_output_from_tables};
|
use crate::output::{LoadOutput, load_output_from_result, load_output_from_tables};
|
||||||
use omnigraph_server::config::OmnigraphConfig;
|
use omnigraph_server::config::OmnigraphConfig;
|
||||||
|
|
@ -66,6 +66,44 @@ pub(crate) enum GraphClient {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RFC-011 Decision 7: a server scope that selects no graph (no `--graph`, no
|
||||||
|
/// `default_graph`) must not silently fall through to the bare server URL when
|
||||||
|
/// the server is multi-graph. Best-effort probe `GET /graphs`: a populated list
|
||||||
|
/// forces `--graph` (listing the candidates); a single-graph/flat server (405),
|
||||||
|
/// a policy-gated `/graphs`, or an unreachable server all proceed — the bare URL
|
||||||
|
/// is then correct, or the real request surfaces the failure. Only fires on the
|
||||||
|
/// no-graph path, so a `--graph`/`default_graph` happy path does no extra I/O.
|
||||||
|
async fn require_graph_for_multi_graph_server(
|
||||||
|
config: &OmnigraphConfig,
|
||||||
|
scope: &crate::scope::ResolvedScope,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (Some(server), None) = (scope.server.as_deref(), scope.graph.as_deref()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let Some(base) = resolve_server_flag(Some(server), None)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let token = resolve_remote_bearer_token(config, Some(&base))?;
|
||||||
|
let probe = GraphClient::Remote {
|
||||||
|
http: build_http_client()?,
|
||||||
|
base_url: base,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
if let Ok(resp) = probe.list_graphs().await {
|
||||||
|
if !resp.graphs.is_empty() {
|
||||||
|
let ids: Vec<&str> = resp.graphs.iter().map(|g| g.graph_id.as_str()).collect();
|
||||||
|
bail!(
|
||||||
|
"server scope '{server}' has {} {}: [{}]; pass --graph <id> to select one \
|
||||||
|
(or set `default_graph` in your operator config)",
|
||||||
|
ids.len(),
|
||||||
|
if ids.len() == 1 { "graph" } else { "graphs" },
|
||||||
|
ids.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// A remote graph must be addressed with `--server` (RFC-011): a positional or
|
/// A remote graph must be addressed with `--server` (RFC-011): a positional or
|
||||||
/// `--uri` `http(s)://` URL no longer auto-dispatches to a server. A remote URL
|
/// `--uri` `http(s)://` URL no longer auto-dispatches to a server. A remote URL
|
||||||
/// produced by a server scope (`via_server`) is fine.
|
/// produced by a server scope (`via_server`) is fine.
|
||||||
|
|
@ -86,7 +124,7 @@ impl GraphClient {
|
||||||
/// fork. Mirrors the read verbs' current preamble (`resolve_uri`
|
/// fork. Mirrors the read verbs' current preamble (`resolve_uri`
|
||||||
/// path, not the policy-bearing `resolve_cli_graph`). Used by reads
|
/// path, not the policy-bearing `resolve_cli_graph`). Used by reads
|
||||||
/// and `query` (which opens without policy, like the reads).
|
/// and `query` (which opens without policy, like the reads).
|
||||||
pub(crate) fn resolve(
|
pub(crate) async fn resolve(
|
||||||
config: &OmnigraphConfig,
|
config: &OmnigraphConfig,
|
||||||
server: Option<&str>,
|
server: Option<&str>,
|
||||||
graph: Option<&str>,
|
graph: Option<&str>,
|
||||||
|
|
@ -102,6 +140,7 @@ impl GraphClient {
|
||||||
crate::planes::Capability::Any,
|
crate::planes::Capability::Any,
|
||||||
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
||||||
)?;
|
)?;
|
||||||
|
require_graph_for_multi_graph_server(config, &scope).await?;
|
||||||
let (server, graph, uri) = (
|
let (server, graph, uri) = (
|
||||||
scope.server.as_deref(),
|
scope.server.as_deref(),
|
||||||
scope.graph.as_deref(),
|
scope.graph.as_deref(),
|
||||||
|
|
@ -133,7 +172,7 @@ impl GraphClient {
|
||||||
/// resolved up front. The embedded arm then opens WITH policy. The
|
/// resolved up front. The embedded arm then opens WITH policy. The
|
||||||
/// resolution order matches the write arms exactly: server flag →
|
/// resolution order matches the write arms exactly: server flag →
|
||||||
/// bearer token → graph.
|
/// bearer token → graph.
|
||||||
pub(crate) fn resolve_with_policy(
|
pub(crate) async fn resolve_with_policy(
|
||||||
config: &OmnigraphConfig,
|
config: &OmnigraphConfig,
|
||||||
server: Option<&str>,
|
server: Option<&str>,
|
||||||
graph: Option<&str>,
|
graph: Option<&str>,
|
||||||
|
|
@ -149,6 +188,7 @@ impl GraphClient {
|
||||||
crate::planes::Capability::Any,
|
crate::planes::Capability::Any,
|
||||||
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
||||||
)?;
|
)?;
|
||||||
|
require_graph_for_multi_graph_server(config, &scope).await?;
|
||||||
let (server, graph, uri) = (
|
let (server, graph, uri) = (
|
||||||
scope.server.as_deref(),
|
scope.server.as_deref(),
|
||||||
scope.graph.as_deref(),
|
scope.graph.as_deref(),
|
||||||
|
|
|
||||||
|
|
@ -632,11 +632,12 @@ pub(crate) async fn resolve_maintenance_uri(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a resolved direct address to a storage URI: a cluster scope
|
/// Map a resolved direct address to a storage URI: a cluster scope
|
||||||
/// (`--cluster <root> --graph <id>`, or a `--profile` cluster binding)
|
/// (`--cluster <root> --graph <id>`, or a `--profile` cluster binding) resolves
|
||||||
/// resolves the graph's storage URI from the **served cluster state** (the
|
/// the graph's storage URI from the **served cluster state**; otherwise the
|
||||||
/// truth a `--cluster` server serves); otherwise the ordinary positional-URI
|
/// ordinary positional-URI path. When a cluster scope carries no graph
|
||||||
/// path. The scope resolver guarantees a cluster scope always carries a graph,
|
/// selection (RFC-011 D7), enumerate the catalog: a sole graph is used
|
||||||
/// so the mismatched arm is defensive.
|
/// automatically, otherwise error and list the candidates so the operator can
|
||||||
|
/// pass `--graph <id>`.
|
||||||
pub(crate) async fn resolve_storage_uri(
|
pub(crate) async fn resolve_storage_uri(
|
||||||
config: &OmnigraphConfig,
|
config: &OmnigraphConfig,
|
||||||
cli_uri: Option<String>,
|
cli_uri: Option<String>,
|
||||||
|
|
@ -646,8 +647,32 @@ pub(crate) async fn resolve_storage_uri(
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
match (cluster, cluster_graph) {
|
match (cluster, cluster_graph) {
|
||||||
(Some(cluster), Some(graph_id)) => resolve_cluster_graph_uri(cluster, graph_id).await,
|
(Some(cluster), Some(graph_id)) => resolve_cluster_graph_uri(cluster, graph_id).await,
|
||||||
|
(Some(cluster), None) => {
|
||||||
|
let graph_id = resolve_sole_cluster_graph(cluster).await?;
|
||||||
|
resolve_cluster_graph_uri(cluster, &graph_id).await
|
||||||
|
}
|
||||||
(None, None) => resolve_local_uri(config, cli_uri, operation),
|
(None, None) => resolve_local_uri(config, cli_uri, operation),
|
||||||
_ => bail!("internal error: a cluster scope was resolved without a graph id"),
|
(None, Some(_)) => {
|
||||||
|
bail!("internal error: a graph was selected without a cluster scope")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the graph for a cluster scope that has no `--graph`/`default_graph`
|
||||||
|
/// (RFC-011 D7): exactly one applied graph → use it; zero → error; more than
|
||||||
|
/// one → error and list the candidates. Never auto-picks among several.
|
||||||
|
async fn resolve_sole_cluster_graph(cluster: &str) -> Result<String> {
|
||||||
|
let ids = omnigraph_cluster::cluster_graph_ids(cluster)
|
||||||
|
.await
|
||||||
|
.map_err(|diagnostic| color_eyre::eyre::eyre!("{}", diagnostic.message))?;
|
||||||
|
match ids.as_slice() {
|
||||||
|
[only] => Ok(only.clone()),
|
||||||
|
[] => bail!("cluster `{cluster}` has no applied graphs; run `cluster apply` first"),
|
||||||
|
many => bail!(
|
||||||
|
"cluster `{cluster}` has {} graphs: [{}]; pass --graph <id> to select one",
|
||||||
|
many.len(),
|
||||||
|
many.join(", ")
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,8 @@ async fn main() -> Result<()> {
|
||||||
cli.as_actor.as_deref(),
|
cli.as_actor.as_deref(),
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let branch = resolve_branch(&config, branch, None, "main");
|
let branch = resolve_branch(&config, branch, None, "main");
|
||||||
if matches!(mode, CliLoadMode::Overwrite) {
|
if matches!(mode, CliLoadMode::Overwrite) {
|
||||||
confirm_destructive("load --mode overwrite", client.uri(), cli.yes, json)?;
|
confirm_destructive("load --mode overwrite", client.uri(), cli.yes, json)?;
|
||||||
|
|
@ -224,7 +225,8 @@ async fn main() -> Result<()> {
|
||||||
cli.as_actor.as_deref(),
|
cli.as_actor.as_deref(),
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let branch = resolve_branch(&config, branch, None, "main");
|
let branch = resolve_branch(&config, branch, None, "main");
|
||||||
let from = resolve_branch(&config, from, None, "main");
|
let from = resolve_branch(&config, from, None, "main");
|
||||||
echo_write_target(cli.quiet, "ingest", client.uri(), client.is_remote());
|
echo_write_target(cli.quiet, "ingest", client.uri(), client.is_remote());
|
||||||
|
|
@ -254,7 +256,8 @@ async fn main() -> Result<()> {
|
||||||
cli.as_actor.as_deref(),
|
cli.as_actor.as_deref(),
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let from = resolve_branch(&config, from, None, "main");
|
let from = resolve_branch(&config, from, None, "main");
|
||||||
echo_write_target(cli.quiet, "branch create", client.uri(), client.is_remote());
|
echo_write_target(cli.quiet, "branch create", client.uri(), client.is_remote());
|
||||||
let payload = client.branch_create_from(&from, &name).await?;
|
let payload = client.branch_create_from(&from, &name).await?;
|
||||||
|
|
@ -277,7 +280,8 @@ async fn main() -> Result<()> {
|
||||||
uri,
|
uri,
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let payload = client.branch_list().await?;
|
let payload = client.branch_list().await?;
|
||||||
if json {
|
if json {
|
||||||
print_json(&payload)?;
|
print_json(&payload)?;
|
||||||
|
|
@ -302,7 +306,8 @@ async fn main() -> Result<()> {
|
||||||
cli.as_actor.as_deref(),
|
cli.as_actor.as_deref(),
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
confirm_destructive("branch delete", client.uri(), cli.yes, json)?;
|
confirm_destructive("branch delete", client.uri(), cli.yes, json)?;
|
||||||
echo_write_target(cli.quiet, "branch delete", client.uri(), client.is_remote());
|
echo_write_target(cli.quiet, "branch delete", client.uri(), client.is_remote());
|
||||||
let payload = client.branch_delete(&name).await?;
|
let payload = client.branch_delete(&name).await?;
|
||||||
|
|
@ -328,7 +333,8 @@ async fn main() -> Result<()> {
|
||||||
cli.as_actor.as_deref(),
|
cli.as_actor.as_deref(),
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let into = resolve_branch(&config, into, None, "main");
|
let into = resolve_branch(&config, into, None, "main");
|
||||||
echo_write_target(cli.quiet, "branch merge", client.uri(), client.is_remote());
|
echo_write_target(cli.quiet, "branch merge", client.uri(), client.is_remote());
|
||||||
let payload = client.branch_merge(&source, &into).await?;
|
let payload = client.branch_merge(&source, &into).await?;
|
||||||
|
|
@ -359,7 +365,8 @@ async fn main() -> Result<()> {
|
||||||
uri,
|
uri,
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let payload = client.list_commits(branch.as_deref()).await?;
|
let payload = client.list_commits(branch.as_deref()).await?;
|
||||||
if json {
|
if json {
|
||||||
print_json(&payload)?;
|
print_json(&payload)?;
|
||||||
|
|
@ -381,7 +388,8 @@ async fn main() -> Result<()> {
|
||||||
uri,
|
uri,
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let commit = client.get_commit(&commit_id).await?;
|
let commit = client.get_commit(&commit_id).await?;
|
||||||
if json {
|
if json {
|
||||||
print_json(&commit)?;
|
print_json(&commit)?;
|
||||||
|
|
@ -436,7 +444,8 @@ async fn main() -> Result<()> {
|
||||||
cli.as_actor.as_deref(),
|
cli.as_actor.as_deref(),
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let schema_source = fs::read_to_string(&schema)?;
|
let schema_source = fs::read_to_string(&schema)?;
|
||||||
// The stored-query registry check is an embedded-only concern
|
// The stored-query registry check is an embedded-only concern
|
||||||
// (the remote arm ignores the validator — the server runs its
|
// (the remote arm ignores the validator — the server runs its
|
||||||
|
|
@ -477,7 +486,8 @@ async fn main() -> Result<()> {
|
||||||
uri,
|
uri,
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let output = client.schema_source().await?;
|
let output = client.schema_source().await?;
|
||||||
if json {
|
if json {
|
||||||
print_json(&output)?;
|
print_json(&output)?;
|
||||||
|
|
@ -528,7 +538,8 @@ async fn main() -> Result<()> {
|
||||||
uri,
|
uri,
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let branch = resolve_branch(&config, branch, None, "main");
|
let branch = resolve_branch(&config, branch, None, "main");
|
||||||
let payload = client.snapshot(&branch).await?;
|
let payload = client.snapshot(&branch).await?;
|
||||||
if json {
|
if json {
|
||||||
|
|
@ -553,7 +564,8 @@ async fn main() -> Result<()> {
|
||||||
uri,
|
uri,
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let branch = resolve_branch(&config, branch, None, "main");
|
let branch = resolve_branch(&config, branch, None, "main");
|
||||||
if jsonl {
|
if jsonl {
|
||||||
eprintln!("warning: --jsonl is deprecated; `omnigraph export` always emits JSONL");
|
eprintln!("warning: --jsonl is deprecated; `omnigraph export` always emits JSONL");
|
||||||
|
|
@ -590,7 +602,8 @@ async fn main() -> Result<()> {
|
||||||
uri.or(legacy_uri),
|
uri.or(legacy_uri),
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let query_source =
|
let query_source =
|
||||||
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
|
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
|
||||||
let params_json = load_params_json(¶ms)?;
|
let params_json = load_params_json(¶ms)?;
|
||||||
|
|
@ -625,7 +638,8 @@ async fn main() -> Result<()> {
|
||||||
cli.as_actor.as_deref(),
|
cli.as_actor.as_deref(),
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let query_source =
|
let query_source =
|
||||||
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
|
resolve_query_source(&config, query.as_ref(), query_string.as_deref(), None)?;
|
||||||
let params_json = load_params_json(¶ms)?;
|
let params_json = load_params_json(¶ms)?;
|
||||||
|
|
@ -1005,7 +1019,8 @@ async fn main() -> Result<()> {
|
||||||
uri,
|
uri,
|
||||||
cli.profile.as_deref(),
|
cli.profile.as_deref(),
|
||||||
cli.store.as_deref(),
|
cli.store.as_deref(),
|
||||||
)?;
|
)
|
||||||
|
.await?;
|
||||||
let payload = client.list_graphs().await?;
|
let payload = client.list_graphs().await?;
|
||||||
if json {
|
if json {
|
||||||
print_json(&payload)?;
|
print_json(&payload)?;
|
||||||
|
|
|
||||||
|
|
@ -189,15 +189,13 @@ fn scope_from_binding(
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.unwrap_or(cluster);
|
.unwrap_or(cluster);
|
||||||
// A cluster holds many graphs; maintenance addresses one at a time.
|
// A cluster holds many graphs; maintenance addresses one at a time.
|
||||||
let Some(graph) = graph else {
|
// When no `--graph`/`default_graph` is given, leave `cluster_graph`
|
||||||
bail!(
|
// empty and defer to the async storage-URI resolver (RFC-011 D7),
|
||||||
"{source} resolves a cluster scope; pass --graph <id> to select which \
|
// which enumerates the catalog: auto-use a sole graph, else error
|
||||||
graph to maintain"
|
// and list the candidates.
|
||||||
);
|
|
||||||
};
|
|
||||||
Ok(ResolvedScope {
|
Ok(ResolvedScope {
|
||||||
cluster: Some(root),
|
cluster: Some(root),
|
||||||
cluster_graph: Some(graph),
|
cluster_graph: graph,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -334,9 +332,13 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cluster_scope_without_a_graph_is_a_loud_error() {
|
fn cluster_scope_without_a_graph_defers_to_catalog_enumeration() {
|
||||||
|
// RFC-011 D7: with no `--graph`/`default_graph`, resolution no longer
|
||||||
|
// bails here — it resolves the cluster root and leaves `cluster_graph`
|
||||||
|
// empty, deferring to the async storage-URI resolver (which enumerates
|
||||||
|
// the catalog: auto-use a sole graph, else error listing candidates).
|
||||||
let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n");
|
let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n");
|
||||||
let err = resolve_scope(
|
let scope = resolve_scope(
|
||||||
&op,
|
&op,
|
||||||
Capability::Direct,
|
Capability::Direct,
|
||||||
ScopeFlags {
|
ScopeFlags {
|
||||||
|
|
@ -344,9 +346,9 @@ mod tests {
|
||||||
..flags()
|
..flags()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap_err()
|
.unwrap();
|
||||||
.to_string();
|
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||||
assert!(err.contains("--graph <id>"), "{err}");
|
assert_eq!(scope.cluster_graph, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1006,21 +1006,80 @@ fn optimize_unknown_cluster_graph_id_errors() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cluster_without_graph_demands_a_graph_selector() {
|
fn optimize_auto_uses_the_sole_cluster_graph() {
|
||||||
// A cluster holds many graphs; `--cluster` alone can't pick one. The scope
|
// RFC-011 D7: a cluster with exactly one applied graph needs no --graph —
|
||||||
// resolver demands `--graph <id>` (replacing the old `--cluster-graph`
|
// the resolver enumerates the catalog and uses the only candidate.
|
||||||
// requirement) before it ever touches cluster state.
|
let temp = applied_knowledge_cluster();
|
||||||
|
let out = output_success(
|
||||||
|
cli()
|
||||||
|
.arg("optimize")
|
||||||
|
.arg("--cluster")
|
||||||
|
.arg(temp.path())
|
||||||
|
.arg("--json"),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parse_stdout_json(&out)["tables"].as_array().is_some(),
|
||||||
|
"optimize should auto-resolve the sole cluster graph"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stand up an applied cluster with two graphs (`knowledge`, `archive`).
|
||||||
|
fn applied_two_graph_cluster() -> tempfile::TempDir {
|
||||||
|
let temp = tempdir().unwrap();
|
||||||
|
let root = temp.path();
|
||||||
|
fs::write(
|
||||||
|
root.join("people.pg"),
|
||||||
|
"node Person {\n name: String @key\n age: I32?\n}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(root.join("base.policy.yaml"), "rules: []\n").unwrap();
|
||||||
|
fs::write(
|
||||||
|
root.join("cluster.yaml"),
|
||||||
|
r#"
|
||||||
|
version: 1
|
||||||
|
metadata:
|
||||||
|
name: two-graph
|
||||||
|
state:
|
||||||
|
backend: cluster
|
||||||
|
lock: true
|
||||||
|
graphs:
|
||||||
|
knowledge:
|
||||||
|
schema: ./people.pg
|
||||||
|
archive:
|
||||||
|
schema: ./people.pg
|
||||||
|
policies:
|
||||||
|
base:
|
||||||
|
file: ./base.policy.yaml
|
||||||
|
applies_to: [knowledge, archive]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
init_named_cluster_graph(root, "knowledge", "people.pg");
|
||||||
|
init_named_cluster_graph(root, "archive", "people.pg");
|
||||||
|
assert_eq!(cluster_json(root, "import")["ok"], true);
|
||||||
|
assert_eq!(cluster_json(root, "apply")["converged"], true);
|
||||||
|
temp
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn optimize_on_multi_graph_cluster_without_graph_lists_candidates() {
|
||||||
|
// RFC-011 D7: >1 graph and no --graph → error naming every candidate,
|
||||||
|
// never an auto-pick.
|
||||||
|
let temp = applied_two_graph_cluster();
|
||||||
let out = output_failure(
|
let out = output_failure(
|
||||||
cli()
|
cli()
|
||||||
.arg("optimize")
|
.arg("optimize")
|
||||||
.arg("--cluster")
|
.arg("--cluster")
|
||||||
.arg(".")
|
.arg(temp.path())
|
||||||
.arg("--json"),
|
.arg("--json"),
|
||||||
);
|
);
|
||||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
assert!(
|
assert!(
|
||||||
stderr.contains("--graph <id>"),
|
stderr.contains("2 graphs")
|
||||||
"expected --cluster to demand --graph; got: {stderr}"
|
&& stderr.contains("archive")
|
||||||
|
&& stderr.contains("knowledge")
|
||||||
|
&& stderr.contains("--graph <id>"),
|
||||||
|
"expected a candidate-listing error; got: {stderr}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1136,5 +1136,27 @@ auth:
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(ids, vec!["alpha"]);
|
assert_eq!(ids, vec!["alpha"]);
|
||||||
|
|
||||||
|
// RFC-011 D7: addressing the multi-graph server via `--server <url>` with no
|
||||||
|
// `--graph` errors and lists the candidate graphs (the resolver probes
|
||||||
|
// GET /graphs; the default-env token authorizes it).
|
||||||
|
let no_graph = cli()
|
||||||
|
.env("OMNIGRAPH_BEARER_TOKEN", "admin-token")
|
||||||
|
.arg("query")
|
||||||
|
.arg("--server")
|
||||||
|
.arg(&server.base_url)
|
||||||
|
.arg("-e")
|
||||||
|
.arg("query q { match { $p: Person { name: \"x\" } } return { $p.name } }")
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!no_graph.status.success(),
|
||||||
|
"multi-graph server with no --graph must error"
|
||||||
|
);
|
||||||
|
let stderr = String::from_utf8_lossy(&no_graph.stderr);
|
||||||
|
assert!(
|
||||||
|
stderr.contains("alpha") && stderr.contains("--graph <id>"),
|
||||||
|
"expected a candidate-listing error naming alpha; got: {stderr}"
|
||||||
|
);
|
||||||
|
|
||||||
drop(server);
|
drop(server);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ mod store;
|
||||||
use store::{ClusterStore, StateLockGuard, StateSnapshot};
|
use store::{ClusterStore, StateLockGuard, StateSnapshot};
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
use types::*;
|
use types::*;
|
||||||
pub use serve::{ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, cluster_root_for_graph_uri, read_serving_snapshot, read_serving_snapshot_from_storage, resolve_graph_storage_uri};
|
pub use serve::{ServingGraph, ServingPolicy, ServingQuery, ServingSnapshot, cluster_graph_ids, cluster_root_for_graph_uri, read_serving_snapshot, read_serving_snapshot_from_storage, resolve_graph_storage_uri};
|
||||||
use config::{QueriesDecl, observe_declared_graphs, validate_cluster_header, future_field_diagnostics, initial_import_state, observe_live_graph, preview_schema_migration, state_resource_digests, graph_address, policy_address, query_address, schema_address, load_desired, normalize_policy_target, parse_cluster_config, resolve_config_path, resolve_query_decls, validate_id, validate_query_source};
|
use config::{QueriesDecl, observe_declared_graphs, validate_cluster_header, future_field_diagnostics, initial_import_state, observe_live_graph, preview_schema_migration, state_resource_digests, graph_address, policy_address, query_address, schema_address, load_desired, normalize_policy_target, parse_cluster_config, resolve_config_path, resolve_query_decls, validate_id, validate_query_source};
|
||||||
use diff::{FailedGraphOrigin, ResourceKind, append_policy_binding_changes, approved_resources, classify_changes, compute_approvals, compute_blast_radius, demote_dependents_of_failed_graphs, diff_resources, resource_kind};
|
use diff::{FailedGraphOrigin, ResourceKind, append_policy_binding_changes, approved_resources, classify_changes, compute_approvals, compute_blast_radius, demote_dependents_of_failed_graphs, diff_resources, resource_kind};
|
||||||
use sweep::{mark_approvals_consumed, record_approval_consumed, sweep_recovery_sidecars, tombstone_graph_subtree, warn_pending_recovery_sidecars};
|
use sweep::{mark_approvals_consumed, record_approval_consumed, sweep_recovery_sidecars, tombstone_graph_subtree, warn_pending_recovery_sidecars};
|
||||||
|
|
|
||||||
|
|
@ -112,28 +112,13 @@ pub async fn cluster_root_for_graph_uri(graph_uri: &str) -> Option<String> {
|
||||||
/// `cluster` is a config directory or a storage-root URI (`s3://…`, config-free),
|
/// `cluster` is a config directory or a storage-root URI (`s3://…`, config-free),
|
||||||
/// mirroring the server's `--cluster` dispatch.
|
/// mirroring the server's `--cluster` dispatch.
|
||||||
pub async fn resolve_graph_storage_uri(cluster: &str, graph_id: &str) -> Result<String, Diagnostic> {
|
pub async fn resolve_graph_storage_uri(cluster: &str, graph_id: &str) -> Result<String, Diagnostic> {
|
||||||
let backend = if cluster.contains("://") {
|
let backend = open_cluster_backend(cluster)?;
|
||||||
ClusterStore::for_storage_root(cluster)?
|
|
||||||
} else {
|
|
||||||
ClusterStore::for_config_dir(Path::new(cluster))
|
|
||||||
};
|
|
||||||
let mut observations = backend.observations();
|
let mut observations = backend.observations();
|
||||||
let snapshot = backend.read_state(&mut observations).await?;
|
let snapshot = backend.read_state(&mut observations).await?;
|
||||||
let state = snapshot.state.ok_or_else(|| {
|
let state = snapshot.state.ok_or_else(|| missing_state_diagnostic(cluster))?;
|
||||||
Diagnostic::error(
|
|
||||||
"cluster_state_missing",
|
|
||||||
CLUSTER_STATE_FILE,
|
|
||||||
format!("cluster `{cluster}` has no applied state; run `cluster apply` first"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let address = format!("graph.{graph_id}");
|
let address = format!("graph.{graph_id}");
|
||||||
if !state.applied_revision.resources.contains_key(&address) {
|
if !state.applied_revision.resources.contains_key(&address) {
|
||||||
let applied: Vec<&str> = state
|
let applied = applied_graph_ids(&state);
|
||||||
.applied_revision
|
|
||||||
.resources
|
|
||||||
.keys()
|
|
||||||
.filter_map(|a| a.strip_prefix("graph."))
|
|
||||||
.collect();
|
|
||||||
return Err(Diagnostic::error(
|
return Err(Diagnostic::error(
|
||||||
"graph_not_applied",
|
"graph_not_applied",
|
||||||
address,
|
address,
|
||||||
|
|
@ -147,6 +132,46 @@ pub async fn resolve_graph_storage_uri(cluster: &str, graph_id: &str) -> Result<
|
||||||
Ok(backend.graph_root(graph_id))
|
Ok(backend.graph_root(graph_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List the graph ids applied in a cluster's served state (sorted). Reads the
|
||||||
|
/// ledger only — no catalog validation — like `resolve_graph_storage_uri`, so
|
||||||
|
/// it works on a degraded cluster. Used to enumerate candidates when no
|
||||||
|
/// `--graph` is selected (RFC-011 Decision 7).
|
||||||
|
pub async fn cluster_graph_ids(cluster: &str) -> Result<Vec<String>, Diagnostic> {
|
||||||
|
let backend = open_cluster_backend(cluster)?;
|
||||||
|
let mut observations = backend.observations();
|
||||||
|
let snapshot = backend.read_state(&mut observations).await?;
|
||||||
|
let state = snapshot.state.ok_or_else(|| missing_state_diagnostic(cluster))?;
|
||||||
|
Ok(applied_graph_ids(&state))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_cluster_backend(cluster: &str) -> Result<ClusterStore, Diagnostic> {
|
||||||
|
if cluster.contains("://") {
|
||||||
|
ClusterStore::for_storage_root(cluster)
|
||||||
|
} else {
|
||||||
|
Ok(ClusterStore::for_config_dir(Path::new(cluster)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missing_state_diagnostic(cluster: &str) -> Diagnostic {
|
||||||
|
Diagnostic::error(
|
||||||
|
"cluster_state_missing",
|
||||||
|
CLUSTER_STATE_FILE,
|
||||||
|
format!("cluster `{cluster}` has no applied state; run `cluster apply` first"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn applied_graph_ids(state: &crate::types::ClusterState) -> Vec<String> {
|
||||||
|
let mut ids: Vec<String> = state
|
||||||
|
.applied_revision
|
||||||
|
.resources
|
||||||
|
.keys()
|
||||||
|
.filter_map(|a| a.strip_prefix("graph."))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect();
|
||||||
|
ids.sort();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
/// Split `<root>/graphs/<id>.omni` → `<root>`, gating on the exact cluster
|
/// Split `<root>/graphs/<id>.omni` → `<root>`, gating on the exact cluster
|
||||||
/// graph-layout shape (a single `<id>` segment, no nested path). `None` for
|
/// graph-layout shape (a single `<id>` segment, no nested path). `None` for
|
||||||
/// anything else — no I/O is done for non-cluster-shaped URIs.
|
/// anything else — no I/O is done for non-cluster-shaped URIs.
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,14 @@ resolves its scope fresh, there is no sticky "current" mode.
|
||||||
`--cluster <root> --graph <id>`. A `--graph` flag overrides the profile's default.
|
`--cluster <root> --graph <id>`. A `--graph` flag overrides the profile's default.
|
||||||
- A `server`-bound scope on a maintenance verb, or a `cluster`-bound scope on a
|
- A `server`-bound scope on a maintenance verb, or a `cluster`-bound scope on a
|
||||||
data verb, is rejected with a message pointing at the right addressing.
|
data verb, is rejected with a message pointing at the right addressing.
|
||||||
|
- **No graph selected (RFC-011 D7).** When a scope has no `--graph` and no
|
||||||
|
`default_graph`, the CLI never silently picks:
|
||||||
|
- **Cluster scope** — exactly **one** applied graph is used automatically;
|
||||||
|
**several** errors and lists the candidates (from the served catalog).
|
||||||
|
- **Server scope** — a multi-graph server (any non-empty `GET /graphs`, even a
|
||||||
|
single entry) errors and lists the candidates: you must pass `--graph <id>`.
|
||||||
|
A single-graph / flat server (405 on `/graphs`), or one whose `/graphs` is
|
||||||
|
policy-gated or unreachable, uses its bare URL as before.
|
||||||
|
|
||||||
`--target`, `--cluster-graph`, and the positional-`http(s)://`→remote dispatch
|
`--target`, `--cluster-graph`, and the positional-`http(s)://`→remote dispatch
|
||||||
have been **removed** (`--graph` is now the one graph selector across server and
|
have been **removed** (`--graph` is now the one graph selector across server and
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,10 @@ not resolvable. Run these from a host with storage access — there are no serve
|
||||||
routes for them. Conversely, **`init` refuses** a cluster-managed path: graphs in
|
routes for them. Conversely, **`init` refuses** a cluster-managed path: graphs in
|
||||||
a cluster are created by `cluster apply`, not by hand.
|
a cluster are created by `cluster apply`, not by hand.
|
||||||
|
|
||||||
|
If the cluster has exactly **one** applied graph you can omit `--graph` — it is
|
||||||
|
used automatically. With **several**, omitting `--graph` errors and lists the
|
||||||
|
candidates (RFC-011 D7); it never picks one for you.
|
||||||
|
|
||||||
Against an **`s3://`-backed cluster** the resolved graph storage is non-local, so a
|
Against an **`s3://`-backed cluster** the resolved graph storage is non-local, so a
|
||||||
destructive `cleanup` additionally requires **`--yes`** (an interactive prompt
|
destructive `cleanup` additionally requires **`--yes`** (an interactive prompt
|
||||||
otherwise, refusal without a TTY) on top of `--confirm` — see [cli-reference.md](../cli/reference.md)'s
|
otherwise, refusal without a TTY) on top of `--confirm` — see [cli-reference.md](../cli/reference.md)'s
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue