mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue