mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-21 02:28:07 +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,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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue