gha_runner/
lib.rs

1#![deny(missing_docs)]
2
3//! `gha-runner` runs Github Actions workflows. It supports pluggable backends
4//! via the `RunnerBackend` trait, and provides a `LocalDockerBackend` implementation
5//! that runs workflows using local docker containers. You can also analyze the
6//! structure of workflow jobs and modify step execution.
7//!
8//! # Example
9//! ```
10//! use gha_runner::*;
11//! let images: DockerImageMapping = DockerImageMapping {
12//!     ubuntu_18_04: "ghcr.io/catthehacker/ubuntu:act-18.04".into(),
13//!     ubuntu_20_04: "ghcr.io/catthehacker/ubuntu:act-20.04".into(),
14//! };
15//! let runtime = tokio::runtime::Builder::new_current_thread()
16//!     .enable_all()
17//!     .build()
18//!     .unwrap();
19//! runtime.block_on(async move {
20//!     run_workflow_with_local_backend(
21//!         "Pernosco",
22//!         "github-actions-test",
23//!         "6475d0f048a72996e3bd559cdd3763f53fe3d072",
24//!         ".github/workflows/build.yml",
25//!         "Build+test (stable, ubuntu-18.04)",
26//!         &images,
27//!         LocalDockerOptions::default(),
28//!     ).await;
29//! });
30//! ```
31//!
32//! # Lower-level API
33//!
34//! * Fill out a `RunnerContext`
35//! * Call `Runner::new()` to create a `Runner`
36//! * Call `Runner::job_descriptions()` to get a list of `JobDescriptions` and pick a job
37//! * Call `Runner::job_runner()` to create a `JobRunner`
38//! * Call `JobRunner::container_images()` to get a list of Docker container images that will be needed, and create one container per image
39//! * Call `JobRunner::run_next_step()` repeatedly to run each job step, until `next_step_index() >= step_count()`
40
41use std::error::Error;
42use std::fmt;
43use std::fs::{self, File, OpenOptions};
44use std::future::Future;
45use std::io::{self, BufRead, BufReader, BufWriter, Read, Write};
46use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
47use std::path::{Path, PathBuf};
48use std::pin::Pin;
49use std::process::{Command, Stdio};
50use std::str;
51
52use backtrace::Backtrace;
53use bytes::Buf;
54use futures::pin_mut;
55use futures::prelude::*;
56use http::Response;
57use http_body::Body;
58use http_body_util::BodyStream;
59use linked_hash_map::LinkedHashMap;
60use log::{debug, info, warn};
61use octocrab::models::JobId;
62use octocrab::models::RunId;
63use octocrab::{params, Octocrab};
64use serde_yaml::Value;
65
66mod contexts;
67mod expressions;
68mod local_docker_backend;
69mod models;
70
71use contexts::*;
72use expressions::ContextResolver;
73pub use local_docker_backend::*;
74use models::*;
75
76/// This contains various configuration values needed to run a Github Actions workflow.
77/// Most of these values are exposed to actions via [standard GHA environment variables](https://docs.github.com/en/actions/learn-github-actions/environment-variables).
78#[derive(Clone)]
79pub struct RunnerContext {
80    /// The `Octocrab` Github client used to fetch workflow resources.
81    /// This can be configured with or without authentication; in the latter case
82    /// all required resources must be public.
83    pub github: Octocrab,
84    /// The Github owner name, e.g. `Pernosco`.
85    pub owner: String,
86    /// The Github repo name, e.g. `github-actions-test`.
87    pub repo: String,
88    /// The repo commit-SHA decoded from hex, e.g. using `hex::decode(sha_string.has_bytes())`.
89    pub commit_sha: Vec<u8>,
90    /// An optional git ref for the commit. Exposed as `$GITHUB_REF` in the GHA steps.
91    pub commit_ref: Option<String>,
92    /// A global working directory whose contents are exposed to all containers as `/github`.
93    /// The runner will create files under this directory.
94    pub global_dir_host: PathBuf,
95    /// Name of workflow file in the repo, e.g. `.github/workflows/build.yml`.
96    pub workflow_repo_path: String,
97    /// The workflow run ID. Exposed as `$GITHUB_RUN_ID` in the GHA steps.
98    /// Can usually just be (e.g.) `1`, but you might want to match the ID of some real
99    /// workflow run.
100    pub run_id: RunId,
101    /// The rerun number. Exposed as `$GITHUB_RUN_NUMBER` in the GHA steps. Can usually
102    /// just be `1`.
103    pub run_number: i64,
104    /// The job ID. Exposed as `$GITHUB_JOB` in the GHA steps. Can usually just be `1`.
105    pub job_id: JobId,
106    /// Exposed as `$GITHUB_ACTOR` in the GHA steps.
107    pub actor: String,
108    /// A Github [personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token)
109    /// for actions to use for checkouts and other Github operations. This *must* be valid.
110    /// See `zero_access_token()` for a token you can use.
111    pub token: String,
112    /// (key, value) pairs that override environment variable settings in a step.
113    /// These are applied after all workflow-defined environment variables.
114    pub override_env: Vec<(String, String)>,
115}
116
117const GITHUB_WORKSPACE: &str = "/github/workspace";
118const GITHUB_COM: &str = "https://github.com";
119const API_GITHUB_COM: &str = "https://api.github.com";
120const API_GITHUB_COM_GRAPHQL: &str = "https://api.github.com/graphql";
121
122impl RunnerContext {
123    // See https://docs.github.com/en/actions/reference/environment-variables
124    fn create_env(&self, workflow_name: Option<String>) -> LinkedHashMap<String, String> {
125        let mut ret = LinkedHashMap::new();
126        let mut insert = |key: &str, value: String| {
127            ret.insert(key.to_string(), value);
128        };
129        insert("CI", "true".to_string());
130        insert(
131            "GITHUB_WORKFLOW",
132            workflow_name.unwrap_or_else(|| self.workflow_repo_path.clone()),
133        );
134        insert("GITHUB_RUN_ID", self.run_id.to_string());
135        insert("GITHUB_RUN_NUMBER", self.run_number.to_string());
136        insert("GITHUB_JOB", self.job_id.to_string());
137        // XXX GITHUB_ACTION
138        // XXX Support GITHUB_ACTION_PATH when we support composite actions
139        insert("GITHUB_ACTIONS", "true".to_string());
140        insert("GITHUB_ACTOR", self.actor.clone());
141        insert("GITHUB_REPOSITORY", format!("{}/{}", self.owner, self.repo));
142        // XXX we probably should support overriding this. If we do we would need
143        // to support GITHUB_HEAD_REF/GITHUB_BASE_REF
144        insert("GITHUB_EVENT_NAME", "push".to_string());
145        insert("GITHUB_EVENT_PATH", "/github/.gha-runner/event".to_string());
146        insert("GITHUB_PATH", "/github/.gha-runner/path".to_string());
147        insert("GITHUB_ENV", "/github/.gha-runner/env".to_string());
148        insert("GITHUB_WORKSPACE", GITHUB_WORKSPACE.to_string());
149        insert("GITHUB_SHA", hex::encode(&self.commit_sha));
150        if let Some(r) = self.commit_ref.as_ref() {
151            insert("GITHUB_REF", r.to_string());
152        }
153        insert("GITHUB_SERVER_URL", GITHUB_COM.to_string());
154        insert("GITHUB_API_URL", API_GITHUB_COM.to_string());
155        insert("GITHUB_GRAPHQL_URL", API_GITHUB_COM_GRAPHQL.to_string());
156        insert("GITHUB_TOKEN", self.token.clone());
157        insert("RUNNER_OS", "Linux".to_string());
158        insert("RUNNER_TEMP", "/tmp".to_string());
159        insert("RUNNER_TOOL_CACHE", "/opt/hostedtoolcache".to_string());
160        ret
161    }
162    fn apply_env(
163        &self,
164        mut target: LinkedHashMap<String, String>,
165    ) -> LinkedHashMap<String, String> {
166        for v in self.override_env.iter() {
167            target.insert(v.0.to_string(), v.1.to_string());
168        }
169        target
170    }
171}
172
173/// A description of a job in a workflow.
174#[derive(Debug)]
175pub struct JobDescription {
176    name: String,
177    matrix_values: LinkedHashMap<String, Value>,
178    job_env: LinkedHashMap<String, String>,
179    runs_on: Vec<String>,
180    steps: Vec<models::Step>,
181}
182
183/// Assigns full docker image names to GHA 'runs-on' names
184pub struct DockerImageMapping {
185    /// Full docker image name for ubuntu-18.04
186    pub ubuntu_18_04: ContainerImage,
187    /// Full docker image name for ubuntu-20.04
188    pub ubuntu_20_04: ContainerImage,
189}
190
191impl JobDescription {
192    /// The name of the job. This includes values added due to `matrix`.
193    pub fn name(&self) -> &str {
194        &self.name
195    }
196    /// The runs-on OS name(s).
197    pub fn runs_on(&self) -> &[String] {
198        &self.runs_on
199    }
200    /// Container image name to use for the main container.
201    pub fn main_container(&self, image_mapping: &DockerImageMapping) -> Option<ContainerImage> {
202        if self.runs_on.is_empty() {
203            return None;
204        }
205        let img = match self.runs_on[0].as_str() {
206            // XXX what to do about self-hosted runners?
207            "ubuntu-latest" | "ubuntu-20.04" => &image_mapping.ubuntu_20_04,
208            "ubuntu-18.04" => &image_mapping.ubuntu_18_04,
209            _ => return None,
210        };
211        Some(img.clone())
212    }
213}
214
215/// An index into the steps in the workflow YAML. This is not the same thing
216/// as Github's "step number", which includes invisible steps like
217/// job creation and isn't always numbered consecutively.
218#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)]
219pub struct StepIndex(pub u32);
220
221/// Runs a specific Job.
222/// The simplest way to use this is to repeatedly call `run_next_step()` until
223/// `next_step_index() >= step_count()`.
224pub struct JobRunner {
225    ctx: RunnerContext,
226    job_name: String,
227    matrix_values: LinkedHashMap<String, Value>,
228    /// Environment variables to set in future steps
229    env: LinkedHashMap<String, String>,
230    /// Path entries to add in future steps. The entries are added
231    /// in reverse order, i.e. the last entry in `paths` is at the
232    /// start of the PATH.
233    paths: Vec<String>,
234    /// The first image is the default image.
235    container_images: Vec<ContainerImage>,
236    /// The full list of steps
237    steps: Vec<models::Step>,
238    outputs: Vec<LinkedHashMap<String, String>>,
239    step_index: StepIndex,
240}
241
242/// A full Docker container image name, e.g `ghcr.io/catthehacker/ubuntu:js-18.04`
243#[derive(Clone, Eq, PartialEq)]
244pub struct ContainerImage(pub String);
245
246impl From<&str> for ContainerImage {
247    fn from(s: &str) -> ContainerImage {
248        ContainerImage(s.to_string())
249    }
250}
251
252impl From<String> for ContainerImage {
253    fn from(s: String) -> ContainerImage {
254        ContainerImage(s)
255    }
256}
257
258/// Index into `JobRunner::containers`
259#[derive(Clone, Copy, Eq, PartialEq)]
260pub struct ContainerId(pub usize);
261
262/// A backend that can run tasks in containers.
263pub trait RunnerBackend {
264    /// Run a command in the context of the container, returning its exit code.
265    /// There are no parameters, environment variables etc --- we emit script files
266    /// into the shared filesystem that set those up.
267    /// `container` is an index into the `container_images()` array.
268    /// `command` is the path to the command *inside the container*, starting with
269    /// "/github".
270    /// `stdout_filter` is a callback that gets invoked for each chunk of data
271    /// emitted to the command's stdout. (We use this to process
272    /// "[workflow commands](https://docs.github.com/en/actions/learn-github-actions/workflow-commands-for-github-actions)".)
273    fn run<'a, F: FnMut(&[u8])>(
274        &'a mut self,
275        container: ContainerId,
276        command: &'a str,
277        stdout_filter: &'a mut F,
278    ) -> Pin<Box<dyn Future<Output = i32> + 'a>>;
279}
280
281async fn untar_response<B: Body>(
282    action: &str,
283    response: Response<B>,
284    dir: &Path,
285) -> Result<(), RunnerErrorKind>
286where
287    B::Error: std::error::Error + Send + Sync + 'static,
288{
289    let response = BodyStream::new(response.into_body());
290    pin_mut!(response);
291    let mut command = Command::new("tar");
292    // Github's tarball contains a toplevel directory (e.g. 'actions-checkout-f1d3225')
293    // so we strip that off.
294    command
295        .args(&["zxf", "-", "--strip-components=1"])
296        .current_dir(dir)
297        .stdin(Stdio::piped());
298    let mut child = command.spawn().expect("Can't find 'tar'");
299    while let Some(b) =
300        response
301            .next()
302            .await
303            .transpose()
304            .map_err(|e| RunnerErrorKind::ActionDownloadError {
305                action: action.to_string(),
306                inner: Box::new(e),
307            })?
308    {
309        let b = match b.into_data() {
310            Ok(b) => b,
311            Err(_) => continue,
312        };
313        child
314            .stdin
315            .as_mut()
316            .unwrap()
317            .write_all(b.chunk())
318            .map_err(|e| RunnerErrorKind::ActionDownloadError {
319                action: action.to_string(),
320                inner: Box::new(e),
321            })?;
322    }
323    // Close stdin so tar terminates
324    child.stdin = None;
325    let status = child.wait().unwrap();
326    if !status.success() {
327        return Err(RunnerErrorKind::ActionDownloadError {
328            action: action.to_string(),
329            inner: format!("tar failed: {}", status).into(),
330        });
331    }
332    Ok(())
333}
334
335fn envify(s: &str) -> String {
336    let mut ret = String::new();
337    for ch in s.chars() {
338        let replace = match ch {
339            'A'..='Z' | '0'..='9' => ch,
340            'a'..='z' => ch.to_ascii_uppercase(),
341            _ => '_',
342        };
343        ret.push(replace);
344    }
345    ret
346}
347
348fn shell_quote(s: &str) -> String {
349    let mut ret = String::new();
350    let mut quote = false;
351    ret.push('\'');
352    for ch in s.chars() {
353        if ch == '\'' {
354            quote = true;
355            ret.push_str("'\"'\"'");
356        } else {
357            if !matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '=' | '.' | '/' | ':')
358            {
359                quote = true;
360            }
361            ret.push(ch);
362        }
363    }
364    ret.push('\'');
365    if quote {
366        ret
367    } else {
368        s.to_string()
369    }
370}
371
372struct RunSpec {
373    env: LinkedHashMap<String, String>,
374    /// The last entry should be prepended first
375    paths: Vec<String>,
376    working_directory: Option<String>,
377    command: Vec<String>,
378}
379
380struct LineBuffer {
381    buf: Vec<u8>,
382}
383
384impl LineBuffer {
385    fn new() -> LineBuffer {
386        LineBuffer { buf: Vec::new() }
387    }
388    fn append(&mut self, data: &[u8]) {
389        self.buf.extend_from_slice(data);
390    }
391    /// Runs closure for complete lines ending in \n, with the \n stripped.
392    fn take_lines<F: FnMut(&[u8])>(&mut self, mut f: F) {
393        let mut offset = 0;
394        while let Some(next) = self.buf[offset..].iter().position(|c| *c == b'\n') {
395            let next = offset + next;
396            f(&self.buf[offset..next]);
397            offset = next + 1;
398        }
399        self.buf.drain(..offset);
400    }
401}
402
403fn parse_simple_shell_command(s: &str) -> Option<Vec<String>> {
404    for ch in s.chars() {
405        if !matches!(ch, '0'..='9' | 'a'..='z' | 'A'..='Z' | '-' | '/' | '.' | ' ' | '\n') {
406            return None;
407        }
408    }
409    Some(s.split_ascii_whitespace().map(|v| v.to_string()).collect())
410}
411
412/// Create a directory that's publicly writeable.
413fn create_public_writable_dir(path: &Path) {
414    let _ = fs::create_dir(&path);
415    fs::set_permissions(&path, fs::Permissions::from_mode(0o777)).unwrap();
416}
417
418/// Create a file that's publicly writeable.
419fn create_public_writable_file(path: &Path) {
420    OpenOptions::new()
421        .write(true)
422        .create(true)
423        .truncate(true)
424        .mode(0o666)
425        .open(path)
426        .unwrap();
427    // umask may have stopped us setting the permissions we wanted,
428    // so reset permissions now.
429    fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).unwrap();
430}
431
432impl JobRunner {
433    /// The job requires a specific set of containers, one container per
434    /// entry in the returned list. Each container must use the given
435    /// `ContainerImage`.
436    pub fn container_images(&self) -> &[ContainerImage] {
437        &self.container_images
438    }
439    /// The total number of steps in the job.
440    pub fn step_count(&self) -> StepIndex {
441        StepIndex(self.steps.len() as u32)
442    }
443    /// The index of the next step to be executed.
444    pub fn next_step_index(&self) -> StepIndex {
445        self.step_index
446    }
447    /// The name of the next step to be executed, if there is one.
448    pub fn next_step_name(&self) -> Result<Option<String>, RunnerError> {
449        let step_context = StepContext::new(
450            &self.ctx,
451            &self.matrix_values,
452            &self.job_name,
453            self.step_index,
454        );
455        self.steps[self.step_index.0 as usize]
456            .clone()
457            .take_name(&step_context)
458    }
459    /// The job name.
460    pub fn job_name(&self) -> &str {
461        &self.job_name
462    }
463    /// Look up a step by name. We don't have access to variables set by previous steps
464    /// so this might not work in obscure cases...
465    pub fn find_step_by_name(&self, step_name: &str) -> Result<Option<StepIndex>, RunnerError> {
466        for (index, step) in self.steps[(self.step_index.0 as usize)..]
467            .iter()
468            .enumerate()
469        {
470            let step_index = StepIndex(index as u32);
471            let step_context =
472                PreStepContext::new(&self.ctx, &self.matrix_values, &self.job_name, step_index);
473            let mut step = step.clone();
474            let name = step.take_name_pre(&step_context)?;
475            if name.as_deref() == Some(step_name) {
476                return Ok(Some(step_index));
477            }
478        }
479        Ok(None)
480    }
481    /// Get the environment variables set in a specific step. We don't have access to variables set by previous steps
482    /// so this might not work in obscure cases...
483    pub fn peek_step_env(
484        &self,
485        step_index: StepIndex,
486    ) -> Result<LinkedHashMap<String, String>, RunnerError> {
487        let mut step = self.steps[step_index.0 as usize].clone();
488        let step_context =
489            PreStepContext::new(&self.ctx, &self.matrix_values, &self.job_name, step_index);
490        step.take_env_pre(&step_context)
491            .map(|env| self.ctx.apply_env(env))
492    }
493    /// `interpose` lets you modify the command that will be run for the step.
494    pub async fn run_next_step<B: RunnerBackend, I>(
495        &mut self,
496        interpose: I,
497        backend: &mut B,
498    ) -> Result<i32, RunnerError>
499    where
500        I: FnOnce(&mut Vec<String>),
501    {
502        if self.step_index.0 == 0 {
503            let _ = create_public_writable_dir(&self.ctx.global_dir_host.join("workspace"));
504            let _ = create_public_writable_dir(&self.ctx.global_dir_host.join(".gha-runner"));
505            fs::write(self.ctx.global_dir_host.join(".gha-runner/event"), "{}").unwrap();
506            let _ = create_public_writable_dir(
507                &self.ctx.global_dir_host.join(".gha-runner/hostedtoolcache"),
508            );
509        }
510        let _ = fs::create_dir(
511            self.ctx
512                .global_dir_host
513                .join(format!(".gha-runner/step{}", self.step_index.0)),
514        );
515
516        let mut step = self.steps[self.step_index.0 as usize].clone();
517        let spec = {
518            let step_context = StepContext::new(
519                &self.ctx,
520                &self.matrix_values,
521                &self.job_name,
522                self.step_index,
523            );
524            let mut env = self.env.clone();
525            for (k, v) in self.ctx.apply_env(step.take_env(&step_context)?) {
526                env.insert(k, v);
527            }
528            let mut spec = RunSpec {
529                env: env,
530                paths: self.paths.clone(),
531                working_directory: step.take_working_directory(&step_context)?,
532                command: Vec::new(),
533            };
534            if let Some(uses) = step.take_uses(&step_context)? {
535                self.configure_action(&mut step, uses, &mut spec).await?;
536            } else if let Some(run) = step.take_run(&step_context)? {
537                self.configure_command(&mut step, run, &mut spec).await?;
538            } else {
539                return Err(RunnerErrorKind::RequiredFieldMissing {
540                    field_name: "uses/run",
541                    got: step.0,
542                }
543                .error(&step_context));
544            }
545            spec
546        };
547        let ret = self.run_command("run", spec, interpose, backend).await;
548        self.step_index.0 += 1;
549        ret
550    }
551
552    async fn run_command<B: RunnerBackend, I>(
553        &mut self,
554        script_name: &str,
555        mut spec: RunSpec,
556        interpose: I,
557        backend: &mut B,
558    ) -> Result<i32, RunnerError>
559    where
560        I: FnOnce(&mut Vec<String>),
561    {
562        let script_path = format!(".gha-runner/step{}/{}", self.step_index.0, script_name);
563        {
564            // Ensure file is closed before we run it.
565            let mut script_file = BufWriter::new(
566                OpenOptions::new()
567                    .write(true)
568                    .create_new(true)
569                    .mode(0o777)
570                    .open(self.ctx.global_dir_host.join(&script_path))
571                    .unwrap(),
572            );
573            // Use a login shell since some path etc setup may only happen with a login shell
574            writeln!(&mut script_file, "#!/bin/bash -l").unwrap();
575            for (k, v) in spec.env {
576                if k.contains('=') {
577                    return Err(RunnerErrorKind::InvalidEnvironmentVariableName { name: k }.into());
578                }
579                writeln!(
580                    &mut script_file,
581                    "export {}={}",
582                    shell_quote(&k),
583                    shell_quote(&v)
584                )
585                .unwrap();
586            }
587            if !spec.paths.is_empty() {
588                write!(&mut script_file, "export PATH=").unwrap();
589                for p in spec.paths.iter().rev() {
590                    write!(&mut script_file, "{}:", p).unwrap();
591                }
592                writeln!(&mut script_file, "$PATH").unwrap();
593            }
594            if self.step_index.0 == 0 {
595                writeln!(
596                    &mut script_file,
597                    "ln -s /github/.gha-runner/hostedtoolcache $RUNNER_TOOL_CACHE"
598                )
599                .unwrap();
600            }
601            writeln!(&mut script_file, "cd /github/workspace").unwrap();
602            if let Some(d) = spec.working_directory {
603                writeln!(&mut script_file, "cd {}", shell_quote(&d)).unwrap();
604            }
605            write!(&mut script_file, "exec").unwrap();
606            interpose(&mut spec.command);
607            for arg in spec.command {
608                write!(&mut script_file, " {}", shell_quote(&arg)).unwrap();
609            }
610        }
611
612        let mut line_buffer = LineBuffer::new();
613        let mut stop_token: Option<Vec<u8>> = None;
614        let mut outputs = LinkedHashMap::new();
615        let mut stdout_filter = |data: &[u8]| {
616            line_buffer.append(data);
617            line_buffer.take_lines(|line| {
618                if line.len() < 2 || &line[0..2] != b"::" {
619                    return;
620                }
621                if let Some(token) = stop_token.as_ref() {
622                    if line[2..].ends_with(b"::") && &line[2..(line.len() - 2)] == token {
623                        stop_token = None;
624                    }
625                    return;
626                }
627                if let Some(token) = line.strip_prefix(b"::stop-commands::") {
628                    stop_token = Some(token.to_vec());
629                    return;
630                }
631                if let Some(rest) = line.strip_prefix(b"::set-output name=") {
632                    if let Ok(rest) = str::from_utf8(rest) {
633                        if let Some(p) = rest.find("::") {
634                            outputs.insert(rest[..p].to_string(), rest[(p + 2)..].to_string());
635                        } else {
636                            warn!(
637                                "No '::' in set-output command: {}",
638                                String::from_utf8_lossy(line)
639                            );
640                        }
641                    } else {
642                        warn!(
643                            "Non-UTF8 set-output command: {}",
644                            String::from_utf8_lossy(line)
645                        );
646                    }
647                    return;
648                }
649                if line == b"::save-state" {
650                    warn!("Ignoring ::save-state for now");
651                }
652            })
653        };
654        create_public_writable_file(&self.github_path_path());
655        create_public_writable_file(&self.github_env_path());
656        let ret = backend
657            .run(
658                ContainerId(0),
659                &format!("/github/{}", script_path),
660                &mut stdout_filter,
661            )
662            .await;
663        self.outputs.push(outputs);
664        self.update_env_from_file();
665        self.update_path_from_file();
666        Ok(ret)
667    }
668
669    fn github_path_path(&self) -> PathBuf {
670        self.ctx.global_dir_host.join(".gha-runner/path")
671    }
672    fn github_env_path(&self) -> PathBuf {
673        self.ctx.global_dir_host.join(".gha-runner/env")
674    }
675
676    fn update_env_from_file(&mut self) {
677        let mut env_file = BufReader::new(File::open(self.github_env_path()).unwrap());
678        let mut buf = Vec::new();
679        loop {
680            buf.clear();
681            let len = env_file.read_until(b'\n', &mut buf).unwrap();
682            if len == 0 {
683                break;
684            }
685            debug!(
686                "env line for step {}: {}",
687                self.step_index.0,
688                String::from_utf8_lossy(&buf)
689            );
690            if buf.last() == Some(&b'\n') {
691                buf.truncate(buf.len() - 1);
692            }
693            if let Ok(line) = str::from_utf8(&buf) {
694                if let Some(p) = line.find('=') {
695                    self.env
696                        .insert(line[..p].to_string(), line[(p + 1)..].to_string());
697                } else if let Some(p) = line.find("<<") {
698                    let name = line[..p].to_string();
699                    let delimiter = line[(p + 2)..].to_string();
700                    let mut value = String::new();
701                    let mut err = false;
702                    loop {
703                        let len = env_file.read_until(b'\n', &mut buf).unwrap();
704                        if len == 0 {
705                            warn!(
706                                "Multiline string value not delimited for step {} value named {}",
707                                self.step_index.0, name
708                            );
709                            err = true;
710                            break;
711                        }
712                        debug!(
713                            "env line for step {}: {}",
714                            self.step_index.0,
715                            String::from_utf8_lossy(&buf)
716                        );
717                        if buf.last() == Some(&b'\n') {
718                            buf.truncate(buf.len() - 1);
719                        }
720                        if buf == delimiter.as_bytes() {
721                            break;
722                        }
723                        if let Ok(s) = str::from_utf8(&buf) {
724                            value.push_str(s);
725                            value.push('\n');
726                        } else {
727                            warn!(
728                                "Multiline string part not UTF8 for step {} value named {}",
729                                self.step_index.0, name
730                            );
731                            err = true;
732                        }
733                    }
734                    if !err {
735                        self.env.insert(name, value);
736                    }
737                } else {
738                    warn!(
739                        "No '=' in environment line for step {}: {}",
740                        self.step_index.0,
741                        String::from_utf8_lossy(&buf)
742                    );
743                }
744            } else {
745                warn!(
746                    "Non-UTF8 environment line for step {}: {}",
747                    self.step_index.0,
748                    String::from_utf8_lossy(&buf)
749                );
750            }
751        }
752    }
753
754    fn update_path_from_file(&mut self) {
755        let mut path_file = BufReader::new(File::open(self.github_path_path()).unwrap());
756        let mut buf = Vec::new();
757        loop {
758            buf.clear();
759            let len = path_file.read_until(b'\n', &mut buf).unwrap();
760            if len == 0 {
761                break;
762            }
763            if buf.last() == Some(&b'\n') {
764                buf.truncate(buf.len() - 1);
765            }
766            if let Ok(path) = str::from_utf8(&buf) {
767                self.paths.push(path.to_string());
768            } else {
769                warn!(
770                    "Non-UTF8 path line for step {}: {}",
771                    self.step_index.0,
772                    String::from_utf8_lossy(&buf)
773                );
774            }
775        }
776    }
777
778    async fn configure_action(
779        &mut self,
780        step: &mut Step,
781        action_name: String,
782        spec: &mut RunSpec,
783    ) -> Result<(), RunnerError> {
784        let (action_host_path, action_path) = self.download_action(&action_name).await?;
785        let mut action = self.read_action_yaml(&action_name, &action_host_path)?;
786        let step_context = StepContext::new(
787            &self.ctx,
788            &self.matrix_values,
789            &self.job_name,
790            self.step_index,
791        );
792        let mut with = step.take_with(&step_context)?;
793        let action_context =
794            ActionContext::new(&self.ctx, &action_name, &self.job_name, self.step_index);
795        for (k, mut v) in action.take_inputs(&action_context)? {
796            let value = if let Some(w) = with.remove(&k) {
797                w
798            } else if let Some(d) = v.take_default(&action_context)? {
799                d
800            } else {
801                continue;
802            };
803            spec.env.insert(format!("INPUT_{}", envify(&k)), value);
804        }
805        let mut runs = action.take_runs(&action_context)?;
806        if runs.take_pre(&action_context)?.is_some() {
807            return Err(RunnerErrorKind::UnsupportedPre {
808                action: action_name,
809            }
810            .into());
811        }
812        let using = runs.take_using(&action_context)?;
813        if using != "node12" {
814            return Err(RunnerErrorKind::UnsupportedActionType {
815                action: action_name,
816                using,
817            }
818            .into());
819        }
820        let main = runs.take_main(&action_context)?;
821
822        spec.command = vec![
823            "node".to_string(),
824            format!("/github/{}/{}", action_path, main),
825        ];
826        Ok(())
827    }
828    async fn configure_command(
829        &mut self,
830        step: &mut Step,
831        command: String,
832        spec: &mut RunSpec,
833    ) -> Result<(), RunnerError> {
834        let step_context = StepContext::new(
835            &self.ctx,
836            &self.matrix_values,
837            &self.job_name,
838            self.step_index,
839        );
840        match step.take_shell(&step_context)?.as_deref() {
841            None | Some("bash") => {
842                spec.command = if let Some(cmd) = parse_simple_shell_command(&command) {
843                    cmd
844                } else {
845                    vec![
846                        "bash".to_string(),
847                        "--noprofile".to_string(),
848                        "--norc".to_string(),
849                        "-eo".to_string(),
850                        "pipefail".to_string(),
851                        "-c".to_string(),
852                        command,
853                    ]
854                };
855            }
856            Some("sh") => {
857                spec.command = if let Some(cmd) = parse_simple_shell_command(&command) {
858                    cmd
859                } else {
860                    vec![
861                        "sh".to_string(),
862                        "-e".to_string(),
863                        "-c".to_string(),
864                        command,
865                    ]
866                };
867            }
868            Some(shell) => {
869                return Err(RunnerErrorKind::UnsupportedShell {
870                    shell: shell.to_string(),
871                }
872                .into())
873            }
874        }
875        Ok(())
876    }
877
878    async fn download_action(&self, action: &str) -> Result<(PathBuf, String), RunnerError> {
879        let mut action_parts = action.splitn(2, '@').collect::<Vec<_>>();
880        if action_parts.len() != 2 {
881            return Err(RunnerErrorKind::BadActionName {
882                action: action.to_string(),
883            }
884            .into());
885        }
886        let (action_ref, action_repo) = (action_parts.pop().unwrap(), action_parts.pop().unwrap());
887        let action_repo_parts = action_repo.splitn(2, '/').collect::<Vec<_>>();
888        if action_repo_parts.len() != 2 {
889            return Err(RunnerErrorKind::BadActionName {
890                action: action.to_string(),
891            }
892            .into());
893        }
894        let action_path = format!(".gha-runner/step{}/action", self.step_index.0);
895        let action_host_path = self.ctx.global_dir_host.join(&action_path);
896        let _ = fs::create_dir(&action_host_path);
897        let commit: params::repos::Commitish = action_ref.to_string().into();
898        let response = self
899            .ctx
900            .github
901            .repos(action_repo_parts[0], action_repo_parts[1])
902            .download_tarball(commit)
903            .await
904            .map_err(|e| RunnerErrorKind::ActionDownloadError {
905                action: action.to_string(),
906                inner: Box::new(e),
907            })?;
908        untar_response(action, response, &action_host_path).await?;
909        Ok((action_host_path, action_path))
910    }
911
912    fn read_action_yaml(&self, action: &str, path: &Path) -> Result<models::Action, RunnerError> {
913        let mut file = match File::open(path.join("action.yml")) {
914            Ok(f) => Ok(f),
915            Err(e) => {
916                if e.kind() == io::ErrorKind::NotFound {
917                    File::open(path.join("action.yaml"))
918                } else {
919                    Err(e)
920                }
921            }
922        }
923        .map_err(|_| RunnerErrorKind::ActionDownloadError {
924            action: action.to_string(),
925            inner: "action.yml not found".into(),
926        })?;
927        let mut buf = Vec::new();
928        file.read_to_end(&mut buf).unwrap();
929        Ok(Action(serde_yaml::from_slice(&buf).map_err(|e| {
930            RunnerErrorKind::InvalidActionYaml {
931                action: action.to_string(),
932                inner: e,
933            }
934        })?))
935    }
936}
937
938/// Analyzes and runs a specific Github Actions workflow.
939pub struct Runner {
940    ctx: RunnerContext,
941    workflow_env: LinkedHashMap<String, String>,
942    workflow_name: Option<String>,
943    job_descriptions: Vec<JobDescription>,
944}
945
946/// `ErrorContextRoot` describes what we were processing when an error occurred.
947#[derive(Clone, Debug)]
948pub enum ErrorContextRoot {
949    /// The error occurred processing a workflow YAML file.
950    Workflow,
951    /// The error occurred processing an action's YAML file.
952    Action(String),
953}
954
955impl fmt::Display for ErrorContextRoot {
956    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
957        match self {
958            ErrorContextRoot::Workflow => {
959                write!(f, "workflow")
960            }
961            ErrorContextRoot::Action(ref action) => {
962                write!(f, "action {}", action)
963            }
964        }
965    }
966}
967
968/// This describes wha we know about the source of an error.
969#[derive(Clone, Debug)]
970pub struct ErrorContext {
971    /// Which YAML file was the context of the error.
972    pub root: ErrorContextRoot,
973    /// Which job we were processing.
974    pub job_name: Option<String>,
975    /// Which step we were processing.
976    pub step: Option<StepIndex>,
977}
978
979impl ErrorContext {
980    pub(crate) fn new(root: ErrorContextRoot) -> ErrorContext {
981        ErrorContext {
982            root: root,
983            job_name: None,
984            step: None,
985        }
986    }
987    pub(crate) fn job_name(mut self, job_name: Option<String>) -> ErrorContext {
988        self.job_name = job_name;
989        self
990    }
991    pub(crate) fn step(mut self, step: StepIndex) -> ErrorContext {
992        self.step = Some(step);
993        self
994    }
995}
996
997impl fmt::Display for ErrorContext {
998    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
999        if let Some(job) = self.job_name.as_ref() {
1000            if let Some(step) = self.step {
1001                write!(f, "{} job '{}' step {}", self.root, job, step.0)
1002            } else {
1003                write!(f, "{} job '{}'", self.root, job)
1004            }
1005        } else {
1006            write!(f, "{}", self.root)
1007        }
1008    }
1009}
1010
1011/// Details about an error we encountered.
1012#[derive(Debug)]
1013pub enum RunnerErrorKind {
1014    /// Error parsing workflow YAML file.
1015    InvalidWorkflowYaml {
1016        /// The actual parse error.
1017        inner: serde_yaml::Error,
1018    },
1019    /// We expected a certain kind of JSON value but found a different kind.
1020    TypeMismatch {
1021        /// What we expected.
1022        expected: String,
1023        /// What we got.
1024        got: Value,
1025    },
1026    /// A JSON object should have had a specific field but it was missing.
1027    RequiredFieldMissing {
1028        /// Name of the missing field.
1029        field_name: &'static str,
1030        /// The JSON object.
1031        got: Value,
1032    },
1033    /// Failed to parse a Github Actions expression.
1034    ExpressionParseError {
1035        /// The unparseable expression.
1036        expression: String,
1037    },
1038    /// An expression occurs in a string interpolation context but produced a non-string result.
1039    ExpressionNonString {
1040        /// The expression.
1041        expression: String,
1042        /// The value it evaluated it.
1043        value: Value,
1044    },
1045    /// An 'runs-on' value is not supported.
1046    UnsupportedPlatform {
1047        /// The 'runs-on' value.
1048        runs_on: String,
1049    },
1050    /// Failed to parse an action name.
1051    BadActionName {
1052        /// The invalid action name.
1053        action: String,
1054    },
1055    /// Failed to download the code for an action.
1056    ActionDownloadError {
1057        /// The name of the action.
1058        action: String,
1059        /// The download error.
1060        inner: Box<dyn Error + Sync + Send + 'static>,
1061    },
1062    /// Error parsing an action's YAML file.
1063    InvalidActionYaml {
1064        /// The name of the action.
1065        action: String,
1066        /// The actual parse error.
1067        inner: serde_yaml::Error,
1068    },
1069    /// The workflow specified an environment variable name that's invalid (e.g. contains `=`).
1070    InvalidEnvironmentVariableName {
1071        /// The invalid environment variable name.
1072        name: String,
1073    },
1074    /// An action's `using` value is not supported.
1075    UnsupportedActionType {
1076        /// The name of the action.
1077        action: String,
1078        /// Its unsupported 'using' value.
1079        using: String,
1080    },
1081    /// `pre` is not currently supported.
1082    UnsupportedPre {
1083        /// The action that uses `pre`.
1084        action: String,
1085    },
1086    /// A shell type is not supported.
1087    UnsupportedShell {
1088        /// The unsupported `shell` value.
1089        shell: String,
1090    },
1091}
1092
1093impl RunnerErrorKind {
1094    fn source(&self) -> Option<&(dyn Error + 'static)> {
1095        Some(match self {
1096            RunnerErrorKind::InvalidWorkflowYaml { ref inner, .. } => &*inner,
1097            RunnerErrorKind::InvalidActionYaml { ref inner, .. } => &*inner,
1098            RunnerErrorKind::ActionDownloadError { ref inner, .. } => &**inner,
1099            _ => return None,
1100        })
1101    }
1102
1103    pub(crate) fn error<'a>(self, context: &impl ContextResolver<'a>) -> RunnerError {
1104        RunnerError {
1105            kind: self,
1106            backtrace: Backtrace::new(),
1107            context: Some(context.error_context()),
1108        }
1109    }
1110}
1111
1112impl fmt::Display for RunnerErrorKind {
1113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
1114        match self {
1115            RunnerErrorKind::InvalidWorkflowYaml { ref inner } => {
1116                write!(f, "Invalid workflow YAML: {}", inner)
1117            }
1118            RunnerErrorKind::TypeMismatch {
1119                ref expected,
1120                ref got,
1121            } => {
1122                write!(f, "Expected {}, got {:?}", expected, got)
1123            }
1124            RunnerErrorKind::RequiredFieldMissing {
1125                field_name,
1126                ref got,
1127            } => {
1128                write!(f, "Expected field {}, missing in {:?}", field_name, got)
1129            }
1130            RunnerErrorKind::ExpressionParseError { ref expression } => {
1131                write!(f, "Error parsing expression '{}'", expression)
1132            }
1133            RunnerErrorKind::ExpressionNonString {
1134                ref expression,
1135                ref value,
1136            } => {
1137                write!(
1138                    f,
1139                    "Expression in string interpolation '{}' is not a string, got {:?}",
1140                    expression, value
1141                )
1142            }
1143            RunnerErrorKind::UnsupportedPlatform { ref runs_on } => {
1144                write!(f, "Platform '{}' not supported", runs_on)
1145            }
1146            RunnerErrorKind::BadActionName { ref action } => {
1147                write!(f, "Cannot parse action name '{}'", action)
1148            }
1149            RunnerErrorKind::ActionDownloadError {
1150                ref action,
1151                ref inner,
1152            } => {
1153                write!(f, "Failed to download action '{}': {}", action, inner)
1154            }
1155            RunnerErrorKind::InvalidActionYaml {
1156                ref action,
1157                ref inner,
1158            } => {
1159                write!(f, "Invalid YAML for action '{}': {}", action, inner)
1160            }
1161            RunnerErrorKind::InvalidEnvironmentVariableName { ref name } => {
1162                write!(f, "Invalid evironment variable name '{}'", name)
1163            }
1164            RunnerErrorKind::UnsupportedActionType {
1165                ref action,
1166                ref using,
1167            } => {
1168                write!(f, "Unsupported type '{}' for action '{}'", using, action)
1169            }
1170            RunnerErrorKind::UnsupportedPre { ref action } => {
1171                write!(f, "'pre' rules not supported for action '{}'", action)
1172            }
1173            RunnerErrorKind::UnsupportedShell { ref shell } => {
1174                write!(f, "Shell '{}' not supported", shell)
1175            }
1176        }
1177    }
1178}
1179
1180/// Errors returned by this crate.
1181#[derive(Debug)]
1182pub struct RunnerError {
1183    kind: RunnerErrorKind,
1184    backtrace: Backtrace,
1185    context: Option<ErrorContext>,
1186}
1187
1188impl RunnerError {
1189    /// Get error details.
1190    pub fn kind(&self) -> &RunnerErrorKind {
1191        &self.kind
1192    }
1193    /// Get the error context if there is one.
1194    pub fn context(&self) -> Option<&ErrorContext> {
1195        self.context.as_ref()
1196    }
1197}
1198
1199impl From<RunnerErrorKind> for RunnerError {
1200    fn from(k: RunnerErrorKind) -> RunnerError {
1201        RunnerError {
1202            kind: k,
1203            backtrace: Backtrace::new(),
1204            context: None,
1205        }
1206    }
1207}
1208
1209impl fmt::Display for RunnerError {
1210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
1211        if let Some(ctx) = self.context.as_ref() {
1212            write!(f, "{} at {} at {:?}", &self.kind, ctx, &self.backtrace)
1213        } else {
1214            write!(f, "{} at {:?}", &self.kind, &self.backtrace)
1215        }
1216    }
1217}
1218
1219impl Error for RunnerError {
1220    fn source(&self) -> Option<&(dyn Error + 'static)> {
1221        self.kind.source()
1222    }
1223}
1224
1225fn cartesian_product(
1226    keys: &LinkedHashMap<String, Vec<Value>>,
1227) -> Vec<LinkedHashMap<String, Value>> {
1228    fn inner(
1229        v: LinkedHashMap<String, Value>,
1230        mut keys: linked_hash_map::Iter<String, Vec<Value>>,
1231        ret: &mut Vec<LinkedHashMap<String, Value>>,
1232    ) {
1233        if let Some((k, vals)) = keys.next() {
1234            for val in vals {
1235                let mut vv = v.clone();
1236                vv.insert(k.clone(), val.clone());
1237                inner(vv, keys.clone(), ret);
1238            }
1239        } else {
1240            ret.push(v);
1241        }
1242    }
1243    let mut ret = Vec::new();
1244    inner(LinkedHashMap::new(), keys.iter(), &mut ret);
1245    ret
1246}
1247
1248impl Runner {
1249    /// Create a new runner. `workflow` is the workflow YAML file contents.
1250    /// Normally you would fetch this from Github but you can instead pass anything
1251    /// you want here.
1252    pub async fn new(ctx: RunnerContext, workflow: &[u8]) -> Result<Runner, RunnerError> {
1253        let mut workflow = Workflow(
1254            serde_yaml::from_slice(workflow)
1255                .map_err(|e| RunnerErrorKind::InvalidWorkflowYaml { inner: e })?,
1256        );
1257        let mut job_descriptions = Vec::new();
1258        let root_context = RootContext::new(&ctx, ErrorContextRoot::Workflow);
1259        let workflow_env = workflow.take_env(&root_context)?;
1260        for (name, mut job) in workflow.take_jobs(&root_context)? {
1261            let job_context = JobContext::new(&ctx);
1262            if let Some(mut strategy) = job.take_strategy(&job_context)? {
1263                if let Some(mut matrix) = strategy.take_matrix(&job_context)? {
1264                    let mut values = cartesian_product(&matrix.take_keys(&job_context)?);
1265                    values.append(&mut matrix.take_include(&job_context)?);
1266                    if !values.is_empty() {
1267                        for v in values {
1268                            let mut context = JobPostStrategyContext::new(&ctx, &v);
1269                            let runs_on = job.clone_runs_on(&context)?;
1270                            let name = job.clone_name(&context)?.unwrap_or_else(|| name.clone());
1271                            context.set_job_name(name.clone());
1272                            let job_env = job.clone_env(&context)?;
1273                            let mut derived_name = format!("{} (", name);
1274                            let mut first = true;
1275                            for val in v.values() {
1276                                if let Value::String(ref s) = val {
1277                                    if first {
1278                                        first = false;
1279                                    } else {
1280                                        derived_name.push_str(", ");
1281                                    }
1282                                    derived_name.push_str(&s[..]);
1283                                } else {
1284                                    warn!(
1285                                        "Non-string matrix value, not sure how this gets rendered"
1286                                    );
1287                                }
1288                            }
1289                            derived_name.push(')');
1290                            let steps = job.clone_steps(&context)?;
1291                            job_descriptions.push(JobDescription {
1292                                name: derived_name,
1293                                runs_on: runs_on,
1294                                matrix_values: v,
1295                                job_env: job_env,
1296                                steps: steps,
1297                            });
1298                        }
1299                        continue;
1300                    }
1301                }
1302            }
1303            let empty = LinkedHashMap::new();
1304            let mut context = JobPostStrategyContext::new(&ctx, &empty);
1305            let name = job.clone_name(&context)?.unwrap_or(name);
1306            context.set_job_name(name.clone());
1307            let steps = job.clone_steps(&context)?;
1308            job_descriptions.push(JobDescription {
1309                name: name,
1310                runs_on: job.clone_runs_on(&context)?,
1311                matrix_values: LinkedHashMap::new(),
1312                job_env: job.clone_env(&context)?,
1313                steps: steps,
1314            });
1315        }
1316        let workflow_name = workflow.take_name(&root_context)?;
1317        info!(
1318            "Created Runner for workflow {}",
1319            workflow_name.as_deref().unwrap_or("<unknown>")
1320        );
1321        Ok(Runner {
1322            ctx: ctx,
1323            workflow_env: workflow_env,
1324            workflow_name: workflow_name,
1325            job_descriptions: job_descriptions,
1326        })
1327    }
1328    /// Get descriptions of all the jobs in the workflow.
1329    pub fn job_descriptions(&self) -> &[JobDescription] {
1330        &self.job_descriptions
1331    }
1332    /// Create a JobRunner that can be used to run the given job.
1333    pub async fn job_runner(
1334        &self,
1335        description: &JobDescription,
1336        image_mapping: &DockerImageMapping,
1337    ) -> Result<JobRunner, RunnerError> {
1338        let mut images = Vec::new();
1339        if let Some(image) = description.main_container(image_mapping) {
1340            images.push(image);
1341        } else {
1342            return Err(RunnerErrorKind::UnsupportedPlatform {
1343                runs_on: description
1344                    .runs_on
1345                    .first()
1346                    .cloned()
1347                    .unwrap_or_else(|| "<none>".to_string()),
1348            }
1349            .into());
1350        }
1351        let mut env = self.ctx.create_env(self.workflow_name.clone());
1352
1353        env.extend(
1354            self.workflow_env
1355                .iter()
1356                .map(|(k, v)| (k.clone(), v.clone())),
1357        );
1358        env.extend(
1359            description
1360                .job_env
1361                .iter()
1362                .map(|(k, v)| (k.clone(), v.clone())),
1363        );
1364        let steps = description.steps.clone();
1365        Ok(JobRunner {
1366            ctx: self.ctx.clone(),
1367            job_name: description.name().to_string(),
1368            matrix_values: description.matrix_values.clone(),
1369            container_images: images,
1370            steps: steps,
1371            outputs: Vec::new(),
1372            env: env,
1373            paths: Vec::new(),
1374            step_index: StepIndex(0),
1375        })
1376    }
1377}