2026-06-11 20:29:02 +03:00
|
|
|
|
//! The operator config surface (RFC-007): `~/.omnigraph/config.yaml` — who
|
|
|
|
|
|
//! the operator IS (identity, ergonomics), never what the system is (that's
|
|
|
|
|
|
//! cluster config) and never a project file (nothing here arrives with a
|
|
|
|
|
|
//! repo checkout).
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! PR-1 scope: `operator.actor` + `defaults.output`. Unknown keys WARN and
|
|
|
|
|
|
//! are preserved-by-ignoring — a file written for a newer CLI (servers,
|
|
|
|
|
|
//! aliases, credentials keys from later slices) must load cleanly on this
|
|
|
|
|
|
//! one. Contrast with `cluster.yaml`, where unknown keys are fatal because
|
|
|
|
|
|
//! they change what a plan means.
|
|
|
|
|
|
//!
|
|
|
|
|
|
//! This module is CLI-only by design: the server never reads operator
|
|
|
|
|
|
//! config (server-side identity comes from bearer auth — invariant 11
|
|
|
|
|
|
//! holds by construction).
|
|
|
|
|
|
|
2026-06-11 21:24:51 +03:00
|
|
|
|
use std::collections::BTreeMap;
|
2026-06-11 20:29:02 +03:00
|
|
|
|
use std::env;
|
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
|
|
use color_eyre::Result;
|
2026-06-15 17:23:46 +03:00
|
|
|
|
use color_eyre::eyre::{bail, eyre};
|
2026-06-11 20:29:02 +03:00
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
|
|
feat!: delete the legacy OmnigraphConfig + config migrate; finish the omnigraph.yaml docs sweep (#252)
* refactor(cli): own ReadOutputFormat/TableCellLayout in the CLI
The two output-presentation enums lived in `omnigraph-server::config` and were
re-exported for the CLI, even though the server never used them. Move both
definitions into `omnigraph-cli/src/read_format.rs` (where the renderer already
lives) and drop them from the server's public re-export. This is a step toward
deleting the legacy `omnigraph-server::config` module entirely — a CLI
presentation concern has no business in the server crate.
No behavior change. The server keeps private copies in `config.rs` only for the
soon-to-be-deleted legacy `CliDefaults`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli)!: remove the `config migrate` command and migrate.rs
`config migrate` was the last CLI consumer of the legacy `omnigraph.yaml`
(`OmnigraphConfig` + `load_config`). With the excision complete there is no
legacy file to split, so the whole `omnigraph config` command group is removed
along with `migrate.rs`. The `OmnigraphConfig` type, `load_config`, and the
deprecation machinery are deleted next.
- Remove `Command::Config` / `ConfigCommand` from the clap surface and the
dispatch arm; drop `mod migrate;` and the now-unused `load_config` import.
- Drop the `Command::Config` arms in `planes.rs`.
- Delete the `config_migrate_splits_legacy_config` integration test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(server)!: delete the legacy OmnigraphConfig type and load_config
With `config migrate` gone, nothing loads `omnigraph.yaml` anymore. Delete the
entire `omnigraph-server::config` module: the `OmnigraphConfig` type and its
sub-structs (`ProjectConfig`, `TargetConfig`, `CliDefaults`, `ServerDefaults`,
`AuthDefaults`, `QueryDefaults`, `AliasConfig`, `AliasCommand`, `PolicySettings`,
`QueryEntry`, `McpSettings`), `load_config`, and the RFC-008 deprecation
machinery (`OMNIGRAPH_CONFIG`, `OMNIGRAPH_NO_LEGACY_CONFIG`,
`OMNIGRAPH_SUPPRESS_YAML_DEPRECATION`, the deprecation map + warner).
- `QueryRegistry::load` (the only `OmnigraphConfig`/`QueryEntry` consumer; its
only caller was its own test) is removed — server boot and the CLI both build
registries via `QueryRegistry::from_specs`.
- `graph_resource_id_for_selection` (CLI-only) moves into the CLI
(`helpers.rs`), with its unit test; the server no longer exports it.
- Drop the already-dead `format_registry_load_errors` helper (config-adjacent).
No behavior change — every deleted item was unreachable after the excision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: purge the legacy omnigraph.yaml surface from the docs
Finish the RFC-011 excision in the docs: the CLI no longer reads omnigraph.yaml
and the server boots cluster-only, so every doc that described the legacy file
as a live config is now wrong.
- AGENTS.md: rewrite the HTTP-server line to cluster-only boot (drop the
single-graph/flat-route and omnigraph.yaml-boot framing); rewrite the CLI
two-surface-config passage (drop `config migrate`, the deprecation env vars,
and "Never extend omnigraph.yaml"); fix the topic table + capability rows.
- cli/reference.md: delete the entire "omnigraph.yaml schema (legacy combined
file)" section and the `config migrate` row; re-home the `policy` row, the
bearer-token chain, the actor/format/param-precedence references, and the
`--config` mentions to the operator config + `--cluster`.
- cli/index.md: rewrite the multi-graph-server + add-graph paragraphs to
cluster (`--cluster` + `cluster apply`); fix the policy examples to
`--cluster`; replace the `## Config` omnigraph.yaml example with the
operator/cluster two-surface model.
- operations/policy.md: rewrite per-graph-vs-server-level policy to the cluster
`policies:`/`applies_to` model; re-home the actor + CLI tooling sections.
- clusters/config.md, clusters/index.md, deployment.md: server boots from the
cluster only; per-operator facts come from ~/.omnigraph/config.yaml.
- architecture.md, testing.md: drop the stale omnigraph.yaml / deleted-test
references.
RFCs, design specs, and prior release notes are left as historical records.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:31:29 +03:00
|
|
|
|
use crate::read_format::{ReadOutputFormat, TableCellLayout};
|
2026-06-11 20:29:02 +03:00
|
|
|
|
|
|
|
|
|
|
pub(crate) const OPERATOR_HOME_ENV: &str = "OMNIGRAPH_HOME";
|
|
|
|
|
|
pub(crate) const OPERATOR_DIR: &str = ".omnigraph";
|
|
|
|
|
|
pub(crate) const OPERATOR_CONFIG_FILE: &str = "config.yaml";
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
|
|
|
|
pub(crate) struct OperatorConfig {
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub(crate) operator: OperatorIdentity,
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub(crate) defaults: OperatorDefaults,
|
2026-06-11 21:24:51 +03:00
|
|
|
|
/// Operator-owned endpoint definitions (RFC-007 §D2/§D4): name → url.
|
|
|
|
|
|
/// The name keys the credential chain; nothing a repo checkout supplies
|
|
|
|
|
|
/// can redefine an entry here. No tokens in this file, ever.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub(crate) servers: BTreeMap<String, OperatorServer>,
|
feat(cli): operator aliases — pure bindings invoking stored queries (RFC-007 PR 3, part 2)
aliases: in the operator config bind a personal name to (server, graph,
stored-query NAME, positional arg mapping, fixed param defaults, format)
— zero content, per the ratified bindings-not-content model. Invocation
goes through the server's stored-query endpoint (POST
{base}/graphs/{g}/queries/{name}) with the keyed credential resolving via
the ordinary URL match; param precedence --params > positionals > fixed
defaults; the result renders through the existing format cascade with the
alias's format as its hop. A legacy omnigraph.yaml alias with the same
name wins during the RFC-008 window, with a warning naming both.
E2e (spawned policy-gated server, invoke_query granted via a per-graph
bundle): the alias invokes with name + one positional and nothing else —
server, graph, query, and token all from the operator layer; --server/
--graph explicit targeting; unknown --server lists defined names;
--server exclusive with a positional URI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:25:42 +03:00
|
|
|
|
/// Personal alias bindings (RFC-007 PR 3); see OperatorAlias.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub(crate) aliases: BTreeMap<String, OperatorAlias>,
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
/// 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>,
|
2026-06-11 20:29:02 +03:00
|
|
|
|
/// Everything this CLI version doesn't know. Warned once at load,
|
|
|
|
|
|
/// otherwise ignored (forward compatibility within the operator layer).
|
|
|
|
|
|
#[serde(flatten)]
|
|
|
|
|
|
unknown: serde_yaml::Mapping,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(cli): operator aliases — pure bindings invoking stored queries (RFC-007 PR 3, part 2)
aliases: in the operator config bind a personal name to (server, graph,
stored-query NAME, positional arg mapping, fixed param defaults, format)
— zero content, per the ratified bindings-not-content model. Invocation
goes through the server's stored-query endpoint (POST
{base}/graphs/{g}/queries/{name}) with the keyed credential resolving via
the ordinary URL match; param precedence --params > positionals > fixed
defaults; the result renders through the existing format cascade with the
alias's format as its hop. A legacy omnigraph.yaml alias with the same
name wins during the RFC-008 window, with a warning naming both.
E2e (spawned policy-gated server, invoke_query granted via a per-graph
bundle): the alias invokes with name + one positional and nothing else —
server, graph, query, and token all from the operator layer; --server/
--graph explicit targeting; unknown --server lists defined names;
--server exclusive with a positional URI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:25:42 +03:00
|
|
|
|
/// A personal alias: a pure BINDING to a stored query on a named server —
|
|
|
|
|
|
/// never content, never a file (RFC-007 §D2 "Aliases are bindings, not
|
|
|
|
|
|
/// content"). The stored query is the team's contract; the alias, its
|
|
|
|
|
|
/// defaults, and its name are the operator's.
|
|
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
|
pub(crate) struct OperatorAlias {
|
|
|
|
|
|
/// Names an entry under `servers:`.
|
|
|
|
|
|
pub(crate) server: String,
|
|
|
|
|
|
/// Graph id for multi-graph servers (appends `/graphs/<id>`).
|
|
|
|
|
|
pub(crate) graph: Option<String>,
|
|
|
|
|
|
/// The STORED query's name on that server.
|
|
|
|
|
|
pub(crate) query: String,
|
|
|
|
|
|
/// Positional CLI args bind to these param names, in order.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub(crate) args: Vec<String>,
|
|
|
|
|
|
/// Fixed default params; positionals and `--params` override per key.
|
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
|
pub(crate) params: serde_yaml::Mapping,
|
|
|
|
|
|
pub(crate) format: Option<ReadOutputFormat>,
|
|
|
|
|
|
#[serde(flatten)]
|
|
|
|
|
|
unknown: serde_yaml::Mapping,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 21:24:51 +03:00
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
|
pub(crate) struct OperatorServer {
|
|
|
|
|
|
pub(crate) url: String,
|
|
|
|
|
|
#[serde(flatten)]
|
|
|
|
|
|
unknown: serde_yaml::Mapping,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 20:29:02 +03:00
|
|
|
|
#[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.
|
|
|
|
|
|
pub(crate) actor: Option<String>,
|
|
|
|
|
|
#[serde(flatten)]
|
|
|
|
|
|
unknown: serde_yaml::Mapping,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
|
|
|
|
pub(crate) struct OperatorDefaults {
|
|
|
|
|
|
/// Default read output format, below every more-specific source.
|
|
|
|
|
|
pub(crate) output: Option<ReadOutputFormat>,
|
feat!: delete the legacy OmnigraphConfig + config migrate; finish the omnigraph.yaml docs sweep (#252)
* refactor(cli): own ReadOutputFormat/TableCellLayout in the CLI
The two output-presentation enums lived in `omnigraph-server::config` and were
re-exported for the CLI, even though the server never used them. Move both
definitions into `omnigraph-cli/src/read_format.rs` (where the renderer already
lives) and drop them from the server's public re-export. This is a step toward
deleting the legacy `omnigraph-server::config` module entirely — a CLI
presentation concern has no business in the server crate.
No behavior change. The server keeps private copies in `config.rs` only for the
soon-to-be-deleted legacy `CliDefaults`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli)!: remove the `config migrate` command and migrate.rs
`config migrate` was the last CLI consumer of the legacy `omnigraph.yaml`
(`OmnigraphConfig` + `load_config`). With the excision complete there is no
legacy file to split, so the whole `omnigraph config` command group is removed
along with `migrate.rs`. The `OmnigraphConfig` type, `load_config`, and the
deprecation machinery are deleted next.
- Remove `Command::Config` / `ConfigCommand` from the clap surface and the
dispatch arm; drop `mod migrate;` and the now-unused `load_config` import.
- Drop the `Command::Config` arms in `planes.rs`.
- Delete the `config_migrate_splits_legacy_config` integration test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(server)!: delete the legacy OmnigraphConfig type and load_config
With `config migrate` gone, nothing loads `omnigraph.yaml` anymore. Delete the
entire `omnigraph-server::config` module: the `OmnigraphConfig` type and its
sub-structs (`ProjectConfig`, `TargetConfig`, `CliDefaults`, `ServerDefaults`,
`AuthDefaults`, `QueryDefaults`, `AliasConfig`, `AliasCommand`, `PolicySettings`,
`QueryEntry`, `McpSettings`), `load_config`, and the RFC-008 deprecation
machinery (`OMNIGRAPH_CONFIG`, `OMNIGRAPH_NO_LEGACY_CONFIG`,
`OMNIGRAPH_SUPPRESS_YAML_DEPRECATION`, the deprecation map + warner).
- `QueryRegistry::load` (the only `OmnigraphConfig`/`QueryEntry` consumer; its
only caller was its own test) is removed — server boot and the CLI both build
registries via `QueryRegistry::from_specs`.
- `graph_resource_id_for_selection` (CLI-only) moves into the CLI
(`helpers.rs`), with its unit test; the server no longer exports it.
- Drop the already-dead `format_registry_load_errors` helper (config-adjacent).
No behavior change — every deleted item was unreachable after the excision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: purge the legacy omnigraph.yaml surface from the docs
Finish the RFC-011 excision in the docs: the CLI no longer reads omnigraph.yaml
and the server boots cluster-only, so every doc that described the legacy file
as a live config is now wrong.
- AGENTS.md: rewrite the HTTP-server line to cluster-only boot (drop the
single-graph/flat-route and omnigraph.yaml-boot framing); rewrite the CLI
two-surface-config passage (drop `config migrate`, the deprecation env vars,
and "Never extend omnigraph.yaml"); fix the topic table + capability rows.
- cli/reference.md: delete the entire "omnigraph.yaml schema (legacy combined
file)" section and the `config migrate` row; re-home the `policy` row, the
bearer-token chain, the actor/format/param-precedence references, and the
`--config` mentions to the operator config + `--cluster`.
- cli/index.md: rewrite the multi-graph-server + add-graph paragraphs to
cluster (`--cluster` + `cluster apply`); fix the policy examples to
`--cluster`; replace the `## Config` omnigraph.yaml example with the
operator/cluster two-surface model.
- operations/policy.md: rewrite per-graph-vs-server-level policy to the cluster
`policies:`/`applies_to` model; re-home the actor + CLI tooling sections.
- clusters/config.md, clusters/index.md, deployment.md: server boots from the
cluster only; per-operator facts come from ~/.omnigraph/config.yaml.
- architecture.md, testing.md: drop the stale omnigraph.yaml / deleted-test
references.
RFCs, design specs, and prior release notes are left as historical records.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:31:29 +03:00
|
|
|
|
/// Table rendering preferences for `--format table`.
|
2026-06-11 23:32:05 +03:00
|
|
|
|
pub(crate) table_max_column_width: Option<usize>,
|
feat!: delete the legacy OmnigraphConfig + config migrate; finish the omnigraph.yaml docs sweep (#252)
* refactor(cli): own ReadOutputFormat/TableCellLayout in the CLI
The two output-presentation enums lived in `omnigraph-server::config` and were
re-exported for the CLI, even though the server never used them. Move both
definitions into `omnigraph-cli/src/read_format.rs` (where the renderer already
lives) and drop them from the server's public re-export. This is a step toward
deleting the legacy `omnigraph-server::config` module entirely — a CLI
presentation concern has no business in the server crate.
No behavior change. The server keeps private copies in `config.rs` only for the
soon-to-be-deleted legacy `CliDefaults`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli)!: remove the `config migrate` command and migrate.rs
`config migrate` was the last CLI consumer of the legacy `omnigraph.yaml`
(`OmnigraphConfig` + `load_config`). With the excision complete there is no
legacy file to split, so the whole `omnigraph config` command group is removed
along with `migrate.rs`. The `OmnigraphConfig` type, `load_config`, and the
deprecation machinery are deleted next.
- Remove `Command::Config` / `ConfigCommand` from the clap surface and the
dispatch arm; drop `mod migrate;` and the now-unused `load_config` import.
- Drop the `Command::Config` arms in `planes.rs`.
- Delete the `config_migrate_splits_legacy_config` integration test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(server)!: delete the legacy OmnigraphConfig type and load_config
With `config migrate` gone, nothing loads `omnigraph.yaml` anymore. Delete the
entire `omnigraph-server::config` module: the `OmnigraphConfig` type and its
sub-structs (`ProjectConfig`, `TargetConfig`, `CliDefaults`, `ServerDefaults`,
`AuthDefaults`, `QueryDefaults`, `AliasConfig`, `AliasCommand`, `PolicySettings`,
`QueryEntry`, `McpSettings`), `load_config`, and the RFC-008 deprecation
machinery (`OMNIGRAPH_CONFIG`, `OMNIGRAPH_NO_LEGACY_CONFIG`,
`OMNIGRAPH_SUPPRESS_YAML_DEPRECATION`, the deprecation map + warner).
- `QueryRegistry::load` (the only `OmnigraphConfig`/`QueryEntry` consumer; its
only caller was its own test) is removed — server boot and the CLI both build
registries via `QueryRegistry::from_specs`.
- `graph_resource_id_for_selection` (CLI-only) moves into the CLI
(`helpers.rs`), with its unit test; the server no longer exports it.
- Drop the already-dead `format_registry_load_errors` helper (config-adjacent).
No behavior change — every deleted item was unreachable after the excision.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs: purge the legacy omnigraph.yaml surface from the docs
Finish the RFC-011 excision in the docs: the CLI no longer reads omnigraph.yaml
and the server boots cluster-only, so every doc that described the legacy file
as a live config is now wrong.
- AGENTS.md: rewrite the HTTP-server line to cluster-only boot (drop the
single-graph/flat-route and omnigraph.yaml-boot framing); rewrite the CLI
two-surface-config passage (drop `config migrate`, the deprecation env vars,
and "Never extend omnigraph.yaml"); fix the topic table + capability rows.
- cli/reference.md: delete the entire "omnigraph.yaml schema (legacy combined
file)" section and the `config migrate` row; re-home the `policy` row, the
bearer-token chain, the actor/format/param-precedence references, and the
`--config` mentions to the operator config + `--cluster`.
- cli/index.md: rewrite the multi-graph-server + add-graph paragraphs to
cluster (`--cluster` + `cluster apply`); fix the policy examples to
`--cluster`; replace the `## Config` omnigraph.yaml example with the
operator/cluster two-surface model.
- operations/policy.md: rewrite per-graph-vs-server-level policy to the cluster
`policies:`/`applies_to` model; re-home the actor + CLI tooling sections.
- clusters/config.md, clusters/index.md, deployment.md: server boots from the
cluster only; per-operator facts come from ~/.omnigraph/config.yaml.
- architecture.md, testing.md: drop the stale omnigraph.yaml / deleted-test
references.
RFCs, design specs, and prior release notes are left as historical records.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:31:29 +03:00
|
|
|
|
pub(crate) table_cell_layout: Option<TableCellLayout>,
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
/// Default server scope (RFC-011): the everyday addressing when no
|
|
|
|
|
|
/// `--profile` / primitive / legacy address is given. Names an entry
|
2026-06-15 17:23:46 +03:00
|
|
|
|
/// under `servers:`. Mutually exclusive with `store` — a scope binds one
|
|
|
|
|
|
/// entity.
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
pub(crate) server: Option<String>,
|
2026-06-15 17:23:46 +03:00
|
|
|
|
/// 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>,
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
/// 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>,
|
2026-06-11 20:29:02 +03:00
|
|
|
|
#[serde(flatten)]
|
|
|
|
|
|
unknown: serde_yaml::Mapping,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
/// 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),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 20:29:02 +03:00
|
|
|
|
impl OperatorConfig {
|
|
|
|
|
|
pub(crate) fn actor(&self) -> Option<&str> {
|
|
|
|
|
|
self.operator.actor.as_deref()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn output(&self) -> Option<ReadOutputFormat> {
|
|
|
|
|
|
self.defaults.output
|
|
|
|
|
|
}
|
2026-06-11 21:24:51 +03:00
|
|
|
|
|
|
|
|
|
|
/// The gh-host model: which operator server (if any) does this request
|
|
|
|
|
|
/// URL belong to? Longest-prefix match after trailing-slash
|
|
|
|
|
|
/// normalization, so `url: http://h:8080` matches
|
|
|
|
|
|
/// `http://h:8080/graphs/spike` but never `http://h:8080-evil`.
|
|
|
|
|
|
pub(crate) fn find_server_for_url(&self, request_url: &str) -> Option<&str> {
|
|
|
|
|
|
let request = request_url.trim_end_matches('/');
|
|
|
|
|
|
let mut best: Option<(&str, usize)> = None;
|
|
|
|
|
|
for (name, server) in &self.servers {
|
|
|
|
|
|
let base = server.url.trim_end_matches('/');
|
|
|
|
|
|
let matches = request == base
|
|
|
|
|
|
|| request
|
|
|
|
|
|
.strip_prefix(base)
|
|
|
|
|
|
.is_some_and(|rest| rest.starts_with('/'));
|
|
|
|
|
|
if matches && best.is_none_or(|(_, len)| base.len() > len) {
|
|
|
|
|
|
best = Some((name, base.len()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
best.map(|(name, _)| name)
|
|
|
|
|
|
}
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
|
|
|
|
|
|
/// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 17:23:46 +03:00
|
|
|
|
/// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
/// 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()
|
|
|
|
|
|
}
|
2026-06-15 17:23:46 +03:00
|
|
|
|
|
|
|
|
|
|
/// 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(())
|
|
|
|
|
|
}
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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(", ")
|
|
|
|
|
|
)),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-11 20:29:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The operator dir: `$OMNIGRAPH_HOME` if set (tilde-expanded), else
|
|
|
|
|
|
/// `~/.omnigraph`. Returns None when no home directory is resolvable
|
|
|
|
|
|
/// (degenerate environments — the layer is simply absent).
|
|
|
|
|
|
pub(crate) fn operator_dir() -> Option<PathBuf> {
|
|
|
|
|
|
if let Some(home_override) = env::var_os(OPERATOR_HOME_ENV) {
|
|
|
|
|
|
let raw = home_override.to_string_lossy().into_owned();
|
|
|
|
|
|
return Some(expand_tilde(&raw));
|
|
|
|
|
|
}
|
|
|
|
|
|
env::home_dir().map(|home| home.join(OPERATOR_DIR))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Load the operator layer. Absent file (or unresolvable home) is an empty
|
|
|
|
|
|
/// layer, never an error; a present-but-malformed file is a loud error (the
|
|
|
|
|
|
/// operator owns it and can fix it); unknown keys warn to stderr once.
|
|
|
|
|
|
pub(crate) fn load_operator_config() -> Result<OperatorConfig> {
|
|
|
|
|
|
let Some(dir) = operator_dir() else {
|
|
|
|
|
|
return Ok(OperatorConfig::default());
|
|
|
|
|
|
};
|
|
|
|
|
|
load_operator_config_at(&dir.join(OPERATOR_CONFIG_FILE))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn load_operator_config_at(path: &Path) -> Result<OperatorConfig> {
|
|
|
|
|
|
let text = match std::fs::read_to_string(path) {
|
|
|
|
|
|
Ok(text) => text,
|
|
|
|
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
|
|
|
|
return Ok(OperatorConfig::default());
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
return Err(eyre!(
|
|
|
|
|
|
"could not read operator config '{}': {err}",
|
|
|
|
|
|
path.display()
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
let config: OperatorConfig = serde_yaml::from_str(&text).map_err(|err| {
|
|
|
|
|
|
eyre!(
|
|
|
|
|
|
"could not parse operator config '{}': {err}",
|
|
|
|
|
|
path.display()
|
|
|
|
|
|
)
|
|
|
|
|
|
})?;
|
|
|
|
|
|
for warning in config.unknown_key_warnings() {
|
|
|
|
|
|
eprintln!("warning: {warning} in operator config '{}'", path.display());
|
|
|
|
|
|
}
|
2026-06-15 17:23:46 +03:00
|
|
|
|
config.validate_defaults()?;
|
2026-06-11 20:29:02 +03:00
|
|
|
|
Ok(config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl OperatorConfig {
|
|
|
|
|
|
fn unknown_key_warnings(&self) -> Vec<String> {
|
|
|
|
|
|
let mut warnings = Vec::new();
|
|
|
|
|
|
let mut collect = |mapping: &serde_yaml::Mapping, prefix: &str| {
|
|
|
|
|
|
for key in mapping.keys() {
|
|
|
|
|
|
if let Some(name) = key.as_str() {
|
|
|
|
|
|
warnings.push(format!(
|
|
|
|
|
|
"unknown key `{prefix}{name}` (newer CLI feature or typo); ignored"
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
collect(&self.unknown, "");
|
|
|
|
|
|
collect(&self.operator.unknown, "operator.");
|
|
|
|
|
|
collect(&self.defaults.unknown, "defaults.");
|
2026-06-11 21:24:51 +03:00
|
|
|
|
for (name, server) in &self.servers {
|
|
|
|
|
|
collect(&server.unknown, &format!("servers.{name}."));
|
|
|
|
|
|
}
|
feat(cli): operator aliases — pure bindings invoking stored queries (RFC-007 PR 3, part 2)
aliases: in the operator config bind a personal name to (server, graph,
stored-query NAME, positional arg mapping, fixed param defaults, format)
— zero content, per the ratified bindings-not-content model. Invocation
goes through the server's stored-query endpoint (POST
{base}/graphs/{g}/queries/{name}) with the keyed credential resolving via
the ordinary URL match; param precedence --params > positionals > fixed
defaults; the result renders through the existing format cascade with the
alias's format as its hop. A legacy omnigraph.yaml alias with the same
name wins during the RFC-008 window, with a warning naming both.
E2e (spawned policy-gated server, invoke_query granted via a per-graph
bundle): the alias invokes with name + one positional and nothing else —
server, graph, query, and token all from the operator layer; --server/
--graph explicit targeting; unknown --server lists defined names;
--server exclusive with a positional URI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:25:42 +03:00
|
|
|
|
for (name, alias) in &self.aliases {
|
|
|
|
|
|
collect(&alias.unknown, &format!("aliases.{name}."));
|
|
|
|
|
|
}
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
for (name, profile) in &self.profiles {
|
|
|
|
|
|
collect(&profile.unknown, &format!("profiles.{name}."));
|
|
|
|
|
|
}
|
|
|
|
|
|
for (name, cluster) in &self.clusters {
|
|
|
|
|
|
collect(&cluster.unknown, &format!("clusters.{name}."));
|
|
|
|
|
|
}
|
2026-06-11 20:29:02 +03:00
|
|
|
|
warnings
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 21:24:51 +03:00
|
|
|
|
// ---- keyed credentials (RFC-007 §D4) ----
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) const CREDENTIALS_FILE: &str = "credentials";
|
|
|
|
|
|
const TOKEN_ENV_PREFIX: &str = "OMNIGRAPH_TOKEN_";
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn credentials_path() -> Option<PathBuf> {
|
|
|
|
|
|
operator_dir().map(|dir| dir.join(CREDENTIALS_FILE))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// `intel-dev` → `OMNIGRAPH_TOKEN_INTEL_DEV`.
|
|
|
|
|
|
pub(crate) fn token_env_name(server: &str) -> String {
|
|
|
|
|
|
let mut name = String::from(TOKEN_ENV_PREFIX);
|
|
|
|
|
|
for c in server.chars() {
|
|
|
|
|
|
name.push(match c {
|
|
|
|
|
|
'-' => '_',
|
|
|
|
|
|
other => other.to_ascii_uppercase(),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
name
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The keyed token chain for a named server (§D4 steps 1–2):
|
|
|
|
|
|
/// `OMNIGRAPH_TOKEN_<NAME>` env → `[<name>]` in the credentials file.
|
|
|
|
|
|
/// `Ok(None)` means "no keyed token" — callers fall through to the legacy
|
|
|
|
|
|
/// chain; a present-but-unreadable/over-permissive credentials file is a
|
|
|
|
|
|
/// loud error, never a silent skip.
|
|
|
|
|
|
pub(crate) fn resolve_keyed_token(server: &str) -> Result<Option<String>> {
|
|
|
|
|
|
if let Ok(token) = env::var(token_env_name(server)) {
|
|
|
|
|
|
let token = token.trim();
|
|
|
|
|
|
if !token.is_empty() {
|
|
|
|
|
|
return Ok(Some(token.to_string()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let Some(path) = credentials_path() else {
|
|
|
|
|
|
return Ok(None);
|
|
|
|
|
|
};
|
|
|
|
|
|
read_credential_at(&path, server)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn read_credential_at(path: &Path, server: &str) -> Result<Option<String>> {
|
|
|
|
|
|
let text = match std::fs::read_to_string(path) {
|
|
|
|
|
|
Ok(text) => text,
|
|
|
|
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
return Err(eyre!(
|
|
|
|
|
|
"could not read credentials file '{}': {err}",
|
|
|
|
|
|
path.display()
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
refuse_over_permissive(path)?;
|
|
|
|
|
|
let mut in_section = false;
|
|
|
|
|
|
for line in text.lines() {
|
|
|
|
|
|
let line = line.trim();
|
|
|
|
|
|
if line.is_empty() || line.starts_with('#') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(section) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
|
|
|
|
|
in_section = section.trim() == server;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if in_section {
|
|
|
|
|
|
if let Some((key, value)) = line.split_once('=') {
|
|
|
|
|
|
if key.trim() == "token" {
|
|
|
|
|
|
let value = unquote(value.trim());
|
|
|
|
|
|
if value.is_empty() {
|
|
|
|
|
|
return Ok(None);
|
|
|
|
|
|
}
|
|
|
|
|
|
return Ok(Some(value.to_string()));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(None)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Write (or rotate) one server's token, preserving every other section.
|
|
|
|
|
|
/// Temp file + rename (#139 finding 7), created 0600.
|
|
|
|
|
|
pub(crate) fn write_credential(server: &str, token: &str) -> Result<PathBuf> {
|
|
|
|
|
|
let path = credentials_path()
|
|
|
|
|
|
.ok_or_else(|| eyre!("no home directory resolvable for the credentials file"))?;
|
|
|
|
|
|
rewrite_credentials_at(&path, server, Some(token))?;
|
|
|
|
|
|
Ok(path)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Remove one server's section. Idempotent: absent file or section is fine.
|
|
|
|
|
|
pub(crate) fn remove_credential(server: &str) -> Result<PathBuf> {
|
|
|
|
|
|
let path = credentials_path()
|
|
|
|
|
|
.ok_or_else(|| eyre!("no home directory resolvable for the credentials file"))?;
|
|
|
|
|
|
rewrite_credentials_at(&path, server, None)?;
|
|
|
|
|
|
Ok(path)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub(crate) fn rewrite_credentials_at(
|
|
|
|
|
|
path: &Path,
|
|
|
|
|
|
server: &str,
|
|
|
|
|
|
token: Option<&str>,
|
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
|
let existing = match std::fs::read_to_string(path) {
|
|
|
|
|
|
Ok(text) => {
|
|
|
|
|
|
refuse_over_permissive(path)?;
|
|
|
|
|
|
text
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
|
|
|
|
|
|
Err(err) => {
|
|
|
|
|
|
return Err(eyre!(
|
|
|
|
|
|
"could not read credentials file '{}': {err}",
|
|
|
|
|
|
path.display()
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Drop the target section (if present), keep everything else verbatim.
|
|
|
|
|
|
let mut out = String::new();
|
|
|
|
|
|
let mut in_target = false;
|
|
|
|
|
|
for line in existing.lines() {
|
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
|
if let Some(section) = trimmed.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
|
|
|
|
|
in_target = section.trim() == server;
|
|
|
|
|
|
if in_target {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !in_target {
|
|
|
|
|
|
out.push_str(line);
|
|
|
|
|
|
out.push('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(token) = token {
|
|
|
|
|
|
if !out.is_empty() && !out.ends_with("\n\n") {
|
|
|
|
|
|
out.push('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
out.push_str(&format!("[{server}]\ntoken = {token}\n"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
|
std::fs::create_dir_all(parent)?;
|
|
|
|
|
|
}
|
|
|
|
|
|
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
|
|
|
|
|
|
write_owner_only(&tmp, &out)?;
|
|
|
|
|
|
std::fs::rename(&tmp, path).map_err(|err| {
|
|
|
|
|
|
let _ = std::fs::remove_file(&tmp);
|
|
|
|
|
|
eyre!(
|
|
|
|
|
|
"could not move credentials file into place '{}': {err}",
|
|
|
|
|
|
path.display()
|
|
|
|
|
|
)
|
|
|
|
|
|
})?;
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
fn write_owner_only(path: &Path, content: &str) -> Result<()> {
|
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
|
|
let mut file = std::fs::OpenOptions::new()
|
|
|
|
|
|
.write(true)
|
|
|
|
|
|
.create(true)
|
|
|
|
|
|
.truncate(true)
|
|
|
|
|
|
.mode(0o600)
|
|
|
|
|
|
.open(path)?;
|
|
|
|
|
|
file.write_all(content.as_bytes())?;
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(not(unix))]
|
|
|
|
|
|
fn write_owner_only(path: &Path, content: &str) -> Result<()> {
|
|
|
|
|
|
std::fs::write(path, content)?;
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Secrets are operator-private: refuse a credentials file other accounts
|
|
|
|
|
|
/// can read (the chain errs loudly rather than using a leaked secret).
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
fn refuse_over_permissive(path: &Path) -> Result<()> {
|
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
let mode = std::fs::metadata(path)?.permissions().mode();
|
|
|
|
|
|
if mode & 0o077 != 0 {
|
|
|
|
|
|
return Err(eyre!(
|
|
|
|
|
|
"credentials file '{}' is group/world-accessible (mode {:o}); run `chmod 600 {}`",
|
|
|
|
|
|
path.display(),
|
|
|
|
|
|
mode & 0o777,
|
|
|
|
|
|
path.display()
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(not(unix))]
|
|
|
|
|
|
fn refuse_over_permissive(_path: &Path) -> Result<()> {
|
|
|
|
|
|
Ok(())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn unquote(value: &str) -> &str {
|
|
|
|
|
|
if value.len() >= 2
|
|
|
|
|
|
&& ((value.starts_with('"') && value.ends_with('"'))
|
|
|
|
|
|
|| (value.starts_with('\'') && value.ends_with('\'')))
|
|
|
|
|
|
{
|
|
|
|
|
|
&value[1..value.len() - 1]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
value
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 20:29:02 +03:00
|
|
|
|
/// Expand a leading `~` / `~/` to the home directory (PR #139 finding 9:
|
|
|
|
|
|
/// a literal `./~/…` path silently created a directory named `~`).
|
|
|
|
|
|
pub(crate) fn expand_tilde(raw: &str) -> PathBuf {
|
|
|
|
|
|
if raw == "~" {
|
|
|
|
|
|
return env::home_dir().unwrap_or_else(|| PathBuf::from(raw));
|
|
|
|
|
|
}
|
|
|
|
|
|
if let Some(rest) = raw.strip_prefix("~/") {
|
|
|
|
|
|
if let Some(home) = env::home_dir() {
|
|
|
|
|
|
return home.join(rest);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
PathBuf::from(raw)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests {
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
use std::fs;
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn absent_file_is_an_empty_layer() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let config = load_operator_config_at(&dir.path().join("config.yaml")).unwrap();
|
|
|
|
|
|
assert!(config.actor().is_none());
|
|
|
|
|
|
assert!(config.output().is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn parses_identity_and_defaults() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("config.yaml");
|
|
|
|
|
|
fs::write(
|
|
|
|
|
|
&path,
|
|
|
|
|
|
"operator:\n actor: act-andrew\ndefaults:\n output: json\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let config = load_operator_config_at(&path).unwrap();
|
|
|
|
|
|
assert_eq!(config.actor(), Some("act-andrew"));
|
|
|
|
|
|
assert_eq!(config.output(), Some(ReadOutputFormat::Json));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 17:23:46 +03:00
|
|
|
|
#[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}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 20:29:02 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn unknown_keys_warn_but_load() {
|
|
|
|
|
|
// A file written for a later slice (servers/aliases) must load
|
|
|
|
|
|
// cleanly today — warn-only forward compatibility.
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("config.yaml");
|
|
|
|
|
|
fs::write(
|
|
|
|
|
|
&path,
|
|
|
|
|
|
"operator:\n actor: act-a\n color: green\nservers:\n prod:\n url: https://example.com\naliases: {}\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let config = load_operator_config_at(&path).unwrap();
|
|
|
|
|
|
assert_eq!(config.actor(), Some("act-a"));
|
|
|
|
|
|
let warnings = config.unknown_key_warnings();
|
feat(cli): operator aliases — pure bindings invoking stored queries (RFC-007 PR 3, part 2)
aliases: in the operator config bind a personal name to (server, graph,
stored-query NAME, positional arg mapping, fixed param defaults, format)
— zero content, per the ratified bindings-not-content model. Invocation
goes through the server's stored-query endpoint (POST
{base}/graphs/{g}/queries/{name}) with the keyed credential resolving via
the ordinary URL match; param precedence --params > positionals > fixed
defaults; the result renders through the existing format cascade with the
alias's format as its hop. A legacy omnigraph.yaml alias with the same
name wins during the RFC-008 window, with a warning naming both.
E2e (spawned policy-gated server, invoke_query granted via a per-graph
bundle): the alias invokes with name + one positional and nothing else —
server, graph, query, and token all from the operator layer; --server/
--graph explicit targeting; unknown --server lists defined names;
--server exclusive with a positional URI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:25:42 +03:00
|
|
|
|
// `servers` (PR 2) and `aliases` (PR 3) are known keys now.
|
|
|
|
|
|
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
2026-06-11 20:29:02 +03:00
|
|
|
|
assert!(warnings.iter().any(|w| w.contains("`operator.color`")));
|
2026-06-11 21:24:51 +03:00
|
|
|
|
assert_eq!(config.servers["prod"].url, "https://example.com");
|
2026-06-11 20:29:02 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(cli): RFC-011 Slice A — additive scope/profile addressing (#235)
* 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>
2026-06-15 02:37:55 +03:00
|
|
|
|
#[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:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 20:29:02 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn malformed_yaml_is_a_loud_error() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("config.yaml");
|
|
|
|
|
|
fs::write(&path, "operator: [not, a, mapping\n").unwrap();
|
|
|
|
|
|
let err = load_operator_config_at(&path).unwrap_err();
|
|
|
|
|
|
assert!(err.to_string().contains("could not parse operator config"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 21:24:51 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn find_server_for_url_longest_prefix_no_substring_traps() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("config.yaml");
|
|
|
|
|
|
fs::write(
|
|
|
|
|
|
&path,
|
|
|
|
|
|
"servers:\n dev:\n url: http://h:8080\n dev-spike:\n url: http://h:8080/graphs/spike\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let config = load_operator_config_at(&path).unwrap();
|
|
|
|
|
|
assert_eq!(config.find_server_for_url("http://h:8080"), Some("dev"));
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
config.find_server_for_url("http://h:8080/graphs/other"),
|
|
|
|
|
|
Some("dev")
|
|
|
|
|
|
);
|
|
|
|
|
|
// longest prefix wins
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
config.find_server_for_url("http://h:8080/graphs/spike/queries/q"),
|
|
|
|
|
|
Some("dev-spike")
|
|
|
|
|
|
);
|
|
|
|
|
|
// no substring trap: a different port/host must not match
|
|
|
|
|
|
assert_eq!(config.find_server_for_url("http://h:8080-evil/x"), None);
|
|
|
|
|
|
assert_eq!(config.find_server_for_url("http://other:9999"), None);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 22:19:25 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn server_lookup_supports_targeting() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("config.yaml");
|
|
|
|
|
|
fs::write(
|
|
|
|
|
|
&path,
|
|
|
|
|
|
"servers:\n intel-dev:\n url: http://127.0.0.1:8080/\n",
|
|
|
|
|
|
)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let config = load_operator_config_at(&path).unwrap();
|
|
|
|
|
|
// the --server resolution shape: bare url and graph-scoped url
|
|
|
|
|
|
let base = config.servers["intel-dev"].url.trim_end_matches('/');
|
|
|
|
|
|
assert_eq!(base, "http://127.0.0.1:8080");
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
format!("{base}/graphs/spike"),
|
|
|
|
|
|
"http://127.0.0.1:8080/graphs/spike"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 21:24:51 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn token_env_name_uppercases_and_underscores() {
|
|
|
|
|
|
assert_eq!(token_env_name("intel-dev"), "OMNIGRAPH_TOKEN_INTEL_DEV");
|
|
|
|
|
|
assert_eq!(token_env_name("prod"), "OMNIGRAPH_TOKEN_PROD");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn credentials_roundtrip_rotate_remove_preserving_other_sections() {
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("credentials");
|
|
|
|
|
|
|
|
|
|
|
|
rewrite_credentials_at(&path, "prod", Some("tok-1")).unwrap();
|
|
|
|
|
|
rewrite_credentials_at(&path, "dev", Some("tok-dev")).unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
read_credential_at(&path, "prod").unwrap().as_deref(),
|
|
|
|
|
|
Some("tok-1")
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// rotate prod; dev preserved
|
|
|
|
|
|
rewrite_credentials_at(&path, "prod", Some("tok-2")).unwrap();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
read_credential_at(&path, "prod").unwrap().as_deref(),
|
|
|
|
|
|
Some("tok-2")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
read_credential_at(&path, "dev").unwrap().as_deref(),
|
|
|
|
|
|
Some("tok-dev")
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// remove prod; dev preserved; removal is idempotent
|
|
|
|
|
|
rewrite_credentials_at(&path, "prod", None).unwrap();
|
|
|
|
|
|
rewrite_credentials_at(&path, "prod", None).unwrap();
|
|
|
|
|
|
assert_eq!(read_credential_at(&path, "prod").unwrap(), None);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
read_credential_at(&path, "dev").unwrap().as_deref(),
|
|
|
|
|
|
Some("tok-dev")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn credentials_written_0600_and_over_permissive_refused() {
|
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
|
let path = dir.path().join("credentials");
|
|
|
|
|
|
rewrite_credentials_at(&path, "prod", Some("tok")).unwrap();
|
|
|
|
|
|
let mode = fs::metadata(&path).unwrap().permissions().mode();
|
|
|
|
|
|
assert_eq!(mode & 0o777, 0o600, "written {:o}", mode & 0o777);
|
|
|
|
|
|
|
|
|
|
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
|
|
|
|
|
|
let err = read_credential_at(&path, "prod").unwrap_err();
|
|
|
|
|
|
assert!(err.to_string().contains("chmod 600"), "{err}");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 20:29:02 +03:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn expand_tilde_resolves_home_prefix() {
|
|
|
|
|
|
let home = env::home_dir().unwrap();
|
|
|
|
|
|
assert_eq!(expand_tilde("~"), home);
|
|
|
|
|
|
assert_eq!(expand_tilde("~/x/y"), home.join("x/y"));
|
|
|
|
|
|
assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
|
|
|
|
|
|
assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|