vdev/testing/
config.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::{env, fs};
4
5use anyhow::{bail, Context, Result};
6use indexmap::IndexMap;
7use itertools::{self, Itertools};
8use serde::{Deserialize, Serialize};
9use serde_yaml::Value;
10
11use crate::{app, util};
12
13const FILE_NAME: &str = "test.yaml";
14
15pub const INTEGRATION_TESTS_DIR: &str = "integration";
16pub const E2E_TESTS_DIR: &str = "e2e";
17
18pub type Environment = BTreeMap<String, Option<String>>;
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, String>>,
78}
79
80#[derive(Debug, Deserialize, Serialize)]
81pub struct ComposeService {
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub image: Option<String>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub hostname: Option<String>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub container_name: Option<String>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub build: Option<Value>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub command: Option<Command>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub ports: Option<Vec<String>>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub env_file: Option<Vec<String>>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub volumes: Option<Vec<VolumeMount>>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub environment: Option<Vec<String>>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub depends_on: Option<Vec<String>>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub healthcheck: Option<Value>,
104}
105
106#[derive(Debug, Deserialize, Serialize)]
107#[serde(untagged)]
108pub enum Command {
109    Single(String),
110    Multiple(Vec<String>),
111}
112
113impl ComposeConfig {
114    pub fn parse(path: &Path) -> Result<Self> {
115        let contents = fs::read_to_string(path)
116            .with_context(|| format!("failed to read {}", path.display()))?;
117        serde_yaml::from_str(&contents)
118            .with_context(|| format!("failed to parse {}", path.display()))
119    }
120}
121
122#[derive(Clone, Debug, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct ComposeTestConfig {
125    /// The list of arguments to add to the command line for the test runner
126    pub args: Option<Vec<String>>,
127    /// The set of environment variables to set in both the services and the runner. Variables with
128    /// no value are treated as "passthrough" -- they must be set by the caller of `vdev` and are
129    /// passed into the containers.
130    #[serde(default)]
131    pub env: Environment,
132    /// The matrix of environment configurations values.
133    matrix: IndexMap<String, Vec<String>>,
134    /// Configuration specific to the compose services.
135    #[serde(default)]
136    pub runner: IntegrationRunnerConfig,
137
138    pub features: Vec<String>,
139
140    pub test: Option<String>,
141
142    pub test_filter: Option<String>,
143
144    pub paths: Option<Vec<String>>,
145}
146
147#[derive(Clone, Debug, Default, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct IntegrationRunnerConfig {
150    /// The set of environment variables to set in just the runner. This is used for settings that
151    /// might otherwise affect the operation of docker but are needed in the runner.
152    #[serde(default)]
153    pub env: Environment,
154    /// The set of volumes that need to be mounted into the runner.
155    #[serde(default)]
156    pub volumes: BTreeMap<String, String>,
157    /// Does the test runner need access to the host's docker socket?
158    #[serde(default)]
159    pub needs_docker_socket: bool,
160}
161
162impl ComposeTestConfig {
163    fn parse_file(config_file: &Path) -> Result<Self> {
164        let contents = fs::read_to_string(config_file)
165            .with_context(|| format!("failed to read {}", config_file.display()))?;
166        let config: Self = serde_yaml::from_str(&contents).with_context(|| {
167            format!(
168                "failed to parse integration test configuration file {}",
169                config_file.display()
170            )
171        })?;
172
173        Ok(config)
174    }
175
176    pub fn environments(&self) -> IndexMap<String, Environment> {
177        self.matrix
178            .values()
179            .multi_cartesian_product()
180            .map(|product| {
181                let key = product.iter().join("-");
182                let config: Environment = self
183                    .matrix
184                    .keys()
185                    .zip(product)
186                    .map(|(variable, value)| (variable.clone(), Some(value.clone())))
187                    .collect();
188                (key, config)
189            })
190            .collect()
191    }
192
193    pub fn load(root_dir: &str, integration: &str) -> Result<(PathBuf, Self)> {
194        let test_dir: PathBuf = [app::path(), "scripts", root_dir, integration]
195            .iter()
196            .collect();
197
198        if !test_dir.is_dir() {
199            bail!("unknown integration: {}", integration);
200        }
201
202        let config = Self::parse_file(&test_dir.join(FILE_NAME))?;
203        Ok((test_dir, config))
204    }
205
206    fn collect_all_dir(tests_dir: &Path, configs: &mut BTreeMap<String, Self>) -> Result<()> {
207        for entry in tests_dir.read_dir()? {
208            let entry = entry?;
209            if entry.path().is_dir() {
210                let config_file: PathBuf =
211                    [entry.path().to_str().unwrap(), FILE_NAME].iter().collect();
212                if util::exists(&config_file)? {
213                    let config = Self::parse_file(&config_file)?;
214                    configs.insert(entry.file_name().into_string().unwrap(), config);
215                }
216            }
217        }
218        Ok(())
219    }
220
221    pub fn collect_all(root_dir: &str) -> Result<BTreeMap<String, Self>> {
222        let mut configs = BTreeMap::new();
223
224        let tests_dir: PathBuf = [app::path(), "scripts", root_dir].iter().collect();
225
226        Self::collect_all_dir(&tests_dir, &mut configs)?;
227
228        Ok(configs)
229    }
230
231    /// Ensure that all passthrough environment variables are set.
232    pub fn check_required(&self) -> Result<()> {
233        let missing: Vec<_> = self
234            .env
235            .iter()
236            .chain(self.runner.env.iter())
237            .filter_map(|(key, value)| value.is_none().then_some(key))
238            .filter(|var| env::var(var).is_err())
239            .collect();
240        if missing.is_empty() {
241            Ok(())
242        } else {
243            let missing = missing.into_iter().join(", ");
244            bail!("Required environment variables are not set: {missing}");
245        }
246    }
247}