Skip to main content

ambient_ci/
linter.rs

1//! Look for common problems in configuration and CI plan.
2
3use std::{
4    path::{Path, PathBuf},
5    process::Command,
6};
7
8use clingwrap::runner::{CommandError, CommandRunner};
9
10use crate::{
11    action::{PostPlanAction, PrePlanAction, UnsafeAction},
12    action_impl::bash_snippet,
13    config::Config,
14    project::{Project, Projects},
15};
16
17/// Look for common problems.
18#[derive(Debug)]
19pub struct Linter<'a> {
20    config: &'a Config,
21    projects: &'a Projects,
22    lints: Vec<Box<dyn Lint>>,
23}
24
25impl<'a> Linter<'a> {
26    /// Create a new linter using a given configuration and project list.
27    pub fn new(config: &'a Config, projects: &'a Projects) -> Self {
28        Self {
29            config,
30            projects,
31            lints: vec![
32                Box::new(RsyncTarget),
33                Box::new(HttpGet),
34                Box::new(ShellCheck),
35                Box::new(DebDputVersions),
36                Box::new(OldWorkspacePath),
37            ],
38        }
39    }
40
41    /// Lint a configuration and projects list.
42    pub fn lint(&self) -> Result<(), LinterError> {
43        for (name, project) in self.projects.iter() {
44            for lint in self.lints.iter() {
45                lint.check(self.config, name, project)?;
46            }
47        }
48        Ok(())
49    }
50}
51
52trait Lint: std::fmt::Debug {
53    #[allow(dead_code)]
54    fn name(&self) -> &'static str;
55    fn check(
56        &self,
57        config: &Config,
58        project_name: &str,
59        project: &Project,
60    ) -> Result<(), LinterError>;
61}
62
63#[derive(Debug)]
64struct RsyncTarget;
65
66impl Lint for RsyncTarget {
67    fn name(&self) -> &'static str {
68        "rsync_target"
69    }
70
71    fn check(
72        &self,
73        config: &Config,
74        project_name: &str,
75        project: &Project,
76    ) -> Result<(), LinterError> {
77        let uses_rsync = project
78            .post_plan()
79            .iter()
80            .any(|a| matches!(a, PostPlanAction::Rsync | PostPlanAction::Rsync2));
81        let configs_rsync = config.rsync_target_for_project(project_name).is_some();
82        if uses_rsync && !configs_rsync {
83            return Err(LinterError::rsync_target_missing(project_name));
84        }
85        Ok(())
86    }
87}
88
89#[derive(Debug)]
90struct HttpGet;
91
92impl Lint for HttpGet {
93    fn name(&self) -> &'static str {
94        "http_get"
95    }
96
97    fn check(
98        &self,
99        _config: &Config,
100        project_name: &str,
101        project: &Project,
102    ) -> Result<(), LinterError> {
103        for action in project.pre_plan() {
104            let mut filenames = vec![];
105            if let PrePlanAction::HttpGet { items } = action {
106                for item in items {
107                    if filenames.contains(&item.filename) {
108                        return Err(LinterError::http_get_duplicate_filename(
109                            project_name,
110                            &item.filename,
111                        ));
112                    }
113                    filenames.push(item.filename.clone());
114                }
115            }
116        }
117
118        Ok(())
119    }
120}
121
122#[derive(Debug)]
123struct ShellCheck;
124
125impl Lint for ShellCheck {
126    fn name(&self) -> &'static str {
127        "shellcheck"
128    }
129
130    fn check(
131        &self,
132        _config: &Config,
133        _project_name: &str,
134        project: &Project,
135    ) -> Result<(), LinterError> {
136        for action in project.plan() {
137            if let UnsafeAction::Shell { shell } = action {
138                let mut cmd = Command::new("shellcheck");
139                cmd.args(["-s", "bash", "-"]);
140                let mut runner = CommandRunner::new(cmd);
141                runner.feed_stdin(bash_snippet(shell).as_bytes());
142                runner.capture_stdout();
143                runner.capture_stderr();
144                match runner.execute() {
145                    Ok(_) => (),
146                    Err(CommandError::CommandFailed { output, .. }) => {
147                        return Err(LinterError::shellcheck(output.stdout))
148                    }
149                    Err(err) => return Err(LinterError::ShellcheckError(err)),
150                }
151            }
152        }
153
154        Ok(())
155    }
156}
157
158#[derive(Debug)]
159struct DebDputVersions;
160
161impl Lint for DebDputVersions {
162    fn name(&self) -> &'static str {
163        "deb_dput_versions"
164    }
165
166    fn check(
167        &self,
168        _config: &Config,
169        _project_name: &str,
170        project: &Project,
171    ) -> Result<(), LinterError> {
172        let has_deb = project
173            .plan()
174            .iter()
175            .any(|action| matches!(action, UnsafeAction::Deb));
176        let has_deb2 = project
177            .plan()
178            .iter()
179            .any(|action| matches!(action, UnsafeAction::Deb2));
180        let has_dput = project
181            .post_plan()
182            .iter()
183            .any(|action| matches!(action, PostPlanAction::Dput));
184        let has_dput2 = project
185            .post_plan()
186            .iter()
187            .any(|action| matches!(action, PostPlanAction::Dput2));
188        if (has_deb && has_dput2) || (has_deb2 && has_dput) {
189            Err(LinterError::IncompatibleDebianActions)
190        } else {
191            Ok(())
192        }
193    }
194}
195
196#[derive(Debug)]
197struct OldWorkspacePath;
198
199impl Lint for OldWorkspacePath {
200    fn name(&self) -> &'static str {
201        "/workspace_used"
202    }
203
204    fn check(
205        &self,
206        _config: &Config,
207        _project_name: &str,
208        project: &Project,
209    ) -> Result<(), LinterError> {
210        let old_path_used = project.plan().iter().any(|action| {
211            if let UnsafeAction::Shell { shell } = action {
212                shell.contains("/workspace")
213            } else {
214                false
215            }
216        });
217        if old_path_used {
218            Err(LinterError::OldWorkspacePath)
219        } else {
220            Ok(())
221        }
222    }
223}
224
225/// Problems found by a linter.
226#[derive(Debug, thiserror::Error)]
227pub enum LinterError {
228    /// `rsync` or `rsync2` action used in plan, but no `rsync` target configured.
229    #[error(
230        "rsync or rsync2 action used in project {project}, but no `rsync` target in configuration"
231    )]
232    RsyncTargetMissing {
233        /// Name of project.
234        project: String,
235    },
236
237    /// `http_get` action uses the same filename for items.
238    #[error("in project {project_name} http_get pre-plan action uses the same filename for more than one item: {filename}")]
239    HttpGetDuplicateFilename {
240        /// Project name.
241        project_name: String,
242
243        /// Duplicate filename.
244        filename: PathBuf,
245    },
246
247    /// `shellcheck` command complains.
248    #[error("shellcheck found problems: {0}")]
249    Shellcheck(String),
250
251    /// Can't execute `shellcheck`.
252    #[error("failed to run shellcheck")]
253    ShellcheckError(#[source] CommandError),
254
255    /// Has conflicting `deb`/`deb2` vs `dput`/`dput2` actions.
256    #[error("has incompible deb/deb2 and dput/dput2 pairs, use only one version")]
257    IncompatibleDebianActions,
258
259    /// Old workspace path used.
260    #[error("old path /workspace used, use /ci instead")]
261    OldWorkspacePath,
262}
263
264impl LinterError {
265    fn rsync_target_missing(project_name: &str) -> Self {
266        Self::RsyncTargetMissing {
267            project: project_name.into(),
268        }
269    }
270
271    fn http_get_duplicate_filename(project_name: &str, filename: &Path) -> Self {
272        Self::HttpGetDuplicateFilename {
273            project_name: project_name.into(),
274            filename: filename.into(),
275        }
276    }
277
278    fn shellcheck(output: Vec<u8>) -> Self {
279        let output = String::from_utf8_lossy(&output).to_string();
280        Self::Shellcheck(output)
281    }
282}