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, MAX_SLOT, WORKSPACE_FILE, WorkspaceEnv, WorkspaceManifest};
8
9use crate::error::{Error, Result};
10use crate::model::{Draft, EnvironmentId, InstanceId, Revision, Workspace, WorkspaceId};
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<Workspace> = 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 first = workspace.get_or_insert_with(|| ws.clone());
56            if first.id != ws.id {
57                return Err(Error::Export(format!(
58                    "the instances belong to two workspaces, `{}` and `{}`",
59                    first.name, ws.name
60                )));
61            }
62            let slot = instance.slot.ok_or_else(|| {
63                Error::Export(format!("instance {id} holds no slot; lease one first"))
64            })?;
65            let revision = self.revision(&instance.environment, instance.revision)?;
66            members.push((env.name, slot, revision));
67        }
68        let Some(workspace) = workspace else {
69            return Err(Error::Export("no instances given".into()));
70        };
71        write_workspace(&workspace, &members, dir)
72    }
73
74    /// Export the head revision of every environment of the workspace as a workspace
75    /// directory in `dir`, as [`export_instances`](Self::export_instances) does. A member sits
76    /// on the slot of an instance of its head when one holds a slot, else on the lowest slot no
77    /// other member takes. Every environment needs a revision.
78    pub fn export_workspace(&self, workspace: &WorkspaceId, dir: &Path) -> Result<()> {
79        let ws = self.workspace_by_id(workspace)?;
80        let mut members = Vec::new();
81        let mut unslotted = Vec::new();
82        for env in self.environments(workspace)? {
83            let head = env.head.ok_or_else(|| {
84                Error::Export(format!(
85                    "environment `{}` has no revision; commit it first",
86                    env.name
87                ))
88            })?;
89            let revision = self.revision(&env.id, head)?;
90            let slot = self
91                .instances_of(&env.id)?
92                .into_iter()
93                .filter(|i| i.revision == head)
94                .find_map(|i| i.slot);
95            match slot {
96                Some(slot) => members.push((env.name, slot, revision)),
97                None => unslotted.push((env.name, revision)),
98            }
99        }
100        for (name, revision) in unslotted {
101            let slot = (0..=MAX_SLOT)
102                .find(|s| members.iter().all(|(_, taken, _)| taken != s))
103                .ok_or(Error::SlotsExhausted)?;
104            members.push((name, slot, revision));
105        }
106        members.sort_by(|a, b| a.0.cmp(&b.0));
107        write_workspace(&ws, &members, dir)
108    }
109
110    /// Export revision `number` of the environment as an environment directory in `dir`: its
111    /// `env.toml` and module manifests. No instance is needed. Like any `env.toml` it carries no
112    /// placement, so it renders as an environment on its own.
113    pub fn export_revision(&self, env: &EnvironmentId, number: u32, dir: &Path) -> Result<()> {
114        write_env_dir(&self.revision(env, number)?, dir)
115    }
116}
117
118/// Write the workspace manifest for `members` (name, slot, revision) and each member's
119/// environment directory under `dir`.
120fn write_workspace(ws: &Workspace, members: &[(String, u16, Revision)], dir: &Path) -> Result<()> {
121    let manifest = WorkspaceManifest {
122        version: WorkspaceManifest::current_version(),
123        name: ws.name.clone(),
124        mesh: ws.mesh,
125        envs: members
126            .iter()
127            .map(|(name, slot, _)| WorkspaceEnv {
128                name: name.clone(),
129                path: name.clone(),
130                slot: *slot,
131            })
132            .collect(),
133    };
134    // Two instances of one environment would be two members of one name.
135    manifest.validate()?;
136    for (name, _, revision) in members {
137        write_env_dir(revision, &dir.join(name))?;
138    }
139    write(&dir.join(WORKSPACE_FILE), &manifest.to_toml()?)
140}
141
142/// Write the revision's `env.toml` and module manifests into `dir`.
143fn write_env_dir(revision: &Revision, dir: &Path) -> Result<()> {
144    for (rel, text) in revision.modules() {
145        write(&dir.join(safe_relative(rel)?), text)?;
146    }
147    write(&dir.join(ENV_FILE), revision.manifest_toml())
148}
149
150/// An environment directory's `env.toml` and the module manifests it lists, as a draft.
151pub fn read_env_dir(dir: &Path) -> Result<Draft> {
152    let manifest = EnvManifest::read_from(&dir.join(ENV_FILE))?;
153    let mut modules = BTreeMap::new();
154    for rel in &manifest.catalog.manifests {
155        let path = dir.join(rel);
156        let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
157        modules.insert(rel.clone(), text);
158    }
159    Ok(Draft {
160        manifest,
161        modules,
162        vars: Default::default(),
163    })
164}
165
166/// `rel` if it stays inside the directory it is relative to.
167fn safe_relative(rel: &str) -> Result<&Path> {
168    let path = Path::new(rel);
169    if path
170        .components()
171        .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
172    {
173        Ok(path)
174    } else {
175        Err(Error::Export(format!(
176            "module manifest path `{rel}` leaves the environment directory"
177        )))
178    }
179}
180
181fn write(path: &Path, contents: &str) -> Result<()> {
182    if let Some(parent) = path.parent() {
183        std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
184    }
185    std::fs::write(path, contents).map_err(|e| Error::io(path, e))
186}