mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-21 02:28:07 +02:00
Merge remote-tracking branch 'origin/main' into ragnorc/shaping-config-integration
# Conflicts: # crates/omnigraph-cluster/src/lib.rs # crates/omnigraph-cluster/src/serve.rs # crates/omnigraph-server/src/lib.rs # crates/omnigraph-server/src/settings.rs # docs/user/clusters/config.md
This commit is contained in:
commit
4f8c71fa23
75 changed files with 6557 additions and 6879 deletions
|
|
@ -18,10 +18,10 @@ any — run against a graph, served (--server / --profile) or embedded (--store
|
|||
URI): query, mutate, load, branch, snapshot, export, commit, schema show/apply.\n \
|
||||
served — require a server: graphs.\n \
|
||||
direct — direct storage access; reject --server (init, optimize, repair, cleanup, \
|
||||
schema plan, lint, queries validate).\n \
|
||||
control — manage a cluster via --config: cluster.\n \
|
||||
local — no graph; local config & tooling: policy, embed, login, logout, config, \
|
||||
version, queries list.\n\
|
||||
schema plan, lint).\n \
|
||||
control — manage or inspect a cluster (cluster via --config; policy & queries via \
|
||||
--cluster).\n \
|
||||
local — no explicit graph scope; local config & tooling: alias, embed, login, logout, profile, version.\n\
|
||||
See the 'Command capabilities' section of the CLI reference for which flags apply where.")]
|
||||
pub(crate) struct Cli {
|
||||
/// Actor id for direct-engine writes; overrides `cli.actor`. No effect on
|
||||
|
|
@ -37,9 +37,11 @@ pub(crate) struct Cli {
|
|||
#[arg(long, global = true, value_name = "NAME|URL")]
|
||||
pub(crate) server: Option<String>,
|
||||
|
||||
/// Graph id on a multi-graph `--server` (appends `/graphs/<id>` to
|
||||
/// the server url). Requires --server.
|
||||
#[arg(long, global = true, value_name = "GRAPH_ID", requires = "server")]
|
||||
/// Select a graph within a multi-graph scope: on a `--server` it appends
|
||||
/// `/graphs/<id>` to the server url; on a `--cluster` it picks which
|
||||
/// cluster graph to maintain. Rejected on a single-graph address (a
|
||||
/// positional URI / `--store`).
|
||||
#[arg(long, global = true, value_name = "GRAPH_ID")]
|
||||
pub(crate) graph: Option<String>,
|
||||
|
||||
/// Select a named scope bundle (RFC-011) from `profiles:` in
|
||||
|
|
@ -56,6 +58,26 @@ pub(crate) struct Cli {
|
|||
#[arg(long, global = true, value_name = "URI")]
|
||||
pub(crate) store: Option<String>,
|
||||
|
||||
/// Address a cluster-managed graph's storage for maintenance (RFC-011):
|
||||
/// a cluster directory or storage-root URI — named via `clusters:` in
|
||||
/// ~/.omnigraph/config.yaml, or a literal `file://`/`s3://` root. Pair
|
||||
/// with `--graph <id>` to select the graph. Used by optimize / repair /
|
||||
/// cleanup; exclusive with a positional URI / `--store` / `--server`.
|
||||
#[arg(long, global = true, value_name = "DIR|URI")]
|
||||
pub(crate) cluster: Option<String>,
|
||||
|
||||
/// Skip the confirmation prompt for a destructive write (`cleanup`,
|
||||
/// overwrite `load`, `branch delete`) against a non-local scope (RFC-011
|
||||
/// Decision 9). Without it, a non-local destructive write prompts on a TTY
|
||||
/// and refuses (errors) when there is no TTY or `--json` is set.
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) yes: bool,
|
||||
|
||||
/// Suppress the one-line resolved-write-target diagnostic that write
|
||||
/// commands echo to stderr (RFC-011 Decision 9).
|
||||
#[arg(long, global = true)]
|
||||
pub(crate) quiet: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
|
@ -70,22 +92,16 @@ pub(crate) enum Command {
|
|||
/// when used. Pairs with `omnigraph mutate` on the write side.
|
||||
#[command(visible_alias = "read")]
|
||||
Query {
|
||||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(hide = true)]
|
||||
legacy_uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long, conflicts_with_all = ["query", "query_string"])]
|
||||
alias: Option<String>,
|
||||
#[arg(long, conflicts_with_all = ["alias", "query_string"])]
|
||||
query: Option<PathBuf>,
|
||||
/// Inline GQ source — alternative to `--query <path>` and `--alias <name>`.
|
||||
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])]
|
||||
query_string: Option<String>,
|
||||
#[arg(long)]
|
||||
/// Query name. With no `--query`/`-e`, the stored query to invoke from
|
||||
/// the catalog (served — addressed via --server/--profile). With
|
||||
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
|
||||
name: Option<String>,
|
||||
/// Ad-hoc query file (a `.gq` you're authoring / break-glass).
|
||||
#[arg(long, conflicts_with = "query_string")]
|
||||
query: Option<PathBuf>,
|
||||
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
|
||||
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
|
||||
query_string: Option<String>,
|
||||
#[command(flatten)]
|
||||
params: ParamsArgs,
|
||||
#[arg(long, conflicts_with = "snapshot")]
|
||||
|
|
@ -96,8 +112,6 @@ pub(crate) enum Command {
|
|||
format: Option<ReadOutputFormat>,
|
||||
#[arg(long, conflicts_with = "format")]
|
||||
json: bool,
|
||||
#[arg()]
|
||||
alias_args: Vec<String>,
|
||||
},
|
||||
/// Execute a graph mutation query against a branch.
|
||||
///
|
||||
|
|
@ -106,38 +120,48 @@ pub(crate) enum Command {
|
|||
/// warning when used. Pairs with `omnigraph query` on the read side.
|
||||
#[command(visible_alias = "change")]
|
||||
Mutate {
|
||||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(hide = true)]
|
||||
legacy_uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long, conflicts_with_all = ["query", "query_string"])]
|
||||
alias: Option<String>,
|
||||
#[arg(long, conflicts_with_all = ["alias", "query_string"])]
|
||||
query: Option<PathBuf>,
|
||||
/// Inline GQ source — alternative to `--query <path>` and `--alias <name>`.
|
||||
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with_all = ["query", "alias"])]
|
||||
query_string: Option<String>,
|
||||
#[arg(long)]
|
||||
/// Query name. With no `--query`/`-e`, the stored mutation to invoke
|
||||
/// from the catalog (served — addressed via --server/--profile). With
|
||||
/// `--query`/`-e`, selects which query in that ad-hoc source to run.
|
||||
name: Option<String>,
|
||||
/// Ad-hoc mutation file (a `.gq` you're authoring / break-glass).
|
||||
#[arg(long, conflicts_with = "query_string")]
|
||||
query: Option<PathBuf>,
|
||||
/// Inline ad-hoc GQ source — alternative to `--query <path>`.
|
||||
#[arg(short = 'e', long = "query-string", value_name = "GQ", conflicts_with = "query")]
|
||||
query_string: Option<String>,
|
||||
#[command(flatten)]
|
||||
params: ParamsArgs,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
#[arg()]
|
||||
alias_args: Vec<String>,
|
||||
},
|
||||
/// Invoke an operator alias (RFC-011 Decision 4).
|
||||
///
|
||||
/// An alias is a personal binding under `aliases:` in
|
||||
/// ~/.omnigraph/config.yaml — name → (server, graph, stored-query name,
|
||||
/// default params). `omnigraph alias <name> [args]` invokes the bound
|
||||
/// stored query on its server. Living in its own namespace, an alias can
|
||||
/// never shadow or be shadowed by a built-in verb. Replaces the removed
|
||||
/// `--alias` flag on `query`/`mutate`.
|
||||
Alias {
|
||||
/// Alias name (a key under `aliases:` in ~/.omnigraph/config.yaml).
|
||||
name: String,
|
||||
/// Positional args bound to the alias's declared `args` params, in order.
|
||||
args: Vec<String>,
|
||||
#[command(flatten)]
|
||||
params: ParamsArgs,
|
||||
#[arg(long, conflicts_with = "json")]
|
||||
format: Option<ReadOutputFormat>,
|
||||
#[arg(long, conflicts_with = "format")]
|
||||
json: bool,
|
||||
},
|
||||
/// Load data into a graph (local or remote)
|
||||
Load {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
data: PathBuf,
|
||||
/// Target branch (defaults to main). Without --from it must exist.
|
||||
#[arg(long)]
|
||||
|
|
@ -159,8 +183,6 @@ pub(crate) enum Command {
|
|||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
data: PathBuf,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
|
|
@ -181,8 +203,6 @@ pub(crate) enum Command {
|
|||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
|
@ -192,8 +212,6 @@ pub(crate) enum Command {
|
|||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long, hide = true)]
|
||||
jsonl: bool,
|
||||
|
|
@ -238,30 +256,12 @@ pub(crate) enum Command {
|
|||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
/// Cluster directory or storage-root URI; with --cluster-graph, resolves
|
||||
/// the graph's storage URI from the served cluster state.
|
||||
#[arg(long, conflicts_with = "uri", requires = "cluster_graph")]
|
||||
cluster: Option<String>,
|
||||
/// Graph id within --cluster.
|
||||
#[arg(long, requires = "cluster")]
|
||||
cluster_graph: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Classify and explicitly repair manifest/head drift
|
||||
Repair {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
/// Cluster directory or storage-root URI; with --cluster-graph, resolves
|
||||
/// the graph's storage URI from the served cluster state.
|
||||
#[arg(long, conflicts_with = "uri", requires = "cluster_graph")]
|
||||
cluster: Option<String>,
|
||||
/// Graph id within --cluster.
|
||||
#[arg(long, requires = "cluster")]
|
||||
cluster_graph: Option<String>,
|
||||
/// Publish verified maintenance drift. Without this flag, repair only
|
||||
/// previews what it would do.
|
||||
#[arg(long)]
|
||||
|
|
@ -277,15 +277,6 @@ pub(crate) enum Command {
|
|||
Cleanup {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
/// Cluster directory or storage-root URI; with --cluster-graph, resolves
|
||||
/// the graph's storage URI from the served cluster state.
|
||||
#[arg(long, conflicts_with = "uri", requires = "cluster_graph")]
|
||||
cluster: Option<String>,
|
||||
/// Graph id within --cluster.
|
||||
#[arg(long, requires = "cluster")]
|
||||
cluster_graph: Option<String>,
|
||||
/// Number of recent versions to keep per table. Either `--keep` or
|
||||
/// `--older-than` (or both) must be set.
|
||||
#[arg(long)]
|
||||
|
|
@ -315,8 +306,6 @@ pub(crate) enum Command {
|
|||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
query: PathBuf,
|
||||
#[arg(long)]
|
||||
schema: Option<PathBuf>,
|
||||
|
|
@ -336,8 +325,7 @@ pub(crate) enum Command {
|
|||
command: ClusterCommand,
|
||||
},
|
||||
|
||||
// ── Session / config ── no graph addressing; local tooling.
|
||||
/// Policy administration and diagnostics
|
||||
/// Policy administration and diagnostics against a cluster's applied bundles
|
||||
Policy {
|
||||
#[command(subcommand)]
|
||||
command: PolicyCommand,
|
||||
|
|
@ -363,16 +351,32 @@ pub(crate) enum Command {
|
|||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Legacy-config tooling (RFC-008): split omnigraph.yaml into its
|
||||
/// two destinations.
|
||||
Config {
|
||||
/// Inspect the scope profiles in ~/.omnigraph/config.yaml (read-only).
|
||||
Profile {
|
||||
#[command(subcommand)]
|
||||
command: ConfigCommand,
|
||||
command: ProfileCommand,
|
||||
},
|
||||
/// Print the CLI version
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum ProfileCommand {
|
||||
/// List the profiles defined in ~/.omnigraph/config.yaml.
|
||||
List {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Show a profile's resolved scope. With no name, shows the active
|
||||
/// (`$OMNIGRAPH_PROFILE`) profile, else the flat operator defaults.
|
||||
Show {
|
||||
/// Profile name (optional).
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum ClusterCommand {
|
||||
/// Validate cluster.yaml and referenced schemas, queries, and policy files.
|
||||
|
|
@ -469,8 +473,6 @@ pub(crate) enum GraphsCommand {
|
|||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
|
@ -483,8 +485,6 @@ pub(crate) enum BranchCommand {
|
|||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
from: Option<String>,
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
|
|
@ -496,8 +496,6 @@ pub(crate) enum BranchCommand {
|
|||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Delete a branch
|
||||
|
|
@ -505,8 +503,6 @@ pub(crate) enum BranchCommand {
|
|||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
|
@ -516,8 +512,6 @@ pub(crate) enum BranchCommand {
|
|||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
source: String,
|
||||
#[arg(long)]
|
||||
into: Option<String>,
|
||||
|
|
@ -533,8 +527,6 @@ pub(crate) enum SchemaCommand {
|
|||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
schema: PathBuf,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
|
@ -549,8 +541,6 @@ pub(crate) enum SchemaCommand {
|
|||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
schema: PathBuf,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
|
@ -572,8 +562,6 @@ pub(crate) enum SchemaCommand {
|
|||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
|
@ -586,8 +574,6 @@ pub(crate) enum CommitCommand {
|
|||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
|
@ -597,8 +583,6 @@ pub(crate) enum CommitCommand {
|
|||
/// Graph URI
|
||||
#[arg(long)]
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
commit_id: String,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
|
@ -607,20 +591,24 @@ pub(crate) enum CommitCommand {
|
|||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum PolicyCommand {
|
||||
/// Validate policy YAML and compiled Cedar policy state
|
||||
Validate {
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
},
|
||||
/// Run declarative policy tests from policy.tests.yaml
|
||||
/// Compile and validate the Cedar policy bundle(s) applied in a cluster.
|
||||
///
|
||||
/// Sources the bundle(s) from the cluster's applied policies
|
||||
/// (`--cluster <dir>`); pass the global `--graph <id>` to pick one
|
||||
/// graph's bundle when several apply.
|
||||
Validate {},
|
||||
/// Run declarative policy tests against a cluster's applied bundle.
|
||||
///
|
||||
/// The cluster model has no per-bundle tests file, so the cases are
|
||||
/// supplied explicitly with `--tests <file>` and checked against the
|
||||
/// bundle selected by `--cluster` (+ optional `--graph`).
|
||||
Test {
|
||||
/// Path to a policy.tests.yaml file.
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
tests: PathBuf,
|
||||
},
|
||||
/// Explain one policy decision locally
|
||||
/// Explain one policy decision against a cluster's applied bundle.
|
||||
Explain {
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
actor: String,
|
||||
#[arg(long)]
|
||||
|
|
@ -634,24 +622,19 @@ pub(crate) enum PolicyCommand {
|
|||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum QueriesCommand {
|
||||
/// Type-check the stored-query registry against the live schema.
|
||||
/// Type-check a cluster's stored-query registry against its schemas.
|
||||
///
|
||||
/// Distinct from `omnigraph lint` (which lints one `.gq` file):
|
||||
/// this validates the whole `queries:` registry — opening the graph
|
||||
/// to read its schema and confirming every stored query still
|
||||
/// type-checks. Exits non-zero on any breakage.
|
||||
/// Distinct from `omnigraph lint` (which lints one `.gq` file): this
|
||||
/// validates the whole `queries:` registry of a cluster (`--cluster
|
||||
/// <dir>`, optional `--graph <id>`) by reading each graph's applied
|
||||
/// schema and confirming every stored query still type-checks. Exits
|
||||
/// non-zero on any breakage.
|
||||
Validate {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// List the registered stored queries (name, MCP exposure, params).
|
||||
/// List a cluster's registered stored queries (name, params).
|
||||
List {
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
|
@ -682,7 +665,6 @@ impl From<CliLoadMode> for LoadMode {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CliLoadMode {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
|
|
@ -692,21 +674,3 @@ impl CliLoadMode {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum ConfigCommand {
|
||||
/// Propose (and with --write, apply) the RFC-008 split of a legacy
|
||||
/// omnigraph.yaml: team half -> a ready-to-review cluster.yaml,
|
||||
/// personal half -> ~/.omnigraph/config.yaml (key-level merge,
|
||||
/// existing entries always win). Touches nothing without --write.
|
||||
Migrate {
|
||||
/// Path to the legacy omnigraph.yaml (default: ./omnigraph.yaml)
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
/// Apply the split instead of only printing it
|
||||
#[arg(long)]
|
||||
write: bool,
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ use omnigraph::db::{Omnigraph, ReadTarget};
|
|||
use omnigraph_api_types::{
|
||||
BranchCreateOutput, BranchCreateRequest, BranchDeleteOutput, BranchListOutput,
|
||||
BranchMergeOutput, BranchMergeRequest, ChangeOutput, CommitListOutput, CommitOutput,
|
||||
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest, ReadOutput,
|
||||
ErrorOutput, ExportRequest, GraphListResponse, IngestOutput, IngestRequest,
|
||||
InvokeStoredQueryRequest, ReadOutput,
|
||||
ReadRequest, SchemaApplyOutput, SchemaApplyRequest, SchemaOutput, SnapshotOutput, commit_output,
|
||||
ingest_output, read_output, schema_apply_output, snapshot_payload,
|
||||
};
|
||||
|
|
@ -39,22 +40,20 @@ use serde_json::Value;
|
|||
|
||||
use crate::cli::CliLoadMode;
|
||||
use crate::helpers::{
|
||||
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,
|
||||
apply_bearer_token, apply_server_flag, build_http_client, is_remote_uri,
|
||||
legacy_change_request_body, query_params_from_json,
|
||||
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 omnigraph_server::config::OmnigraphConfig;
|
||||
|
||||
pub(crate) enum GraphClient {
|
||||
/// Local engine at `uri`. Reads (`resolve()`) leave `graph`/`actor`
|
||||
/// empty and open without policy; writes (`resolve_with_policy()`)
|
||||
/// fill them, opening through `open_local_db_with_policy` and
|
||||
/// attributing the resolved actor.
|
||||
/// Local engine at `uri`. Reads (`resolve()`) leave `actor` empty;
|
||||
/// writes (`resolve_with_policy()`) attribute the resolved actor.
|
||||
/// Direct-store access carries no Cedar policy (RFC-011: policy lives
|
||||
/// in the cluster/server, not in per-operator addressing).
|
||||
Embedded {
|
||||
uri: String,
|
||||
graph: Option<ResolvedCliGraph>,
|
||||
actor: Option<String>,
|
||||
},
|
||||
/// Remote HTTP server. The actor is resolved server-side from the
|
||||
|
|
@ -66,6 +65,43 @@ 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(
|
||||
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(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
|
||||
/// `--uri` `http(s)://` URL no longer auto-dispatches to a server. A remote URL
|
||||
/// produced by a server scope (`via_server`) is fine.
|
||||
|
|
@ -86,8 +122,7 @@ impl GraphClient {
|
|||
/// fork. Mirrors the read verbs' current preamble (`resolve_uri`
|
||||
/// path, not the policy-bearing `resolve_cli_graph`). Used by reads
|
||||
/// and `query` (which opens without policy, like the reads).
|
||||
pub(crate) fn resolve(
|
||||
config: &OmnigraphConfig,
|
||||
pub(crate) async fn resolve(
|
||||
server: Option<&str>,
|
||||
graph: Option<&str>,
|
||||
uri: Option<String>,
|
||||
|
|
@ -100,8 +135,9 @@ impl GraphClient {
|
|||
let scope = crate::scope::resolve_scope(
|
||||
&crate::operator::load_operator_config()?,
|
||||
crate::planes::Capability::Any,
|
||||
crate::scope::ScopeFlags { profile, store, server, graph, uri },
|
||||
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
||||
)?;
|
||||
require_graph_for_multi_graph_server(&scope).await?;
|
||||
let (server, graph, uri) = (
|
||||
scope.server.as_deref(),
|
||||
scope.graph.as_deref(),
|
||||
|
|
@ -109,8 +145,8 @@ impl GraphClient {
|
|||
);
|
||||
let via_server = server.is_some();
|
||||
let uri = apply_server_flag(server, graph, uri)?;
|
||||
let token = resolve_remote_bearer_token(config, uri.as_deref())?;
|
||||
let uri = crate::helpers::resolve_uri(config, uri)?;
|
||||
let token = resolve_remote_bearer_token(uri.as_deref())?;
|
||||
let uri = crate::helpers::resolve_uri(uri)?;
|
||||
reject_positional_remote(via_server, &uri)?;
|
||||
if is_remote_uri(&uri) {
|
||||
Ok(GraphClient::Remote {
|
||||
|
|
@ -119,11 +155,7 @@ impl GraphClient {
|
|||
token,
|
||||
})
|
||||
} else {
|
||||
Ok(GraphClient::Embedded {
|
||||
uri,
|
||||
graph: None,
|
||||
actor: None,
|
||||
})
|
||||
Ok(GraphClient::Embedded { uri, actor: None })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,8 +165,7 @@ impl GraphClient {
|
|||
/// resolved up front. The embedded arm then opens WITH policy. The
|
||||
/// resolution order matches the write arms exactly: server flag →
|
||||
/// bearer token → graph.
|
||||
pub(crate) fn resolve_with_policy(
|
||||
config: &OmnigraphConfig,
|
||||
pub(crate) async fn resolve_with_policy(
|
||||
server: Option<&str>,
|
||||
graph: Option<&str>,
|
||||
uri: Option<String>,
|
||||
|
|
@ -147,8 +178,9 @@ impl GraphClient {
|
|||
let scope = crate::scope::resolve_scope(
|
||||
&crate::operator::load_operator_config()?,
|
||||
crate::planes::Capability::Any,
|
||||
crate::scope::ScopeFlags { profile, store, server, graph, uri },
|
||||
crate::scope::ScopeFlags { profile, store, server, cluster: None, graph, uri },
|
||||
)?;
|
||||
require_graph_for_multi_graph_server(&scope).await?;
|
||||
let (server, graph, uri) = (
|
||||
scope.server.as_deref(),
|
||||
scope.graph.as_deref(),
|
||||
|
|
@ -156,8 +188,8 @@ impl GraphClient {
|
|||
);
|
||||
let via_server = server.is_some();
|
||||
let uri = apply_server_flag(server, graph, uri)?;
|
||||
let token = resolve_remote_bearer_token(config, uri.as_deref())?;
|
||||
let resolved = resolve_cli_graph(config, uri)?;
|
||||
let token = resolve_remote_bearer_token(uri.as_deref())?;
|
||||
let resolved = resolve_cli_graph(uri)?;
|
||||
reject_positional_remote(via_server, &resolved.uri)?;
|
||||
if resolved.is_remote {
|
||||
// A served write resolves the actor server-side from the bearer
|
||||
|
|
@ -175,10 +207,9 @@ impl GraphClient {
|
|||
token,
|
||||
})
|
||||
} else {
|
||||
let actor = resolve_cli_actor(cli_as, config)?;
|
||||
let actor = resolve_cli_actor(cli_as)?;
|
||||
Ok(GraphClient::Embedded {
|
||||
uri: resolved.uri.clone(),
|
||||
graph: Some(resolved),
|
||||
uri: resolved.uri,
|
||||
actor,
|
||||
})
|
||||
}
|
||||
|
|
@ -192,28 +223,15 @@ impl GraphClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// The selected graph name, when a policy-bearing embedded client was
|
||||
/// resolved against a named graph. `None` for remote and for reads.
|
||||
pub(crate) fn selected(&self) -> Option<&str> {
|
||||
match self {
|
||||
GraphClient::Embedded { graph, .. } => graph.as_ref().and_then(ResolvedCliGraph::selected),
|
||||
GraphClient::Remote { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_remote(&self) -> bool {
|
||||
matches!(self, GraphClient::Remote { .. })
|
||||
}
|
||||
|
||||
/// Open the local engine the way the resolved client demands: with
|
||||
/// policy when a `graph` context is present (write path), bare
|
||||
/// otherwise (read/`query` path). Captures today's two open paths in
|
||||
/// one place so each verb stays a single match arm.
|
||||
async fn open_embedded(uri: &str, graph: &Option<ResolvedCliGraph>) -> Result<Omnigraph> {
|
||||
match graph {
|
||||
Some(graph) => open_local_db_with_policy(graph).await,
|
||||
None => Ok(Omnigraph::open(uri).await?),
|
||||
}
|
||||
/// Open the local engine. Direct-store access carries no Cedar policy
|
||||
/// (RFC-011), so both read and write paths open bare; the actor is still
|
||||
/// attributed on the write via the `_as` engine APIs.
|
||||
async fn open_embedded(uri: &str) -> Result<Omnigraph> {
|
||||
Ok(Omnigraph::open(uri).await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn branch_list(&self) -> Result<BranchListOutput> {
|
||||
|
|
@ -375,8 +393,8 @@ impl GraphClient {
|
|||
.await?;
|
||||
Ok(load_output_from_tables(base_url, branch, mode.as_str(), &output))
|
||||
}
|
||||
GraphClient::Embedded { uri, graph, actor } => {
|
||||
let db = Self::open_embedded(uri, graph).await?;
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let result = db
|
||||
.load_file_as(branch, from, data, mode.into(), actor.as_deref())
|
||||
.await?;
|
||||
|
|
@ -418,8 +436,8 @@ impl GraphClient {
|
|||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, graph, actor } => {
|
||||
let db = Self::open_embedded(uri, graph).await?;
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let result = db
|
||||
.load_file_as(branch, Some(from), data, mode.into(), actor.as_deref())
|
||||
.await?;
|
||||
|
|
@ -457,10 +475,10 @@ impl GraphClient {
|
|||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, graph, actor } => {
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
|
||||
let params = query_params_from_json(&query_params, params_json)?;
|
||||
let db = Self::open_embedded(uri, graph).await?;
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let actor = actor.as_deref();
|
||||
let result = db
|
||||
.mutate_as(branch, query_source, &selected_name, ¶ms, actor)
|
||||
|
|
@ -511,10 +529,10 @@ impl GraphClient {
|
|||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, graph, .. } => {
|
||||
GraphClient::Embedded { uri, .. } => {
|
||||
let (selected_name, query_params) = select_named_query(query_source, query_name)?;
|
||||
let params = query_params_from_json(&query_params, params_json)?;
|
||||
let db = Self::open_embedded(uri, graph).await?;
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let result = db
|
||||
.query(target.clone(), query_source, &selected_name, ¶ms)
|
||||
.await?;
|
||||
|
|
@ -523,6 +541,50 @@ impl GraphClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// `invoke_named` — run a stored query **by catalog name** (RFC-011 D3).
|
||||
/// Served-only: the catalog is server-owned, so a `--store` (embedded)
|
||||
/// scope has nothing to resolve the name against. `expect_mutation` carries
|
||||
/// the verb's asserted kind; the server rejects a mismatch (400) before
|
||||
/// running, so the response is exactly the expected envelope — the caller
|
||||
/// deserializes it as the concrete `T` (`ReadOutput` for `query`,
|
||||
/// `ChangeOutput` for `mutate`), sidestepping the untagged wire enum.
|
||||
pub(crate) async fn invoke_named<T: serde::de::DeserializeOwned>(
|
||||
&self,
|
||||
name: &str,
|
||||
expect_mutation: bool,
|
||||
params_json: Option<&Value>,
|
||||
branch: Option<String>,
|
||||
snapshot: Option<String>,
|
||||
) -> Result<T> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
http,
|
||||
base_url,
|
||||
token,
|
||||
} => {
|
||||
let body = InvokeStoredQueryRequest {
|
||||
params: params_json.cloned(),
|
||||
branch,
|
||||
snapshot,
|
||||
expect_mutation: Some(expect_mutation),
|
||||
};
|
||||
remote_json(
|
||||
http,
|
||||
Method::POST,
|
||||
remote_url(base_url, &["queries", name], &[])?,
|
||||
Some(serde_json::to_value(body)?),
|
||||
token.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { .. } => bail!(
|
||||
"by-name invocation needs a server (the stored-query catalog is \
|
||||
server-owned); use -e '<gq>' or --query <file> for an ad-hoc query \
|
||||
against --store, or address a server with --server / --profile"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn branch_create_from(
|
||||
&self,
|
||||
from: &str,
|
||||
|
|
@ -546,8 +608,8 @@ impl GraphClient {
|
|||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, graph, actor } => {
|
||||
let db = Self::open_embedded(uri, graph).await?;
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let actor = actor.as_deref();
|
||||
db.branch_create_from_as(ReadTarget::branch(from), name, actor)
|
||||
.await?;
|
||||
|
|
@ -577,8 +639,8 @@ impl GraphClient {
|
|||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, graph, actor } => {
|
||||
let db = Self::open_embedded(uri, graph).await?;
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let actor = actor.as_deref();
|
||||
db.branch_delete_as(name, actor).await?;
|
||||
Ok(BranchDeleteOutput {
|
||||
|
|
@ -609,8 +671,8 @@ impl GraphClient {
|
|||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, graph, actor } => {
|
||||
let db = Self::open_embedded(uri, graph).await?;
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let actor = actor.as_deref();
|
||||
let outcome = db.branch_merge_as(source, into, actor).await?;
|
||||
Ok(BranchMergeOutput {
|
||||
|
|
@ -660,8 +722,8 @@ impl GraphClient {
|
|||
)
|
||||
.await
|
||||
}
|
||||
GraphClient::Embedded { uri, graph, actor } => {
|
||||
let db = Self::open_embedded(uri, graph).await?;
|
||||
GraphClient::Embedded { uri, actor } => {
|
||||
let db = Self::open_embedded(uri).await?;
|
||||
let result = db
|
||||
.apply_schema_as_with_catalog_check(
|
||||
schema_source,
|
||||
|
|
@ -730,9 +792,9 @@ impl GraphClient {
|
|||
|
||||
/// `graphs list` — enumerate the graphs a remote multi-graph server
|
||||
/// serves (`GET /graphs`). Remote-only by design: there is no local
|
||||
/// enumeration endpoint, so the Embedded arm fails loudly pointing the
|
||||
/// operator at `omnigraph.yaml`. Routing it through the enum still buys
|
||||
/// the shared `resolve()` addressing/token preamble.
|
||||
/// enumeration endpoint, so the Embedded arm fails loudly. Routing it
|
||||
/// through the enum still buys the shared `resolve()` addressing/token
|
||||
/// preamble.
|
||||
pub(crate) async fn list_graphs(&self) -> Result<GraphListResponse> {
|
||||
match self {
|
||||
GraphClient::Remote {
|
||||
|
|
@ -750,9 +812,9 @@ impl GraphClient {
|
|||
.await
|
||||
}
|
||||
GraphClient::Embedded { .. } => bail!(
|
||||
"`omnigraph graphs list` requires a remote multi-graph server URL \
|
||||
(http:// or https://). To enumerate local graphs, read `omnigraph.yaml` \
|
||||
directly."
|
||||
"`omnigraph graphs list` requires a remote multi-graph server \
|
||||
(--server <url>). To enumerate the graphs in a cluster, run \
|
||||
`omnigraph cluster status --config <dir>`."
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,22 +1,16 @@
|
|||
//! In-source test suite for the CLI binary (moved verbatim from
|
||||
//! main.rs; `use super::*` resolves through the #[path] declaration).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use super::{
|
||||
DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, bearer_token_from_env_file,
|
||||
legacy_change_request_body, load_cli_config, load_env_file_into_process,
|
||||
normalize_bearer_token, parse_env_assignment, resolve_cli_graph, resolve_policy_context,
|
||||
resolve_remote_bearer_token,
|
||||
DEFAULT_BEARER_TOKEN_ENV, apply_bearer_token, legacy_change_request_body,
|
||||
normalize_bearer_token, resolve_remote_bearer_token,
|
||||
};
|
||||
use omnigraph_server::load_config;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn legacy_change_request_body_uses_legacy_field_names() {
|
||||
// `execute_change_remote` hits `POST /change`, which old
|
||||
// `mutate`'s remote arm hits `POST /change`, which old
|
||||
// `omnigraph-server` builds deserialize as `ChangeRequest` with
|
||||
// **required** `query_source` and optional `query_name` keys.
|
||||
// Newer servers accept both spellings via serde alias, but a
|
||||
|
|
@ -96,120 +90,20 @@
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parse_env_assignment_supports_plain_and_exported_values() {
|
||||
assert_eq!(
|
||||
parse_env_assignment("DEMO_TOKEN=demo-token"),
|
||||
Some(("DEMO_TOKEN".to_string(), "demo-token".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_env_assignment("export DEMO_TOKEN=\"quoted-token\""),
|
||||
Some(("DEMO_TOKEN".to_string(), "quoted-token".to_string()))
|
||||
);
|
||||
assert_eq!(parse_env_assignment("# comment"), None);
|
||||
assert_eq!(parse_env_assignment(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_token_from_env_file_reads_named_value() {
|
||||
let temp = tempdir().unwrap();
|
||||
let env_file = temp.path().join(".env.omni");
|
||||
fs::write(
|
||||
&env_file,
|
||||
"FIRST=ignore\nexport DEMO_TOKEN=\" demo-token \"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
bearer_token_from_env_file(&env_file, "DEMO_TOKEN")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("demo-token")
|
||||
);
|
||||
assert_eq!(
|
||||
bearer_token_from_env_file(&env_file, "MISSING").unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_env_file_into_process_sets_missing_values_without_overriding_existing_ones() {
|
||||
let temp = tempdir().unwrap();
|
||||
let env_file = temp.path().join(".env.omni");
|
||||
fs::write(
|
||||
&env_file,
|
||||
"AUTOLOAD_ONLY=from-file\nAUTOLOAD_PRESET=from-file\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let missing_key = "AUTOLOAD_ONLY";
|
||||
let preset_key = "AUTOLOAD_PRESET";
|
||||
let previous_missing = std::env::var_os(missing_key);
|
||||
let previous_preset = std::env::var_os(preset_key);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(missing_key);
|
||||
std::env::set_var(preset_key, "from-env");
|
||||
}
|
||||
|
||||
load_env_file_into_process(&env_file).unwrap();
|
||||
|
||||
assert_eq!(std::env::var(missing_key).unwrap(), "from-file");
|
||||
assert_eq!(std::env::var(preset_key).unwrap(), "from-env");
|
||||
|
||||
unsafe {
|
||||
if let Some(value) = previous_missing {
|
||||
std::env::set_var(missing_key, value);
|
||||
} else {
|
||||
std::env::remove_var(missing_key);
|
||||
}
|
||||
|
||||
if let Some(value) = previous_preset {
|
||||
std::env::set_var(preset_key, value);
|
||||
} else {
|
||||
std::env::remove_var(preset_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_remote_bearer_token_uses_scoped_env_file_with_global_fallback() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
r#"
|
||||
graphs:
|
||||
demo:
|
||||
uri: https://example.com
|
||||
bearer_token_env: DEMO_TOKEN
|
||||
auth:
|
||||
env_file: .env.omni
|
||||
cli:
|
||||
graph: demo
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join(".env.omni"),
|
||||
"DEMO_TOKEN=scoped-token\nOMNIGRAPH_BEARER_TOKEN=global-token\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fn resolve_remote_bearer_token_falls_back_to_default_env() {
|
||||
// RFC-011: with no operator server matching the URL, the only chain
|
||||
// left is the default `OMNIGRAPH_BEARER_TOKEN` env (no omnigraph.yaml
|
||||
// scoped chain). Hermetic: no operator config is read for a literal URL
|
||||
// that matches no `servers:` entry.
|
||||
let previous = std::env::var_os(DEFAULT_BEARER_TOKEN_ENV);
|
||||
let previous_home = std::env::var_os("OMNIGRAPH_HOME");
|
||||
unsafe {
|
||||
std::env::remove_var(DEFAULT_BEARER_TOKEN_ENV);
|
||||
// Hermetic: the keyed hop (RFC-007 PR 2) must not pick up a real
|
||||
// ~/.omnigraph on the developer's machine — and with no operator
|
||||
// servers defined, the legacy chain below must behave
|
||||
// byte-identically to pre-PR-2 (tested-as-untouched).
|
||||
std::env::set_var("OMNIGRAPH_HOME", temp.path().join("no-operator-config"));
|
||||
std::env::set_var(DEFAULT_BEARER_TOKEN_ENV, "global-token");
|
||||
std::env::set_var("OMNIGRAPH_HOME", "/nonexistent/omnigraph-test-home");
|
||||
}
|
||||
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
let config = load_config(Some(&config_path)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolve_remote_bearer_token(&config, Some("https://override.example.com"))
|
||||
resolve_remote_bearer_token(Some("https://override.example.com"))
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("global-token")
|
||||
|
|
@ -228,196 +122,3 @@ cli:
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cli_config_autoloads_env_file_into_process() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
r#"
|
||||
auth:
|
||||
env_file: .env.omni
|
||||
graphs:
|
||||
demo:
|
||||
uri: s3://bucket/prefix
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join(".env.omni"),
|
||||
"AUTOLOAD_FROM_CONFIG=loaded\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let key = "AUTOLOAD_FROM_CONFIG";
|
||||
let previous = std::env::var_os(key);
|
||||
unsafe {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
let config = load_cli_config(Some(&config_path)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.resolve_target_uri(None, Some("demo"), None).unwrap(),
|
||||
"s3://bucket/prefix"
|
||||
);
|
||||
assert_eq!(std::env::var(key).unwrap(), "loaded");
|
||||
|
||||
unsafe {
|
||||
if let Some(value) = previous {
|
||||
std::env::set_var(key, value);
|
||||
} else {
|
||||
std::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_identity_resolve_policy_context_named_cli_graph_uses_graph_key_not_project_name_or_uri()
|
||||
{
|
||||
let temp = tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
project:
|
||||
name: misleading-project
|
||||
graphs:
|
||||
local:
|
||||
uri: /tmp/local-policy-graph.omni
|
||||
policy:
|
||||
file: ./policy.yaml
|
||||
cli:
|
||||
graph: local
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config(Some(&config_path)).unwrap();
|
||||
let context = resolve_policy_context(&config).unwrap();
|
||||
assert_eq!(context.graph_id, "local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_identity_resolve_policy_context_server_graph_uses_graph_key_when_cli_graph_absent() {
|
||||
let temp = tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
project:
|
||||
name: misleading-project
|
||||
graphs:
|
||||
local:
|
||||
uri: /tmp/local-policy-graph.omni
|
||||
policy:
|
||||
file: ./server-policy.yaml
|
||||
server:
|
||||
graph: local
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config(Some(&config_path)).unwrap();
|
||||
let context = resolve_policy_context(&config).unwrap();
|
||||
assert_eq!(context.graph_id, "local");
|
||||
assert!(context.policy_file.ends_with("server-policy.yaml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_identity_resolve_policy_context_anonymous_uses_top_level_default_identity() {
|
||||
let temp = tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
project:
|
||||
name: misleading-project
|
||||
graphs:
|
||||
local:
|
||||
uri: /tmp/local-policy-graph.omni
|
||||
policy:
|
||||
file: ./top-policy.yaml
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config(Some(&config_path)).unwrap();
|
||||
let context = resolve_policy_context(&config).unwrap();
|
||||
assert_eq!(context.graph_id, "default");
|
||||
assert!(context.policy_file.ends_with("top-policy.yaml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_identity_resolve_cli_graph_named_target_uses_graph_key_not_project_name_or_uri() {
|
||||
let temp = tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
project:
|
||||
name: misleading-project
|
||||
graphs:
|
||||
prod:
|
||||
uri: s3://bucket/prod-graph/
|
||||
policy:
|
||||
file: ./prod-policy.yaml
|
||||
cli:
|
||||
graph: prod
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config(Some(&config_path)).unwrap();
|
||||
// `--target` is removed; the `cli.graph` default drives the same
|
||||
// graph-key (not project name / URI) selection.
|
||||
let graph = resolve_cli_graph(&config, None).unwrap();
|
||||
assert_eq!(graph.selected(), Some("prod"));
|
||||
assert_eq!(graph.graph_id, "prod");
|
||||
assert_eq!(graph.uri, "s3://bucket/prod-graph/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_identity_resolve_cli_graph_positional_uri_uses_anonymous_normalized_uri() {
|
||||
let temp = tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
project:
|
||||
name: misleading-project
|
||||
graphs:
|
||||
local:
|
||||
uri: /tmp/configured-graph.omni
|
||||
policy:
|
||||
file: ./policy.yaml
|
||||
cli:
|
||||
graph: local
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config(Some(&config_path)).unwrap();
|
||||
let local_graph_path = temp.path().join("explicit-graph.omni");
|
||||
let local_graph = resolve_cli_graph(
|
||||
&config,
|
||||
Some(format!("file://{}", local_graph_path.display())),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(local_graph.selected(), None);
|
||||
assert_eq!(
|
||||
local_graph.graph_id,
|
||||
local_graph_path.to_string_lossy().as_ref()
|
||||
);
|
||||
assert_eq!(local_graph.policy_file, None);
|
||||
|
||||
let s3_graph = resolve_cli_graph(
|
||||
&config,
|
||||
Some("s3://bucket/anonymous-graph/".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s3_graph.selected(), None);
|
||||
assert_eq!(s3_graph.graph_id, "s3://bucket/anonymous-graph");
|
||||
assert_eq!(s3_graph.policy_file, None);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,408 +0,0 @@
|
|||
//! `omnigraph config migrate` (RFC-008 stage 2): split a legacy
|
||||
//! `omnigraph.yaml` into its two destinations — the team half as a
|
||||
//! ready-to-review `cluster.yaml` proposal, the personal half merged into
|
||||
//! `~/.omnigraph/config.yaml` — and name what's obsolete. The command is
|
||||
//! the completeness test of RFC-008's migration map: any key it cannot
|
||||
//! place is a bug in the RFC.
|
||||
//!
|
||||
//! Touches nothing without `--write`. Referenced `.gq`/policy files are
|
||||
//! never moved; manual steps are printed instead.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use color_eyre::Result;
|
||||
use color_eyre::eyre::eyre;
|
||||
use omnigraph_server::OmnigraphConfig;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::operator;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct MigrateReport {
|
||||
pub(crate) source: String,
|
||||
/// The ready-to-review cluster.yaml text (None when the legacy file
|
||||
/// declares nothing team-shaped).
|
||||
pub(crate) cluster_yaml: Option<String>,
|
||||
/// Operator keys to merge: dotted key -> YAML value text.
|
||||
pub(crate) operator_merge: BTreeMap<String, String>,
|
||||
/// Keys with no destination, and why.
|
||||
pub(crate) dropped: Vec<DroppedKey>,
|
||||
/// Steps the command will not do for you.
|
||||
pub(crate) manual_steps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct DroppedKey {
|
||||
pub(crate) key: String,
|
||||
pub(crate) reason: String,
|
||||
}
|
||||
|
||||
/// Classify a parsed legacy config into the report. Pure — no I/O.
|
||||
pub(crate) fn build_report(config: &OmnigraphConfig, source: &Path) -> MigrateReport {
|
||||
let mut dropped = Vec::new();
|
||||
let mut manual_steps = Vec::new();
|
||||
let mut operator_merge: BTreeMap<String, String> = BTreeMap::new();
|
||||
|
||||
// ---- personal half ----
|
||||
if let Some(actor) = &config.cli.actor {
|
||||
operator_merge.insert("operator.actor".into(), actor.clone());
|
||||
}
|
||||
if let Some(format) = config.cli.output_format {
|
||||
operator_merge.insert(
|
||||
"defaults.output".into(),
|
||||
serde_yaml::to_string(&format).unwrap_or_default().trim().to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(width) = config.cli.table_max_column_width {
|
||||
operator_merge.insert("defaults.table_max_column_width".into(), width.to_string());
|
||||
}
|
||||
if let Some(layout) = config.cli.table_cell_layout {
|
||||
operator_merge.insert(
|
||||
"defaults.table_cell_layout".into(),
|
||||
serde_yaml::to_string(&layout).unwrap_or_default().trim().to_string(),
|
||||
);
|
||||
}
|
||||
if config.cli.graph.is_some() {
|
||||
dropped.push(DroppedKey {
|
||||
key: "cli.graph".into(),
|
||||
reason: "address graphs explicitly via --store/--server, or set defaults.default_graph in the operator config".into(),
|
||||
});
|
||||
}
|
||||
if config.cli.branch.is_some() {
|
||||
dropped.push(DroppedKey {
|
||||
key: "cli.branch".into(),
|
||||
reason: "pass --branch explicitly".into(),
|
||||
});
|
||||
}
|
||||
|
||||
// Remote graphs with a token env become operator servers (the keyed
|
||||
// chain replaces invented env-var names).
|
||||
for (name, target) in &config.graphs {
|
||||
if target.uri.starts_with("http://") || target.uri.starts_with("https://") {
|
||||
operator_merge.insert(format!("servers.{name}.url"), target.uri.clone());
|
||||
if target.bearer_token_env.is_some() {
|
||||
manual_steps.push(format!(
|
||||
"store the '{name}' token in the keyed chain: echo $TOKEN | omnigraph login {name} (replaces bearer_token_env)"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.auth.env_file.is_some() {
|
||||
manual_steps.push(
|
||||
"auth.env_file keeps working during the window; prefer `omnigraph login <server>` per server going forward".into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy aliases split: content -> catalog stored query, binding ->
|
||||
// operator alias referencing the name.
|
||||
for (name, alias) in &config.aliases {
|
||||
let query_name = alias.name.clone().unwrap_or_else(|| name.clone());
|
||||
operator_merge.insert(
|
||||
format!("aliases.{name}"),
|
||||
format!(
|
||||
"{{ server: TODO-server-name, graph: {}, query: {query_name}, args: [{}] }}",
|
||||
alias.graph.as_deref().unwrap_or("TODO-graph-id"),
|
||||
alias.args.join(", ")
|
||||
),
|
||||
);
|
||||
manual_steps.push(format!(
|
||||
"alias '{name}': move its query content ('{}') into the cluster checkout's queries/ so '{query_name}' becomes a catalog stored query",
|
||||
alias.query
|
||||
));
|
||||
}
|
||||
|
||||
// ---- team half ----
|
||||
let has_team_content = !config.graphs.is_empty()
|
||||
|| !config.queries.is_empty()
|
||||
|| config.policy.file.is_some()
|
||||
|| config.server.policy.file.is_some();
|
||||
let cluster_yaml = has_team_content.then(|| {
|
||||
let mut out = String::from("version: 1\n");
|
||||
if let Some(name) = &config.project.name {
|
||||
out.push_str(&format!("metadata:\n name: {name}\n"));
|
||||
}
|
||||
out.push_str("# storage: s3://bucket/prefix # or omit: this folder is the root\n");
|
||||
if !config.graphs.is_empty() || !config.queries.is_empty() {
|
||||
out.push_str("graphs:\n");
|
||||
}
|
||||
// Single-graph top-level queries belong to a graph the legacy file
|
||||
// never named; propose one.
|
||||
if !config.queries.is_empty() && config.graphs.is_empty() {
|
||||
out.push_str(" default: # TODO: pick the graph id\n schema: # TODO: path to this graph's .pg schema\n queries: queries/\n");
|
||||
}
|
||||
for (name, target) in &config.graphs {
|
||||
out.push_str(&format!(" {name}:\n"));
|
||||
out.push_str(" schema: # TODO: path to this graph's .pg schema\n");
|
||||
if !target.queries.is_empty() {
|
||||
out.push_str(" queries: queries/ # move the .gq files here\n");
|
||||
}
|
||||
out.push_str(&format!(
|
||||
" # legacy root: {} — the cluster manages graph roots under its storage; run `omnigraph cluster import` after reviewing\n",
|
||||
target.uri
|
||||
));
|
||||
}
|
||||
let mut policies: Vec<(String, String, String)> = Vec::new();
|
||||
if let Some(file) = &config.policy.file {
|
||||
policies.push(("default".into(), file.clone(), "graph.<id> # TODO: bind".into()));
|
||||
}
|
||||
if let Some(file) = &config.server.policy.file {
|
||||
policies.push(("server".into(), file.clone(), "cluster".into()));
|
||||
}
|
||||
for (name, target) in &config.graphs {
|
||||
if let Some(file) = &target.policy.file {
|
||||
policies.push((name.clone(), file.clone(), format!("graph.{name}")));
|
||||
}
|
||||
}
|
||||
if !policies.is_empty() {
|
||||
out.push_str("policies:\n");
|
||||
for (name, file, binding) in policies {
|
||||
out.push_str(&format!(
|
||||
" {name}:\n file: {file}\n applies_to: [{binding}]\n"
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
});
|
||||
|
||||
if !config.query.roots.is_empty() {
|
||||
dropped.push(DroppedKey {
|
||||
key: "query.roots".into(),
|
||||
reason: "obsolete — cluster query discovery (queries: <dir>) replaced it".into(),
|
||||
});
|
||||
}
|
||||
if config.server.bind.is_some() || config.server.graph.is_some() {
|
||||
dropped.push(DroppedKey {
|
||||
key: "server.bind / server.graph".into(),
|
||||
reason: "deployment runtime — pass --bind / target flags or env".into(),
|
||||
});
|
||||
}
|
||||
if config.project.name.is_some() && cluster_yaml.is_none() {
|
||||
dropped.push(DroppedKey {
|
||||
key: "project.name".into(),
|
||||
reason: "the cluster's metadata.name is the deployment label".into(),
|
||||
});
|
||||
}
|
||||
|
||||
MigrateReport {
|
||||
source: source.display().to_string(),
|
||||
cluster_yaml,
|
||||
operator_merge,
|
||||
dropped,
|
||||
manual_steps,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_report(report: &MigrateReport) -> String {
|
||||
let mut out = format!("migration plan for {}\n", report.source);
|
||||
if let Some(cluster) = &report.cluster_yaml {
|
||||
out.push_str("\n== team half -> cluster.yaml (ready to review) ==\n");
|
||||
out.push_str(cluster);
|
||||
}
|
||||
if !report.operator_merge.is_empty() {
|
||||
out.push_str("\n== personal half -> ~/.omnigraph/config.yaml ==\n");
|
||||
for (key, value) in &report.operator_merge {
|
||||
out.push_str(&format!(" {key}: {value}\n"));
|
||||
}
|
||||
}
|
||||
if !report.dropped.is_empty() {
|
||||
out.push_str("\n== no destination ==\n");
|
||||
for dropped in &report.dropped {
|
||||
out.push_str(&format!(" {} — {}\n", dropped.key, dropped.reason));
|
||||
}
|
||||
}
|
||||
if !report.manual_steps.is_empty() {
|
||||
out.push_str("\n== manual steps ==\n");
|
||||
for step in &report.manual_steps {
|
||||
out.push_str(&format!(" - {step}\n"));
|
||||
}
|
||||
}
|
||||
out.push_str("\n(nothing written; pass --write to apply the operator merge and emit cluster.yaml)\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// `--write`: merge the personal half into the operator config (key-level,
|
||||
/// existing entries always win; the prior file is backed up) and write the
|
||||
/// team half to cluster.yaml in the legacy config's directory (or
|
||||
/// cluster.yaml.proposed when one already exists).
|
||||
pub(crate) fn apply_report(report: &MigrateReport, legacy_dir: &Path) -> Result<Vec<String>> {
|
||||
let mut written = Vec::new();
|
||||
|
||||
if !report.operator_merge.is_empty() {
|
||||
let dir = operator::operator_dir()
|
||||
.ok_or_else(|| eyre!("no home directory resolvable for the operator config"))?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(operator::OPERATOR_CONFIG_FILE);
|
||||
let existing_text = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let mut mapping: serde_yaml::Mapping = if existing_text.trim().is_empty() {
|
||||
serde_yaml::Mapping::new()
|
||||
} else {
|
||||
serde_yaml::from_str(&existing_text)
|
||||
.map_err(|err| eyre!("operator config '{}' does not parse: {err}", path.display()))?
|
||||
};
|
||||
let mut merged_any = false;
|
||||
for (dotted, value_text) in &report.operator_merge {
|
||||
if merge_dotted_if_absent(&mut mapping, dotted, value_text)? {
|
||||
merged_any = true;
|
||||
}
|
||||
}
|
||||
if merged_any {
|
||||
if !existing_text.is_empty() {
|
||||
let backup = path.with_extension("yaml.bak");
|
||||
std::fs::write(&backup, &existing_text)?;
|
||||
written.push(format!("backed up prior operator config to {}", backup.display()));
|
||||
}
|
||||
let rendered = serde_yaml::to_string(&mapping)?;
|
||||
let tmp = path.with_extension(format!("yaml.tmp.{}", std::process::id()));
|
||||
std::fs::write(&tmp, &rendered)?;
|
||||
std::fs::rename(&tmp, &path)?;
|
||||
written.push(format!("merged personal keys into {}", path.display()));
|
||||
} else {
|
||||
written.push("operator config already carries every personal key (nothing merged)".into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cluster) = &report.cluster_yaml {
|
||||
let target = legacy_dir.join("cluster.yaml");
|
||||
let target = if target.exists() {
|
||||
legacy_dir.join("cluster.yaml.proposed")
|
||||
} else {
|
||||
target
|
||||
};
|
||||
std::fs::write(&target, cluster)?;
|
||||
written.push(format!("wrote team-half proposal to {}", target.display()));
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Set `a.b.c` in the mapping only when absent; returns whether it wrote.
|
||||
fn merge_dotted_if_absent(
|
||||
mapping: &mut serde_yaml::Mapping,
|
||||
dotted: &str,
|
||||
value_text: &str,
|
||||
) -> Result<bool> {
|
||||
let value: serde_yaml::Value =
|
||||
serde_yaml::from_str(value_text).unwrap_or(serde_yaml::Value::String(value_text.into()));
|
||||
let parts: Vec<&str> = dotted.split('.').collect();
|
||||
let mut current = mapping;
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
let key = serde_yaml::Value::String((*part).into());
|
||||
let entry = current
|
||||
.entry(key)
|
||||
.or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
|
||||
current = entry
|
||||
.as_mapping_mut()
|
||||
.ok_or_else(|| eyre!("operator config key '{dotted}' collides with a non-mapping"))?;
|
||||
}
|
||||
let leaf = serde_yaml::Value::String(parts[parts.len() - 1].into());
|
||||
if current.contains_key(&leaf) {
|
||||
return Ok(false);
|
||||
}
|
||||
current.insert(leaf, value);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_config_path(explicit: Option<&PathBuf>) -> PathBuf {
|
||||
explicit.cloned().unwrap_or_else(|| PathBuf::from("omnigraph.yaml"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use omnigraph_server::config::load_config;
|
||||
|
||||
fn full_legacy_fixture(dir: &Path) -> PathBuf {
|
||||
let path = dir.join("omnigraph.yaml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
project: { name: brain }
|
||||
graphs:
|
||||
prod:
|
||||
uri: https://graph.example.com
|
||||
bearer_token_env: PROD_TOKEN
|
||||
policy: { file: ./prod.policy.yaml }
|
||||
queries:
|
||||
find: { file: ./find.gq }
|
||||
local:
|
||||
uri: /tmp/local.omni
|
||||
server: { bind: "0.0.0.0:9999", policy: { file: ./server.policy.yaml } }
|
||||
auth: { env_file: .env.omni }
|
||||
cli:
|
||||
graph: prod
|
||||
branch: main
|
||||
actor: act-me
|
||||
output_format: json
|
||||
table_max_column_width: 40
|
||||
query: { roots: ["."] }
|
||||
aliases:
|
||||
triage: { command: query, query: ./triage.gq, name: weekly_triage, args: [since], graph: prod }
|
||||
policy: { file: ./top.policy.yaml }
|
||||
queries:
|
||||
top_q: { file: ./top.gq }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// The RFC-008 completeness contract: every top-level key of the
|
||||
/// legacy schema must appear in the report somewhere (team half,
|
||||
/// operator merge, dropped, or manual steps).
|
||||
#[test]
|
||||
fn every_legacy_key_is_classified() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = full_legacy_fixture(dir.path());
|
||||
let config = load_config(Some(&path)).unwrap();
|
||||
let report = build_report(&config, &path);
|
||||
let rendered = render_report(&report);
|
||||
|
||||
let serialized =
|
||||
serde_yaml::to_value(OmnigraphConfig::default()).expect("default serializes");
|
||||
for key in serialized.as_mapping().unwrap().keys() {
|
||||
let key = key.as_str().unwrap();
|
||||
assert!(
|
||||
rendered.contains(key)
|
||||
|| report.operator_merge.keys().any(|k| k.contains(key))
|
||||
|| matches!(key, "graphs" | "queries" | "policy" | "project")
|
||||
&& report.cluster_yaml.is_some(),
|
||||
"legacy key '{key}' is unclassified — fix the RFC-008 map: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
// spot checks on each section
|
||||
assert_eq!(report.operator_merge["operator.actor"], "act-me");
|
||||
assert_eq!(report.operator_merge["defaults.output"], "json");
|
||||
assert_eq!(
|
||||
report.operator_merge["servers.prod.url"],
|
||||
"https://graph.example.com"
|
||||
);
|
||||
assert!(report.operator_merge["aliases.triage"].contains("query: weekly_triage"));
|
||||
let cluster = report.cluster_yaml.as_deref().unwrap();
|
||||
assert!(cluster.contains("version: 1"));
|
||||
assert!(cluster.contains("name: brain"));
|
||||
assert!(cluster.contains(" prod:"));
|
||||
assert!(cluster.contains("applies_to: [cluster]"));
|
||||
assert!(cluster.contains("applies_to: [graph.prod]"));
|
||||
assert!(report.dropped.iter().any(|d| d.key == "query.roots"));
|
||||
assert!(report.dropped.iter().any(|d| d.key.contains("server.bind")));
|
||||
assert!(
|
||||
report
|
||||
.manual_steps
|
||||
.iter()
|
||||
.any(|s| s.contains("omnigraph login prod"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_dotted_never_clobbers_existing() {
|
||||
let mut mapping: serde_yaml::Mapping =
|
||||
serde_yaml::from_str("operator:\n actor: keep-me\n").unwrap();
|
||||
assert!(!merge_dotted_if_absent(&mut mapping, "operator.actor", "new").unwrap());
|
||||
assert!(merge_dotted_if_absent(&mut mapping, "defaults.output", "json").unwrap());
|
||||
let text = serde_yaml::to_string(&mapping).unwrap();
|
||||
assert!(text.contains("keep-me") && !text.contains("new"));
|
||||
assert!(text.contains("output: json"));
|
||||
}
|
||||
}
|
||||
|
|
@ -18,10 +18,10 @@ use std::env;
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use color_eyre::Result;
|
||||
use color_eyre::eyre::eyre;
|
||||
use color_eyre::eyre::{bail, eyre};
|
||||
use serde::Deserialize;
|
||||
|
||||
use omnigraph_server::config::ReadOutputFormat;
|
||||
use crate::read_format::{ReadOutputFormat, TableCellLayout};
|
||||
|
||||
pub(crate) const OPERATOR_HOME_ENV: &str = "OMNIGRAPH_HOME";
|
||||
pub(crate) const OPERATOR_DIR: &str = ".omnigraph";
|
||||
|
|
@ -91,8 +91,7 @@ pub(crate) struct OperatorServer {
|
|||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OperatorIdentity {
|
||||
/// Default actor for every `--as` cascade (CLI direct-engine writes and
|
||||
/// cluster commands alike): `--as` > legacy config actor (RFC-008
|
||||
/// window) > this > none.
|
||||
/// cluster commands alike): `--as` > this > none.
|
||||
pub(crate) actor: Option<String>,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
|
|
@ -102,14 +101,19 @@ pub(crate) struct OperatorIdentity {
|
|||
pub(crate) struct OperatorDefaults {
|
||||
/// Default read output format, below every more-specific source.
|
||||
pub(crate) output: Option<ReadOutputFormat>,
|
||||
/// Table rendering preferences (below the legacy cli.table_* keys
|
||||
/// during the RFC-008 window).
|
||||
/// Table rendering preferences for `--format table`.
|
||||
pub(crate) table_max_column_width: Option<usize>,
|
||||
pub(crate) table_cell_layout: Option<omnigraph_server::config::TableCellLayout>,
|
||||
pub(crate) table_cell_layout: Option<TableCellLayout>,
|
||||
/// Default server scope (RFC-011): the everyday addressing when no
|
||||
/// `--profile` / primitive / legacy address is given. Names an entry
|
||||
/// under `servers:`.
|
||||
/// under `servers:`. Mutually exclusive with `store` — a scope binds one
|
||||
/// entity.
|
||||
pub(crate) server: Option<String>,
|
||||
/// Default **store** scope (RFC-011): a `file://` / `s3://` graph storage
|
||||
/// URI used as the zero-flag local default for graph commands when no
|
||||
/// `--profile` / primitive address is given. The local-dev counterpart of
|
||||
/// `server`; mutually exclusive with it.
|
||||
pub(crate) store: Option<String>,
|
||||
/// Default graph selected within a server/cluster scope when no
|
||||
/// `--graph` is passed (RFC-011).
|
||||
pub(crate) default_graph: Option<String>,
|
||||
|
|
@ -202,10 +206,36 @@ impl OperatorConfig {
|
|||
self.defaults.server.as_deref()
|
||||
}
|
||||
|
||||
/// The flat-default store scope URI, if set (RFC-011) — the zero-flag
|
||||
/// local-dev default.
|
||||
pub(crate) fn default_store(&self) -> Option<&str> {
|
||||
self.defaults.store.as_deref()
|
||||
}
|
||||
|
||||
/// The flat-default graph within a server/cluster scope, if set (RFC-011).
|
||||
pub(crate) fn default_graph(&self) -> Option<&str> {
|
||||
self.defaults.default_graph.as_deref()
|
||||
}
|
||||
|
||||
/// A scope binds one entity (Decision 6): `defaults.server` and
|
||||
/// `defaults.store` are mutually exclusive, and a `store` (already a single
|
||||
/// graph) cannot carry a `default_graph`. Both are refused loudly rather
|
||||
/// than silently dropped.
|
||||
fn validate_defaults(&self) -> Result<()> {
|
||||
if self.defaults.server.is_some() && self.defaults.store.is_some() {
|
||||
bail!(
|
||||
"operator config `defaults` sets both `server` and `store` — a default scope \
|
||||
binds one entity; keep one (use a `profile` if you need both)"
|
||||
);
|
||||
}
|
||||
if self.defaults.store.is_some() && self.defaults.default_graph.is_some() {
|
||||
bail!(
|
||||
"operator config `defaults` sets both `store` and `default_graph` — a store is \
|
||||
already a single graph; drop `default_graph` (it applies only to a server/cluster scope)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl OperatorProfile {
|
||||
|
|
@ -282,6 +312,7 @@ pub(crate) fn load_operator_config_at(path: &Path) -> Result<OperatorConfig> {
|
|||
for warning in config.unknown_key_warnings() {
|
||||
eprintln!("warning: {warning} in operator config '{}'", path.display());
|
||||
}
|
||||
config.validate_defaults()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
|
@ -560,6 +591,42 @@ mod tests {
|
|||
assert_eq!(config.output(), Some(ReadOutputFormat::Json));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_store_parses_and_is_accessible() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(&path, "defaults:\n store: file:///tmp/dev.omni\n").unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
assert_eq!(config.default_store(), Some("file:///tmp/dev.omni"));
|
||||
assert_eq!(config.default_server(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_server_and_store_together_is_a_loud_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"defaults:\n server: prod\n store: file:///tmp/dev.omni\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_operator_config_at(&path).unwrap_err().to_string();
|
||||
assert!(err.contains("binds one entity"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_store_with_default_graph_is_a_loud_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"defaults:\n store: file:///tmp/dev.omni\n default_graph: knowledge\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_operator_config_at(&path).unwrap_err().to_string();
|
||||
assert!(err.contains("already a single graph"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_warn_but_load() {
|
||||
// A file written for a later slice (servers/aliases) must load
|
||||
|
|
|
|||
|
|
@ -749,15 +749,10 @@ pub(crate) fn print_snapshot_human(branch: &str, manifest_version: u64, entries:
|
|||
pub(crate) fn print_read_output(
|
||||
output: &ReadOutput,
|
||||
format: ReadOutputFormat,
|
||||
config: &OmnigraphConfig,
|
||||
) -> Result<()> {
|
||||
println!(
|
||||
"{}",
|
||||
render_read(
|
||||
output,
|
||||
format,
|
||||
&resolve_table_render_options(config),
|
||||
)?
|
||||
render_read(output, format, &resolve_table_render_options())?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -907,21 +902,87 @@ pub(crate) fn finish_logout(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Table prefs cascade (RFC-007/008): legacy cli.table_* (window) >
|
||||
/// operator defaults.table_* > built-in.
|
||||
pub(crate) fn resolve_table_render_options(config: &OmnigraphConfig) -> ReadRenderOptions {
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ProfileListItem {
|
||||
pub(crate) name: String,
|
||||
/// `server: <n>` / `cluster: <n>` / `store: <uri>` / `invalid: <reason>`.
|
||||
pub(crate) binding: String,
|
||||
/// `server` | `cluster` | `store` | `invalid`.
|
||||
pub(crate) scope_kind: String,
|
||||
/// The bound server/cluster name, or the store URI. `None` when invalid.
|
||||
pub(crate) target: Option<String>,
|
||||
pub(crate) valid: bool,
|
||||
pub(crate) error: Option<String>,
|
||||
pub(crate) default_graph: Option<String>,
|
||||
pub(crate) active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ProfileDetail {
|
||||
/// Profile name, or `(defaults)` for the no-name flat-defaults view.
|
||||
pub(crate) name: String,
|
||||
/// `server` | `cluster` | `store` | `none`.
|
||||
pub(crate) scope_kind: String,
|
||||
/// The bound server/cluster name, or the store URI.
|
||||
pub(crate) target: Option<String>,
|
||||
/// Resolved endpoint: a server's URL / a cluster's root / the store URI;
|
||||
/// `None` if a named server/cluster isn't defined in this config.
|
||||
pub(crate) endpoint: Option<String>,
|
||||
pub(crate) default_graph: Option<String>,
|
||||
pub(crate) output_format: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn print_profile_list(items: &[ProfileListItem], json: bool) -> Result<()> {
|
||||
if json {
|
||||
return print_json(&items);
|
||||
}
|
||||
if items.is_empty() {
|
||||
println!("no profiles defined in the operator config");
|
||||
return Ok(());
|
||||
}
|
||||
for item in items {
|
||||
let active = if item.active { " (active)" } else { "" };
|
||||
let graph = item
|
||||
.default_graph
|
||||
.as_deref()
|
||||
.map(|g| format!(" · graph: {g}"))
|
||||
.unwrap_or_default();
|
||||
println!("{}{active} {}{graph}", item.name, item.binding);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn print_profile_detail(detail: &ProfileDetail, json: bool) -> Result<()> {
|
||||
if json {
|
||||
return print_json(detail);
|
||||
}
|
||||
println!("profile: {}", detail.name);
|
||||
let target = detail
|
||||
.target
|
||||
.as_deref()
|
||||
.map(|t| format!(" {t}"))
|
||||
.unwrap_or_default();
|
||||
println!(" scope: {}{target}", detail.scope_kind);
|
||||
if let Some(endpoint) = &detail.endpoint {
|
||||
println!(" endpoint: {endpoint}");
|
||||
} else if matches!(detail.scope_kind.as_str(), "server" | "cluster") {
|
||||
println!(" endpoint: (undefined — name not in this config)");
|
||||
}
|
||||
if let Some(graph) = &detail.default_graph {
|
||||
println!(" default graph: {graph}");
|
||||
}
|
||||
if let Some(format) = &detail.output_format {
|
||||
println!(" output: {format}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Table prefs cascade (RFC-011): operator defaults.table_* > built-in.
|
||||
pub(crate) fn resolve_table_render_options() -> ReadRenderOptions {
|
||||
let operator = crate::operator::load_operator_config().unwrap_or_default();
|
||||
ReadRenderOptions {
|
||||
max_column_width: config
|
||||
.cli
|
||||
.table_max_column_width
|
||||
.or(operator.defaults.table_max_column_width)
|
||||
.unwrap_or(80),
|
||||
cell_layout: config
|
||||
.cli
|
||||
.table_cell_layout
|
||||
.or(operator.defaults.table_cell_layout)
|
||||
.unwrap_or_default(),
|
||||
max_column_width: operator.defaults.table_max_column_width.unwrap_or(80),
|
||||
cell_layout: operator.defaults.table_cell_layout.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,9 +82,7 @@ impl Capability {
|
|||
/// classifier) plus the one Data→Served refinement: `graphs` is remote-only.
|
||||
///
|
||||
/// This reflects *current enforced behavior*, so messages stay truthful:
|
||||
/// `queries list` is `Local` (reads config today) and `queries validate` is
|
||||
/// `Direct` (opens a graph directly today). Both converge to the RFC end-state
|
||||
/// (served / control) only when later slices re-route them.
|
||||
/// `queries`/`policy` read a cluster's applied state (`Control`).
|
||||
pub(crate) fn command_capability(cmd: &Command) -> Capability {
|
||||
if let Command::Graphs { .. } = cmd {
|
||||
return Capability::Served;
|
||||
|
|
@ -100,8 +98,7 @@ pub(crate) fn command_capability(cmd: &Command) -> Capability {
|
|||
/// The plane a subcommand belongs to. Exhaustive — a new `Command` variant
|
||||
/// will not compile until classified. Descends into the nested enums where
|
||||
/// the plane differs per subcommand (`schema plan` is storage while `schema
|
||||
/// show`/`apply` are data; `queries validate` opens the graph while `queries
|
||||
/// list` only reads config).
|
||||
/// show`/`apply` are data; `queries`/`policy` read cluster applied state).
|
||||
pub(crate) fn command_plane(cmd: &Command) -> Plane {
|
||||
match cmd {
|
||||
Command::Query { .. }
|
||||
|
|
@ -119,23 +116,22 @@ pub(crate) fn command_plane(cmd: &Command) -> Plane {
|
|||
Command::Schema {
|
||||
command: SchemaCommand::Plan { .. },
|
||||
} => Plane::Storage,
|
||||
Command::Queries {
|
||||
command: QueriesCommand::Validate { .. },
|
||||
} => Plane::Storage,
|
||||
Command::Queries {
|
||||
command: QueriesCommand::List { .. },
|
||||
} => Plane::Session,
|
||||
// `queries` and `policy` tooling now source their inputs from a
|
||||
// cluster's applied state (`--cluster`), so they live on the control
|
||||
// plane (RFC-011 — omnigraph.yaml excised from the CLI).
|
||||
Command::Queries { .. } => Plane::Control,
|
||||
Command::Policy { .. } => Plane::Control,
|
||||
Command::Init { .. }
|
||||
| Command::Optimize { .. }
|
||||
| Command::Repair { .. }
|
||||
| Command::Cleanup { .. }
|
||||
| Command::Lint { .. } => Plane::Storage,
|
||||
Command::Cluster { .. } => Plane::Control,
|
||||
Command::Policy { .. }
|
||||
Command::Alias { .. }
|
||||
| Command::Embed(_)
|
||||
| Command::Login { .. }
|
||||
| Command::Logout { .. }
|
||||
| Command::Config { .. }
|
||||
| Command::Profile { .. }
|
||||
| Command::Version => Plane::Session,
|
||||
}
|
||||
}
|
||||
|
|
@ -147,7 +143,7 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str {
|
|||
Command::Version => "version",
|
||||
Command::Login { .. } => "login",
|
||||
Command::Logout { .. } => "logout",
|
||||
Command::Config { .. } => "config",
|
||||
Command::Profile { .. } => "profile",
|
||||
Command::Embed(_) => "embed",
|
||||
Command::Init { .. } => "init",
|
||||
Command::Load { .. } => "load",
|
||||
|
|
@ -168,6 +164,7 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str {
|
|||
Command::Commit { .. } => "commit",
|
||||
Command::Query { .. } => "query",
|
||||
Command::Mutate { .. } => "mutate",
|
||||
Command::Alias { .. } => "alias",
|
||||
Command::Policy { .. } => "policy",
|
||||
Command::Optimize { .. } => "optimize",
|
||||
Command::Repair { .. } => "repair",
|
||||
|
|
@ -177,35 +174,128 @@ pub(crate) fn command_label(cmd: &Command) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reject the data-plane addressing flags (`--server`/`--graph`) on any verb
|
||||
/// that does not live on the data plane. This replaces the old silent-ignore
|
||||
/// — e.g. `optimize --server prod` previously dropped `--server` and tried to
|
||||
/// resolve a default target, failing (if at all) with an unrelated message.
|
||||
/// Now it fails with one honest, declared error. RFC-010 Slice 1.
|
||||
/// The verbs that consume a cluster scope. Maintenance/lint select a graph with
|
||||
/// `--cluster <root> --graph <id>`; policy/queries inspect the cluster's
|
||||
/// applied control-plane state and may optionally use `--graph` to select one
|
||||
/// bundle/registry. `init` is storage-plane too but *creates* a graph (cluster
|
||||
/// graphs are born from `cluster apply`, not `init`), and `schema plan` takes a
|
||||
/// positional URI, so the guard rejects `--cluster`/`--graph` there rather than
|
||||
/// silently dropping the flag.
|
||||
pub(crate) fn accepts_cluster_addressing(cmd: &Command) -> bool {
|
||||
matches!(
|
||||
cmd,
|
||||
Command::Optimize { .. }
|
||||
| Command::Repair { .. }
|
||||
| Command::Cleanup { .. }
|
||||
// `lint` can type-check a `.gq` against a cluster graph's schema
|
||||
// (RFC-011): `--cluster <dir> --graph <id>`.
|
||||
| Command::Lint { .. }
|
||||
// The policy/queries tooling addresses a cluster's applied state
|
||||
// (RFC-011): `--cluster <dir>` selects the cluster, `--graph <id>`
|
||||
// picks a graph's bundle/registry within it.
|
||||
| Command::Policy { .. }
|
||||
| Command::Queries { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Reject a scope-addressing flag (`--server`/`--cluster`/`--graph`) on a verb
|
||||
/// that cannot consume it, rather than silently dropping it (the old behavior:
|
||||
/// e.g. `optimize --server prod` dropped `--server` and failed later with an
|
||||
/// unrelated message). `alias` gets an extra guard because its binding owns all
|
||||
/// addressing and several ignored globals sit outside this three-flag guard.
|
||||
/// Each flag has a distinct valid surface:
|
||||
/// - `--server` → served-graph scopes (`any`/`served`);
|
||||
/// - `--cluster` → cluster-scoped direct/control verbs;
|
||||
/// - `--graph` → any multi-graph scope: a served scope *or* a cluster one.
|
||||
/// RFC-010 Slice 1, generalized for RFC-011 cluster addressing.
|
||||
pub(crate) fn guard_addressing(cli: &Cli) -> Result<()> {
|
||||
if cli.server.is_none() && cli.graph.is_none() {
|
||||
if let Command::Alias { .. } = &cli.command {
|
||||
let mut flags = Vec::new();
|
||||
if cli.server.is_some() {
|
||||
flags.push("--server");
|
||||
}
|
||||
if cli.graph.is_some() {
|
||||
flags.push("--graph");
|
||||
}
|
||||
if cli.store.is_some() {
|
||||
flags.push("--store");
|
||||
}
|
||||
if cli.cluster.is_some() {
|
||||
flags.push("--cluster");
|
||||
}
|
||||
if cli.profile.is_some() {
|
||||
flags.push("--profile");
|
||||
}
|
||||
if cli.as_actor.is_some() {
|
||||
flags.push("--as");
|
||||
}
|
||||
if !flags.is_empty() {
|
||||
bail!(
|
||||
"`alias` uses the server, graph, and stored query declared in \
|
||||
`aliases.<name>` in ~/.omnigraph/config.yaml; remove global scope \
|
||||
flag(s): {}",
|
||||
flags.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
if cli.server.is_none() && cli.cluster.is_none() && cli.graph.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let capability = command_capability(&cli.command);
|
||||
if capability.accepts_server_addressing() {
|
||||
return Ok(());
|
||||
}
|
||||
let label = command_label(&cli.command);
|
||||
let how = match capability {
|
||||
Capability::Direct => match cli.command {
|
||||
Command::Init { .. } => "Pass a storage URI.",
|
||||
_ => "Pass a storage URI, or --cluster <dir> --cluster-graph <id>.",
|
||||
let cluster_ok = accepts_cluster_addressing(&cli.command);
|
||||
|
||||
if cli.server.is_some() && !capability.accepts_server_addressing() {
|
||||
bail!(
|
||||
"`{label}` is a {} command; --server addresses a served graph and does not apply.{}",
|
||||
capability.describe(),
|
||||
remediation(capability, &cli.command),
|
||||
);
|
||||
}
|
||||
if cli.cluster.is_some() && !cluster_ok {
|
||||
bail!(
|
||||
"`{label}` is a {} command; --cluster addresses a cluster-scoped command \
|
||||
and does not apply.{}",
|
||||
capability.describe(),
|
||||
remediation(capability, &cli.command),
|
||||
);
|
||||
}
|
||||
if cli.graph.is_some() && !(capability.accepts_server_addressing() || cluster_ok) {
|
||||
bail!(
|
||||
"`{label}` is a {} command; --graph selects a graph within a server or cluster \
|
||||
scope and does not apply.{}",
|
||||
capability.describe(),
|
||||
remediation(capability, &cli.command),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The "what to do instead" tail for a wrong-address error, by capability.
|
||||
/// Includes its own leading space when non-empty so the caller appends it
|
||||
/// directly — an empty tail (the served-addressing capabilities, which only
|
||||
/// reach this fn for a misplaced `--cluster`/`--graph`) leaves no trailing space.
|
||||
fn remediation(capability: Capability, cmd: &Command) -> &'static str {
|
||||
match capability {
|
||||
Capability::Direct => match cmd {
|
||||
Command::Init { .. } => " Pass a storage URI.",
|
||||
Command::Optimize { .. } | Command::Repair { .. } | Command::Cleanup { .. } => {
|
||||
" Pass a storage URI, or --cluster <dir> --graph <id>."
|
||||
}
|
||||
_ => " Pass a storage URI.",
|
||||
},
|
||||
Capability::Control => "It operates on a cluster (pass --config <dir>).",
|
||||
Capability::Local => "It does not address a graph.",
|
||||
Capability::Any | Capability::Served => {
|
||||
unreachable!("served-addressing capabilities returned early")
|
||||
}
|
||||
};
|
||||
bail!(
|
||||
"`{label}` is a {} command; --server/--graph address a served graph and do not apply. {how}",
|
||||
capability.describe()
|
||||
);
|
||||
Capability::Control => match cmd {
|
||||
Command::Cluster { .. } => {
|
||||
" It operates on a cluster config directory (pass --config <dir>)."
|
||||
}
|
||||
Command::Policy { .. } | Command::Queries { .. } => {
|
||||
" It operates on a cluster (pass --cluster <dir|uri>, or select a cluster profile)."
|
||||
}
|
||||
_ => " It operates on a cluster.",
|
||||
},
|
||||
Capability::Local => " It does not address a graph.",
|
||||
Capability::Any | Capability::Served => "",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -235,11 +325,17 @@ mod tests {
|
|||
// The one Data→Served refinement — if the `graphs` guard were deleted,
|
||||
// every other assertion here would still pass.
|
||||
assert_eq!(cap(&["omnigraph", "graphs", "list"]), Capability::Served);
|
||||
assert_eq!(cap(&["omnigraph", "alias", "who"]), Capability::Local);
|
||||
assert_eq!(cap(&["omnigraph", "optimize", "graph.omni"]), Capability::Direct);
|
||||
assert_eq!(cap(&["omnigraph", "schema", "plan", "--schema", "s.pg", "graph.omni"]), Capability::Direct);
|
||||
assert_eq!(cap(&["omnigraph", "cluster", "status", "--config", "."]), Capability::Control);
|
||||
assert_eq!(cap(&["omnigraph", "version"]), Capability::Local);
|
||||
assert_eq!(cap(&["omnigraph", "queries", "list"]), Capability::Local);
|
||||
// `queries`/`policy` tooling reads cluster state now (control plane).
|
||||
assert_eq!(cap(&["omnigraph", "queries", "list"]), Capability::Control);
|
||||
assert_eq!(
|
||||
cap(&["omnigraph", "policy", "validate"]),
|
||||
Capability::Control
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,9 +1,31 @@
|
|||
use clap::ValueEnum;
|
||||
use color_eyre::eyre::Result;
|
||||
use omnigraph_server::ReadOutputFormat;
|
||||
use omnigraph_server::api::ReadOutput;
|
||||
use omnigraph_server::config::TableCellLayout;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
/// Output rendering format for read-shaped commands (`read`/`query`/`alias`).
|
||||
/// A CLI presentation concern — lives here, not in the server.
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReadOutputFormat {
|
||||
#[default]
|
||||
Table,
|
||||
Kv,
|
||||
Csv,
|
||||
Jsonl,
|
||||
Json,
|
||||
}
|
||||
|
||||
/// How an over-wide table cell is laid out when rendering `--format table`.
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TableCellLayout {
|
||||
#[default]
|
||||
Truncate,
|
||||
Wrap,
|
||||
}
|
||||
|
||||
pub struct ReadRenderOptions {
|
||||
pub max_column_width: usize,
|
||||
pub cell_layout: TableCellLayout,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ pub(crate) struct ScopeFlags<'a> {
|
|||
pub(crate) profile: Option<&'a str>,
|
||||
pub(crate) store: Option<&'a str>,
|
||||
pub(crate) server: Option<&'a str>,
|
||||
pub(crate) cluster: Option<&'a str>,
|
||||
pub(crate) graph: Option<&'a str>,
|
||||
pub(crate) uri: Option<String>,
|
||||
}
|
||||
|
|
@ -56,17 +57,49 @@ pub(crate) fn resolve_scope(
|
|||
capability: Capability,
|
||||
flags: ScopeFlags<'_>,
|
||||
) -> Result<ResolvedScope> {
|
||||
// `--store` is its own way to address a graph; combining it with a positional
|
||||
// URI or `--server` is a contradiction, not a silent precedence.
|
||||
if flags.store.is_some() && (flags.uri.is_some() || flags.server.is_some()) {
|
||||
// At most one explicit scope primitive may address a command — a positional
|
||||
// URI, `--store`, `--server`, or `--cluster` are mutually exclusive ways to
|
||||
// name the graph. Combining them is a contradiction, not a silent precedence.
|
||||
let primitives: Vec<&str> = [
|
||||
flags.uri.as_deref().map(|_| "a positional URI"),
|
||||
flags.store.map(|_| "--store"),
|
||||
flags.server.map(|_| "--server"),
|
||||
flags.cluster.map(|_| "--cluster"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
if primitives.len() > 1 {
|
||||
bail!(
|
||||
"--store is exclusive with a positional URI and --server — pick one way to \
|
||||
address the graph"
|
||||
"{} are mutually exclusive — pick one way to address the graph",
|
||||
primitives.join(" and ")
|
||||
);
|
||||
}
|
||||
// 1. Any explicit address wins; reproduce today's behavior untouched.
|
||||
// `--store` is an explicit store URI — fold it into `uri`.
|
||||
|
||||
// 1a. `--cluster` is the cluster scope primitive (maintenance): resolve its
|
||||
// root + select the graph with `--graph`.
|
||||
if let Some(cluster) = flags.cluster {
|
||||
return scope_from_binding(
|
||||
op,
|
||||
capability,
|
||||
ScopeBinding::Cluster(cluster.to_string()),
|
||||
flags.graph.map(str::to_string),
|
||||
"--cluster",
|
||||
);
|
||||
}
|
||||
|
||||
// 1b. Any other explicit address wins; reproduce today's behavior untouched.
|
||||
// `--store` is an explicit store URI — fold it into `uri`.
|
||||
if flags.uri.is_some() || flags.server.is_some() || flags.store.is_some() {
|
||||
// `--graph` selects within a multi-graph scope; a bare positional URI /
|
||||
// `--store` is already a single graph, so a stray `--graph` is an error
|
||||
// rather than a silently-dropped flag.
|
||||
if flags.graph.is_some() && flags.server.is_none() {
|
||||
bail!(
|
||||
"--graph selects a graph within a server or cluster scope; a positional \
|
||||
URI / --store is already a single graph"
|
||||
);
|
||||
}
|
||||
return Ok(ResolvedScope {
|
||||
server: flags.server.map(str::to_string),
|
||||
graph: flags.graph.map(str::to_string),
|
||||
|
|
@ -107,6 +140,18 @@ pub(crate) fn resolve_scope(
|
|||
);
|
||||
}
|
||||
|
||||
// 3b. Flat default store scope — the zero-flag local-dev default (RFC-011).
|
||||
// Mutually exclusive with `defaults.server` (enforced at config load).
|
||||
if let Some(store) = op.default_store() {
|
||||
return scope_from_binding(
|
||||
op,
|
||||
capability,
|
||||
ScopeBinding::Store(store.to_string()),
|
||||
flags.graph.map(str::to_string),
|
||||
"operator defaults",
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Nothing resolved — leave the tuple empty; downstream falls through to
|
||||
// today's behavior (legacy `cli.graph` default or a no-address error).
|
||||
Ok(ResolvedScope::default())
|
||||
|
|
@ -128,8 +173,8 @@ fn scope_from_binding(
|
|||
if capability == Capability::Direct {
|
||||
bail!(
|
||||
"this command needs direct storage access, but {source} resolves a \
|
||||
server scope; name storage explicitly with --store <uri> (or a \
|
||||
--cluster/--cluster-graph for a managed graph)"
|
||||
server scope; name storage explicitly with --store <uri> (or \
|
||||
--cluster <dir> --graph <id> for a managed graph)"
|
||||
);
|
||||
}
|
||||
Ok(ResolvedScope {
|
||||
|
|
@ -141,23 +186,25 @@ fn scope_from_binding(
|
|||
ScopeBinding::Cluster(cluster) => {
|
||||
if capability == Capability::Any {
|
||||
bail!(
|
||||
"{source} resolves a cluster scope, which is maintenance-only; run \
|
||||
data commands through a server, or use --store <uri> for ad-hoc \
|
||||
direct access"
|
||||
"{source} resolves a cluster scope, which is not valid for graph data \
|
||||
commands; run data commands through a server, or use --store <uri> \
|
||||
for ad-hoc direct access"
|
||||
);
|
||||
}
|
||||
// A cluster binding is a config name (resolved against `clusters:`)
|
||||
// or a literal root URI.
|
||||
let root = if let Some(root) = op.cluster_root(&cluster) {
|
||||
root.to_string()
|
||||
} else if cluster.contains("://") {
|
||||
cluster
|
||||
} else {
|
||||
bail!(
|
||||
"unknown cluster '{cluster}' ({source}); define it under `clusters:` \
|
||||
in operator config, or use a literal root URI"
|
||||
);
|
||||
};
|
||||
// A cluster value is a config name (resolved against `clusters:`)
|
||||
// or a literal root: an `s3://`/`file://` URI or a local cluster
|
||||
// directory. Only a configured name is rewritten; anything else is
|
||||
// passed through to the cluster-state resolver verbatim, so a bare
|
||||
// directory path keeps working as it did for per-command `--cluster`.
|
||||
let root = op
|
||||
.cluster_root(&cluster)
|
||||
.map(str::to_string)
|
||||
.unwrap_or(cluster);
|
||||
// A cluster holds many graphs; maintenance addresses one at a time.
|
||||
// When no `--graph`/`default_graph` is given, leave `cluster_graph`
|
||||
// empty and defer to the async storage-URI resolver (RFC-011 D7),
|
||||
// which enumerates the catalog: auto-use a sole graph, else error
|
||||
// and list the candidates.
|
||||
Ok(ResolvedScope {
|
||||
cluster: Some(root),
|
||||
cluster_graph: graph,
|
||||
|
|
@ -192,6 +239,7 @@ mod tests {
|
|||
profile: None,
|
||||
store: None,
|
||||
server: None,
|
||||
cluster: None,
|
||||
graph: None,
|
||||
uri: None,
|
||||
}
|
||||
|
|
@ -230,7 +278,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn store_is_exclusive_with_positional_uri_and_server() {
|
||||
fn scope_primitives_are_mutually_exclusive() {
|
||||
let op = OperatorConfig::default();
|
||||
for flags in [
|
||||
ScopeFlags {
|
||||
|
|
@ -243,12 +291,128 @@ mod tests {
|
|||
server: Some("prod"),
|
||||
..flags()
|
||||
},
|
||||
ScopeFlags {
|
||||
cluster: Some("./brain"),
|
||||
uri: Some("file://other.omni".into()),
|
||||
..flags()
|
||||
},
|
||||
ScopeFlags {
|
||||
cluster: Some("./brain"),
|
||||
server: Some("prod"),
|
||||
..flags()
|
||||
},
|
||||
] {
|
||||
let err = resolve_scope(&op, Capability::Any, flags).unwrap_err().to_string();
|
||||
assert!(err.contains("--store is exclusive"), "{err}");
|
||||
let err = resolve_scope(&op, Capability::Direct, flags)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("mutually exclusive"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_flag_resolves_root_and_graph_for_maintenance() {
|
||||
let op = cfg("clusters:\n brain:\n root: s3://acme/brain\n");
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
cluster: Some("brain"),
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_flag_accepts_a_literal_root_uri() {
|
||||
let op = OperatorConfig::default();
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
cluster: Some("s3://bucket/clusters/brain"),
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://bucket/clusters/brain"));
|
||||
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
cluster: Some("brain"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||
assert_eq!(scope.cluster_graph, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_on_a_bare_store_or_uri_is_rejected() {
|
||||
let op = OperatorConfig::default();
|
||||
for flags in [
|
||||
ScopeFlags {
|
||||
uri: Some("graph.omni".into()),
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
ScopeFlags {
|
||||
store: Some("s3://b/g.omni"),
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
] {
|
||||
let err = resolve_scope(&op, Capability::Any, flags)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("already a single graph"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_default_store_drives_local_verbs() {
|
||||
// RFC-011: `defaults.store` is the zero-flag local default — no flags,
|
||||
// no profile → the store URI resolves as the (single-graph) store scope.
|
||||
let op = cfg("defaults:\n store: file:///tmp/dev.omni\n");
|
||||
let scope = resolve_scope(&op, Capability::Any, flags()).unwrap();
|
||||
assert_eq!(scope.uri.as_deref(), Some("file:///tmp/dev.omni"));
|
||||
assert_eq!(scope.server, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_default_store_rejects_graph() {
|
||||
// A store is already a single graph, so `--graph` against a default
|
||||
// store is a loud error.
|
||||
let op = cfg("defaults:\n store: file:///tmp/dev.omni\n");
|
||||
let err = resolve_scope(
|
||||
&op,
|
||||
Capability::Any,
|
||||
ScopeFlags {
|
||||
graph: Some("knowledge"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("does not apply to a store scope"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_default_server_drives_data_verbs() {
|
||||
let op = cfg("defaults:\n server: prod\n default_graph: knowledge\nservers:\n prod:\n url: https://x\n");
|
||||
|
|
@ -294,6 +458,27 @@ mod tests {
|
|||
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_cluster_scope_with_graph_override() {
|
||||
// The deferral closed by this slice: a `--graph` flag overrides a
|
||||
// profile cluster's default_graph, exactly as it does for a server scope.
|
||||
let op = cfg(
|
||||
"clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n default_graph: knowledge\n",
|
||||
);
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Capability::Direct,
|
||||
ScopeFlags {
|
||||
profile: Some("admin"),
|
||||
graph: Some("archive"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||
assert_eq!(scope.cluster_graph.as_deref(), Some("archive")); // flag beats profile default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_scope_on_maintenance_verb_errors() {
|
||||
let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n");
|
||||
|
|
@ -316,7 +501,7 @@ mod tests {
|
|||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("maintenance-only"), "{err}");
|
||||
assert!(err.contains("not valid for graph data commands"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -683,51 +683,8 @@ fn cluster_apply_locked_exits_nonzero() {
|
|||
assert!(!temp.path().join("__cluster/resources").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_apply_uses_cli_actor_from_local_config() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"cli:\n actor: act-local\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Phase 1: import once (setup, not under test).
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.arg("cluster")
|
||||
.arg("import")
|
||||
.arg("--config")
|
||||
.arg(temp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
|
||||
// Phase 2: apply alone, capturing the echoed actor (idempotent re-runs).
|
||||
let apply = |extra: &[&str]| {
|
||||
let mut command = cli();
|
||||
command.current_dir(temp.path());
|
||||
for arg in extra {
|
||||
command.arg(arg);
|
||||
}
|
||||
let output = command
|
||||
.arg("cluster")
|
||||
.arg("apply")
|
||||
.arg("--config")
|
||||
.arg(temp.path())
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap();
|
||||
json["actor"].clone()
|
||||
};
|
||||
assert_eq!(apply(&[]), "act-local", "cli.actor is the no-flag default");
|
||||
assert_eq!(apply(&["--as", "andrew"]), "andrew", "--as overrides cli.actor");
|
||||
}
|
||||
|
||||
/// RFC-007 PR 1: the operator layer joins the actor chain —
|
||||
/// `--as` > legacy `cli.actor` (RFC-008 window) > `operator.actor` > none.
|
||||
/// RFC-011: the actor chain is `--as` > `operator.actor` > none. The CLI no
|
||||
/// longer reads omnigraph.yaml `cli.actor`.
|
||||
#[test]
|
||||
fn cluster_apply_uses_operator_actor_from_omnigraph_home() {
|
||||
let temp = tempdir().unwrap();
|
||||
|
|
@ -771,41 +728,31 @@ fn cluster_apply_uses_operator_actor_from_omnigraph_home() {
|
|||
json["actor"].clone()
|
||||
};
|
||||
|
||||
// No --as, no omnigraph.yaml: the operator identity applies.
|
||||
// No --as: the operator identity applies.
|
||||
assert_eq!(
|
||||
apply(&[]),
|
||||
"act-operator",
|
||||
"operator.actor is the no-flag, no-legacy-config default"
|
||||
"operator.actor is the no-flag default"
|
||||
);
|
||||
// --as still wins over everything.
|
||||
// --as still wins over the operator layer.
|
||||
assert_eq!(apply(&["--as", "andrew"]), "andrew");
|
||||
|
||||
// A legacy cli.actor (RFC-008 window) outranks the operator layer.
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"cli:\n actor: act-legacy\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
apply(&[]),
|
||||
"act-legacy",
|
||||
"legacy cli.actor wins over operator.actor during the deprecation window"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_approve_uses_cli_actor_fallback() {
|
||||
fn cluster_approve_uses_operator_actor_fallback() {
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
let operator_home = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"cli:\n actor: act-local\n",
|
||||
operator_home.path().join("config.yaml"),
|
||||
"operator:\n actor: act-operator\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Converge, then remove the graph so a gated delete is pending.
|
||||
for command in ["import", "apply"] {
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.arg("cluster")
|
||||
.arg(command)
|
||||
.arg("--config")
|
||||
|
|
@ -818,6 +765,7 @@ fn cluster_approve_uses_cli_actor_fallback() {
|
|||
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.arg("cluster")
|
||||
.arg("approve")
|
||||
.arg("graph.knowledge")
|
||||
|
|
@ -829,14 +777,17 @@ fn cluster_approve_uses_cli_actor_fallback() {
|
|||
assert!(output.status.success(), "{output:?}");
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim()).unwrap();
|
||||
assert_eq!(json["approved_by"], "act-local");
|
||||
assert_eq!(json["approved_by"], "act-operator");
|
||||
|
||||
// With neither flag nor config: refused with the actionable message.
|
||||
// With neither flag nor operator config: refused with the actionable
|
||||
// message (an approval without an approver is meaningless).
|
||||
let bare = tempdir().unwrap();
|
||||
write_cluster_config_fixture(bare.path());
|
||||
let bare_home = tempdir().unwrap();
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.current_dir(bare.path())
|
||||
.env("OMNIGRAPH_HOME", bare_home.path())
|
||||
.arg("cluster")
|
||||
.arg("approve")
|
||||
.arg("graph.knowledge")
|
||||
|
|
@ -845,11 +796,13 @@ fn cluster_approve_uses_cli_actor_fallback() {
|
|||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("--as"), "{stderr}");
|
||||
assert!(stderr.contains("cli.actor"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_commands_ignore_malformed_local_config() {
|
||||
fn cluster_commands_ignore_legacy_omnigraph_yaml() {
|
||||
// RFC-011: the CLI never reads omnigraph.yaml for cluster commands — a
|
||||
// present (even malformed) legacy file is inert. The actor falls back to
|
||||
// `operator.actor`, then to none (no loud failure on absence).
|
||||
let temp = tempdir().unwrap();
|
||||
write_cluster_config_fixture(temp.path());
|
||||
fs::write(temp.path().join("omnigraph.yaml"), "{{{{ not yaml").unwrap();
|
||||
|
|
@ -873,14 +826,11 @@ fn cluster_commands_ignore_malformed_local_config() {
|
|||
"cluster {command} touched omnigraph.yaml"
|
||||
);
|
||||
}
|
||||
// import + apply with an explicit --as: the config is never loaded.
|
||||
for (command, args) in [("import", vec![]), ("apply", vec!["--as", "andrew"])] {
|
||||
let mut invocation = cli();
|
||||
invocation.current_dir(temp.path());
|
||||
for arg in &args {
|
||||
invocation.arg(arg);
|
||||
}
|
||||
let output = invocation
|
||||
// import + apply (no --as, no operator config): the legacy file is never
|
||||
// loaded and the no-actor apply succeeds (actor defaults to none).
|
||||
for command in ["import", "apply"] {
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.arg("cluster")
|
||||
.arg(command)
|
||||
.arg("--config")
|
||||
|
|
@ -893,20 +843,6 @@ fn cluster_commands_ignore_malformed_local_config() {
|
|||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
// Only the no-flag actor lookup is allowed to fail, and loudly.
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.current_dir(temp.path())
|
||||
.arg("cluster")
|
||||
.arg("apply")
|
||||
.arg("--config")
|
||||
.arg(temp.path()),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("omnigraph.yaml") && stderr.contains("--as"),
|
||||
"the actor-default config read must fail loudly and actionably: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -975,7 +911,7 @@ fn optimize_resolves_a_cluster_graph_by_id() {
|
|||
.arg("optimize")
|
||||
.arg("--cluster")
|
||||
.arg(temp.path())
|
||||
.arg("--cluster-graph")
|
||||
.arg("--graph")
|
||||
.arg("knowledge")
|
||||
.arg("--json"),
|
||||
);
|
||||
|
|
@ -994,7 +930,7 @@ fn optimize_unknown_cluster_graph_id_errors() {
|
|||
.arg("optimize")
|
||||
.arg("--cluster")
|
||||
.arg(temp.path())
|
||||
.arg("--cluster-graph")
|
||||
.arg("--graph")
|
||||
.arg("does-not-exist")
|
||||
.arg("--json"),
|
||||
);
|
||||
|
|
@ -1006,19 +942,80 @@ fn optimize_unknown_cluster_graph_id_errors() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_flag_requires_cluster_graph() {
|
||||
// clap enforces both-or-neither.
|
||||
fn optimize_auto_uses_the_sole_cluster_graph() {
|
||||
// RFC-011 D7: a cluster with exactly one applied graph needs no --graph —
|
||||
// the resolver enumerates the catalog and uses the only candidate.
|
||||
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(
|
||||
cli()
|
||||
.arg("optimize")
|
||||
.arg("--cluster")
|
||||
.arg(".")
|
||||
.arg(temp.path())
|
||||
.arg("--json"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("cluster-graph") || stderr.contains("required"),
|
||||
"expected --cluster to require --cluster-graph; got: {stderr}"
|
||||
stderr.contains("2 graphs")
|
||||
&& stderr.contains("archive")
|
||||
&& stderr.contains("knowledge")
|
||||
&& stderr.contains("--graph <id>"),
|
||||
"expected a candidate-listing error; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1042,6 +1039,47 @@ fn init_refuses_a_cluster_managed_path_and_signposts_cluster_apply() {
|
|||
assert!(!temp.path().join("graphs").join("sneaky.omni").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_apply_refuses_a_cluster_managed_graph_and_signposts_cluster_apply() {
|
||||
// RFC-011 Decision 10: a direct `schema apply` against a cluster-managed
|
||||
// graph's storage root would bypass the ledger/recovery/approvals, so it is
|
||||
// refused and points at `cluster apply` (mirrors `init`'s refusal).
|
||||
let temp = applied_knowledge_cluster();
|
||||
// A schema that WOULD change the graph (adds `bio`) — so the no-mutation
|
||||
// assertion below is meaningful, not a no-op re-apply.
|
||||
fs::write(
|
||||
temp.path().join("people_v2.pg"),
|
||||
"node Person {\n name: String @key\n age: I32?\n bio: String?\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let out = output_failure(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg("--schema")
|
||||
.arg(temp.path().join("people_v2.pg"))
|
||||
.arg("--store")
|
||||
.arg(temp.path().join("graphs").join("knowledge.omni")),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("cluster apply"),
|
||||
"schema apply against a cluster-managed graph should signpost `cluster apply`; got: {stderr}"
|
||||
);
|
||||
// And it bailed BEFORE mutating: the live schema still lacks `bio`.
|
||||
let show = output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("show")
|
||||
.arg(temp.path().join("graphs").join("knowledge.omni")),
|
||||
);
|
||||
assert!(
|
||||
!stdout_string(&show).contains("bio"),
|
||||
"the refused apply must not have changed the live schema; got: {}",
|
||||
stdout_string(&show)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_outside_a_cluster_still_works() {
|
||||
// Regression guard: ordinary init (no cluster layout) is unaffected.
|
||||
|
|
@ -1076,7 +1114,7 @@ fn optimize_by_cluster_works_when_catalog_payloads_are_degraded() {
|
|||
.arg("optimize")
|
||||
.arg("--cluster")
|
||||
.arg(temp.path())
|
||||
.arg("--cluster-graph")
|
||||
.arg("--graph")
|
||||
.arg("knowledge")
|
||||
.arg("--json"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
use std::fs;
|
||||
|
||||
use omnigraph::db::Omnigraph;
|
||||
use tempfile::tempdir;
|
||||
|
||||
mod support;
|
||||
|
|
@ -236,27 +237,28 @@ fn cluster_e2e_out_of_band_schema_drift_then_apply_converges_it() {
|
|||
let apply = cluster_json(temp.path(), "apply");
|
||||
assert_eq!(apply["converged"], true, "{apply}");
|
||||
|
||||
// Out-of-band: the live graph evolves, cluster.yaml stays put.
|
||||
fs::write(
|
||||
temp.path().join("people_v2.pg"),
|
||||
r#"
|
||||
// Out-of-band: the live graph evolves while cluster.yaml stays put. RFC-011
|
||||
// D10 makes the CLI `schema apply` refuse a cluster-managed graph, so this
|
||||
// simulates a true bypass — a direct engine apply against the storage root,
|
||||
// exactly the drift the control plane must still detect and converge.
|
||||
let people_v2 = r#"
|
||||
node Person {
|
||||
name: String @key
|
||||
age: I32?
|
||||
bio: String?
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
output_success(
|
||||
cli()
|
||||
.arg("schema")
|
||||
.arg("apply")
|
||||
.arg(temp.path().join("graphs/knowledge.omni"))
|
||||
.arg("--schema")
|
||||
.arg(temp.path().join("people_v2.pg"))
|
||||
.arg("--json"),
|
||||
);
|
||||
"#;
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let db = Omnigraph::open(
|
||||
temp.path()
|
||||
.join("graphs/knowledge.omni")
|
||||
.to_string_lossy()
|
||||
.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.apply_schema(people_v2).await.unwrap();
|
||||
});
|
||||
|
||||
// Drift is visible...
|
||||
let refresh = cluster_json(temp.path(), "refresh");
|
||||
|
|
|
|||
|
|
@ -165,12 +165,87 @@ fn optimize_with_server_flag_errors_wrong_plane() {
|
|||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("`optimize` is a direct (storage-native) command")
|
||||
&& stderr.contains("--server/--graph address a served graph and do not apply")
|
||||
&& stderr.contains("Pass a storage URI, or --cluster <dir> --cluster-graph <id>."),
|
||||
&& stderr.contains("--server addresses a served graph and does not apply")
|
||||
&& stderr.contains("Pass a storage URI, or --cluster <dir> --graph <id>."),
|
||||
"wrong-capability guard message not found; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_address_guard_message_has_no_trailing_space() {
|
||||
// The remediation tail is empty for served-addressing capabilities, so a
|
||||
// misplaced --cluster on a data verb must not leave "… does not apply. "
|
||||
// with a dangling space (error text is observable contract). NO_COLOR keeps
|
||||
// the assertion off ANSI styling.
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.env("NO_COLOR", "1")
|
||||
.arg("query")
|
||||
.arg("--cluster")
|
||||
.arg("./brain")
|
||||
.arg("-e")
|
||||
.arg("query q { Person { id } }"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("and does not apply."),
|
||||
"expected the wrong-address message; got: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("and does not apply. "),
|
||||
"trailing space after the message; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_flag_on_a_positional_uri_errors() {
|
||||
// RFC-011: `--graph` selects within a multi-graph scope (a server or
|
||||
// cluster). An explicit `--store <uri>` is already a single graph, so
|
||||
// pairing it with `--graph` is a loud error, not a silently-dropped flag.
|
||||
// (The guard lets `--graph` reach a data verb; the scope resolver rejects
|
||||
// it.)
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
init_graph(&graph);
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("query")
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--graph")
|
||||
.arg("knowledge")
|
||||
.arg("-e")
|
||||
.arg("query q { Person { id } }"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("already a single graph"),
|
||||
"expected --graph-on-explicit-store rejection; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_by_name_against_a_store_needs_a_server() {
|
||||
// RFC-011 D3: by-name (catalog) invocation is served-only — the catalog is
|
||||
// server-owned, so a bare `--store` has nothing to resolve the name
|
||||
// against. The ad-hoc lane (`-e`/`--query`) is the local alternative.
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
init_graph(&graph);
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("query")
|
||||
.arg("find_people")
|
||||
.arg("--store")
|
||||
.arg(&graph),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("needs a server"),
|
||||
"expected a served-only by-name error; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimize_with_remote_target_errors_storage_plane() {
|
||||
// RFC-010 Slice 1: a maintenance verb pointed at a remote URI fails loudly
|
||||
|
|
@ -454,10 +529,9 @@ query list_people() {
|
|||
|
||||
#[test]
|
||||
fn deprecated_read_and_change_subcommands_emit_warnings() {
|
||||
// Both subcommands require `--query`/`--query-string`/`--alias`, so
|
||||
// invoking them with no args will exit non-zero. That's fine --
|
||||
// we only care that the deprecation warning is printed before the
|
||||
// argument-required error.
|
||||
// Both subcommands require `--query`/`--query-string`, so invoking them
|
||||
// with no args will exit non-zero. That's fine -- we only care that the
|
||||
// deprecation warning is printed before the argument-required error.
|
||||
let output = cli().arg("read").output().unwrap();
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
|
|
@ -525,13 +599,15 @@ query list_people() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn query_lint_can_resolve_graph_and_query_from_config() {
|
||||
fn query_lint_can_resolve_graph_from_store_scope() {
|
||||
// RFC-011: lint resolves its graph target through `--store` (the direct
|
||||
// scope), not omnigraph.yaml's cli.graph; the .gq path is plain cwd-relative.
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
init_graph(&graph);
|
||||
let query_path = temp.path().join("queries.gq");
|
||||
write_query_file(
|
||||
&temp.path().join("queries.gq"),
|
||||
&query_path,
|
||||
r#"
|
||||
query list_people() {
|
||||
match { $p: Person }
|
||||
|
|
@ -539,16 +615,15 @@ query list_people() {
|
|||
}
|
||||
"#,
|
||||
);
|
||||
write_config(&config_path, &local_yaml_config(&graph));
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("query")
|
||||
.arg("lint")
|
||||
.arg("--query")
|
||||
.arg("queries.gq")
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.arg(&query_path)
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--json"),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
|
|
@ -616,7 +691,9 @@ query list_people() {
|
|||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("lint requires --schema <schema.pg> or a resolvable graph target")
|
||||
stderr.contains("lint requires --schema <schema.pg>")
|
||||
|| stderr.contains("no graph addressed"),
|
||||
"expected a schema-or-graph-target requirement; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -785,10 +862,10 @@ fn read_json_outputs_rows_for_named_query() {
|
|||
let output = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--query")
|
||||
.arg(&queries)
|
||||
.arg("--name")
|
||||
.arg("get_person")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Alice"}"#)
|
||||
|
|
@ -817,7 +894,6 @@ fn read_via_store_flag_and_profile_match_positional_uri() {
|
|||
let output = output_success(
|
||||
cmd.arg("--query")
|
||||
.arg(&queries)
|
||||
.arg("--name")
|
||||
.arg("get_person")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Alice"}"#)
|
||||
|
|
@ -826,8 +902,8 @@ fn read_via_store_flag_and_profile_match_positional_uri() {
|
|||
serde_json::from_slice(&output.stdout).unwrap()
|
||||
};
|
||||
|
||||
// Baseline: positional URI.
|
||||
let baseline = read_rows(cli().arg("query").arg(&graph));
|
||||
// Baseline: --store names the graph.
|
||||
let baseline = read_rows(cli().arg("query").arg("--store").arg(&graph));
|
||||
assert_eq!(baseline["rows"][0]["p.name"], "Alice");
|
||||
|
||||
// --store names the same graph directly.
|
||||
|
|
@ -914,43 +990,38 @@ fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() {
|
|||
);
|
||||
}
|
||||
|
||||
// RFC-011: `policy validate|test|explain` source the Cedar bundle from a
|
||||
// converged cluster's applied policies (`--cluster <dir>` + `--graph <id>`),
|
||||
// not omnigraph.yaml's policy.file.
|
||||
|
||||
#[test]
|
||||
fn policy_validate_accepts_valid_policy_file() {
|
||||
let temp = tempdir().unwrap();
|
||||
let (config, _) = write_policy_config_fixture(temp.path());
|
||||
fn policy_validate_accepts_cluster_bundle() {
|
||||
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML));
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("policy")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path())
|
||||
.arg("--graph")
|
||||
.arg("knowledge"),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
|
||||
assert!(stdout.contains("policy valid:"));
|
||||
assert!(stdout.contains("policy.yaml"));
|
||||
assert!(stdout.contains("[2 actors]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_validate_fails_for_invalid_policy_file() {
|
||||
let temp = tempdir().unwrap();
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
let policy = temp.path().join("policy.yaml");
|
||||
fs::write(
|
||||
&config,
|
||||
r#"
|
||||
project:
|
||||
name: policy-test-graph
|
||||
policy:
|
||||
file: ./policy.yaml
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
&policy,
|
||||
r#"
|
||||
fn policy_validate_fails_for_invalid_cluster_bundle() {
|
||||
// The cluster does not validate a policy bundle's internal rules, so an
|
||||
// applied-but-malformed bundle reaches `policy validate`, which compiles it
|
||||
// and surfaces the error (here: a duplicate rule id).
|
||||
let cluster = converged_loaded_cluster(
|
||||
"knowledge",
|
||||
Some(
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
team: [act-andrew]
|
||||
|
|
@ -966,26 +1037,42 @@ rules:
|
|||
actions: [export]
|
||||
branch_scope: any
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
),
|
||||
);
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("policy")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path())
|
||||
.arg("--graph")
|
||||
.arg("knowledge"),
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(stderr.contains("duplicate policy rule id"));
|
||||
assert!(
|
||||
stderr.contains("duplicate policy rule id"),
|
||||
"expected a duplicate-rule error; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_test_runs_declarative_cases() {
|
||||
let temp = tempdir().unwrap();
|
||||
let (config, _) = write_policy_config_fixture(temp.path());
|
||||
fn policy_test_runs_declarative_cases_against_cluster_bundle() {
|
||||
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML));
|
||||
let tests = cluster.path().join("policy.tests.yaml");
|
||||
fs::write(&tests, POLICY_TESTS_YAML).unwrap();
|
||||
|
||||
let output = output_success(cli().arg("policy").arg("test").arg("--config").arg(&config));
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("policy")
|
||||
.arg("test")
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path())
|
||||
.arg("--graph")
|
||||
.arg("knowledge")
|
||||
.arg("--tests")
|
||||
.arg(&tests),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
|
||||
assert!(stdout.contains("policy tests passed: 2 cases"));
|
||||
|
|
@ -993,15 +1080,16 @@ fn policy_test_runs_declarative_cases() {
|
|||
|
||||
#[test]
|
||||
fn policy_explain_reports_decision_and_matched_rule() {
|
||||
let temp = tempdir().unwrap();
|
||||
let (config, _) = write_policy_config_fixture(temp.path());
|
||||
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_YAML));
|
||||
|
||||
let allow = output_success(
|
||||
cli()
|
||||
.arg("policy")
|
||||
.arg("explain")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path())
|
||||
.arg("--graph")
|
||||
.arg("knowledge")
|
||||
.arg("--actor")
|
||||
.arg("act-andrew")
|
||||
.arg("--action")
|
||||
|
|
@ -1017,8 +1105,10 @@ fn policy_explain_reports_decision_and_matched_rule() {
|
|||
cli()
|
||||
.arg("policy")
|
||||
.arg("explain")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path())
|
||||
.arg("--graph")
|
||||
.arg("knowledge")
|
||||
.arg("--actor")
|
||||
.arg("act-bruno")
|
||||
.arg("--action")
|
||||
|
|
@ -1032,22 +1122,26 @@ fn policy_explain_reports_decision_and_matched_rule() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn read_can_resolve_uri_from_config() {
|
||||
fn read_resolves_uri_from_default_store_scope() {
|
||||
// RFC-011: a zero-flag read resolves its graph from `defaults.store` in the
|
||||
// operator config (the local-dev default scope) — no omnigraph.yaml.
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
init_graph(&graph);
|
||||
load_fixture(&graph);
|
||||
write_config(&config, &local_yaml_config(&graph));
|
||||
let home = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
home.path().join("config.yaml"),
|
||||
format!("defaults:\n store: {}\n", graph.to_string_lossy()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.arg("read")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("--query")
|
||||
.arg(fixture("test.gq"))
|
||||
.arg("--name")
|
||||
.arg("get_person")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Alice"}"#)
|
||||
|
|
@ -1067,10 +1161,10 @@ fn read_csv_format_outputs_header_and_row_values() {
|
|||
let output = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--query")
|
||||
.arg(fixture("test.gq"))
|
||||
.arg("--name")
|
||||
.arg("get_person")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Alice"}"#)
|
||||
|
|
@ -1104,10 +1198,10 @@ fn read_uses_operator_default_output_format() {
|
|||
command
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.arg("read")
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--query")
|
||||
.arg(fixture("test.gq"))
|
||||
.arg("--name")
|
||||
.arg("get_person")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Alice"}"#);
|
||||
|
|
@ -1139,10 +1233,10 @@ fn read_jsonl_format_outputs_metadata_header_first() {
|
|||
let output = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--query")
|
||||
.arg(fixture("test.gq"))
|
||||
.arg("--name")
|
||||
.arg("get_person")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Alice"}"#)
|
||||
|
|
@ -1174,6 +1268,7 @@ query insert_person($name: String, $age: I32) {
|
|||
let output = output_success(
|
||||
cli()
|
||||
.arg("change")
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--query")
|
||||
.arg(&mutation_file)
|
||||
|
|
@ -1190,10 +1285,10 @@ query insert_person($name: String, $age: I32) {
|
|||
let verify = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--query")
|
||||
.arg(fixture("test.gq"))
|
||||
.arg("--name")
|
||||
.arg("get_person")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Eve"}"#)
|
||||
|
|
@ -1205,13 +1300,13 @@ query insert_person($name: String, $age: I32) {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn change_can_resolve_uri_and_branch_from_config() {
|
||||
fn change_resolves_uri_and_default_branch_from_store_scope() {
|
||||
// RFC-011: a mutate resolves its graph from `--store` and defaults the
|
||||
// branch to main (no omnigraph.yaml cli.graph / cli.branch).
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
init_graph(&graph);
|
||||
load_fixture(&graph);
|
||||
write_config(&config, &local_yaml_config(&graph));
|
||||
let mutation_file = temp.path().join("config-mutations.gq");
|
||||
write_query_file(
|
||||
&mutation_file,
|
||||
|
|
@ -1225,8 +1320,8 @@ query insert_person($name: String, $age: I32) {
|
|||
let output = output_success(
|
||||
cli()
|
||||
.arg("change")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--query")
|
||||
.arg(&mutation_file)
|
||||
.arg("--params")
|
||||
|
|
@ -1248,6 +1343,7 @@ fn read_requires_name_for_multi_query_files() {
|
|||
let output = output_failure(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--query")
|
||||
.arg(fixture("test.gq")),
|
||||
|
|
@ -1266,6 +1362,7 @@ fn read_supports_inline_query_string() {
|
|||
let output = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--store")
|
||||
.arg(&repo)
|
||||
.arg("-e")
|
||||
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }")
|
||||
|
|
@ -1281,11 +1378,12 @@ fn read_supports_inline_query_string() {
|
|||
|
||||
#[test]
|
||||
fn positional_http_uri_on_a_data_verb_is_rejected() {
|
||||
// RFC-011: a positional/`--uri` http(s):// URL no longer dispatches to a
|
||||
// remote server — that requires `--server <url>`.
|
||||
// RFC-011: a `--store` http(s):// URL no longer dispatches to a remote
|
||||
// server — that requires `--server <url>`.
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("query")
|
||||
.arg("--store")
|
||||
.arg("http://127.0.0.1:1")
|
||||
.arg("-e")
|
||||
.arg("query q() { match { $p: Person { } } return { $p } }"),
|
||||
|
|
@ -1293,7 +1391,7 @@ fn positional_http_uri_on_a_data_verb_is_rejected() {
|
|||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("must be addressed with `--server <url>`"),
|
||||
"expected positional-remote rejection; got: {stderr}"
|
||||
"expected store-remote rejection; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1331,6 +1429,7 @@ fn change_supports_inline_query_string() {
|
|||
let output = output_success(
|
||||
cli()
|
||||
.arg("change")
|
||||
.arg("--store")
|
||||
.arg(&repo)
|
||||
.arg("--query-string")
|
||||
.arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }")
|
||||
|
|
@ -1345,6 +1444,7 @@ fn change_supports_inline_query_string() {
|
|||
let verify = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--store")
|
||||
.arg(&repo)
|
||||
.arg("-e")
|
||||
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }")
|
||||
|
|
@ -1366,6 +1466,7 @@ fn read_rejects_query_string_combined_with_query() {
|
|||
let output = output_failure(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--store")
|
||||
.arg(&repo)
|
||||
.arg("--query")
|
||||
.arg(fixture("test.gq"))
|
||||
|
|
@ -1386,7 +1487,7 @@ fn read_rejects_empty_query_string() {
|
|||
init_graph(&repo);
|
||||
load_fixture(&repo);
|
||||
|
||||
let output = output_failure(cli().arg("read").arg(&repo).arg("-e").arg(""));
|
||||
let output = output_failure(cli().arg("read").arg("--store").arg(&repo).arg("-e").arg(""));
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("must not be empty"),
|
||||
|
|
@ -1514,6 +1615,160 @@ fn branch_delete_rejects_main() {
|
|||
assert!(stderr.contains("cannot delete branch 'main'"));
|
||||
}
|
||||
|
||||
// ── RFC-011 Decision 9: write diagnostics + non-local destructive-confirm ──
|
||||
|
||||
#[test]
|
||||
fn write_echoes_resolved_target_to_stderr() {
|
||||
// Every write echoes its resolved target + access path to stderr; --json
|
||||
// (stdout) is unaffected. A local load → "(direct, local)".
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
init_graph(&graph);
|
||||
let data = fixture("test.jsonl");
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("load")
|
||||
.arg("--mode")
|
||||
.arg("append")
|
||||
.arg("--data")
|
||||
.arg(&data)
|
||||
.arg(&graph)
|
||||
.arg("--json"),
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("omnigraph load →") && stderr.contains("(direct, local)"),
|
||||
"missing write-target echo; stderr: {stderr}"
|
||||
);
|
||||
// stdout still parses as JSON — the echo went to stderr.
|
||||
let _: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_suppresses_the_write_target_echo() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
init_graph(&graph);
|
||||
let data = fixture("test.jsonl");
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("--quiet")
|
||||
.arg("load")
|
||||
.arg("--mode")
|
||||
.arg("append")
|
||||
.arg("--data")
|
||||
.arg(&data)
|
||||
.arg(&graph),
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
!stderr.contains("omnigraph load →"),
|
||||
"--quiet should suppress the echo; stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_delete_against_non_local_scope_refuses_without_yes() {
|
||||
// No bucket needed: the confirm gate fires before the graph is opened.
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("branch")
|
||||
.arg("delete")
|
||||
.arg("--store")
|
||||
.arg("s3://fake-bucket/g.omni")
|
||||
.arg("feature")
|
||||
.arg("--json"),
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("refusing destructive `branch delete`") && stderr.contains("--yes"),
|
||||
"expected a non-local destructive refusal; stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_delete_against_non_local_scope_passes_gate_with_yes() {
|
||||
// With --yes the gate is bypassed; the command then fails for an unrelated
|
||||
// reason (the fake bucket can't be opened), so the refusal must be ABSENT.
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("branch")
|
||||
.arg("delete")
|
||||
.arg("--store")
|
||||
.arg("s3://fake-bucket/g.omni")
|
||||
.arg("feature")
|
||||
.arg("--yes")
|
||||
.arg("--json"),
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
!stderr.contains("refusing destructive"),
|
||||
"--yes should bypass the confirm gate; stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overwrite_load_against_non_local_scope_refuses_without_yes() {
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("load")
|
||||
.arg("--mode")
|
||||
.arg("overwrite")
|
||||
.arg("--data")
|
||||
.arg(fixture("test.jsonl"))
|
||||
.arg("--store")
|
||||
.arg("s3://fake-bucket/g.omni")
|
||||
.arg("--json"),
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("refusing destructive `load --mode overwrite`"),
|
||||
"expected a non-local overwrite refusal; stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_against_non_local_scope_refuses_without_yes() {
|
||||
// Past the --confirm preview gate, a non-local cleanup still needs --yes.
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("cleanup")
|
||||
.arg("--store")
|
||||
.arg("s3://fake-bucket/g.omni")
|
||||
.arg("--keep")
|
||||
.arg("5")
|
||||
.arg("--confirm")
|
||||
.arg("--json"),
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("refusing destructive `cleanup`"),
|
||||
"expected a non-local cleanup refusal; stderr: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_against_local_scope_executes_with_confirm() {
|
||||
// Local cleanup needs no --yes; --confirm alone executes (and echoes).
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
init_graph(&graph);
|
||||
load_fixture(&graph);
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("cleanup")
|
||||
.arg("--keep")
|
||||
.arg("1")
|
||||
.arg("--confirm")
|
||||
.arg(&graph)
|
||||
.arg("--json"),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert!(payload["tables"].as_array().is_some(), "{payload}");
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(stderr.contains("omnigraph cleanup →"), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_merge_defaults_target_to_main() {
|
||||
let temp = tempdir().unwrap();
|
||||
|
|
@ -1663,19 +1918,17 @@ fn snapshot_json_returns_manifest_version_and_tables() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_can_resolve_uri_from_config() {
|
||||
fn snapshot_resolves_uri_from_store_scope() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
init_graph(&graph);
|
||||
load_fixture(&graph);
|
||||
write_config(&config, &local_yaml_config(&graph));
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("snapshot")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("--store")
|
||||
.arg(&graph)
|
||||
.arg("--json"),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
|
|
@ -1816,3 +2069,162 @@ fn cli_fails_for_invalid_merge_requests() {
|
|||
.contains("distinct source and target")
|
||||
);
|
||||
}
|
||||
|
||||
/// RFC-011 Decision 8: `profile list` / `profile show` inspect the operator
|
||||
/// config's profiles read-only. Hermetic via OMNIGRAPH_HOME.
|
||||
fn profile_home() -> tempfile::TempDir {
|
||||
let home = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
home.path().join("config.yaml"),
|
||||
"operator:\n actor: act-andrew\n\
|
||||
defaults:\n output: json\n server: prod\n default_graph: knowledge\n\
|
||||
servers:\n prod:\n url: https://graph.example.com\n\
|
||||
clusters:\n brain:\n root: s3://acme/clusters/brain\n\
|
||||
profiles:\n\
|
||||
\x20 staging:\n server: prod\n default_graph: kb\n\
|
||||
\x20 brain-admin:\n cluster: brain\n\
|
||||
\x20 localdev:\n store: file:///data/dev.omni\n\
|
||||
\x20 broken:\n server: a\n store: b\n",
|
||||
)
|
||||
.unwrap();
|
||||
home
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_list_names_each_profile_with_its_binding_and_marks_active() {
|
||||
let home = profile_home();
|
||||
let out = output_success(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.env("OMNIGRAPH_PROFILE", "staging")
|
||||
.arg("profile")
|
||||
.arg("list"),
|
||||
);
|
||||
let stdout = stdout_string(&out);
|
||||
assert!(stdout.contains("staging (active)"), "{stdout}");
|
||||
assert!(stdout.contains("server: prod"), "{stdout}");
|
||||
assert!(stdout.contains("cluster: brain"), "{stdout}");
|
||||
assert!(stdout.contains("store: file:///data/dev.omni"), "{stdout}");
|
||||
// A malformed (two-scope) profile is reported, not a hard failure.
|
||||
assert!(stdout.contains("broken") && stdout.contains("invalid:"), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_list_json_shape() {
|
||||
let home = profile_home();
|
||||
let out = output_success(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.arg("profile")
|
||||
.arg("list")
|
||||
.arg("--json"),
|
||||
);
|
||||
let items: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
let brain = items
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == "brain-admin")
|
||||
.unwrap();
|
||||
assert_eq!(brain["binding"], "cluster: brain");
|
||||
assert_eq!(brain["scope_kind"], "cluster");
|
||||
assert_eq!(brain["target"], "brain");
|
||||
assert_eq!(brain["valid"], true);
|
||||
assert!(brain["error"].is_null());
|
||||
assert_eq!(brain["active"], false);
|
||||
let broken = items
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["name"] == "broken")
|
||||
.unwrap();
|
||||
assert_eq!(broken["scope_kind"], "invalid");
|
||||
assert_eq!(broken["valid"], false);
|
||||
assert!(broken["target"].is_null());
|
||||
assert!(
|
||||
broken["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("profile 'broken'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_show_resolves_named_scope_endpoints() {
|
||||
let home = profile_home();
|
||||
// A cluster profile resolves its root.
|
||||
let cluster = output_success(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.arg("profile")
|
||||
.arg("show")
|
||||
.arg("brain-admin"),
|
||||
);
|
||||
let cs = stdout_string(&cluster);
|
||||
assert!(cs.contains("scope: cluster brain"), "{cs}");
|
||||
assert!(cs.contains("endpoint: s3://acme/clusters/brain"), "{cs}");
|
||||
|
||||
// A store profile shows its URI as the endpoint.
|
||||
let store = output_success(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.arg("profile")
|
||||
.arg("show")
|
||||
.arg("localdev")
|
||||
.arg("--json"),
|
||||
);
|
||||
let detail: Value = serde_json::from_slice(&store.stdout).unwrap();
|
||||
assert_eq!(detail["scope_kind"], "store");
|
||||
assert_eq!(detail["endpoint"], "file:///data/dev.omni");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_show_without_name_falls_back_to_flat_defaults() {
|
||||
let home = profile_home();
|
||||
let out = output_success(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.arg("profile")
|
||||
.arg("show")
|
||||
.arg("--json"),
|
||||
);
|
||||
let detail: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(detail["name"], "(defaults)");
|
||||
assert_eq!(detail["scope_kind"], "server");
|
||||
assert_eq!(detail["endpoint"], "https://graph.example.com");
|
||||
assert_eq!(detail["default_graph"], "knowledge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_show_without_name_uses_active_env_profile() {
|
||||
let home = profile_home();
|
||||
let out = output_success(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.env("OMNIGRAPH_PROFILE", "brain-admin")
|
||||
.arg("profile")
|
||||
.arg("show")
|
||||
.arg("--json"),
|
||||
);
|
||||
let detail: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
// No name arg, but $OMNIGRAPH_PROFILE selects brain-admin (not the flat defaults).
|
||||
assert_eq!(detail["name"], "brain-admin");
|
||||
assert_eq!(detail["scope_kind"], "cluster");
|
||||
assert_eq!(detail["endpoint"], "s3://acme/clusters/brain");
|
||||
// output_format renders as the canonical lowercase value name.
|
||||
assert_eq!(detail["output_format"], "json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_show_unknown_name_errors() {
|
||||
let home = profile_home();
|
||||
let out = output_failure(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.arg("profile")
|
||||
.arg("show")
|
||||
.arg("nope"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("unknown profile 'nope'"), "{stderr}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
//! Moved verbatim from tests/cli.rs in the modularization.
|
||||
|
||||
|
||||
use serde_json::Value;
|
||||
use tempfile::tempdir;
|
||||
|
||||
mod support;
|
||||
|
|
@ -57,227 +56,172 @@ query list_people() {
|
|||
assert_eq!(stdout_string(&lint_output), stdout_string(&check_output));
|
||||
}
|
||||
|
||||
// Legacy `omnigraph.yaml` `aliases:` invoked via the `--alias` flag were
|
||||
// removed in RFC-011 D4 — operator aliases now live under `omnigraph alias
|
||||
// <name>` (the happy path is covered by system_local's operator-alias e2e).
|
||||
// The legacy file-alias path has no CLI entry point.
|
||||
|
||||
#[test]
|
||||
fn read_alias_from_yaml_config_runs_with_kv_output() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
let query = temp.path().join("aliases.gq");
|
||||
init_graph(&graph);
|
||||
load_fixture(&graph);
|
||||
write_query_file(
|
||||
&query,
|
||||
&std::fs::read_to_string(fixture("test.gq")).unwrap(),
|
||||
fn alias_flag_is_removed_from_query() {
|
||||
// RFC-011 D4: `--alias` no longer exists on query/mutate; use `alias <name>`.
|
||||
let output = output_failure(cli().arg("query").arg("--alias").arg("who"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("unexpected argument") && stderr.contains("--alias"),
|
||||
"expected clap to reject --alias on query; got: {stderr}"
|
||||
);
|
||||
write_config(
|
||||
&config,
|
||||
&format!(
|
||||
"{}aliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n format: kv\n",
|
||||
local_yaml_config(&graph)
|
||||
),
|
||||
);
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("--alias")
|
||||
.arg("owner")
|
||||
.arg("Alice"),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
|
||||
assert!(stdout.contains("row 1"));
|
||||
assert!(stdout.contains("p.name: Alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_alias_uses_alias_target_without_cli_default_and_accepts_url_like_arg() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
let query = temp.path().join("aliases.gq");
|
||||
let data = temp.path().join("url-like.jsonl");
|
||||
init_graph(&graph);
|
||||
write_jsonl(
|
||||
&data,
|
||||
r#"{"type":"Person","data":{"name":"https://example.com","age":30}}"#,
|
||||
);
|
||||
output_success(
|
||||
fn alias_unknown_name_errors_listing_defined() {
|
||||
// Hermetic: an unknown alias fails before any network, listing defined ones.
|
||||
let home = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
home.path().join("config.yaml"),
|
||||
"servers:\n dev:\n url: https://x\naliases:\n who:\n server: dev\n query: find_person\n",
|
||||
)
|
||||
.unwrap();
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("load")
|
||||
.arg("--mode")
|
||||
.arg("overwrite")
|
||||
.arg("--data")
|
||||
.arg(&data)
|
||||
.arg(&graph),
|
||||
.env("OMNIGRAPH_HOME", home.path())
|
||||
.arg("alias")
|
||||
.arg("nope"),
|
||||
);
|
||||
write_query_file(
|
||||
&query,
|
||||
&std::fs::read_to_string(fixture("test.gq")).unwrap(),
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("unknown alias 'nope'") && stderr.contains("who"),
|
||||
"expected an unknown-alias error listing defined aliases; got: {stderr}"
|
||||
);
|
||||
write_config(
|
||||
&config,
|
||||
&format!(
|
||||
"graphs:\n local:\n uri: '{}'\nquery:\n roots:\n - .\npolicy: {{}}\naliases:\n owner:\n command: read\n query: aliases.gq\n name: get_person\n args: [name]\n graph: local\n format: kv\n",
|
||||
graph.to_string_lossy()
|
||||
),
|
||||
);
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("--alias")
|
||||
.arg("owner")
|
||||
.arg("https://example.com"),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
|
||||
assert!(stdout.contains("row 1"));
|
||||
assert!(stdout.contains("p.name: https://example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_alias_from_yaml_config_persists_changes() {
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
let query = temp.path().join("mutations.gq");
|
||||
init_graph(&graph);
|
||||
load_fixture(&graph);
|
||||
write_query_file(
|
||||
&query,
|
||||
r#"
|
||||
query insert_person($name: String, $age: I32) {
|
||||
insert Person { name: $name, age: $age }
|
||||
fn alias_rejects_global_scope_flags_that_the_binding_owns() {
|
||||
for (flag, value) in [
|
||||
("--server", "dev"),
|
||||
("--graph", "local"),
|
||||
("--store", "file:///tmp/graph.omni"),
|
||||
("--cluster", "."),
|
||||
("--profile", "prod"),
|
||||
("--as", "act-op"),
|
||||
] {
|
||||
let output = output_failure(cli().arg(flag).arg(value).arg("alias").arg("who"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("`alias` uses the server, graph, and stored query")
|
||||
&& stderr.contains(flag),
|
||||
"expected {flag} to be rejected by the alias binding guard; got: {stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
"#,
|
||||
|
||||
#[test]
|
||||
fn queries_and_policy_wrong_server_scope_points_at_cluster_scope() {
|
||||
let output = output_failure(cli().arg("--server").arg("prod").arg("queries").arg("list"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
|
||||
"queries should point at --cluster, not --config; got: {stderr}"
|
||||
);
|
||||
write_config(
|
||||
&config,
|
||||
&format!(
|
||||
"{}aliases:\n add_person:\n command: change\n query: mutations.gq\n name: insert_person\n args: [name, age]\n",
|
||||
local_yaml_config(&graph)
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("--server")
|
||||
.arg("prod")
|
||||
.arg("policy")
|
||||
.arg("validate"),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("pass --cluster <dir|uri>") && !stderr.contains("pass --config <dir>"),
|
||||
"policy should point at --cluster, not --config; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// RFC-011: `queries validate`/`list` source the registry + schemas from a
|
||||
// converged cluster's applied state (`--cluster <dir>`), not omnigraph.yaml.
|
||||
|
||||
/// Build a converged single-graph cluster (id `knowledge`) with one stored
|
||||
/// query. `query_block` is the YAML under the graph's `queries:` key.
|
||||
fn converged_cluster_with_query(query_file: &str, query_src: &str, query_block: &str) -> tempfile::TempDir {
|
||||
let temp = tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
|
||||
write_query_file(&dir.join(query_file), query_src);
|
||||
std::fs::write(
|
||||
dir.join("cluster.yaml"),
|
||||
format!(
|
||||
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
|
||||
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n{query_block}"
|
||||
),
|
||||
);
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("change")
|
||||
.arg("--config")
|
||||
.arg(&config)
|
||||
.arg("--alias")
|
||||
.arg("add_person")
|
||||
.arg("Eve")
|
||||
.arg("29")
|
||||
.arg("--json"),
|
||||
);
|
||||
let payload: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(payload["affected_nodes"], 1);
|
||||
|
||||
let verify = output_success(
|
||||
cli()
|
||||
.arg("read")
|
||||
.arg(&graph)
|
||||
.arg("--query")
|
||||
.arg(fixture("test.gq"))
|
||||
.arg("--name")
|
||||
.arg("get_person")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Eve"}"#)
|
||||
.arg("--json"),
|
||||
);
|
||||
let verify_payload: Value = serde_json::from_slice(&verify.stdout).unwrap();
|
||||
assert_eq!(verify_payload["row_count"], 1);
|
||||
)
|
||||
.unwrap();
|
||||
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
||||
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
|
||||
temp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_validate_exits_zero_on_clean_registry() {
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query(
|
||||
let cluster = converged_cluster_with_query(
|
||||
"find_person.gq",
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&queries_test_config(
|
||||
&graph.path().to_string_lossy(),
|
||||
"find_person",
|
||||
"find_person.gq",
|
||||
),
|
||||
" find_person:\n file: ./find_person.gq\n",
|
||||
);
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("OK"), "stdout:\n{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_validate_exits_nonzero_on_type_broken_query() {
|
||||
let graph = SystemGraph::loaded();
|
||||
// `Widget` is not in the fixture schema.
|
||||
graph.write_query(
|
||||
"ghost.gq",
|
||||
fn cluster_import_rejects_a_type_broken_query() {
|
||||
// In the cluster model a stored query is type-checked at the cluster
|
||||
// boundary (import/apply), so a broken query can never reach the applied
|
||||
// state `queries validate` reads — the gate is upstream. `Widget` is not in
|
||||
// the fixture schema, so import must reject it, naming the query.
|
||||
let temp = tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
std::fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
|
||||
write_query_file(
|
||||
&dir.join("ghost.gq"),
|
||||
"query ghost() { match { $w: Widget } return { $w.name } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&queries_test_config(&graph.path().to_string_lossy(), "ghost", "ghost.gq"),
|
||||
std::fs::write(
|
||||
dir.join("cluster.yaml"),
|
||||
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\n\
|
||||
graphs:\n knowledge:\n schema: ./graph.pg\n queries:\n ghost:\n file: ./ghost.gq\n",
|
||||
)
|
||||
.unwrap();
|
||||
let output = output_failure(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
||||
let combined = format!(
|
||||
"{}{}",
|
||||
stdout_string(&output),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(
|
||||
stdout.contains("ghost"),
|
||||
"validation should name the broken query; stdout:\n{stdout}"
|
||||
combined.contains("ghost"),
|
||||
"cluster import must reject the broken query, naming it; got:\n{combined}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_prints_registered_query() {
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query(
|
||||
let cluster = converged_cluster_with_query(
|
||||
"find_person.gq",
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
);
|
||||
// Exposed with an explicit tool name so the list shows the MCP suffix.
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&format!(
|
||||
concat!(
|
||||
"graphs:\n",
|
||||
" local:\n",
|
||||
" uri: '{}'\n",
|
||||
" queries:\n",
|
||||
" find_person:\n",
|
||||
" file: ./find_person.gq\n",
|
||||
" mcp: {{ expose: true, tool_name: lookup_person }}\n",
|
||||
"cli:\n",
|
||||
" graph: local\n",
|
||||
"policy: {{}}\n",
|
||||
),
|
||||
graph.path().to_string_lossy().replace('\'', "''")
|
||||
),
|
||||
" find_person:\n file: ./find_person.gq\n",
|
||||
);
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
.arg("--cluster")
|
||||
.arg(cluster.path()),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("find_person"), "stdout:\n{stdout}");
|
||||
|
|
@ -285,242 +229,37 @@ fn queries_list_prints_registered_query() {
|
|||
stdout.contains("$name: String"),
|
||||
"list should show typed params; stdout:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("[mcp: lookup_person]"),
|
||||
"list should show the MCP tool name for exposed queries; stdout:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_requires_graph_selection_for_per_graph_only_registries() {
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query(
|
||||
"find_person.gq",
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&format!(
|
||||
concat!(
|
||||
"graphs:\n",
|
||||
" local:\n",
|
||||
" uri: '{}'\n",
|
||||
" queries:\n",
|
||||
" find_person:\n",
|
||||
" file: ./find_person.gq\n",
|
||||
"policy: {{}}\n",
|
||||
),
|
||||
graph.path().to_string_lossy().replace('\'', "''")
|
||||
),
|
||||
);
|
||||
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
fn queries_validate_requires_a_cluster() {
|
||||
// RFC-011: with no --cluster (and no cluster profile), the command errors
|
||||
// loudly rather than reading any omnigraph.yaml.
|
||||
let output = output_failure(cli().arg("queries").arg("validate"));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("local") && stderr.contains("set `cli.graph`"),
|
||||
"error must name the graph and give a concrete selection hint; stderr:\n{stderr}"
|
||||
stderr.contains("needs a cluster") || stderr.contains("--cluster"),
|
||||
"queries validate must require a cluster; stderr:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_without_graph_selection_lists_top_level_registry() {
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query(
|
||||
"top_find.gq",
|
||||
"query top_find($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
concat!(
|
||||
"queries:\n",
|
||||
" top_find:\n",
|
||||
" file: ./top_find.gq\n",
|
||||
"policy: {}\n",
|
||||
),
|
||||
);
|
||||
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(stdout.contains("top_find"), "stdout:\n{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_list_unknown_cli_graph_errors() {
|
||||
// `queries list` opens no graph URI, so unknown-graph validation can't ride
|
||||
// along on URI resolution the way it does for every other command. An
|
||||
// unknown `cli.graph` selection must still error (naming the graph) instead
|
||||
// of silently falling back to the top-level registry and showing the wrong
|
||||
// (or empty) catalog. (`--target` was removed; `cli.graph` drives selection.)
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query(
|
||||
"find_person.gq",
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&format!(
|
||||
"graphs:\n local:\n uri: '{}'\n queries:\n find_person:\n file: ./find_person.gq\ncli:\n graph: nonexistent\npolicy: {{}}\n",
|
||||
graph.path().to_string_lossy().replace('\'', "''"),
|
||||
),
|
||||
);
|
||||
let output = output_failure(cli().arg("queries").arg("list").arg("--config").arg(&config));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("nonexistent"),
|
||||
"error must name the unknown graph; stderr:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_commands_reject_named_graph_with_populated_top_level_block() {
|
||||
// A named graph (here via `cli.graph`) uses its own `graphs.<name>` block,
|
||||
// so a populated top-level `queries:` block would be silently ignored — a
|
||||
// config the server REFUSES to boot. `queries validate`/`list` must reject
|
||||
// it too (matching boot) instead of validating/listing the per-graph block
|
||||
// and giving a false green.
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query(
|
||||
"find_person.gq",
|
||||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&format!(
|
||||
concat!(
|
||||
"graphs:\n",
|
||||
" local:\n",
|
||||
" uri: '{}'\n",
|
||||
" queries:\n",
|
||||
" find_person:\n",
|
||||
" file: ./find_person.gq\n",
|
||||
"cli:\n",
|
||||
" graph: local\n",
|
||||
"queries:\n", // populated top-level block: the coherence violation
|
||||
" legacy:\n",
|
||||
" file: ./legacy.gq\n",
|
||||
"policy: {{}}\n",
|
||||
),
|
||||
graph.path().to_string_lossy().replace('\'', "''")
|
||||
),
|
||||
);
|
||||
// Both resolve `local` from cli.graph (no positional URI), so both must
|
||||
// error and name the graph + the ignored block — like server boot does.
|
||||
for sub in ["validate", "list"] {
|
||||
let output = output_failure(cli().arg("queries").arg(sub).arg("--config").arg(&config));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("local") && stderr.contains("queries"),
|
||||
"`queries {sub}` must reject a named graph with a populated top-level block; stderr:\n{stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_validate_exits_nonzero_on_duplicate_tool_name() {
|
||||
// Two exposed queries claiming one MCP tool name is a load-time
|
||||
// collision — `queries validate` must fail (offline, before the engine
|
||||
// opens) and name both queries plus the contested tool.
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query(
|
||||
"a.gq",
|
||||
"query a() { match { $p: Person } return { $p.name } }",
|
||||
);
|
||||
graph.write_query(
|
||||
"b.gq",
|
||||
"query b() { match { $p: Person } return { $p.name } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
&format!(
|
||||
concat!(
|
||||
"graphs:\n",
|
||||
" local:\n",
|
||||
" uri: '{}'\n",
|
||||
" queries:\n",
|
||||
" a:\n",
|
||||
" file: ./a.gq\n",
|
||||
" mcp: {{ expose: true, tool_name: dup }}\n",
|
||||
" b:\n",
|
||||
" file: ./b.gq\n",
|
||||
" mcp: {{ expose: true, tool_name: dup }}\n",
|
||||
"cli:\n",
|
||||
" graph: local\n",
|
||||
"policy: {{}}\n",
|
||||
),
|
||||
graph.path().to_string_lossy().replace('\'', "''")
|
||||
),
|
||||
);
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("validate")
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("dup") && stderr.contains("'a'") && stderr.contains("'b'"),
|
||||
"duplicate tool name should be reported naming both queries; stderr:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queries_validate_positional_uri_ignores_default_graph() {
|
||||
// A positional URI is anonymous → the schema AND the registry both come
|
||||
// from top-level, even when `cli.graph` names a graph whose per-graph
|
||||
// queries would fail. Pins that the URI and registry can't diverge.
|
||||
let graph = SystemGraph::loaded();
|
||||
graph.write_query(
|
||||
"clean.gq",
|
||||
"query clean($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||||
);
|
||||
// `Widget` is not in the fixture schema — the default graph's per-graph
|
||||
// query would break validate if it were (wrongly) selected.
|
||||
graph.write_query(
|
||||
"broken.gq",
|
||||
"query broken() { match { $w: Widget } return { $w.name } }",
|
||||
);
|
||||
let config = graph.write_config(
|
||||
"omnigraph.yaml",
|
||||
concat!(
|
||||
"cli:\n graph: prod\n",
|
||||
"graphs:\n",
|
||||
" prod:\n",
|
||||
" uri: /nonexistent-prod.omni\n",
|
||||
" queries:\n",
|
||||
" broken:\n",
|
||||
" file: ./broken.gq\n",
|
||||
"queries:\n",
|
||||
" clean:\n",
|
||||
" file: ./clean.gq\n",
|
||||
"policy: {}\n",
|
||||
),
|
||||
);
|
||||
// Positional URI = the real loaded graph; selection is anonymous, so the
|
||||
// CLEAN top-level registry validates (not prod's broken one).
|
||||
fn queries_validate_graph_filter_selects_one_graph() {
|
||||
// A multi-graph cluster: validate scoped to `knowledge` type-checks only
|
||||
// that graph's registry, ignoring `engineering`'s.
|
||||
let temp = tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
write_multi_graph_cluster_fixture(dir);
|
||||
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
||||
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
|
||||
let output = output_success(
|
||||
cli()
|
||||
.arg("queries")
|
||||
.arg("validate")
|
||||
.arg(graph.path())
|
||||
.arg("--config")
|
||||
.arg(&config),
|
||||
);
|
||||
let stdout = stdout_string(&output);
|
||||
assert!(
|
||||
stdout.contains("OK"),
|
||||
"positional URI must validate the top-level registry, not the cli.graph default; stdout:\n{stdout}"
|
||||
.arg("--cluster")
|
||||
.arg(dir)
|
||||
.arg("--graph")
|
||||
.arg("knowledge"),
|
||||
);
|
||||
assert!(stdout_string(&output).contains("OK"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ fn schema_plan_with_server_flag_errors_wrong_plane() {
|
|||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("`schema plan` is a direct (storage-native) command")
|
||||
&& stderr.contains("Pass a storage URI, or --cluster <dir> --cluster-graph <id>."),
|
||||
&& stderr.contains("Pass a storage URI."),
|
||||
"schema plan wrong-capability message not found; got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
|
@ -334,7 +334,13 @@ fn schema_apply_json_adds_index_for_existing_property() {
|
|||
let dataset = snapshot.open("node:Person").await.unwrap();
|
||||
dataset.load_indices().await.unwrap().len()
|
||||
});
|
||||
assert!(after_index_count > before_index_count);
|
||||
// iss-848: `schema apply` records the `@index` intent but defers the physical
|
||||
// index build (materialized later by ensure_indices/optimize; on this empty
|
||||
// table nothing builds anyway). So the physical index count is unchanged.
|
||||
assert_eq!(
|
||||
after_index_count, before_index_count,
|
||||
"schema apply records @index intent but defers the physical build (iss-848)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -540,163 +546,18 @@ fn graphs_subcommand_help_lists_list_only() {
|
|||
|
||||
#[test]
|
||||
fn graphs_list_against_local_uri_errors_with_remote_only_message() {
|
||||
// RFC-011: `graphs list` is served-only; a `--store` (local) address has no
|
||||
// enumeration endpoint, so it fails loudly pointing at a server / cluster.
|
||||
let output = output_failure(
|
||||
cli()
|
||||
.arg("graphs")
|
||||
.arg("list")
|
||||
.arg("--uri")
|
||||
.arg("--store")
|
||||
.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}"
|
||||
stderr.contains("remote multi-graph server"),
|
||||
"expected a remote-server rejection in stderr; got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// RFC-008 stage 1: loading a legacy omnigraph.yaml emits the per-key
|
||||
/// deprecation block (the migration map applied to THIS file), suppressible
|
||||
/// via OMNIGRAPH_SUPPRESS_YAML_DEPRECATION.
|
||||
#[test]
|
||||
fn legacy_config_load_warns_per_key_and_suppression_silences() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"cli:\n actor: act-x\ngraphs:\n g:\n uri: /tmp/never-opened\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// `graphs list --json` loads the config and exits without touching the
|
||||
// graph URI.
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.arg("graphs")
|
||||
.arg("list")
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("deprecated (RFC-008)") && stderr.contains("`cli.actor` -> `operator.actor`"),
|
||||
"{stderr}"
|
||||
);
|
||||
assert!(stderr.contains("config migrate"), "{stderr}");
|
||||
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
|
||||
.arg("graphs")
|
||||
.arg("list")
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!stderr.contains("deprecated (RFC-008)"), "{stderr}");
|
||||
}
|
||||
|
||||
/// RFC-008 stage 2: `config migrate` proposes the split read-only, applies
|
||||
/// it with --write (operator merge never clobbers; cluster.yaml emitted),
|
||||
/// and a second --write is idempotent.
|
||||
#[test]
|
||||
fn config_migrate_splits_legacy_config() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"graphs:\n prod:\n uri: https://graph.example.com\n bearer_token_env: PROD_TOKEN\ncli:\n actor: act-me\n output_format: json\npolicy:\n file: ./top.policy.yaml\n",
|
||||
)
|
||||
.unwrap();
|
||||
let operator_home = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
operator_home.path().join("config.yaml"),
|
||||
"operator:\n actor: act-existing\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Read-only proposal: names both halves, writes nothing.
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
|
||||
.arg("config")
|
||||
.arg("migrate")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("team half -> cluster.yaml"), "{stdout}");
|
||||
assert!(stdout.contains("operator.actor: act-me"), "{stdout}");
|
||||
assert!(stdout.contains("omnigraph login prod"), "{stdout}");
|
||||
assert!(!temp.path().join("cluster.yaml").exists());
|
||||
|
||||
// --write: cluster.yaml lands; the existing operator actor is KEPT.
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
|
||||
.arg("config")
|
||||
.arg("migrate")
|
||||
.arg("--write")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
let cluster = fs::read_to_string(temp.path().join("cluster.yaml")).unwrap();
|
||||
assert!(cluster.contains("version: 1") && cluster.contains(" prod:"), "{cluster}");
|
||||
let operator_text =
|
||||
fs::read_to_string(operator_home.path().join("config.yaml")).unwrap();
|
||||
assert!(operator_text.contains("act-existing"), "{operator_text}");
|
||||
assert!(!operator_text.contains("act-me"), "existing keys win: {operator_text}");
|
||||
assert!(operator_text.contains("output: json"), "{operator_text}");
|
||||
assert!(
|
||||
operator_text.contains("url: https://graph.example.com"),
|
||||
"{operator_text}"
|
||||
);
|
||||
|
||||
// Second --write: cluster.yaml exists -> proposal file, no clobber.
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||||
.env("OMNIGRAPH_SUPPRESS_YAML_DEPRECATION", "1")
|
||||
.arg("config")
|
||||
.arg("migrate")
|
||||
.arg("--write")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
assert!(temp.path().join("cluster.yaml.proposed").exists());
|
||||
}
|
||||
|
||||
/// RFC-008 stage 4: OMNIGRAPH_NO_LEGACY_CONFIG refuses a present legacy
|
||||
/// file (pointing at config migrate) but changes nothing on migrated
|
||||
/// setups with no file.
|
||||
#[test]
|
||||
fn strict_mode_refuses_legacy_file_but_not_its_absence() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(temp.path().join("omnigraph.yaml"), "cli:\n actor: a\n").unwrap();
|
||||
let output = cli()
|
||||
.current_dir(temp.path())
|
||||
.env("OMNIGRAPH_NO_LEGACY_CONFIG", "1")
|
||||
.arg("graphs")
|
||||
.arg("list")
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("OMNIGRAPH_NO_LEGACY_CONFIG") && stderr.contains("config migrate"),
|
||||
"{stderr}"
|
||||
);
|
||||
|
||||
// Migrated setup (no file): strict mode is a no-op — a config-loading
|
||||
// command that tolerates empty defaults succeeds.
|
||||
let clean = tempdir().unwrap();
|
||||
let output = cli()
|
||||
.current_dir(clean.path())
|
||||
.env("OMNIGRAPH_NO_LEGACY_CONFIG", "1")
|
||||
.arg("queries")
|
||||
.arg("list")
|
||||
.arg("--json")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,21 +25,23 @@ const KNOWN_DIVERGENCES: &[&str] = &[
|
|||
// populated by the rows below as they are written
|
||||
];
|
||||
|
||||
/// One matched setup per row: twin graphs + the SAME Cedar bundle on both
|
||||
/// arms (the local arm via --config top-level policy.file; the server via
|
||||
/// its config). Returns everything a row needs.
|
||||
/// One matched setup per row: twin graphs + the parity Cedar bundle on the
|
||||
/// served arm. The local (`--store`) arm carries no policy (RFC-011); the
|
||||
/// bundle is permissive for `act-parity`, so the arms still agree.
|
||||
struct Parity {
|
||||
_temp: TempDir,
|
||||
local: std::path::PathBuf,
|
||||
local_cfg: std::path::PathBuf,
|
||||
server: TestServer,
|
||||
}
|
||||
|
||||
fn parity() -> Parity {
|
||||
let (temp, local, remote) = twin_graphs();
|
||||
let (local_cfg, server_cfg) = parity_configs(temp.path(), &local, &remote);
|
||||
let server = spawn_server_with_config_env(
|
||||
&server_cfg,
|
||||
// RFC-011 cluster-only: the remote arm is served from a converged
|
||||
// cluster directory (one graph, id `parity`), seeded with the same
|
||||
// fixture data as the local twin.
|
||||
let cluster_dir = parity_configs(temp.path(), &local, &remote);
|
||||
let server = spawn_server_with_cluster_env(
|
||||
&cluster_dir,
|
||||
&[(
|
||||
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
|
||||
r#"{"act-parity":"parity-tok"}"#,
|
||||
|
|
@ -48,14 +50,13 @@ fn parity() -> Parity {
|
|||
Parity {
|
||||
_temp: temp,
|
||||
local,
|
||||
local_cfg,
|
||||
server,
|
||||
}
|
||||
}
|
||||
|
||||
impl Parity {
|
||||
fn run(&self, args: &[&str]) -> (std::process::Output, std::process::Output) {
|
||||
run_both_with_config(&self.local, Some(&self.local_cfg), &self.server.base_url, args)
|
||||
run_both(&self.local, &self.server.base_url, args)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +84,6 @@ fn parity_query() {
|
|||
"query",
|
||||
"--query",
|
||||
query.to_str().unwrap(),
|
||||
"--name",
|
||||
"get_person",
|
||||
"--params",
|
||||
r#"{"name":"Alice"}"#,
|
||||
|
|
@ -142,7 +142,10 @@ fn parity_branch_create_delete() {
|
|||
let (l, r) = p.run(&["branch", "create", "--from", "main", "parity-branch", "--json"],
|
||||
);
|
||||
assert_parity("branch create", &l, &r);
|
||||
let (l, r) = p.run(&["branch", "delete", "parity-branch", "--json"],
|
||||
// `branch delete` is destructive: the served (remote) arm is non-local and
|
||||
// requires consent (RFC-011 Decision 9), so the row passes `--yes` to test
|
||||
// the operation itself, not the safety gate. The local arm ignores `--yes`.
|
||||
let (l, r) = p.run(&["branch", "delete", "parity-branch", "--yes", "--json"],
|
||||
);
|
||||
assert_parity("branch delete", &l, &r);
|
||||
}
|
||||
|
|
@ -229,7 +232,6 @@ fn parity_errors_share_exit_codes() {
|
|||
"query",
|
||||
"--query",
|
||||
query.to_str().unwrap(),
|
||||
"--name",
|
||||
"no_such_query",
|
||||
"--json",
|
||||
],
|
||||
|
|
@ -249,7 +251,6 @@ fn parity_errors_share_exit_codes() {
|
|||
"query",
|
||||
"--query",
|
||||
query.to_str().unwrap(),
|
||||
"--name",
|
||||
"get_person",
|
||||
"--json",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -339,6 +339,63 @@ impl SystemGraph {
|
|||
}
|
||||
}
|
||||
|
||||
/// A converged cluster directory the server can boot from (`--cluster`),
|
||||
/// serving one graph seeded with the standard fixture. Holds the temp dir
|
||||
/// alive for the test's lifetime.
|
||||
pub struct ClusterFixture {
|
||||
_temp: TempDir,
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ClusterFixture {
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a converged cluster (RFC-011 cluster-only serving) with a single
|
||||
/// graph `graph_id`, seeded with the `test.jsonl` fixture so reads return
|
||||
/// data. When `policy_yaml` is `Some`, the bundle is bound to the graph
|
||||
/// scope. The server boots from the returned path via `--cluster`.
|
||||
pub fn converged_loaded_cluster(graph_id: &str, policy_yaml: Option<&str>) -> ClusterFixture {
|
||||
let temp = tempdir().unwrap();
|
||||
let dir = temp.path().to_path_buf();
|
||||
fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
|
||||
|
||||
let policy_block = match policy_yaml {
|
||||
Some(source) => {
|
||||
fs::write(dir.join("graph.policy.yaml"), source).unwrap();
|
||||
format!(
|
||||
"policies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [{graph_id}]\n"
|
||||
)
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
fs::write(
|
||||
dir.join("cluster.yaml"),
|
||||
format!(
|
||||
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n {graph_id}:\n schema: ./graph.pg\n{policy_block}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
output_success(cli().arg("cluster").arg("import").arg("--config").arg(&dir));
|
||||
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(&dir));
|
||||
|
||||
let served_root = dir.join("graphs").join(format!("{graph_id}.omni"));
|
||||
output_success(
|
||||
cli()
|
||||
.arg("load")
|
||||
.arg("--data")
|
||||
.arg(fixture("test.jsonl"))
|
||||
.arg("--mode")
|
||||
.arg("overwrite")
|
||||
.arg(&served_root),
|
||||
);
|
||||
|
||||
ClusterFixture { _temp: temp, dir }
|
||||
}
|
||||
|
||||
// ---- helpers moved from the monolithic tests/cli.rs ----
|
||||
#[allow(unused_imports)]
|
||||
use lance::Dataset;
|
||||
|
|
@ -788,29 +845,94 @@ rules:
|
|||
.to_string()
|
||||
}
|
||||
|
||||
/// Per-arm config files carrying the same policy. Both arms address the
|
||||
/// graph by positional URI, so the TOP-LEVEL policy.file applies on each
|
||||
/// side (single-graph semantics).
|
||||
pub fn parity_configs(root: &Path, _local_graph: &Path, remote_graph: &Path) -> (PathBuf, PathBuf) {
|
||||
/// The graph id the parity cluster serves the remote arm under. The
|
||||
/// remote arm addresses it with `--graph PARITY_GRAPH_ID` (RFC-011: the
|
||||
/// server is cluster-only, so a graph selector is required).
|
||||
pub const PARITY_GRAPH_ID: &str = "parity";
|
||||
|
||||
/// Build the remote arm's configuration (RFC-011 cluster-only server).
|
||||
///
|
||||
/// The remote arm is served from a converged cluster directory whose single
|
||||
/// graph (id `parity`) carries the parity Cedar bundle (bound to the graph
|
||||
/// scope). The cluster's derived graph root (`<dir>/graphs/parity.omni`) is
|
||||
/// seeded with the SAME fixture data as the local twin so the two arms compare
|
||||
/// like-for-like. The local (`--store`) arm carries no Cedar policy (RFC-011),
|
||||
/// which is fine because the parity bundle is permissive for `act-parity`.
|
||||
///
|
||||
/// `local_graph` is overwritten with a byte-for-byte copy of the cluster's
|
||||
/// seeded served graph so identity-bearing values that are NOT scrubbed
|
||||
/// (e.g. `graph_commit_id`, edge `id`s in export) match across the arms —
|
||||
/// the served graph is the source of truth and the local twin mirrors it.
|
||||
///
|
||||
/// Returns the `cluster_dir`. The caller spawns the server with `--cluster`.
|
||||
pub fn parity_configs(root: &Path, local_graph: &Path, _remote_graph: &Path) -> PathBuf {
|
||||
let policy = root.join("parity.policy.yaml");
|
||||
fs::write(&policy, parity_policy_yaml()).unwrap();
|
||||
let local_cfg = root.join("local.omnigraph.yaml");
|
||||
|
||||
// Remote arm: a cluster directory the server boots from. One graph
|
||||
// (`parity`), schema = the shared fixture, policy bound to the graph.
|
||||
let cluster_dir = root.join("parity-cluster");
|
||||
fs::create_dir_all(&cluster_dir).unwrap();
|
||||
fs::copy(fixture("test.pg"), cluster_dir.join("parity.pg")).unwrap();
|
||||
fs::copy(&policy, cluster_dir.join("parity.policy.yaml")).unwrap();
|
||||
fs::write(
|
||||
&local_cfg,
|
||||
format!("policy:\n file: {}\n", policy.display()),
|
||||
)
|
||||
.unwrap();
|
||||
let server_cfg = root.join("server.omnigraph.yaml");
|
||||
fs::write(
|
||||
&server_cfg,
|
||||
cluster_dir.join("cluster.yaml"),
|
||||
format!(
|
||||
"server:\n graph: parity\ngraphs:\n parity:\n uri: {}\n policy:\n file: {}\n",
|
||||
remote_graph.display(),
|
||||
policy.display()
|
||||
r#"version: 1
|
||||
metadata:
|
||||
name: parity
|
||||
state:
|
||||
backend: cluster
|
||||
lock: true
|
||||
graphs:
|
||||
{PARITY_GRAPH_ID}:
|
||||
schema: ./parity.pg
|
||||
policies:
|
||||
parity:
|
||||
file: ./parity.policy.yaml
|
||||
applies_to: [{PARITY_GRAPH_ID}]
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
(local_cfg, server_cfg)
|
||||
|
||||
// Converge the cluster (creates the empty graph at the derived root),
|
||||
// then seed it with the same fixture data the local twin holds.
|
||||
output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("import")
|
||||
.arg("--config")
|
||||
.arg(&cluster_dir),
|
||||
);
|
||||
output_success(
|
||||
cli()
|
||||
.arg("cluster")
|
||||
.arg("apply")
|
||||
.arg("--config")
|
||||
.arg(&cluster_dir),
|
||||
);
|
||||
let served_root = cluster_dir
|
||||
.join("graphs")
|
||||
.join(format!("{PARITY_GRAPH_ID}.omni"));
|
||||
output_success(
|
||||
cli()
|
||||
.arg("load")
|
||||
.arg("--data")
|
||||
.arg(fixture("test.jsonl"))
|
||||
.arg("--mode")
|
||||
.arg("overwrite")
|
||||
.arg(&served_root),
|
||||
);
|
||||
|
||||
// Mirror the seeded served graph into the local twin so both arms hold
|
||||
// identical ULIDs / commit ids (the served graph is authoritative).
|
||||
if local_graph.exists() {
|
||||
fs::remove_dir_all(local_graph).unwrap();
|
||||
}
|
||||
copy_dir(&served_root, local_graph);
|
||||
|
||||
cluster_dir
|
||||
}
|
||||
|
||||
/// Run one CLI invocation per arm with identical verb args: locally against
|
||||
|
|
@ -821,21 +943,14 @@ pub fn run_both(
|
|||
local_graph: &Path,
|
||||
server_url: &str,
|
||||
args: &[&str],
|
||||
) -> (std::process::Output, std::process::Output) {
|
||||
run_both_with_config(local_graph, None, server_url, args)
|
||||
}
|
||||
|
||||
pub fn run_both_with_config(
|
||||
local_graph: &Path,
|
||||
local_config: Option<&Path>,
|
||||
server_url: &str,
|
||||
args: &[&str],
|
||||
) -> (std::process::Output, std::process::Output) {
|
||||
// Address both arms with GLOBAL flags (`--store` / `--server`) appended after
|
||||
// the verb + its args, so the address is placed correctly regardless of
|
||||
// subcommand nesting (a positional graph only works for top-level verbs;
|
||||
// `schema show <graph>` etc. need the global flag). Local = embedded store,
|
||||
// remote = served.
|
||||
// remote = served. RFC-011: a direct (`--store`) write carries no Cedar
|
||||
// policy — the parity policy is permissive for `act-parity` on the served
|
||||
// arm, so the two arms still agree.
|
||||
let mut local = cli();
|
||||
local
|
||||
.args(args)
|
||||
|
|
@ -843,9 +958,6 @@ pub fn run_both_with_config(
|
|||
.arg(local_graph)
|
||||
.arg("--as")
|
||||
.arg(PARITY_ACTOR);
|
||||
if let Some(config) = local_config {
|
||||
local.arg("--config").arg(config);
|
||||
}
|
||||
let local_out = local.output().unwrap();
|
||||
|
||||
let mut remote = cli();
|
||||
|
|
@ -853,7 +965,11 @@ pub fn run_both_with_config(
|
|||
.env("OMNIGRAPH_BEARER_TOKEN", PARITY_TOKEN)
|
||||
.args(args)
|
||||
.arg("--server")
|
||||
.arg(server_url);
|
||||
.arg(server_url)
|
||||
// RFC-011: the parity server is cluster-only (multi-graph), so the
|
||||
// remote arm must name the graph it addresses.
|
||||
.arg("--graph")
|
||||
.arg(PARITY_GRAPH_ID);
|
||||
let remote_out = remote.output().unwrap();
|
||||
(local_out, remote_out)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue