mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-21 02:28:07 +02:00
feat(server)!: cluster-only server — remove single-graph serving (RFC-011) (#250)
omnigraph-server boots only from --cluster; all HTTP is /graphs/<id>/…; flat single-graph routes and the omnigraph.yaml server boot are removed. GraphRouting/ServerConfigMode collapse to multi-only; openapi.json regenerated to the nested shape; ~100 server route tests migrated; parity/system_local boot from a converged cluster. Gate green (1410 tests).
This commit is contained in:
parent
b183db078f
commit
8b01c6e547
20 changed files with 988 additions and 1492 deletions
|
|
@ -51,16 +51,7 @@ pub(crate) async fn server_graphs_list(
|
|||
State(state): State<AppState>,
|
||||
actor: Option<Extension<ResolvedActor>>,
|
||||
) -> std::result::Result<Json<GraphListResponse>, ApiError> {
|
||||
// 405 in single mode — there's no registry to enumerate, and the
|
||||
// legacy URL surface didn't expose this endpoint.
|
||||
let registry = match state.routing() {
|
||||
GraphRouting::Single { .. } => {
|
||||
return Err(ApiError::method_not_allowed(
|
||||
"GET /graphs is only available in multi-graph mode",
|
||||
));
|
||||
}
|
||||
GraphRouting::Multi { registry, .. } => registry,
|
||||
};
|
||||
let registry = &state.routing().registry;
|
||||
|
||||
// Server-level Cedar gate. `state.server_policy` is loaded from
|
||||
// `server.policy.file` in `omnigraph.yaml` at startup. When no
|
||||
|
|
@ -93,17 +84,15 @@ pub(crate) async fn server_graphs_list(
|
|||
}
|
||||
|
||||
pub(crate) async fn server_openapi(State(state): State<AppState>) -> Json<utoipa::openapi::OpenApi> {
|
||||
let mut doc = ApiDoc::openapi();
|
||||
// `served_openapi` is the single nesting source — the protected
|
||||
// routes always live under `/graphs/{graph_id}/...` (public/management
|
||||
// paths `/healthz`, `/graphs` stay flat). Building from it here means
|
||||
// the runtime spec and the committed `openapi.json` share one nesting
|
||||
// pass and can't drift.
|
||||
let mut doc = crate::served_openapi();
|
||||
if !state.requires_bearer_auth() {
|
||||
strip_security(&mut doc);
|
||||
}
|
||||
// MR-668: in multi mode, the protected routes live under
|
||||
// `/graphs/{graph_id}/...`. Rewrite the doc so the spec matches
|
||||
// the routes the router actually serves. Public paths (`/healthz`)
|
||||
// stay flat in both modes.
|
||||
if matches!(state.routing(), GraphRouting::Multi { .. }) {
|
||||
nest_paths_under_cluster_prefix(&mut doc);
|
||||
}
|
||||
Json(doc)
|
||||
}
|
||||
|
||||
|
|
@ -248,16 +237,11 @@ pub(crate) async fn require_bearer_auth(
|
|||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
/// Routing middleware (MR-668). Resolves the active graph for the
|
||||
/// request and injects `Arc<GraphHandle>` as an extension so handlers can
|
||||
/// extract it via `Extension<Arc<GraphHandle>>`.
|
||||
/// Routing middleware (RFC-011 cluster-only). Resolves the active graph
|
||||
/// for the request and injects `Arc<GraphHandle>` as an extension so
|
||||
/// handlers can extract it via `Extension<Arc<GraphHandle>>`.
|
||||
///
|
||||
/// **Single mode**: the routing field holds the single handle directly.
|
||||
/// Routes are flat; every request resolves to that handle, regardless
|
||||
/// of the URI path. No registry walk, no sentinel key, no
|
||||
/// programmer-error guard.
|
||||
///
|
||||
/// **Multi mode**: routes are nested under `/graphs/{graph_id}/...`. The
|
||||
/// Routes are always nested under `/graphs/{graph_id}/...`. The
|
||||
/// middleware extracts `{graph_id}` from the URI path and looks it up in
|
||||
/// the registry. Returns 404 if the graph is not registered.
|
||||
///
|
||||
|
|
@ -268,39 +252,33 @@ pub(crate) async fn resolve_graph_handle(
|
|||
mut request: Request,
|
||||
next: Next,
|
||||
) -> std::result::Result<Response, ApiError> {
|
||||
let handle = match &state.routing {
|
||||
GraphRouting::Single { handle } => Arc::clone(handle),
|
||||
GraphRouting::Multi { registry, .. } => {
|
||||
// `Router::nest("/graphs/{graph_id}", inner)` rewrites
|
||||
// `request.uri().path()` to the inner suffix (e.g. `/snapshot`).
|
||||
// The pre-rewrite URI is preserved in the `OriginalUri`
|
||||
// request extension by axum's router; we read from there to
|
||||
// extract `{graph_id}`. Fall back to the current URI only if
|
||||
// the extension is missing, which shouldn't happen for
|
||||
// nested routes but is safe defensive code.
|
||||
let original_path: String = request
|
||||
.extensions()
|
||||
.get::<OriginalUri>()
|
||||
.map(|OriginalUri(uri)| uri.path().to_string())
|
||||
.unwrap_or_else(|| request.uri().path().to_string());
|
||||
let graph_id_str = original_path
|
||||
.strip_prefix("/graphs/")
|
||||
.and_then(|rest| rest.split('/').next())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ApiError::bad_request(
|
||||
"cluster route missing /graphs/{graph_id} prefix".to_string(),
|
||||
)
|
||||
})?;
|
||||
let graph_id = GraphId::try_from(graph_id_str.to_string())
|
||||
.map_err(|err| ApiError::bad_request(err.to_string()))?;
|
||||
let key = GraphKey::cluster(graph_id.clone());
|
||||
match registry.get(&key) {
|
||||
RegistryLookup::Ready(handle) => handle,
|
||||
RegistryLookup::Gone => {
|
||||
return Err(ApiError::not_found(format!("graph '{graph_id}' not found")));
|
||||
}
|
||||
}
|
||||
let registry = &state.routing.registry;
|
||||
// `Router::nest("/graphs/{graph_id}", inner)` rewrites
|
||||
// `request.uri().path()` to the inner suffix (e.g. `/snapshot`).
|
||||
// The pre-rewrite URI is preserved in the `OriginalUri`
|
||||
// request extension by axum's router; we read from there to
|
||||
// extract `{graph_id}`. Fall back to the current URI only if
|
||||
// the extension is missing, which shouldn't happen for
|
||||
// nested routes but is safe defensive code.
|
||||
let original_path: String = request
|
||||
.extensions()
|
||||
.get::<OriginalUri>()
|
||||
.map(|OriginalUri(uri)| uri.path().to_string())
|
||||
.unwrap_or_else(|| request.uri().path().to_string());
|
||||
let graph_id_str = original_path
|
||||
.strip_prefix("/graphs/")
|
||||
.and_then(|rest| rest.split('/').next())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ApiError::bad_request("cluster route missing /graphs/{graph_id} prefix".to_string())
|
||||
})?;
|
||||
let graph_id = GraphId::try_from(graph_id_str.to_string())
|
||||
.map_err(|err| ApiError::bad_request(err.to_string()))?;
|
||||
let key = GraphKey::cluster(graph_id.clone());
|
||||
let handle = match registry.get(&key) {
|
||||
RegistryLookup::Ready(handle) => handle,
|
||||
RegistryLookup::Gone => {
|
||||
return Err(ApiError::not_found(format!("graph '{graph_id}' not found")));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
pub mod api;
|
||||
mod handlers;
|
||||
mod settings;
|
||||
pub use settings::{load_server_settings, classify_server_runtime_state, server_config_is_multi, ServerRuntimeState};
|
||||
pub use settings::{load_server_settings, classify_server_runtime_state, ServerRuntimeState};
|
||||
use settings::*;
|
||||
use handlers::*;
|
||||
pub mod auth;
|
||||
|
|
@ -122,6 +122,20 @@ fn hash_bearer_token(token: &str) -> BearerTokenHash {
|
|||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
/// The canonical served OpenAPI shape (RFC-011 cluster-only): the static
|
||||
/// `ApiDoc` with every protected path nested under `/graphs/{graph_id}/…`
|
||||
/// and `cluster_`-prefixed operation ids. `/healthz` and `/graphs` stay
|
||||
/// flat. This is the single source of nesting — both the runtime
|
||||
/// `server_openapi` handler and the committed `openapi.json` derive from
|
||||
/// it, so the published spec can never describe routes the server does
|
||||
/// not serve. The handler additionally strips security in open mode; the
|
||||
/// committed spec retains it.
|
||||
pub fn served_openapi() -> utoipa::openapi::OpenApi {
|
||||
let mut doc = ApiDoc::openapi();
|
||||
handlers::nest_paths_under_cluster_prefix(&mut doc);
|
||||
doc
|
||||
}
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl utoipa::Modify for SecurityAddon {
|
||||
|
|
@ -143,11 +157,10 @@ const SERVER_SOURCE_VERSION: Option<&str> = option_env!("OMNIGRAPH_SOURCE_VERSIO
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerConfig {
|
||||
/// Server topology + the graphs to open at startup. Single-mode
|
||||
/// invocations (`omnigraph-server <URI>` or `--target <name>`)
|
||||
/// produce `ServerConfigMode::Single`; multi-mode invocations
|
||||
/// (`--config omnigraph.yaml` with a non-empty `graphs:` map and
|
||||
/// no single-mode selector) produce `ServerConfigMode::Multi`.
|
||||
/// Server topology + the graphs to open at startup. RFC-011
|
||||
/// cluster-only: the server always boots from a cluster
|
||||
/// (`--cluster <dir | s3://…>`) and serves N graphs under cluster
|
||||
/// routes.
|
||||
pub mode: ServerConfigMode,
|
||||
pub bind: String,
|
||||
/// Operator opt-in for fully-unauthenticated dev mode (MR-723).
|
||||
|
|
@ -161,41 +174,25 @@ pub struct ServerConfig {
|
|||
pub allow_unauthenticated: bool,
|
||||
}
|
||||
|
||||
/// What `load_server_settings` produces after applying the four-rule
|
||||
/// mode inference matrix (MR-668 decision 2).
|
||||
/// What `load_server_settings` produces. RFC-011 cluster-only: the
|
||||
/// server always boots from a cluster's applied revision into a
|
||||
/// multi-graph deployment (N ≥ 1 graphs).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ServerConfigMode {
|
||||
/// Legacy invocation — one graph at the given URI. Either:
|
||||
/// * `omnigraph-server <URI>` (CLI positional), or
|
||||
/// * `omnigraph-server --target <name> --config omnigraph.yaml`, or
|
||||
/// * `omnigraph-server --config omnigraph.yaml` with `server.graph`
|
||||
/// set to a named target.
|
||||
Single {
|
||||
uri: String,
|
||||
/// Cedar graph resource id for the single graph. A named selection
|
||||
/// uses the graph name; an anonymous URI uses the normalized URI to
|
||||
/// preserve legacy single-graph policy identity.
|
||||
graph_id: String,
|
||||
/// Top-level `policy.file` (single-graph Cedar policy).
|
||||
policy_file: Option<PathBuf>,
|
||||
/// Top-level stored-query registry, loaded and identity-checked
|
||||
/// at settings-build time; type-checked against the schema when
|
||||
/// the engine opens.
|
||||
queries: QueryRegistry,
|
||||
},
|
||||
/// Multi-graph invocation — `--config omnigraph.yaml` with a
|
||||
/// non-empty `graphs:` map and no single-mode selector.
|
||||
/// Cluster boot — `--cluster <dir | s3://…>` resolves the applied
|
||||
/// revision into per-graph startup configs plus an optional
|
||||
/// server-level policy.
|
||||
Multi {
|
||||
/// Per-graph startup configs, sorted by graph id (BTreeMap
|
||||
/// iteration order). The parallel-open loop iterates this.
|
||||
graphs: Vec<GraphStartupConfig>,
|
||||
/// Path to the config file the server was started from. Kept on
|
||||
/// the mode so future runtime mutation (deferred — see release
|
||||
/// notes) can locate the source of truth without re-parsing CLI
|
||||
/// args.
|
||||
/// The cluster boot source (config directory or storage root).
|
||||
/// Kept on the mode so future runtime mutation (deferred — see
|
||||
/// release notes) can locate the source of truth without
|
||||
/// re-parsing CLI args.
|
||||
config_path: PathBuf,
|
||||
/// `server.policy.file` (server-level Cedar policy for the
|
||||
/// management endpoints). Wired into `GET /graphs` authorization.
|
||||
/// Server-level Cedar policy for the management endpoints
|
||||
/// (`GET /graphs`). Wired into `GET /graphs` authorization.
|
||||
server_policy: Option<PolicySource>,
|
||||
},
|
||||
}
|
||||
|
|
@ -224,36 +221,25 @@ pub struct GraphStartupConfig {
|
|||
pub queries: QueryRegistry,
|
||||
}
|
||||
|
||||
/// Runtime routing for the server. Single mode = legacy
|
||||
/// `omnigraph-server <URI>` invocation, one graph, flat HTTP routes.
|
||||
/// Multi mode = `--config omnigraph.yaml` with a non-empty `graphs:`
|
||||
/// map, N graphs, cluster routes (`/graphs/{graph_id}/...`). Mode is
|
||||
/// determined at startup by `load_server_settings`.
|
||||
/// Runtime routing for the server (RFC-011 cluster-only). Every
|
||||
/// deployment serves cluster routes (`/graphs/{graph_id}/...`) backed by
|
||||
/// a registry of N graphs (N ≥ 1). The single-graph convenience
|
||||
/// constructors build a one-graph registry keyed by `default`; the
|
||||
/// cluster boot path builds an N-graph registry. There is no longer a
|
||||
/// flat-route mode.
|
||||
///
|
||||
/// In single mode the handle lives here directly — there is no
|
||||
/// registry, no sentinel key, no walk-and-assert. In multi mode the
|
||||
/// registry carries N handles and the middleware dispatches on the
|
||||
/// URL's `{graph_id}` segment.
|
||||
/// `config_path` is the boot source (the cluster directory or storage
|
||||
/// root); preserved here so future runtime mutation (deferred) can find
|
||||
/// the source of truth without re-parsing CLI args. The server treats
|
||||
/// the source as operator-owned and never writes it.
|
||||
///
|
||||
/// Both modes share the same handler bodies — the routing middleware
|
||||
/// All handler bodies are mode-agnostic — the routing middleware
|
||||
/// (`resolve_graph_handle`) injects `Arc<GraphHandle>` as a request
|
||||
/// extension so handlers never see the routing discriminator.
|
||||
/// extension by looking up the `{graph_id}` URL segment in the registry.
|
||||
#[derive(Clone)]
|
||||
pub enum GraphRouting {
|
||||
/// Single-graph deployment: one handle, flat routes (`/snapshot`,
|
||||
/// `/read`, …). The `handle.uri` field carries the URI the engine
|
||||
/// was opened from. Backward compatible with v0.6.0 deployments.
|
||||
Single { handle: Arc<GraphHandle> },
|
||||
/// Multi-graph deployment: many handles, cluster routes
|
||||
/// (`/graphs/{graph_id}/...`). `config_path` is the `omnigraph.yaml`
|
||||
/// the server reads at startup; preserved here so future runtime
|
||||
/// mutation (deferred) can find the source of truth without
|
||||
/// re-parsing CLI args. The server treats the file as
|
||||
/// operator-owned and never writes it.
|
||||
Multi {
|
||||
registry: Arc<GraphRegistry>,
|
||||
config_path: Option<PathBuf>,
|
||||
},
|
||||
pub struct GraphRouting {
|
||||
pub registry: Arc<GraphRegistry>,
|
||||
pub config_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
@ -499,11 +485,13 @@ impl AppState {
|
|||
))
|
||||
}
|
||||
|
||||
/// Single-mode shared construction: wraps the bare engine + per-graph
|
||||
/// policy in a `GraphHandle` carried directly by `GraphRouting::Single`.
|
||||
/// Per-graph policy enforcement on the engine (MR-722) is re-applied
|
||||
/// via `Omnigraph::with_policy` so HTTP and engine layers can never
|
||||
/// diverge.
|
||||
/// Single-graph convenience construction (RFC-011 cluster-only):
|
||||
/// wraps the bare engine + per-graph policy in a `GraphHandle` keyed
|
||||
/// by `default`, then builds a one-graph registry so the deployment
|
||||
/// serves the same `/graphs/{graph_id}/...` cluster routes as any
|
||||
/// other. Per-graph policy enforcement on the engine (MR-722) is
|
||||
/// re-applied via `Omnigraph::with_policy` so HTTP and engine layers
|
||||
/// can never diverge.
|
||||
fn build_single_mode(
|
||||
uri: String,
|
||||
db: Omnigraph,
|
||||
|
|
@ -522,18 +510,13 @@ impl AppState {
|
|||
} else {
|
||||
db
|
||||
};
|
||||
// `GraphHandle.key` is required by the struct, but in single
|
||||
// mode it is never a registry key (there's no registry) and
|
||||
// never compared against user input (routes are flat, no
|
||||
// `{graph_id}` parameter). The label appears only in tracing
|
||||
// output from `resolve_graph_handle`. The literal below is a
|
||||
// log label, not a routing key — when the future cluster
|
||||
// catalog ships, single mode may carry the catalog-assigned
|
||||
// id here instead.
|
||||
// The convenience constructors address the single graph by the
|
||||
// reserved id `default` — both the registry key and the URL
|
||||
// segment (`/graphs/default/...`).
|
||||
let uri = normalize_root_uri(&uri).unwrap_or(uri);
|
||||
let key = GraphKey::cluster(
|
||||
GraphId::try_from("default").expect("'default' is a valid GraphId log label"),
|
||||
);
|
||||
let graph_id =
|
||||
GraphId::try_from("default").expect("'default' is a valid GraphId");
|
||||
let key = GraphKey::cluster(graph_id);
|
||||
let handle = Arc::new(GraphHandle {
|
||||
key,
|
||||
uri,
|
||||
|
|
@ -541,8 +524,15 @@ impl AppState {
|
|||
policy: policy_engine,
|
||||
queries,
|
||||
});
|
||||
let registry = Arc::new(
|
||||
GraphRegistry::from_handles(vec![handle])
|
||||
.expect("a single handle never collides on graph id"),
|
||||
);
|
||||
Self {
|
||||
routing: GraphRouting::Single { handle },
|
||||
routing: GraphRouting {
|
||||
registry,
|
||||
config_path: None,
|
||||
},
|
||||
workload,
|
||||
bearer_tokens,
|
||||
server_policy: None,
|
||||
|
|
@ -566,7 +556,7 @@ impl AppState {
|
|||
let bearer_tokens = hash_bearer_tokens(bearer_tokens);
|
||||
let registry = Arc::new(GraphRegistry::from_handles(handles)?);
|
||||
Ok(Self {
|
||||
routing: GraphRouting::Multi {
|
||||
routing: GraphRouting {
|
||||
registry,
|
||||
config_path,
|
||||
},
|
||||
|
|
@ -578,9 +568,7 @@ impl AppState {
|
|||
|
||||
/// Runtime routing accessor. Handlers don't typically inspect this —
|
||||
/// they extract `Arc<GraphHandle>` via the routing middleware — but
|
||||
/// `build_app` matches on it to decide flat vs nested route
|
||||
/// mounting, and a handful of management endpoints (`GET /graphs`,
|
||||
/// the OpenAPI cluster rewrite) match on the discriminant.
|
||||
/// `server_graphs_list` reads the registry through it.
|
||||
pub fn routing(&self) -> &GraphRouting {
|
||||
&self.routing
|
||||
}
|
||||
|
|
@ -594,13 +582,9 @@ impl AppState {
|
|||
}
|
||||
// Any per-graph policy also requires auth — otherwise the
|
||||
// policy gate would receive unauthenticated requests. Reading
|
||||
// from `routing` is O(1) in both arms: single mode is a direct
|
||||
// `handle.policy.is_some()` check, multi mode reads the
|
||||
// cached `any_per_graph_policy` flag on the registry snapshot.
|
||||
match &self.routing {
|
||||
GraphRouting::Single { handle } => handle.policy.is_some(),
|
||||
GraphRouting::Multi { registry, .. } => registry.snapshot_ref().any_per_graph_policy,
|
||||
}
|
||||
// the cached `any_per_graph_policy` flag off the registry
|
||||
// snapshot is O(1).
|
||||
self.routing.registry.snapshot_ref().any_per_graph_policy
|
||||
}
|
||||
|
||||
fn authenticate_bearer_token(&self, provided_token: &str) -> Option<ResolvedActor> {
|
||||
|
|
@ -972,13 +956,9 @@ pub fn build_app(state: AppState) -> Router {
|
|||
// Management endpoints (`GET /graphs`) live alongside the per-graph
|
||||
// router. They go through bearer auth but NOT through
|
||||
// `resolve_graph_handle` — they operate on the registry directly.
|
||||
// The endpoint is mounted in both modes; in single mode the handler
|
||||
// returns 405 so clients see "resource exists, wrong context"
|
||||
// rather than 404 "no such resource."
|
||||
//
|
||||
// Runtime add/remove (`POST /graphs`, `DELETE /graphs/{id}`) is not
|
||||
// exposed in v0.6.0 — operators add graphs by editing
|
||||
// `omnigraph.yaml` and restarting.
|
||||
// exposed — operators run `cluster apply` and restart.
|
||||
let management = Router::new()
|
||||
.route("/graphs", get(server_graphs_list))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
|
|
@ -986,15 +966,11 @@ pub fn build_app(state: AppState) -> Router {
|
|||
require_bearer_auth,
|
||||
));
|
||||
|
||||
// Mount the protected routes differently per mode:
|
||||
// * Single → flat routes (legacy: `/snapshot`, `/read`, etc.)
|
||||
// * Multi → nested under `/graphs/{graph_id}/...`
|
||||
let protected: Router<AppState> = match state.routing() {
|
||||
GraphRouting::Single { .. } => per_graph_protected.merge(management),
|
||||
GraphRouting::Multi { .. } => Router::new()
|
||||
.nest("/graphs/{graph_id}", per_graph_protected)
|
||||
.merge(management),
|
||||
};
|
||||
// RFC-011 cluster-only: per-graph routes always nest under
|
||||
// `/graphs/{graph_id}/...`; there are no flat single-graph routes.
|
||||
let protected: Router<AppState> = Router::new()
|
||||
.nest("/graphs/{graph_id}", per_graph_protected)
|
||||
.merge(management);
|
||||
|
||||
Router::new()
|
||||
.route("/healthz", get(server_health))
|
||||
|
|
@ -1015,7 +991,6 @@ pub async fn serve(config: ServerConfig) -> Result<()> {
|
|||
// policy OR any per-graph policy file. Mirrors the
|
||||
// `requires_bearer_auth` semantics on AppState.
|
||||
let has_policy_configured = match &config.mode {
|
||||
ServerConfigMode::Single { policy_file, .. } => policy_file.is_some(),
|
||||
ServerConfigMode::Multi {
|
||||
graphs,
|
||||
server_policy,
|
||||
|
|
@ -1043,29 +1018,6 @@ pub async fn serve(config: ServerConfig) -> Result<()> {
|
|||
|
||||
let bind = config.bind.clone();
|
||||
let state = match config.mode {
|
||||
ServerConfigMode::Single {
|
||||
uri,
|
||||
graph_id,
|
||||
policy_file,
|
||||
queries,
|
||||
} => {
|
||||
let uri_for_log = uri.clone();
|
||||
info!(
|
||||
uri = %uri_for_log,
|
||||
graph_id = %graph_id,
|
||||
bind = %bind,
|
||||
mode = "single",
|
||||
"serving omnigraph"
|
||||
);
|
||||
AppState::open_single_with_queries_for_graph_id(
|
||||
uri,
|
||||
tokens,
|
||||
policy_file.as_ref(),
|
||||
queries,
|
||||
Some(graph_id),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
ServerConfigMode::Multi {
|
||||
graphs,
|
||||
config_path,
|
||||
|
|
@ -1073,7 +1025,7 @@ pub async fn serve(config: ServerConfig) -> Result<()> {
|
|||
} => {
|
||||
info!(
|
||||
bind = %bind,
|
||||
mode = "multi",
|
||||
mode = "cluster",
|
||||
graph_count = graphs.len(),
|
||||
config = %config_path.display(),
|
||||
"serving omnigraph"
|
||||
|
|
|
|||
|
|
@ -8,16 +8,10 @@ use omnigraph_server::{ServerConfig, init_tracing, load_server_settings, serve};
|
|||
#[command(name = "omnigraph-server")]
|
||||
#[command(about = "HTTP server for the Omnigraph graph database")]
|
||||
struct Cli {
|
||||
/// Graph URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
target: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
/// Boot from a cluster: either a config directory (storage resolved
|
||||
/// through cluster.yaml) or a storage-root URI directly
|
||||
/// (s3://bucket/prefix — config-free serving from the bucket).
|
||||
/// Exclusive: cannot combine with <URI>, --target, or --config.
|
||||
/// The server's only boot source (RFC-011 cluster-only).
|
||||
#[arg(long)]
|
||||
cluster: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
|
|
@ -36,14 +30,7 @@ async fn main() -> Result<()> {
|
|||
init_tracing();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let settings: ServerConfig = load_server_settings(
|
||||
cli.config.as_ref(),
|
||||
cli.cluster.as_ref(),
|
||||
cli.uri,
|
||||
cli.target,
|
||||
cli.bind,
|
||||
cli.unauthenticated,
|
||||
)
|
||||
.await?;
|
||||
let settings: ServerConfig =
|
||||
load_server_settings(cli.cluster.as_ref(), cli.bind, cli.unauthenticated).await?;
|
||||
serve(settings).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,162 +122,24 @@ pub(crate) async fn load_cluster_settings(
|
|||
})
|
||||
}
|
||||
|
||||
/// RFC-011 cluster-only boot: the server serves exclusively from a
|
||||
/// cluster's applied revision (`--cluster <dir | s3://…>`). The legacy
|
||||
/// omnigraph.yaml / `--target` / positional-URI single-graph boot paths
|
||||
/// were removed — a deployment serves from exactly one source.
|
||||
pub async fn load_server_settings(
|
||||
config_path: Option<&PathBuf>,
|
||||
cli_cluster: Option<&PathBuf>,
|
||||
cli_uri: Option<String>,
|
||||
cli_target: Option<String>,
|
||||
cli_bind: Option<String>,
|
||||
cli_allow_unauthenticated: bool,
|
||||
) -> Result<ServerConfig> {
|
||||
// Rule 0 (RFC-005): --cluster is an exclusive boot source. It is checked
|
||||
// before anything reads omnigraph.yaml — in cluster mode that file is
|
||||
// never opened, not even the implicit current-directory search.
|
||||
if let Some(cluster_dir) = cli_cluster {
|
||||
if cli_uri.is_some() || cli_target.is_some() || config_path.is_some() {
|
||||
bail!(
|
||||
"--cluster is an exclusive boot source; it cannot combine with a graph URI, --target, or --config (axiom 15: a deployment serves from one source)"
|
||||
);
|
||||
}
|
||||
return load_cluster_settings(cluster_dir, cli_bind, cli_allow_unauthenticated).await;
|
||||
}
|
||||
let config = load_config(config_path)?;
|
||||
let bind = cli_bind.unwrap_or_else(|| config.server_bind().to_string());
|
||||
// Either `--unauthenticated` or `OMNIGRAPH_UNAUTHENTICATED=1` flips
|
||||
// this. Treat any non-empty, non-"0"/"false" string as truthy —
|
||||
// standard 12-factor "any value is true" reading of the env var.
|
||||
let env_unauth = std::env::var("OMNIGRAPH_UNAUTHENTICATED")
|
||||
.ok()
|
||||
.map(|v| {
|
||||
let trimmed = v.trim();
|
||||
!trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false")
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let allow_unauthenticated = cli_allow_unauthenticated || env_unauth;
|
||||
|
||||
// MR-668 decision 2 — four-rule mode inference matrix.
|
||||
//
|
||||
// 1. CLI `<URI>` positional → Single (URI = the value)
|
||||
// 2. CLI `--target <name>` → Single (URI = graphs.<name>.uri)
|
||||
// 3. `server.graph` in config → Single (URI = graphs.<server.graph>.uri)
|
||||
// 4. `--config` + non-empty `graphs:` + no single-mode selector
|
||||
// → Multi (every entry in `graphs:`)
|
||||
// 5. otherwise → error with migration hint
|
||||
//
|
||||
// Rules 1-3 are mutually compatible (CLI URI wins over `--target`
|
||||
// wins over `server.graph`), reusing the existing
|
||||
// `resolve_target_uri` precedence.
|
||||
let has_cli_uri = cli_uri.is_some();
|
||||
let has_cli_target = cli_target.is_some();
|
||||
let has_server_graph = config.server_graph_name().is_some();
|
||||
let has_graphs_map = !config.graphs.is_empty();
|
||||
let has_explicit_config = config_path.is_some();
|
||||
|
||||
let mode = if has_cli_uri || has_cli_target || has_server_graph {
|
||||
// Rules 1, 2, or 3 → Single mode.
|
||||
let raw_uri = config.resolve_target_uri(
|
||||
cli_uri,
|
||||
cli_target.as_deref(),
|
||||
config.server_graph_name(),
|
||||
)?;
|
||||
let uri = normalize_root_uri(&raw_uri).wrap_err_with(|| {
|
||||
format!("normalize single-graph URI '{raw_uri}' from server settings")
|
||||
})?;
|
||||
// Config follows graph IDENTITY, not mode: a bare URI is anonymous
|
||||
// (top-level config); a graph chosen by name uses its per-graph
|
||||
// `graphs.<name>.{policy,queries}`. `resolve_target_uri` already
|
||||
// errored on an unknown name, so a `Some(name)` here is a known graph.
|
||||
let selected: Option<&str> = if has_cli_uri {
|
||||
None
|
||||
} else {
|
||||
cli_target.as_deref().or_else(|| config.server_graph_name())
|
||||
};
|
||||
// A named selection must not leave a populated top-level block
|
||||
// silently unused — refuse boot and point at the per-graph block. The
|
||||
// same rule the CLI selection gate enforces, shared via one helper so
|
||||
// the boot check and `omnigraph queries validate`/`list` can't drift.
|
||||
config.ensure_top_level_blocks_honored(selected)?;
|
||||
// Load + identity-check now (no engine needed); the schema
|
||||
// type-check happens when the engine opens.
|
||||
let policy_file = config.resolve_policy_file_for(selected);
|
||||
let queries = QueryRegistry::load(&config, config.query_entries_for(selected))
|
||||
.map_err(|errs| color_eyre::eyre::eyre!(format_registry_load_errors(&uri, &errs)))?;
|
||||
let graph_id = graph_resource_id_for_selection(selected, &uri);
|
||||
ServerConfigMode::Single {
|
||||
uri,
|
||||
graph_id,
|
||||
policy_file,
|
||||
queries,
|
||||
}
|
||||
} else if has_explicit_config && has_graphs_map {
|
||||
// Multi mode: every graph uses its per-graph block; top-level
|
||||
// policy/queries are never honored, so a populated one is an error.
|
||||
let unhonored = config.populated_top_level_blocks();
|
||||
if !unhonored.is_empty() {
|
||||
bail!(
|
||||
"multi-graph mode: top-level {} {} not honored — each graph uses its own \
|
||||
`graphs.<graph_id>.…` block. Move per-graph rules there (and any \
|
||||
`graph_list` policy to `server.policy.file`).",
|
||||
unhonored.join(" and "),
|
||||
if unhonored.len() == 1 { "is" } else { "are" },
|
||||
);
|
||||
}
|
||||
// Rule 4 → Multi mode. Build a startup config per graph.
|
||||
let mut graphs = Vec::with_capacity(config.graphs.len());
|
||||
for (name, target) in &config.graphs {
|
||||
// Validate the graph id can construct a `GraphId` newtype.
|
||||
// Doing this here (not at registry insert) so a malformed
|
||||
// omnigraph.yaml fails at startup with a clear error.
|
||||
GraphId::try_from(name.clone()).map_err(|err| {
|
||||
color_eyre::eyre::eyre!("invalid graph id '{name}' in omnigraph.yaml: {err}")
|
||||
})?;
|
||||
let raw_uri = config.resolve_uri_value(&target.uri);
|
||||
let uri = normalize_root_uri(&raw_uri).wrap_err_with(|| {
|
||||
format!("normalize URI '{raw_uri}' for graph '{name}' in omnigraph.yaml")
|
||||
})?;
|
||||
// Per-graph `queries:`, selected through the shared
|
||||
// `query_entries_for` so server and CLI resolve identically.
|
||||
// Load + identity-check now; the schema type-check happens
|
||||
// when this graph's engine opens.
|
||||
let queries = QueryRegistry::load(&config, config.query_entries_for(Some(name.as_str())))
|
||||
.map_err(|errs| color_eyre::eyre::eyre!(format_registry_load_errors(name, &errs)))?;
|
||||
graphs.push(GraphStartupConfig {
|
||||
graph_id: name.clone(),
|
||||
uri,
|
||||
policy: config.resolve_target_policy_file(name).map(PolicySource::File),
|
||||
queries,
|
||||
});
|
||||
}
|
||||
let config_path = config_path
|
||||
.cloned()
|
||||
.expect("has_explicit_config implies config_path is Some");
|
||||
let server_policy = config.resolve_server_policy_file().map(PolicySource::File);
|
||||
ServerConfigMode::Multi {
|
||||
graphs,
|
||||
config_path,
|
||||
server_policy,
|
||||
}
|
||||
} else {
|
||||
// Rule 5 → error with migration hint.
|
||||
let Some(cluster_dir) = cli_cluster else {
|
||||
bail!(
|
||||
"no graph to serve: pass a URI (`omnigraph-server <URI>`), select a target \
|
||||
(`--target <name> --config omnigraph.yaml`), set `server.graph: <name>` in \
|
||||
omnigraph.yaml, or for multi-graph mode add a `graphs:` map to the config \
|
||||
file referenced by `--config`."
|
||||
"omnigraph-server boots from a cluster: pass --cluster <dir|s3://…> \
|
||||
(the cluster's applied revision is the deployment artifact). The legacy \
|
||||
single-graph boot (positional <URI>, --target, --config omnigraph.yaml) \
|
||||
was removed in RFC-011."
|
||||
);
|
||||
};
|
||||
|
||||
Ok(ServerConfig {
|
||||
mode,
|
||||
bind,
|
||||
allow_unauthenticated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the loaded config will run the server in multi-graph mode.
|
||||
/// Useful for the test that constructs `ServerConfig` directly.
|
||||
pub fn server_config_is_multi(config: &ServerConfig) -> bool {
|
||||
matches!(config.mode, ServerConfigMode::Multi { .. })
|
||||
load_cluster_settings(cluster_dir, cli_bind, cli_allow_unauthenticated).await
|
||||
}
|
||||
|
||||
/// MR-723 server runtime state, classified from the three-state matrix
|
||||
|
|
@ -417,8 +279,8 @@ pub(crate) fn server_bearer_tokens_from_env() -> Result<Vec<(String, String)>> {
|
|||
mod tests {
|
||||
use super::{
|
||||
GraphStartupConfig, ServerConfig, ServerConfigMode, ServerRuntimeState,
|
||||
classify_server_runtime_state, hash_bearer_token, load_server_settings,
|
||||
normalize_bearer_token, parse_bearer_tokens_json, serve, server_bearer_tokens_from_env,
|
||||
classify_server_runtime_state, hash_bearer_token, normalize_bearer_token,
|
||||
parse_bearer_tokens_json, serve, server_bearer_tokens_from_env,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use std::env;
|
||||
|
|
@ -577,108 +439,15 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_settings_load_from_yaml_config() {
|
||||
let temp = tempdir().unwrap();
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config,
|
||||
r#"
|
||||
graphs:
|
||||
local:
|
||||
uri: /tmp/demo.omni
|
||||
server:
|
||||
graph: local
|
||||
bind: 0.0.0.0:9090
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = load_server_settings(Some(&config), None, None, None, None, false).await.unwrap();
|
||||
match &settings.mode {
|
||||
ServerConfigMode::Single { uri, graph_id, .. } => {
|
||||
assert_eq!(uri, "/tmp/demo.omni");
|
||||
assert_eq!(graph_id, "local");
|
||||
}
|
||||
ServerConfigMode::Multi { .. } => panic!("expected Single mode, got Multi"),
|
||||
}
|
||||
assert_eq!(settings.bind, "0.0.0.0:9090");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_settings_cli_flags_override_yaml_config() {
|
||||
let temp = tempdir().unwrap();
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config,
|
||||
r#"
|
||||
graphs:
|
||||
local:
|
||||
uri: /tmp/demo.omni
|
||||
server:
|
||||
graph: local
|
||||
bind: 127.0.0.1:8080
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = load_server_settings(
|
||||
Some(&config),
|
||||
None,
|
||||
Some("/tmp/override.omni".to_string()),
|
||||
None,
|
||||
Some("0.0.0.0:9999".to_string()),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match &settings.mode {
|
||||
ServerConfigMode::Single { uri, graph_id, .. } => {
|
||||
assert_eq!(uri, "/tmp/override.omni");
|
||||
assert_eq!(graph_id, "/tmp/override.omni");
|
||||
}
|
||||
ServerConfigMode::Multi { .. } => panic!("expected Single mode, got Multi"),
|
||||
}
|
||||
assert_eq!(settings.bind, "0.0.0.0:9999");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_settings_can_resolve_named_target() {
|
||||
let temp = tempdir().unwrap();
|
||||
let config = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config,
|
||||
r#"
|
||||
graphs:
|
||||
local:
|
||||
uri: ./demo.omni
|
||||
dev:
|
||||
uri: http://127.0.0.1:8080
|
||||
server:
|
||||
graph: local
|
||||
bind: 127.0.0.1:8080
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings =
|
||||
load_server_settings(Some(&config), None, None, Some("dev".to_string()), None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
match &settings.mode {
|
||||
ServerConfigMode::Single { uri, graph_id, .. } => {
|
||||
assert_eq!(uri, "http://127.0.0.1:8080");
|
||||
assert_eq!(graph_id, "dev");
|
||||
}
|
||||
ServerConfigMode::Multi { .. } => panic!("expected Single mode, got Multi"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_settings_require_uri_from_cli_or_config() {
|
||||
let error = load_server_settings(None, None, None, None, None, false).await.unwrap_err();
|
||||
async fn server_settings_require_cluster_boot_source() {
|
||||
// RFC-011 cluster-only: with no --cluster the server refuses to
|
||||
// start and names the cluster-required remedy.
|
||||
let error = super::load_server_settings(None, None, false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("no graph to serve"),
|
||||
"expected mode-inference error, got: {error}",
|
||||
error.to_string().contains("boots from a cluster"),
|
||||
"expected cluster-required error, got: {error}",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -788,17 +557,21 @@ server:
|
|||
]);
|
||||
let temp = tempdir().unwrap();
|
||||
// Graph path doesn't need to exist — classifier fires before
|
||||
// `AppState::open_with_bearer_tokens_and_policy`.
|
||||
// any engine open.
|
||||
let config = ServerConfig {
|
||||
mode: ServerConfigMode::Single {
|
||||
uri: temp
|
||||
.path()
|
||||
.join("graph.omni")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
graph_id: "default".to_string(),
|
||||
policy_file: None,
|
||||
queries: crate::queries::QueryRegistry::default(),
|
||||
mode: ServerConfigMode::Multi {
|
||||
graphs: vec![GraphStartupConfig {
|
||||
graph_id: "default".to_string(),
|
||||
uri: temp
|
||||
.path()
|
||||
.join("graph.omni")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
policy: None,
|
||||
queries: crate::queries::QueryRegistry::default(),
|
||||
}],
|
||||
config_path: temp.path().join("cluster"),
|
||||
server_policy: None,
|
||||
},
|
||||
bind: "127.0.0.1:0".to_string(),
|
||||
allow_unauthenticated: false,
|
||||
|
|
@ -813,75 +586,6 @@ server:
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn unauthenticated_env_var_classification() {
|
||||
// MR-723 PR A: closes the gap where the env-var read path inside
|
||||
// `load_server_settings` was structurally implemented but not
|
||||
// exercised by any test. Three properties to pin, all in one
|
||||
// sequential test because `cargo test` runs the mod test suite
|
||||
// in parallel and `OMNIGRAPH_UNAUTHENTICATED` is process-global
|
||||
// — interleaving with another test that sets the same env var
|
||||
// (concurrent classifier tests, even the bearer-token suite
|
||||
// sharing `EnvGuard`) corrupts the read. Sequential within one
|
||||
// test fn is the simplest race-free shape.
|
||||
let temp = tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
graphs:
|
||||
local:
|
||||
uri: /tmp/demo-unauth.omni
|
||||
server:
|
||||
graph: local
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Truthy values flip Open mode on, even with CLI flag off.
|
||||
for value in ["1", "true", "yes", "TRUE", "anything"] {
|
||||
let _guard = EnvGuard::set(&[("OMNIGRAPH_UNAUTHENTICATED", Some(value))]);
|
||||
let settings = load_server_settings(Some(&config_path), None, None, None, None, false).await
|
||||
.expect("settings load should succeed");
|
||||
assert!(
|
||||
settings.allow_unauthenticated,
|
||||
"OMNIGRAPH_UNAUTHENTICATED={value:?} should enable Open mode",
|
||||
);
|
||||
}
|
||||
|
||||
// Falsy values keep refusal behavior, even with CLI flag off.
|
||||
for value in ["0", "false", "FALSE", ""] {
|
||||
let _guard = EnvGuard::set(&[("OMNIGRAPH_UNAUTHENTICATED", Some(value))]);
|
||||
let settings = load_server_settings(Some(&config_path), None, None, None, None, false).await
|
||||
.expect("settings load should succeed");
|
||||
assert!(
|
||||
!settings.allow_unauthenticated,
|
||||
"OMNIGRAPH_UNAUTHENTICATED={value:?} should NOT enable Open mode",
|
||||
);
|
||||
}
|
||||
|
||||
// Unset env var: also false.
|
||||
let _guard = EnvGuard::set(&[("OMNIGRAPH_UNAUTHENTICATED", None)]);
|
||||
let settings = load_server_settings(Some(&config_path), None, None, None, None, false).await
|
||||
.expect("settings load should succeed");
|
||||
assert!(
|
||||
!settings.allow_unauthenticated,
|
||||
"OMNIGRAPH_UNAUTHENTICATED unset should NOT enable Open mode",
|
||||
);
|
||||
drop(_guard);
|
||||
|
||||
// CLI flag wins even when env is falsy — `serve()` honors the
|
||||
// OR of both inputs.
|
||||
let _guard = EnvGuard::set(&[("OMNIGRAPH_UNAUTHENTICATED", Some("0"))]);
|
||||
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await
|
||||
.expect("settings load should succeed");
|
||||
assert!(
|
||||
settings.allow_unauthenticated,
|
||||
"--unauthenticated CLI flag should win even when env is falsy",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_policy_enabled_requires_tokens() {
|
||||
// State 3: tokens + policy → PolicyEnabled, regardless of the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue