1use std::fmt;
22use std::path::{Path, PathBuf};
23
24use thiserror::Error;
25
26pub const ORACLE_KUBECONFIG_ENV: &str = "ENGENHO_ORACLE_KUBECONFIG";
29
30pub const ORACLE_KUBECONFIG_FALLBACK: &str = ".kube/engenho-local-tunnel.yaml";
39
40#[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#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct OracleKubeconfig(PathBuf);
57
58impl OracleKubeconfig {
59 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 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 #[must_use]
95 pub fn path(&self) -> &Path {
96 &self.0
97 }
98
99 #[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#[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 #[must_use]
133 pub fn kubeconfig(&self) -> &OracleKubeconfig {
134 &self.kubeconfig
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 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 #[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 #[test]
211 fn no_integration_binary_loads_the_oracle_itself() {
212 let tests_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests");
213
214 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}