Skip to main content

isoloom_render/configure/
cloud_init.rs

1//! The cloud-init carrier for a machine's recipe (design `plan/phase4/design.md` §2.1, §2.3,
2//! §5b): the shell library and a recipe's calls, as one `sh` script, delivered in a `write_files`
3//! entry (gzip then base64, `encoding: gz+b64`) and run from `runcmd` at the step's place in the
4//! machine's set-up.
5//!
6//! **Parity by construction.** The script is exactly [`shell::script`]'s output, the same text a
7//! Linux VM's shell set-up runs. cloud-init only delivers it compressed and runs it, so it leaves
8//! exactly the state the shell renderer leaves. The resource conformance harness proves it by
9//! running real cloud-init (`cargo xtask conformance --renderer cloud-init`): cloud-init writes
10//! and runs the script, then the shell library applied on top changes nothing.
11//!
12//! **Why the whole script, not cloud-init's own modules** (design decision 5): cloud-init's
13//! `packages:`, `users:` and the rest run in cloud-init's fixed module order, not the recipe's,
14//! and before `runcmd` (so before the routes, project, files, secrets and waits the set-up runs
15//! there). A recipe step reordered that way would change what the next step sees, and a native
16//! module that quietly makes a change can't set the restart mark a later `service` step reads, so
17//! a service would not restart when the shell renderer would restart it. The library keeps one
18//! implementation, guarded and ordered, that every carrier runs the same way.
19//!
20//! **Size.** A provider that caps user data ([`Limit`]) measures the whole document; gzip keeps a
21//! recipe's script small, and [`Limit::fits`] refuses, naming the machine, what still overflows.
22
23use std::io::Write as _;
24
25use flate2::Compression;
26use flate2::write::GzEncoder;
27use isoloom_plan::configure::Resource;
28use isoloom_spec::shell::base64;
29
30use super::{Configured, shell};
31
32/// Where a machine's configure scripts are delivered on it.
33pub const DIR: &str = "/var/lib/isoloom/configure";
34
35/// What the ready file calls a configure run (the same label every carrier gives it).
36pub const LABEL: &str = "its configuration";
37
38/// A file cloud-init writes before it runs anything (`write_files`): a recipe's script, gzip
39/// then base64, which cloud-init decodes with `encoding: gz+b64`.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct WriteFile {
42    /// Where it is written on the machine.
43    pub path: String,
44    /// Its mode, as `permissions` wants it (octal text).
45    pub permissions: &'static str,
46    /// Its content, gzip then base64: a `write_files` entry's `content` with `encoding: gz+b64`.
47    pub content: String,
48}
49
50/// A machine's `Step::Configure` as cloud-init carries it: the script delivered in a
51/// [`WriteFile`], and the `runcmd` that runs it under [`LABEL`].
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Configure {
54    /// The script, in `write_files`.
55    pub file: WriteFile,
56    /// The `runcmd` command that runs it (`sh <path>`).
57    pub command: String,
58}
59
60/// The `index`-th configure run of machine `c`, its `resources` and `fresh` flag as the shell
61/// renderer takes them (see [`shell::script`]): the script compressed into a `write_files` entry
62/// and the `runcmd` that runs it. A machine's runs are numbered from 0, in set-up order.
63#[must_use]
64pub fn configure(c: &Configured<'_>, resources: &[Resource], fresh: bool, index: usize) -> Configure {
65    let path = format!("{DIR}/configure-{index}.sh");
66    let command = format!("sh {path}");
67    let file = WriteFile {
68        path,
69        permissions: "0700",
70        content: gz_b64(&shell::script(c, resources, fresh)),
71    };
72    Configure { file, command }
73}
74
75/// `script` gzip-compressed then base64-encoded, for a `write_files` entry's `content` with
76/// `encoding: gz+b64`. Deterministic: the gzip header carries no modification time or name, so
77/// the same script gives the same bytes on every host.
78#[must_use]
79pub fn gz_b64(script: &str) -> String {
80    let mut gz = GzEncoder::new(Vec::new(), Compression::best());
81    // Writing to and flushing an in-memory `Vec` can't fail; the empty fallback is unreachable.
82    let written = gz.write_all(script.as_bytes());
83    let bytes = written.and(gz.finish()).unwrap_or_default();
84    base64(&bytes)
85}
86
87/// How large a provider lets a machine's user data be, in bytes of the document it receives
88/// (design §4.9 cloud design; a `docker-vm`, Proxmox or ESXi snippet has no such cap).
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum Limit {
92    /// AWS: 16 KiB of raw user data.
93    Aws,
94    /// Azure: 64 KiB.
95    Azure,
96    /// Google Cloud: 256 KiB.
97    Gcp,
98}
99
100impl Limit {
101    /// The cap in bytes.
102    #[must_use]
103    pub fn bytes(self) -> usize {
104        match self {
105            Limit::Aws => 16 * 1024,
106            Limit::Azure => 64 * 1024,
107            Limit::Gcp => 256 * 1024,
108        }
109    }
110
111    /// The provider's name, for a refusal.
112    #[must_use]
113    pub fn provider(self) -> &'static str {
114        match self {
115            Limit::Aws => "AWS",
116            Limit::Azure => "Azure",
117            Limit::Gcp => "Google Cloud",
118        }
119    }
120
121    /// `Ok` when `user_data` (the document the provider receives) fits; otherwise [`TooBig`],
122    /// which a caller turns into a refusal naming the machine.
123    ///
124    /// # Errors
125    ///
126    /// [`TooBig`] when the document is over the cap, with its size and the overflow.
127    pub fn fits(self, user_data: &str) -> Result<(), TooBig> {
128        let size = user_data.len();
129        let cap = self.bytes();
130        if size <= cap {
131            Ok(())
132        } else {
133            Err(TooBig {
134                provider: self.provider(),
135                size,
136                cap,
137            })
138        }
139    }
140}
141
142/// A machine's user data doesn't fit its provider's cap.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
144#[error("its cloud-init user data is {size} bytes, over {provider}'s {cap}-byte limit: fewer or smaller files, or a smaller recipe")]
145#[non_exhaustive]
146pub struct TooBig {
147    /// The provider whose cap it broke.
148    pub provider: &'static str,
149    /// The document's size, in bytes.
150    pub size: usize,
151    /// The cap, in bytes.
152    pub cap: usize,
153}
154
155#[cfg(test)]
156mod tests;