vdev/testing/
config.rs

1use std::{
2    collections::BTreeMap,
3    env, fs,
4    path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result, bail};
8use indexmap::IndexMap;
9use itertools::{self, Itertools};
10use serde::{Deserialize, Serialize};
11use serde_yaml::Value;
12
13use crate::{app, environment::Environment, util};
14
15const FILE_NAME: &str = "test.yaml";
16
17pub const INTEGRATION_TESTS_DIR: &str = "integration";
18pub const E2E_TESTS_DIR: &str = "e2e";
19
20#[derive(Deserialize, Debug)]
21pub struct RustToolchainRootConfig {
22    pub toolchain: RustToolchainConfig,
23}
24
25#[derive(Deserialize, Debug)]
26pub struct RustToolchainConfig {
27    pub channel: String,
28}
29
30impl RustToolchainConfig {
31    fn parse() -> Result<Self> {
32        let repo_path = app::path();
33        let config_file: PathBuf = [repo_path, "rust-toolchain.toml"].iter().collect();
34        let contents = fs::read_to_string(&config_file)
35            .with_context(|| format!("failed to read {}", config_file.display()))?;
36        let config: RustToolchainRootConfig = toml::from_str(&contents)
37            .with_context(|| format!("failed to parse {}", config_file.display()))?;
38
39        Ok(config.toolchain)
40    }
41
42    pub fn rust_version() -> String {
43        match RustToolchainConfig::parse() {
44            Ok(config) => config.channel,
45            Err(error) => fatal!("Could not read `rust-toolchain.toml` file: {error}"),
46        }
47    }
48}
49
50#[derive(Debug, Deserialize, Serialize)]
51#[serde(untagged)]
52pub enum VolumeMount {
53    Short(String),
54    Long {
55        #[serde(default)]
56        r#type: Option<String>,
57        source: String,
58        target: String,
59        #[serde(default, skip_serializing_if = "Option::is_none")]
60        read_only: Option<bool>,
61    },
62}
63
64#[derive(Debug, Deserialize, Serialize)]
65#[serde(untagged)]
66pub enum VolumeDefinition {
67    Empty,
68    WithOptions(BTreeMap<String, Value>),
69}
70
71#[derive(Debug, Deserialize, Serialize)]
72pub struct ComposeConfig {
73    pub services: BTreeMap<String, ComposeService>,
74    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
75    pub volumes: BTreeMap<String, VolumeDefinition>,
76    #[serde(default)]
77    pub networks: BTreeMap<String, BTreeMap<String, Value>>,
78}
79
80#[derive(Debug, Deserialize, Serialize)]
81#[serde(untagged)]
82pub enum DependsOn {
83    Simple(Vec<String>),
84    Conditional(BTreeMap<String, DependencyCondition>),
85}
86
87#[derive(Debug, Deserialize, Serialize)]
88pub struct DependencyCondition {
89    pub condition: String,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub required: Option<bool>,
92}
93
94#[derive(Debug, Deserialize, Serialize)]
95pub struct ComposeService {
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub image: Option<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub hostname: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub container_name: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub build: Option<Value>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub command: Option<Command>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub ports: Option<Vec<String>>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub env_file: Option<Vec<String>>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub volumes: Option<Vec<VolumeMount>>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub environment: Option<Vec<String>>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub depends_on: Option<DependsOn>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub healthcheck: Option<Value>,
118}
119
120#[derive(Debug, Deserialize, Serialize)]
121#[serde(untagged)]
122pub enum Command {
123    Single(String),
124    Multiple(Vec<String>),
125}
126
127impl ComposeConfig {
128    pub fn parse(path: &Path) -> Result<Self> {
129        let contents = fs::read_to_string(path)
130            .with_context(|| format!("failed to read {}", path.display()))?;
131        serde_yaml::from_str(&contents)
132            .with_context(|| format!("failed to parse {}", path.display()))
133    }
134}
135
136#[derive(Clone, Debug, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct ComposeTestConfig {
139    /// The list of arguments to add to the command line for the test runner
140    pub args: Option<Vec<String>>,
141    /// The set of environment variables to set in both the services and the runner. Variables with
142    /// no value are treated as "passthrough" -- they must be set by the caller of `vdev` and are
143    /// passed into the containers.
144    #[serde(default)]
145    pub env: Environment,
146    /// The matrix of environment configurations values.
147    matrix: IndexMap<String, Vec<String>>,
148    /// Configuration specific to the compose services.
149    #[serde(default)]
150    pub runner: IntegrationRunnerConfig,
151
152    pub features: Vec<String>,
153
154    pub test: Option<String>,
155
156    pub test_filter: Option<String>,
157
158    pub paths: Option<Vec<String>>,
159}
160
161#[derive(Clone, Debug, Default, Deserialize)]
162#[serde(deny_unknown_fields)]
163pub struct IntegrationRunnerConfig {
164    /// The set of environment variables to set in just the runner. This is used for settings that
165    /// might otherwise affect the operation of docker but are needed in the runner.
166    #[serde(default)]
167    pub env: Environment,
168    /// The set of volumes that need to be mounted into the runner.
169    #[serde(default)]
170    pub volumes: BTreeMap<String, String>,
171    /// Does the test runner need access to the host's docker socket?
172    #[serde(default)]
173    pub needs_docker_socket: bool,
174}
175
176impl ComposeTestConfig {
177    fn parse_file(config_file: &Path) -> Result<Self> {
178        let contents = fs::read_to_string(config_file)
179            .with_context(|| format!("failed to read {}", config_file.display()))?;
180        let config: Self = serde_yaml::from_str(&contents).with_context(|| {
181            format!(
182                "failed to parse integration test configuration file {}",
183                config_file.display()
184            )
185        })?;
186
187        Ok(config)
188    }
189
190    pub fn environments(&self) -> IndexMap<String, Environment> {
191        self.matrix
192            .values()
193            .multi_cartesian_product()
194            .map(|product| {
195                let key = product.iter().join("-");
196                let config: Environment = self
197                    .matrix
198                    .keys()
199                    .zip(product)
200                    .map(|(variable, value)| (variable.clone(), Some(value.clone())))
201                    .collect();
202                (key, config)
203            })
204            .collect()
205    }
206
207    pub fn load(root_dir: &str, integration: &str) -> Result<(PathBuf, Self)> {
208        let test_dir: PathBuf = [app::path(), "scripts", root_dir, integration]
209            .iter()
210            .collect();
211
212        if !test_dir.is_dir() {
213            bail!("unknown integration: {}", integration);
214        }
215
216        let config = Self::parse_file(&test_dir.join(FILE_NAME))?;
217        Ok((test_dir, config))
218    }
219
220    fn collect_all_dir(tests_dir: &Path, configs: &mut BTreeMap<String, Self>) -> Result<()> {
221        for entry in tests_dir.read_dir()? {
222            let entry = entry?;
223            if entry.path().is_dir() {
224                let config_file: PathBuf =
225                    [entry.path().to_str().unwrap(), FILE_NAME].iter().collect();
226                if util::exists(&config_file)? {
227                    let config = Self::parse_file(&config_file)?;
228                    configs.insert(entry.file_name().into_string().unwrap(), config);
229                }
230            }
231        }
232        Ok(())
233    }
234
235    pub fn collect_all(root_dir: &str) -> Result<BTreeMap<String, Self>> {
236        let mut configs = BTreeMap::new();
237
238        let tests_dir: PathBuf = [app::path(), "scripts", root_dir].iter().collect();
239
240        Self::collect_all_dir(&tests_dir, &mut configs)?;
241
242        Ok(configs)
243    }
244
245    /// Ensure that all passthrough environment variables are set.
246    pub fn check_required(&self) -> Result<()> {
247        let missing: Vec<_> = self
248            .env
249            .iter()
250            .chain(self.runner.env.iter())
251            .filter_map(|(key, value)| value.is_none().then_some(key))
252            .filter(|var| env::var(var).is_err())
253            .collect();
254        if missing.is_empty() {
255            Ok(())
256        } else {
257            let missing = missing.into_iter().join(", ");
258            bail!("Required environment variables are not set: {missing}");
259        }
260    }
261}