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;