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