1use alembic_adapter_sdk::external::{ExternalResponse, EXTERNAL_PROTOCOL_VERSION};
4use alembic_adapter_sdk::{
5 AppliedOp, ApplyReport, BackendId, ExternalCapabilities, ExternalObject, ExternalRole,
6 ProvisionReport,
7};
8use alembic_core::{key_string, uid_v5, validate_inventory, Inventory, Object, Schema, TypeName};
9use anyhow::Context;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12use std::io::{Read, Write};
13use std::path::Path;
14use std::process::{Command, ExitStatus, Stdio};
15use std::sync::mpsc;
16use std::thread;
17use std::time::{Duration, Instant};
18
19#[derive(Debug, Clone)]
21pub struct Outcome {
22 pub name: String,
23 pub failure: Option<Failure>,
24 pub skipped: Option<String>,
27}
28
29impl Outcome {
30 pub fn passed(&self) -> bool {
32 self.failure.is_none() && self.skipped.is_none()
33 }
34
35 pub fn skipped(&self) -> bool {
37 self.skipped.is_some()
38 }
39}
40
41#[derive(Debug, Clone, Copy, Default)]
44pub struct Builtins {
45 pub writes: bool,
48}
49
50#[derive(Debug, Clone)]
52pub struct Failure {
53 pub message: String,
54 pub status: String,
55 pub stdout: String,
56 pub stderr: String,
57}
58
59#[derive(Debug, Clone, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct Case {
63 pub name: String,
64 pub request: Value,
65 pub expect: Expect,
66}
67
68#[derive(Debug, Clone, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct Expect {
73 pub ok: bool,
74 pub result: Option<Value>,
75 pub error: Option<String>,
76}
77
78pub fn run_builtin(adapter: &[String], timeout: Duration) -> Vec<Outcome> {
80 run_builtin_with(adapter, timeout, Builtins::default())
81}
82
83pub fn run_builtin_with(adapter: &[String], timeout: Duration, builtins: Builtins) -> Vec<Outcome> {
85 let version = EXTERNAL_PROTOCOL_VERSION;
87 let (role, capabilities) = probe_capabilities(adapter, timeout);
89 let read_empty = (
90 "protocol/read-empty",
91 "read",
92 json!({
93 "version": version,
94 "setup": {},
95 "method": "read",
96 "schema": { "types": {} },
97 "types": [],
98 "state": {}
99 }),
100 );
101 let write_empty = (
102 "protocol/write-empty",
103 "write",
104 json!({
105 "version": version,
106 "setup": {},
107 "method": "write",
108 "schema": { "types": {} },
109 "ops": [],
110 "state": {}
111 }),
112 );
113 let preview_empty = (
114 "protocol/preview-schema-empty",
115 "preview_schema",
116 json!({
117 "version": version,
118 "setup": {},
119 "method": "preview_schema",
120 "schema": { "types": {} }
121 }),
122 );
123 let (_, probe_method, probe_request) = match role {
126 ExternalRole::Emitter => &preview_empty,
127 ExternalRole::Observer | ExternalRole::Adapter => &read_empty,
128 };
129 let mut mismatched = probe_request.clone();
130 mismatched["version"] = json!(version + 1);
131 let version_mismatch = check(
132 adapter,
133 timeout,
134 "protocol/version-mismatch",
135 &request_bytes(&mismatched),
136 probe_method,
137 Expectation::MustError,
138 );
139 let liveness = match role {
142 ExternalRole::Emitter => vec![write_empty],
143 ExternalRole::Observer => vec![read_empty],
144 ExternalRole::Adapter => vec![read_empty, write_empty],
145 };
146 let mut outcomes = vec![
147 check(
148 adapter,
149 timeout,
150 "protocol/invalid-json",
151 b"not json",
152 "read",
153 Expectation::MustError,
154 ),
155 version_mismatch,
156 check(
157 adapter,
158 timeout,
159 "protocol/unknown-method",
160 &request_bytes(&json!({ "version": version, "setup": {}, "method": "frobnicate" })),
161 "read",
162 Expectation::MustError,
163 ),
164 capabilities,
165 ];
166 outcomes.extend(liveness.iter().map(|(name, method, request)| {
170 if builtins.writes || *method != "write" {
171 check(
172 adapter,
173 timeout,
174 name,
175 &request_bytes(request),
176 method,
177 Expectation::MustSucceed,
178 )
179 } else {
180 skipped(name, WRITES_ARE_OPT_IN)
181 }
182 }));
183 if matches!(role, ExternalRole::Emitter | ExternalRole::Adapter) {
186 let (name, method, request) = &preview_empty;
187 outcomes.push(check(
188 adapter,
189 timeout,
190 name,
191 &request_bytes(request),
192 method,
193 Expectation::MustSucceed,
196 ));
197 outcomes.push(if builtins.writes {
198 check(
199 adapter,
200 timeout,
201 "protocol/ensure-schema-empty",
202 &request_bytes(&json!({
203 "version": version,
204 "setup": {},
205 "method": "ensure_schema",
206 "schema": { "types": {} }
207 })),
208 "ensure_schema",
209 Expectation::MustSucceed,
210 )
211 } else {
212 skipped("protocol/ensure-schema-empty", WRITES_ARE_OPT_IN)
213 });
214 }
215 outcomes
216}
217
218const WRITES_ARE_OPT_IN: &str = "writes are opt-in, pass --write-checks";
219
220fn skipped(name: &str, reason: &str) -> Outcome {
223 Outcome {
224 name: name.to_string(),
225 failure: None,
226 skipped: Some(reason.to_string()),
227 }
228}
229
230fn probe_capabilities(adapter: &[String], timeout: Duration) -> (ExternalRole, Outcome) {
235 let request = request_bytes(&json!({
236 "version": EXTERNAL_PROTOCOL_VERSION,
237 "setup": {},
238 "method": "capabilities"
239 }));
240 let run = run_once(adapter, timeout, &request);
241 let judged = parse_response(&run).and_then(|response| {
242 match (response.ok, response.result, response.error) {
243 (true, Some(result), None) => check_payload("capabilities", &result).and_then(|()| {
246 serde_json::from_value::<ExternalCapabilities>(result)
247 .map(|capabilities| capabilities.role)
248 .map_err(|e| format!("bad capabilities result: {e}"))
249 }),
250 (false, None, Some(error)) => {
251 if error.is_empty() {
252 Err("inconsistent envelope: error message is empty".to_string())
253 } else {
254 Ok(ExternalRole::default())
255 }
256 }
257 (ok, result, error) => Err(format!(
258 "inconsistent envelope: ok={}, has_result={}, has_error={}",
259 ok,
260 result.is_some(),
261 error.is_some()
262 )),
263 }
264 });
265 let (role, failure) = match judged {
266 Ok(role) => (role, None),
267 Err(message) => (
268 ExternalRole::default(),
269 Some(Failure {
270 message,
271 status: status_label(&run),
272 stdout: String::from_utf8_lossy(&run.stdout).into_owned(),
273 stderr: String::from_utf8_lossy(&run.stderr).into_owned(),
274 }),
275 ),
276 };
277 (
278 role,
279 Outcome {
280 name: "protocol/capabilities".to_string(),
281 failure,
282 skipped: None,
283 },
284 )
285}
286
287pub fn run_cases(adapter: &[String], timeout: Duration, cases: &[Case]) -> Vec<Outcome> {
289 cases
290 .iter()
291 .map(|case| {
292 let method = case
293 .request
294 .get("method")
295 .and_then(Value::as_str)
296 .unwrap_or("");
297 check(
298 adapter,
299 timeout,
300 &format!("case/{}", case.name),
301 &request_bytes(&case.request),
302 method,
303 Expectation::Case {
304 expect: &case.expect,
305 request: &case.request,
306 },
307 )
308 })
309 .collect()
310}
311
312pub fn load_cases(path: &Path) -> anyhow::Result<Vec<Case>> {
315 let metadata =
316 std::fs::metadata(path).with_context(|| format!("reading cases at {}", path.display()))?;
317 let mut files = Vec::new();
318 if metadata.is_dir() {
319 for entry in
320 std::fs::read_dir(path).with_context(|| format!("reading {}", path.display()))?
321 {
322 let entry = entry?;
323 let file = entry.path();
324 if file.extension().and_then(|e| e.to_str()) == Some("json") {
325 files.push(file);
326 }
327 }
328 files.sort();
329 } else {
330 files.push(path.to_path_buf());
331 }
332 let mut cases = Vec::new();
335 for file in files {
336 let text = std::fs::read_to_string(&file)
337 .with_context(|| format!("reading {}", file.display()))?;
338 let case: Case =
339 serde_json::from_str(&text).with_context(|| format!("parsing {}", file.display()))?;
340 cases.push(case);
341 }
342 Ok(cases)
343}
344
345enum Expectation<'a> {
347 MustError,
349 MustSucceed,
351 Case {
353 expect: &'a Expect,
354 request: &'a Value,
355 },
356}
357
358fn check(
360 adapter: &[String],
361 timeout: Duration,
362 name: &str,
363 stdin: &[u8],
364 method: &str,
365 expectation: Expectation,
366) -> Outcome {
367 let run = run_once(adapter, timeout, stdin);
368 let failure = validate(&run, method, &expectation)
369 .err()
370 .map(|message| Failure {
371 message,
372 status: status_label(&run),
373 stdout: String::from_utf8_lossy(&run.stdout).into_owned(),
374 stderr: String::from_utf8_lossy(&run.stderr).into_owned(),
375 });
376 Outcome {
377 name: name.to_string(),
378 failure,
379 skipped: None,
380 }
381}
382
383fn parse_response(run: &RunResult) -> Result<ExternalResponse<Value>, String> {
385 if run.timed_out {
386 return Err("did not terminate within the timeout".to_string());
387 }
388 match run.status {
389 Some(status) if status.success() => {}
390 Some(status) => return Err(format!("adapter exited unsuccessfully ({status})")),
391 None => return Err("adapter did not start".to_string()),
392 }
393
394 let mut de = serde_json::Deserializer::from_slice(&run.stdout);
397 let value =
398 Value::deserialize(&mut de).map_err(|e| format!("stdout is not one json document: {e}"))?;
399 de.end()
400 .map_err(|_| "stdout has trailing output after the json document".to_string())?;
401
402 reject_unknown_keys(&as_template(response_envelope())?, &value, "")
405 .map_err(|e| format!("bad response envelope: {e}"))?;
406
407 serde_json::from_value(value).map_err(|e| format!("not a response envelope: {e}"))
408}
409
410fn validate(run: &RunResult, method: &str, expectation: &Expectation) -> Result<(), String> {
412 let response = parse_response(run)?;
413
414 let consistent = match (response.ok, &response.result, &response.error) {
415 (true, Some(_), None) => true,
416 (true, None, None) if method == "preview_schema" => true,
422 (false, None, Some(error)) => {
423 if error.is_empty() {
424 return Err("inconsistent envelope: error message is empty".to_string());
425 }
426 true
427 }
428 _ => false,
429 };
430 if !consistent {
431 return Err(format!(
432 "inconsistent envelope: ok={}, has_result={}, has_error={}",
433 response.ok,
434 response.result.is_some(),
435 response.error.is_some()
436 ));
437 }
438
439 match expectation {
440 Expectation::MustError => {
441 if response.ok {
442 return Err("expected a structured error, got ok=true".to_string());
443 }
444 }
445 Expectation::MustSucceed => {
446 if let Some(error) = &response.error {
447 return Err(format!(
448 "a valid request must succeed, but the adapter returned an error: {error}"
449 ));
450 }
451 if let Some(result) = &response.result {
452 check_payload(method, result)?;
453 }
454 }
455 Expectation::Case { expect, request } => {
456 if response.ok != expect.ok {
457 return Err(format!("expected ok={}, got ok={}", expect.ok, response.ok));
458 }
459 match (&response.result, &response.error) {
460 (Some(result), _) => {
461 check_payload(method, result)?;
462 if method == "read" {
463 check_read_values(request, result)?;
464 }
465 if let Some(want) = &expect.result {
466 if result != want {
467 return Err(format!(
468 "result did not match: expected {want}, got {result}"
469 ));
470 }
471 }
472 }
473 (None, Some(error)) => {
474 if let Some(want) = &expect.error {
475 if error != want {
476 return Err(format!(
477 "error did not match: expected {want:?}, got {error:?}"
478 ));
479 }
480 }
481 }
482 (None, None) => {
483 if let Some(want) = &expect.result {
484 return Err(format!("result did not match: expected {want}, got null"));
485 }
486 }
487 }
488 }
489 }
490 Ok(())
491}
492
493fn check_payload(method: &str, result: &Value) -> Result<(), String> {
497 type Parse = fn(Value) -> serde_json::Result<()>;
498 let (template, parse): (Value, Parse) = match method {
499 "read" => (as_template(vec![observed_object()])?, |value| {
500 serde_json::from_value::<Vec<ExternalObject>>(value).map(drop)
501 }),
502 "write" => (as_template(apply_report())?, |value| {
503 serde_json::from_value::<ApplyReport>(value).map(drop)
504 }),
505 "ensure_schema" => (as_template(provision_report())?, |value| {
506 serde_json::from_value::<ProvisionReport>(value).map(drop)
507 }),
508 "preview_schema" => (as_template(provision_report())?, |value| {
509 serde_json::from_value::<Option<ProvisionReport>>(value).map(drop)
510 }),
511 "capabilities" => (
512 as_template(ExternalCapabilities {
513 role: ExternalRole::default(),
514 })?,
515 |value| serde_json::from_value::<ExternalCapabilities>(value).map(drop),
516 ),
517 other => return Err(format!("unknown method {other}")),
518 };
519 reject_unknown_keys(&template, result, "").map_err(|e| format!("bad {method} result: {e}"))?;
520 parse(result.clone()).map_err(|e| format!("bad {method} result: {e}"))
521}
522
523fn check_read_values(request: &Value, result: &Value) -> Result<(), String> {
531 let schema = request
532 .get("schema")
533 .cloned()
534 .ok_or_else(|| "read case request carries no schema".to_string())?;
535 let schema: Schema = serde_json::from_value(schema)
536 .map_err(|e| format!("read case request schema does not parse: {e}"))?;
537 let objects: Vec<ExternalObject> =
538 serde_json::from_value(result.clone()).map_err(|e| format!("bad read result: {e}"))?;
539 let objects: Vec<Object> = objects
540 .into_iter()
541 .filter(|object| schema.types.contains_key(object.type_name.as_str()))
542 .map(|object| Object {
543 uid: uid_v5(object.type_name.as_str(), &key_string(&object.key)),
544 type_name: object.type_name,
545 key: object.key,
546 attrs: object.attrs,
547 source: None,
548 })
549 .collect();
550 let inventory = Inventory {
551 schema,
552 scope: None,
553 objects,
554 };
555 let report = validate_inventory(&inventory);
556 if report.is_err() {
557 let rendered: Vec<String> = report
558 .with_sources(&inventory.objects)
559 .into_iter()
560 .map(|error| error.to_string())
561 .collect();
562 return Err(format!(
563 "read result leaves alembic's value space: {}",
564 rendered.join("; ")
565 ));
566 }
567 Ok(())
568}
569
570fn as_template<T: Serialize>(populated: T) -> Result<Value, String> {
573 serde_json::to_value(populated).map_err(|e| format!("building the template: {e}"))
574}
575
576fn response_envelope() -> ExternalResponse<Value> {
582 ExternalResponse {
583 ok: true,
584 result: Some(Value::Null),
585 error: Some(String::new()),
586 }
587}
588
589fn provision_report() -> ProvisionReport {
590 let one = || vec![String::new()];
591 ProvisionReport {
592 created_fields: one(),
593 updated_fields: one(),
594 created_tags: one(),
595 created_object_types: one(),
596 created_object_fields: one(),
597 updated_object_fields: one(),
598 deprecated_object_types: one(),
599 deprecated_object_fields: one(),
600 deleted_object_types: one(),
601 deleted_object_fields: one(),
602 }
603}
604
605fn apply_report() -> ApplyReport {
606 ApplyReport {
607 applied: vec![applied_op()],
608 resumed: vec![applied_op()],
609 previously_applied_count: Some(0),
610 provision: provision_report(),
611 }
612}
613
614fn applied_op() -> AppliedOp {
615 AppliedOp {
616 uid: Default::default(),
617 type_name: TypeName::new(""),
618 backend_id: Some(BackendId::Int(0)),
619 }
620}
621
622fn observed_object() -> ExternalObject {
625 ExternalObject {
626 type_name: TypeName::new(""),
627 key: Default::default(),
628 attrs: Default::default(),
629 backend_id: Some(BackendId::Int(0)),
630 }
631}
632
633fn reject_unknown_keys(template: &Value, payload: &Value, path: &str) -> Result<(), String> {
636 match (template, payload) {
637 (Value::Object(known), Value::Object(got)) if !known.is_empty() => {
638 for (key, value) in got {
639 let path = at(path, key);
640 let nested = known
641 .get(key)
642 .ok_or_else(|| format!("unknown key {path}"))?;
643 reject_unknown_keys(nested, value, &path)?;
644 }
645 Ok(())
646 }
647 (Value::Array(known), Value::Array(got)) => match known.first() {
649 Some(element) => got.iter().enumerate().try_for_each(|(index, value)| {
650 reject_unknown_keys(element, value, &format!("{path}[{index}]"))
651 }),
652 None => Ok(()),
653 },
654 _ => Ok(()),
655 }
656}
657
658fn at(path: &str, key: &str) -> String {
660 if path.is_empty() {
661 key.to_string()
662 } else {
663 format!("{path}.{key}")
664 }
665}
666
667struct RunResult {
669 timed_out: bool,
670 status: Option<ExitStatus>,
671 stdout: Vec<u8>,
672 stderr: Vec<u8>,
673}
674
675fn status_label(run: &RunResult) -> String {
677 if run.timed_out {
678 "timed out".to_string()
679 } else {
680 match run.status {
681 Some(status) => status.to_string(),
682 None => "did not start".to_string(),
683 }
684 }
685}
686
687fn request_bytes(value: &Value) -> Vec<u8> {
689 value.to_string().into_bytes()
690}
691
692fn run_once(adapter: &[String], timeout: Duration, stdin: &[u8]) -> RunResult {
694 let mut child = match Command::new(&adapter[0])
695 .args(&adapter[1..])
696 .stdin(Stdio::piped())
697 .stdout(Stdio::piped())
698 .stderr(Stdio::piped())
699 .spawn()
700 {
701 Ok(child) => child,
702 Err(e) => {
703 return RunResult {
704 timed_out: false,
705 status: None,
706 stdout: Vec::new(),
707 stderr: format!("could not start adapter: {e}").into_bytes(),
708 }
709 }
710 };
711
712 let stdout = child.stdout.take().map(drain);
713 let stderr = child.stderr.take().map(drain);
714 if let Some(pipe) = child.stdin.take() {
715 feed(pipe, stdin.to_vec());
716 }
717
718 let deadline = Instant::now() + timeout;
719 let mut timed_out = false;
720 let status = loop {
721 match child.try_wait() {
722 Ok(Some(status)) => break Some(status),
723 Ok(None) => {
724 if Instant::now() >= deadline {
725 let _ = child.kill();
726 let _ = child.wait();
727 timed_out = true;
728 break None;
729 }
730 thread::sleep(Duration::from_millis(20));
731 }
732 Err(_) => break None,
733 }
734 };
735
736 let drain_deadline = Instant::now() + DRAIN_GRACE;
739 RunResult {
740 timed_out,
741 status,
742 stdout: stdout
743 .map(|rx| collect(rx, drain_deadline))
744 .unwrap_or_default(),
745 stderr: stderr
746 .map(|rx| collect(rx, drain_deadline))
747 .unwrap_or_default(),
748 }
749}
750
751fn feed<W: Write + Send + 'static>(mut pipe: W, bytes: Vec<u8>) {
753 thread::spawn(move || {
754 let _ = pipe.write_all(&bytes);
757 });
758}
759
760const DRAIN_GRACE: Duration = Duration::from_secs(1);
765
766fn drain<R: Read + Send + 'static>(mut pipe: R) -> mpsc::Receiver<Vec<u8>> {
769 let (tx, rx) = mpsc::channel();
770 thread::spawn(move || {
771 let mut buf = Vec::new();
772 let _ = pipe.read_to_end(&mut buf);
773 let _ = tx.send(buf);
774 });
775 rx
776}
777
778fn collect(rx: mpsc::Receiver<Vec<u8>>, deadline: Instant) -> Vec<u8> {
784 let remaining = deadline.saturating_duration_since(Instant::now());
785 rx.recv_timeout(remaining).unwrap_or_default()
786}
787
788#[cfg(test)]
789mod tests {
790 use super::{load_cases, Case, Expect};
791 use std::path::Path;
792 use tempfile::tempdir;
793
794 fn examples() -> &'static Path {
795 Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/examples/cases"))
796 }
797
798 #[test]
799 fn a_case_rejects_an_unknown_key() {
800 let err = serde_json::from_str::<Case>(
801 r#"{"name": "c", "requst": {}, "request": {}, "expect": {"ok": true}}"#,
802 )
803 .unwrap_err()
804 .to_string();
805 assert!(err.contains("requst"), "{err}");
806 }
807
808 #[test]
809 fn an_expectation_rejects_an_unknown_key() {
810 for typo in ["errror", "reslt"] {
813 let err = serde_json::from_str::<Expect>(&format!(r#"{{"ok": false, "{typo}": "x"}}"#))
814 .unwrap_err()
815 .to_string();
816 assert!(err.contains(typo), "{err}");
817 }
818 }
819
820 #[test]
821 fn an_expectation_still_accepts_ok_result_and_error() {
822 let expect: Expect =
823 serde_json::from_str(r#"{"ok": false, "result": null, "error": "boom"}"#).unwrap();
824 assert!(!expect.ok);
825 assert_eq!(expect.error.as_deref(), Some("boom"));
826 }
827
828 #[test]
829 fn the_committed_fixtures_still_load() {
830 let cases = load_cases(examples()).unwrap();
831 assert_eq!(cases.len(), 6, "every committed fixture must still parse");
832 }
833
834 #[test]
835 fn an_empty_case_directory_loads_no_cases() {
836 let dir = tempdir().expect("create case dir");
837 assert!(load_cases(dir.path()).unwrap().is_empty());
838 }
839
840 #[test]
841 fn a_case_directory_holding_no_json_files_loads_no_cases() {
842 let dir = tempdir().expect("create case dir");
843 std::fs::write(dir.path().join("read.yaml"), "name: read").expect("write case");
844 assert!(load_cases(dir.path()).unwrap().is_empty());
845 }
846
847 #[test]
848 fn cases_one_directory_down_are_not_loaded() {
849 let dir = tempdir().expect("create case dir");
850 let nested = dir.path().join("netbox");
851 std::fs::create_dir(&nested).expect("create subdirectory");
852 std::fs::copy(
853 examples().join("delete-unsupported.json"),
854 nested.join("delete-unsupported.json"),
855 )
856 .expect("copy fixture");
857 assert!(load_cases(dir.path()).unwrap().is_empty());
858 }
859}