Skip to main content

haematite_cli/install/
bundle.rs

1use std::collections::BTreeSet;
2use std::fs::{self, DirBuilder};
3use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
4use std::path::{Path, PathBuf};
5
6use super::agent::Agent;
7use super::files::{Payload, bounded, entry};
8#[cfg(test)]
9use super::fixtures::Hooks;
10use super::plan::{Plan, destination, json, text};
11use super::publication::{Stage, stale_stages, sync_path};
12use super::types::{Error, Manifest, Report};
13
14/// Install the operator, configured workers and plugin, or verify an identical bundle.
15///
16/// # Errors
17/// Refuses invalid inputs, partial or different bundles and every write or sync failure.
18pub fn install(binary: &Path, config: &Path, directory: &Path) -> Result<Report, Error> {
19    install_with(
20        binary,
21        config,
22        directory,
23        #[cfg(test)]
24        &mut Hooks::default(),
25    )
26}
27
28pub(super) fn install_with(
29    binary: &Path,
30    config: &Path,
31    directory: &Path,
32    #[cfg(test)] hooks: &mut Hooks,
33) -> Result<Report, Error> {
34    install_from(
35        || Ok(binary),
36        config,
37        directory,
38        None,
39        #[cfg(test)]
40        hooks,
41    )
42}
43
44pub(super) fn install_current(
45    config: &Path,
46    directory: &Path,
47    agent: Option<&Agent>,
48    source: impl FnOnce() -> Result<PathBuf, Error>,
49) -> Result<Report, Error> {
50    install_from(
51        source,
52        config,
53        directory,
54        agent,
55        #[cfg(test)]
56        &mut Hooks::default(),
57    )
58}
59
60fn install_from<B: AsRef<Path>>(
61    source: impl FnOnce() -> Result<B, Error>,
62    config: &Path,
63    directory: &Path,
64    agent: Option<&Agent>,
65    #[cfg(test)] hooks: &mut Hooks,
66) -> Result<Report, Error> {
67    let directory = destination(directory)?;
68    let stages = stale_stages(&directory)?;
69    let outcome = source().and_then(|binary| {
70        install_to(
71            binary.as_ref(),
72            config,
73            &directory,
74            agent,
75            #[cfg(test)]
76            hooks,
77        )
78    });
79    match outcome {
80        Ok(mut report) => {
81            report.stale_stages = stages;
82            Ok(report)
83        }
84        Err(error) => Err(error.with_stale_stages(stages)),
85    }
86}
87
88fn install_to(
89    binary: &Path,
90    config: &Path,
91    directory: &Path,
92    agent: Option<&Agent>,
93    #[cfg(test)] hooks: &mut Hooks,
94) -> Result<Report, Error> {
95    let mut plan = match agent {
96        Some(agent) => Plan::with_agent(binary, config, directory, Some(agent))?,
97        None => Plan::new(binary, config, directory)?,
98    };
99    match fs::symlink_metadata(directory) {
100        Ok(metadata) => {
101            if !metadata.is_dir() || metadata.file_type().is_symlink() {
102                return Err(Error::new(
103                    "bundle_destination_invalid",
104                    "bundle destination is not a real directory",
105                ));
106            }
107            verify(directory, &plan.manifest)?;
108            durable(
109                directory,
110                &plan.manifest,
111                #[cfg(test)]
112                hooks,
113            )?;
114            return report(directory, plan.manifest.files.len(), true);
115        }
116        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
117        Err(error) => return Err(Error::new("bundle_destination_invalid", &error)),
118    }
119    let mut stage = Stage::create(
120        directory,
121        #[cfg(test)]
122        hooks,
123    )?;
124    let mut completed = Vec::new();
125    let result = write(
126        stage.path(),
127        &mut plan,
128        &mut completed,
129        #[cfg(test)]
130        hooks,
131    )
132    .and_then(|()| verify(stage.path(), &plan.manifest))
133    .and_then(|()| {
134        stage.publish(
135            directory,
136            #[cfg(test)]
137            hooks,
138        )
139    });
140    if let Err(error) = result {
141        let mut error = error.with_partial(&completed, stage.published);
142        if let Err(cleanup) = stage.cleanup(
143            #[cfg(test)]
144            hooks,
145        ) {
146            error = error.with_cleanup(&cleanup);
147        }
148        return Err(error);
149    }
150    report(directory, plan.manifest.files.len(), false)
151}
152
153fn report(directory: &Path, files: usize, already_installed: bool) -> Result<Report, Error> {
154    Ok(Report {
155        directory: text(directory)?.to_owned(),
156        files,
157        already_installed,
158        stale_stages: Vec::new(),
159    })
160}
161
162fn directories(manifest: &Manifest) -> BTreeSet<String> {
163    let mut directories = BTreeSet::new();
164    for name in manifest.files.keys() {
165        let mut path = Path::new(name).parent();
166        while let Some(parent) = path {
167            if let Some(name) = parent.to_str().filter(|name| !name.is_empty()) {
168                directories.insert(name.to_owned());
169            }
170            path = parent.parent();
171        }
172    }
173    directories
174}
175
176fn write(
177    directory: &Path,
178    plan: &mut Plan,
179    completed: &mut Vec<String>,
180    #[cfg(test)] hooks: &mut Hooks,
181) -> Result<(), Error> {
182    let directories = directories(&plan.manifest);
183    for name in &directories {
184        DirBuilder::new()
185            .mode(0o700)
186            .create(directory.join(name))
187            .map_err(|error| Error::new("bundle_write_failed", &error))?;
188    }
189    for (name, payload) in &mut plan.payloads {
190        let entry =
191            plan.manifest.files.get(name).ok_or_else(|| {
192                Error::new("bundle_plan_invalid", "payload has no manifest entry")
193            })?;
194        let path = directory.join(name);
195        #[cfg(test)]
196        hooks.payload(completed.len() + 1, &path)?;
197        payload.write(
198            &path,
199            entry,
200            #[cfg(test)]
201            hooks,
202        )?;
203        completed.push(name.clone());
204    }
205    let mut manifest = Payload::Bytes(json(&plan.manifest)?);
206    let manifest_entry = manifest.entry(0o600)?;
207    manifest.write(
208        &directory.join("bundle.json"),
209        &manifest_entry,
210        #[cfg(test)]
211        hooks,
212    )?;
213    sync_directories(
214        directory,
215        &plan.manifest,
216        #[cfg(test)]
217        hooks,
218    )
219}
220
221fn durable(
222    directory: &Path,
223    manifest: &Manifest,
224    #[cfg(test)] hooks: &mut Hooks,
225) -> Result<(), Error> {
226    for name in manifest
227        .files
228        .keys()
229        .map(String::as_str)
230        .chain(std::iter::once("bundle.json"))
231    {
232        sync_path(
233            &directory.join(name),
234            #[cfg(test)]
235            hooks,
236        )?;
237    }
238    sync_directories(
239        directory,
240        manifest,
241        #[cfg(test)]
242        hooks,
243    )?;
244    sync_path(
245        directory
246            .parent()
247            .ok_or_else(|| Error::new("bundle_path_invalid", "bundle parent missing"))?,
248        #[cfg(test)]
249        hooks,
250    )
251}
252
253fn sync_directories(
254    directory: &Path,
255    manifest: &Manifest,
256    #[cfg(test)] hooks: &mut Hooks,
257) -> Result<(), Error> {
258    for name in directories(manifest).iter().rev() {
259        sync_path(
260            &directory.join(name),
261            #[cfg(test)]
262            hooks,
263        )?;
264    }
265    sync_path(
266        directory,
267        #[cfg(test)]
268        hooks,
269    )
270}
271
272fn verify(directory: &Path, expected: &Manifest) -> Result<(), Error> {
273    let bytes = bounded(&directory.join("bundle.json"), "bundle_manifest_invalid")?;
274    let actual: Manifest = serde_json::from_slice(&bytes)
275        .map_err(|error| Error::new("bundle_manifest_invalid", &error))?;
276    if actual != *expected {
277        return Err(Error::new(
278            "bundle_conflict",
279            "bundle manifest differs from the requested inputs",
280        ));
281    }
282    for (name, expected) in &actual.files {
283        if entry(&directory.join(name), "bundle_payload_invalid")? != *expected {
284            return Err(Error::new(
285                "bundle_payload_invalid",
286                &format!("payload does not match manifest: {name}"),
287            ));
288        }
289    }
290    let directories = directories(&actual);
291    let mut pending = vec![String::new()];
292    let mut seen = BTreeSet::new();
293    while let Some(relative) = pending.pop() {
294        let path = directory.join(&relative);
295        let metadata = fs::symlink_metadata(&path)
296            .map_err(|error| Error::new("bundle_inventory_invalid", &error))?;
297        if !metadata.is_dir() || metadata.permissions().mode() & 0o7777 != 0o700 {
298            return Err(Error::new(
299                "bundle_inventory_invalid",
300                "bundle directory type or permissions changed",
301            ));
302        }
303        for child in
304            fs::read_dir(path).map_err(|error| Error::new("bundle_inventory_invalid", &error))?
305        {
306            let child = child.map_err(|error| Error::new("bundle_inventory_invalid", &error))?;
307            let name = child
308                .file_name()
309                .into_string()
310                .map_err(|_| Error::new("bundle_inventory_invalid", "bundle entry is not UTF-8"))?;
311            let name = if relative.is_empty() {
312                name
313            } else {
314                format!("{relative}/{name}")
315            };
316            if directories.contains(&name) {
317                pending.push(name);
318            } else if actual.files.contains_key(&name) {
319                seen.insert(name);
320            } else if name == "bundle.json" {
321                if entry(&child.path(), "bundle_manifest_invalid")?.mode != 0o600 {
322                    return Err(Error::new(
323                        "bundle_manifest_invalid",
324                        "manifest permissions changed",
325                    ));
326                }
327            } else {
328                return Err(Error::new(
329                    "bundle_inventory_invalid",
330                    "unexpected bundle entry",
331                ));
332            }
333        }
334    }
335    if seen.len() != actual.files.len() {
336        return Err(Error::new(
337            "bundle_inventory_invalid",
338            "bundle inventory is incomplete",
339        ));
340    }
341    Ok(())
342}