1use super::*;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "snake_case")]
8pub enum DiagnosticSeverity {
9 Error,
10 Warning,
11}
12
13#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
14pub struct Diagnostic {
15 pub code: String,
16 pub severity: DiagnosticSeverity,
17 pub path: String,
18 pub message: String,
19}
20
21impl Diagnostic {
22 pub(crate) fn error(code: impl Into<String>, path: impl Into<String>, message: impl Into<String>) -> Self {
23 Self {
24 code: code.into(),
25 severity: DiagnosticSeverity::Error,
26 path: path.into(),
27 message: message.into(),
28 }
29 }
30
31 pub(crate) fn warning(
32 code: impl Into<String>,
33 path: impl Into<String>,
34 message: impl Into<String>,
35 ) -> Self {
36 Self {
37 code: code.into(),
38 severity: DiagnosticSeverity::Warning,
39 path: path.into(),
40 message: message.into(),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
46pub struct ResourceSummary {
47 pub address: String,
48 pub kind: String,
49 pub digest: String,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub path: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)]
55pub struct Dependency {
56 pub from: String,
57 pub to: String,
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct ValidateOutput {
62 pub ok: bool,
63 pub config_dir: String,
64 pub config_file: String,
65 pub resource_digests: BTreeMap<String, String>,
66 pub resources: Vec<ResourceSummary>,
67 pub dependencies: Vec<Dependency>,
68 pub diagnostics: Vec<Diagnostic>,
69}
70
71#[derive(Debug, Clone, Serialize)]
72pub struct DesiredRevision {
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub config_digest: Option<String>,
75}
76
77#[derive(Debug, Clone, Serialize)]
78pub struct StateObservations {
79 pub state_path: String,
80 pub lock_path: String,
81 pub state_found: bool,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub applied_config_digest: Option<String>,
84 pub state_revision: u64,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub state_cas: Option<String>,
87 pub resource_count: usize,
88 pub locked: bool,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub lock_id: Option<String>,
91 pub lock_acquired: bool,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub acquired_lock_id: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub lock_operation: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub lock_created_at: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub lock_pid: Option<u32>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub lock_age_seconds: Option<u64>,
102}
103
104impl StateObservations {
105 pub(crate) fn observe_lock_metadata(&mut self, lock: &StateLockFile) {
106 self.locked = true;
107 self.lock_id = Some(lock.lock_id.clone());
108 self.lock_operation = Some(lock.operation.clone());
109 self.lock_created_at = Some(lock.created_at.clone());
110 self.lock_pid = Some(lock.pid);
111 self.lock_age_seconds = lock_age_seconds(&lock.created_at);
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "snake_case")]
117pub enum ResourceLifecycleStatus {
118 Pending,
119 Planned,
120 Applying,
121 Applied,
122 Drifted,
123 Blocked,
124 Error,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(deny_unknown_fields)]
129pub struct ResourceStatusRecord {
130 pub status: ResourceLifecycleStatus,
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub conditions: Vec<String>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub message: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139pub enum PlanOperation {
140 Create,
141 Update,
142 Delete,
143}
144
145#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
154#[serde(rename_all = "snake_case")]
155pub enum ApplyDisposition {
156 Applied,
157 Derived,
158 Deferred,
159 Blocked,
160}
161
162#[derive(Debug, Clone, Serialize, PartialEq)]
163pub struct PlanChange {
164 pub resource: String,
165 pub operation: PlanOperation,
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub before_digest: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
169 pub after_digest: Option<String>,
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub disposition: Option<ApplyDisposition>,
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub reason: Option<String>,
174 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
178 pub binding_change: bool,
179 #[serde(skip_serializing_if = "Option::is_none")]
182 pub metadata_change: Option<PlanMetadataChange>,
183 #[serde(skip_serializing_if = "Option::is_none")]
187 pub migration: Option<SchemaMigrationPlan>,
188}
189
190#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
191#[serde(rename_all = "snake_case")]
192pub enum PlanMetadataChange {
193 PolicyBindings,
194 EmbeddingProfile,
195}
196
197#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
198pub struct BlastRadius {
199 pub resource: String,
200 pub affected: Vec<String>,
201}
202
203#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
204pub struct ApprovalRequirement {
205 pub resource: String,
206 pub reason: String,
207 pub satisfied: bool,
210}
211
212#[derive(Debug, Clone, Serialize)]
213pub struct PlanOutput {
214 pub ok: bool,
215 pub config_dir: String,
216 pub desired_revision: DesiredRevision,
217 pub resource_digests: BTreeMap<String, String>,
218 pub dependencies: Vec<Dependency>,
219 pub state_observations: StateObservations,
220 pub changes: Vec<PlanChange>,
221 pub blast_radius: Vec<BlastRadius>,
222 pub approvals_required: Vec<ApprovalRequirement>,
223 pub diagnostics: Vec<Diagnostic>,
224}
225
226#[derive(Debug, Clone, Serialize)]
227pub struct StatusOutput {
228 pub ok: bool,
229 pub config_dir: String,
230 pub state_observations: StateObservations,
231 pub resource_digests: BTreeMap<String, String>,
232 pub resource_statuses: BTreeMap<String, ResourceStatusRecord>,
233 pub observations: BTreeMap<String, serde_json::Value>,
234 pub diagnostics: Vec<Diagnostic>,
235}
236
237#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
238#[serde(rename_all = "snake_case")]
239pub enum StateSyncOperation {
240 Refresh,
241 Import,
242}
243
244#[derive(Debug, Clone, Serialize)]
245pub struct StateSyncOutput {
246 pub ok: bool,
247 pub operation: StateSyncOperation,
248 pub config_dir: String,
249 pub state_observations: StateObservations,
250 pub resource_digests: BTreeMap<String, String>,
251 pub resource_statuses: BTreeMap<String, ResourceStatusRecord>,
252 pub observations: BTreeMap<String, serde_json::Value>,
253 pub diagnostics: Vec<Diagnostic>,
254}
255
256#[derive(Debug, Clone, Serialize)]
257pub struct ForceUnlockOutput {
258 pub ok: bool,
259 pub config_dir: String,
260 pub state_observations: StateObservations,
261 pub lock_removed: bool,
262 pub diagnostics: Vec<Diagnostic>,
263}
264
265#[derive(Debug, Clone, Serialize)]
269pub struct ApplyOutput {
270 pub ok: bool,
271 pub config_dir: String,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub actor: Option<String>,
274 pub desired_revision: DesiredRevision,
275 pub state_observations: StateObservations,
276 pub changes: Vec<PlanChange>,
278 pub applied_count: usize,
279 pub deferred_count: usize,
281 pub converged: bool,
283 pub state_written: bool,
285 pub resource_statuses: BTreeMap<String, ResourceStatusRecord>,
288 pub diagnostics: Vec<Diagnostic>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
297#[serde(deny_unknown_fields)]
298pub(crate) struct ApprovalArtifact {
299 pub(crate) schema_version: u32,
300 pub(crate) approval_id: String,
301 pub(crate) resource: String,
302 pub(crate) operation: String,
303 pub(crate) reason: String,
304 pub(crate) bound_config_digest: String,
305 #[serde(default)]
306 pub(crate) bound_before_digest: Option<String>,
307 #[serde(default)]
308 pub(crate) bound_after_digest: Option<String>,
309 pub(crate) approved_by: String,
310 pub(crate) created_at: String,
311 #[serde(default)]
312 pub(crate) consumed_at: Option<String>,
313 #[serde(default)]
314 pub(crate) consumed_by_operation: Option<String>,
315}
316
317#[derive(Debug, Clone, Serialize)]
318pub struct ApproveOutput {
319 pub ok: bool,
320 pub config_dir: String,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 pub approval_id: Option<String>,
323 #[serde(skip_serializing_if = "Option::is_none")]
324 pub resource: Option<String>,
325 #[serde(skip_serializing_if = "Option::is_none")]
326 pub operation: Option<PlanOperation>,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub approved_by: Option<String>,
329 pub diagnostics: Vec<Diagnostic>,
330}
331
332#[derive(Debug, Clone)]
333pub(crate) struct DesiredCluster {
334 pub(crate) config_dir: PathBuf,
335 pub(crate) config_digest: String,
336 pub(crate) storage_root: Option<String>,
338 pub(crate) state_lock: bool,
339 pub(crate) embedding_providers: BTreeMap<String, EmbeddingProviderConfig>,
340 pub(crate) graphs: Vec<DesiredGraph>,
341 pub(crate) resource_digests: BTreeMap<String, String>,
342 pub(crate) resources: Vec<ResourceSummary>,
343 pub(crate) dependencies: Vec<Dependency>,
344 pub(crate) policy_bindings: BTreeMap<String, Vec<String>>,
346}
347
348#[derive(Debug, Clone)]
349pub(crate) struct DesiredGraph {
350 pub(crate) id: String,
351 pub(crate) schema_digest: String,
352 pub(crate) embedding_provider: Option<String>,
353}
354
355#[derive(Debug)]
356pub(crate) struct ParsedConfig {
357 pub(crate) raw: Option<RawClusterConfig>,
358 pub(crate) diagnostics: Vec<Diagnostic>,
359 pub(crate) config_dir: PathBuf,
360 pub(crate) config_file: PathBuf,
361}
362
363#[derive(Debug, Clone)]
364pub(crate) struct ClusterSettings {
365 pub(crate) state_lock: bool,
366 pub(crate) storage_root: Option<String>,
367}
368
369#[derive(Debug)]
370pub(crate) struct LoadOutcome {
371 pub(crate) desired: Option<DesiredCluster>,
372 pub(crate) diagnostics: Vec<Diagnostic>,
373 pub(crate) config_dir: PathBuf,
374 pub(crate) config_file: PathBuf,
375}
376
377#[derive(Debug, Serialize, Deserialize)]
378#[serde(deny_unknown_fields)]
379pub(crate) struct RawClusterConfig {
380 pub(crate) version: u32,
381 #[serde(default)]
382 pub(crate) metadata: Metadata,
383 #[serde(default)]
388 pub(crate) storage: Option<String>,
389 #[serde(default)]
390 pub(crate) state: StateConfig,
391 #[serde(default)]
392 pub(crate) providers: ProvidersConfig,
393 #[serde(default)]
394 pub(crate) graphs: BTreeMap<String, GraphConfig>,
395 #[serde(default)]
396 pub(crate) policies: BTreeMap<String, PolicyConfig>,
397}
398
399#[derive(Debug, Default, Serialize, Deserialize)]
400#[serde(deny_unknown_fields)]
401pub(crate) struct Metadata {
402 pub(crate) name: Option<String>,
403}
404
405#[derive(Debug, Default, Serialize, Deserialize)]
406#[serde(deny_unknown_fields)]
407pub(crate) struct StateConfig {
408 pub(crate) backend: Option<String>,
409 pub(crate) lock: Option<bool>,
410}
411
412#[derive(Debug, Default, Serialize, Deserialize)]
413#[serde(deny_unknown_fields)]
414pub(crate) struct ProvidersConfig {
415 #[serde(default)]
416 pub(crate) embedding: BTreeMap<String, EmbeddingProviderConfig>,
417}
418
419#[derive(Debug, Serialize, Deserialize)]
420#[serde(deny_unknown_fields)]
421pub(crate) struct GraphConfig {
422 pub(crate) schema: PathBuf,
423 #[serde(default)]
424 pub(crate) queries: QueriesDecl,
425 #[serde(default)]
427 pub(crate) embedding_provider: Option<String>,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
435#[serde(deny_unknown_fields)]
436pub struct EmbeddingProviderConfig {
437 #[serde(default, alias = "provider", skip_serializing_if = "Option::is_none")]
438 pub kind: Option<String>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub base_url: Option<String>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub model: Option<String>,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub api_key: Option<String>,
445}
446
447impl EmbeddingProviderConfig {
448 pub(crate) fn validate(&self, path: String, diagnostics: &mut Vec<Diagnostic>) {
449 if let Err(error) = omnigraph::embedding::EmbeddingConfig::from_parts(
450 self.kind.as_deref(),
451 self.base_url.clone(),
452 self.model.clone(),
453 "validation-placeholder".to_string(),
454 ) {
455 diagnostics.push(Diagnostic::error(
456 "invalid_embedding_provider",
457 path.clone(),
458 error.to_string(),
459 ));
460 }
461
462 if self.kind.as_deref() == Some("mock") {
463 if let Some(api_key) = self.api_key.as_deref() {
464 if secret_ref_name(api_key).is_err() {
465 diagnostics.push(Diagnostic::error(
466 "embedding_api_key_inline",
467 format!("{path}.api_key"),
468 "embedding api_key must be a ${NAME} env reference, not an inline secret",
469 ));
470 }
471 }
472 return;
473 }
474
475 match self.api_key.as_deref() {
476 Some(api_key) if secret_ref_name(api_key).is_err() => diagnostics.push(
477 Diagnostic::error(
478 "embedding_api_key_inline",
479 format!("{path}.api_key"),
480 "embedding api_key must be a ${NAME} env reference, not an inline secret",
481 ),
482 ),
483 Some(_) => {}
484 None => diagnostics.push(Diagnostic::error(
485 "embedding_api_key_required",
486 format!("{path}.api_key"),
487 "non-mock embedding providers must set api_key to a ${NAME} env reference",
488 )),
489 }
490 }
491
492 pub fn resolve(&self) -> Result<omnigraph::embedding::EmbeddingConfig, String> {
496 let api_key = if self.kind.as_deref() == Some("mock") {
497 String::new()
498 } else {
499 resolve_secret_ref(self.api_key.as_deref().ok_or_else(|| {
500 "embedding api_key is required for non-mock providers".to_string()
501 })?)?
502 };
503 omnigraph::embedding::EmbeddingConfig::from_parts(
504 self.kind.as_deref(),
505 self.base_url.clone(),
506 self.model.clone(),
507 api_key,
508 )
509 .map_err(|e| e.to_string())
510 }
511}
512
513fn secret_ref_name(value: &str) -> Result<&str, String> {
514 value
515 .trim()
516 .strip_prefix("${")
517 .and_then(|s| s.strip_suffix('}'))
518 .filter(|name| !name.trim().is_empty())
519 .ok_or_else(|| {
520 format!("embedding api_key must be a ${{NAME}} env reference, got '{}'", value.trim())
521 })
522}
523
524fn resolve_secret_ref(value: &str) -> Result<String, String> {
527 let name = secret_ref_name(value)?;
528 std::env::var(name).map_err(|_| format!("embedding api_key env var '{name}' is not set"))
529}
530
531#[derive(Debug, Serialize, Deserialize)]
534#[serde(deny_unknown_fields)]
535pub(crate) struct QueryConfig {
536 pub(crate) file: PathBuf,
537}
538
539#[derive(Debug, Serialize, Deserialize)]
540#[serde(deny_unknown_fields)]
541pub(crate) struct PolicyConfig {
542 pub(crate) file: PathBuf,
543 pub(crate) applies_to: Vec<String>,
544}
545
546#[allow(dead_code)]
549#[derive(Debug, Clone, Serialize, Deserialize)]
550#[serde(deny_unknown_fields)]
551pub(crate) struct ClusterState {
552 pub(crate) version: u32,
553 #[serde(default)]
554 pub(crate) state_revision: u64,
555 pub(crate) applied_revision: AppliedRevisionState,
556 #[serde(default)]
557 pub(crate) resource_statuses: BTreeMap<String, ResourceStatusRecord>,
558 #[serde(default)]
559 pub(crate) approval_records: BTreeMap<String, serde_json::Value>,
560 #[serde(default)]
561 pub(crate) recovery_records: BTreeMap<String, serde_json::Value>,
562 #[serde(default)]
563 pub(crate) observations: BTreeMap<String, serde_json::Value>,
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize)]
567#[serde(deny_unknown_fields)]
568pub(crate) struct AppliedRevisionState {
569 #[serde(default)]
570 pub(crate) config_digest: Option<String>,
571 #[serde(default)]
572 pub(crate) resources: BTreeMap<String, StateResource>,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
576#[serde(deny_unknown_fields)]
577pub(crate) struct StateResource {
578 pub(crate) digest: String,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub(crate) applies_to: Option<Vec<String>>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub(crate) embedding_provider: Option<String>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub(crate) embedding_profile: Option<EmbeddingProviderConfig>,
596}
597
598#[derive(Debug, Serialize, Deserialize)]
599#[serde(deny_unknown_fields)]
600pub(crate) struct StateLockFile {
601 pub(crate) version: u32,
602 pub(crate) lock_id: String,
603 pub(crate) operation: String,
604 pub(crate) created_at: String,
605 pub(crate) pid: u32,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(deny_unknown_fields)]
614pub(crate) struct RecoverySidecar {
615 pub(crate) schema_version: u32,
616 pub(crate) operation_id: String,
617 pub(crate) started_at: String,
618 #[serde(default)]
619 pub(crate) actor: Option<String>,
620 pub(crate) kind: RecoverySidecarKind,
621 pub(crate) graph_id: String,
622 pub(crate) graph_uri: String,
623 #[serde(default)]
624 pub(crate) observed_manifest_version: Option<u64>,
625 #[serde(default)]
626 pub(crate) expected_manifest_version: Option<u64>,
627 pub(crate) desired_schema_digest: String,
628 #[serde(default)]
629 pub(crate) state_cas_base: Option<String>,
630 #[serde(default)]
633 pub(crate) approval_id: Option<String>,
634}
635
636#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
637#[serde(rename_all = "snake_case")]
638pub(crate) enum RecoverySidecarKind {
639 GraphCreate,
640 SchemaApply,
641 GraphDelete,
642}
643
644#[derive(Debug, Default)]
645pub(crate) struct SweepOutcome {
646 pub(crate) pending_graphs: BTreeSet<String>,
649 pub(crate) completed_sidecars: Vec<String>,
653 pub(crate) consumed_approvals: Vec<String>,
656}
657
658#[cfg(test)]
659mod embedding_provider_config_tests {
660 use super::EmbeddingProviderConfig;
661
662 #[test]
663 fn resolves_secret_from_env_and_applies_defaults() {
664 unsafe { std::env::set_var("OG_TEST_EMBED_KEY_A", "secret-x") };
666 let profile = EmbeddingProviderConfig {
667 kind: Some("openai-compatible".to_string()),
668 base_url: None,
669 model: Some("m".to_string()),
670 api_key: Some("${OG_TEST_EMBED_KEY_A}".to_string()),
671 };
672 let config = profile.resolve().unwrap();
673 assert_eq!(config.api_key, "secret-x");
674 assert_eq!(config.model, "m");
675 unsafe { std::env::remove_var("OG_TEST_EMBED_KEY_A") };
676 }
677
678 #[test]
679 fn rejects_inline_api_key() {
680 let profile = EmbeddingProviderConfig {
681 kind: None,
682 base_url: None,
683 model: None,
684 api_key: Some("sk-inline".to_string()),
685 };
686 let err = profile.resolve().unwrap_err();
687 assert!(err.contains("${NAME}"), "got: {err}");
688 }
689
690 #[test]
691 fn errors_on_unset_secret() {
692 let profile = EmbeddingProviderConfig {
693 kind: None,
694 base_url: None,
695 model: None,
696 api_key: Some("${OG_TEST_DEFINITELY_UNSET_VAR}".to_string()),
697 };
698 let err = profile.resolve().unwrap_err();
699 assert!(err.contains("not set"), "got: {err}");
700 }
701
702 #[test]
703 fn rejects_unknown_provider() {
704 unsafe { std::env::set_var("OG_TEST_EMBED_KEY_B", "x") };
705 let profile = EmbeddingProviderConfig {
706 kind: Some("cohere".to_string()),
707 base_url: None,
708 model: None,
709 api_key: Some("${OG_TEST_EMBED_KEY_B}".to_string()),
710 };
711 let err = profile.resolve().unwrap_err();
712 assert!(err.contains("unknown embedding provider"), "got: {err}");
713 unsafe { std::env::remove_var("OG_TEST_EMBED_KEY_B") };
714 }
715
716 #[test]
717 fn mock_does_not_require_secret_env() {
718 let profile = EmbeddingProviderConfig {
719 kind: Some("mock".to_string()),
720 base_url: None,
721 model: Some("cluster-mock".to_string()),
722 api_key: None,
723 };
724 let config = profile.resolve().unwrap();
725 assert_eq!(config.model, "cluster-mock");
726 }
727}