Skip to main content

isoloom_render/
project.rs

1//! The project copied into the machines, without what `.isoloomignore` leaves out: its rules
2//! ([`ignore`], the run manifest's: the runner's `project.archive` follows them too), and the
3//! same rules in the Terraform outputs, read when Terraform runs, exactly as the Vagrantfiles read
4//! them (`vagrant_project.rb`), so every target leaves out the same files.
5//!
6//! - Proxmox writes the project's files through cloud-init: `local.project_paths`, the files
7//!   that go (not version control, the tools' state, or Isoloom's outputs).
8//! - The cloud modules archive the project on the host with `tar`, which walks it itself
9//!   (symbolic links kept as links): `local.project_ignored`, the files that don't go, is its
10//!   exclusion list (`-X`, a file next to the module: `local_file.isoloom_project`). Each entry
11//!   starts with the project folder's name (the archive's top folder, stripped when unpacked),
12//!   since bsdtar (macOS, Windows) matches exclusions at any depth. A Windows machine there,
13//!   which cloudbase-init sets up, gets the files that go one by one: `local.project_paths`.
14//!
15//! - A module that only makes the machines lists nothing: the runner archives the project and
16//!   pipes it into each guest over SSH (`Copy::Runner`).
17//!
18//! A module gets only the lists it reads ([`lists`]).
19
20pub use isoloom_manifest::ignore;
21
22/// How a Terraform module copies the project into its machines.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24#[non_exhaustive]
25pub enum Copy {
26    /// Each file written into the machine by its cloud-init (Proxmox): the files that go.
27    Files,
28    /// An archive made where Terraform runs, with `tar`: its exclusion list is a file too.
29    Archive,
30    /// None: Terraform only makes the machines, and the runner copies the project in over SSH
31    /// (the run manifest's `project.archive`, piped to the guest), so the module lists nothing.
32    Runner,
33}
34
35/// The rules, read as regular expressions, and each file's verdict (`project_keep`): what both
36/// lists are made from. The `locals` block stays open for the lists.
37const RULES: &str = r##"
38# The project's files the machines don't get: version control and the tools' state, and what
39# .isoloomignore at the project's root lists (gitignore-style: one pattern per line, `#` comments,
40# `!` brings a path back, a trailing `/` matches folders only, a `/` at the start or in the middle
41# anchors the pattern at the root; `*`, `?`, `[abc]`, `**`; the last pattern matching a path, or
42# a folder it is in, decides). Read as regular expressions, as the Vagrantfiles do.
43locals {
44  project_ignore_lines = [for l in split("\n", replace(try(file("${local.root}/.isoloomignore"), ""), "\r", "")) : trimspace(l)]
45  project_ignore_pats  = [for l in local.project_ignore_lines : { keep = startswith(l, "!"), pat = trimprefix(l, "!") } if l != "" && !startswith(l, "#")]
46  project_ignore_globs = [for r in local.project_ignore_pats : merge(r, { glob = trimsuffix(trimsuffix(trimsuffix(r.pat, "/"), "/**"), "/") })]
47  project_ignore = [for r in local.project_ignore_globs : {
48    keep = r.keep
49    re = format(r.glob == r.pat ? "^%s(/|$)" : "^%s/", replace(replace(replace(replace(replace(replace(replace(replace(
50      length(split("/", r.glob)) > 1 ? trimprefix(r.glob, "/") : "**/${r.glob}",
51    "/[.+^$(){}|\\\\]/", "\\$0"), "[!", "[^"), "**/", "\u0001"), "**", "\u0002"), "*", "[^/]*"), "?", "[^/]"), "\u0001", "(.*/)?"), "\u0002", ".*"))
52  } if r.glob != ""]
53  # Each file: whether it goes (the last matching pattern decides; none: it goes).
54"##;
55
56/// The files that go, as Proxmox leaves them (`SKIP` is the regular expression of the paths it
57/// leaves out).
58const PATHS: (&str, &str) = (
59    "project_paths",
60    r#"[for f, keep in local.project_keep : f if keep && length(regexall("SKIP", f)) == 0]"#,
61);
62
63/// The files that don't go.
64const IGNORED: (&str, &str) = ("project_ignored", "[for f, keep in local.project_keep : f if !keep]");
65
66/// Each file's verdict.
67const KEEP: (&str, &str) = (
68    "project_keep",
69    r#"{ for f in fileset(local.root, "**") : f => reverse(concat([true], [for r in local.project_ignore : r.keep if length(regexall(r.re, f)) > 0]))[0] }"#,
70);
71
72const EXCLUSIONS: &str = r#"
73# What `tar` leaves out of the project's archive: .isoloomignore's files, from the archive's top
74# folder, their wildcard characters escaped.
75resource "local_file" "isoloom_project" {
76  filename = "${path.module}/.isoloom-project-ignored.txt"
77  content  = join("", [for f in local.project_ignored : "${replace("${basename(local.root)}/${f}", "/[\\\\*?\\[]/", "\\$0")}\n"])
78}
79"#;
80
81/// Proxmox leaves out version control and the tools' state at any depth (a `.vagrant` holds
82/// private keys), and Isoloom's outputs.
83const SKIP: &str = r"^\\.isoloom|(^|/)\\.(git|vagrant|terraform)/";
84
85/// The lists a module reads, as a fixed HCL fragment: `local.project_paths` when the rest of
86/// the module (`module`, as written) names it; for an archive, `local.project_ignored` and
87/// `tar`'s exclusion file, which reads it. Nothing when it reads neither, or when the runner
88/// copies the project: a list nothing reads is a declaration Terraform's linters report.
89#[must_use]
90pub fn lists(copy: Copy, module: &str) -> String {
91    if copy == Copy::Runner {
92        return String::new();
93    }
94    let mut listed = Vec::new();
95    if module.contains("local.project_paths") {
96        listed.push(PATHS);
97    }
98    if copy == Copy::Archive {
99        listed.push(IGNORED);
100    }
101    if listed.is_empty() {
102        return String::new();
103    }
104    listed.insert(0, KEEP);
105    // `terraform fmt` aligns the `=` of consecutive one-line attributes.
106    let width = listed.iter().map(|(name, _)| name.len()).max().unwrap_or_default();
107    let lines = listed
108        .into_iter()
109        .map(|(name, value)| format!("  {name:<width$} = {}\n", value.replace("SKIP", SKIP)))
110        .collect::<Vec<_>>()
111        .concat();
112    let exclusions = if copy == Copy::Archive { EXCLUSIONS } else { "" };
113    format!("{RULES}{lines}}}\n{exclusions}")
114}
115
116#[cfg(test)]
117mod tests {
118    use super::{Copy, lists};
119
120    /// A module's body that reads the files that go.
121    const READS_PATHS: &str = "content = join(\"\\n\", local.project_paths)";
122
123    #[test]
124    fn the_files_alone_list_only_the_files_that_go_when_the_module_reads_them() {
125        let files = lists(Copy::Files, READS_PATHS);
126        assert!(files.contains("  project_keep  = { for f in fileset(local.root"), "{files}");
127        assert!(files.contains("  project_paths = [for f, keep in local.project_keep : f if keep && length(regexall(\"^\\\\.isoloom|(^|/)\\\\.(git|vagrant|terraform)/\", f)) == 0]\n}\n"), "{files}");
128        assert!(!files.contains("project_ignored") && !files.contains("local_file"), "{files}");
129    }
130
131    #[test]
132    fn a_module_reading_no_list_gets_none() {
133        assert_eq!(lists(Copy::Files, "resource \"x\" \"y\" {}"), "");
134        // The runner's copy needs nothing of the module.
135        assert_eq!(lists(Copy::Runner, READS_PATHS), "");
136    }
137
138    #[test]
139    fn an_archive_lists_what_tar_leaves_out_and_the_files_that_go_only_when_read() {
140        let archive = lists(Copy::Archive, "");
141        assert!(
142            archive.contains("  project_keep    = {") && archive.contains("  project_ignored = [for f, keep"),
143            "{archive}"
144        );
145        assert!(!archive.contains("project_paths"), "{archive}");
146        assert!(
147            archive.ends_with("\\n\"])\n}\n") && archive.contains("resource \"local_file\" \"isoloom_project\""),
148            "{archive}"
149        );
150        let both = lists(Copy::Archive, READS_PATHS);
151        assert!(
152            both.contains("  project_paths   = [for f") && both.contains("  project_ignored = [for f"),
153            "{both}"
154        );
155    }
156}