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.
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 1 | //! Containers a job asks for, as GitHub's runner starts them, with the |
| 2 | //! `docker` command: its `services:` (a database beside the steps), its | |
| 3 | //! `container:` (every step run inside an image), `uses: docker://image` | |
| 4 | //! steps and Docker actions (`runs.using: docker`). | |
| 5 | //! | |
| 6 | //! On g1t's machines the Engine is the job's own (crate::docker). Every | |
| 7 | //! container shares the job's network there, so a service is reached at | |
| 8 | //! `localhost:<port>` as on GitHub's runner, and by its name as from a job | |
| 9 | //! container: the API proxy makes each service's name mean 127.0.0.1. | |
| 10 | ||
| 11 | use std::collections::{BTreeMap, BTreeSet}; | |
| 12 | use std::path::{Path, PathBuf}; | |
| 13 | use std::process::Command; | |
| 14 | use std::time::{Duration, Instant}; | |
| 15 | ||
| 16 | use g1t_actions::expr; | |
| 17 | use serde_json::{Map, Value, json}; | |
| 18 | ||
| 19 | use super::files::StepFiles; | |
| 20 | use super::process::{self, Commands, Ended}; | |
| 21 | use super::Job; | |
| 22 | ||
| 23 | /// Set in a job a self-hosted runner started inside its `container:` image. | |
| 24 | pub(crate) const IN_JOB_CONTAINER: &str = "G1T_JOB_CONTAINER"; | |
| 25 | /// Where a job container finds the runner's Node, for JavaScript actions. | |
| 26 | pub(crate) const CONTAINER_NODE: &str = "/__e/node24/bin/node"; | |
| 27 | /// GitHub's folders inside a Docker action's container. | |
| 28 | const GITHUB_WORKSPACE: &str = "/github/workspace"; | |
| 29 | const GITHUB_HOME: &str = "/github/home"; | |
| 30 | const GITHUB_WORKFLOW: &str = "/github/workflow"; | |
| 31 | const GITHUB_FILE_COMMANDS: &str = "/github/file_commands"; | |
| 32 | ||
| 33 | /// The job's own container, when it has `container:`. | |
| 34 | pub(crate) struct JobContainer { | |
| 35 | pub(crate) id: String, | |
| 36 | /// `bash`, or `sh` in an image without it. | |
| 37 | pub(crate) shell: &'static str, | |
| 38 | /// Whether the runner's Node runs in it. | |
| 39 | pub(crate) node: bool, | |
| 40 | /// The image's own `PATH`. | |
| 41 | pub(crate) path: String, | |
| 42 | } | |
| 43 | ||
| 44 | /// A `services:` entry or `container:`, read. | |
| 45 | #[derive(Debug, Default, PartialEq)] | |
| 46 | pub(crate) struct ContainerSpec { | |
| 47 | pub(crate) image: String, | |
| 48 | pub(crate) env: BTreeMap<String, String>, | |
| 49 | pub(crate) ports: Vec<String>, | |
| 50 | pub(crate) volumes: Vec<String>, | |
| 51 | pub(crate) options: Vec<String>, | |
| 52 | pub(crate) credentials: Option<(String, String)>, | |
| 53 | pub(crate) command: Vec<String>, | |
| 54 | pub(crate) entrypoint: Option<String>, | |
| 55 | } | |
| 56 | ||
| 57 | /// Splits a command line as a shell would: words, with `'…'`, `"…"` and | |
| 58 | /// `\` quoting. No variables are expanded. | |
| 59 | pub(crate) fn split_words(text: &str) -> Vec<String> { | |
| 60 | let mut words = Vec::new(); | |
| 61 | let mut word = String::new(); | |
| 62 | let mut started = false; | |
| 63 | let mut chars = text.chars().peekable(); | |
| 64 | while let Some(c) = chars.next() { | |
| 65 | match c { | |
| 66 | '\'' => { | |
| 67 | started = true; | |
| 68 | for c in chars.by_ref() { | |
| 69 | if c == '\'' { | |
| 70 | break; | |
| 71 | } | |
| 72 | word.push(c); | |
| 73 | } | |
| 74 | } | |
| 75 | '"' => { | |
| 76 | started = true; | |
| 77 | while let Some(c) = chars.next() { | |
| 78 | match c { | |
| 79 | '"' => break, | |
| 80 | '\\' if matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => word.push(chars.next().unwrap_or('\\')), | |
| 81 | c => word.push(c), | |
| 82 | } | |
| 83 | } | |
| 84 | } | |
| 85 | '\\' => { | |
| 86 | started = true; | |
| 87 | if let Some(next) = chars.next() | |
| 88 | && next != '\n' | |
| 89 | { | |
| 90 | word.push(next); | |
| 91 | } | |
| 92 | } | |
| 93 | c if c.is_whitespace() => { | |
| 94 | if started { | |
| 95 | words.push(std::mem::take(&mut word)); | |
| 96 | started = false; | |
| 97 | } | |
| 98 | } | |
| 99 | c => { | |
| 100 | started = true; | |
| 101 | word.push(c); | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | if started { | |
| 106 | words.push(word); | |
| 107 | } | |
| 108 | words | |
| 109 | } | |
| 110 | ||
| 111 | fn texts(value: Option<&Value>) -> Vec<String> { | |
| 112 | match value { | |
| 113 | Some(Value::Array(items)) => items.iter().map(expr::to_text).filter(|s| !s.is_empty()).collect(), | |
| 114 | Some(Value::Null) | None => Vec::new(), | |
| 115 | Some(other) => vec![expr::to_text(other)].into_iter().filter(|s| !s.is_empty()).collect(), | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | /// Reads a `services:` entry or `container:`, its expressions already | |
| 120 | /// read: a string (the image) or a mapping. | |
| 121 | pub(crate) fn container_spec(value: &Value) -> ContainerSpec { | |
| 122 | match value { | |
| 123 | Value::String(image) => ContainerSpec { image: image.trim().to_owned(), ..ContainerSpec::default() }, | |
| 124 | Value::Object(fields) => ContainerSpec { | |
| 125 | image: fields.get("image").map(expr::to_text).unwrap_or_default().trim().to_owned(), | |
| 126 | env: fields | |
| 127 | .get("env") | |
| 128 | .and_then(Value::as_object) | |
| 129 | .map(|env| env.iter().map(|(k, v)| (k.clone(), expr::to_text(v))).collect()) | |
| 130 | .unwrap_or_default(), | |
| 131 | ports: texts(fields.get("ports")), | |
| 132 | volumes: texts(fields.get("volumes")), | |
| 133 | options: fields.get("options").map(|o| split_words(&expr::to_text(o))).unwrap_or_default(), | |
| 134 | credentials: fields.get("credentials").and_then(Value::as_object).and_then(|c| { | |
| 135 | let username = c.get("username").map(expr::to_text).unwrap_or_default(); | |
| 136 | let password = c.get("password").map(expr::to_text).unwrap_or_default(); | |
| 137 | (!username.is_empty() || !password.is_empty()).then_some((username, password)) | |
| 138 | }), | |
| 139 | command: fields.get("command").map(|c| split_words(&expr::to_text(c))).unwrap_or_default(), | |
| 140 | entrypoint: fields.get("entrypoint").map(expr::to_text).filter(|e| !e.is_empty()), | |
| 141 | }, | |
| 142 | _ => ContainerSpec::default(), | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | /// `job.services.<id>.ports`: each container port, and the host port it | |
| 147 | /// is reached on. On g1t's machines a port left for Docker to choose is | |
| 148 | /// the container's own. | |
| 149 | pub(crate) fn port_map(ports: &[String]) -> BTreeMap<String, String> { | |
| 150 | let mut map = BTreeMap::new(); | |
| 151 | for port in ports { | |
| 152 | let without_protocol = port.split('/').next().unwrap_or(port); | |
| 153 | let parts: Vec<&str> = without_protocol.split(':').collect(); | |
| 154 | let (host, container) = match parts.as_slice() { | |
| 155 | [container] => (*container, *container), | |
| 156 | [host, container] => (if host.is_empty() { *container } else { *host }, *container), | |
| 157 | [_, host, container] => (if host.is_empty() { *container } else { *host }, *container), | |
| 158 | _ => continue, | |
| 159 | }; | |
| 160 | if !container.is_empty() { | |
| 161 | map.insert(container.to_owned(), host.to_owned()); | |
| 162 | } | |
| 163 | } | |
| 164 | map | |
| 165 | } | |
| 166 | ||
| 167 | /// The registry an image is pulled from, for signing in with | |
| 168 | /// `credentials:`. | |
| 169 | pub(crate) fn registry_of(image: &str) -> String { | |
| 170 | match image.split_once('/') { | |
| 171 | Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => first.to_owned(), | |
| 172 | _ => "https://index.docker.io/v1/".to_owned(), | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | /// A name for Docker from any text: lower case, letters, digits, `_`, | |
| 177 | /// `.` and `-`. | |
| 178 | pub(crate) fn docker_name(text: &str) -> String { | |
| 179 | let name: String = text | |
| 180 | .to_ascii_lowercase() | |
| 181 | .chars() | |
| 182 | .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '_' }) | |
| 183 | .collect(); | |
| 184 | name.trim_matches(['_', '.', '-']).chars().take(100).collect() | |
| 185 | } | |
| 186 | ||
| 187 | /// The `docker create` arguments of a container. `env` is passed by name | |
| 188 | /// (`-e NAME`), its values in the command's environment, so secrets never | |
| 189 | /// stand on a command line. `keep_alive`: a job container, which waits | |
| 190 | /// while steps run in it. | |
| 191 | pub(crate) fn create_args(name: &str, network: Option<&str>, alias: Option<&str>, spec: &ContainerSpec, mounts: &[(String, String)], keep_alive: bool) -> Vec<String> { | |
| 192 | let mut args: Vec<String> = vec!["create".into(), "--name".into(), name.into(), "--label".into(), "g1t-job".into()]; | |
| 193 | if let Some(network) = network { | |
| 194 | args.extend(["--network".into(), network.into()]); | |
| 195 | if let Some(alias) = alias { | |
| 196 | args.extend(["--network-alias".into(), alias.into()]); | |
| 197 | } | |
| 198 | } | |
| 199 | for port in &spec.ports { | |
| 200 | args.extend(["-p".into(), port.clone()]); | |
| 201 | } | |
| 202 | for volume in &spec.volumes { | |
| 203 | args.extend(["-v".into(), volume.clone()]); | |
| 204 | } | |
| 205 | for (from, to) in mounts { | |
| 206 | args.extend(["-v".into(), format!("{from}:{to}")]); | |
| 207 | } | |
| 208 | for name in spec.env.keys() { | |
| 209 | args.extend(["-e".into(), name.clone()]); | |
| 210 | } | |
| 211 | args.extend(spec.options.iter().cloned()); | |
| 212 | if keep_alive { | |
| 213 | args.extend(["--entrypoint".into(), "tail".into(), spec.image.clone(), "-f".into(), "/dev/null".into()]); | |
| 214 | } else { | |
| 215 | if let Some(entrypoint) = &spec.entrypoint { | |
| 216 | args.extend(["--entrypoint".into(), entrypoint.clone()]); | |
| 217 | } | |
| 218 | args.push(spec.image.clone()); | |
| 219 | args.extend(spec.command.iter().cloned()); | |
| 220 | } | |
| 221 | args | |
| 222 | } | |
| 223 | ||
| 224 | /// A Docker action's run, or a `docker://` step's. | |
| 225 | #[derive(Clone, Debug)] | |
| 226 | pub(crate) struct DockerRun { | |
| 227 | pub(crate) image: String, | |
| 228 | pub(crate) entrypoint: Option<String>, | |
| 229 | pub(crate) args: Vec<String>, | |
| 230 | /// The step's variables, `INPUT_*` included. | |
| 231 | pub(crate) env: BTreeMap<String, String>, | |
| 232 | } | |
| 233 | ||
| 234 | /// What a command line shows: secrets are passed by name, so nothing | |
| 235 | /// needs hiding, but long lists are kept readable. | |
| 236 | fn shown(args: &[String]) -> String { | |
| 237 | args.iter().map(|a| if a.contains(' ') || a.is_empty() { format!("'{a}'") } else { a.clone() }).collect::<Vec<_>>().join(" ") | |
| 238 | } | |
| 239 | ||
| 240 | impl Job { | |
| 241 | /// Runs `docker` with `args`, its output in the log. `env`: the | |
| 242 | /// variables it passes to a container by name. | |
| 243 | pub(crate) fn docker(&mut self, args: &[String], env: &BTreeMap<String, String>, timeout: Duration) -> bool { | |
| 244 | self.log.line(&format!("[command]docker {}", shown(args))); | |
| 245 | let mut command = Command::new("docker"); | |
| 246 | command.args(args).env_clear().envs(self.docker_cli_env()).envs(env); | |
| 247 | let mut commands = Commands::default(); | |
| 248 | matches!(process::run(command, timeout.min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0))) | |
| 249 | } | |
| 250 | ||
| 251 | /// What `docker` prints, or None if it fails. | |
| 252 | pub(crate) fn docker_output(&self, args: &[&str]) -> Option<String> { | |
| 253 | let output = Command::new("docker").args(args).env_clear().envs(self.docker_cli_env()).output().ok()?; | |
| 254 | output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()) | |
| 255 | } | |
| 256 | ||
| 257 | /// What the `docker` command itself needs of the sandbox's variables: | |
| 258 | /// where it is, where its config is, and how to reach the Engine. | |
| 259 | fn docker_cli_env(&self) -> BTreeMap<String, String> { | |
| 260 | ["PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY"] | |
| 261 | .iter() | |
| 262 | .filter_map(|name| self.base_env_value(name).map(|value| (name.to_string(), value))) | |
| 263 | .collect() | |
| 264 | } | |
| 265 | ||
| 266 | /// Makes sure Docker answers before a job's containers start: the | |
| 267 | /// job's own Engine, on g1t's machines. | |
| 268 | fn docker_ready(&mut self) -> bool { | |
| 269 | if self.docker_hosted { | |
| 270 | let started = crate::docker::engine::ensure_started(); | |
| 271 | self.log_docker_notes(); | |
| 272 | // Why it could not start is among the notes just logged. | |
| 273 | return started.is_ok(); | |
| 274 | } | |
| 275 | if self.docker_output(&["version", "--format", "{{.Server.Version}}"]).is_some() { | |
| 276 | return true; | |
| 277 | } | |
| 278 | self.log.line("##[error]This job needs Docker (`services`, `container` or a Docker action), and Docker does not answer here. On a self-hosted runner, run the runner directly on a machine with Docker (`--no-docker`)."); | |
| 279 | false | |
| 280 | } | |
| 281 | ||
| 282 | pub(crate) fn log_docker_notes(&mut self) { | |
| 283 | for line in crate::docker::take_notes() { | |
| 284 | self.log.line(&line); | |
| 285 | } | |
| 286 | } | |
| 287 | ||
| 288 | /// Pulls an image, signed in with `credentials` if it has them. | |
| 289 | fn pull(&mut self, image: &str, credentials: Option<&(String, String)>) -> bool { | |
| 290 | let Some((username, password)) = credentials else { | |
| 291 | return self.docker(&["pull".into(), image.into()], &BTreeMap::new(), Duration::from_secs(1800)); | |
| 292 | }; | |
| 293 | // A config of its own, so the credentials go no further than this pull. | |
| 294 | let config = self.temp.join(format!("docker-config-{:x}", super::rand_id())); | |
| 295 | let _ = std::fs::create_dir_all(&config); | |
| 296 | let registry = registry_of(image); | |
| 297 | let config_text = config.display().to_string(); | |
| 298 | self.log.line(&format!("[command]docker --config {config_text} login {registry} --username *** --password-stdin")); | |
| 299 | let login = Command::new("docker") | |
| 300 | .args(["--config", &config_text, "login", ®istry, "--username", username, "--password-stdin"]) | |
| 301 | .env_clear() | |
| 302 | .envs(self.docker_cli_env()) | |
| 303 | .stdin(std::process::Stdio::piped()) | |
| 304 | .stdout(std::process::Stdio::piped()) | |
| 305 | .stderr(std::process::Stdio::piped()) | |
| 306 | .spawn() | |
| 307 | .and_then(|mut child| { | |
| 308 | use std::io::Write; | |
| 309 | if let Some(mut stdin) = child.stdin.take() { | |
| 310 | let _ = stdin.write_all(password.as_bytes()); | |
| 311 | } | |
| 312 | child.wait_with_output() | |
| 313 | }); | |
| 314 | let ok = match login { | |
| 315 | Ok(output) if output.status.success() => { | |
| 316 | self.docker(&["--config".into(), config_text.clone(), "pull".into(), image.into()], &BTreeMap::new(), Duration::from_secs(1800)) | |
| 317 | } | |
| 318 | Ok(output) => { | |
| 319 | self.log.line(&format!("##[error]Could not sign in to {registry}: {}", String::from_utf8_lossy(&output.stderr).trim())); | |
| 320 | false | |
| 321 | } | |
| 322 | Err(error) => { | |
| 323 | self.log.line(&format!("##[error]Could not run docker login: {error}")); | |
| 324 | false | |
| 325 | } | |
| 326 | }; | |
| 327 | let _ = std::fs::remove_dir_all(&config); | |
| 328 | ok | |
| 329 | } | |
| 330 | ||
| 331 | /// Reads a `services:` entry or `container:` with the job's contexts. | |
| 332 | fn read_container(&self, value: &Value) -> ContainerSpec { | |
| 333 | let frame = super::Frame::default(); | |
| 334 | let env = self.env_context(&frame); | |
| 335 | let contexts = self.contexts_for(&frame, &env); | |
| 336 | let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null); | |
| 337 | container_spec(&value) | |
| 338 | } | |
| 339 | ||
| 340 | /// Starts the job's services and its container, before its steps: | |
| 341 | /// GitHub's "Initialize containers". Returns whether they all started. | |
| 342 | pub(crate) fn start_containers(&mut self) -> bool { | |
| 343 | let services: Vec<(String, Value)> = self.spec["spec"]["services"].as_object().map(|s| s.iter().map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default(); | |
| 344 | let container = self.spec["spec"].get("container").filter(|c| !c.is_null()).cloned(); | |
| 345 | let container = container.map(|c| self.read_container(&c)).filter(|c| !c.image.is_empty()); | |
| 346 | let services: Vec<(String, ContainerSpec)> = services.into_iter().map(|(name, value)| (name, self.read_container(&value))).filter(|(_, spec)| !spec.image.is_empty()).collect(); | |
| 347 | // A self-hosted runner in Docker mode started this job in its | |
| 348 | // `container:` image already (selfhosted/exec.rs). | |
| 349 | let container = container.filter(|_| std::env::var_os(IN_JOB_CONTAINER).is_none()); | |
| 350 | if services.is_empty() && container.is_none() { | |
| 351 | return true; | |
| 352 | } | |
| 353 | // A self-hosted machine without Docker runs the steps as before, | |
| 354 | // on the machine, without the containers. | |
| 355 | if !self.docker_hosted && self.docker_output(&["version", "--format", "{{.Server.Version}}"]).is_none() { | |
| 356 | self.log.line("##[warning]Docker does not answer on this runner, so the job's `services` and `container` are not started and its steps run on the machine. Run the runner directly on a machine with Docker (`--no-docker`) to start them."); | |
| 357 | return true; | |
| 358 | } | |
| 359 | self.log.line("##[group]Initialize containers"); | |
| 360 | let ok = self.docker_ready() && self.start_containers_now(services, container); | |
| 361 | self.log.line("##[endgroup]"); | |
| 362 | ok | |
| 363 | } | |
| 364 | ||
| 365 | fn start_containers_now(&mut self, services: Vec<(String, ContainerSpec)>, container: Option<ContainerSpec>) -> bool { | |
| 366 | let job_id = docker_name(&format!("{}_{:x}", self.spec["name"].as_str().unwrap_or("job"), super::rand_id() & 0xffff_ffff)); | |
| 367 | let network = format!("g1t_{job_id}"); | |
| 368 | if !self.docker(&["network".into(), "create".into(), "--label".into(), "g1t-job".into(), network.clone()], &BTreeMap::new(), Duration::from_secs(60)) { | |
| 369 | self.log.line("##[error]Could not make the job's network."); | |
| 370 | return false; | |
| 371 | } | |
| 372 | self.network = Some(network.clone()); | |
| 373 | ||
| 374 | let mut services_context = Map::new(); | |
| 375 | for (service, spec) in &services { | |
| 376 | if !self.pull(&spec.image, spec.credentials.as_ref()) { | |
| 377 | self.log.line(&format!("##[error]Could not pull {} for the service `{service}`.", spec.image)); | |
| 378 | return false; | |
| 379 | } | |
| 380 | let name = docker_name(&format!("{service}_{job_id}")); | |
| 381 | let args = create_args(&name, Some(&network), Some(service), spec, &[], false); | |
| 382 | if !self.docker(&args, &spec.env, Duration::from_secs(300)) { | |
| 383 | self.log.line(&format!("##[error]Could not create the service `{service}`.")); | |
| 384 | return false; | |
| 385 | } | |
| 386 | self.services.push((service.clone(), name.clone())); | |
| 387 | if !self.docker(&["start".into(), name.clone()], &BTreeMap::new(), Duration::from_secs(300)) { | |
| 388 | self.log.line(&format!("##[error]Could not start the service `{service}`.")); | |
| 389 | return false; | |
| 390 | } | |
| 391 | let id = self.docker_output(&["inspect", "--format", "{{.Id}}", &name]).unwrap_or_default(); | |
| 392 | let ports: Map<String, Value> = port_map(&spec.ports).into_iter().map(|(k, v)| (k, json!(v))).collect(); | |
| 393 | services_context.insert(service.clone(), json!({ "id": id, "network": network, "ports": ports })); | |
| 394 | } | |
| 395 | ||
| 396 | if let Some(spec) = container { | |
| 397 | if !self.pull(&spec.image, spec.credentials.as_ref()) { | |
| 398 | self.log.line(&format!("##[error]Could not pull {} for the job's container.", spec.image)); | |
| 399 | return false; | |
| 400 | } | |
| 401 | let name = docker_name(&format!("job_{job_id}")); | |
| 402 | let mounts = self.container_mounts(); | |
| 403 | let mut spec = spec; | |
| 404 | spec.env.insert("HOME".into(), GITHUB_HOME.into()); | |
| 405 | spec.env.insert("CI".into(), "true".into()); | |
| 406 | spec.env.insert("GITHUB_ACTIONS".into(), "true".into()); | |
| 407 | let mut args = create_args(&name, Some(&network), None, &spec, &mounts, true); | |
| 408 | // Steps start in the workspace. | |
| 409 | args.splice(1..1, ["-w".to_owned(), self.workspace.display().to_string()]); | |
| 410 | if !self.docker(&args, &spec.env, Duration::from_secs(300)) || !self.docker(&["start".into(), name.clone()], &BTreeMap::new(), Duration::from_secs(300)) { | |
| 411 | self.log.line("##[error]Could not start the job's container."); | |
| 412 | return false; | |
| 413 | } | |
| 414 | let id = self.docker_output(&["inspect", "--format", "{{.Id}}", &name]).unwrap_or_default(); | |
| 415 | let has_bash = self.docker_output(&["exec", &name, "sh", "-c", "command -v bash"]).is_some_and(|out| !out.is_empty()); | |
| 416 | let node = self.docker_output(&["exec", &name, CONTAINER_NODE, "--version"]).is_some(); | |
| 417 | let path = self.docker_output(&["exec", &name, "sh", "-c", "printf %s \"$PATH\""]).unwrap_or_else(|| "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into()); | |
| 418 | if !node { | |
| 419 | self.log.line("##[warning]The runner's Node does not run in this image (it needs glibc and libstdc++), so JavaScript actions run beside the container, on g1t's image, with the same files."); | |
| 420 | } | |
| 421 | self.job_context.insert("container".into(), json!({ "id": id, "network": network })); | |
| 422 | self.container = Some(JobContainer { id: name, shell: if has_bash { "bash" } else { "sh" }, node, path }); | |
| 423 | } | |
| 424 | ||
| 425 | // Services with a health check are waited for. | |
| 426 | for (service, name) in self.services.clone() { | |
| 427 | if !self.wait_healthy(&service, &name) { | |
| 428 | return false; | |
| 429 | } | |
| 430 | } | |
| 431 | if !services_context.is_empty() { | |
| 432 | self.job_context.insert("services".into(), Value::Object(services_context)); | |
| 433 | } | |
| 434 | self.log_docker_notes(); | |
| 435 | true | |
| 436 | } | |
| 437 | ||
| 438 | /// GitHub's runner's folders, at the same paths, and Docker's socket. | |
| 439 | fn container_mounts(&self) -> Vec<(String, String)> { | |
| 440 | let home = super::paths::under_home(super::paths::HOME_RUNNER); | |
| 441 | let github_home = self.temp.join("_github_home"); | |
| 442 | let _ = std::fs::create_dir_all(&github_home); | |
| 443 | let mut mounts: Vec<(String, String)> = ["work", "_temp", "_actions", "_tool"] | |
| 444 | .iter() | |
| 445 | .map(|dir| { | |
| 446 | let path = home.join(dir); | |
| 447 | let _ = std::fs::create_dir_all(&path); | |
| 448 | (path.display().to_string(), path.display().to_string()) | |
| 449 | }) | |
| 450 | .collect(); | |
| 451 | mounts.push((github_home.display().to_string(), GITHUB_HOME.into())); | |
| 452 | if let Some(node) = which_node() { | |
| 453 | mounts.push((node.display().to_string(), format!("{CONTAINER_NODE}:ro"))); | |
| 454 | } | |
| 455 | mounts.push((crate::docker::engine::DOCKER_SOCKET.into(), crate::docker::engine::DOCKER_SOCKET.into())); | |
| 456 | mounts | |
| 457 | } | |
| 458 | ||
| 459 | fn wait_healthy(&mut self, service: &str, name: &str) -> bool { | |
| 460 | let limit = Duration::from_secs(600).min(self.remaining_time()); | |
| 461 | let until = Instant::now() + limit; | |
| 462 | let mut wait = Duration::from_secs(1); | |
| 463 | loop { | |
| 464 | let status = self.docker_output(&["inspect", "--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}", name]).unwrap_or_default(); | |
| 465 | match status.as_str() { | |
| 466 | "" => return true, | |
| 467 | "healthy" => { | |
| 468 | self.log.line(&format!("{service} is healthy.")); | |
| 469 | return true; | |
| 470 | } | |
| 471 | "unhealthy" => { | |
| 472 | self.log.line(&format!("##[error]The service `{service}` is unhealthy.")); | |
| 473 | self.service_logs(service, name); | |
| 474 | return false; | |
| 475 | } | |
| 476 | _ => {} | |
| 477 | } | |
| 478 | if Instant::now() >= until { | |
| 479 | self.log.line(&format!("##[error]The service `{service}` did not become healthy in {} s.", limit.as_secs())); | |
| 480 | self.service_logs(service, name); | |
| 481 | return false; | |
| 482 | } | |
| 483 | self.log.line(&format!("Waiting for {service} to be healthy ({status}).")); | |
| 484 | std::thread::sleep(wait); | |
| 485 | wait = (wait * 2).min(Duration::from_secs(8)); | |
| 486 | } | |
| 487 | } | |
| 488 | ||
| 489 | fn service_logs(&mut self, service: &str, name: &str) { | |
| 490 | self.log.line(&format!("##[group]Service container {service}")); | |
| 491 | self.docker(&["logs".into(), "--tail".into(), "200".into(), name.into()], &BTreeMap::new(), Duration::from_secs(60)); | |
| 492 | self.log.line("##[endgroup]"); | |
| 493 | } | |
| 494 | ||
| 495 | /// Whether the job has containers to stop when it ends. | |
| 496 | pub(crate) fn has_containers(&self) -> bool { | |
| 497 | self.network.is_some() | |
| 498 | } | |
| 499 | ||
| 500 | /// GitHub's "Stop containers": each service's log, then the job's | |
| 501 | /// containers and network removed. | |
| 502 | pub(crate) fn stop_containers(&mut self) -> bool { | |
| 503 | for (service, name) in self.services.clone() { | |
| 504 | self.service_logs(&service, &name); | |
| 505 | } | |
| 506 | let mut names: Vec<String> = self.services.iter().map(|(_, name)| name.clone()).collect(); | |
| 507 | if let Some(container) = &self.container { | |
| 508 | names.push(container.id.clone()); | |
| 509 | } | |
| 510 | if !names.is_empty() { | |
| 511 | let mut args = vec!["rm".to_owned(), "--force".to_owned()]; | |
| 512 | args.extend(names); | |
| 513 | self.docker(&args, &BTreeMap::new(), Duration::from_secs(120)); | |
| 514 | } | |
| 515 | if let Some(network) = self.network.take() { | |
| 516 | self.docker(&["network".into(), "rm".into(), network], &BTreeMap::new(), Duration::from_secs(60)); | |
| 517 | } | |
| 518 | self.container = None; | |
| 519 | true | |
| 520 | } | |
| 521 | ||
| 522 | /// The variables a process inside a container is given: the step's, | |
| 523 | /// GitHub's and the job's, but not the sandbox's own (its `PATH`, | |
| 524 | /// `HOME` and the like, which mean nothing in another image). | |
| 525 | pub(crate) fn container_env(&self, step_env: &BTreeMap<String, String>, full: BTreeMap<String, String>, image_path: &str) -> BTreeMap<String, String> { | |
| 526 | let mut out: BTreeMap<String, String> = full | |
| 527 | .into_iter() | |
| 528 | .filter(|(name, _)| step_env.contains_key(name) || !self.host_env.contains(name) || name.starts_with("GITHUB_") || name.starts_with("RUNNER_")) | |
| 529 | .collect(); | |
| 530 | if !step_env.contains_key("PATH") { | |
| 531 | out.remove("PATH"); | |
| 532 | if !self.path_prepend_entries().is_empty() { | |
| 533 | out.insert("PATH".into(), format!("{}:{image_path}", self.path_prepend_entries().join(":"))); | |
| 534 | } | |
| 535 | } | |
| 536 | if !step_env.contains_key("HOME") { | |
| 537 | out.remove("HOME"); | |
| 538 | } | |
| 539 | out | |
| 540 | } | |
| 541 | ||
| 542 | /// A step's command, run inside the job's container instead. | |
| 543 | pub(crate) fn in_container(&self, program: &str, args: &[String], dir: &Path, env: BTreeMap<String, String>) -> Option<Command> { | |
| 544 | let container = self.container.as_ref()?; | |
| 545 | let mut command = Command::new("docker"); | |
| 546 | command.args(["exec", "-w", &dir.display().to_string()]); | |
| 547 | for name in env.keys() { | |
| 548 | command.args(["-e", name]); | |
| 549 | } | |
| 550 | command.arg(&container.id).arg(program).args(args); | |
| 551 | command.env_clear().envs(self.docker_cli_env()).envs(env); | |
| 552 | Some(command) | |
| 553 | } | |
| 554 | ||
| 555 | /// Runs a container for a Docker action or a `docker://` step, as | |
| 556 | /// GitHub's runner does: the workspace at /github/workspace, the step's | |
| 557 | /// files at /github/file_commands, the job's network. | |
| 558 | pub(crate) fn run_docker(&mut self, run: &DockerRun) -> (bool, BTreeMap<String, String>, BTreeMap<String, String>) { | |
| 559 | let fail = (false, BTreeMap::new(), BTreeMap::new()); | |
| 560 | if !self.docker_ready() { | |
| 561 | return fail; | |
| 562 | } | |
| 563 | let id = format!("{:x}", super::rand_id()); | |
| 564 | let Ok(files) = StepFiles::new(&self.temp, &id) else { return fail }; | |
| 565 | let commands_dir = self.temp.join("_runner_file_commands"); | |
| 566 | let workflow_dir = self.temp.join("_github_workflow"); | |
| 567 | let home_dir = self.temp.join("_github_home"); | |
| 568 | for dir in [&workflow_dir, &home_dir] { | |
| 569 | let _ = std::fs::create_dir_all(dir); | |
| 570 | } | |
| 571 | let _ = std::fs::copy(self.temp.join("event.json"), workflow_dir.join("event.json")); | |
| 572 | ||
| 573 | let full = self.process_env(&run.env, &files); | |
| 574 | let mut env = self.container_env(&run.env, full, ""); | |
| 575 | env.remove("PATH"); | |
| 576 | // Paths as the container sees them. | |
| 577 | let moved = |path: &Path| format!("{GITHUB_FILE_COMMANDS}/{}", path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); | |
| 578 | env.insert("GITHUB_OUTPUT".into(), moved(&files.output)); | |
| 579 | env.insert("GITHUB_ENV".into(), moved(&files.env)); | |
| 580 | env.insert("GITHUB_PATH".into(), moved(&files.path)); | |
| 581 | env.insert("GITHUB_STATE".into(), moved(&files.state)); | |
| 582 | env.insert("GITHUB_STEP_SUMMARY".into(), moved(&files.summary)); | |
| 583 | env.insert("GITHUB_WORKSPACE".into(), GITHUB_WORKSPACE.into()); | |
| 584 | env.insert("GITHUB_EVENT_PATH".into(), format!("{GITHUB_WORKFLOW}/event.json")); | |
| 585 | env.insert("HOME".into(), GITHUB_HOME.into()); | |
| 586 | env.remove("GITHUB_ACTION_PATH"); | |
| 587 | ||
| 588 | let mut args: Vec<String> = vec!["run".into(), "--rm".into(), "--label".into(), "g1t-job".into(), "--workdir".into(), GITHUB_WORKSPACE.into()]; | |
| 589 | if let Some(network) = &self.network { | |
| 590 | args.extend(["--network".into(), network.clone()]); | |
| 591 | } | |
| 592 | for (from, to) in [ | |
| 593 | (self.workspace.clone(), GITHUB_WORKSPACE), | |
| 594 | (home_dir, GITHUB_HOME), | |
| 595 | (workflow_dir, GITHUB_WORKFLOW), | |
| 596 | (commands_dir, GITHUB_FILE_COMMANDS), | |
| 597 | (PathBuf::from(crate::docker::engine::DOCKER_SOCKET), crate::docker::engine::DOCKER_SOCKET), | |
| 598 | ] { | |
| 599 | args.extend(["-v".into(), format!("{}:{to}", from.display())]); | |
| 600 | } | |
| 601 | for name in env.keys() { | |
| 602 | args.extend(["-e".into(), name.clone()]); | |
| 603 | } | |
| 604 | if let Some(entrypoint) = &run.entrypoint { | |
| 605 | args.extend(["--entrypoint".into(), entrypoint.clone()]); | |
| 606 | } | |
| 607 | args.push(run.image.clone()); | |
| 608 | args.extend(run.args.iter().cloned()); | |
| 609 | ||
| 610 | crate::abuse::touch(); | |
| 611 | if let Some(miner) = crate::abuse::miner_in(&shown(&args)) { | |
| 612 | self.log.line(&format!("##[error]g1t does not run cryptocurrency miners ({miner}). This step was not run.")); | |
| 613 | return fail; | |
| 614 | } | |
| 615 | self.log.line(&format!("[command]docker {}", shown(&args))); | |
| 616 | let mut command = Command::new("docker"); | |
| 617 | command.args(&args).env_clear().envs(self.docker_cli_env()).envs(&env); | |
| 618 | let mut commands = Commands::default(); | |
| 619 | let ended = process::run(command, self.remaining_time(), &mut self.log, &mut commands); | |
| 620 | self.log_docker_notes(); | |
| 621 | let ok = match ended { | |
| 622 | Ok(Ended::Exited(0)) => true, | |
| 623 | Ok(Ended::Exited(code)) => { | |
| 624 | self.log.line(&format!("##[error]Docker run failed with exit code {code}.")); | |
| 625 | false | |
| 626 | } | |
| 627 | Ok(Ended::TimedOut) => { | |
| 628 | self.log.line("##[error]The step ran past its time limit and was stopped."); | |
| 629 | false | |
| 630 | } | |
| 631 | Err(error) => { | |
| 632 | self.log.line(&format!("##[error]docker could not be started: {error}")); | |
| 633 | false | |
| 634 | } | |
| 635 | }; | |
| 636 | let (outputs, state) = self.absorb(&files, &commands); | |
| 637 | (ok, outputs, state) | |
| 638 | } | |
| 639 | ||
| 640 | /// Builds a Docker action's image from its Dockerfile, once per job. | |
| 641 | pub(crate) fn build_action_image(&mut self, dir: &Path, dockerfile: &str, tag_of: &str) -> Option<String> { | |
| 642 | let tag = format!("g1t-action/{}", docker_name(tag_of)); | |
| 643 | if self.built_actions.contains(&tag) { | |
| 644 | return Some(tag); | |
| 645 | } | |
| 646 | if !self.docker_ready() { | |
| 647 | return None; | |
| 648 | } | |
| 649 | let file = dir.join(dockerfile); | |
| 650 | let args = vec!["build".into(), "-t".into(), tag.clone(), "-f".into(), file.display().to_string(), dir.display().to_string()]; | |
| 651 | if !self.docker(&args, &BTreeMap::new(), Duration::from_secs(1800)) { | |
| 652 | self.log.line("##[error]The action's image did not build."); | |
| 653 | return None; | |
| 654 | } | |
| 655 | self.built_actions.insert(tag.clone()); | |
| 656 | Some(tag) | |
| 657 | } | |
| 658 | ||
| 659 | /// `docker/setup-buildx-action` on g1t's machines: the job's own | |
| 660 | /// Engine is the builder (BuildKit, the `docker` driver, with the | |
| 661 | /// containerd image store, so cache export and attestations work). | |
| 662 | pub(crate) fn setup_buildx(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { | |
| 663 | if !self.docker_ready() { | |
| 664 | return (false, BTreeMap::new()); | |
| 665 | } | |
| 666 | if let Some(driver) = with.get("driver").filter(|d| !d.is_empty() && *d != "docker") { | |
| 667 | self.log.line(&format!("`driver: {driver}` is not used on g1t's machines: builds run on this job's own Docker Engine (BuildKit, the `docker` driver), which keeps the sandbox's network and guardrails.")); | |
| 668 | } | |
| 669 | for input in ["driver-opts", "buildkitd-flags", "buildkitd-config", "buildkitd-config-inline", "endpoint"] { | |
| 670 | if with.get(input).is_some_and(|v| !v.trim().is_empty()) { | |
| 671 | self.log.line(&format!("`{input}` is not used on g1t's machines.")); | |
| 672 | } | |
| 673 | } | |
| 674 | self.docker(&["buildx".into(), "version".into()], &BTreeMap::new(), Duration::from_secs(60)); | |
| 675 | let inspect = self.docker_output(&["buildx", "inspect", "default"]).unwrap_or_default(); | |
| 676 | let platforms = inspect | |
| 677 | .lines() | |
| 678 | .find_map(|line| line.trim().strip_prefix("Platforms:")) | |
| 679 | .map(|p| p.split(',').map(|s| s.trim().trim_end_matches('*').to_owned()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(",")) | |
| 680 | .unwrap_or_else(|| "linux/amd64".into()); | |
| 681 | self.log.line(&format!("Builder: default (this job's Docker Engine), platforms {platforms}")); | |
| 682 | let mut outputs = BTreeMap::new(); | |
| 683 | outputs.insert("name".into(), "default".into()); | |
| 684 | outputs.insert("driver".into(), "docker".into()); | |
| 685 | outputs.insert("platforms".into(), platforms.clone()); | |
| 686 | outputs.insert("endpoint".into(), "default".into()); | |
| 687 | outputs.insert("status".into(), "running".into()); | |
| 688 | outputs.insert("flags".into(), String::new()); | |
| 689 | outputs.insert( | |
| 690 | "nodes".into(), | |
| 691 | json!([{ "name": "default", "endpoint": "default", "status": "running", "platforms": platforms }]).to_string(), | |
| 692 | ); | |
| 693 | (true, outputs) | |
| 694 | } | |
| 695 | } | |
| 696 | ||
| 697 | /// The runner's Node, its real file (not a link), to mount into a job | |
| 698 | /// container. | |
| 699 | fn which_node() -> Option<PathBuf> { | |
| 700 | let path = std::env::var_os("PATH")?; | |
| 701 | std::env::split_paths(&path).map(|dir| dir.join("node")).find(|p| p.is_file()).and_then(|p| std::fs::canonicalize(p).ok()) | |
| 702 | } | |
| 703 | ||
| 704 | /// The job context's `container` and `services`, merged with its status. | |
| 705 | pub(crate) fn job_context(status: &str, extra: &Map<String, Value>) -> Value { | |
| 706 | let mut job = Map::new(); | |
| 707 | job.insert("status".into(), json!(status)); | |
| 708 | for (key, value) in extra { | |
| 709 | job.insert(key.clone(), value.clone()); | |
| 710 | } | |
| 711 | Value::Object(job) | |
| 712 | } | |
| 713 | ||
| 714 | /// Names a set of tags already built in this job. | |
| 715 | pub(crate) type Built = BTreeSet<String>; | |
| 716 | ||
| 717 | #[cfg(test)] | |
| 718 | mod tests { | |
| 719 | use super::*; | |
| 720 | ||
| 721 | #[test] | |
| 722 | fn options_split_as_a_shell_would() { | |
| 723 | assert_eq!( | |
| 724 | split_words(r#"--health-cmd "pg_isready -U postgres" --health-interval 10s --health-retries=5"#), | |
| 725 | vec!["--health-cmd", "pg_isready -U postgres", "--health-interval", "10s", "--health-retries=5"] | |
| 726 | ); | |
| 727 | assert_eq!(split_words("--health-cmd 'redis-cli ping' --tmpfs /var/lib/x"), vec!["--health-cmd", "redis-cli ping", "--tmpfs", "/var/lib/x"]); | |
| 728 | assert_eq!(split_words(r#"a\ b "c\"d" ''"#), vec!["a b", "c\"d", ""]); | |
| 729 | assert!(split_words(" ").is_empty()); | |
| 730 | } | |
| 731 | ||
| 732 | #[test] | |
| 733 | fn services_are_read_from_either_form() { | |
| 734 | assert_eq!(container_spec(&json!("redis:7")).image, "redis:7"); | |
| 735 | let spec = container_spec(&json!({ | |
| 736 | "image": "postgres:17", | |
| 737 | "env": { "POSTGRES_PASSWORD": "secret", "POSTGRES_DB": "app" }, | |
| 738 | "ports": ["5432:5432", 6543], | |
| 739 | "volumes": ["/data:/var/lib/postgresql/data"], | |
| 740 | "options": "--health-cmd pg_isready --health-interval 10s", | |
| 741 | "credentials": { "username": "me", "password": "token" }, | |
| 742 | })); | |
| 743 | assert_eq!(spec.image, "postgres:17"); | |
| 744 | assert_eq!(spec.env["POSTGRES_DB"], "app"); | |
| 745 | assert_eq!(spec.ports, vec!["5432:5432", "6543"]); | |
| 746 | assert_eq!(spec.options, vec!["--health-cmd", "pg_isready", "--health-interval", "10s"]); | |
| 747 | assert_eq!(spec.credentials, Some(("me".into(), "token".into()))); | |
| 748 | // An empty image is a service the job leaves out, as on GitHub. | |
| 749 | assert_eq!(container_spec(&json!({ "image": "" })).image, ""); | |
| 750 | } | |
| 751 | ||
| 752 | #[test] | |
| 753 | fn ports_map_container_to_host() { | |
| 754 | let map = port_map(&["5432:5432".into(), "6543:5432/tcp".into(), "6379".into(), "127.0.0.1:8080:80".into(), ":9000".into()]); | |
| 755 | assert_eq!(map["5432"], "6543"); | |
| 756 | assert_eq!(map["6379"], "6379"); | |
| 757 | assert_eq!(map["80"], "8080"); | |
| 758 | assert_eq!(map["9000"], "9000"); | |
| 759 | } | |
| 760 | ||
| 761 | #[test] | |
| 762 | fn credentials_sign_in_to_the_images_registry() { | |
| 763 | assert_eq!(registry_of("ghcr.io/acme/db:1"), "ghcr.io"); | |
| 764 | assert_eq!(registry_of("g1t.sh/acme/web"), "g1t.sh"); | |
| 765 | assert_eq!(registry_of("localhost:5000/x"), "localhost:5000"); | |
| 766 | assert_eq!(registry_of("acme/private"), "https://index.docker.io/v1/"); | |
| 767 | assert_eq!(registry_of("postgres"), "https://index.docker.io/v1/"); | |
| 768 | } | |
| 769 | ||
| 770 | #[test] | |
| 771 | fn a_service_is_created_with_its_values_passed_by_name() { | |
| 772 | let spec = container_spec(&json!({ | |
| 773 | "image": "postgres:17", | |
| 774 | "env": { "POSTGRES_PASSWORD": "secret" }, | |
| 775 | "ports": ["5432:5432"], | |
| 776 | "options": "--health-cmd pg_isready", | |
| 777 | })); | |
| 778 | let args = create_args("postgres_job", Some("g1t_job"), Some("postgres"), &spec, &[], false); | |
| 779 | assert_eq!( | |
| 780 | args, | |
| 781 | vec![ | |
| 782 | "create", "--name", "postgres_job", "--label", "g1t-job", "--network", "g1t_job", "--network-alias", "postgres", "-p", "5432:5432", "-e", | |
| 783 | "POSTGRES_PASSWORD", "--health-cmd", "pg_isready", "postgres:17" | |
| 784 | ] | |
| 785 | ); | |
| 786 | assert!(!args.iter().any(|a| a.contains("secret"))); | |
| 787 | let job = create_args("job_x", Some("g1t_job"), None, &container_spec(&json!("node:24")), &[("/home/runner/work".into(), "/home/runner/work".into())], true); | |
| 788 | assert!(job.ends_with(&["--entrypoint".into(), "tail".into(), "node:24".into(), "-f".into(), "/dev/null".into()])); | |
| 789 | assert!(job.contains(&"/home/runner/work:/home/runner/work".to_owned())); | |
| 790 | } | |
| 791 | ||
| 792 | #[test] | |
| 793 | fn names_are_docker_names() { | |
| 794 | assert_eq!(docker_name("Build & test_1a2b"), "build___test_1a2b"); | |
| 795 | assert_eq!(docker_name("docker/login-action@v3"), "docker_login-action_v3"); | |
| 796 | } | |
| 797 | ||
| 798 | #[test] | |
| 799 | fn the_job_context_carries_containers() { | |
| 800 | let mut extra = Map::new(); | |
| 801 | extra.insert("services".into(), json!({ "db": { "ports": { "5432": "5432" } } })); | |
| 802 | let job = job_context("success", &extra); | |
| 803 | assert_eq!(job["status"], "success"); | |
| 804 | assert_eq!(job["services"]["db"]["ports"]["5432"], "5432"); | |
| 805 | } | |
| 806 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.