mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-24 03:51:02 +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
|
|
@ -18,10 +18,7 @@ use support::*;
|
|||
mod multi_graph_startup {
|
||||
use super::*;
|
||||
use omnigraph::storage::normalize_root_uri;
|
||||
use omnigraph_server::{
|
||||
GraphHandle, GraphId, GraphKey, GraphRegistry, InsertError, ServerConfig, ServerConfigMode,
|
||||
load_server_settings,
|
||||
};
|
||||
use omnigraph_server::{GraphHandle, GraphId, GraphKey, GraphRegistry, InsertError};
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn build_multi_mode_app(graph_ids: &[&str]) -> (Vec<tempfile::TempDir>, Router) {
|
||||
|
|
@ -280,10 +277,11 @@ mod multi_graph_startup {
|
|||
);
|
||||
}
|
||||
|
||||
/// Flat routes 404 in multi mode — the router only mounts under
|
||||
/// `/graphs/{graph_id}/...` so `/snapshot` doesn't resolve.
|
||||
/// RFC-011 cluster-only: flat per-graph routes never resolve — the
|
||||
/// router only mounts under `/graphs/{graph_id}/...` so a root
|
||||
/// `/snapshot` returns 404.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn flat_routes_404_in_multi_mode() {
|
||||
async fn flat_routes_404_at_root() {
|
||||
let (_dirs, app) = build_multi_mode_app(&["alpha"]).await;
|
||||
let resp = app
|
||||
.oneshot(
|
||||
|
|
@ -298,28 +296,6 @@ mod multi_graph_startup {
|
|||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// `GraphId` validation runs at startup — a reserved name in
|
||||
/// `omnigraph.yaml` produces a clear error rather than getting
|
||||
/// rejected per-request.
|
||||
#[tokio::test]
|
||||
async fn load_server_settings_rejects_reserved_graph_id() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
graphs:
|
||||
policies:
|
||||
uri: /tmp/g1.omni
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_server_settings(Some(&config_path), None, None, None, None, false).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("invalid graph id 'policies'"),
|
||||
"expected reserved-name rejection, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn registry_rejects_duplicate_normalized_graph_uris() {
|
||||
|
|
@ -375,372 +351,6 @@ graphs:
|
|||
assert_eq!(listed[0].uri, graph_uri);
|
||||
}
|
||||
|
||||
// ── Four-rule mode inference matrix ───────────────────────────────
|
||||
|
||||
/// Rule 1: CLI positional URI → Single.
|
||||
#[tokio::test]
|
||||
async fn mode_inference_cli_uri_is_single() {
|
||||
let settings = load_server_settings(
|
||||
None,
|
||||
None,
|
||||
Some("/tmp/cli.omni".to_string()),
|
||||
None,
|
||||
None,
|
||||
true, // allow unauth so we get past the runtime-state check
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match settings.mode {
|
||||
ServerConfigMode::Single { uri, .. } => assert_eq!(uri, "/tmp/cli.omni"),
|
||||
ServerConfigMode::Multi { .. } => panic!("expected Single (rule 1), got Multi"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 2: --target picks one graph from `graphs:` map → Single.
|
||||
#[tokio::test]
|
||||
async fn mode_inference_cli_target_is_single() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
graphs:
|
||||
alpha:
|
||||
uri: /tmp/alpha.omni
|
||||
beta:
|
||||
uri: /tmp/beta.omni
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings =
|
||||
load_server_settings(Some(&config_path), None, None, Some("alpha".into()), None, true)
|
||||
.await
|
||||
.unwrap();
|
||||
match settings.mode {
|
||||
ServerConfigMode::Single { uri, .. } => assert_eq!(uri, "/tmp/alpha.omni"),
|
||||
ServerConfigMode::Multi { .. } => panic!("expected Single (rule 2), got Multi"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 3: `server.graph` set → Single (target picked from config).
|
||||
#[tokio::test]
|
||||
async fn mode_inference_server_graph_is_single() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
graphs:
|
||||
alpha:
|
||||
uri: /tmp/alpha.omni
|
||||
beta:
|
||||
uri: /tmp/beta.omni
|
||||
server:
|
||||
graph: beta
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
||||
match settings.mode {
|
||||
ServerConfigMode::Single { uri, .. } => assert_eq!(uri, "/tmp/beta.omni"),
|
||||
ServerConfigMode::Multi { .. } => panic!("expected Single (rule 3), got Multi"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 4: `--config` + non-empty `graphs:` + no single-mode selector → Multi.
|
||||
#[tokio::test]
|
||||
async fn mode_inference_config_plus_graphs_is_multi() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
graphs:
|
||||
alpha:
|
||||
uri: /tmp/alpha.omni
|
||||
beta:
|
||||
uri: /tmp/beta.omni
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
||||
match settings.mode {
|
||||
ServerConfigMode::Multi { graphs, .. } => {
|
||||
let ids: Vec<&str> = graphs.iter().map(|g| g.graph_id.as_str()).collect();
|
||||
// BTreeMap iteration order is alphabetical.
|
||||
assert_eq!(ids, vec!["alpha", "beta"]);
|
||||
}
|
||||
ServerConfigMode::Single { .. } => panic!("expected Multi (rule 4), got Single"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mode_inference_multi_rejects_top_level_policy_file() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
policy:
|
||||
file: ./policy.yaml
|
||||
graphs:
|
||||
alpha:
|
||||
uri: /tmp/alpha.omni
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("top-level") && msg.contains("policy.file") && msg.contains("not honored"),
|
||||
"expected top-level-not-honored guidance, got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("graphs.<graph_id>"),
|
||||
"expected per-graph migration guidance, got: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("server.policy.file"),
|
||||
"expected server policy migration guidance, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mode_inference_multi_rejects_top_level_queries() {
|
||||
// Symmetric to the policy guard: a top-level `queries:` block in
|
||||
// multi-graph mode is not honored (each graph uses its own), so it
|
||||
// is a loud error rather than a silent no-op.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
"queries:\n q:\n file: ./q.gq\ngraphs:\n alpha:\n uri: /tmp/alpha.omni\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("queries") && msg.contains("not honored"),
|
||||
"top-level queries must be rejected in multi-graph mode: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_mode_named_graph_rejects_top_level_blocks() {
|
||||
// Serving a graph by name (`--target`/`server.graph`) uses its
|
||||
// per-graph block; a populated top-level block would be silently
|
||||
// shadowed, so boot refuses and names the per-graph location.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
"policy:\n file: ./top.yaml\ngraphs:\n prod:\n uri: /tmp/prod.omni\n",
|
||||
)
|
||||
.unwrap();
|
||||
let err =
|
||||
load_server_settings(Some(&config_path), None, None, Some("prod".to_string()), None, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("prod") && msg.contains("policy.file") && msg.contains("graphs.prod"),
|
||||
"named single-mode + top-level policy must refuse, naming the graph: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_mode_named_graph_uses_per_graph_policy_and_queries() {
|
||||
// The identity rule: `--target prod` attaches `graphs.prod`'s own
|
||||
// policy + queries, not the top-level ones (which are absent here).
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("prod.gq"),
|
||||
"query pq() { match { $u: User } return { $u.name } }",
|
||||
)
|
||||
.unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
"graphs:\n prod:\n uri: /tmp/prod.omni\n policy:\n file: ./prod-policy.yaml\n \
|
||||
queries:\n pq:\n file: ./prod.gq\n",
|
||||
)
|
||||
.unwrap();
|
||||
let settings =
|
||||
load_server_settings(Some(&config_path), None, None, Some("prod".to_string()), None, true)
|
||||
.await
|
||||
.unwrap();
|
||||
match settings.mode {
|
||||
ServerConfigMode::Single {
|
||||
graph_id,
|
||||
policy_file,
|
||||
queries,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(graph_id, "prod", "named single-mode keeps graph identity");
|
||||
assert!(
|
||||
policy_file
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.ends_with("prod-policy.yaml")),
|
||||
"per-graph policy attached: {policy_file:?}"
|
||||
);
|
||||
assert!(queries.lookup("pq").is_some(), "per-graph query attached");
|
||||
}
|
||||
other => panic!("expected Single mode, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mode_inference_normalizes_multi_graph_uris() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let graph = temp.path().join("alpha.omni");
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
r#"
|
||||
graphs:
|
||||
alpha:
|
||||
uri: file://{}/
|
||||
"#,
|
||||
graph.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
||||
match settings.mode {
|
||||
ServerConfigMode::Multi { graphs, .. } => {
|
||||
assert_eq!(graphs[0].uri, graph.to_string_lossy());
|
||||
}
|
||||
ServerConfigMode::Single { .. } => panic!("expected Multi"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 5: nothing → error with migration hint.
|
||||
#[tokio::test]
|
||||
async fn mode_inference_no_inputs_errors_with_migration_hint() {
|
||||
let err = load_server_settings(None, None, None, None, None, true).await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("no graph to serve"),
|
||||
"expected migration-hint error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rule 4 sub-case: `--config` with empty `graphs:` map and no
|
||||
/// single-mode selector → rule 5 fires (no graph to serve).
|
||||
#[tokio::test]
|
||||
async fn mode_inference_empty_graphs_map_errors() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(&config_path, "server:\n bind: 127.0.0.1:8080\n").unwrap();
|
||||
let err = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap_err();
|
||||
assert!(err.to_string().contains("no graph to serve"));
|
||||
}
|
||||
|
||||
/// `--config` + `<URI>` together: URI wins → Single (the CLI URI
|
||||
/// takes precedence over the config's graphs map).
|
||||
#[tokio::test]
|
||||
async fn mode_inference_cli_uri_overrides_graphs_map() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
graphs:
|
||||
alpha:
|
||||
uri: /tmp/alpha.omni
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = load_server_settings(
|
||||
Some(&config_path),
|
||||
None,
|
||||
Some("/tmp/cli-override.omni".to_string()),
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
match settings.mode {
|
||||
ServerConfigMode::Single { uri, .. } => {
|
||||
assert_eq!(
|
||||
uri, "/tmp/cli-override.omni",
|
||||
"CLI URI must win over graphs: map"
|
||||
);
|
||||
}
|
||||
ServerConfigMode::Multi { .. } => {
|
||||
panic!("expected Single (CLI URI wins), got Multi")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-graph `policy.file` is resolved relative to the config base_dir.
|
||||
#[tokio::test]
|
||||
async fn per_graph_policy_file_is_resolved_relative_to_base_dir() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
graphs:
|
||||
alpha:
|
||||
uri: /tmp/alpha.omni
|
||||
policy:
|
||||
file: ./policies/alpha.yaml
|
||||
beta:
|
||||
uri: /tmp/beta.omni
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
||||
let graphs = match settings.mode {
|
||||
ServerConfigMode::Multi { graphs, .. } => graphs,
|
||||
_ => panic!("expected Multi"),
|
||||
};
|
||||
// graphs is BTreeMap-iter order (alphabetical).
|
||||
let alpha = &graphs[0];
|
||||
let beta = &graphs[1];
|
||||
assert_eq!(alpha.graph_id, "alpha");
|
||||
let omnigraph_server::PolicySource::File(alpha_policy) =
|
||||
alpha.policy.as_ref().unwrap()
|
||||
else {
|
||||
panic!("yaml-configured policy must stay file-based");
|
||||
};
|
||||
assert_eq!(alpha_policy, &temp.path().join("policies/alpha.yaml"));
|
||||
assert_eq!(beta.graph_id, "beta");
|
||||
assert!(beta.policy.is_none());
|
||||
}
|
||||
|
||||
/// `server.policy.file` resolves alongside the graphs map.
|
||||
#[tokio::test]
|
||||
async fn server_policy_file_is_resolved_relative_to_base_dir() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let config_path = temp.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
server:
|
||||
policy:
|
||||
file: ./server-policy.yaml
|
||||
graphs:
|
||||
alpha:
|
||||
uri: /tmp/alpha.omni
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
||||
match settings.mode {
|
||||
ServerConfigMode::Multi { server_policy, .. } => {
|
||||
let omnigraph_server::PolicySource::File(path) = server_policy.unwrap() else {
|
||||
panic!("yaml-configured server policy must stay file-based");
|
||||
};
|
||||
assert_eq!(path, temp.path().join("server-policy.yaml"));
|
||||
}
|
||||
_ => panic!("expected Multi"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /graphs` must NOT leak the registry in Open mode without
|
||||
/// an explicit server policy. Operators who pass `--unauthenticated`
|
||||
/// opted into trusting the network for graph DATA, not for leaking
|
||||
|
|
@ -786,28 +396,6 @@ graphs:
|
|||
);
|
||||
}
|
||||
|
||||
/// `GET /graphs` returns 405 in single mode (resource exists in the
|
||||
/// API surface, just not operational without a `graphs:` map).
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn get_graphs_returns_405_in_single_mode() {
|
||||
let temp = init_loaded_graph().await;
|
||||
let graph = graph_path(temp.path());
|
||||
let state = AppState::open(graph.to_string_lossy().to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_app(state);
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/graphs")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
/// `GET /graphs` requires bearer auth when tokens are configured.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
|
@ -971,52 +559,4 @@ rules:
|
|||
);
|
||||
}
|
||||
|
||||
/// Loads an `omnigraph.yaml` with two graphs and verifies multi-mode
|
||||
/// inference plus graph entry resolution. Cluster-route dispatch is
|
||||
/// covered by the route tests above.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn server_settings_load_multi_graph_config_entries() {
|
||||
let cfg_dir = tempfile::tempdir().unwrap();
|
||||
// Real graph storage dirs (the URIs in the config must point to
|
||||
// a graph init-able location).
|
||||
let alpha_dir = cfg_dir.path().join("alpha.omni");
|
||||
let beta_dir = cfg_dir.path().join("beta.omni");
|
||||
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
||||
Omnigraph::init(alpha_dir.to_str().unwrap(), &schema)
|
||||
.await
|
||||
.unwrap();
|
||||
Omnigraph::init(beta_dir.to_str().unwrap(), &schema)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let config_path = cfg_dir.path().join("omnigraph.yaml");
|
||||
fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
r#"
|
||||
graphs:
|
||||
alpha:
|
||||
uri: {alpha}
|
||||
beta:
|
||||
uri: {beta}
|
||||
"#,
|
||||
alpha = alpha_dir.display(),
|
||||
beta = beta_dir.display(),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings: ServerConfig =
|
||||
load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
||||
assert!(matches!(settings.mode, ServerConfigMode::Multi { .. }));
|
||||
|
||||
match settings.mode {
|
||||
ServerConfigMode::Multi { graphs, .. } => {
|
||||
assert_eq!(graphs.len(), 2);
|
||||
let ids: Vec<&str> = graphs.iter().map(|g| g.graph_id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["alpha", "beta"]);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue