wdl_engine/
config.rs

1//! Implementation of engine configuration.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use anyhow::Context;
9use anyhow::Result;
10use anyhow::anyhow;
11use anyhow::bail;
12use serde::Deserialize;
13use serde::Serialize;
14use tracing::warn;
15use url::Url;
16
17use crate::DockerBackend;
18use crate::LocalBackend;
19use crate::SYSTEM;
20use crate::TaskExecutionBackend;
21use crate::TesBackend;
22use crate::convert_unit_string;
23use crate::path::is_url;
24
25/// The inclusive maximum number of task retries the engine supports.
26pub const MAX_RETRIES: u64 = 100;
27
28/// The default task shell.
29pub const DEFAULT_TASK_SHELL: &str = "bash";
30
31/// The default maximum number of concurrent HTTP downloads.
32pub const DEFAULT_MAX_CONCURRENT_DOWNLOADS: u64 = 10;
33
34/// The default backend name.
35pub const DEFAULT_BACKEND_NAME: &str = "default";
36
37/// Represents WDL evaluation configuration.
38#[derive(Debug, Default, Clone, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case", deny_unknown_fields)]
40pub struct Config {
41    /// HTTP configuration.
42    #[serde(default)]
43    pub http: HttpConfig,
44    /// Workflow evaluation configuration.
45    #[serde(default)]
46    pub workflow: WorkflowConfig,
47    /// Task evaluation configuration.
48    #[serde(default)]
49    pub task: TaskConfig,
50    /// The name of the backend to use.
51    ///
52    /// If not specified and `backends` has multiple entries, it will use a name
53    /// of `default`.
54    pub backend: Option<String>,
55    /// Task execution backends configuration.
56    ///
57    /// If the collection is empty and `backend` is not specified, the engine
58    /// default backend is used.
59    ///
60    /// If the collection has exactly one entry and `backend` is not specified,
61    /// the singular entry will be used.
62    #[serde(default)]
63    pub backends: HashMap<String, BackendConfig>,
64    /// Storage configuration.
65    #[serde(default)]
66    pub storage: StorageConfig,
67}
68
69impl Config {
70    /// Validates the evaluation configuration.
71    pub fn validate(&self) -> Result<()> {
72        self.http.validate()?;
73        self.workflow.validate()?;
74        self.task.validate()?;
75
76        if self.backend.is_none() && self.backends.len() < 2 {
77            // This is OK, we'll use either the singular backends entry (1) or
78            // the default (0)
79        } else {
80            // Check the backends map for the backend name (or "default")
81            let backend = self.backend.as_deref().unwrap_or(DEFAULT_BACKEND_NAME);
82            if !self.backends.contains_key(backend) {
83                bail!("a backend named `{backend}` is not present in the configuration");
84            }
85        }
86
87        for backend in self.backends.values() {
88            backend.validate()?;
89        }
90
91        self.storage.validate()?;
92        Ok(())
93    }
94
95    /// Creates a new task execution backend based on this configuration.
96    pub async fn create_backend(self: &Arc<Self>) -> Result<Arc<dyn TaskExecutionBackend>> {
97        let config = if self.backend.is_none() && self.backends.len() < 2 {
98            if self.backends.len() == 1 {
99                // Use the singular entry
100                Cow::Borrowed(self.backends.values().next().unwrap())
101            } else {
102                // Use the default
103                Cow::Owned(BackendConfig::default())
104            }
105        } else {
106            // Lookup the backend to use
107            let backend = self.backend.as_deref().unwrap_or(DEFAULT_BACKEND_NAME);
108            Cow::Borrowed(self.backends.get(backend).ok_or_else(|| {
109                anyhow!("a backend named `{backend}` is not present in the configuration")
110            })?)
111        };
112
113        match config.as_ref() {
114            BackendConfig::Local(config) => {
115                warn!(
116                    "the engine is configured to use the local backend: tasks will not be run \
117                     inside of a container"
118                );
119                Ok(Arc::new(LocalBackend::new(self.clone(), config)?))
120            }
121            BackendConfig::Docker(config) => {
122                Ok(Arc::new(DockerBackend::new(self.clone(), config).await?))
123            }
124            BackendConfig::Tes(config) => {
125                Ok(Arc::new(TesBackend::new(self.clone(), config).await?))
126            }
127        }
128    }
129}
130
131/// Represents HTTP configuration.
132#[derive(Debug, Default, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case", deny_unknown_fields)]
134pub struct HttpConfig {
135    /// The HTTP download cache location.
136    ///
137    /// Defaults to using the system cache directory.
138    #[serde(default)]
139    pub cache: Option<PathBuf>,
140    /// The maximum number of concurrent downloads allowed.
141    ///
142    /// Defaults to 10.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub max_concurrent_downloads: Option<u64>,
145}
146
147impl HttpConfig {
148    /// Validates the HTTP configuration.
149    pub fn validate(&self) -> Result<()> {
150        if let Some(limit) = self.max_concurrent_downloads {
151            if limit == 0 {
152                bail!("configuration value `http.max_concurrent_downloads` cannot be zero");
153            }
154        }
155        Ok(())
156    }
157}
158
159/// Represents storage configuration.
160#[derive(Debug, Default, Clone, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case", deny_unknown_fields)]
162pub struct StorageConfig {
163    /// Azure Blob Storage configuration.
164    #[serde(default)]
165    pub azure: AzureStorageConfig,
166    /// AWS S3 configuration.
167    #[serde(default)]
168    pub s3: S3StorageConfig,
169    /// Google Cloud Storage configuration.
170    #[serde(default)]
171    pub google: GoogleStorageConfig,
172}
173
174impl StorageConfig {
175    /// Validates the HTTP configuration.
176    pub fn validate(&self) -> Result<()> {
177        self.azure.validate()?;
178        self.s3.validate()?;
179        self.google.validate()?;
180        Ok(())
181    }
182}
183
184/// Represents configuration for Azure Blob Storage.
185#[derive(Debug, Default, Clone, Serialize, Deserialize)]
186#[serde(rename_all = "snake_case", deny_unknown_fields)]
187pub struct AzureStorageConfig {
188    /// The Azure Blob Storage authentication configuration.
189    ///
190    /// The key for the outer map is the storage account name.
191    ///
192    /// The key for the inner map is the container name.
193    ///
194    /// The value for the inner map is the SAS token query string to apply to
195    /// matching Azure Blob Storage URLs.
196    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
197    pub auth: HashMap<String, HashMap<String, String>>,
198}
199
200impl AzureStorageConfig {
201    /// Validates the Azure Blob Storage configuration.
202    pub fn validate(&self) -> Result<()> {
203        Ok(())
204    }
205}
206
207/// Represents configuration for AWS S3 storage.
208#[derive(Debug, Default, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case", deny_unknown_fields)]
210pub struct S3StorageConfig {
211    /// The default region to use for S3-schemed URLs (e.g.
212    /// `s3://<bucket>/<blob>`).
213    ///
214    /// Defaults to `us-east-1`.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub region: Option<String>,
217
218    /// The AWS S3 storage authentication configuration.
219    ///
220    /// The key for the map is the bucket name.
221    ///
222    /// The value for the map is the presigned query string.
223    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
224    pub auth: HashMap<String, String>,
225}
226
227impl S3StorageConfig {
228    /// Validates the AWS S3 storage configuration.
229    pub fn validate(&self) -> Result<()> {
230        Ok(())
231    }
232}
233
234/// Represents configuration for Google Cloud Storage.
235#[derive(Debug, Default, Clone, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case", deny_unknown_fields)]
237pub struct GoogleStorageConfig {
238    /// The Google Cloud Storage authentication configuration.
239    ///
240    /// The key for the map is the bucket name.
241    ///
242    /// The value for the map is the presigned query string.
243    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
244    pub auth: HashMap<String, String>,
245}
246
247impl GoogleStorageConfig {
248    /// Validates the Google Cloud Storage configuration.
249    pub fn validate(&self) -> Result<()> {
250        Ok(())
251    }
252}
253
254/// Represents workflow evaluation configuration.
255#[derive(Debug, Default, Clone, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case", deny_unknown_fields)]
257pub struct WorkflowConfig {
258    /// Scatter statement evaluation configuration.
259    #[serde(default)]
260    pub scatter: ScatterConfig,
261}
262
263impl WorkflowConfig {
264    /// Validates the workflow configuration.
265    pub fn validate(&self) -> Result<()> {
266        self.scatter.validate()?;
267        Ok(())
268    }
269}
270
271/// Represents scatter statement evaluation configuration.
272#[derive(Debug, Default, Clone, Serialize, Deserialize)]
273#[serde(rename_all = "snake_case", deny_unknown_fields)]
274pub struct ScatterConfig {
275    /// The number of scatter array elements to process concurrently.
276    ///
277    /// By default, the value is the parallelism supported by the task
278    /// execution backend.
279    ///
280    /// A value of `0` is invalid.
281    ///
282    /// Lower values use less memory for evaluation and higher values may better
283    /// saturate the task execution backend with tasks to execute.
284    ///
285    /// This setting does not change how many tasks an execution backend can run
286    /// concurrently, but may affect how many tasks are sent to the backend to
287    /// run at a time.
288    ///
289    /// For example, if `concurrency` was set to 10 and we evaluate the
290    /// following scatters:
291    ///
292    /// ```wdl
293    /// scatter (i in range(100)) {
294    ///     call my_task
295    /// }
296    ///
297    /// scatter (j in range(100)) {
298    ///     call my_task as my_task2
299    /// }
300    /// ```
301    ///
302    /// Here each scatter is independent and therefore there will be 20 calls
303    /// (10 for each scatter) made concurrently. If the task execution
304    /// backend can only execute 5 tasks concurrently, 5 tasks will execute
305    /// and 15 will be "ready" to execute and waiting for an executing task
306    /// to complete.
307    ///
308    /// If instead we evaluate the following scatters:
309    ///
310    /// ```wdl
311    /// scatter (i in range(100)) {
312    ///     scatter (j in range(100)) {
313    ///         call my_task
314    ///     }
315    /// }
316    /// ```
317    ///
318    /// Then there will be 100 calls (10*10 as 10 are made for each outer
319    /// element) made concurrently. If the task execution backend can only
320    /// execute 5 tasks concurrently, 5 tasks will execute and 95 will be
321    /// "ready" to execute and waiting for an executing task to complete.
322    ///
323    /// <div class="warning">
324    /// Warning: nested scatter statements cause exponential memory usage based
325    /// on this value, as each scatter statement evaluation requires allocating
326    /// new scopes for scatter array elements being processed. </div>
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub concurrency: Option<u64>,
329}
330
331impl ScatterConfig {
332    /// Validates the scatter configuration.
333    pub fn validate(&self) -> Result<()> {
334        if let Some(concurrency) = self.concurrency {
335            if concurrency == 0 {
336                bail!("configuration value `workflow.scatter.concurrency` cannot be zero");
337            }
338        }
339
340        Ok(())
341    }
342}
343
344/// Represents task evaluation configuration.
345#[derive(Debug, Default, Clone, Serialize, Deserialize)]
346#[serde(rename_all = "snake_case", deny_unknown_fields)]
347pub struct TaskConfig {
348    /// The default maximum number of retries to attempt if a task fails.
349    ///
350    /// A task's `max_retries` requirement will override this value.
351    ///
352    /// Defaults to 0 (no retries).
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub retries: Option<u64>,
355    /// The default container to use if a container is not specified in a task's
356    /// requirements.
357    ///
358    /// Defaults to `ubuntu:latest`.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub container: Option<String>,
361    /// The default shell to use for tasks.
362    ///
363    /// Defaults to `bash`.
364    ///
365    /// <div class="warning">
366    /// Warning: the use of a shell other than `bash` may lead to tasks that may
367    /// not be portable to other execution engines.</div>
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub shell: Option<String>,
370    /// The behavior when a task's `cpu` requirement cannot be met.
371    #[serde(default)]
372    pub cpu_limit_behavior: TaskResourceLimitBehavior,
373    /// The behavior when a task's `memory` requirement cannot be met.
374    #[serde(default)]
375    pub memory_limit_behavior: TaskResourceLimitBehavior,
376}
377
378impl TaskConfig {
379    /// Validates the task evaluation configuration.
380    pub fn validate(&self) -> Result<()> {
381        if self.retries.unwrap_or(0) > MAX_RETRIES {
382            bail!("configuration value `task.retries` cannot exceed {MAX_RETRIES}");
383        }
384
385        Ok(())
386    }
387}
388
389/// The behavior when a task resource requirement, such as `cpu` or `memory`,
390/// cannot be met.
391#[derive(Debug, Default, Clone, Serialize, Deserialize)]
392#[serde(rename_all = "snake_case", deny_unknown_fields)]
393pub enum TaskResourceLimitBehavior {
394    /// Try executing a task with the maximum amount of the resource available
395    /// when the task's corresponding requirement cannot be met.
396    TryWithMax,
397    /// Do not execute a task if its corresponding requirement cannot be met.
398    ///
399    /// This is the default behavior.
400    #[default]
401    Deny,
402}
403
404/// Represents supported task execution backends.
405#[derive(Debug, Clone, Serialize, Deserialize)]
406#[serde(rename_all = "snake_case", tag = "type")]
407pub enum BackendConfig {
408    /// Use the local task execution backend.
409    Local(LocalBackendConfig),
410    /// Use the Docker task execution backend.
411    Docker(DockerBackendConfig),
412    /// Use the TES task execution backend.
413    Tes(Box<TesBackendConfig>),
414}
415
416impl Default for BackendConfig {
417    fn default() -> Self {
418        Self::Docker(Default::default())
419    }
420}
421
422impl BackendConfig {
423    /// Validates the backend configuration.
424    pub fn validate(&self) -> Result<()> {
425        match self {
426            Self::Local(config) => config.validate(),
427            Self::Docker(config) => config.validate(),
428            Self::Tes(config) => config.validate(),
429        }
430    }
431
432    /// Converts the backend configuration into a local backend configuration
433    ///
434    /// Returns `None` if the backend configuration is not local.
435    pub fn as_local(&self) -> Option<&LocalBackendConfig> {
436        match self {
437            Self::Local(config) => Some(config),
438            _ => None,
439        }
440    }
441
442    /// Converts the backend configuration into a Docker backend configuration
443    ///
444    /// Returns `None` if the backend configuration is not Docker.
445    pub fn as_docker(&self) -> Option<&DockerBackendConfig> {
446        match self {
447            Self::Docker(config) => Some(config),
448            _ => None,
449        }
450    }
451
452    /// Converts the backend configuration into a TES backend configuration
453    ///
454    /// Returns `None` if the backend configuration is not TES.
455    pub fn as_tes(&self) -> Option<&TesBackendConfig> {
456        match self {
457            Self::Tes(config) => Some(config),
458            _ => None,
459        }
460    }
461}
462
463/// Represents configuration for the local task execution backend.
464///
465/// <div class="warning">
466/// Warning: the local task execution backend spawns processes on the host
467/// directly without the use of a container; only use this backend on trusted
468/// WDL. </div>
469#[derive(Debug, Default, Clone, Serialize, Deserialize)]
470#[serde(rename_all = "snake_case", deny_unknown_fields)]
471pub struct LocalBackendConfig {
472    /// Set the number of CPUs available for task execution.
473    ///
474    /// Defaults to the number of logical CPUs for the host.
475    ///
476    /// The value cannot be zero or exceed the host's number of CPUs.
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub cpu: Option<u64>,
479
480    /// Set the total amount of memory for task execution as a unit string (e.g.
481    /// `2 GiB`).
482    ///
483    /// Defaults to the total amount of memory for the host.
484    ///
485    /// The value cannot be zero or exceed the host's total amount of memory.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub memory: Option<String>,
488}
489
490impl LocalBackendConfig {
491    /// Validates the local task execution backend configuration.
492    pub fn validate(&self) -> Result<()> {
493        if let Some(cpu) = self.cpu {
494            if cpu == 0 {
495                bail!("local backend configuration value `cpu` cannot be zero");
496            }
497
498            let total = SYSTEM.cpus().len() as u64;
499            if cpu > total {
500                bail!(
501                    "local backend configuration value `cpu` cannot exceed the virtual CPUs \
502                     available to the host ({total})"
503                );
504            }
505        }
506
507        if let Some(memory) = &self.memory {
508            let memory = convert_unit_string(memory).with_context(|| {
509                format!("local backend configuration value `memory` has invalid value `{memory}`")
510            })?;
511
512            if memory == 0 {
513                bail!("local backend configuration value `memory` cannot be zero");
514            }
515
516            let total = SYSTEM.total_memory();
517            if memory > total {
518                bail!(
519                    "local backend configuration value `memory` cannot exceed the total memory of \
520                     the host ({total} bytes)"
521                );
522            }
523        }
524
525        Ok(())
526    }
527}
528
529/// Gets the default value for the docker `cleanup` field.
530const fn cleanup_default() -> bool {
531    true
532}
533
534/// Represents configuration for the Docker backend.
535#[derive(Debug, Clone, Serialize, Deserialize)]
536#[serde(rename_all = "snake_case", deny_unknown_fields)]
537pub struct DockerBackendConfig {
538    /// Whether or not to remove a task's container after the task completes.
539    ///
540    /// Defaults to `true`.
541    #[serde(default = "cleanup_default")]
542    pub cleanup: bool,
543}
544
545impl DockerBackendConfig {
546    /// Validates the Docker backend configuration.
547    pub fn validate(&self) -> Result<()> {
548        Ok(())
549    }
550}
551
552impl Default for DockerBackendConfig {
553    fn default() -> Self {
554        Self { cleanup: true }
555    }
556}
557
558/// Represents HTTP basic authentication configuration.
559#[derive(Debug, Default, Clone, Serialize, Deserialize)]
560#[serde(rename_all = "snake_case", deny_unknown_fields)]
561pub struct BasicAuthConfig {
562    /// The HTTP basic authentication username.
563    pub username: Option<String>,
564    /// The HTTP basic authentication password.
565    pub password: Option<String>,
566}
567
568impl BasicAuthConfig {
569    /// Validates the HTTP basic auth configuration.
570    pub fn validate(&self) -> Result<()> {
571        if self.username.is_none() {
572            bail!("HTTP basic auth configuration value `username` is required");
573        }
574
575        if self.password.is_none() {
576            bail!("HTTP basic auth configuration value `password` is required");
577        }
578
579        Ok(())
580    }
581}
582
583/// Represents HTTP bearer token authentication configuration.
584#[derive(Debug, Default, Clone, Serialize, Deserialize)]
585#[serde(rename_all = "snake_case", deny_unknown_fields)]
586pub struct BearerAuthConfig {
587    /// The HTTP bearer authentication token.
588    pub token: Option<String>,
589}
590
591impl BearerAuthConfig {
592    /// Validates the HTTP basic auth configuration.
593    pub fn validate(&self) -> Result<()> {
594        if self.token.is_none() {
595            bail!("HTTP bearer auth configuration value `token` is required");
596        }
597
598        Ok(())
599    }
600}
601
602/// Represents the kind of authentication for a TES backend.
603#[derive(Debug, Clone, Serialize, Deserialize)]
604#[serde(rename_all = "snake_case", tag = "type")]
605pub enum TesBackendAuthConfig {
606    /// Use basic authentication for the TES backend.
607    Basic(BasicAuthConfig),
608    /// Use bearer token authentication for the TES backend.
609    Bearer(BearerAuthConfig),
610}
611
612impl TesBackendAuthConfig {
613    /// Validates the TES backend authentication configuration.
614    pub fn validate(&self) -> Result<()> {
615        match self {
616            Self::Basic(config) => config.validate(),
617            Self::Bearer(config) => config.validate(),
618        }
619    }
620}
621
622/// Represents configuration for the Task Execution Service (TES) backend.
623#[derive(Debug, Default, Clone, Serialize, Deserialize)]
624#[serde(rename_all = "snake_case", deny_unknown_fields)]
625pub struct TesBackendConfig {
626    /// The URL of the Task Execution Service.
627    #[serde(default)]
628    pub url: Option<Url>,
629
630    /// The authentication configuration for the TES backend.
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub auth: Option<TesBackendAuthConfig>,
633
634    /// The cloud storage URL for storing inputs.
635    #[serde(default, skip_serializing_if = "Option::is_none")]
636    pub inputs: Option<Url>,
637
638    /// The cloud storage URL for storing outputs.
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub outputs: Option<Url>,
641
642    /// The polling interval, in seconds, for checking task status.
643    ///
644    /// Defaults to 60 second.
645    #[serde(default)]
646    pub interval: Option<u64>,
647
648    /// The maximum task concurrency for the backend.
649    ///
650    /// Defaults to unlimited.
651    #[serde(default)]
652    pub max_concurrency: Option<u64>,
653
654    /// Whether or not the TES server URL may use an insecure protocol like
655    /// HTTP.
656    #[serde(default)]
657    pub insecure: bool,
658}
659
660impl TesBackendConfig {
661    /// Validates the TES backend configuration.
662    pub fn validate(&self) -> Result<()> {
663        match &self.url {
664            Some(url) => {
665                if !self.insecure && url.scheme() != "https" {
666                    bail!(
667                        "TES backend configuration value `url` has invalid value `{url}`: URL \
668                         must use a HTTPS scheme"
669                    );
670                }
671            }
672            None => bail!("TES backend configuration value `url` is required"),
673        }
674
675        if let Some(auth) = &self.auth {
676            auth.validate()?;
677        }
678
679        match &self.inputs {
680            Some(url) => {
681                if !is_url(url.as_str()) {
682                    bail!(
683                        "TES backend storage configuration value `inputs` has invalid value \
684                         `{url}`: URL scheme is not supported"
685                    );
686                }
687
688                if !url.path().ends_with('/') {
689                    bail!(
690                        "TES backend storage configuration value `inputs` has invalid value \
691                         `{url}`: URL path must end with a slash"
692                    );
693                }
694            }
695            None => bail!("TES backend configuration value `inputs` is required"),
696        }
697
698        match &self.outputs {
699            Some(url) => {
700                if !is_url(url.as_str()) {
701                    bail!(
702                        "TES backend storage configuration value `outputs` has invalid value \
703                         `{url}`: URL scheme is not supported"
704                    );
705                }
706
707                if !url.path().ends_with('/') {
708                    bail!(
709                        "TES backend storage configuration value `outputs` has invalid value \
710                         `{url}`: URL path must end with a slash"
711                    );
712                }
713            }
714            None => bail!("TES backend storage configuration value `outputs` is required"),
715        }
716
717        Ok(())
718    }
719}
720
721#[cfg(test)]
722mod test {
723    use pretty_assertions::assert_eq;
724
725    use super::*;
726
727    #[test]
728    fn test_config_validate() {
729        // Test invalid task config
730        let mut config = Config::default();
731        config.task.retries = Some(1000000);
732        assert_eq!(
733            config.validate().unwrap_err().to_string(),
734            "configuration value `task.retries` cannot exceed 100"
735        );
736
737        // Test invalid scatter concurrency config
738        let mut config = Config::default();
739        config.workflow.scatter.concurrency = Some(0);
740        assert_eq!(
741            config.validate().unwrap_err().to_string(),
742            "configuration value `workflow.scatter.concurrency` cannot be zero"
743        );
744
745        // Test invalid backend name
746        let config = Config {
747            backend: Some("foo".into()),
748            ..Default::default()
749        };
750        assert_eq!(
751            config.validate().unwrap_err().to_string(),
752            "a backend named `foo` is not present in the configuration"
753        );
754        let config = Config {
755            backend: Some("bar".into()),
756            backends: [("foo".to_string(), BackendConfig::default())].into(),
757            ..Default::default()
758        };
759        assert_eq!(
760            config.validate().unwrap_err().to_string(),
761            "a backend named `bar` is not present in the configuration"
762        );
763
764        // Test a singular backend
765        let config = Config {
766            backends: [("foo".to_string(), BackendConfig::default())].into(),
767            ..Default::default()
768        };
769        config.validate().expect("config should validate");
770
771        // Test invalid local backend cpu config
772        let config = Config {
773            backends: [(
774                "default".to_string(),
775                BackendConfig::Local(LocalBackendConfig {
776                    cpu: Some(0),
777                    ..Default::default()
778                }),
779            )]
780            .into(),
781            ..Default::default()
782        };
783        assert_eq!(
784            config.validate().unwrap_err().to_string(),
785            "local backend configuration value `cpu` cannot be zero"
786        );
787        let config = Config {
788            backends: [(
789                "default".to_string(),
790                BackendConfig::Local(LocalBackendConfig {
791                    cpu: Some(10000000),
792                    ..Default::default()
793                }),
794            )]
795            .into(),
796            ..Default::default()
797        };
798        assert!(config.validate().unwrap_err().to_string().starts_with(
799            "local backend configuration value `cpu` cannot exceed the virtual CPUs available to \
800             the host"
801        ));
802
803        // Test invalid local backend memory config
804        let config = Config {
805            backends: [(
806                "default".to_string(),
807                BackendConfig::Local(LocalBackendConfig {
808                    memory: Some("0 GiB".to_string()),
809                    ..Default::default()
810                }),
811            )]
812            .into(),
813            ..Default::default()
814        };
815        assert_eq!(
816            config.validate().unwrap_err().to_string(),
817            "local backend configuration value `memory` cannot be zero"
818        );
819        let config = Config {
820            backends: [(
821                "default".to_string(),
822                BackendConfig::Local(LocalBackendConfig {
823                    memory: Some("100 meows".to_string()),
824                    ..Default::default()
825                }),
826            )]
827            .into(),
828            ..Default::default()
829        };
830        assert_eq!(
831            config.validate().unwrap_err().to_string(),
832            "local backend configuration value `memory` has invalid value `100 meows`"
833        );
834
835        let config = Config {
836            backends: [(
837                "default".to_string(),
838                BackendConfig::Local(LocalBackendConfig {
839                    memory: Some("1000 TiB".to_string()),
840                    ..Default::default()
841                }),
842            )]
843            .into(),
844            ..Default::default()
845        };
846        assert!(config.validate().unwrap_err().to_string().starts_with(
847            "local backend configuration value `memory` cannot exceed the total memory of the host"
848        ));
849
850        // Test missing TES URL
851        let config = Config {
852            backends: [(
853                "default".to_string(),
854                BackendConfig::Tes(Default::default()),
855            )]
856            .into(),
857            ..Default::default()
858        };
859        assert_eq!(
860            config.validate().unwrap_err().to_string(),
861            "TES backend configuration value `url` is required"
862        );
863
864        // Insecure TES URL
865        let config = Config {
866            backends: [(
867                "default".to_string(),
868                BackendConfig::Tes(
869                    TesBackendConfig {
870                        url: Some("http://example.com".parse().unwrap()),
871                        inputs: Some("http://example.com".parse().unwrap()),
872                        outputs: Some("http://example.com".parse().unwrap()),
873                        ..Default::default()
874                    }
875                    .into(),
876                ),
877            )]
878            .into(),
879            ..Default::default()
880        };
881        assert_eq!(
882            config.validate().unwrap_err().to_string(),
883            "TES backend configuration value `url` has invalid value `http://example.com/`: URL \
884             must use a HTTPS scheme"
885        );
886
887        // Allow insecure URL
888        let config = Config {
889            backends: [(
890                "default".to_string(),
891                BackendConfig::Tes(
892                    TesBackendConfig {
893                        url: Some("http://example.com".parse().unwrap()),
894                        inputs: Some("http://example.com".parse().unwrap()),
895                        outputs: Some("http://example.com".parse().unwrap()),
896                        insecure: true,
897                        ..Default::default()
898                    }
899                    .into(),
900                ),
901            )]
902            .into(),
903            ..Default::default()
904        };
905        config.validate().expect("configuration should validate");
906
907        // Test invalid TES basic auth
908        let config = Config {
909            backends: [(
910                "default".to_string(),
911                BackendConfig::Tes(Box::new(TesBackendConfig {
912                    url: Some(Url::parse("https://example.com").unwrap()),
913                    auth: Some(TesBackendAuthConfig::Basic(Default::default())),
914                    ..Default::default()
915                })),
916            )]
917            .into(),
918            ..Default::default()
919        };
920        assert_eq!(
921            config.validate().unwrap_err().to_string(),
922            "HTTP basic auth configuration value `username` is required"
923        );
924        let config = Config {
925            backends: [(
926                "default".to_string(),
927                BackendConfig::Tes(Box::new(TesBackendConfig {
928                    url: Some(Url::parse("https://example.com").unwrap()),
929                    auth: Some(TesBackendAuthConfig::Basic(BasicAuthConfig {
930                        username: Some("Foo".into()),
931                        ..Default::default()
932                    })),
933                    ..Default::default()
934                })),
935            )]
936            .into(),
937            ..Default::default()
938        };
939        assert_eq!(
940            config.validate().unwrap_err().to_string(),
941            "HTTP basic auth configuration value `password` is required"
942        );
943
944        // Test invalid TES bearer auth
945        let config = Config {
946            backends: [(
947                "default".to_string(),
948                BackendConfig::Tes(Box::new(TesBackendConfig {
949                    url: Some(Url::parse("https://example.com").unwrap()),
950                    auth: Some(TesBackendAuthConfig::Bearer(Default::default())),
951                    ..Default::default()
952                })),
953            )]
954            .into(),
955            ..Default::default()
956        };
957        assert_eq!(
958            config.validate().unwrap_err().to_string(),
959            "HTTP bearer auth configuration value `token` is required"
960        );
961
962        let mut config = Config::default();
963        config.http.max_concurrent_downloads = Some(0);
964        assert_eq!(
965            config.validate().unwrap_err().to_string(),
966            "configuration value `http.max_concurrent_downloads` cannot be zero"
967        );
968
969        let mut config = Config::default();
970        config.http.max_concurrent_downloads = Some(5);
971        assert!(
972            config.validate().is_ok(),
973            "should pass for valid configuration"
974        );
975
976        let mut config = Config::default();
977        config.http.max_concurrent_downloads = None;
978        assert!(config.validate().is_ok(), "should pass for default (None)");
979    }
980}