autoschematic_core/connector.rs
1use std::{
2 collections::HashMap,
3 ffi::OsString,
4 fs::File,
5 io::BufReader,
6 path::{Component, Path, PathBuf},
7 sync::Arc,
8};
9
10use anyhow::bail;
11use documented::{Documented, DocumentedFields};
12use serde::{Deserialize, Serialize};
13
14use async_trait::async_trait;
15
16use crate::{bundle::UnbundleResponseElement, macros::FieldTypes, template::ReadOutput};
17
18pub use crate::diag::DiagnosticResponse;
19
20/// ConnectorOps output by Connector::plan() may declare a set of output values
21/// that they will set or delete on execution.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub enum OutputValuePlan {
24 Set,
25 Delete,
26}
27
28pub type OutputValueExec = Option<String>;
29
30pub type OutputMapPlan = HashMap<String, OutputValuePlan>;
31pub type OutputMapExec = HashMap<String, OutputValueExec>;
32
33pub type OutputMap = HashMap<String, String>;
34
35#[derive(Serialize, Deserialize)]
36pub enum OutputMapFile {
37 PointerToVirtual(PathBuf),
38 OutputMap(OutputMap),
39}
40
41impl OutputMapFile {
42 pub fn path(prefix: &Path, addr: &Path) -> PathBuf {
43 let mut output = PathBuf::from(".autoschematic");
44 // let mut output = prefix.to_path_buf();
45
46 output.push(prefix);
47
48 // Join the parent portion of `addr`, if it exists
49 if let Some(parent) = addr.parent() {
50 // Guard against pathological cases like ".." or "." parents
51 // by only pushing normal components
52 for comp in parent.components() {
53 if let Component::Normal(_) = comp {
54 output.push(comp)
55 }
56 }
57 }
58
59 let mut new_filename = OsString::new();
60 if let Some(fname) = addr.file_name() {
61 new_filename.push(fname);
62 } else {
63 // If there's no file name at all, we'll just use ".out.json"
64 // so `new_filename` right now is just "." - that's fine.
65 // We'll end up producing something like "./office/east/ec2/us-east-1/.out.json"
66 }
67 new_filename.push(".out.json");
68
69 output.push(new_filename);
70
71 output
72 }
73
74 pub fn read(prefix: &Path, addr: &Path) -> anyhow::Result<Option<Self>> {
75 let output_path = Self::path(prefix, addr);
76
77 if output_path.is_file() {
78 let file = File::open(&output_path)?;
79 let reader = BufReader::new(file);
80
81 let output: Self = serde_json::from_reader(reader)?;
82
83 return Ok(Some(output));
84 }
85
86 Ok(None)
87 }
88
89 pub fn read_recurse(prefix: &Path, addr: &Path) -> anyhow::Result<Option<Self>> {
90 let output_path = Self::path(prefix, addr);
91
92 if output_path.is_file() {
93 let file = File::open(&output_path)?;
94 let reader = BufReader::new(file);
95
96 let output: Self = serde_json::from_reader(reader)?;
97
98 match &output {
99 OutputMapFile::PointerToVirtual(virt_addr) => {
100 return Self::read_recurse(prefix, virt_addr);
101 }
102 OutputMapFile::OutputMap(_) => return Ok(Some(output)),
103 }
104 }
105
106 Ok(None)
107 }
108
109 pub fn write(&self, prefix: &Path, addr: &Path) -> anyhow::Result<PathBuf> {
110 let output_path = Self::path(prefix, addr);
111
112 let contents = serde_json::to_string_pretty(self)?;
113
114 if let Some(parent) = output_path.parent() {
115 std::fs::create_dir_all(parent)?;
116 }
117 if output_path.exists() {
118 std::fs::remove_file(&output_path)?;
119 }
120
121 std::fs::write(&output_path, contents)?;
122
123 Ok(output_path)
124 }
125
126 pub fn write_recurse(&self, prefix: &Path, addr: &Path) -> anyhow::Result<()> {
127 let output_path = Self::path(prefix, addr);
128
129 if output_path.is_file() {
130 let contents = std::fs::read_to_string(&output_path)?;
131
132 let output: Self = serde_json::from_str(&contents)?;
133
134 match &output {
135 OutputMapFile::PointerToVirtual(virtual_address) => {
136 return self.write_recurse(prefix, virtual_address);
137 }
138 OutputMapFile::OutputMap(_) => {
139 if let Some(parent) = output_path.parent() {
140 std::fs::create_dir_all(parent)?;
141 }
142 std::fs::write(&output_path, serde_json::to_string_pretty(self)?)?;
143 }
144 }
145 } else {
146 if let Some(parent) = output_path.parent() {
147 std::fs::create_dir_all(parent)?;
148 }
149 std::fs::write(&output_path, serde_json::to_string_pretty(self)?)?;
150 }
151
152 Ok(())
153 }
154
155 // TODO we should disallow infinite recursive links somehow
156 pub fn resolve(prefix: &Path, addr: &Path) -> anyhow::Result<Option<VirtualAddress>> {
157 let Some(output) = Self::read(prefix, addr)? else {
158 return Ok(None);
159 };
160
161 match output {
162 OutputMapFile::PointerToVirtual(virtual_address) => Self::resolve(prefix, &virtual_address),
163 OutputMapFile::OutputMap(_) => Ok(Some(VirtualAddress(addr.to_path_buf()))),
164 }
165 }
166
167 pub fn get(prefix: &Path, addr: &Path, key: &str) -> anyhow::Result<Option<String>> {
168 let Some(output) = Self::read(prefix, addr)? else {
169 return Ok(None);
170 };
171
172 match output {
173 OutputMapFile::PointerToVirtual(virtual_address) => Self::get(prefix, &virtual_address, key),
174 OutputMapFile::OutputMap(map) => Ok(map.get(key).cloned()),
175 }
176 }
177
178 pub fn apply_output_map(prefix: &Path, addr: &Path, output_map_exec: &OutputMapExec) -> anyhow::Result<Option<PathBuf>> {
179 let original = Self::read_recurse(prefix, addr)?.unwrap_or(OutputMapFile::OutputMap(HashMap::new()));
180
181 let OutputMapFile::OutputMap(mut original_map) = original else {
182 bail!(
183 "apply_output_map({}, {}): resolved to a link file!",
184 prefix.display(),
185 addr.display()
186 );
187 };
188
189 for (key, value) in output_map_exec {
190 match value {
191 Some(value) => {
192 original_map.insert(key.clone(), value.clone());
193 }
194 None => {
195 original_map.remove(key);
196 }
197 }
198 }
199
200 if original_map.is_empty() {
201 Ok(None)
202 } else {
203 OutputMapFile::OutputMap(original_map).write_recurse(prefix, addr)?;
204 Ok(Some(Self::path(prefix, addr)))
205 }
206 }
207
208 pub fn write_link(prefix: &Path, phy_addr: &Path, virt_addr: &Path) -> anyhow::Result<PathBuf> {
209 let output_map = Self::PointerToVirtual(virt_addr.to_path_buf());
210 output_map.write(prefix, phy_addr)?;
211 Ok(Self::path(prefix, phy_addr))
212 }
213
214 pub fn delete(prefix: &Path, addr: &Path) -> anyhow::Result<Option<PathBuf>> {
215 let path = Self::path(prefix, addr);
216 if path.is_file() {
217 std::fs::remove_file(&path)?;
218 Ok(Some(path))
219 } else {
220 Ok(None)
221 }
222 }
223}
224
225pub mod handle;
226pub mod shutdown;
227pub mod spawn;
228pub mod task_registry;
229
230#[bitmask_enum::bitmask(u32)]
231#[derive(Serialize, Deserialize)]
232pub enum FilterResponse {
233 Config,
234 Resource,
235 Bundle,
236 Task,
237 Metric,
238 None = 0b0,
239}
240
241#[derive(Clone, Debug, Serialize, Deserialize)]
242pub struct VirtualAddress(pub PathBuf);
243
244#[derive(Clone, Debug, Serialize, Deserialize)]
245pub struct PhysicalAddress(pub PathBuf);
246
247#[derive(Debug, Serialize, Deserialize)]
248/// GetResourceResponse represents the successful result of Connector.get(addr),
249/// where a resource exists at `addr` that is fetched by the connector.
250pub struct GetResourceResponse {
251 /// The on-disk (serialized) representation of this resource.
252 pub resource_definition: Vec<u8>,
253 /// The virtual address (if a canonical one can be derived) of this resource.
254 /// E.G. a Grafana dashboard's physical address is based on its uid, but its virtual address on
255 /// import might be hinted based on the dashboard's title.
256 /// AWS resources might read a `name` (or configurable) tag to suggest a virtual address here on import,
257 pub virt_addr: Option<PathBuf>,
258 /// Outputs (e.g. canonical resource id, vpc_id, subnet_id...) to set here.
259 pub outputs: Option<OutputMap>,
260}
261
262impl GetResourceResponse {
263 /// Write the contents of this GetResourceResponse to disk. Assumes that the caller has the current
264 /// directory set to the repo root.
265 /// Returns a Vec of the file paths that were actually written.
266 pub async fn write(self, prefix: &Path, phy_addr: &Path, virt_addr: &Path) -> anyhow::Result<Vec<PathBuf>> {
267 let virt_addr = if phy_addr == virt_addr
268 && let Some(ref self_virt_addr) = self.virt_addr
269 {
270 self_virt_addr
271 } else {
272 virt_addr
273 };
274
275 let mut res = Vec::new();
276
277 let body = self.resource_definition;
278 let res_path = prefix.join(virt_addr);
279
280 if let Some(parent) = res_path.parent() {
281 tokio::fs::create_dir_all(parent).await?;
282 }
283
284 tokio::fs::write(&res_path, body).await?;
285
286 res.push(res_path);
287
288 if let Some(outputs) = self.outputs
289 && !outputs.is_empty()
290 {
291 let output_map_file = OutputMapFile::OutputMap(outputs);
292 res.push(output_map_file.write(prefix, virt_addr)?);
293
294 if virt_addr != phy_addr {
295 res.push(OutputMapFile::write_link(prefix, phy_addr, virt_addr)?);
296 }
297 }
298
299 Ok(res)
300 }
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304/// DocIdent represents the target of Connector::GetDocstring().
305/// It is used by connector implementations to determine which specific item to return documentation for,
306/// whether it be a struct or a field of a struct.
307pub enum DocIdent {
308 Struct { name: String },
309 // Enum { name: String },
310 EnumVariant { parent: String, name: String },
311 Field { parent: String, name: String },
312}
313
314#[derive(Debug, Serialize, Deserialize)]
315/// GetDocResponse represents the successful result of Connector.get_docstring(ident).
316/// This represents the Docstring or other documentation corresponding to
317/// structs or enums used in resource bodies.
318/// Just like Connector::diag(), it is intended for use with autoschematic-lsp
319/// to help users write resource bodies manually.
320pub struct GetDocResponse {
321 pub r#type: String,
322 pub markdown: String,
323 pub fields: Vec<String>,
324}
325
326impl GetDocResponse {
327 pub fn from_documented<T: Documented + DocumentedFields>() -> Self {
328 Self {
329 r#type: std::any::type_name::<T>().into(),
330 markdown: T::DOCS.to_string(),
331 fields: T::FIELD_NAMES.iter().map(|s| String::from(*s)).collect(),
332 }
333 }
334
335 pub fn from_documented_field<T: FieldTypes + DocumentedFields>(field: &str) -> Result<Self, documented::Error> {
336 Ok(Self {
337 r#type: T::field_type(field).unwrap_or_default().to_string(),
338 markdown: T::get_field_docs(field)?.to_string(),
339 fields: Vec::new(),
340 })
341 }
342}
343
344impl From<&'static str> for GetDocResponse {
345 fn from(value: &'static str) -> Self {
346 Self {
347 r#type: String::new(),
348 markdown: value.to_string(),
349 fields: Vec::new(),
350 }
351 }
352}
353
354#[derive(Debug, Serialize, Deserialize, Clone)]
355/// PlanResponseElement represents the successful result of `Connector.plan(addr, current, desired)``.
356/// Specifically, Connector.plan(...) will return a list of one or more PlanResponseElements representing
357/// a sequence of steps to take such that ideally, Connector.get(addr) == desired after executing
358/// each of those steps.
359pub struct PlanResponseElement {
360 pub op_definition: String,
361 pub writes_outputs: Vec<String>,
362 pub friendly_message: Option<String>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
366/// OpExecResponse represents the result of a Connector successfully executing a ConnectorOp.
367/// Where a ConnectorOp may, for example, return the ID of a created resource,
368/// OpExecResponse may be used to store that ID in `outputs`, where it will be
369/// saved and committed as {addr}.output.json file adjacent to the addr at which the.
370pub struct OpExecResponse {
371 pub outputs: Option<OutputMapExec>,
372 pub friendly_message: Option<String>,
373}
374
375#[derive(Debug, Default, Clone, Serialize, Deserialize)]
376/// TaskExecResponse represents the result of a Connector successfully executing a Task.
377pub struct TaskExecResponse {
378 /// The next value of `state` with which to call task_exec(...) next time. If None, the task is not executed again.
379 pub next_state: Option<Vec<u8>>,
380 /// If a task modifies a file on-disk within its repository, it can let the client know
381 /// by including it here.
382 /// Paths should be relative to the root of the repository.
383 pub modified_files: Option<Vec<PathBuf>>,
384 /// Task files, like Resource files, can have associated outputs. Outputs returned here are merged into the task's
385 /// output file.
386 pub outputs: Option<HashMap<String, Option<String>>>,
387 /// Tasks may also return secret values for the runtime to optionally seal and write to disk if desired
388 pub secrets: Option<HashMap<PathBuf, Option<String>>>,
389 /// Each task_exec phase can return a friendly human-readable message detailing its state.
390 pub friendly_message: Option<String>,
391 /// Delay the next task_exec phase until at least `delay_until` seconds after the UNIX epoch
392 pub delay_until: Option<u64>,
393}
394
395#[derive(Debug, Serialize, Deserialize)]
396/// SkeletonResponse represents a template of a resource managed by a Connector.
397/// A connector can return multiple skeletons through get_skeletons() in order to
398/// provide the user with a set of templates to quickly scaffold new resources, or as
399/// examples of the kinds of resources that the user can instantiate and manage through the connector.
400pub struct SkeletonResponse {
401 pub addr: PathBuf,
402 pub body: Vec<u8>,
403}
404
405/// ConnectorOutbox is primarily used to transmit logs for tracestores across
406/// the remote bridge, i.e. tarpc. The usefulness of this may be reexamined later.
407pub type ConnectorOutbox = tokio::sync::broadcast::Sender<Option<OsString>>;
408pub type ConnectorInbox = tokio::sync::broadcast::Receiver<Option<OsString>>;
409
410#[derive(Debug, Serialize, Deserialize)]
411/// VirtToPhyResponse represents the result of Connector::addr_virt_to_phy(addr).
412/// Where a connector implementation may map a "virtual" name, to, for instance, route table ID within
413/// a VPC, or an EC2 instance ID. This allows resources to be created within a repo and named or laid out
414/// ahead of their actual creation, even though their "canonical" address, their instance ID etc,
415/// is not known until after creation.
416pub enum VirtToPhyResponse {
417 /// The resource is defined at a "virtual" address, but its physical address is not populated
418 /// because it does not exist yet. For example, an EC2 instance may have been
419 /// drafted in the repository, but because it doesn't exist yet, its physical address
420 /// (in essence its EC2 instance ID) is undefined.
421 NotPresent,
422 /// The resource is defined at a "virtual" address, but its physical address is not populated
423 /// because it does not exist yet, and in addition, its physical address relies on a
424 /// parent resource that also does not exist yet.
425 /// For example, a new subnet within a new VPC may have been
426 /// drafted in the repository, but because the VPC does not exist yet,
427 /// the subnet is "deferred". It cannot even be planned until the parent VPC exists.
428 Deferred(Vec<ReadOutput>),
429 /// The virtual address resolved successfully to a physical address.
430 /// For example, an EC2 instance within a repository exists and resolved to its canonical instance-id-derived address.
431 /// E.G: aws/ec2/instances/main_supercomputer_1.ron
432 /// -> aws/ec2/instances/i-398180832.ron
433 Present(PathBuf),
434 /// For virtual addresses that have no need to map to physical addresses, this represents that trivial mapping.
435 Null(PathBuf),
436}
437
438#[async_trait]
439#[allow(clippy::new_ret_no_self)]
440pub trait Connector: Send + Sync {
441 /// Attempt to create a new, uninitialized Connector mounted at `prefix`.
442 /// Returns `dyn Connector` to allow implementations to dynamically select Connectors by name.
443 /// Should not fail due to invalid config - Connector::init() should handle that.
444 async fn new(name: &str, prefix: &Path, outbox: ConnectorOutbox) -> Result<Arc<dyn Connector>, anyhow::Error>
445 where
446 Self: Sized;
447
448 /// Attempt to initialize, or reinitialize, a Connector.
449 /// This will read from environment variables, config files, etc and
450 /// may fail on invalid configuration.
451 /// Methods like Connector::eq() and Connector::diag() may
452 /// still be possible even when uninitialized or when
453 /// `Connector::init()` has failed.
454 async fn init(&self) -> Result<(), anyhow::Error>;
455
456 /// For a given file within a prefix, this function determines if that file
457 /// corresponds to a resource managed by this connector, a configuration file
458 /// controlling this connector, or neither.
459 /// In essence, this decides on the subset of the address space that the connector
460 /// will manage, where "address space" is the nested hierarchy of files.
461 /// If `addr` falls within the resource address space of this connector, return `FilterResponse::Resource`.
462 /// If `addr` is a configuration file for this connector, return `FilterResponse::Config`.
463 /// Otherwise, return `FilterResponse::None`.
464 /// filter() is cached by the upstream client, but that cache is reset if
465 /// a file is modified for which connector.filter() returns `FilterResponse::Config`.
466 /// So, filter() can read config files to, say, have multiple k8s clusters under its management,
467 /// or multiple SSH hosts to manage files over sshfs, and thus dynamically determine
468 /// whether, say, sshfs/hosts/example.com/var/www/some_file.txt is `Resource` or `None`
469 /// (depending on whether example.com is listed in its config file or not),
470 /// but if it implements this behaviour, it must declare that config file by returning 'CONFIG' for the config file path.
471 async fn filter(&self, addr: &Path) -> Result<FilterResponse, anyhow::Error>;
472
473 /// List all "extant" (E.G., currently existing in AWS, k8s, etc...) object paths, whether they exist in local config or not.
474 /// subpath is used to constrain the space of queried results.
475 /// The subpath "./" applies no constraint. Connectors may choose interpret subpath in order to
476 /// E.G. avoid redundant network queries, but there is no requirement that they do so.
477 /// The upstream client will automatically handle more fine-grained filtering.
478 /// See [addr_matches_filter] for the implementation of that filtering.
479 /// For example, if the subpath is "aws/vpc/us-east-2/vpcs/", the AWS VPC connector might choose to parse
480 /// that subpath up to "aws/vpc/us-east-2/" and only run the list queries for resources in the us-east-2 region.
481 /// It will still return a superset of the resources specified by the full subpath, but a subset of those
482 /// specified by "./" - again, clients will do the more fine-grained filtering themselves after the fact.
483 /// Connectors inform clients about how deeply they'll parse `subpath` through the `Connector::subpaths()` function.
484 async fn list(&self, subpath: &Path) -> anyhow::Result<Vec<PathBuf>>;
485
486 /// Describes how the connector's list() implementation orthogonally subdivides the address space in order to
487 /// more efficiently parallelize imports spanning large address spaces.
488 /// For example, the AWS VPC connector's list() implementation might
489 /// return ["aws/vpc/us-east-1", "aws/vpc/us-east-2", ...]
490 /// in order to represent to the client that it can efficiently run parallel list() operations
491 /// under those subpaths. Then, the list() implementation must guarantee that it can
492 /// correctly parse and limit its querying to each subset of the address space.
493 /// The implementation could even be more fine-grained and return, for instance,
494 /// ["aws/vpc/us-east-1/vpcs", "aws/vpc/us-east-1/internet_gateways", "aws/vpc/us-east-2/vpcs", "aws/vpc/us-east-2/internet_gateways"]
495 /// if it can do even deeper parsing, but this example would likely see diminishing returns.
496 async fn subpaths(&self) -> anyhow::Result<Vec<PathBuf>> {
497 Ok(vec![PathBuf::from("./")])
498 }
499
500 /// Get the current "real" state of the object at `addr`.
501 /// For instance, get("aws/vpc/us-east-1/vpcs/vpc-0348895.ron") would query the AWS API for the
502 /// current state of the vpc with that ID in the us-east-1 region in the account configured,
503 /// and form the human-readable (code) representation as contained in the .ron file.
504 /// Note that get() only takes physical addresses - the addr_virt_to_phy is always carried out by the
505 /// client ahead of time where needed.
506 async fn get(&self, addr: &Path) -> Result<Option<GetResourceResponse>, anyhow::Error>;
507
508 /// Determine how to set current -> desired.
509 /// Returns a sequence of Ops that can be executed by op_exec.
510 /// This function essentially takes,for a given resource address
511 /// the current state as returned by `Connector::get(addr)`, (or none if that resource does not exist),
512 /// as well as the desired state (as defined by the state of the file at `./${prefix}/${addr}` on disk).
513 /// It will return a set of ConnectorOps which, when passed to op_exec and executed successfully in sequence, should
514 /// result in Connector::get(addr)? == desired .
515 /// Note that if desired is None, and current is Some(...), this indicates deleting the remote resource at addr.
516 async fn plan(
517 &self,
518 addr: &Path,
519 current: Option<Vec<u8>>,
520 desired: Option<Vec<u8>>,
521 ) -> Result<Vec<PlanResponseElement>, anyhow::Error>;
522
523 /// Execute a ConnectorOp.
524 /// OpExecResponse may include output files, containing, for example,
525 /// the resultant IDs of created resources such as EC2 instances or VPCs.
526 /// This will be stored at ./{prefix}/{addr}.out.json,
527 /// or merged if already present.
528 async fn op_exec(&self, addr: &Path, op: &str) -> Result<OpExecResponse, anyhow::Error>;
529
530 /// For resources like VPCs whose ID cannot be known until after creation,
531 /// we allow returning the resultant vpc_id in outputs after get() or op_exec().
532 /// This allows connectors to translate this mapping and resolve a "virtual" path, with a
533 /// user-provided "fake" ID (like a human-readable name), into a physical path, with the actual canonical resource ID.
534 /// For example, if we created a VPC with virtual addr "aws/vpc/eu-west-2/vpcs/main.ron",
535 /// then after creation, we'd have two output files:
536 /// `.outputs/aws/vpc/eu-west-2/vpcs/vpc-038598204.ron` -> PointerToVirtual("aws/vpc/eu-west-2/vpcs/main.ron")
537 /// `.outputs/aws/vpc/eu-west-2/vpcs/main.ron` -> OutputMap({vpc_id: "vpc-038598204", ...})
538 /// addr_virt_to_phy is where connectors define this mapping: that the physical address can be formed from the
539 /// virtual address by reading the output map and substituting certain path components.
540 /// Most connectors shouldn't need to override this.
541 async fn addr_virt_to_phy(&self, addr: &Path) -> Result<VirtToPhyResponse, anyhow::Error> {
542 Ok(VirtToPhyResponse::Null(addr.into()))
543 }
544
545 /// Reverses the mapping from addr_virt_to_phy. Usually this just means traversing the resource
546 /// hierarchy for each parent resource and resolving it with ResourceAddress::phy_to_virt.
547 /// See the AWS VpcConnector for an example implementation.
548 /// Most connectors shouldn't need to implement this and can just return `addr`.
549 /// `None` indicates a failure to resolve the address, such as a dangling output file at a physical address
550 /// with a PointerToVirtual entry that no longer exists.
551 async fn addr_phy_to_virt(&self, addr: &Path) -> anyhow::Result<Option<PathBuf>> {
552 Ok(Some(addr.into()))
553 }
554
555 /// To aid development, connectors can provide the user with a set of
556 /// "skeleton" resources outlining each type of resource managed by the connector.
557 /// Each skeleton resource has an address with `[square_brackets]` for the variable portions,
558 /// and the body of the resource should serve as a valid example instance of the resource.
559 async fn get_skeletons(&self) -> anyhow::Result<Vec<SkeletonResponse>> {
560 Ok(Vec::new())
561 }
562
563 /// Connectors may additionally serve docstrings. This is intended to aid development
564 /// from an IDE or similar, with a language server hooking into connectors on hover.
565 async fn get_docstring(&self, _addr: &Path, _ident: DocIdent) -> anyhow::Result<Option<GetDocResponse>> {
566 Ok(None)
567 }
568
569 /// Corresponds to an implementation of PartialEq for the underlying resource types
570 /// as parsed by the connector.
571 /// This is used in, for example, pull-state, in order to determine if local state needs to be updated
572 /// to match remote state.
573 /// The default implementation simply compares strings, without serializing or parsing in any way.
574 /// addr is ignored in this default case.
575 async fn eq(&self, _addr: &Path, a: &[u8], b: &[u8]) -> anyhow::Result<bool> {
576 Ok(a == b)
577 }
578
579 /// If a resource at `addr` with body `a` fails to parse, connectors may return diagnostics
580 /// that outline where the parsing failed with error information.
581 /// This is intended to aid development from an IDE or similar, with a language server hooking into connectors.
582 async fn diag(&self, _addr: &Path, _a: &[u8]) -> anyhow::Result<Option<DiagnosticResponse>> {
583 Ok(None)
584 }
585
586 /// Where a Connector or Bundle implementation may define a bundle, with its associated ResourceAddress and Resource formats,
587 /// This is where that bundle will be unpacked into one or more resources.
588 async fn unbundle(&self, _addr: &Path, _bundle: &[u8]) -> anyhow::Result<Vec<UnbundleResponseElement>> {
589 Ok(Vec::new())
590 }
591
592 /// task_exec represents the entrypoint, or continuation point, of a Connector's
593 /// custom stateful task implementation. Connectors can implement imperative workflows associated with resources,
594 /// or as standalone task bodies, by returning `FilterResponse::Task | ... ` in their filter() implementation.
595 /// `arg` sets the initial argument for the task. `arg` is set to None after the first execution.
596 /// state always starts as None when a task is first executed.
597 /// Once invoked by a user, a connector client will repeatedly call task_exec until it returns next_state = None within `TaskExecResponse`,
598 /// or throws an error. In this way, connectors store no state - only the clients.
599 async fn task_exec(
600 &self,
601 _addr: &Path,
602 _body: Vec<u8>,
603
604 _arg: Option<Vec<u8>>,
605 _state: Option<Vec<u8>>,
606 ) -> anyhow::Result<TaskExecResponse> {
607 Ok(TaskExecResponse::default())
608 }
609
610 /// Design: TODO: How shall we define the GetMetricResponse enum?
611 /// Again, how will metrics be stored by the server and queried?
612 async fn list_metrics(&self, _addr: &Path) -> anyhow::Result<Vec<String>> {
613 Ok(Vec::new())
614 }
615
616 async fn read_metric(&self, _addr: &Path, _name: &str) -> anyhow::Result<()> {
617 Ok(())
618 }
619
620 async fn version(&self) -> anyhow::Result<String> {
621 Ok(env!("CARGO_PKG_VERSION").to_string())
622 }
623}
624
625// Helper traits for defining custom internal types in Connector implementations.
626// Note that such types are erased by definition at the Connector interface boundary.
627
628/// Resource represents a resource body, either the contents of a file on disk, or
629/// a virtual, remote resource as returned by `Connector::get(addr)`.
630/// Connectors implement implement and consume their own Resource types. The actual
631/// types themselves are erased at the interface between Autoschematic and the Connector implementations
632/// it instantiates.
633/// For example, Connector::plan(addr, current, desired) takes a &Path, Option<&\[u8\]>, Option<&\[u8\]>,
634/// and the connector implementation will parse that as an internal implementation of ResourceAddress, Option<Resource>, Option<Resource>,
635/// in order to produce a Vec of structs that implement ConnectorOp, which it will then pass back in serialized form as Vec<String>.
636/// Then, Connector::op_exec(addr, connector_op) will similarly parse the raw addr and connector_op into its internal implementations of ResourceAddress and ConnectorOp
637/// in order to interpret them and execute an operation.
638pub trait Resource: Send + Sync {
639 fn to_bytes(&self) -> anyhow::Result<Vec<u8>>;
640
641 fn from_bytes(addr: &impl ResourceAddress, s: &[u8]) -> anyhow::Result<Self>
642 where
643 Self: Sized;
644}
645
646/// A ResourceAddress represents a unique identifier addressing a single resource
647/// unambiguously within a prefix. A given ResourceAddress should have a static,
648/// bidirectional mapping to a relative file path.
649/// `{prefix}/{addr.to_path_buf()}` with a given connector configuration should therefore
650/// serve to globally and uniquely identify a particular resource of a particular type.
651pub trait ResourceAddress: Send + Sync + Clone + std::fmt::Debug {
652 /// Produce the path in the repository corresponding to this resource address
653 fn to_path_buf(&self) -> PathBuf;
654
655 /// Produce the resource address corresponding to a path
656 fn from_path(path: &Path) -> anyhow::Result<Self>
657 where
658 Self: Sized;
659
660 fn get_output(&self, prefix: &Path, key: &str) -> anyhow::Result<Option<String>> {
661 let addr = self.to_path_buf();
662 OutputMapFile::get(prefix, &addr, key)
663 }
664
665 fn phy_to_virt(&self, prefix: &Path) -> anyhow::Result<Option<Self>> {
666 let Some(virt_addr) = OutputMapFile::resolve(prefix, &self.to_path_buf())? else {
667 return Ok(None);
668 };
669
670 Ok(Some(Self::from_path(&virt_addr.0)?))
671 }
672}
673
674/// A ConnectorOp represents a single discrete operation that a Connector can execute.
675/// Not all ConnectorOps can be truly idempotent, but they should make efforts to be so.
676/// ConnectorOps should be as granular as the Connector requires.
677/// ConnectorOps are always executed with an associated ResourceAddress
678/// through Connector::op_exec(ResourceAddress, ConnectorOp).
679pub trait ConnectorOp: Send + Sync + std::fmt::Debug {
680 fn to_string(&self) -> anyhow::Result<String>;
681 fn from_str(s: &str) -> anyhow::Result<Self>
682 where
683 Self: Sized;
684 /// A human-readable message in plain english explaining what this connector op will do.
685 /// You should use the imperative mood, for example:
686 /// "Modify tags for IAM role Steve", or
687 /// "Delete VPC vpc-923898 in region us-south-5"
688 fn friendly_plan_message(&self) -> Option<String> {
689 None
690 }
691 /// A human-readable message in plain english explaining what this connector op has just done.
692 /// You should use the past-tense indicative mood, for example:
693 /// "Modified tags for IAM role Steve", or
694 /// "Deleted VPC vpc-923898 in region us-south-5"
695 fn friendly_exec_message(&self) -> Option<String> {
696 None
697 }
698}
699
700#[async_trait]
701impl Connector for Arc<dyn Connector> {
702 async fn new(name: &str, prefix: &Path, outbox: ConnectorOutbox) -> anyhow::Result<Arc<dyn Connector>> {
703 return Self::new(name, prefix, outbox).await;
704 }
705
706 async fn init(&self) -> anyhow::Result<()> {
707 Connector::init(self.as_ref()).await
708 }
709
710 async fn version(&self) -> anyhow::Result<String> {
711 Connector::version(self.as_ref()).await
712 }
713
714 async fn filter(&self, addr: &Path) -> anyhow::Result<FilterResponse> {
715 Connector::filter(self.as_ref(), addr).await
716 }
717
718 async fn list(&self, subpath: &Path) -> anyhow::Result<Vec<PathBuf>> {
719 Connector::list(self.as_ref(), subpath).await
720 }
721
722 async fn subpaths(&self) -> anyhow::Result<Vec<PathBuf>> {
723 Connector::subpaths(self.as_ref()).await
724 }
725
726 async fn get(&self, addr: &Path) -> anyhow::Result<Option<GetResourceResponse>> {
727 Connector::get(self.as_ref(), addr).await
728 }
729
730 async fn plan(
731 &self,
732 addr: &Path,
733 current: Option<Vec<u8>>,
734 desired: Option<Vec<u8>>,
735 ) -> anyhow::Result<Vec<PlanResponseElement>> {
736 Connector::plan(self.as_ref(), addr, current, desired).await
737 }
738
739 async fn op_exec(&self, addr: &Path, op: &str) -> anyhow::Result<OpExecResponse> {
740 Connector::op_exec(self.as_ref(), addr, op).await
741 }
742
743 async fn addr_virt_to_phy(&self, addr: &Path) -> anyhow::Result<VirtToPhyResponse> {
744 Connector::addr_virt_to_phy(self.as_ref(), addr).await
745 }
746
747 async fn addr_phy_to_virt(&self, addr: &Path) -> anyhow::Result<Option<PathBuf>> {
748 Connector::addr_phy_to_virt(self.as_ref(), addr).await
749 }
750
751 async fn get_docstring(&self, addr: &Path, ident: DocIdent) -> anyhow::Result<Option<GetDocResponse>> {
752 Connector::get_docstring(self.as_ref(), addr, ident).await
753 }
754
755 async fn get_skeletons(&self) -> anyhow::Result<Vec<SkeletonResponse>> {
756 Connector::get_skeletons(self.as_ref()).await
757 }
758
759 async fn eq(&self, addr: &Path, a: &[u8], b: &[u8]) -> anyhow::Result<bool> {
760 Connector::eq(self.as_ref(), addr, a, b).await
761 }
762
763 async fn diag(&self, addr: &Path, a: &[u8]) -> anyhow::Result<Option<DiagnosticResponse>> {
764 Connector::diag(self.as_ref(), addr, a).await
765 }
766
767 async fn task_exec(
768 &self,
769 addr: &Path,
770 body: Vec<u8>,
771 arg: Option<Vec<u8>>,
772 state: Option<Vec<u8>>,
773 ) -> anyhow::Result<TaskExecResponse> {
774 Connector::task_exec(self.as_ref(), addr, body, arg, state).await
775 }
776
777 async fn unbundle(&self, addr: &Path, bundle: &[u8]) -> anyhow::Result<Vec<UnbundleResponseElement>> {
778 Connector::unbundle(self.as_ref(), addr, bundle).await
779 }
780}