mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-12 03:12:11 +02:00
cluster.yaml gains an optional storage: URI deciding where everything the cluster STORES lives: the state ledger, lock, content-addressed catalog, recovery sidecars, approval artifacts, and the derived graph roots (<storage>/graphs/<id>.omni). Absent, it defaults to the config directory itself — the original layout, byte-compatible, so pre-existing clusters and the whole test suite are untouched. Declared configuration always stays in the working tree (Terraform's config-local/state-remote split); credentials are env-only, never in cluster.yaml. Every command resolves its store from the declared root (a bad root is a loud invalid_storage_root). Graph-root derivation, the delete executor (prefix delete via the adapter), the sweep's existence probes, the catalog payload write/verify/read paths, and the serving snapshot all flow through ClusterStore — the last raw-fs holdouts for stored state are gone, and the deny-list gains the rule that keeps it that way. Tests: default-layout byte-compat, a file:// root relocating the entire cluster (ledger+catalog+graphs under the new root, nothing under the config dir, serving snapshot follows), invalid-root validation. 98 in-crate + 9 failpoints + full workspace gate green. The s3:// flavor lands with PR 3's gated RustFS e2e. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
171 lines
6.2 KiB
Rust
171 lines
6.2 KiB
Rust
//! Phase-5 serving snapshot: the read-only loader a `--cluster` server
|
|
//! boots from (moved verbatim from lib.rs in the modularization).
|
|
|
|
use super::*;
|
|
|
|
/// One graph in a serving snapshot: its id and on-disk root.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ServingGraph {
|
|
pub graph_id: String,
|
|
pub root: PathBuf,
|
|
}
|
|
|
|
/// One stored query: its graph binding, registry name, and verified source.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ServingQuery {
|
|
pub graph_id: String,
|
|
pub name: String,
|
|
pub source: String,
|
|
}
|
|
|
|
/// One policy bundle: its verified catalog blob path and applied bindings
|
|
/// (normalized typed refs: `cluster` | `graph.<id>`).
|
|
#[derive(Debug, Clone)]
|
|
pub struct ServingPolicy {
|
|
pub name: String,
|
|
pub blob_path: PathBuf,
|
|
pub applies_to: Vec<String>,
|
|
}
|
|
|
|
/// Everything a server needs to boot from the cluster catalog (RFC-005 §D2).
|
|
#[derive(Debug, Clone)]
|
|
pub struct ServingSnapshot {
|
|
pub graphs: Vec<ServingGraph>,
|
|
pub queries: Vec<ServingQuery>,
|
|
pub policies: Vec<ServingPolicy>,
|
|
}
|
|
|
|
/// Read the applied revision as a serving snapshot — the read-only loader for
|
|
/// the Phase-5 server boot. All-or-nothing per RFC-005 §D4: every readiness
|
|
/// failure is collected and the whole snapshot refused; no partial serving.
|
|
/// Takes no lock: the state file is replaced atomically, so this reads a
|
|
/// consistent point-in-time ledger.
|
|
pub async fn read_serving_snapshot(
|
|
config_dir: impl AsRef<Path>,
|
|
) -> Result<ServingSnapshot, Vec<Diagnostic>> {
|
|
let config_dir = config_dir.as_ref().to_path_buf();
|
|
let mut diagnostics: Vec<Diagnostic> = Vec::new();
|
|
// The declared storage: root decides where the ledger/catalog/graphs
|
|
// live; config parse errors surface through the normal validation path.
|
|
let parsed = parse_cluster_config(&config_dir);
|
|
let storage_root = parsed.raw.as_ref().and_then(|raw| {
|
|
raw.storage
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|root| !root.is_empty())
|
|
.map(|root| root.trim_end_matches('/').to_string())
|
|
});
|
|
let backend = match storage_root.as_deref() {
|
|
Some(root) => match ClusterStore::for_storage_root(root) {
|
|
Ok(backend) => backend,
|
|
Err(diagnostic) => return Err(vec![diagnostic]),
|
|
},
|
|
None => ClusterStore::for_config_dir(&config_dir),
|
|
};
|
|
|
|
// A ledger a sweep is about to rewrite must not start serving.
|
|
let sidecars = backend.list_recovery_sidecars(&mut diagnostics).await;
|
|
if !sidecars.is_empty() {
|
|
diagnostics.push(Diagnostic::error(
|
|
"cluster_recovery_pending",
|
|
CLUSTER_RECOVERIES_DIR,
|
|
format!(
|
|
"{} interrupted operation(s) await recovery; run any state-mutating cluster command (e.g. `cluster apply`) to sweep, then retry",
|
|
sidecars.len()
|
|
),
|
|
));
|
|
}
|
|
|
|
let mut observations = backend.observations();
|
|
let state = match backend.read_state(&mut observations).await {
|
|
Ok(snapshot) => match snapshot.state {
|
|
Some(state) => Some(state),
|
|
None => {
|
|
diagnostics.push(Diagnostic::error(
|
|
"cluster_state_missing",
|
|
CLUSTER_STATE_FILE,
|
|
"no cluster state ledger; run `cluster import` and `cluster apply` first",
|
|
));
|
|
None
|
|
}
|
|
},
|
|
Err(diagnostic) => {
|
|
diagnostics.push(diagnostic);
|
|
None
|
|
}
|
|
};
|
|
let Some(state) = state else {
|
|
return Err(diagnostics);
|
|
};
|
|
|
|
let mut graphs = Vec::new();
|
|
let mut queries = Vec::new();
|
|
let mut policies = Vec::new();
|
|
for (address, entry) in &state.applied_revision.resources {
|
|
match resource_kind(address) {
|
|
ResourceKind::Graph(graph_id) => {
|
|
graphs.push(ServingGraph {
|
|
root: PathBuf::from(backend.graph_root(&graph_id)),
|
|
graph_id,
|
|
});
|
|
}
|
|
ResourceKind::Schema(_) => {}
|
|
kind @ ResourceKind::Query { .. } => {
|
|
let ResourceKind::Query { graph, name } = &kind else {
|
|
unreachable!()
|
|
};
|
|
match backend.read_verified_payload(&kind, &entry.digest, address).await {
|
|
Ok(source) => queries.push(ServingQuery {
|
|
graph_id: graph.clone(),
|
|
name: name.clone(),
|
|
source,
|
|
}),
|
|
Err(diagnostic) => diagnostics.push(diagnostic),
|
|
}
|
|
}
|
|
kind @ ResourceKind::Policy(_) => {
|
|
let ResourceKind::Policy(name) = &kind else {
|
|
unreachable!()
|
|
};
|
|
let Some(applies_to) = entry.applies_to.clone() else {
|
|
diagnostics.push(Diagnostic::error(
|
|
"policy_bindings_missing",
|
|
address.clone(),
|
|
"no applied applies_to bindings recorded (ledger predates binding metadata); re-run `cluster apply` to backfill",
|
|
));
|
|
continue;
|
|
};
|
|
match backend.read_verified_payload(&kind, &entry.digest, address).await {
|
|
Ok(_) => policies.push(ServingPolicy {
|
|
name: name.clone(),
|
|
blob_path: PathBuf::from(
|
|
backend
|
|
.payload_display(&kind, &entry.digest)
|
|
.expect("policy kind always has a payload path"),
|
|
),
|
|
applies_to,
|
|
}),
|
|
Err(diagnostic) => diagnostics.push(diagnostic),
|
|
}
|
|
}
|
|
ResourceKind::Unknown => {}
|
|
}
|
|
}
|
|
|
|
if graphs.is_empty() {
|
|
diagnostics.push(Diagnostic::error(
|
|
"cluster_empty",
|
|
CLUSTER_STATE_FILE,
|
|
"the applied revision records no graphs; apply a cluster with at least one graph before serving from it",
|
|
));
|
|
}
|
|
if has_errors(&diagnostics) {
|
|
return Err(diagnostics);
|
|
}
|
|
Ok(ServingSnapshot {
|
|
graphs,
|
|
queries,
|
|
policies,
|
|
})
|
|
}
|
|
|