Skip to content

g1t/crates/runner/src/docker/oci.rs

274 lines12,541 bytesCodeBlame
1//! `runc`, as the job's Docker Engine finds it. The Engine is started with
2//! a folder of g1t's first on its `PATH`, holding this program under the
3//! name `runc`, so every container it and its builder start passes
4//! through here on its way to the real runc. Two changes, then the real
5//! runc runs with the same arguments:
6//!
7//! - **BuildKit's `RUN` steps** that would join a bridge network join the
8//! job's own network instead (their network namespace and libnetwork's
9//! hook are taken out of the container's config), because a sandbox has
10//! no route out of a bridge. `RUN --network=none` stays without one.
11//! - **In a guarded job**, every container (`docker run`, services, build
12//! steps, `docker exec`) gets the certificate the job's HTTPS is
13//! re-signed with: the folder of certificates at `/dev/g1t-egress`, and
14//! the variables that point common tools at it, unless the container
15//! sets them itself. `/dev` is a filesystem of the container's own, so
16//! nothing of this is ever written into an image's layers.
17//!
18//! Anything unexpected leaves the config as it was: this never stops a
19//! container from starting.
20
21use serde_json::{Value, json};
22
23/// Where the certificates are seen inside a container.
24pub(crate) const CA_MOUNT: &str = "/dev/g1t-egress";
25/// The system's bundle, with the egress certificate added.
26pub(crate) const BUNDLE: &str = "ca-certificates.crt";
27/// The egress certificate alone.
28pub(crate) const EGRESS: &str = "egress-ca.crt";
29
30/// The variables a container is given in a guarded job, as the sandbox's
31/// own (services/runner egress.ts `EGRESS_ENV`) point its tools.
32pub(crate) fn ca_variables() -> Vec<(&'static str, String)> {
33 let bundle = format!("{CA_MOUNT}/{BUNDLE}");
34 vec![
35 ("SSL_CERT_FILE", bundle.clone()),
36 ("NODE_EXTRA_CA_CERTS", format!("{CA_MOUNT}/{EGRESS}")),
37 ("REQUESTS_CA_BUNDLE", bundle.clone()),
38 ("CURL_CA_BUNDLE", bundle.clone()),
39 ("PIP_CERT", bundle.clone()),
40 ("GIT_SSL_CAINFO", bundle.clone()),
41 ("CARGO_HTTP_CAINFO", bundle),
42 ]
43}
44
45/// What runc was asked to do, from its arguments.
46#[derive(Debug, Default, PartialEq)]
47pub(crate) struct Call {
48 pub(crate) command: Option<String>,
49 pub(crate) bundle: Option<String>,
50 /// `runc exec --process <file>`.
51 pub(crate) process: Option<String>,
52}
53
54/// runc's global flags that take a value.
55const GLOBAL_VALUES: &[&str] = &["--root", "--log", "--log-format", "--criu", "--rootless"];
56
57pub(crate) fn parse(args: &[String]) -> Call {
58 let mut call = Call::default();
59 let mut iter = args.iter().peekable();
60 while let Some(arg) = iter.next() {
61 if call.command.is_none() {
62 if GLOBAL_VALUES.contains(&arg.as_str()) {
63 iter.next();
64 } else if !arg.starts_with('-') {
65 call.command = Some(arg.clone());
66 }
67 continue;
68 }
69 let (flag, inline) = match arg.split_once('=') {
70 Some((flag, value)) if flag.starts_with("--") => (flag, Some(value.to_owned())),
71 _ => (arg.as_str(), None),
72 };
73 let mut value = || inline.clone().or_else(|| iter.next().cloned());
74 match flag {
75 "--bundle" | "-b" => call.bundle = value(),
76 "--process" | "-p" => call.process = value(),
77 _ => {}
78 }
79 }
80 call
81}
82
83/// Whether a hook entry is libnetwork's, which joins a container to a
84/// network the Engine set up.
85fn libnetwork_hook(hook: &Value) -> bool {
86 hook.get("args")
87 .and_then(Value::as_array)
88 .is_some_and(|args| args.iter().any(|a| a.as_str().is_some_and(|a| a.contains("libnetwork-setkey"))))
89}
90
91/// Adds the certificates and their variables to a process's environment.
92fn add_variables(process: &mut Value) -> bool {
93 let Some(env) = process.get_mut("env").and_then(Value::as_array_mut) else { return false };
94 let mut changed = false;
95 for (name, value) in ca_variables() {
96 let prefix = format!("{name}=");
97 if !env.iter().any(|e| e.as_str().is_some_and(|e| e.starts_with(&prefix))) {
98 env.push(json!(format!("{name}={value}")));
99 changed = true;
100 }
101 }
102 changed
103}
104
105/// Changes a container's `config.json`. `ca_dir`: the folder of
106/// certificates to give it, in a guarded job. Returns whether it changed.
107pub(crate) fn patch_config(config: &mut Value, bundle: &str, ca_dir: Option<&str>) -> bool {
108 let mut changed = false;
109 // A BuildKit step bound for a bridge network: the job's network instead.
110 if bundle.contains("/buildkit/") {
111 let mut bridged = false;
112 if let Some(hooks) = config.get_mut("hooks").and_then(Value::as_object_mut) {
113 for list in hooks.values_mut() {
114 if let Some(entries) = list.as_array_mut() {
115 let before = entries.len();
116 entries.retain(|hook| !libnetwork_hook(hook));
117 bridged |= entries.len() != before;
118 }
119 }
120 }
121 if bridged && let Some(namespaces) = config.pointer_mut("/linux/namespaces").and_then(Value::as_array_mut) {
122 namespaces.retain(|ns| ns.get("type").and_then(Value::as_str) != Some("network"));
123 changed = true;
124 }
125 }
126 if let Some(dir) = ca_dir {
127 if let Some(mounts) = config.get_mut("mounts").and_then(Value::as_array_mut)
128 && !mounts.iter().any(|m| m.get("destination").and_then(Value::as_str) == Some(CA_MOUNT))
129 {
130 mounts.push(json!({
131 "destination": CA_MOUNT,
132 "type": "bind",
133 "source": dir,
134 "options": ["rbind", "ro", "nosuid", "nodev", "noexec"],
135 }));
136 changed = true;
137 }
138 if let Some(process) = config.get_mut("process") {
139 changed |= add_variables(process);
140 }
141 }
142 changed
143}
144
145/// Changes the process of a `runc exec` (`docker exec`), which has an
146/// environment of its own.
147pub(crate) fn patch_process(process: &mut Value, ca_dir: Option<&str>) -> bool {
148 ca_dir.is_some() && add_variables(process)
149}
150
151/// Whether this program was started as `runc`.
152pub(crate) fn invoked_as_runc() -> bool {
153 std::env::args_os()
154 .next()
155 .and_then(|arg| std::path::Path::new(&arg).file_name().map(|name| name == "runc"))
156 .unwrap_or(false)
157}
158
159/// Rewrites a JSON file in place, if `change` changes it.
160#[cfg(unix)]
161fn rewrite(path: &std::path::Path, change: impl FnOnce(&mut Value) -> bool) {
162 let Ok(text) = std::fs::read(path) else { return };
163 let Ok(mut value) = serde_json::from_slice::<Value>(&text) else { return };
164 if change(&mut value)
165 && let Ok(out) = serde_json::to_vec(&value)
166 {
167 let staged = path.with_extension("g1t");
168 if std::fs::write(&staged, out).is_ok() {
169 let _ = std::fs::rename(&staged, path);
170 }
171 }
172}
173
174/// `runc …`: changes the container's config, then becomes the real runc.
175#[cfg(unix)]
176pub(crate) fn main() -> ! {
177 use std::os::unix::process::CommandExt;
178 let args: Vec<String> = std::env::args().skip(1).collect();
179 let call = parse(&args);
180 let ca_dir = std::env::var("G1T_DOCKER_CA_DIR").ok().filter(|dir| std::path::Path::new(dir).is_dir());
181 match (call.command.as_deref(), &call.bundle, &call.process) {
182 (Some("create" | "run"), Some(bundle), _) => {
183 rewrite(&std::path::Path::new(bundle).join("config.json"), |config| patch_config(config, bundle, ca_dir.as_deref()));
184 }
185 (Some("exec"), _, Some(process)) => rewrite(std::path::Path::new(process), |process| patch_process(process, ca_dir.as_deref())),
186 _ => {}
187 }
188 let real = std::env::var("G1T_REAL_RUNC").unwrap_or_else(|_| "/usr/bin/runc".into());
189 let error = std::process::Command::new(&real).arg0("runc").args(&args).exec();
190 eprintln!("g1t-runner: could not run {real}: {error}");
191 std::process::exit(127)
192}
193
194#[cfg(not(unix))]
195pub(crate) fn main() -> ! {
196 eprintln!("g1t-runner: runc runs on Linux only");
197 std::process::exit(127)
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 fn args(text: &str) -> Vec<String> {
205 text.split_whitespace().map(str::to_owned).collect()
206 }
207
208 #[test]
209 fn calls_are_read_as_containerd_and_buildkit_make_them() {
210 let call = parse(&args(
211 "--root /var/run/docker/runtime-runc/moby --log /x/log.json --log-format json create --bundle /var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc --pid-file /x/init.pid abc",
212 ));
213 assert_eq!(call.command.as_deref(), Some("create"));
214 assert_eq!(call.bundle.as_deref(), Some("/var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc"));
215 let call = parse(&args("--log /var/lib/docker/buildkit/executor/runc-log.json --log-format json run --bundle /var/lib/docker/buildkit/executor/x1 --keep x1"));
216 assert_eq!((call.command.as_deref(), call.bundle.as_deref()), (Some("run"), Some("/var/lib/docker/buildkit/executor/x1")));
217 let call = parse(&args("--root /r exec --process /tmp/runc-process1 --detach --pid-file /p abc"));
218 assert_eq!((call.command.as_deref(), call.process.as_deref()), (Some("exec"), Some("/tmp/runc-process1")));
219 assert_eq!(parse(&args("--root=/r start abc")).command.as_deref(), Some("start"));
220 assert_eq!(parse(&args("create --bundle=/b x")).bundle.as_deref(), Some("/b"));
221 assert_eq!(parse(&args("--version")), Call::default());
222 }
223
224 fn build_step(hooked: bool) -> Value {
225 let hooks = if hooked {
226 json!({ "prestart": [{ "path": "/proc/21/exe", "args": ["libnetwork-setkey", "-exec-root=/var/run/docker", "abc", "def"] }] })
227 } else {
228 json!({})
229 };
230 json!({
231 "hostname": "buildkitsandbox",
232 "process": { "env": ["PATH=/usr/bin", "SSL_CERT_FILE=/mine.pem"] },
233 "mounts": [{ "destination": "/proc" }, { "destination": "/dev", "type": "tmpfs" }],
234 "linux": { "namespaces": [{ "type": "pid" }, { "type": "network" }, { "type": "mount" }] },
235 "hooks": hooks,
236 })
237 }
238
239 #[test]
240 fn build_steps_bound_for_a_bridge_join_the_jobs_network() {
241 let mut config = build_step(true);
242 assert!(patch_config(&mut config, "/var/lib/docker/buildkit/executor/x1", None));
243 let namespaces: Vec<&str> = config["linux"]["namespaces"].as_array().unwrap().iter().map(|n| n["type"].as_str().unwrap()).collect();
244 assert_eq!(namespaces, vec!["pid", "mount"]);
245 assert!(config["hooks"]["prestart"].as_array().unwrap().is_empty());
246 // RUN --network=none: no libnetwork hook, so its own empty network.
247 let mut config = build_step(false);
248 assert!(!patch_config(&mut config, "/var/lib/docker/buildkit/executor/x2", None));
249 assert_eq!(config["linux"]["namespaces"].as_array().unwrap().len(), 3);
250 // A container the Engine runs is the API proxy's business, not this.
251 let mut config = build_step(true);
252 assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", None));
253 }
254
255 #[test]
256 fn guarded_containers_get_the_certificates_without_losing_their_own_settings() {
257 let mut config = build_step(false);
258 assert!(patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs")));
259 let mounts = config["mounts"].as_array().unwrap();
260 let last = mounts.last().unwrap();
261 assert_eq!(last["destination"], CA_MOUNT);
262 assert_eq!(last["source"], "/run/g1t-docker/certs");
263 assert!(last["options"].as_array().unwrap().contains(&json!("ro")));
264 let env: Vec<&str> = config["process"]["env"].as_array().unwrap().iter().map(|e| e.as_str().unwrap()).collect();
265 assert!(env.contains(&"SSL_CERT_FILE=/mine.pem"));
266 assert!(!env.contains(&"SSL_CERT_FILE=/dev/g1t-egress/ca-certificates.crt"));
267 assert!(env.contains(&"NODE_EXTRA_CA_CERTS=/dev/g1t-egress/egress-ca.crt"));
268 // Patching twice changes nothing more.
269 assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs")));
270 let mut process = json!({ "env": ["PATH=/bin"] });
271 assert!(patch_process(&mut process, Some("/run/g1t-docker/certs")));
272 assert!(!patch_process(&mut json!({ "env": [] }), None));
273 }
274}