1use crate::adapters::{Attestor, LockManager, OwnershipOracle, SmokeTestRunner};
17use crate::logging::audit::new_run_id;
18use crate::logging::{AuditSink, FactsEmitter, StageLogger};
19use crate::policy::Policy;
20use crate::types::{ApplyMode, ApplyReport, Plan, PlanInput, PreflightReport};
21use serde_json::json;
22
23mod apply;
25mod builder;
26pub mod errors;
27mod overrides;
28mod plan;
29mod preflight;
30mod rollback;
31pub use builder::ApiBuilder;
33pub use overrides::Overrides;
35pub type SwitchyardBuilder<E, A> = ApiBuilder<E, A>;
39
40pub trait DebugLockManager: LockManager + std::fmt::Debug {}
45impl<T: LockManager + std::fmt::Debug> DebugLockManager for T {}
46
47pub trait DebugOwnershipOracle: OwnershipOracle + std::fmt::Debug {}
49impl<T: OwnershipOracle + std::fmt::Debug> DebugOwnershipOracle for T {}
50
51pub trait DebugAttestor: Attestor + std::fmt::Debug {}
53impl<T: Attestor + std::fmt::Debug> DebugAttestor for T {}
54
55pub trait DebugSmokeTestRunner: SmokeTestRunner + std::fmt::Debug {}
57impl<T: SmokeTestRunner + std::fmt::Debug> DebugSmokeTestRunner for T {}
58
59#[derive(Debug)]
65pub struct Switchyard<E: FactsEmitter, A: AuditSink> {
66 facts: E,
67 audit: A,
68 policy: Policy,
69 overrides: Overrides,
70 lock: Option<Box<dyn DebugLockManager>>, owner: Option<Box<dyn DebugOwnershipOracle>>, attest: Option<Box<dyn DebugAttestor>>, smoke: Option<Box<dyn DebugSmokeTestRunner>>, lock_timeout_ms: u64,
75}
76
77impl<E: FactsEmitter, A: AuditSink> Switchyard<E, A> {
78 pub fn new(facts: E, audit: A, policy: Policy) -> Self {
83 ApiBuilder::new(facts, audit, policy).build()
84 }
85
86 pub fn builder(facts: E, audit: A, policy: Policy) -> ApiBuilder<E, A> {
90 ApiBuilder::new(facts, audit, policy)
91 }
92
93 #[must_use]
95 pub fn with_lock_manager(mut self, lock: Box<dyn DebugLockManager>) -> Self {
96 self.lock = Some(lock);
97 self
98 }
99
100 #[must_use]
102 #[allow(
103 clippy::missing_const_for_fn,
104 reason = "Not meaningful to expose as const; builder-style setter"
105 )]
106 pub fn with_overrides(mut self, overrides: Overrides) -> Self {
107 self.overrides = overrides;
108 self
109 }
110
111 #[must_use]
113 #[allow(
114 clippy::missing_const_for_fn,
115 reason = "Getter const provides no benefit; keep simple runtime API"
116 )]
117 pub fn overrides(&self) -> &Overrides {
118 &self.overrides
119 }
120
121 #[must_use]
123 pub fn with_ownership_oracle(mut self, owner: Box<dyn DebugOwnershipOracle>) -> Self {
124 self.owner = Some(owner);
125 self
126 }
127
128 #[must_use]
130 pub fn with_attestor(mut self, attest: Box<dyn DebugAttestor>) -> Self {
131 self.attest = Some(attest);
132 self
133 }
134
135 #[must_use]
137 pub fn with_smoke_runner(mut self, smoke: Box<dyn DebugSmokeTestRunner>) -> Self {
138 self.smoke = Some(smoke);
139 self
140 }
141
142 #[must_use]
144 pub const fn with_lock_timeout_ms(mut self, timeout_ms: u64) -> Self {
145 self.lock_timeout_ms = timeout_ms;
146 self
147 }
148
149 pub fn plan(&self, input: PlanInput) -> Plan {
153 #[cfg(feature = "tracing")]
154 let _span = tracing::info_span!("switchyard.plan").entered();
155 plan::build(self, input)
156 }
157
158 pub fn preflight(&self, plan: &Plan) -> Result<PreflightReport, errors::ApiError> {
166 #[cfg(feature = "tracing")]
167 let _span = tracing::info_span!("switchyard.preflight").entered();
168 Ok(preflight::run(self, plan))
169 }
170
171 pub fn apply(&self, plan: &Plan, mode: ApplyMode) -> Result<ApplyReport, errors::ApiError> {
180 #[cfg(feature = "tracing")]
181 let _span = tracing::info_span!("switchyard.apply", mode = ?mode).entered();
182 let report = apply::run(self, plan, mode);
183 if matches!(mode, ApplyMode::Commit) && !report.errors.is_empty() {
184 let joined = report.errors.join("; ").to_lowercase();
185 if joined.contains("lock") {
186 return Err(errors::ApiError::LockingTimeout(
187 "lock manager required or acquisition failed".to_string(),
188 ));
189 }
190 }
191 Ok(report)
192 }
193
194 pub fn plan_rollback_of(&self, report: &ApplyReport) -> Plan {
198 #[cfg(feature = "tracing")]
199 let _span = tracing::info_span!("switchyard.plan_rollback").entered();
200 let plan_like = format!(
202 "rollback:{}",
203 report
204 .plan_uuid
205 .map_or_else(|| "unknown".to_string(), |u| u.to_string())
206 );
207 let pid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, plan_like.as_bytes());
208 let run_id = new_run_id();
209 let tctx = crate::logging::audit::AuditCtx::new(
210 &self.facts,
211 pid.to_string(),
212 run_id,
213 crate::logging::redact::now_iso(),
214 crate::logging::audit::AuditMode {
215 dry_run: false,
216 redact: false,
217 },
218 );
219 StageLogger::new(&tctx)
220 .rollback()
221 .merge(&json!({
222 "planning": true,
223 "executed": report.executed.len(),
224 }))
225 .emit_success();
226 rollback::inverse_with_policy(&self.policy, report)
227 }
228
229 pub fn prune_backups(
237 &self,
238 target: &crate::types::safepath::SafePath,
239 ) -> Result<crate::types::PruneResult, errors::ApiError> {
240 #[cfg(feature = "tracing")]
241 let _span = tracing::info_span!(
242 "switchyard.prune_backups",
243 path = %target.as_path().display(),
244 tag = %self.policy.backup.tag
245 )
246 .entered();
247 let plan_like = format!(
249 "prune:{}:{}",
250 target.as_path().display(),
251 self.policy.backup.tag
252 );
253 let pid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, plan_like.as_bytes());
254 let run_id = new_run_id();
255 let tctx = crate::logging::audit::AuditCtx::new(
256 &self.facts,
257 pid.to_string(),
258 run_id,
259 crate::logging::redact::now_iso(),
260 crate::logging::audit::AuditMode {
261 dry_run: false,
262 redact: false,
263 },
264 );
265
266 let count_limit = self.policy.retention_count_limit;
267 let age_limit = self.policy.retention_age_limit;
268 match crate::fs::backup::prune::prune_backups(
269 target,
270 &self.policy.backup.tag,
271 count_limit,
272 age_limit,
273 ) {
274 Ok(res) => {
275 StageLogger::new(&tctx).prune_result().merge(&json!({
276 "path": target.as_path().display().to_string(),
277 "backup_tag": self.policy.backup.tag,
278 "retention_count_limit": count_limit,
279 "retention_age_limit_ms": age_limit.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)),
280 "pruned_count": res.pruned_count,
281 "retained_count": res.retained_count,
282 })).emit_success();
283 Ok(res)
284 }
285 Err(e) => {
286 StageLogger::new(&tctx)
287 .prune_result()
288 .merge(&json!({
289 "path": target.as_path().display().to_string(),
290 "backup_tag": self.policy.backup.tag,
291 "error": e.to_string(),
292 "error_id": errors::id_str(errors::ErrorId::E_GENERIC),
293 "exit_code": errors::exit_code_for(errors::ErrorId::E_GENERIC),
294 }))
295 .emit_failure();
296 Err(errors::ApiError::FilesystemError(e.to_string()))
297 }
298 }
299 }
300}