Skip to main content

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}