| 1 | //! `g1t-runner deploy` and `g1t-runner detect` as a workflow step runs |
| 2 | //! them: in a checkout of its own, with nothing cloned, reporting to an API |
| 3 | //! (here a fake one on localhost) with the build's token. |
| 4 | |
| 5 | use std::io::{BufRead, BufReader, Read, Write}; |
| 6 | use std::net::TcpListener; |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | use std::process::Command; |
| 9 | use std::sync::{Arc, Mutex}; |
| 10 | |
| 11 | use serde_json::{Value, json}; |
| 12 | |
| 13 | fn runner() -> Command { |
| 14 | Command::new(env!("CARGO_BIN_EXE_g1t-runner")) |
| 15 | } |
| 16 | |
| 17 | /// A fresh checkout holding `files`. |
| 18 | fn checkout(name: &str, files: &[(&str, &str)]) -> PathBuf { |
| 19 | let dir = std::env::temp_dir().join(format!("g1t-runner-cli-{name}-{}", std::process::id())); |
| 20 | let _ = std::fs::remove_dir_all(&dir); |
| 21 | std::fs::create_dir_all(&dir).unwrap(); |
| 22 | for (path, text) in files { |
| 23 | let path = dir.join(path); |
| 24 | std::fs::create_dir_all(path.parent().unwrap()).unwrap(); |
| 25 | std::fs::write(path, text).unwrap(); |
| 26 | } |
| 27 | dir |
| 28 | } |
| 29 | |
| 30 | #[test] |
| 31 | fn detect_prints_what_the_build_would_find() { |
| 32 | let dir = checkout("detect", &[("site/index.html", "<h1>hello</h1>")]); |
| 33 | let output = runner().arg("detect").args(["--root-dir", "site"]).current_dir(&dir).env_remove("GIT_REMOTE").output().unwrap(); |
| 34 | assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); |
| 35 | assert_eq!(String::from_utf8_lossy(&output.stdout), "kind=html\ninstall=\nbuild=\noutput-dir=.\n"); |
| 36 | let unknown = runner().args(["detect", "--nope"]).current_dir(&dir).output().unwrap(); |
| 37 | assert_eq!(unknown.status.code(), Some(2)); |
| 38 | } |
| 39 | |
| 40 | /// The API's `/deployments/jobs/<id>/<step>`: answers each step as the |
| 41 | /// deployments service does, and keeps what it was sent. |
| 42 | fn fake_api() -> (String, Arc<Mutex<Vec<(String, Value)>>>) { |
| 43 | let listener = TcpListener::bind("127.0.0.1:0").unwrap(); |
| 44 | let base = format!("http://{}", listener.local_addr().unwrap()); |
| 45 | let seen = Arc::new(Mutex::new(Vec::new())); |
| 46 | let kept = seen.clone(); |
| 47 | std::thread::spawn(move || { |
| 48 | for stream in listener.incoming() { |
| 49 | let Ok(mut stream) = stream else { continue }; |
| 50 | let mut reader = BufReader::new(stream.try_clone().unwrap()); |
| 51 | let mut first = String::new(); |
| 52 | reader.read_line(&mut first).unwrap(); |
| 53 | let path = first.split_whitespace().nth(1).unwrap_or_default().to_owned(); |
| 54 | let mut length = 0; |
| 55 | loop { |
| 56 | let mut line = String::new(); |
| 57 | reader.read_line(&mut line).unwrap(); |
| 58 | if line.trim().is_empty() { |
| 59 | break; |
| 60 | } |
| 61 | if let Some((name, value)) = line.split_once(':') |
| 62 | && name.eq_ignore_ascii_case("content-length") |
| 63 | { |
| 64 | length = value.trim().parse().unwrap_or(0); |
| 65 | } |
| 66 | } |
| 67 | let mut body = vec![0; length]; |
| 68 | reader.read_exact(&mut body).unwrap(); |
| 69 | let body: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); |
| 70 | let answer = if path.ends_with("/session") { |
| 71 | json!({ "ok": true, "value": { "jwt": "complete-me", "buckets": [], "upload_url": "http://127.0.0.1:9/never" } }) |
| 72 | } else { |
| 73 | json!({ "ok": true, "value": true }) |
| 74 | }; |
| 75 | kept.lock().unwrap().push((path, body)); |
| 76 | let text = answer.to_string(); |
| 77 | let _ = write!( |
| 78 | stream, |
| 79 | "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{text}", |
| 80 | text.len() |
| 81 | ); |
| 82 | } |
| 83 | }); |
| 84 | (base, seen) |
| 85 | } |
| 86 | |
| 87 | fn deploy(dir: &Path, api: &str, extra: &[(&str, &str)]) -> std::process::Output { |
| 88 | let mut command = runner(); |
| 89 | command |
| 90 | .arg("deploy") |
| 91 | .current_dir(dir) |
| 92 | .env_remove("GIT_REMOTE") |
| 93 | .env("G1T_API", api) |
| 94 | .env("DEPLOY_ID", "dpl_1") |
| 95 | .env("DEPLOY_TOKEN", "build-token-0123456789") |
| 96 | .env("BUILD_SECRETS", r#"{"API_KEY":"very-secret-value"}"#) |
| 97 | .env("BUILD_ENV", r#"{"PUBLIC_URL":"https://example.com"}"#); |
| 98 | for (name, value) in extra { |
| 99 | command.env(name, value); |
| 100 | } |
| 101 | command.output().unwrap() |
| 102 | } |
| 103 | |
| 104 | #[test] |
| 105 | fn deploy_builds_the_checkout_it_runs_in_without_cloning() { |
| 106 | let (api, seen) = fake_api(); |
| 107 | let dir = checkout("deploy", &[("site/index.html", "<h1>hello</h1>"), ("site/about.html", "<p>about</p>"), ("README.md", "not served")]); |
| 108 | let output = deploy(&dir, &api, &[("ROOT_DIR", "site")]); |
| 109 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 110 | assert!(output.status.success(), "{stdout}\n{}", String::from_utf8_lossy(&output.stderr)); |
| 111 | // Its log is the step's too. |
| 112 | assert!(stdout.contains("as it is checked out"), "{stdout}"); |
| 113 | assert!(stdout.contains("Uploading 2 files."), "{stdout}"); |
| 114 | let seen = seen.lock().unwrap(); |
| 115 | let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect(); |
| 116 | assert_eq!(steps, ["/deployments/jobs/dpl_1/started", "/deployments/jobs/dpl_1/session", "/deployments/jobs/dpl_1/finish"]); |
| 117 | for (_, body) in seen.iter() { |
| 118 | assert_eq!(body["token"], "build-token-0123456789"); |
| 119 | } |
| 120 | let manifest = seen[1].1["manifest"].as_object().unwrap(); |
| 121 | let mut files: Vec<&String> = manifest.keys().collect(); |
| 122 | files.sort(); |
| 123 | assert_eq!(files, ["/about.html", "/index.html"]); |
| 124 | let finish = &seen[2].1; |
| 125 | assert_eq!(finish["detected"], "html"); |
| 126 | assert_eq!(finish["completionJwt"], "complete-me"); |
| 127 | assert!(!finish["log"].as_str().unwrap().contains("build-token-0123456789")); |
| 128 | } |
| 129 | |
| 130 | #[test] |
| 131 | fn a_failed_build_is_reported_and_fails_the_step() { |
| 132 | let (api, seen) = fake_api(); |
| 133 | let dir = checkout("fail", &[("README.md", "nothing to serve")]); |
| 134 | let output = deploy(&dir, &api, &[]); |
| 135 | assert_eq!(output.status.code(), Some(1)); |
| 136 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 137 | assert!(stdout.contains("found nothing to serve"), "{stdout}"); |
| 138 | let seen = seen.lock().unwrap(); |
| 139 | assert_eq!(seen.last().unwrap().0, "/deployments/jobs/dpl_1/fail"); |
| 140 | // A missing root directory says so. |
| 141 | drop(seen); |
| 142 | let missing = deploy(&dir, &api, &[("ROOT_DIR", "apps/web")]); |
| 143 | assert_eq!(missing.status.code(), Some(1)); |
| 144 | assert!(String::from_utf8_lossy(&missing.stdout).contains("does not exist in this checkout")); |
| 145 | } |
| 146 | |
| 147 | #[test] |
| 148 | fn deploy_needs_its_build() { |
| 149 | let output = runner().arg("deploy").env_remove("DEPLOY_ID").env_remove("DEPLOY_TOKEN").output().unwrap(); |
| 150 | assert_eq!(output.status.code(), Some(2)); |
| 151 | assert!(String::from_utf8_lossy(&output.stderr).contains("DEPLOY_ID")); |
| 152 | } |