Skip to main content

engenho_diff/
oracle.rs

1//! Where the reference oracle lives, and what to say when it does not answer.
2//!
3//! ONE typed resolution of the oracle's kubeconfig ([`OracleKubeconfig`]) and
4//! ONE typed diagnostic for an oracle that stayed silent
5//! ([`OracleUnreachable`]).
6//!
7//! Both used to be inlined per test binary. `m0_core_crud_parity.rs` carried
8//! its own copy of each, and its copy of the hint told the operator to check
9//! a tunnel — `kubectl --tls-server-name 127.0.0.1 …` — to a hand-built k3s
10//! VM that has not existed since 2026-08-08. So the one sentence an operator
11//! acts on named a knob that is gone instead of
12//! [`ORACLE_KUBECONFIG_ENV`], the knob that works.
13//!
14//! That is how a duplicated operator-facing hint fails: silently. Both
15//! binaries went on passing against a live oracle and failing loudly against
16//! a dead one, exactly as designed — only the human reading the panic was
17//! sent to the wrong place. A hint has to hold wherever the harness panics,
18//! so it is represented once, at that scope, and every binary reads it from
19//! here.
20
21use std::fmt;
22use std::path::{Path, PathBuf};
23
24use thiserror::Error;
25
26/// The environment variable that selects the oracle's kubeconfig. The ONE
27/// knob; every diagnostic in this module names it.
28pub const ORACLE_KUBECONFIG_ENV: &str = "ENGENHO_ORACLE_KUBECONFIG";
29
30/// Where the oracle's kubeconfig is looked for when [`ORACLE_KUBECONFIG_ENV`]
31/// says nothing: `$HOME` joined with this.
32///
33/// It is the historical path of a hand-built k3s VM's tunnel and it is
34/// expected to be absent. Kept as the fallback because
35/// `.config/nextest.toml` documents it as such; any conformant apiserver
36/// works, and a disposable `kind` cluster pinned to the target Kubernetes
37/// version is the reproducible choice.
38pub const ORACLE_KUBECONFIG_FALLBACK: &str = ".kube/engenho-local-tunnel.yaml";
39
40/// Neither [`ORACLE_KUBECONFIG_ENV`] nor `HOME` named anything, so there is
41/// no path to even attempt.
42///
43/// A typed absence rather than a guessed path: resolving to `""` or to a
44/// bare relative name would turn "nothing was configured" into "the oracle
45/// at <nonsense> did not answer", which reads as a dead cluster.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
47#[error(
48    "no oracle kubeconfig to try: {ORACLE_KUBECONFIG_ENV} is unset or empty and HOME is unset, \
49     so the $HOME/{ORACLE_KUBECONFIG_FALLBACK} fallback cannot be formed. Set \
50     {ORACLE_KUBECONFIG_ENV} to a live cluster's kubeconfig."
51)]
52pub struct OracleUnresolved;
53
54/// The reference oracle's kubeconfig path, resolved once.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct OracleKubeconfig(PathBuf);
57
58impl OracleKubeconfig {
59    /// Resolve from an already-read environment value and home directory.
60    ///
61    /// Pure on purpose: the resolution rule is then testable without
62    /// mutating the process environment (`set_var` is `unsafe` under edition
63    /// 2024, and a test that mutates it races every other test in the same
64    /// binary).
65    ///
66    /// An EMPTY environment value counts as unset — a shell that exports
67    /// `ENGENHO_ORACLE_KUBECONFIG=` has configured nothing, and `Kubeconfig::load("")`
68    /// would report a missing file instead.
69    ///
70    /// # Errors
71    ///
72    /// [`OracleUnresolved`] when neither input names a path.
73    pub fn resolve(env_value: Option<&str>, home: Option<&Path>) -> Result<Self, OracleUnresolved> {
74        if let Some(configured) = env_value.filter(|v| !v.is_empty()) {
75            return Ok(Self(PathBuf::from(configured)));
76        }
77        home.map(|h| Self(h.join(ORACLE_KUBECONFIG_FALLBACK)))
78            .ok_or(OracleUnresolved)
79    }
80
81    /// Resolve from this process's environment.
82    ///
83    /// # Errors
84    ///
85    /// [`OracleUnresolved`] when neither [`ORACLE_KUBECONFIG_ENV`] nor `HOME`
86    /// is set.
87    pub fn from_env() -> Result<Self, OracleUnresolved> {
88        let configured = std::env::var(ORACLE_KUBECONFIG_ENV).ok();
89        let home = std::env::var("HOME").ok();
90        Self::resolve(configured.as_deref(), home.as_deref().map(Path::new))
91    }
92
93    /// The resolved path, for the loader that opens it.
94    #[must_use]
95    pub fn path(&self) -> &Path {
96        &self.0
97    }
98
99    /// The fail-loud diagnostic for an oracle at this path that did not
100    /// answer `GET /api/v1`.
101    #[must_use]
102    pub fn unreachable(&self) -> OracleUnreachable {
103        OracleUnreachable {
104            kubeconfig: self.clone(),
105        }
106    }
107}
108
109impl fmt::Display for OracleKubeconfig {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(f, "{}", self.0.display())
112    }
113}
114
115/// The oracle did not answer — the ONE operator-facing hint for that state.
116///
117/// Carries the path it tried, so the message never says "the oracle" without
118/// saying which one.
119#[derive(Debug, Clone, PartialEq, Eq, Error)]
120#[error(
121    "Verdict::ReferenceUnreachable — the oracle at {kubeconfig} did not answer GET /api/v1. A \
122     live differential run cannot proceed. Point {ORACLE_KUBECONFIG_ENV} at a live cluster's \
123     kubeconfig (a kind cluster pinned to the target Kubernetes version works) and run \
124     `cargo nextest run --profile oracle`."
125)]
126pub struct OracleUnreachable {
127    kubeconfig: OracleKubeconfig,
128}
129
130impl OracleUnreachable {
131    /// The kubeconfig that was tried.
132    #[must_use]
133    pub fn kubeconfig(&self) -> &OracleKubeconfig {
134        &self.kubeconfig
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    /// A second oracle loader is spelled one of two ways: opening the
143    /// kubeconfig, or restating the unreachable hint.
144    const SECOND_LOADER: &[&str] = &["from_kubeconfig(", "tls-server-name"];
145
146    #[test]
147    fn a_configured_kubeconfig_wins_over_the_home_fallback() {
148        let resolved = OracleKubeconfig::resolve(
149            Some("/tmp/kind-oracle.yaml"),
150            Some(Path::new("/home/operator")),
151        )
152        .expect("a configured value resolves");
153        assert_eq!(resolved.path(), Path::new("/tmp/kind-oracle.yaml"));
154    }
155
156    #[test]
157    fn an_empty_configured_value_counts_as_unset() {
158        let resolved = OracleKubeconfig::resolve(Some(""), Some(Path::new("/home/operator")))
159            .expect("the home fallback resolves");
160        assert_eq!(
161            resolved.path(),
162            Path::new("/home/operator").join(ORACLE_KUBECONFIG_FALLBACK)
163        );
164    }
165
166    #[test]
167    fn nothing_configured_and_no_home_is_a_typed_absence_not_a_guessed_path() {
168        assert_eq!(
169            OracleKubeconfig::resolve(None, None),
170            Err(OracleUnresolved),
171            "with no env value and no HOME there is no path to try, and inventing one \
172             would report a configuration gap as a dead cluster"
173        );
174    }
175
176    /// The defect meta-04 pins. The unreachable hint is the only sentence an
177    /// operator acts on, so it must name the knob that EXISTS — not the
178    /// tunnel of a VM that was decommissioned on 2026-08-08.
179    #[test]
180    fn the_unreachable_hint_names_the_env_knob_and_never_the_dead_tunnel() {
181        let kubeconfig = OracleKubeconfig::resolve(Some("/tmp/kind-oracle.yaml"), None)
182            .expect("a configured value resolves");
183        let hint = kubeconfig.unreachable().to_string();
184
185        assert!(
186            hint.contains(ORACLE_KUBECONFIG_ENV),
187            "the hint must name the knob that repoints the oracle; got: {hint}"
188        );
189        assert!(
190            hint.contains("/tmp/kind-oracle.yaml"),
191            "the hint must name the kubeconfig it actually tried; got: {hint}"
192        );
193        for dead in ["tls-server-name", "tunnel", "192.168.64.10"] {
194            assert!(
195                !hint.contains(dead),
196                "the hint still sends the operator to the decommissioned k3s VM \
197                 ({dead}); got: {hint}"
198            );
199        }
200    }
201
202    /// ONE loader. No integration binary may open the oracle's kubeconfig
203    /// itself or carry its own unreachable hint: `common/mod.rs` owns both,
204    /// and every binary routes through `common::load_oracle`.
205    ///
206    /// `m0_core_crud_parity.rs` did carry its own, which is how its hint
207    /// drifted a year behind the shared one without anything going red.
208    /// `tests/common/` is the carve-out and is not scanned; everything
209    /// directly under `tests/` is.
210    #[test]
211    fn no_integration_binary_loads_the_oracle_itself() {
212        let tests_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests");
213
214        // Positive controls: a scan over a directory that moved would find
215        // no offender and pass while checking nothing.
216        assert!(
217            tests_dir.join("common").join("mod.rs").is_file(),
218            "the shared loader is not at {}/common/mod.rs — this gate is scanning \
219             the wrong tree",
220            tests_dir.display()
221        );
222        assert!(
223            tests_dir.join("m0_core_crud_parity.rs").is_file(),
224            "the binary this gate exists for is not at {}/m0_core_crud_parity.rs \
225             — this gate is scanning the wrong tree",
226            tests_dir.display()
227        );
228
229        let mut offenders: Vec<String> = Vec::new();
230        let entries = std::fs::read_dir(&tests_dir).expect("engenho-diff/tests is readable");
231        for entry in entries {
232            let path = entry.expect("a readable directory entry").path();
233            if path.extension().and_then(std::ffi::OsStr::to_str) != Some("rs") {
234                continue;
235            }
236            let source = std::fs::read_to_string(&path).expect("a readable test source");
237            let name = path
238                .file_name()
239                .and_then(std::ffi::OsStr::to_str)
240                .unwrap_or("<unnamed>")
241                .to_owned();
242            for needle in SECOND_LOADER {
243                if source.contains(needle) {
244                    offenders.push(format!("{name} contains {needle:?}"));
245                }
246            }
247        }
248
249        assert!(
250            offenders.is_empty(),
251            "a second oracle loader lives outside tests/common/mod.rs, and its copy of \
252             the hint will drift out of sync silently:\n  {}\n\nUse \
253             `common::load_oracle()`; if it does not return what the binary needs, add \
254             that to common/mod.rs.",
255            offenders.join("\n  ")
256        );
257    }
258}