Skip to main content

network_device/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3
4//! A dependency-free, strongly typed eDSL and base contracts for network
5//! device operations and declarative infrastructure resources.
6//!
7//! The imperative API runs operations directly through [`Driver`] and
8//! [`Execute`]. The declarative API describes desired resources for an
9//! [`IacBackend`]. These models are intentionally separate.
10//!
11//! # Direct execution
12//!
13//! ```
14//! use network_device::{
15//!     AdministrativeState, Driver, Execute, InterfaceName, Run,
16//!     SetInterfaceState, operation,
17//! };
18//! use std::convert::Infallible;
19//! use std::future::Future;
20//!
21//! struct Device;
22//!
23//! impl Driver for Device {
24//!     type Error = Infallible;
25//! }
26//!
27//! impl Execute<SetInterfaceState> for Device {
28//!     type Output = ();
29//!
30//!     fn execute<'a>(
31//!         &'a self,
32//!         _operation: SetInterfaceState,
33//!     ) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send + 'a {
34//!         async { Ok(()) }
35//!     }
36//! }
37//!
38//! # async fn example() -> Result<(), Infallible> {
39//! let name = InterfaceName::try_from("Ethernet1/1").expect("valid interface name");
40//! operation(SetInterfaceState::new(name, AdministrativeState::Enabled))
41//!     .run(&Device)
42//!     .await?;
43//! # Ok(())
44//! # }
45//! ```
46//!
47//! # IaC backend
48//!
49//! ```
50//! use network_device::{
51//!     AdministrativeState, IacBackend, InterfaceConfiguration, InterfaceName,
52//! };
53//! use std::convert::Infallible;
54//! use std::future::Future;
55//!
56//! struct LocalBackend;
57//!
58//! impl IacBackend<InterfaceConfiguration> for LocalBackend {
59//!     type Error = Infallible;
60//!     type Plan = usize;
61//!     type ApplyResult = ();
62//!
63//!     fn plan<'a>(
64//!         &'a self,
65//!         desired: &'a InterfaceConfiguration,
66//!     ) -> impl Future<Output = Result<Self::Plan, Self::Error>> + Send + 'a {
67//!         async move { Ok(usize::from(desired.administrative_state.is_some())) }
68//!     }
69//!
70//!     fn apply<'a>(
71//!         &'a self,
72//!         _desired: &'a InterfaceConfiguration,
73//!     ) -> impl Future<Output = Result<Self::ApplyResult, Self::Error>> + Send + 'a {
74//!         async { Ok(()) }
75//!     }
76//! }
77//!
78//! # async fn example() -> Result<(), Infallible> {
79//! let desired = InterfaceConfiguration::new(
80//!     InterfaceName::try_from("Ethernet1/1").expect("valid interface name"),
81//! )
82//! .administrative_state(AdministrativeState::Enabled);
83//! let backend = LocalBackend;
84//! let _plan = backend.plan(&desired).await?;
85//! backend.apply(&desired).await?;
86//! # Ok(())
87//! # }
88//! ```
89//!
90//! # Driver connection and credentials
91//!
92//! [`DriverConnector`] constructs and authenticates a concrete provider driver
93//! from a provider-defined target and authentication value. Credential lookup
94//! remains the responsibility of the integrating application. For example, an
95//! integration can resolve an `oss_providers::CredentialMaterial` using its
96//! `CredentialProvider`, convert that value into the selected connector's
97//! `Authentication` type, and pass it to [`DriverConnector::connect`]. This
98//! crate has no dependency on `oss-providers`, Vault, or a credential store.
99
100mod connector;
101mod iac;
102mod interface;
103mod operation;
104mod routing;
105
106pub use connector::DriverConnector;
107pub use iac::{IacBackend, InterfaceConfiguration, StaticRouteConfiguration};
108pub use interface::{
109    InterfaceName, InterfaceNameError, IpPrefix, IpPrefixError, IpPrefixParseError,
110};
111pub use operation::{
112    AdministrativeState, Driver, EnsureStaticRoute, Execute, OperationStep, RemoveStaticRoute, Run,
113    Sequence, SetInterfaceDescription, SetInterfaceState, operation,
114};
115pub use routing::{StaticRoute, StaticRouteError};
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::convert::Infallible;
121    use std::future::Future;
122    use std::net::{IpAddr, Ipv4Addr};
123    use std::pin::pin;
124    use std::sync::atomic::{AtomicBool, Ordering};
125    use std::sync::mpsc::{self, Receiver, Sender};
126    use std::task::{Context, Poll, Waker};
127
128    fn block_on<F: Future>(future: F) -> F::Output {
129        let mut future = pin!(future);
130        let waker = Waker::noop();
131        let mut context = Context::from_waker(waker);
132        match future.as_mut().poll(&mut context) {
133            Poll::Ready(output) => output,
134            Poll::Pending => panic!("test future unexpectedly yielded"),
135        }
136    }
137
138    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
139    enum TestError {
140        Failed,
141    }
142
143    impl std::fmt::Display for TestError {
144        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145            formatter.write_str("test operation failed")
146        }
147    }
148
149    impl std::error::Error for TestError {}
150
151    struct TestDriver {
152        events: Sender<&'static str>,
153        fail_first: AtomicBool,
154    }
155
156    impl Driver for TestDriver {
157        type Error = TestError;
158    }
159
160    struct FirstOperation;
161    struct SecondOperation;
162
163    impl Execute<FirstOperation> for TestDriver {
164        type Output = u8;
165
166        #[allow(clippy::manual_async_fn)]
167        fn execute<'a>(
168            &'a self,
169            _operation: FirstOperation,
170        ) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send + 'a {
171            async move {
172                self.events.send("first").expect("receiver is alive");
173                if self.fail_first.load(Ordering::Relaxed) {
174                    Err(TestError::Failed)
175                } else {
176                    Ok(7)
177                }
178            }
179        }
180    }
181
182    impl Execute<SecondOperation> for TestDriver {
183        type Output = String;
184
185        #[allow(clippy::manual_async_fn)]
186        fn execute<'a>(
187            &'a self,
188            _operation: SecondOperation,
189        ) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send + 'a {
190            async move {
191                self.events.send("second").expect("receiver is alive");
192                Ok(String::from("completed"))
193            }
194        }
195    }
196
197    fn test_driver(fail_first: bool) -> (TestDriver, Receiver<&'static str>) {
198        let (events, receiver) = mpsc::channel();
199        (
200            TestDriver {
201                events,
202                fail_first: AtomicBool::new(fail_first),
203            },
204            receiver,
205        )
206    }
207
208    #[test]
209    fn operation_sequence_runs_in_order_and_returns_second_output() {
210        let (driver, events) = test_driver(false);
211        let output = block_on(
212            operation(FirstOperation)
213                .then(operation(SecondOperation))
214                .run(&driver),
215        )
216        .unwrap();
217
218        assert_eq!(output, "completed");
219        assert_eq!(events.try_iter().collect::<Vec<_>>(), ["first", "second"]);
220    }
221
222    #[test]
223    fn operation_sequence_stops_after_first_error() {
224        let (driver, events) = test_driver(true);
225        let result = block_on(
226            operation(FirstOperation)
227                .then(operation(SecondOperation))
228                .run(&driver),
229        );
230
231        assert_eq!(result, Err(TestError::Failed));
232        assert_eq!(events.try_iter().collect::<Vec<_>>(), ["first"]);
233    }
234
235    struct CustomOperation(&'static str);
236
237    impl Execute<CustomOperation> for TestDriver {
238        type Output = usize;
239
240        #[allow(clippy::manual_async_fn)]
241        fn execute<'a>(
242            &'a self,
243            operation: CustomOperation,
244        ) -> impl Future<Output = Result<Self::Output, Self::Error>> + Send + 'a {
245            async move {
246                self.events.send(operation.0).expect("receiver is alive");
247                Ok(operation.0.len())
248            }
249        }
250    }
251
252    #[test]
253    fn downstream_custom_operation_is_supported_without_core_changes() {
254        let (driver, events) = test_driver(false);
255        let result = block_on(operation(CustomOperation("extension")).run(&driver)).unwrap();
256
257        assert_eq!(result, "extension".len());
258        assert_eq!(events.try_iter().collect::<Vec<_>>(), ["extension"]);
259    }
260
261    struct TestIacBackend;
262
263    impl IacBackend<StaticRouteConfiguration> for TestIacBackend {
264        type Error = Infallible;
265        type Plan = bool;
266        type ApplyResult = usize;
267
268        #[allow(clippy::manual_async_fn)]
269        fn plan<'a>(
270            &'a self,
271            desired: &'a StaticRouteConfiguration,
272        ) -> impl Future<Output = Result<Self::Plan, Self::Error>> + Send + 'a {
273            async move { Ok(desired.route.destination().prefix_length() == 24) }
274        }
275
276        #[allow(clippy::manual_async_fn)]
277        fn apply<'a>(
278            &'a self,
279            desired: &'a StaticRouteConfiguration,
280        ) -> impl Future<Output = Result<Self::ApplyResult, Self::Error>> + Send + 'a {
281            async move { Ok(usize::from(desired.route.destination().prefix_length())) }
282        }
283    }
284
285    #[test]
286    fn iac_backend_can_plan_and_apply_a_standard_resource() {
287        let destination = "192.0.2.0/24".parse::<IpPrefix>().unwrap();
288        let route = StaticRoute::new(destination, IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1))).unwrap();
289        let desired = StaticRouteConfiguration::new(route);
290        let backend = TestIacBackend;
291
292        assert!(block_on(backend.plan(&desired)).unwrap());
293        assert_eq!(block_on(backend.apply(&desired)).unwrap(), 24);
294    }
295}