Skip to main content

olai_stack_state/
transfer.rs

1//! Import and export: `trestle-workspace.toml` and environment directories as an exchange
2//! format for the store.
3
4use std::collections::BTreeMap;
5use std::path::{Component, Path};
6
7use olai_stack_topology::{EnvManifest, WORKSPACE_FILE, WorkspaceEnv, WorkspaceManifest};
8
9use crate::error::{Error, Result};
10use crate::model::{Draft, InstanceId, Workspace};
11use crate::store::Store;
12
13/// The environment manifest's file name inside an environment directory.
14pub const ENV_FILE: &str = "env.toml";
15
16impl Store {
17    /// Import the workspace directory `dir` (a `trestle-workspace.toml` and one environment
18    /// directory per member) as a new workspace. Each member becomes an environment whose
19    /// draft is its `env.toml` and the module manifests it lists, committed as revision 1.
20    ///
21    /// Slots are not imported: the store leases them per instance, host-wide. Nor is
22    /// `secrets.env`: the store holds secret references, not values.
23    pub fn import_workspace(&self, dir: &Path) -> Result<Workspace> {
24        let path = dir.join(WORKSPACE_FILE);
25        let manifest = WorkspaceManifest::read_from(&path)?;
26        let workspace = self.create_workspace(&manifest.name, manifest.mesh)?;
27        let imported = (|| {
28            for member in &manifest.envs {
29                let draft = read_env_dir(&dir.join(&member.path))?;
30                let env = self.create_environment(&workspace.id, &member.name, draft)?;
31                self.commit_draft(&env.id)?;
32            }
33            Ok(())
34        })();
35        if let Err(e) = imported {
36            // All or nothing: drop what the failed import created.
37            let _ = self.delete_workspace(&workspace.id);
38            return Err(e);
39        }
40        Ok(workspace)
41    }
42
43    /// Export `instances` (all of one workspace, each of a different environment) as a
44    /// workspace directory in `dir`: a `trestle-workspace.toml` whose members sit on the slots
45    /// the instances lease, and per member its revision's `env.toml` and module manifests. In
46    /// the exported directory `trestle env render` and `docker compose up` work as in any
47    /// workspace.
48    pub fn export_instances(&self, instances: &[InstanceId], dir: &Path) -> Result<()> {
49        let mut workspace: Option<WorkspaceManifest> = None;
50        let mut members = Vec::new();
51        for id in instances {
52            let instance = self.instance(id)?;
53            let env = self.environment(&instance.environment)?;
54            let ws = self.workspace_by_id(&env.workspace)?;
55            let manifest = workspace.get_or_insert_with(|| WorkspaceManifest {
56                version: WorkspaceManifest::current_version(),
57                name: ws.name.clone(),
58                mesh: ws.mesh,
59                envs: Vec::new(),
60            });
61            if manifest.name != ws.name {
62                return Err(Error::Export(format!(
63                    "the instances belong to two workspaces, `{}` and `{}`",
64                    manifest.name, ws.name
65                )));
66            }
67            let slot = instance.slot.ok_or_else(|| {
68                Error::Export(format!("instance {id} holds no slot; lease one first"))
69            })?;
70            manifest.envs.push(WorkspaceEnv {
71                name: env.name.clone(),
72                path: env.name.clone(),
73                slot,
74            });
75            members.push((
76                env.name,
77                self.revision(&instance.environment, instance.revision)?,
78            ));
79        }
80        let Some(workspace) = workspace else {
81            return Err(Error::Export("no instances given".into()));
82        };
83        // Two instances of one environment would be two members of one name.
84        workspace.validate()?;
85
86        for (name, revision) in &members {
87            let env_dir = dir.join(name);
88            for (rel, text) in revision.modules() {
89                write(&env_dir.join(safe_relative(rel)?), text)?;
90            }
91            write(&env_dir.join(ENV_FILE), revision.manifest_toml())?;
92        }
93        write(&dir.join(WORKSPACE_FILE), &workspace.to_toml()?)
94    }
95}
96
97/// An environment directory's `env.toml` and the module manifests it lists, as a draft.
98pub fn read_env_dir(dir: &Path) -> Result<Draft> {
99    let manifest = EnvManifest::read_from(&dir.join(ENV_FILE))?;
100    let mut modules = BTreeMap::new();
101    for rel in &manifest.catalog.manifests {
102        let path = dir.join(rel);
103        let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
104        modules.insert(rel.clone(), text);
105    }
106    Ok(Draft {
107        manifest,
108        modules,
109        vars: Default::default(),
110    })
111}
112
113/// `rel` if it stays inside the directory it is relative to.
114fn safe_relative(rel: &str) -> Result<&Path> {
115    let path = Path::new(rel);
116    if path
117        .components()
118        .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
119    {
120        Ok(path)
121    } else {
122        Err(Error::Export(format!(
123            "module manifest path `{rel}` leaves the environment directory"
124        )))
125    }
126}
127
128fn write(path: &Path, contents: &str) -> Result<()> {
129    if let Some(parent) = path.parent() {
130        std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
131    }
132    std::fs::write(path, contents).map_err(|e| Error::io(path, e))
133}