astro_run/
user_config.rs

1use crate::{Condition, EnvironmentVariables, Error, Id, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Deserialize, Serialize)]
6pub struct ContainerOptions {
7  pub name: String,
8  pub volumes: Option<Vec<String>>,
9  #[serde(rename = "security-opts")]
10  pub security_opts: Option<Vec<String>>,
11}
12
13#[derive(Debug, Clone, Deserialize, Serialize)]
14#[serde(untagged)]
15pub enum Container {
16  Options(ContainerOptions),
17  Name(String),
18}
19
20#[derive(Debug, Clone, Deserialize, Serialize, Default)]
21pub struct UserCommandStep {
22  pub name: Option<String>,
23  pub container: Option<Container>,
24  pub run: String,
25  pub on: Option<Condition>,
26  #[serde(rename = "continue-on-error")]
27  pub continue_on_error: Option<bool>,
28  pub environments: Option<EnvironmentVariables>,
29  pub secrets: Option<Vec<String>>,
30  pub timeout: Option<String>,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize, Default)]
34pub struct UserActionStep {
35  pub name: Option<String>,
36  pub uses: String,
37  pub with: Option<serde_yaml::Value>,
38  pub on: Option<Condition>,
39  #[serde(rename = "continue-on-error")]
40  pub continue_on_error: Option<bool>,
41  pub environments: Option<EnvironmentVariables>,
42  pub secrets: Option<Vec<String>>,
43  pub timeout: Option<String>,
44}
45
46#[allow(clippy::large_enum_variant)]
47#[derive(Debug, Clone, Deserialize, Serialize)]
48#[serde(untagged)]
49pub enum UserStep {
50  Command(UserCommandStep),
51  Action(UserActionStep),
52}
53
54#[derive(Serialize, Deserialize, Debug, Clone)]
55pub struct UserJob {
56  pub name: Option<String>,
57  pub container: Option<Container>,
58  /// Working directory for all steps in this job
59  #[serde(rename = "working-directories")]
60  pub working_dirs: Option<Vec<String>>,
61  pub steps: Vec<UserStep>,
62  pub on: Option<Condition>,
63  #[serde(rename = "depends-on")]
64  pub depends_on: Option<Vec<String>>,
65}
66
67#[derive(Serialize, Deserialize, Debug, Clone)]
68pub struct UserWorkflow {
69  pub name: Option<String>,
70  pub on: Option<Condition>,
71  pub jobs: HashMap<Id, UserJob>,
72}
73
74impl UserWorkflow {
75  fn validate(workflow: &UserWorkflow) -> Result<()> {
76    if workflow.jobs.is_empty() {
77      return Err(Error::workflow_config_error(
78        "Workflow must have at least one job",
79      ));
80    }
81
82    let mut is_all_jobs_has_dependencies = true;
83    // Validate dependencies key in jobs
84    for (job_name, job) in &workflow.jobs {
85      if let Some(depends_on) = &job.depends_on {
86        if !depends_on.is_empty() {
87          for depend_job_key in depends_on {
88            if !workflow.jobs.contains_key(depend_job_key) {
89              return Err(Error::workflow_config_error(format!(
90                "Job {} depends on job {}, but job {} is not defined",
91                job_name, depend_job_key, depend_job_key
92              )));
93            }
94          }
95        } else {
96          is_all_jobs_has_dependencies = false;
97        }
98      } else {
99        is_all_jobs_has_dependencies = false;
100      }
101
102      if job.steps.is_empty() {
103        return Err(Error::workflow_config_error(format!(
104          "Job `{}` must have at least one step",
105          job_name
106        )));
107      }
108    }
109
110    if is_all_jobs_has_dependencies {
111      return Err(Error::workflow_config_error(
112        "Cannot have all jobs has dependencies",
113      ));
114    }
115
116    Ok(())
117  }
118}
119
120impl Container {
121  pub fn name(&self) -> &str {
122    match self {
123      Self::Options(docker) => &docker.name,
124      Self::Name(name) => name,
125    }
126  }
127
128  pub fn normalize(&self) -> ContainerOptions {
129    match self {
130      Self::Options(docker) => docker.clone(),
131      Self::Name(name) => ContainerOptions {
132        name: name.clone(),
133        security_opts: None,
134        volumes: None,
135      },
136    }
137  }
138}
139
140impl TryFrom<&str> for UserWorkflow {
141  type Error = Error;
142
143  fn try_from(value: &str) -> Result<Self> {
144    let workflow = serde_yaml::from_str(value)
145      .map_err(|e| Error::workflow_config_error(format!("Failed to parse workflow: {}", e)))?;
146
147    Self::validate(&workflow)?;
148
149    Ok(workflow)
150  }
151}
152
153impl TryFrom<String> for UserWorkflow {
154  type Error = Error;
155
156  fn try_from(value: String) -> Result<Self> {
157    Self::try_from(value.as_str())
158  }
159}
160
161#[cfg(test)]
162mod tests {
163  use super::*;
164  use crate::{ConditionConfig, EnvironmentVariable, PullRequestCondition, PushCondition};
165
166  #[test]
167  fn test_parse() {
168    let yaml = r#"
169name: Test Workflow
170
171jobs:
172  test-job:
173    name: Test Job
174    working-directories:
175    - /home/runner/work
176    steps:
177      - name: Test Step
178        continue-on-error: true
179        timeout: 10m
180        environments:
181          TEST_ENV: test
182          number: 1
183          boolean: true
184        run: echo "Hello World"
185      - name: Action step
186        uses: cache
187"#;
188
189    let workflow = UserWorkflow::try_from(yaml).unwrap();
190
191    assert_eq!(workflow.name, Some("Test Workflow".to_string()));
192
193    let job = workflow.jobs.get("test-job").unwrap();
194
195    assert_eq!(job.name, Some("Test Job".to_string()));
196
197    let step = job.steps.first().unwrap();
198
199    if let UserStep::Command(command_step) = step {
200      let UserCommandStep {
201        name,
202        environments,
203        run,
204        continue_on_error,
205        timeout,
206        ..
207      } = command_step;
208      assert_eq!(name.as_ref().unwrap(), "Test Step");
209      // assert_eq!(working_dir.as_ref().unwrap(), "/home/runner/work");
210      assert_eq!(timeout.as_ref().unwrap(), "10m");
211      assert_eq!(continue_on_error, &Some(true));
212
213      let environments = environments.clone().unwrap();
214      assert_eq!(
215        environments.get("TEST_ENV").unwrap(),
216        &EnvironmentVariable::String("test".to_string())
217      );
218      assert_eq!(
219        environments.get("number").unwrap(),
220        &EnvironmentVariable::Number(1.0)
221      );
222      assert_eq!(
223        environments.get("boolean").unwrap(),
224        &EnvironmentVariable::Boolean(true)
225      );
226
227      assert_eq!(run, "echo \"Hello World\"");
228    } else {
229      panic!("Step should be command step");
230    }
231  }
232
233  #[test]
234  fn test_empty_jobs() {
235    let yaml = r#"jobs:"#;
236
237    let res = UserWorkflow::try_from(yaml);
238
239    assert_eq!(
240      res.unwrap_err(),
241      Error::workflow_config_error("Workflow must have at least one job")
242    );
243  }
244
245  #[test]
246  fn test_job_depend_not_exist() {
247    let yaml = r#"
248jobs:
249  job1:
250    depends-on: [job2]
251    steps:
252      - run: echo "Hello World"
253"#;
254
255    let res = UserWorkflow::try_from(yaml);
256    assert_eq!(
257      res.unwrap_err(),
258      Error::workflow_config_error("Job job1 depends on job job2, but job job2 is not defined")
259    );
260  }
261
262  #[test]
263  fn test_empty_depend() {
264    let yaml = r#"
265    jobs:
266      job1:
267        depends-on: []
268        steps:
269          - run: echo "Hello World"
270      job2:
271        depends-on: [job1]
272        steps:
273          - run: echo "Hello World"
274    "#;
275
276    UserWorkflow::try_from(yaml).unwrap();
277  }
278
279  #[test]
280  fn test_job_dependencies() {
281    let yaml = r#"
282jobs:
283  job1:
284    depends-on: [job2]
285    steps:
286      - run: echo "Hello World"
287  job2:
288    depends-on: [job1]
289    steps:
290      - run: echo "Hello World"
291"#;
292
293    let res = UserWorkflow::try_from(yaml);
294    assert_eq!(
295      res.unwrap_err(),
296      Error::workflow_config_error("Cannot have all jobs has dependencies")
297    );
298  }
299
300  #[test]
301  fn test_empty_steps() {
302    let yaml = r#"
303jobs:
304  job1:
305    name: Test Job
306    steps:
307"#;
308
309    let res = UserWorkflow::try_from(yaml);
310    assert_eq!(
311      res.unwrap_err(),
312      Error::workflow_config_error("Job `job1` must have at least one step")
313    );
314  }
315
316  #[test]
317  fn test_container_name() {
318    let yaml = r#"
319jobs:
320  job1:
321    name: Test Job
322    container: test
323    steps:
324      - run: echo "Hello World"
325"#;
326
327    let workflow = UserWorkflow::try_from(yaml).unwrap();
328    let job = workflow.jobs.get("job1").unwrap();
329    let container = job.container.as_ref().unwrap();
330    assert_eq!(container.name(), "test");
331  }
332
333  #[test]
334  fn test_container_options() {
335    let yaml = r#"
336jobs:
337  job1:
338    name: Test Job
339    container: 
340      name: test
341      volumes:
342        - /home/runner/work
343      security-opts:
344        - seccomp=unconfined
345    steps:
346      - run: echo "Hello World"
347"#;
348
349    let workflow = UserWorkflow::try_from(yaml).unwrap();
350    let job = workflow.jobs.get("job1").unwrap();
351    let container = job.container.as_ref().unwrap();
352    assert_eq!(container.name(), "test");
353
354    let normalized = container.normalize();
355    assert_eq!(normalized.name, "test");
356    assert_eq!(
357      normalized.security_opts,
358      Some(vec!["seccomp=unconfined".to_string()])
359    );
360    assert_eq!(
361      normalized.volumes,
362      Some(vec!["/home/runner/work".to_string()])
363    );
364  }
365
366  #[test]
367  fn test_events_condition() {
368    let yaml = r#"
369on:
370  - push
371  - pull_request
372jobs:
373  job:
374    name: Test Job
375    on:
376      - push
377      - pull_request
378    steps:
379      - run: echo "Hello World"
380        on:
381          - push
382          - pull_request
383"#;
384
385    let workflow = UserWorkflow::try_from(yaml).unwrap();
386    let on = Some(Condition::Event(vec![
387      "push".to_string(),
388      "pull_request".to_string(),
389    ]));
390
391    assert_eq!(&workflow.on, &on);
392
393    let job = workflow.jobs.get("job").unwrap();
394    assert_eq!(&job.on, &on);
395
396    let step = job.steps.first().unwrap();
397
398    if let UserStep::Command(command_step) = step {
399      assert_eq!(&command_step.on, &on);
400    } else {
401      panic!("Step should be command step");
402    }
403  }
404
405  #[test]
406  fn test_config_condition() {
407    let yaml = r#"
408on:
409  push:
410    branches:
411      - master
412    paths:
413      - "src/**"
414jobs:
415  job:
416    name: Test Job
417    on:
418      push:
419        paths:
420          - "src/**"
421    steps:
422      - run: echo "Hello World"
423        on:
424          pull_request:
425            branches:
426              - master
427"#;
428
429    let workflow = UserWorkflow::try_from(yaml).unwrap();
430    let on = Some(Condition::Config(ConditionConfig {
431      push: Some(PushCondition {
432        branches: Some(vec!["master".to_string()]),
433        paths: Some(vec!["src/**".to_string()]),
434      }),
435      pull_request: None,
436    }));
437
438    assert_eq!(workflow.on, on);
439
440    let on = Some(Condition::Config(ConditionConfig {
441      push: Some(PushCondition {
442        branches: None,
443        paths: Some(vec!["src/**".to_string()]),
444      }),
445      pull_request: None,
446    }));
447    let job = workflow.jobs.get("job").unwrap();
448    assert_eq!(job.on, on);
449
450    let step = job.steps.first().unwrap();
451
452    if let UserStep::Command(command_step) = step {
453      assert_eq!(
454        command_step.on,
455        Some(Condition::Config(ConditionConfig {
456          push: None,
457          pull_request: Some(PullRequestCondition {
458            branches: Some(vec!["master".to_string()]),
459            paths: None,
460          }),
461        }))
462      );
463    } else {
464      panic!("Step should be command step");
465    }
466  }
467}