github_workflow_update/
workflow.rs

1// Copyright (C) 2022 Leandro Lisboa Penz <lpenz@lpenz.org>
2// This file is subject to the terms and conditions defined in
3// file 'LICENSE', which is part of this source code package.
4
5//! Workflow file parsing, into [`Workflow`] type.
6//!
7//! A workflow can have one or more [`Entity`]s that represent
8//! resource with a version.
9
10use anyhow::{anyhow, Result};
11use futures::future::join_all;
12use regex::Regex;
13use serde_yaml::Value;
14use std::collections::HashSet;
15use std::io;
16use std::path;
17use tokio::io::AsyncReadExt;
18use tokio::io::AsyncWriteExt;
19use tracing::event;
20use tracing::instrument;
21use tracing::Level;
22
23use crate::entity::Entity;
24use crate::resolver;
25use crate::version::Version;
26
27#[derive(Debug)]
28pub struct Workflow {
29    pub filename: path::PathBuf,
30    pub contents: String,
31    pub entities: HashSet<Entity>,
32}
33
34impl Workflow {
35    #[instrument(level="debug", fields(filename = ?filename.as_ref().display()))]
36    pub async fn new(filename: impl AsRef<path::Path>) -> Result<Workflow> {
37        let filename = filename.as_ref();
38        let mut file = tokio::fs::File::open(filename).await?;
39        let mut contents = String::new();
40        file.read_to_string(&mut contents).await?;
41        let entities = buf_parse(contents.as_bytes())?;
42        Ok(Workflow {
43            filename: filename.to_owned(),
44            contents,
45            entities,
46        })
47    }
48
49    #[instrument(level = "debug")]
50    pub async fn resolve_entities(&mut self, resolver: &resolver::Server) {
51        let entities = std::mem::take(&mut self.entities);
52        let resolve_entity_tasks = entities
53            .into_iter()
54            .map(|e| (e, resolver.new_client()))
55            .map(|(e, resolver_client)| async move { resolver_client.resolve_entity(e).await });
56        self.entities = join_all(resolve_entity_tasks)
57            .await
58            .into_iter()
59            .collect::<HashSet<_>>();
60    }
61
62    #[instrument(level = "debug")]
63    pub async fn update_file(&self) -> Result<bool> {
64        let mut contents = self.contents.clone();
65        for entity in &self.entities {
66            if let Some(updated_line) = &entity.updated_line {
67                contents = contents.replace(&entity.line, updated_line);
68            }
69        }
70        let updated = contents != self.contents;
71        if updated {
72            let mut file = tokio::fs::File::create(&self.filename).await?;
73            file.write_all(contents.as_bytes()).await?;
74        }
75        Ok(updated)
76    }
77}
78
79#[instrument(level = "debug")]
80fn reference_parse_version(
81    re_docker: &Regex,
82    re_github: &Regex,
83    reference: &str,
84) -> Option<(String, Version)> {
85    if let Some(m) = re_docker.captures(reference) {
86        return Some((
87            m.name("resource").unwrap().as_str().into(),
88            Version::new(m.name("version").unwrap().as_str())?,
89        ));
90    }
91    if let Some(m) = re_github.captures(reference) {
92        return Some((
93            format!("github://{}", m.name("userrepo").unwrap().as_str()),
94            Version::new(m.name("version").unwrap().as_str())?,
95        ));
96    }
97    None
98}
99
100#[instrument(level = "debug", skip(r))]
101fn buf_parse(r: impl io::BufRead) -> Result<HashSet<Entity>> {
102    let data: serde_yaml::Mapping = serde_yaml::from_reader(r)?;
103    let jobs = data
104        .get(&Value::String("jobs".into()))
105        .ok_or_else(|| anyhow!("jobs entry not found"))?
106        .as_mapping()
107        .ok_or_else(|| anyhow!("invalid type for jobs entry"))?;
108    let mut ret = HashSet::default();
109    let re_docker = Regex::new(r"^(?P<resource>docker://[^:]+):(?P<version>[^:]+)$").unwrap();
110    let re_github = Regex::new(r"^(?P<userrepo>[^/]+/[^@]+)@(?P<version>[^@]+)$").unwrap();
111    for (_, job) in jobs {
112        if let Some(steps) = job.get(&Value::String("steps".into())) {
113            let steps = steps
114                .as_sequence()
115                .ok_or_else(|| anyhow!("invalid type for steps entry"))?;
116            for step in steps {
117                if let Some(uses) = step.get(&Value::String("uses".into())) {
118                    let reference = uses
119                        .as_str()
120                        .ok_or_else(|| anyhow!("invalid type for uses entry"))?;
121                    if let Some((resource, version)) =
122                        reference_parse_version(&re_docker, &re_github, reference)
123                    {
124                        let entity = Entity {
125                            line: reference.into(),
126                            resource,
127                            version,
128                            ..Default::default()
129                        };
130                        event!(Level::INFO, reference = reference, "parsed entity");
131                        ret.insert(entity);
132                    } else {
133                        event!(Level::WARN, reference = reference, "entity not parsed");
134                    }
135                }
136            }
137        }
138    }
139    Ok(ret)
140}
141
142#[test]
143fn test_parse() -> Result<()> {
144    let s = r"
145---
146name: test
147jobs:
148  omnilint:
149    runs-on: ubuntu-latest
150    steps:
151      - uses: actions/checkout@v2
152      - uses: docker://lpenz/omnilint:0.4
153      - run: ls
154";
155    buf_parse(s.as_bytes())?;
156    Ok(())
157}