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