github_workflows_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
7use anyhow::{Result, anyhow};
8use futures::future::join_all;
9use serde_yml::Value;
10use std::collections::HashMap;
11use std::collections::HashSet;
12use std::io;
13use std::path;
14use tokio::io::AsyncReadExt;
15use tokio::io::AsyncWriteExt;
16use tracing::Level;
17use tracing::event;
18use tracing::instrument;
19
20use crate::proxy;
21use crate::resource::Resource;
22use crate::version::Version;
23
24#[derive(Debug)]
25pub struct Workflow {
26    /// The name of the workflow file.
27    pub filename: path::PathBuf,
28    /// Contents of the workflow file as a `String`.
29    pub contents: String,
30    /// Set with all [`Resource`]s that the workflow `uses` along with the
31    /// current versions.
32    pub uses: HashSet<(Resource, Version)>,
33    /// The latest version of each [`Resource`] as fetched from the
34    /// upstream docker or github repository.
35    pub latest: HashMap<Resource, Version>,
36}
37
38impl Workflow {
39    #[instrument(level="debug", fields(filename = ?filename.as_ref().display()))]
40    pub async fn new(filename: impl AsRef<path::Path>) -> Result<Workflow> {
41        let filename = filename.as_ref();
42        let mut file = tokio::fs::File::open(filename).await?;
43        let mut contents = String::new();
44        file.read_to_string(&mut contents).await?;
45        let uses = buf_parse(contents.as_bytes())?;
46        Ok(Workflow {
47            filename: filename.to_owned(),
48            contents,
49            uses,
50            latest: Default::default(),
51        })
52    }
53
54    #[instrument(level = "debug")]
55    pub async fn fetch_latest_versions(&mut self, proxy_server: &proxy::Server) {
56        let tasks = self
57            .uses
58            .iter()
59            .map(|rv| (rv, proxy_server.new_client()))
60            .map(|((resource, current_version), proxy_client)| async move {
61                proxy_client
62                    .fetch_latest_version(resource, current_version)
63                    .await
64            });
65        self.latest = join_all(tasks)
66            .await
67            .into_iter()
68            .flatten()
69            .collect::<HashMap<_, _>>();
70    }
71
72    #[instrument(level = "debug")]
73    pub async fn update_file(&self) -> Result<bool> {
74        let mut contents = self.contents.clone();
75        for (resource, current_version) in &self.uses {
76            if let Some(latest_version) = self.latest.get(resource) {
77                let current_line = resource.versioned_string(current_version);
78                let latest_line = resource.versioned_string(latest_version);
79                contents = contents.replace(&current_line, &latest_line);
80            }
81        }
82        let updated = contents != self.contents;
83        if updated {
84            let mut file = tokio::fs::File::create(&self.filename).await?;
85            file.write_all(contents.as_bytes()).await?;
86        }
87        Ok(updated)
88    }
89}
90
91#[instrument(level = "debug", skip(r))]
92fn buf_parse(r: impl io::BufRead) -> Result<HashSet<(Resource, Version)>> {
93    let data: serde_yml::Mapping = serde_yml::from_reader(r)?;
94    let jobs = data
95        .get(Value::String("jobs".into()))
96        .ok_or_else(|| anyhow!("jobs entry not found"))?
97        .as_mapping()
98        .ok_or_else(|| anyhow!("invalid type for jobs entry"))?;
99    let mut ret = HashSet::default();
100    for (_, job) in jobs {
101        if let Some(uses) = job.get(Value::String("uses".into())) {
102            let reference = uses
103                .as_str()
104                .ok_or_else(|| anyhow!("invalid type for uses entry"))?;
105            if let Ok((resource, version)) = Resource::parse(reference) {
106                event!(
107                    Level::INFO,
108                    resource = %resource,
109                    version = %version,
110                    "parsed entity"
111                );
112                ret.insert((resource, version));
113            } else {
114                event!(
115                    Level::WARN,
116                    reference = reference,
117                    "unable to parse resource"
118                );
119            }
120        }
121        if let Some(steps) = job.get(Value::String("steps".into())) {
122            let steps = steps
123                .as_sequence()
124                .ok_or_else(|| anyhow!("invalid type for steps entry"))?;
125            for step in steps {
126                if let Some(uses) = step.get(Value::String("uses".into())) {
127                    let reference = uses
128                        .as_str()
129                        .ok_or_else(|| anyhow!("invalid type for uses entry"))?;
130                    if let Ok((resource, version)) = Resource::parse(reference) {
131                        event!(
132                            Level::INFO,
133                            resource = %resource,
134                            version = %version,
135                            "parsed entity"
136                        );
137                        ret.insert((resource, version));
138                    } else {
139                        event!(
140                            Level::WARN,
141                            reference = reference,
142                            "unable to parse resource"
143                        );
144                    }
145                }
146            }
147        }
148    }
149    Ok(ret)
150}
151
152#[test]
153fn test_parse() -> Result<()> {
154    let s = r"
155---
156name: test
157jobs:
158  omnilint:
159    runs-on: ubuntu-latest
160    steps:
161      - uses: actions/checkout@v2
162      - uses: docker://lpenz/omnilint:0.4
163      - run: ls
164  rust:
165    uses: lpenz/ghworkflow-rust/.github/workflows/rust.yml@v0.4
166";
167    buf_parse(s.as_bytes())?;
168    Ok(())
169}