turnout 0.27.0

A developer's switchyard: point local apps at any backend stand, keep servers and secrets at hand, build and deploy from any directory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! What a build produced, on this machine: the files a deploy sends, packed
//! once however many targets they go to, and whether they are older than the
//! sources they were built from.

use std::cell::OnceCell;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};

use anyhow::{Context, Result};

/// Below this, packing is not worth the extra round trips: a handful of files
/// go over SFTP faster than tar + upload + untar can set itself up.
pub const ARCHIVE_THRESHOLD: u64 = 8;

/// Whether a tree of `files` files should travel as one archive.
///
/// Split out from the upload so the rule is testable without a server, and so
/// the opt-out and the threshold live in one readable place.
pub fn should_archive(files: u64, no_archive: bool) -> bool {
    !no_archive && files >= ARCHIVE_THRESHOLD
}

/// What the upload is about to send: directories to create and files to copy,
/// each with the path relative to the artifact root.
pub struct Plan {
    pub dirs: Vec<String>,
    pub entries: Vec<Entry>,
    pub files: u64,
    pub bytes: u64,
    /// When the newest file was written - the moment the build finished, as
    /// far as anything can tell afterwards.
    pub newest: Option<SystemTime>,
}

/// One file of the artifact.
pub struct Entry {
    pub path: PathBuf,
    /// Relative to the artifact root, with `/` whatever the OS.
    pub relative: String,
    pub size: u64,
}

/// Walk the artifact directory to learn its size before sending anything.
/// Directories come out parents-first so they can be created in order.
pub fn plan_upload(local_root: &Path) -> Result<Plan> {
    let mut plan = Plan {
        dirs: Vec::new(),
        entries: Vec::new(),
        files: 0,
        bytes: 0,
        newest: None,
    };
    let mut queue = vec![local_root.to_path_buf()];
    while let Some(dir) = queue.pop() {
        for entry in std::fs::read_dir(&dir).with_context(|| format!("cannot read {}", dir.display()))? {
            let entry = entry?;
            let path = entry.path();
            let relative = path.strip_prefix(local_root).expect("entry under root").to_string_lossy().replace('\\', "/");
            let metadata = entry.metadata().with_context(|| format!("cannot stat {}", path.display()))?;
            if metadata.is_dir() {
                plan.dirs.push(relative);
                queue.push(path);
            } else {
                plan.files += 1;
                plan.bytes += metadata.len();
                if let Ok(modified) = metadata.modified() {
                    plan.newest = plan.newest.max(Some(modified));
                }
                plan.entries.push(Entry {
                    path,
                    relative,
                    size: metadata.len(),
                });
            }
        }
    }
    // Shallow paths first: a child directory is never created before its parent.
    plan.dirs.sort_by_key(|d| d.matches('/').count());
    Ok(plan)
}

/// Build the gzipped tar in memory, entries relative to the artifact root so it
/// unpacks straight into the deploy directory.
pub fn pack(plan: &Plan) -> Result<Vec<u8>> {
    let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
    let mut builder = tar::Builder::new(encoder);
    for entry in &plan.entries {
        let mut file = std::fs::File::open(&entry.path).with_context(|| format!("cannot open {}", entry.path.display()))?;
        builder
            .append_file(&entry.relative, &mut file)
            .with_context(|| format!("cannot add {} to the archive", entry.path.display()))?;
    }
    let encoder = builder.into_inner().context("cannot finish the archive")?;
    encoder.finish().context("cannot compress the archive")
}

/// One app's build, ready to go out: planned once, packed at most once, and
/// sent to as many targets as name the app.
pub struct Artifact {
    pub app: String,
    pub plan: Plan,
    /// The version and commit it was built from, for the deploy's record.
    pub source: crate::history::Source,
    archive: OnceCell<Vec<u8>>,
    digest: OnceCell<String>,
}

impl Artifact {
    pub fn new(app: &str, plan: Plan) -> Self {
        Self {
            app: app.to_string(),
            plan,
            source: crate::history::Source::default(),
            archive: OnceCell::new(),
            digest: OnceCell::new(),
        }
    }

    /// A build that was packed before - a kept one a rollback sends again.
    /// `plan` walks its unpacked files; the archive goes out as it was, so
    /// its digest is the one the earlier deploy recorded.
    pub fn packed(app: &str, plan: Plan, archive: Vec<u8>, source: crate::history::Source) -> Self {
        let artifact = Self {
            source,
            ..Self::new(app, plan)
        };
        let _ = artifact.archive.set(archive);
        artifact
    }

    /// The archive, packed on first use: a deploy that goes file by file
    /// everywhere never pays for it, and the second target does not pay again.
    pub fn archive(&self) -> Result<&[u8]> {
        if self.archive.get().is_none() {
            let packed = pack(&self.plan)?;
            let _ = self.archive.set(packed);
        }
        Ok(self.archive.get().expect("the archive was just packed"))
    }

    /// The SHA-256 of the archive, as lowercase hex: the name the build is
    /// kept under.
    pub fn digest(&self) -> Result<&str> {
        if self.digest.get().is_none() {
            use sha2::Digest;
            let hash = sha2::Sha256::digest(self.archive()?);
            let _ = self.digest.set(hash.iter().map(|byte| format!("{byte:02x}")).collect());
        }
        Ok(self.digest.get().expect("the digest was just taken"))
    }
}

/// The files an archive holds, by their relative path with `/`.
pub fn files_in(archive: &[u8]) -> Result<std::collections::BTreeSet<String>> {
    let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive));
    let mut files = std::collections::BTreeSet::new();
    for entry in tar.entries().context("cannot read a kept build")? {
        let entry = entry.context("cannot read a kept build")?;
        if !entry.header().entry_type().is_file() {
            continue;
        }
        let path = entry.path().context("a kept build holds an unreadable name")?;
        // Our own archives hold plain relative paths; anything else is not a
        // name to hand to a remote `rm`.
        if path.components().all(|part| matches!(part, std::path::Component::Normal(_))) {
            files.insert(path.to_string_lossy().replace('\\', "/"));
        }
    }
    Ok(files)
}

/// Unpack an archive into `into`. The tar crate refuses entries that would
/// land outside it.
pub fn unpack(archive: &[u8], into: &Path) -> Result<()> {
    tar::Archive::new(flate2::read::GzDecoder::new(archive))
        .unpack(into)
        .with_context(|| format!("cannot unpack a kept build into {}", into.display()))
}

/// A source changed after the build that is about to be sent.
#[derive(Debug, PartialEq, Eq)]
pub struct Stale {
    /// The newest such file, relative to the project.
    pub source: String,
    /// How long after the build it changed.
    pub after: Duration,
}

/// File systems that keep two-second times (FAT, some network shares) would
/// otherwise call a source saved in the build's own second newer than it.
const SLACK: Duration = Duration::from_secs(2);

/// Directories a source walk never enters when git cannot be asked: the
/// project's own outputs and caches, not what it is built from.
const NOT_SOURCES: [&str; 5] = [".git", "node_modules", "target", ".turbo", ".cache"];

/// Whether a source in `project` changed after `built`, the newest file of
/// the artifact in `dist`.
///
/// The sources are what git tracks or would track - asked of git itself, so
/// an ignored `.env.local` or a cache the tool writes is not mistaken for an
/// edit. Outside a repository the project is walked, leaving out the usual
/// outputs. The artifact directory and `skip` (the dotenv file turnout itself
/// writes on every switch) never count.
pub fn stale(project: &Path, dist: &Path, built: SystemTime, skip: &[&Path]) -> Option<Stale> {
    let sources = git_sources(project).unwrap_or_else(|| walked_sources(project));
    let dist = dist.strip_prefix(project).unwrap_or(dist);
    let newest = sources
        .into_iter()
        .filter(|relative| !relative.starts_with(dist) && !skip.iter().any(|skip| relative == skip))
        .filter_map(|relative| {
            let modified = std::fs::metadata(project.join(&relative)).ok()?.modified().ok()?;
            Some((modified, relative))
        })
        .max_by_key(|(modified, _)| *modified)?;
    let after = newest.0.duration_since(built).ok().filter(|after| *after > SLACK)?;
    Some(Stale {
        source: newest.1.to_string_lossy().replace('\\', "/"),
        after,
    })
}

/// The files git tracks or would track in `project`, relative to it; `None`
/// when git cannot answer (no repository, no git).
fn git_sources(project: &Path) -> Option<Vec<PathBuf>> {
    let output = std::process::Command::new("git")
        .args(["ls-files", "--cached", "--others", "--exclude-standard", "-z"])
        .current_dir(project)
        .stderr(std::process::Stdio::null())
        .output()
        .ok()
        .filter(|output| output.status.success())?;
    Some(
        output
            .stdout
            .split(|byte| *byte == 0)
            .filter(|name| !name.is_empty())
            .map(|name| PathBuf::from(String::from_utf8_lossy(name).into_owned()))
            .collect(),
    )
}

fn walked_sources(project: &Path) -> Vec<PathBuf> {
    let mut found = Vec::new();
    let mut pending = vec![project.to_path_buf()];
    while let Some(dir) = pending.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else { continue };
        for entry in entries.flatten() {
            let path = entry.path();
            match entry.file_type() {
                Ok(kind) if kind.is_dir() => {
                    if !NOT_SOURCES.iter().any(|skip| entry.file_name() == *skip) {
                        pending.push(path);
                    }
                }
                Ok(_) => {
                    if let Ok(relative) = path.strip_prefix(project) {
                        found.push(relative.to_path_buf());
                    }
                }
                Err(_) => {}
            }
        }
    }
    found
}

/// The SHA-256 of a file, as lowercase hex - the form `sha256sum` and
/// a Windows server's .NET hash (lowercased) print.
pub fn sha256(path: &Path) -> Result<String> {
    use sha2::Digest;
    use std::io::Read;
    let mut hasher = sha2::Sha256::new();
    let mut file = std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
    let mut buffer = [0u8; 64 * 1024];
    loop {
        let read = file.read(&mut buffer).with_context(|| format!("cannot read {}", path.display()))?;
        if read == 0 {
            break;
        }
        hasher.update(&buffer[..read]);
    }
    Ok(hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The plan drives both the progress total and the order of remote mkdirs,
    /// so it has to count every nested file and list parents before children.
    #[test]
    fn plans_a_nested_tree() {
        let root = tempfile::tempdir().expect("temp dir");
        let deep = root.path().join("assets").join("img");
        std::fs::create_dir_all(&deep).expect("create dirs");
        std::fs::write(root.path().join("index.html"), "hello").expect("write");
        std::fs::write(deep.join("logo.svg"), "12345678").expect("write");

        let plan = plan_upload(root.path()).expect("plan");

        assert_eq!(plan.files, 2);
        assert_eq!(plan.bytes, 13);
        assert_eq!(plan.dirs, vec!["assets", "assets/img"]);
        let mut relative: Vec<_> = plan.entries.iter().map(|e| e.relative.as_str()).collect();
        relative.sort_unstable();
        assert_eq!(relative, vec!["assets/img/logo.svg", "index.html"]);
        assert!(plan.newest.is_some(), "the build's moment is read off its files");
    }

    /// A real dist directory is thousands of files and always packs; a couple
    /// of files are faster sent as they are. `--no-archive` overrides both.
    #[test]
    fn only_trees_worth_packing_are_packed() {
        assert!(should_archive(2_000, false), "a dist directory packs");
        assert!(should_archive(ARCHIVE_THRESHOLD, false), "the threshold itself packs");
        assert!(!should_archive(ARCHIVE_THRESHOLD - 1, false), "just below it does not");
        assert!(!should_archive(1, false), "a single file never packs");
        assert!(!should_archive(2_000, true), "--no-archive wins over any size");
    }

    /// The archive has to unpack straight into the deploy directory, so entries
    /// are stored relative to the artifact root - an absolute or `./`-prefixed
    /// path would land the files somewhere else entirely.
    #[test]
    fn the_archive_holds_relative_paths_and_content() {
        let root = tempfile::tempdir().expect("temp dir");
        let deep = root.path().join("assets").join("img");
        std::fs::create_dir_all(&deep).expect("create dirs");
        std::fs::write(root.path().join("index.html"), "<!doctype html>").expect("write");
        std::fs::write(deep.join("logo.svg"), "<svg/>").expect("write");

        let plan = plan_upload(root.path()).expect("plan");
        let archive = pack(&plan).expect("pack");

        let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(&archive));
        let mut tar = tar::Archive::new(decoder);
        let mut found = std::collections::BTreeMap::new();
        for entry in tar.entries().expect("entries") {
            use std::io::Read;
            let mut entry = entry.expect("entry");
            let path = entry.path().expect("path").to_string_lossy().replace('\\', "/");
            let mut content = String::new();
            entry.read_to_string(&mut content).expect("read");
            found.insert(path, content);
        }

        assert_eq!(found.get("index.html").map(String::as_str), Some("<!doctype html>"));
        assert_eq!(found.get("assets/img/logo.svg").map(String::as_str), Some("<svg/>"));
        assert_eq!(found.len(), 2, "only files travel; directories come from the paths: {found:?}");
        assert!(
            !found.keys().any(|p| p.starts_with('/') || p.starts_with("./")),
            "paths must be relative: {found:?}"
        );
    }

    /// Compression is the point: a dist directory of text files should not
    /// cross the wire at full size.
    #[test]
    fn the_archive_is_smaller_than_the_tree() {
        let root = tempfile::tempdir().expect("temp dir");
        for index in 0..20 {
            std::fs::write(root.path().join(format!("chunk-{index}.js")), "console.log('hello world');\n".repeat(200)).expect("write");
        }
        let plan = plan_upload(root.path()).expect("plan");
        let archive = pack(&plan).expect("pack");
        assert!(
            (archive.len() as u64) < plan.bytes / 4,
            "expected real compression, got {} from {}",
            archive.len(),
            plan.bytes
        );
    }

    /// Two targets of one app send the same bytes: the archive is packed on
    /// the first ask and handed out again on the second.
    #[test]
    fn an_artifact_packs_once() {
        let root = tempfile::tempdir().expect("temp dir");
        std::fs::write(root.path().join("index.html"), "hello").expect("write");
        let artifact = Artifact::new("web", plan_upload(root.path()).unwrap());
        let first = artifact.archive().unwrap().as_ptr();
        let second = artifact.archive().unwrap().as_ptr();
        assert_eq!(first, second, "the second target must not pack again");
    }

    #[test]
    fn an_empty_tree_plans_nothing() {
        let root = tempfile::tempdir().expect("temp dir");
        let plan = plan_upload(root.path()).expect("plan");
        assert_eq!(plan.files, 0);
        assert!(plan.entries.is_empty());
        assert_eq!(plan.newest, None);
    }

    fn touch(path: &Path, at: SystemTime) {
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, "x").unwrap();
        std::fs::File::options().write(true).open(path).unwrap().set_modified(at).unwrap();
    }

    /// A source saved after the build makes the build stale, and the message
    /// names it; the build's own output and turnout's dotenv file never do.
    #[test]
    fn a_source_newer_than_the_build_is_named() {
        let project = tempfile::tempdir().unwrap();
        let built = SystemTime::now() - Duration::from_secs(3600);
        touch(&project.path().join("dist/index.html"), built);
        touch(&project.path().join("src/main.ts"), built - Duration::from_secs(60));
        touch(&project.path().join(".env.development.local"), built + Duration::from_secs(600));
        touch(&project.path().join("node_modules/x/index.js"), built + Duration::from_secs(600));
        let dist = project.path().join("dist");
        let skip = [Path::new(".env.development.local")];
        assert_eq!(stale(project.path(), &dist, built, &skip), None, "nothing the build reads changed");

        touch(&project.path().join("src/App.vue"), built + Duration::from_secs(720));
        let found = stale(project.path(), &dist, built, &skip).expect("stale");
        assert_eq!(found.source, "src/App.vue");
        assert_eq!(found.after.as_secs(), 720);
    }

    /// A save in the build's own second is not "after the build" on a file
    /// system that rounds to two seconds.
    #[test]
    fn a_save_within_the_slack_is_not_stale() {
        let project = tempfile::tempdir().unwrap();
        let built = SystemTime::now() - Duration::from_secs(60);
        touch(&project.path().join("src/a.ts"), built + Duration::from_secs(1));
        assert_eq!(stale(project.path(), &project.path().join("dist"), built, &[]), None);
    }

    #[test]
    fn sha256_matches_the_standard_vector() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("abc");
        std::fs::write(&file, "abc").unwrap();
        assert_eq!(sha256(&file).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
    }
}