mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-15 01:55:13 +02:00
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
Some checks failed
CI / Classify Changes (push) Has been cancelled
CI / Check AGENTS.md Links (push) Has been cancelled
CI / Container Entrypoint (push) Has been cancelled
Release Edge / Prepare edge release (push) Has been cancelled
CI / Test Workspace (push) Has been cancelled
CI / Test omnigraph-server --features aws (push) Has been cancelled
CI / RustFS S3 Integration (push) Has been cancelled
Release Edge / Build edge omnigraph-linux-x86_64 (push) Has been cancelled
Release Edge / Build edge omnigraph-macos-arm64 (push) Has been cancelled
Release Edge / Build edge omnigraph-windows-x86_64 (push) Has been cancelled
Release Edge / Smoke Windows installer (push) Has been cancelled
Some checks failed
CI / Classify Changes (push) Has been cancelled
CI / Check AGENTS.md Links (push) Has been cancelled
CI / Container Entrypoint (push) Has been cancelled
Release Edge / Prepare edge release (push) Has been cancelled
CI / Test Workspace (push) Has been cancelled
CI / Test omnigraph-server --features aws (push) Has been cancelled
CI / RustFS S3 Integration (push) Has been cancelled
Release Edge / Build edge omnigraph-linux-x86_64 (push) Has been cancelled
Release Edge / Build edge omnigraph-macos-arm64 (push) Has been cancelled
Release Edge / Build edge omnigraph-windows-x86_64 (push) Has been cancelled
Release Edge / Smoke Windows installer (push) Has been cancelled
* feat(cli): RFC-011 Slice A — operator-config scope structs (profiles/clusters/defaults)
Additive operator-config surface for the RFC-011 scope model. No behavior
change yet — these structs are parsed but not consumed until the scope
resolver lands.
- OperatorConfig gains `profiles:` (name → OperatorProfile) and `clusters:`
(name → OperatorCluster { root }) — the latter the only place a storage
root appears in operator config (RFC-011 storage-root rule).
- OperatorDefaults gains `server` and `default_graph` (the flat-default scope).
- OperatorProfile binds one of {server, cluster, store} + default_graph;
`binding()` validates exactly-one on use and returns a ScopeBinding.
- Accessors profile()/cluster_root()/default_server()/default_graph();
unknown-key warnings extended to the new blocks (forward-compat preserved —
old configs still load, new keys are no longer "unknown").
Tests: parse profiles/clusters/scope-defaults, binding rejects zero/multiple
entities, unknown keys in a profile warn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): RFC-011 Slice A — scope resolver + --profile/--store, wired (additive)
Translate the new scope inputs into the existing addressing tuple, in front of
the unchanged resolvers. Purely additive: an explicit address
(--uri/--target/--server/--store) passes straight through, so every existing
invocation is byte-for-byte unchanged.
- scope.rs: resolve_scope() with the RFC-011 precedence (explicit > --profile /
OMNIGRAPH_PROFILE > flat defaults.server), producing the effective
(server, graph, uri, target) for data verbs and (cluster, cluster_graph) for
maintenance. Plane×scope capability check (server scope rejected on a
maintenance verb; cluster scope rejected on a data verb; store rejects --graph)
fires only on the new paths. 9 unit tests.
- cli.rs: global --profile <NAME> and --store <URI>. (--graph keeps
requires=server for now; profile/default graph comes from default_graph —
profile+--graph override is deferred to the --cluster-graph rework.)
- client.rs: the two GraphClient factories call resolve_scope (Plane::Data) up
front; the explicit branch reproduces today's behavior exactly.
- main.rs: the 15 data call sites forward --profile/--store; the 3 maintenance
verbs consult the scope (Plane::Storage) only when no explicit per-command
address is given, so cluster-binding profiles and --store reach
optimize/repair/cleanup.
Verified: the full omnigraph-cli suite (221 tests) stays green untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test+docs(cli): RFC-011 Slice A — end-to-end scope test + reference docs
- cli_data.rs: prove --store and a --profile store binding drive a read
identically to the legacy positional URI (the additive-coexistence contract),
end to end against a local graph (no server needed).
- cli/reference.md: document profiles/clusters/defaults.server/default_graph,
the --profile/--store flags, and a "Scopes & profiles" section; note the model
coexists with legacy addressing (nothing removed yet).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ceb37dd4cb
commit
a4d08a4184
7 changed files with 777 additions and 28 deletions
|
|
@ -41,6 +41,20 @@ pub(crate) struct Cli {
|
|||
#[arg(long, global = true, value_name = "GRAPH_ID", requires = "server")]
|
||||
pub(crate) graph: Option<String>,
|
||||
|
||||
/// Select a named scope bundle (RFC-011) from `profiles:` in
|
||||
/// ~/.omnigraph/config.yaml: fills in this command's omitted addressing
|
||||
/// (server/cluster/store + default graph). Falls back to
|
||||
/// $OMNIGRAPH_PROFILE. Config data, not state — every command resolves
|
||||
/// scope fresh.
|
||||
#[arg(long, global = true, value_name = "NAME")]
|
||||
pub(crate) profile: Option<String>,
|
||||
|
||||
/// Address a single graph's storage directly (RFC-011): a `file://` /
|
||||
/// `s3://` store URI. Explicit, ad-hoc direct access — bypasses any
|
||||
/// server. Exclusive with a positional URI / `--target` / `--server`.
|
||||
#[arg(long, global = true, value_name = "URI")]
|
||||
pub(crate) store: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,23 @@ impl GraphClient {
|
|||
graph: Option<&str>,
|
||||
uri: Option<String>,
|
||||
target: Option<&str>,
|
||||
profile: Option<&str>,
|
||||
store: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
// RFC-011: a scope (profile / --store / operator defaults) may stand in
|
||||
// for omitted addressing. The explicit branch passes server/graph/uri/
|
||||
// target straight through, so existing invocations are unchanged.
|
||||
let scope = crate::scope::resolve_scope(
|
||||
&crate::operator::load_operator_config()?,
|
||||
crate::planes::Plane::Data,
|
||||
crate::scope::ScopeFlags { profile, store, server, graph, uri, target },
|
||||
)?;
|
||||
let (server, graph, uri, target) = (
|
||||
scope.server.as_deref(),
|
||||
scope.graph.as_deref(),
|
||||
scope.uri,
|
||||
scope.target.as_deref(),
|
||||
);
|
||||
let uri = apply_server_flag(server, graph, uri, target)?;
|
||||
let token = resolve_remote_bearer_token(config, uri.as_deref(), target)?;
|
||||
let uri = crate::helpers::resolve_uri(config, uri, target)?;
|
||||
|
|
@ -111,7 +127,22 @@ impl GraphClient {
|
|||
uri: Option<String>,
|
||||
target: Option<&str>,
|
||||
cli_as: Option<&str>,
|
||||
profile: Option<&str>,
|
||||
store: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
// RFC-011 scope translation (see `resolve`); explicit addressing passes
|
||||
// through unchanged.
|
||||
let scope = crate::scope::resolve_scope(
|
||||
&crate::operator::load_operator_config()?,
|
||||
crate::planes::Plane::Data,
|
||||
crate::scope::ScopeFlags { profile, store, server, graph, uri, target },
|
||||
)?;
|
||||
let (server, graph, uri, target) = (
|
||||
scope.server.as_deref(),
|
||||
scope.graph.as_deref(),
|
||||
scope.uri,
|
||||
scope.target.as_deref(),
|
||||
);
|
||||
let uri = apply_server_flag(server, graph, uri, target)?;
|
||||
let token = resolve_remote_bearer_token(config, uri.as_deref(), target)?;
|
||||
let resolved = resolve_cli_graph(config, uri, target)?;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ mod cli;
|
|||
mod client;
|
||||
mod helpers;
|
||||
mod output;
|
||||
mod scope;
|
||||
mod planes;
|
||||
use cli::*;
|
||||
use helpers::*;
|
||||
|
|
@ -185,6 +186,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
target.as_deref(),
|
||||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let branch = resolve_branch(&config, branch, None, "main");
|
||||
let payload = client
|
||||
|
|
@ -219,6 +222,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
target.as_deref(),
|
||||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let branch = resolve_branch(&config, branch, None, "main");
|
||||
let from = resolve_branch(&config, from, None, "main");
|
||||
|
|
@ -248,6 +253,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
target.as_deref(),
|
||||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let from = resolve_branch(&config, from, None, "main");
|
||||
let payload = client.branch_create_from(&from, &name).await?;
|
||||
|
|
@ -270,6 +277,8 @@ async fn main() -> Result<()> {
|
|||
cli.graph.as_deref(),
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let payload = client.branch_list().await?;
|
||||
if json {
|
||||
|
|
@ -295,6 +304,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
target.as_deref(),
|
||||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let payload = client.branch_delete(&name).await?;
|
||||
if json {
|
||||
|
|
@ -319,6 +330,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
target.as_deref(),
|
||||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let into = resolve_branch(&config, into, None, "main");
|
||||
let payload = client.branch_merge(&source, &into).await?;
|
||||
|
|
@ -349,6 +362,8 @@ async fn main() -> Result<()> {
|
|||
cli.graph.as_deref(),
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let payload = client.list_commits(branch.as_deref()).await?;
|
||||
if json {
|
||||
|
|
@ -371,6 +386,8 @@ async fn main() -> Result<()> {
|
|||
cli.graph.as_deref(),
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let commit = client.get_commit(&commit_id).await?;
|
||||
if json {
|
||||
|
|
@ -427,6 +444,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
target.as_deref(),
|
||||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let schema_source = fs::read_to_string(&schema)?;
|
||||
// The stored-query registry check is an embedded-only concern
|
||||
|
|
@ -467,6 +486,8 @@ async fn main() -> Result<()> {
|
|||
cli.graph.as_deref(),
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let output = client.schema_source().await?;
|
||||
if json {
|
||||
|
|
@ -521,6 +542,8 @@ async fn main() -> Result<()> {
|
|||
cli.graph.as_deref(),
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let branch = resolve_branch(&config, branch, None, "main");
|
||||
let payload = client.snapshot(&branch).await?;
|
||||
|
|
@ -546,6 +569,8 @@ async fn main() -> Result<()> {
|
|||
cli.graph.as_deref(),
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let branch = resolve_branch(&config, branch, None, "main");
|
||||
if jsonl {
|
||||
|
|
@ -636,6 +661,8 @@ async fn main() -> Result<()> {
|
|||
cli.graph.as_deref(),
|
||||
uri,
|
||||
target_name,
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let query_source = resolve_query_source(
|
||||
&config,
|
||||
|
|
@ -714,6 +741,8 @@ async fn main() -> Result<()> {
|
|||
uri,
|
||||
target_name,
|
||||
cli.as_actor.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let query_source = resolve_query_source(
|
||||
&config,
|
||||
|
|
@ -798,15 +827,41 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri = resolve_storage_uri(
|
||||
&config,
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cluster.as_deref(),
|
||||
cluster_graph.as_deref(),
|
||||
"optimize",
|
||||
)
|
||||
.await?;
|
||||
let uri = if uri.is_some() || target.is_some() || cluster.is_some() {
|
||||
resolve_storage_uri(
|
||||
&config,
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cluster.as_deref(),
|
||||
cluster_graph.as_deref(),
|
||||
"optimize",
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// RFC-011: no explicit per-command address — consult the scope
|
||||
// (a --profile cluster binding, --store, or operator defaults).
|
||||
let scope = scope::resolve_scope(
|
||||
&operator::load_operator_config()?,
|
||||
planes::Plane::Storage,
|
||||
scope::ScopeFlags {
|
||||
profile: cli.profile.as_deref(),
|
||||
store: cli.store.as_deref(),
|
||||
server: None,
|
||||
graph: cli.graph.as_deref(),
|
||||
uri: None,
|
||||
target: None,
|
||||
},
|
||||
)?;
|
||||
resolve_storage_uri(
|
||||
&config,
|
||||
scope.uri,
|
||||
scope.target.as_deref(),
|
||||
scope.cluster.as_deref(),
|
||||
scope.cluster_graph.as_deref(),
|
||||
"optimize",
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let db = Omnigraph::open(&uri).await?;
|
||||
let stats = db.optimize().await?;
|
||||
if json {
|
||||
|
|
@ -850,15 +905,40 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri = resolve_storage_uri(
|
||||
&config,
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cluster.as_deref(),
|
||||
cluster_graph.as_deref(),
|
||||
"repair",
|
||||
)
|
||||
.await?;
|
||||
let uri = if uri.is_some() || target.is_some() || cluster.is_some() {
|
||||
resolve_storage_uri(
|
||||
&config,
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cluster.as_deref(),
|
||||
cluster_graph.as_deref(),
|
||||
"repair",
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// RFC-011: no explicit per-command address — consult the scope.
|
||||
let scope = scope::resolve_scope(
|
||||
&operator::load_operator_config()?,
|
||||
planes::Plane::Storage,
|
||||
scope::ScopeFlags {
|
||||
profile: cli.profile.as_deref(),
|
||||
store: cli.store.as_deref(),
|
||||
server: None,
|
||||
graph: cli.graph.as_deref(),
|
||||
uri: None,
|
||||
target: None,
|
||||
},
|
||||
)?;
|
||||
resolve_storage_uri(
|
||||
&config,
|
||||
scope.uri,
|
||||
scope.target.as_deref(),
|
||||
scope.cluster.as_deref(),
|
||||
scope.cluster_graph.as_deref(),
|
||||
"repair",
|
||||
)
|
||||
.await?
|
||||
};
|
||||
let db = Omnigraph::open(&uri).await?;
|
||||
let stats = db
|
||||
.repair(omnigraph::db::RepairOptions { confirm, force })
|
||||
|
|
@ -944,15 +1024,40 @@ async fn main() -> Result<()> {
|
|||
json,
|
||||
} => {
|
||||
let config = load_cli_config(config.as_ref())?;
|
||||
let uri = resolve_storage_uri(
|
||||
&config,
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cluster.as_deref(),
|
||||
cluster_graph.as_deref(),
|
||||
"cleanup",
|
||||
)
|
||||
.await?;
|
||||
let uri = if uri.is_some() || target.is_some() || cluster.is_some() {
|
||||
resolve_storage_uri(
|
||||
&config,
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cluster.as_deref(),
|
||||
cluster_graph.as_deref(),
|
||||
"cleanup",
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
// RFC-011: no explicit per-command address — consult the scope.
|
||||
let scope = scope::resolve_scope(
|
||||
&operator::load_operator_config()?,
|
||||
planes::Plane::Storage,
|
||||
scope::ScopeFlags {
|
||||
profile: cli.profile.as_deref(),
|
||||
store: cli.store.as_deref(),
|
||||
server: None,
|
||||
graph: cli.graph.as_deref(),
|
||||
uri: None,
|
||||
target: None,
|
||||
},
|
||||
)?;
|
||||
resolve_storage_uri(
|
||||
&config,
|
||||
scope.uri,
|
||||
scope.target.as_deref(),
|
||||
scope.cluster.as_deref(),
|
||||
scope.cluster_graph.as_deref(),
|
||||
"cleanup",
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
let older_than_dur = older_than.as_deref().map(parse_duration_arg).transpose()?;
|
||||
|
||||
|
|
@ -1088,6 +1193,8 @@ async fn main() -> Result<()> {
|
|||
cli.graph.as_deref(),
|
||||
uri,
|
||||
target.as_deref(),
|
||||
cli.profile.as_deref(),
|
||||
cli.store.as_deref(),
|
||||
)?;
|
||||
let payload = client.list_graphs().await?;
|
||||
if json {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,17 @@ pub(crate) struct OperatorConfig {
|
|||
/// Personal alias bindings (RFC-007 PR 3); see OperatorAlias.
|
||||
#[serde(default)]
|
||||
pub(crate) aliases: BTreeMap<String, OperatorAlias>,
|
||||
/// Named scope bundles (RFC-011): each binds exactly one of
|
||||
/// {server, cluster, store} plus an optional default graph. Config data,
|
||||
/// not state — selecting one (`--profile`/`OMNIGRAPH_PROFILE`) fills in a
|
||||
/// command's omitted addressing; it never puts you "in" a mode.
|
||||
#[serde(default)]
|
||||
pub(crate) profiles: BTreeMap<String, OperatorProfile>,
|
||||
/// Managed-cluster storage roots (RFC-011): name → root URI. The ONLY
|
||||
/// place a storage root appears in operator config — admin-only and
|
||||
/// opt-in; a normal operator's file has none.
|
||||
#[serde(default)]
|
||||
pub(crate) clusters: BTreeMap<String, OperatorCluster>,
|
||||
/// Everything this CLI version doesn't know. Warned once at load,
|
||||
/// otherwise ignored (forward compatibility within the operator layer).
|
||||
#[serde(flatten)]
|
||||
|
|
@ -95,10 +106,58 @@ pub(crate) struct OperatorDefaults {
|
|||
/// during the RFC-008 window).
|
||||
pub(crate) table_max_column_width: Option<usize>,
|
||||
pub(crate) table_cell_layout: Option<omnigraph_server::config::TableCellLayout>,
|
||||
/// Default server scope (RFC-011): the everyday addressing when no
|
||||
/// `--profile` / primitive / legacy address is given. Names an entry
|
||||
/// under `servers:`.
|
||||
pub(crate) server: Option<String>,
|
||||
/// Default graph selected within a server/cluster scope when no
|
||||
/// `--graph` is passed (RFC-011).
|
||||
pub(crate) default_graph: Option<String>,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
/// A named scope bundle (RFC-011): exactly one of {server, cluster, store}
|
||||
/// plus an optional default graph. Validated on use (`binding()`), not at
|
||||
/// parse time, so an unknown CLI's profile still loads.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OperatorProfile {
|
||||
/// Names an entry under `servers:` — a served scope.
|
||||
pub(crate) server: Option<String>,
|
||||
/// Names an entry under `clusters:` — a privileged direct cluster scope.
|
||||
pub(crate) cluster: Option<String>,
|
||||
/// A single graph's storage URI — a direct store scope.
|
||||
pub(crate) store: Option<String>,
|
||||
/// Default graph within a server/cluster scope (ignored for a store,
|
||||
/// which is already one graph).
|
||||
pub(crate) default_graph: Option<String>,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
/// A managed-cluster storage root (RFC-011).
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub(crate) struct OperatorCluster {
|
||||
/// The cluster's storage-root URI (`file://` / `s3://`).
|
||||
pub(crate) root: String,
|
||||
#[serde(flatten)]
|
||||
unknown: serde_yaml::Mapping,
|
||||
}
|
||||
|
||||
/// The one entity a profile (or flat default) binds. Exactly one variant —
|
||||
/// the scope resolver consumes this; "exactly one of server/cluster/store"
|
||||
/// is enforced when producing it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ScopeBinding {
|
||||
/// Served scope: a server name (resolved against `servers:`) or a literal URL.
|
||||
Server(String),
|
||||
/// Direct cluster scope: a cluster name (resolved against `clusters:`) or a
|
||||
/// literal root URI.
|
||||
Cluster(String),
|
||||
/// Direct store scope: a single graph's storage URI.
|
||||
Store(String),
|
||||
}
|
||||
|
||||
impl OperatorConfig {
|
||||
pub(crate) fn actor(&self) -> Option<&str> {
|
||||
self.operator.actor.as_deref()
|
||||
|
|
@ -127,6 +186,57 @@ impl OperatorConfig {
|
|||
}
|
||||
best.map(|(name, _)| name)
|
||||
}
|
||||
|
||||
/// A named profile, if defined (RFC-011).
|
||||
pub(crate) fn profile(&self, name: &str) -> Option<&OperatorProfile> {
|
||||
self.profiles.get(name)
|
||||
}
|
||||
|
||||
/// The storage root of a named cluster, if defined (RFC-011).
|
||||
pub(crate) fn cluster_root(&self, name: &str) -> Option<&str> {
|
||||
self.clusters.get(name).map(|c| c.root.as_str())
|
||||
}
|
||||
|
||||
/// The flat-default server scope name, if set (RFC-011).
|
||||
pub(crate) fn default_server(&self) -> Option<&str> {
|
||||
self.defaults.server.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()
|
||||
}
|
||||
}
|
||||
|
||||
impl OperatorProfile {
|
||||
/// The single entity this profile binds, or a loud error if it binds zero
|
||||
/// or more than one of {server, cluster, store} (Decision 6: a scope binds
|
||||
/// exactly one entity). Validated here, on use, rather than at parse time.
|
||||
pub(crate) fn binding(&self, profile_name: &str) -> Result<ScopeBinding> {
|
||||
let set: Vec<&str> = [
|
||||
self.server.as_ref().map(|_| "server"),
|
||||
self.cluster.as_ref().map(|_| "cluster"),
|
||||
self.store.as_ref().map(|_| "store"),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
match set.as_slice() {
|
||||
["server"] => Ok(ScopeBinding::Server(self.server.clone().unwrap())),
|
||||
["cluster"] => Ok(ScopeBinding::Cluster(self.cluster.clone().unwrap())),
|
||||
["store"] => Ok(ScopeBinding::Store(self.store.clone().unwrap())),
|
||||
[] => Err(eyre!(
|
||||
"profile '{profile_name}' binds no scope; set exactly one of \
|
||||
`server`, `cluster`, or `store`"
|
||||
)),
|
||||
many => Err(eyre!(
|
||||
"profile '{profile_name}' binds {} scopes ({}); a profile must \
|
||||
bind exactly one of `server`, `cluster`, or `store`",
|
||||
many.len(),
|
||||
many.join(", ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator dir: `$OMNIGRAPH_HOME` if set (tilde-expanded), else
|
||||
|
|
@ -196,6 +306,12 @@ impl OperatorConfig {
|
|||
for (name, alias) in &self.aliases {
|
||||
collect(&alias.unknown, &format!("aliases.{name}."));
|
||||
}
|
||||
for (name, profile) in &self.profiles {
|
||||
collect(&profile.unknown, &format!("profiles.{name}."));
|
||||
}
|
||||
for (name, cluster) in &self.clusters {
|
||||
collect(&cluster.unknown, &format!("clusters.{name}."));
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
|
@ -464,6 +580,82 @@ mod tests {
|
|||
assert_eq!(config.servers["prod"].url, "https://example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_profiles_clusters_and_scope_defaults() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
let yaml = "\
|
||||
defaults:
|
||||
server: prod
|
||||
default_graph: knowledge
|
||||
servers:
|
||||
prod:
|
||||
url: https://example.com
|
||||
clusters:
|
||||
brain:
|
||||
root: s3://acme/clusters/brain
|
||||
profiles:
|
||||
staging:
|
||||
server: staging
|
||||
default_graph: knowledge
|
||||
brain-admin:
|
||||
cluster: brain
|
||||
default_graph: knowledge
|
||||
";
|
||||
fs::write(&path, yaml).unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
assert_eq!(config.default_server(), Some("prod"));
|
||||
assert_eq!(config.default_graph(), Some("knowledge"));
|
||||
assert_eq!(config.cluster_root("brain"), Some("s3://acme/clusters/brain"));
|
||||
assert_eq!(
|
||||
config.profile("staging").unwrap().binding("staging").unwrap(),
|
||||
ScopeBinding::Server("staging".into())
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.profile("brain-admin")
|
||||
.unwrap()
|
||||
.binding("brain-admin")
|
||||
.unwrap(),
|
||||
ScopeBinding::Cluster("brain".into())
|
||||
);
|
||||
// No unknown-key warnings for the new blocks.
|
||||
assert!(config.unknown_key_warnings().is_empty(), "{:?}", config.unknown_key_warnings());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_binding_rejects_zero_or_multiple_entities() {
|
||||
let none = OperatorProfile::default();
|
||||
let err = none.binding("p").unwrap_err().to_string();
|
||||
assert!(err.contains("binds no scope"), "{err}");
|
||||
|
||||
let two = OperatorProfile {
|
||||
server: Some("prod".into()),
|
||||
store: Some("graph.omni".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let err = two.binding("p").unwrap_err().to_string();
|
||||
assert!(err.contains("binds 2 scopes"), "{err}");
|
||||
assert!(err.contains("server") && err.contains("store"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_keys_in_a_profile_warn() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
"profiles:\n p:\n server: prod\n flavour: spicy\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = load_operator_config_at(&path).unwrap();
|
||||
let warnings = config.unknown_key_warnings();
|
||||
assert!(
|
||||
warnings.iter().any(|w| w.contains("`profiles.p.flavour`")),
|
||||
"{warnings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_yaml_is_a_loud_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
321
crates/omnigraph-cli/src/scope.rs
Normal file
321
crates/omnigraph-cli/src/scope.rs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
//! RFC-011 Slice A scope resolution.
|
||||
//!
|
||||
//! Translates the new scope inputs (`--profile` / `--store` / operator-config
|
||||
//! `profiles`/`clusters`/`defaults`) into the SAME effective addressing tuple
|
||||
//! the existing `GraphClient` factories (`client.rs`) and the maintenance
|
||||
//! resolver (`helpers::resolve_storage_uri`) already consume. This is a
|
||||
//! translation layer that sits *in front* of those resolvers — it is purely
|
||||
//! additive: an explicit legacy address (`--uri`/`--target`/`--server`/
|
||||
//! `--store`) wins and reproduces today's behavior exactly, so existing
|
||||
//! invocations are unaffected.
|
||||
//!
|
||||
//! The access path (served vs direct) is never chosen here; it falls out of the
|
||||
//! scope's binding × the verb's plane. The plane→scope capability check rejects
|
||||
//! mismatches (e.g. a server scope on a maintenance verb) only on the *new*
|
||||
//! resolution paths.
|
||||
|
||||
use std::env;
|
||||
|
||||
use color_eyre::Result;
|
||||
use color_eyre::eyre::{bail, eyre};
|
||||
|
||||
use crate::operator::{OperatorConfig, ScopeBinding};
|
||||
use crate::planes::Plane;
|
||||
|
||||
pub(crate) const PROFILE_ENV: &str = "OMNIGRAPH_PROFILE";
|
||||
|
||||
/// The effective addressing a command should use, in the terms the existing
|
||||
/// resolvers consume. Data/served verbs read `server`/`graph`/`uri`/`target`;
|
||||
/// maintenance verbs read `cluster`/`cluster_graph`.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct ResolvedScope {
|
||||
pub(crate) server: Option<String>,
|
||||
pub(crate) graph: Option<String>,
|
||||
pub(crate) uri: Option<String>,
|
||||
pub(crate) target: Option<String>,
|
||||
pub(crate) cluster: Option<String>,
|
||||
pub(crate) cluster_graph: Option<String>,
|
||||
}
|
||||
|
||||
/// The raw addressing inputs for one command: the global scope flags plus the
|
||||
/// command's own positional/`--target` address.
|
||||
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) graph: Option<&'a str>,
|
||||
pub(crate) uri: Option<String>,
|
||||
pub(crate) target: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Resolve the scope for a command on `plane`. Precedence (RFC-011):
|
||||
/// 1. explicit legacy/primitive address (`uri`/`target`/`--server`/`--store`) → passthrough;
|
||||
/// 2. `--profile` / `OMNIGRAPH_PROFILE`;
|
||||
/// 3. flat `defaults.server` + `defaults.default_graph`;
|
||||
/// 4. nothing — downstream behaves as today.
|
||||
pub(crate) fn resolve_scope(
|
||||
op: &OperatorConfig,
|
||||
plane: Plane,
|
||||
flags: ScopeFlags<'_>,
|
||||
) -> Result<ResolvedScope> {
|
||||
// 1. Any explicit address wins; reproduce today's behavior untouched.
|
||||
// `--store` is an explicit store URI — fold it into `uri`.
|
||||
if flags.uri.is_some() || flags.target.is_some() || flags.server.is_some() || flags.store.is_some()
|
||||
{
|
||||
return Ok(ResolvedScope {
|
||||
server: flags.server.map(str::to_string),
|
||||
graph: flags.graph.map(str::to_string),
|
||||
uri: flags.store.map(str::to_string).or(flags.uri),
|
||||
target: flags.target.map(str::to_string),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// 2. A named profile (flag, else env).
|
||||
let profile_name = flags
|
||||
.profile
|
||||
.map(str::to_string)
|
||||
.or_else(|| env::var(PROFILE_ENV).ok().filter(|s| !s.is_empty()));
|
||||
if let Some(name) = profile_name {
|
||||
let profile = op.profile(&name).ok_or_else(|| {
|
||||
eyre!("unknown profile '{name}' (not defined under `profiles:` in operator config)")
|
||||
})?;
|
||||
let binding = profile.binding(&name)?;
|
||||
let graph = flags
|
||||
.graph
|
||||
.map(str::to_string)
|
||||
.or_else(|| profile.default_graph.clone());
|
||||
return scope_from_binding(op, plane, binding, graph, &format!("profile '{name}'"));
|
||||
}
|
||||
|
||||
// 3. Flat default server scope.
|
||||
if let Some(server) = op.default_server() {
|
||||
let graph = flags
|
||||
.graph
|
||||
.map(str::to_string)
|
||||
.or_else(|| op.default_graph().map(str::to_string));
|
||||
return scope_from_binding(
|
||||
op,
|
||||
plane,
|
||||
ScopeBinding::Server(server.to_string()),
|
||||
graph,
|
||||
"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())
|
||||
}
|
||||
|
||||
/// Map a resolved binding to the effective tuple, enforcing scope × plane
|
||||
/// capability (RFC-011): a server scope is served (data only); a cluster scope
|
||||
/// is privileged direct (maintenance/control only); a store scope is direct
|
||||
/// (either).
|
||||
fn scope_from_binding(
|
||||
op: &OperatorConfig,
|
||||
plane: Plane,
|
||||
binding: ScopeBinding,
|
||||
graph: Option<String>,
|
||||
source: &str,
|
||||
) -> Result<ResolvedScope> {
|
||||
match binding {
|
||||
ScopeBinding::Server(server) => {
|
||||
if plane == Plane::Storage {
|
||||
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)"
|
||||
);
|
||||
}
|
||||
Ok(ResolvedScope {
|
||||
server: Some(server),
|
||||
graph,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
ScopeBinding::Cluster(cluster) => {
|
||||
if plane == Plane::Data {
|
||||
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"
|
||||
);
|
||||
}
|
||||
// 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"
|
||||
);
|
||||
};
|
||||
Ok(ResolvedScope {
|
||||
cluster: Some(root),
|
||||
cluster_graph: graph,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
ScopeBinding::Store(uri) => {
|
||||
if graph.is_some() {
|
||||
bail!(
|
||||
"--graph does not apply to a store scope ({source}): a store is already \
|
||||
a single graph"
|
||||
);
|
||||
}
|
||||
Ok(ResolvedScope {
|
||||
uri: Some(uri),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg(yaml: &str) -> OperatorConfig {
|
||||
serde_yaml::from_str(yaml).unwrap()
|
||||
}
|
||||
|
||||
fn flags<'a>() -> ScopeFlags<'a> {
|
||||
ScopeFlags {
|
||||
profile: None,
|
||||
store: None,
|
||||
server: None,
|
||||
graph: None,
|
||||
uri: None,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_legacy_address_wins_unchanged() {
|
||||
let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n");
|
||||
// A positional URI given → profile/defaults are ignored entirely.
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Plane::Data,
|
||||
ScopeFlags {
|
||||
uri: Some("graph.omni".into()),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.uri.as_deref(), Some("graph.omni"));
|
||||
assert_eq!(scope.server, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_flag_folds_into_uri_and_rejects_graph() {
|
||||
let op = OperatorConfig::default();
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Plane::Data,
|
||||
ScopeFlags {
|
||||
store: Some("s3://b/g.omni"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.uri.as_deref(), Some("s3://b/g.omni"));
|
||||
}
|
||||
|
||||
#[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");
|
||||
let scope = resolve_scope(&op, Plane::Data, flags()).unwrap();
|
||||
assert_eq!(scope.server.as_deref(), Some("prod"));
|
||||
assert_eq!(scope.graph.as_deref(), Some("knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_server_scope_with_graph_override() {
|
||||
let op = cfg(
|
||||
"servers:\n staging:\n url: https://s\nprofiles:\n staging:\n server: staging\n default_graph: knowledge\n",
|
||||
);
|
||||
let scope = resolve_scope(
|
||||
&op,
|
||||
Plane::Data,
|
||||
ScopeFlags {
|
||||
profile: Some("staging"),
|
||||
graph: Some("archive"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.server.as_deref(), Some("staging"));
|
||||
assert_eq!(scope.graph.as_deref(), Some("archive")); // flag beats profile default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_cluster_scope_resolves_root_for_maintenance() {
|
||||
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,
|
||||
Plane::Storage,
|
||||
ScopeFlags {
|
||||
profile: Some("admin"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(scope.cluster.as_deref(), Some("s3://acme/brain"));
|
||||
assert_eq!(scope.cluster_graph.as_deref(), Some("knowledge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_scope_on_maintenance_verb_errors() {
|
||||
let op = cfg("defaults:\n server: prod\nservers:\n prod:\n url: https://x\n");
|
||||
let err = resolve_scope(&op, Plane::Storage, flags()).unwrap_err().to_string();
|
||||
assert!(err.contains("direct storage access"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_scope_on_data_verb_errors() {
|
||||
let op = cfg(
|
||||
"clusters:\n brain:\n root: s3://acme/brain\nprofiles:\n admin:\n cluster: brain\n",
|
||||
);
|
||||
let err = resolve_scope(
|
||||
&op,
|
||||
Plane::Data,
|
||||
ScopeFlags {
|
||||
profile: Some("admin"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("maintenance-only"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_profile_is_a_loud_error() {
|
||||
let op = OperatorConfig::default();
|
||||
let err = resolve_scope(
|
||||
&op,
|
||||
Plane::Data,
|
||||
ScopeFlags {
|
||||
profile: Some("nope"),
|
||||
..flags()
|
||||
},
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("unknown profile 'nope'"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_address_resolves_empty_for_legacy_fallthrough() {
|
||||
let op = OperatorConfig::default();
|
||||
let scope = resolve_scope(&op, Plane::Data, flags()).unwrap();
|
||||
assert_eq!(scope, ResolvedScope::default());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
use std::fs;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use serde_json::Value;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
|
@ -801,6 +802,59 @@ fn read_json_outputs_rows_for_named_query() {
|
|||
assert_eq!(payload["rows"][0]["p.name"], "Alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_via_store_flag_and_profile_match_positional_uri() {
|
||||
// RFC-011 Slice A: the new scope addressing (--store, and a --profile that
|
||||
// binds a store) drives a read identically to the legacy positional URI —
|
||||
// the scope layer is additive, not a behavior change.
|
||||
let temp = tempdir().unwrap();
|
||||
let graph = graph_path(temp.path());
|
||||
init_graph(&graph);
|
||||
load_fixture(&graph);
|
||||
let queries = fixture("test.gq");
|
||||
|
||||
let read_rows = |cmd: &mut Command| -> Value {
|
||||
let output = output_success(
|
||||
cmd.arg("--query")
|
||||
.arg(&queries)
|
||||
.arg("--name")
|
||||
.arg("get_person")
|
||||
.arg("--params")
|
||||
.arg(r#"{"name":"Alice"}"#)
|
||||
.arg("--json"),
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).unwrap()
|
||||
};
|
||||
|
||||
// Baseline: positional URI.
|
||||
let baseline = read_rows(cli().arg("query").arg(&graph));
|
||||
assert_eq!(baseline["rows"][0]["p.name"], "Alice");
|
||||
|
||||
// --store names the same graph directly.
|
||||
let via_store = read_rows(cli().arg("query").arg("--store").arg(&graph));
|
||||
assert_eq!(via_store["rows"], baseline["rows"]);
|
||||
|
||||
// A profile binding that store, selected with --profile (no positional).
|
||||
let home = temp.path().join("op-home");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::write(
|
||||
home.join("config.yaml"),
|
||||
format!(
|
||||
"profiles:\n local:\n store: '{}'\n",
|
||||
graph.to_string_lossy()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let via_profile = read_rows(
|
||||
cli()
|
||||
.env("OMNIGRAPH_HOME", &home)
|
||||
.arg("query")
|
||||
.arg("--profile")
|
||||
.arg("local"),
|
||||
);
|
||||
assert_eq!(via_profile["rows"], baseline["rows"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_jsonl_outputs_source_rows_for_selected_branch_and_type() {
|
||||
let temp = tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
A reference for the `omnigraph` binary's command surface and `omnigraph.yaml` schema. For a quick-start guide, see [cli.md](index.md).
|
||||
|
||||
Top-level command families and subcommands. Graph-targeting commands accept a positional `URI`, `--uri`, a `--target <name>` resolved against `omnigraph.yaml`, or `--server <name>` (an operator-defined server from `~/.omnigraph/config.yaml`, optionally with `--graph <id>` for multi-graph servers; exclusive with the other forms); `cluster` commands use `--config <dir>`.
|
||||
Top-level command families and subcommands. Graph-targeting commands accept a positional `URI`, `--uri`, a `--target <name>` resolved against `omnigraph.yaml`, `--server <name>` (an operator-defined server from `~/.omnigraph/config.yaml`, optionally with `--graph <id>` for multi-graph servers; exclusive with the other forms), `--store <uri>` (a single graph's storage directly), or `--profile <name>` / `$OMNIGRAPH_PROFILE` (a named scope bundle; see [Scopes & profiles](#scopes--profiles-rfc-011)); `cluster` commands use `--config <dir>`.
|
||||
|
||||
## Top-level commands
|
||||
|
||||
|
|
@ -71,12 +71,42 @@ servers: # operator-owned endpoints; names key the credentials
|
|||
url: https://graph.example.com # no tokens in this file, ever
|
||||
defaults:
|
||||
output: table # read format default, below --json/--format/alias/legacy
|
||||
server: prod # the everyday scope when no address is given (RFC-011)
|
||||
default_graph: knowledge # graph selected in a server/cluster scope
|
||||
clusters: # admin-only: managed-cluster storage roots (RFC-011).
|
||||
brain: # the ONLY place a storage root lives in this file.
|
||||
root: s3://acme/clusters/brain
|
||||
profiles: # named scope bundles (RFC-011); pick with --profile
|
||||
staging: { server: staging, default_graph: knowledge } # a served scope
|
||||
brain-admin: { cluster: brain, default_graph: knowledge } # a direct cluster scope
|
||||
```
|
||||
|
||||
Absent file = empty layer. Unknown keys warn and load (a file written for a
|
||||
newer CLI works on an older one). `$OMNIGRAPH_CONFIG=<path>` stands in for
|
||||
`--config` (the flag wins) in both the CLI and the server.
|
||||
|
||||
#### Scopes & profiles (RFC-011)
|
||||
|
||||
A command resolves a **scope** — a server, a cluster, or a store — then selects a
|
||||
graph in it; the served-vs-direct access path is derived from the scope, not
|
||||
toggled. The scope comes from one of (highest precedence first): an explicit
|
||||
address (a positional URI, `--target`, `--server`, or `--store <uri>`); a named
|
||||
`--profile <name>` (or `$OMNIGRAPH_PROFILE`); or the flat `defaults.server` +
|
||||
`defaults.default_graph`. A **profile** binds exactly one of `server` / `cluster`
|
||||
/ `store` plus an optional default graph — config data, not state: every command
|
||||
resolves its scope fresh, there is no sticky "current" mode.
|
||||
|
||||
- `--store <uri>` addresses a single graph's storage directly (ad-hoc / break-glass).
|
||||
- A `cluster`-bound profile reaches `optimize` / `repair` / `cleanup` for a managed
|
||||
graph (resolving its storage root from `clusters:`), the same as
|
||||
`--cluster <root> --cluster-graph <id>`.
|
||||
- A `server`-bound scope on a maintenance verb, or a `cluster`-bound scope on a
|
||||
data verb, is rejected with a message pointing at the right addressing.
|
||||
|
||||
This model **coexists** with the legacy addressing (`--uri` / `--target` /
|
||||
`--cluster-graph` / `omnigraph.yaml`) — nothing is removed yet; an explicit legacy
|
||||
address always wins.
|
||||
|
||||
#### Credentials keyed by server name
|
||||
|
||||
`omnigraph login <name>` stores a bearer token in
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue