switchyard/api/
mod.rs

1// Facade for API module; delegates to submodules under src/api/
2//! API facade and orchestrators.
3//!
4//! Construct using the builder:
5//!
6//! ```rust
7//! use switchyard::api::Switchyard;
8//! use switchyard::logging::JsonlSink;
9//! use switchyard::policy::Policy;
10//!
11//! let facts = JsonlSink::default();
12//! let audit = JsonlSink::default();
13//! let _api = Switchyard::builder(facts, audit, Policy::default()).build();
14//! ```
15
16use 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
23// Internal API submodules (idiomatic; directory module)
24mod apply;
25mod builder;
26pub mod errors;
27mod overrides;
28mod plan;
29mod preflight;
30mod rollback;
31/// Public API builder.
32pub use builder::ApiBuilder;
33/// Public API overrides.
34pub use overrides::Overrides;
35/// DX alias for `ApiBuilder`.
36///
37/// This type alias provides a more convenient and readable way to construct a `Switchyard` instance.
38pub type SwitchyardBuilder<E, A> = ApiBuilder<E, A>;
39
40/// Trait marker to bound `LockManager` with `Debug` for use inside the public API.
41///
42/// This is not a user-implemented trait; it is a convenience alias for
43/// `LockManager + std::fmt::Debug` so adapters can be boxed and passed to the builder.
44pub trait DebugLockManager: LockManager + std::fmt::Debug {}
45impl<T: LockManager + std::fmt::Debug> DebugLockManager for T {}
46
47/// Trait marker to bound `OwnershipOracle` with `Debug` for use inside the public API.
48pub trait DebugOwnershipOracle: OwnershipOracle + std::fmt::Debug {}
49impl<T: OwnershipOracle + std::fmt::Debug> DebugOwnershipOracle for T {}
50
51/// Trait marker to bound `Attestor` with `Debug` for use inside the public API.
52pub trait DebugAttestor: Attestor + std::fmt::Debug {}
53impl<T: Attestor + std::fmt::Debug> DebugAttestor for T {}
54
55/// Trait marker to bound `SmokeTestRunner` with `Debug` for use inside the public API.
56pub trait DebugSmokeTestRunner: SmokeTestRunner + std::fmt::Debug {}
57impl<T: SmokeTestRunner + std::fmt::Debug> DebugSmokeTestRunner for T {}
58
59/// Facade for orchestrating Switchyard stages over a configured `Policy` and adapters.
60///
61/// Construct via [`ApiBuilder`] or `Switchyard::builder` and then call `plan`,
62/// `preflight`, and `apply` in sequence. The type parameters `E` and `A` are
63/// emitter types implementing [`FactsEmitter`] and [`AuditSink`], respectively.
64#[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>>, // None in dev/test; required in production
71    owner: Option<Box<dyn DebugOwnershipOracle>>, // for strict ownership gating
72    attest: Option<Box<dyn DebugAttestor>>,  // for final summary attestation
73    smoke: Option<Box<dyn DebugSmokeTestRunner>>, // for post-apply health verification
74    lock_timeout_ms: u64,
75}
76
77impl<E: FactsEmitter, A: AuditSink> Switchyard<E, A> {
78    /// Construct a `Switchyard` with defaults. This function delegates to the builder.
79    ///
80    /// This delegates to `ApiBuilder::new(facts, audit, policy).build()` to
81    /// avoid duplicating initialization logic.
82    pub fn new(facts: E, audit: A, policy: Policy) -> Self {
83        ApiBuilder::new(facts, audit, policy).build()
84    }
85
86    /// Entrypoint for constructing via the builder (default construction path).
87    ///
88    /// Prefer this over `new` so you can configure optional adapters and timeouts.
89    pub fn builder(facts: E, audit: A, policy: Policy) -> ApiBuilder<E, A> {
90        ApiBuilder::new(facts, audit, policy)
91    }
92
93    /// Configure via `ApiBuilder::with_lock_manager`.
94    #[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    /// Configure per-instance overrides for simulations (tests/controlled scenarios).
101    #[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    /// Access the current per-instance overrides.
112    #[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    /// Configure via `ApiBuilder::with_ownership_oracle`.
122    #[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    /// Configure via `ApiBuilder::with_attestor`.
129    #[must_use]
130    pub fn with_attestor(mut self, attest: Box<dyn DebugAttestor>) -> Self {
131        self.attest = Some(attest);
132        self
133    }
134
135    /// Configure via `ApiBuilder::with_smoke_runner`.
136    #[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    /// Configure via `ApiBuilder::with_lock_timeout_ms`.
143    #[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    /// Build a `Plan` from the provided `PlanInput` with stable action ordering.
150    ///
151    /// This emits planning facts and returns a `Plan` suitable for `preflight` and `apply`.
152    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    /// Execute preflight analysis for a plan.
159    ///
160    /// Returns a `PreflightReport` containing rows (one per action), warnings, and stops.
161    ///
162    /// # Errors
163    ///
164    /// Returns an `ApiError` if the preflight analysis fails.
165    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    /// Apply a plan in the specified mode.
172    ///
173    /// Returns an `ApplyReport` with execution results. In `Commit` mode, missing
174    /// required adapters such as a `LockManager` may be mapped to `ApiError::LockingTimeout`.
175    ///
176    /// # Errors
177    ///
178    /// Returns an `ApiError` if the plan application fails.
179    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    /// Construct a rollback `Plan` that inverses executed actions from an `ApplyReport`.
195    ///
196    /// Emits a planning fact for visibility and then builds the inverse plan.
197    pub fn plan_rollback_of(&self, report: &ApplyReport) -> Plan {
198        #[cfg(feature = "tracing")]
199        let _span = tracing::info_span!("switchyard.plan_rollback").entered();
200        // Emit a planning fact for rollback to satisfy visibility and tests
201        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    /// Prune backup artifacts for a given target according to retention policy.
230    ///
231    /// Emits a `prune.result` fact with details about counts and policy used.
232    ///
233    /// # Errors
234    ///
235    /// Returns an `ApiError` if backup pruning fails.
236    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        // Synthesize a stable plan-like ID for pruning based on target path and tag.
248        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}