153 lines5,465 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Fast pages, required checks on the branch, self-hosted runners, honest incidents1//! What `g1t-runner register` keeps: where g1t is, who this runner is, its
2//! credential, and how it runs work. One JSON file in the runner's folder,
3//! readable by its owner only.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct Config {
12 /// The site, as given to `--url`: `https://g1t.sh`.
13 pub url: String,
14 /// The API the runner calls: `https://api.g1t.sh`.
15 pub api: String,
16 /// The runner's id and name.
17 pub runner: String,
18 pub name: String,
19 /// Its credential (`g1tr_…`). Rotated by g1t every day.
20 pub credential: String,
21 pub workspace: String,
22 #[serde(default)]
23 pub repo: Option<String>,
24 #[serde(default)]
25 pub group: Option<String>,
26 pub labels: Vec<String>,
27 /// Runs one job, then removes itself.
28 #[serde(default)]
29 pub ephemeral: bool,
30 /// Where jobs keep their files when they run on this machine directly.
31 pub work_dir: PathBuf,
32 /// Runs work in Docker containers (the default), or directly.
33 #[serde(default = "yes")]
34 pub docker: bool,
35 /// The image workflow jobs run in when they name no `container:`.
36 #[serde(default)]
37 pub image: Option<String>,
38 /// The image agent work runs in: git, Node and the agent's CLI.
39 #[serde(default)]
40 pub agent_image: Option<String>,
41 /// A Linux build of `g1t-runner` to run inside containers, instead of
42 /// this binary (on Linux) or the release's (elsewhere).
43 #[serde(default)]
44 pub harness: Option<PathBuf>,
45 /// Checks for new versions and updates itself.
46 #[serde(default = "yes")]
47 pub auto_update: bool,
48}
49
50fn yes() -> bool {
51 true
52}
53
54/// The runner's folder: `--dir`, `G1T_RUNNER_DIR`, else `.g1t-runner` in
55/// the home folder.
56pub fn folder(given: Option<&str>) -> PathBuf {
57 if let Some(dir) = given.filter(|d| !d.is_empty()) {
58 return PathBuf::from(dir);
59 }
60 if let Some(dir) = std::env::var_os("G1T_RUNNER_DIR").filter(|d| !d.is_empty()) {
61 return PathBuf::from(dir);
62 }
63 let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
64 .map(PathBuf::from)
65 .unwrap_or_else(|| PathBuf::from("."));
66 home.join(".g1t-runner")
67}
68
69pub fn path(folder: &Path) -> PathBuf {
70 folder.join("config.json")
71}
72
73pub fn load(folder: &Path) -> Result<Config> {
74 let file = path(folder);
75 let text = std::fs::read_to_string(&file)
76 .with_context(|| format!("this runner is not registered ({} not found): run `g1t-runner register` first", file.display()))?;
77 serde_json::from_str(&text).with_context(|| format!("{} does not read", file.display()))
78}
79
80/// Writes the configuration so only its owner can read it, through a
81/// temporary file, so a crash never leaves half a credential.
82pub fn save(folder: &Path, config: &Config) -> Result<()> {
83 std::fs::create_dir_all(folder).with_context(|| format!("could not make {}", folder.display()))?;
84 let file = path(folder);
85 let temp = folder.join("config.json.new");
86 std::fs::write(&temp, serde_json::to_string_pretty(config)?)?;
87 #[cfg(unix)]
88 {
89 use std::os::unix::fs::PermissionsExt;
90 std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
91 }
92 std::fs::rename(&temp, &file).with_context(|| format!("could not write {}", file.display()))?;
93 Ok(())
94}
95
96pub fn forget(folder: &Path) {
97 let _ = std::fs::remove_file(path(folder));
98}
99
100/// The API for a site: `https://g1t.sh` calls `https://api.g1t.sh`.
101pub fn api_for(url: &str) -> String {
102 let url = url.trim_end_matches('/');
103 match url.split_once("://") {
104 Some((scheme, host)) if !host.starts_with("api.") && !host.starts_with("localhost") && !host.starts_with("127.") => {
105 format!("{scheme}://api.{host}")
106 }
107 _ => url.to_owned(),
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn the_api_is_found_from_the_site() {
117 assert_eq!(api_for("https://g1t.sh"), "https://api.g1t.sh");
118 assert_eq!(api_for("https://g1t.sh/"), "https://api.g1t.sh");
119 assert_eq!(api_for("https://api.g1t.sh"), "https://api.g1t.sh");
120 assert_eq!(api_for("https://git.example.com"), "https://api.git.example.com");
121 assert_eq!(api_for("http://localhost:8787"), "http://localhost:8787");
122 }
123
124 #[test]
125 fn a_configuration_survives_a_round_trip() {
126 let dir = std::env::temp_dir().join(format!("g1t-runner-config-{}", std::process::id()));
127 let config = Config {
128 url: "https://g1t.sh".into(),
129 api: "https://api.g1t.sh".into(),
130 runner: "rnr_1".into(),
131 name: "build-01".into(),
132 credential: "g1tr_x".into(),
133 workspace: "acme".into(),
134 repo: None,
135 group: Some("Default".into()),
136 labels: vec!["self-hosted".into(), "linux".into()],
137 ephemeral: false,
138 work_dir: dir.join("work"),
139 docker: true,
140 image: None,
141 agent_image: None,
142 harness: None,
143 auto_update: true,
144 };
145 save(&dir, &config).unwrap();
146 let back = load(&dir).unwrap();
147 assert_eq!(back.runner, "rnr_1");
148 assert_eq!(back.labels, config.labels);
149 forget(&dir);
150 assert!(load(&dir).is_err());
151 let _ = std::fs::remove_dir_all(&dir);
152 }
153}