Skip to main content

alembic_engine/
external.rs

1//! helpers for implementing external adapters.
2
3use crate::{ApplyReport, BackendId, Op, ProvisionReport, StateData};
4use alembic_core::{JsonMap, Key, Schema, TypeName};
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7use std::io::{self, BufReader, Read, Write};
8
9/// current external adapter protocol version.
10pub const EXTERNAL_PROTOCOL_VERSION: u8 = 1;
11
12/// request envelope sent to external adapters.
13#[derive(Debug, Serialize, Deserialize)]
14pub struct ExternalEnvelope {
15    /// protocol version.
16    pub version: u8,
17    /// custom plugin configuration.
18    pub setup: serde_yaml::Value,
19    /// request payload.
20    #[serde(flatten)]
21    pub request: ExternalRequest,
22}
23
24/// external adapter request variants.
25#[derive(Debug, Serialize, Deserialize)]
26#[serde(tag = "method", rename_all = "snake_case")]
27pub enum ExternalRequest {
28    /// read inventory for the requested types.
29    Read {
30        schema: Schema,
31        types: Vec<TypeName>,
32        state: StateData,
33    },
34    /// apply a set of operations.
35    Write {
36        schema: Schema,
37        ops: Vec<Op>,
38        state: StateData,
39    },
40    /// ensure the backend schema exists.
41    EnsureSchema { schema: Schema },
42    /// preview what ensuring the backend schema would provision, writing nothing.
43    PreviewSchema { schema: Schema },
44}
45
46/// borrowed host-side serializer; keep field-compatible with [`ExternalEnvelope`].
47#[derive(Debug, Serialize)]
48pub struct ExternalEnvelopeRef<'a> {
49    /// protocol version.
50    pub version: u8,
51    /// custom plugin configuration.
52    pub setup: serde_yaml::Value,
53    /// request payload.
54    #[serde(flatten)]
55    pub request: ExternalRequestRef<'a>,
56}
57
58/// borrowed host-side serializer; keep field-compatible with [`ExternalRequest`].
59#[derive(Debug, Serialize)]
60#[serde(tag = "method", rename_all = "snake_case")]
61pub enum ExternalRequestRef<'a> {
62    /// read inventory for the requested types.
63    Read {
64        schema: &'a Schema,
65        types: &'a [TypeName],
66        state: StateData,
67    },
68    /// apply a set of operations.
69    Write {
70        schema: &'a Schema,
71        ops: &'a [Op],
72        state: StateData,
73    },
74    /// ensure the backend schema exists.
75    EnsureSchema { schema: &'a Schema },
76    /// preview what ensuring the backend schema would provision, writing nothing.
77    PreviewSchema { schema: &'a Schema },
78}
79
80/// observed object representation for external adapters.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ExternalObject {
83    /// object type.
84    pub type_name: TypeName,
85    /// natural key for matching.
86    pub key: Key,
87    /// observed attributes.
88    pub attrs: JsonMap,
89    /// backend id when known.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub backend_id: Option<BackendId>,
92}
93
94/// response wrapper for external adapters.
95#[derive(Debug, Serialize, Deserialize)]
96pub struct ExternalResponse<T> {
97    /// whether the request succeeded.
98    pub ok: bool,
99    /// payload on success.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub result: Option<T>,
102    /// error message on failure.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub error: Option<String>,
105}
106
107impl<T> ExternalResponse<T> {
108    /// build a success response.
109    pub fn ok(result: T) -> Self {
110        Self {
111            ok: true,
112            result: Some(result),
113            error: None,
114        }
115    }
116
117    /// build an error response.
118    pub fn error(message: impl Into<String>) -> Self {
119        Self {
120            ok: false,
121            result: None,
122            error: Some(message.into()),
123        }
124    }
125
126    /// convert a result into a response.
127    pub fn from_result(result: Result<T>) -> Self {
128        match result {
129            Ok(value) => Self::ok(value),
130            Err(err) => Self::error(format!("{err:#}")),
131        }
132    }
133}
134
135/// external adapter helper trait.
136pub trait ExternalAdapter {
137    /// initial configuration of the adapter
138    fn setup(&mut self, configuration: &serde_yaml::Value) -> Result<()>;
139
140    /// read objects from the backend.
141    fn read(
142        &mut self,
143        schema: &Schema,
144        types: &[TypeName],
145        state: &StateData,
146    ) -> Result<Vec<ExternalObject>>;
147
148    /// apply operations to the backend.
149    fn write(&mut self, schema: &Schema, ops: &[Op], state: &StateData) -> Result<ApplyReport>;
150
151    /// provision backend schema elements.
152    fn ensure_schema(&mut self, schema: &Schema) -> Result<ProvisionReport> {
153        let _ = schema;
154        Ok(ProvisionReport::default())
155    }
156
157    /// preview schema provisioning, writing nothing. `None` (the default) means this
158    /// adapter cannot preview schema; `Some(report)` is what [`ExternalAdapter::ensure_schema`]
159    /// would provision.
160    fn preview_schema(&mut self, schema: &Schema) -> Result<Option<ProvisionReport>> {
161        let _ = schema;
162        Ok(None)
163    }
164}
165
166/// run an external adapter using stdin/stdout for a single request.
167pub fn run_external_adapter<A: ExternalAdapter>(
168    mut adapter: A,
169    (reader, mut writer): (impl Read, impl Write),
170) -> io::Result<()> {
171    let mut input = String::new();
172    BufReader::new(reader).read_to_string(&mut input)?;
173
174    let envelope: ExternalEnvelope = match serde_json::from_str(&input) {
175        Ok(envelope) => envelope,
176        Err(err) => return write_error(&mut writer, format!("invalid request: {err}")),
177    };
178
179    if envelope.version != EXTERNAL_PROTOCOL_VERSION {
180        return write_error(
181            &mut writer,
182            format!(
183                "unsupported protocol version {} (expected {})",
184                envelope.version, EXTERNAL_PROTOCOL_VERSION
185            ),
186        );
187    }
188
189    if let Err(e) = adapter.setup(&envelope.setup) {
190        return write_error(&mut writer, format!("invalid setup: {e}"));
191    }
192
193    match envelope.request {
194        ExternalRequest::Read {
195            schema,
196            types,
197            state,
198        } => {
199            let response = ExternalResponse::from_result(adapter.read(&schema, &types, &state));
200            write_response(&mut writer, response)
201        }
202        ExternalRequest::Write { schema, ops, state } => {
203            let response = ExternalResponse::from_result(adapter.write(&schema, &ops, &state));
204            write_response(&mut writer, response)
205        }
206        ExternalRequest::EnsureSchema { schema } => {
207            let response = ExternalResponse::from_result(adapter.ensure_schema(&schema));
208            write_response(&mut writer, response)
209        }
210        ExternalRequest::PreviewSchema { schema } => {
211            let response = ExternalResponse::from_result(adapter.preview_schema(&schema));
212            write_response(&mut writer, response)
213        }
214    }
215}
216
217fn write_error(out: &mut impl Write, message: String) -> io::Result<()> {
218    let response = ExternalResponse::<serde_json::Value>::error(message);
219    write_response(out, response)
220}
221
222fn write_response<T: Serialize>(
223    out: &mut impl Write,
224    response: ExternalResponse<T>,
225) -> io::Result<()> {
226    serde_json::to_writer(&mut *out, &response).map_err(io::Error::other)?;
227    out.write_all(b"\n")?;
228    out.flush()
229}
230
231/// convenience macro to define an external adapter main.
232#[macro_export]
233macro_rules! alembic_external_main {
234    ($adapter:expr) => {
235        fn main() -> std::io::Result<()> {
236            let stdin = std::io::stdin();
237            let mut stdout = std::io::BufWriter::new(std::io::stdout());
238            $crate::external::run_external_adapter($adapter, (stdin, stdout))
239        }
240    };
241}
242
243#[cfg(test)]
244mod tests {
245    use super::ExternalResponse;
246    use crate::{
247        run_external_adapter, ApplyReport, ExternalAdapter, ExternalEnvelope, ExternalEnvelopeRef,
248        ExternalObject, ExternalRequest, ExternalRequestRef, Op, ProvisionReport, StateData,
249        EXTERNAL_PROTOCOL_VERSION,
250    };
251    use alembic_core::{Key, Object, Schema, TypeName, TypeSchema, Uid};
252    use serde_json::json;
253    use serde_yaml::Value;
254    use std::io::BufReader;
255    use std::io::{BufRead, Write};
256
257    #[test]
258    fn external_response_ok_serializes() {
259        let response = ExternalResponse::ok(vec!["one".to_string()]);
260        let value = serde_json::to_value(&response).unwrap();
261        assert_eq!(value, json!({"ok": true, "result": ["one"]}));
262    }
263
264    #[test]
265    fn external_response_error_serializes() {
266        let response: ExternalResponse<Vec<String>> = ExternalResponse::error("boom");
267        let value = serde_json::to_value(&response).unwrap();
268        assert_eq!(value, json!({"ok": false, "error": "boom"}));
269    }
270
271    #[test]
272    fn external_response_from_result_renders_error_chain() {
273        let err = anyhow::anyhow!("connection refused")
274            .context("connecting to backend")
275            .context("reading inventory");
276        let response: ExternalResponse<()> = ExternalResponse::from_result(Err(err));
277        let error = response.error.unwrap();
278        assert!(error.contains("reading inventory"));
279        assert!(error.contains("connecting to backend"));
280        assert!(error.contains("connection refused"));
281    }
282
283    #[test]
284    fn ref_and_owned_request_types_serialize_identically() {
285        let schema = Schema {
286            types: [(
287                "dcim.device".to_string(),
288                TypeSchema {
289                    key: [].into(),
290                    fields: [].into(),
291                },
292            )]
293            .into(),
294        };
295        let types = vec![TypeName::new("dcim.device")];
296        let ops = vec![Op::Create {
297            uid: Uid::from_u128(1),
298            type_name: TypeName::new("dcim.device"),
299            desired: Object {
300                uid: Uid::from_u128(1),
301                type_name: TypeName::new("dcim.device"),
302                key: Key::default(),
303                attrs: Default::default(),
304                source: None,
305            },
306        }];
307        let state = StateData::default();
308
309        let owned_read = serde_json::to_value(ExternalRequest::Read {
310            schema: schema.clone(),
311            types: types.clone(),
312            state: state.clone(),
313        })
314        .unwrap();
315        let ref_read = serde_json::to_value(ExternalRequestRef::Read {
316            schema: &schema,
317            types: &types,
318            state: state.clone(),
319        })
320        .unwrap();
321        assert_eq!(owned_read, ref_read);
322
323        let owned_write = serde_json::to_value(ExternalRequest::Write {
324            schema: schema.clone(),
325            ops: ops.clone(),
326            state: state.clone(),
327        })
328        .unwrap();
329        let ref_write = serde_json::to_value(ExternalRequestRef::Write {
330            schema: &schema,
331            ops: &ops,
332            state: state.clone(),
333        })
334        .unwrap();
335        assert_eq!(owned_write, ref_write);
336
337        let owned_ensure = serde_json::to_value(ExternalRequest::EnsureSchema {
338            schema: schema.clone(),
339        })
340        .unwrap();
341        let ref_ensure =
342            serde_json::to_value(ExternalRequestRef::EnsureSchema { schema: &schema }).unwrap();
343        assert_eq!(owned_ensure, ref_ensure);
344
345        let owned_preview = serde_json::to_value(ExternalRequest::PreviewSchema {
346            schema: schema.clone(),
347        })
348        .unwrap();
349        let ref_preview =
350            serde_json::to_value(ExternalRequestRef::PreviewSchema { schema: &schema }).unwrap();
351        assert_eq!(owned_preview, ref_preview);
352
353        let owned_envelope = serde_json::to_value(ExternalEnvelope {
354            version: EXTERNAL_PROTOCOL_VERSION,
355            setup: Default::default(),
356            request: ExternalRequest::Read {
357                schema: schema.clone(),
358                types: types.clone(),
359                state: state.clone(),
360            },
361        })
362        .unwrap();
363        let ref_envelope = serde_json::to_value(ExternalEnvelopeRef {
364            version: EXTERNAL_PROTOCOL_VERSION,
365            setup: Default::default(),
366            request: ExternalRequestRef::Read {
367                schema: &schema,
368                types: &types,
369                state,
370            },
371        })
372        .unwrap();
373        assert_eq!(owned_envelope, ref_envelope);
374    }
375
376    #[derive(Debug, Default)]
377    struct TestExternalAdapter {
378        pub x: i64,
379    }
380
381    impl ExternalAdapter for TestExternalAdapter {
382        fn setup(&mut self, configuration: &Value) -> anyhow::Result<()> {
383            if let Some(x) = configuration.get("x").and_then(serde_yaml::Value::as_i64) {
384                self.x = x;
385            }
386            Ok(())
387        }
388
389        fn read(
390            &mut self,
391            _schema: &Schema,
392            _types: &[TypeName],
393            _state: &StateData,
394        ) -> anyhow::Result<Vec<ExternalObject>> {
395            let mut result = vec![];
396            for _ in 0..self.x {
397                result.push(ExternalObject {
398                    type_name: TypeName::new(""),
399                    key: Default::default(),
400                    attrs: Default::default(),
401                    backend_id: None,
402                })
403            }
404            Ok(result)
405        }
406
407        fn write(
408            &mut self,
409            _schema: &Schema,
410            _ops: &[Op],
411            _state: &StateData,
412        ) -> anyhow::Result<ApplyReport> {
413            Err(anyhow::anyhow!("unsupported operation"))
414        }
415
416        fn ensure_schema(&mut self, schema: &Schema) -> anyhow::Result<ProvisionReport> {
417            let mut created_fields = vec![];
418            for ty_name in schema.types.keys() {
419                created_fields.push(ty_name.clone());
420            }
421            Ok(ProvisionReport {
422                created_fields,
423                ..Default::default()
424            })
425        }
426
427        fn preview_schema(&mut self, schema: &Schema) -> anyhow::Result<Option<ProvisionReport>> {
428            // read-only mirror of ensure_schema: report the same fields, provision none.
429            Ok(Some(ProvisionReport {
430                created_fields: schema.types.keys().cloned().collect(),
431                ..Default::default()
432            }))
433        }
434    }
435
436    #[test]
437    fn external_adapter_communication_over_stdio() {
438        let adapter = TestExternalAdapter::default();
439
440        let (in_reader, mut in_writer) = std::io::pipe().unwrap();
441        let (out_reader, out_writer) = std::io::pipe().unwrap();
442
443        let t = std::thread::spawn(move || {
444            assert!(run_external_adapter(adapter, (in_reader, out_writer)).is_ok());
445        });
446
447        let dummy_type_schema = TypeSchema {
448            key: [].into(),
449            fields: [].into(),
450        };
451
452        let request = ExternalRequest::EnsureSchema {
453            schema: Schema {
454                types: [
455                    ("a".to_string(), dummy_type_schema.clone()),
456                    ("b".to_string(), dummy_type_schema.clone()),
457                ]
458                .into(),
459            },
460        };
461        let envelope = ExternalEnvelope {
462            version: EXTERNAL_PROTOCOL_VERSION,
463            setup: Default::default(),
464            request,
465        };
466
467        writeln!(in_writer, "{}", serde_json::to_string(&envelope).unwrap()).unwrap();
468        drop(in_writer);
469
470        let mut response = String::new();
471        BufReader::new(out_reader).read_line(&mut response).unwrap();
472
473        let response: ExternalResponse<ProvisionReport> = serde_json::from_str(&response).unwrap();
474        assert!(response.ok);
475        assert_eq!(
476            response.result.unwrap().created_fields,
477            vec!["a".to_string(), "b".to_string()]
478        );
479
480        t.join().unwrap();
481    }
482
483    #[test]
484    fn external_adapter_preview_schema_roundtrip() {
485        let adapter = TestExternalAdapter::default();
486
487        let (in_reader, mut in_writer) = std::io::pipe().unwrap();
488        let (out_reader, out_writer) = std::io::pipe().unwrap();
489
490        let t = std::thread::spawn(move || {
491            assert!(run_external_adapter(adapter, (in_reader, out_writer)).is_ok());
492        });
493
494        let dummy_type_schema = TypeSchema {
495            key: [].into(),
496            fields: [].into(),
497        };
498        let request = ExternalRequest::PreviewSchema {
499            schema: Schema {
500                types: [
501                    ("a".to_string(), dummy_type_schema.clone()),
502                    ("b".to_string(), dummy_type_schema.clone()),
503                ]
504                .into(),
505            },
506        };
507        let envelope = ExternalEnvelope {
508            version: EXTERNAL_PROTOCOL_VERSION,
509            setup: Default::default(),
510            request,
511        };
512
513        writeln!(in_writer, "{}", serde_json::to_string(&envelope).unwrap()).unwrap();
514        drop(in_writer);
515
516        let mut response = String::new();
517        BufReader::new(out_reader).read_line(&mut response).unwrap();
518
519        let response: ExternalResponse<Option<ProvisionReport>> =
520            serde_json::from_str(&response).unwrap();
521        assert!(response.ok);
522        // preview returned Some(report) with the same fields ensure_schema would create.
523        assert_eq!(
524            response.result.flatten().unwrap().created_fields,
525            vec!["a".to_string(), "b".to_string()]
526        );
527
528        t.join().unwrap();
529    }
530
531    #[test]
532    fn preview_schema_none_roundtrips_as_null_result() {
533        // the honesty-critical case: an adapter that cannot preview returns Ok(None),
534        // which must survive the wire as an explicit null result (not a missing one)
535        // so the host reads it back as None, never as an empty "no schema changes".
536        let response: ExternalResponse<Option<ProvisionReport>> =
537            ExternalResponse::from_result(Ok(None));
538        let wire = serde_json::to_value(&response).unwrap();
539        assert_eq!(wire, json!({"ok": true, "result": null}));
540        let back: ExternalResponse<Option<ProvisionReport>> = serde_json::from_value(wire).unwrap();
541        assert!(back.ok);
542        assert!(back.result.flatten().is_none());
543    }
544
545    #[test]
546    fn external_adapter_communication_error() {
547        let adapter = TestExternalAdapter::default();
548
549        let (in_reader, mut in_writer) = std::io::pipe().unwrap();
550        let (out_reader, out_writer) = std::io::pipe().unwrap();
551
552        let t = std::thread::spawn(move || {
553            assert!(run_external_adapter(adapter, (in_reader, out_writer)).is_ok());
554        });
555
556        // the 'Write' request is booby trapped on TestExternalAdapter
557        let request = ExternalRequest::Write {
558            schema: Default::default(),
559            ops: vec![],
560            state: Default::default(),
561        };
562        let envelope = ExternalEnvelope {
563            version: EXTERNAL_PROTOCOL_VERSION,
564            setup: Default::default(),
565            request,
566        };
567
568        writeln!(in_writer, "{}", serde_json::to_string(&envelope).unwrap()).unwrap();
569        drop(in_writer);
570
571        let mut response = String::new();
572        BufReader::new(out_reader).read_line(&mut response).unwrap();
573
574        let response: ExternalResponse<ProvisionReport> = serde_json::from_str(&response).unwrap();
575        assert!(response.error.is_some());
576        assert!(!response.ok);
577
578        t.join().unwrap();
579    }
580
581    #[test]
582    fn external_adapter_outdated() {
583        let adapter = TestExternalAdapter::default();
584
585        let (in_reader, mut in_writer) = std::io::pipe().unwrap();
586        let (out_reader, out_writer) = std::io::pipe().unwrap();
587
588        let t = std::thread::spawn(move || {
589            assert!(run_external_adapter(adapter, (in_reader, out_writer)).is_ok());
590        });
591
592        let request = ExternalRequest::EnsureSchema {
593            schema: Default::default(),
594        };
595        let envelope = ExternalEnvelope {
596            version: EXTERNAL_PROTOCOL_VERSION + 1,
597            setup: Default::default(),
598            request,
599        };
600
601        writeln!(in_writer, "{}", serde_json::to_string(&envelope).unwrap()).unwrap();
602        drop(in_writer);
603
604        let mut response = String::new();
605        BufReader::new(out_reader).read_line(&mut response).unwrap();
606
607        let response: ExternalResponse<ProvisionReport> = serde_json::from_str(&response).unwrap();
608        if let Some(error) = response.error {
609            assert_eq!(
610                error,
611                format!(
612                    "unsupported protocol version {} (expected {})",
613                    EXTERNAL_PROTOCOL_VERSION + 1,
614                    EXTERNAL_PROTOCOL_VERSION
615                )
616            );
617        }
618        assert!(!response.ok);
619
620        t.join().unwrap();
621    }
622
623    #[test]
624    fn external_adapter_configuration() {
625        let adapter = TestExternalAdapter::default();
626
627        let (in_reader, mut in_writer) = std::io::pipe().unwrap();
628        let (out_reader, out_writer) = std::io::pipe().unwrap();
629
630        let t = std::thread::spawn(move || {
631            assert!(run_external_adapter(adapter, (in_reader, out_writer)).is_ok());
632        });
633
634        let request = ExternalRequest::Read {
635            schema: Default::default(),
636            types: vec![],
637            state: Default::default(),
638        };
639        const MAGIC_NUMBER: usize = 13;
640
641        let envelope = ExternalEnvelope {
642            version: EXTERNAL_PROTOCOL_VERSION,
643            setup: serde_yaml::from_str(&format!("x: {MAGIC_NUMBER}")).unwrap(),
644            request,
645        };
646
647        writeln!(in_writer, "{}", serde_json::to_string(&envelope).unwrap()).unwrap();
648        drop(in_writer);
649
650        let mut response = String::new();
651        BufReader::new(out_reader).read_line(&mut response).unwrap();
652
653        let response: ExternalResponse<Vec<ExternalObject>> =
654            serde_json::from_str(&response).unwrap();
655        assert!(response.ok);
656        assert_eq!(response.result.unwrap().len(), MAGIC_NUMBER,);
657
658        t.join().unwrap();
659    }
660}