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}
371
372impl TaskConfig {
373    /// Validates the task evaluation configuration.
374    pub fn validate(&self) -> Result<()> {
375        if self.retries.unwrap_or(0) > MAX_RETRIES {
376            bail!("configuration value `task.retries` cannot exceed {MAX_RETRIES}");
377        }
378
379        Ok(())
380    }
381}
382
383/// Represents supported task execution backends.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385#[serde(rename_all = "snake_case", tag = "type")]
386pub enum BackendConfig {
387    /// Use the local task execution backend.
388    Local(LocalBackendConfig),
389    /// Use the Docker task execution backend.
390    Docker(DockerBackendConfig),
391    /// Use the TES task execution backend.
392    Tes(Box<TesBackendConfig>),
393}
394
395impl Default for BackendConfig {
396    fn default() -> Self {
397        Self::Docker(Default::default())
398    }
399}
400
401impl BackendConfig {
402    /// Validates the backend configuration.
403    pub fn validate(&self) -> Result<()> {
404        match self {
405            Self::Local(config) => config.validate(),
406            Self::Docker(config) => config.validate(),
407            Self::Tes(config) => config.validate(),
408        }
409    }
410
411    /// Converts the backend configuration into a local backend configuration
412    ///
413    /// Returns `None` if the backend configuration is not local.
414    pub fn as_local(&self) -> Option<&LocalBackendConfig> {
415        match self {
416            Self::Local(config) => Some(config),
417            _ => None,
418        }
419    }
420
421    /// Converts the backend configuration into a Docker backend configuration
422    ///
423    /// Returns `None` if the backend configuration is not Docker.
424    pub fn as_docker(&self) -> Option<&DockerBackendConfig> {
425        match self {
426            Self::Docker(config) => Some(config),
427            _ => None,
428        }
429    }
430
431    /// Converts the backend configuration into a TES backend configuration
432    ///
433    /// Returns `None` if the backend configuration is not TES.
434    pub fn as_tes(&self) -> Option<&TesBackendConfig> {
435        match self {
436            Self::Tes(config) => Some(config),
437            _ => None,
438        }
439    }
440}
441
442/// Represents configuration for the local task execution backend.
443///
444/// <div class="warning">
445/// Warning: the local task execution backend spawns processes on the host
446/// directly without the use of a container; only use this backend on trusted
447/// WDL. </div>
448#[derive(Debug, Default, Clone, Serialize, Deserialize)]
449#[serde(rename_all = "snake_case", deny_unknown_fields)]
450pub struct LocalBackendConfig {
451    /// Set the number of CPUs available for task execution.
452    ///
453    /// Defaults to the number of logical CPUs for the host.
454    ///
455    /// The value cannot be zero or exceed the host's number of CPUs.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub cpu: Option<u64>,
458
459    /// Set the total amount of memory for task execution as a unit string (e.g.
460    /// `2 GiB`).
461    ///
462    /// Defaults to the total amount of memory for the host.
463    ///
464    /// The value cannot be zero or exceed the host's total amount of memory.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub memory: Option<String>,
467}
468
469impl LocalBackendConfig {
470    /// Validates the local task execution backend configuration.
471    pub fn validate(&self) -> Result<()> {
472        if let Some(cpu) = self.cpu {
473            if cpu == 0 {
474                bail!("local backend configuration value `cpu` cannot be zero");
475            }
476
477            let total = SYSTEM.cpus().len() as u64;
478            if cpu > total {
479                bail!(
480                    "local backend configuration value `cpu` cannot exceed the virtual CPUs \
481                     available to the host ({total})"
482                );
483            }
484        }
485
486        if let Some(memory) = &self.memory {
487            let memory = convert_unit_string(memory).with_context(|| {
488                format!("local backend configuration value `memory` has invalid value `{memory}`")
489            })?;
490
491            if memory == 0 {
492                bail!("local backend configuration value `memory` cannot be zero");
493            }
494
495            let total = SYSTEM.total_memory();
496            if memory > total {
497                bail!(
498                    "local backend configuration value `memory` cannot exceed the total memory of \
499                     the host ({total} bytes)"
500                );
501            }
502        }
503
504        Ok(())
505    }
506}
507
508/// Gets the default value for the docker `cleanup` field.
509const fn cleanup_default() -> bool {
510    true
511}
512
513/// Represents configuration for the Docker backend.
514#[derive(Debug, Clone, Serialize, Deserialize)]
515#[serde(rename_all = "snake_case", deny_unknown_fields)]
516pub struct DockerBackendConfig {
517    /// Whether or not to remove a task's container after the task completes.
518    ///
519    /// Defaults to `true`.
520    #[serde(default = "cleanup_default")]
521    pub cleanup: bool,
522}
523
524impl DockerBackendConfig {
525    /// Validates the Docker backend configuration.
526    pub fn validate(&self) -> Result<()> {
527        Ok(())
528    }
529}
530
531impl Default for DockerBackendConfig {
532    fn default() -> Self {
533        Self { cleanup: true }
534    }
535}
536
537/// Represents HTTP basic authentication configuration.
538#[derive(Debug, Default, Clone, Serialize, Deserialize)]
539#[serde(rename_all = "snake_case", deny_unknown_fields)]
540pub struct BasicAuthConfig {
541    /// The HTTP basic authentication username.
542    pub username: Option<String>,
543    /// The HTTP basic authentication password.
544    pub password: Option<String>,
545}
546
547impl BasicAuthConfig {
548    /// Validates the HTTP basic auth configuration.
549    pub fn validate(&self) -> Result<()> {
550        if self.username.is_none() {
551            bail!("HTTP basic auth configuration value `username` is required");
552        }
553
554        if self.password.is_none() {
555            bail!("HTTP basic auth configuration value `password` is required");
556        }
557
558        Ok(())
559    }
560}
561
562/// Represents HTTP bearer token authentication configuration.
563#[derive(Debug, Default, Clone, Serialize, Deserialize)]
564#[serde(rename_all = "snake_case", deny_unknown_fields)]
565pub struct BearerAuthConfig {
566    /// The HTTP bearer authentication token.
567    pub token: Option<String>,
568}
569
570impl BearerAuthConfig {
571    /// Validates the HTTP basic auth configuration.
572    pub fn validate(&self) -> Result<()> {
573        if self.token.is_none() {
574            bail!("HTTP bearer auth configuration value `token` is required");
575        }
576
577        Ok(())
578    }
579}
580
581/// Represents the kind of authentication for a TES backend.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583#[serde(rename_all = "snake_case", tag = "type")]
584pub enum TesBackendAuthConfig {
585    /// Use basic authentication for the TES backend.
586    Basic(BasicAuthConfig),
587    /// Use bearer token authentication for the TES backend.
588    Bearer(BearerAuthConfig),
589}
590
591impl TesBackendAuthConfig {
592    /// Validates the TES backend authentication configuration.
593    pub fn validate(&self) -> Result<()> {
594        match self {
595            Self::Basic(config) => config.validate(),
596            Self::Bearer(config) => config.validate(),
597        }
598    }
599}
600
601/// Represents configuration for the Task Execution Service (TES) backend.
602#[derive(Debug, Default, Clone, Serialize, Deserialize)]
603#[serde(rename_all = "snake_case", deny_unknown_fields)]
604pub struct TesBackendConfig {
605    /// The URL of the Task Execution Service.
606    #[serde(default)]
607    pub url: Option<Url>,
608
609    /// The authentication configuration for the TES backend.
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub auth: Option<TesBackendAuthConfig>,
612
613    /// The cloud storage URL for storing inputs.
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub inputs: Option<Url>,
616
617    /// The cloud storage URL for storing outputs.
618    #[serde(default, skip_serializing_if = "Option::is_none")]
619    pub outputs: Option<Url>,
620
621    /// The polling interval, in seconds, for checking task status.
622    ///
623    /// Defaults to 60 second.
624    #[serde(default)]
625    pub interval: Option<u64>,
626
627    /// The maximum task concurrency for the backend.
628    ///
629    /// Defaults to unlimited.
630    #[serde(default)]
631    pub max_concurrency: Option<u64>,
632
633    /// Whether or not the TES server URL may use an insecure protocol like
634    /// HTTP.
635    #[serde(default)]
636    pub insecure: bool,
637}
638
639impl TesBackendConfig {
640    /// Validates the TES backend configuration.
641    pub fn validate(&self) -> Result<()> {
642        match &self.url {
643            Some(url) => {
644                if !self.insecure && url.scheme() != "https" {
645                    bail!(
646                        "TES backend configuration value `url` has invalid value `{url}`: URL \
647                         must use a HTTPS scheme"
648                    );
649                }
650            }
651            None => bail!("TES backend configuration value `url` is required"),
652        }
653
654        if let Some(auth) = &self.auth {
655            auth.validate()?;
656        }
657
658        match &self.inputs {
659            Some(url) => {
660                if !is_url(url.as_str()) {
661                    bail!(
662                        "TES backend storage configuration value `inputs` has invalid value \
663                         `{url}`: URL scheme is not supported"
664                    );
665                }
666
667                if !url.path().ends_with('/') {
668                    bail!(
669                        "TES backend storage configuration value `inputs` has invalid value \
670                         `{url}`: URL path must end with a slash"
671                    );
672                }
673            }
674            None => bail!("TES backend configuration value `inputs` is required"),
675        }
676
677        match &self.outputs {
678            Some(url) => {
679                if !is_url(url.as_str()) {
680                    bail!(
681                        "TES backend storage configuration value `outputs` has invalid value \
682                         `{url}`: URL scheme is not supported"
683                    );
684                }
685
686                if !url.path().ends_with('/') {
687                    bail!(
688                        "TES backend storage configuration value `outputs` has invalid value \
689                         `{url}`: URL path must end with a slash"
690                    );
691                }
692            }
693            None => bail!("TES backend storage configuration value `outputs` is required"),
694        }
695
696        Ok(())
697    }
698}
699
700#[cfg(test)]
701mod test {
702    use pretty_assertions::assert_eq;
703
704    use super::*;
705
706    #[test]
707    fn test_config_validate() {
708        // Test invalid task config
709        let mut config = Config::default();
710        config.task.retries = Some(1000000);
711        assert_eq!(
712            config.validate().unwrap_err().to_string(),
713            "configuration value `task.retries` cannot exceed 100"
714        );
715
716        // Test invalid scatter concurrency config
717        let mut config = Config::default();
718        config.workflow.scatter.concurrency = Some(0);
719        assert_eq!(
720            config.validate().unwrap_err().to_string(),
721            "configuration value `workflow.scatter.concurrency` cannot be zero"
722        );
723
724        // Test invalid backend name
725        let config = Config {
726            backend: Some("foo".into()),
727            ..Default::default()
728        };
729        assert_eq!(
730            config.validate().unwrap_err().to_string(),
731            "a backend named `foo` is not present in the configuration"
732        );
733        let config = Config {
734            backend: Some("bar".into()),
735            backends: [("foo".to_string(), BackendConfig::default())].into(),
736            ..Default::default()
737        };
738        assert_eq!(
739            config.validate().unwrap_err().to_string(),
740            "a backend named `bar` is not present in the configuration"
741        );
742
743        // Test a singular backend
744        let config = Config {
745            backends: [("foo".to_string(), BackendConfig::default())].into(),
746            ..Default::default()
747        };
748        config.validate().expect("config should validate");
749
750        // Test invalid local backend cpu config
751        let config = Config {
752            backends: [(
753                "default".to_string(),
754                BackendConfig::Local(LocalBackendConfig {
755                    cpu: Some(0),
756                    ..Default::default()
757                }),
758            )]
759            .into(),
760            ..Default::default()
761        };
762        assert_eq!(
763            config.validate().unwrap_err().to_string(),
764            "local backend configuration value `cpu` cannot be zero"
765        );
766        let config = Config {
767            backends: [(
768                "default".to_string(),
769                BackendConfig::Local(LocalBackendConfig {
770                    cpu: Some(10000000),
771                    ..Default::default()
772                }),
773            )]
774            .into(),
775            ..Default::default()
776        };
777        assert!(config.validate().unwrap_err().to_string().starts_with(
778            "local backend configuration value `cpu` cannot exceed the virtual CPUs available to \
779             the host"
780        ));
781
782        // Test invalid local backend memory config
783        let config = Config {
784            backends: [(
785                "default".to_string(),
786                BackendConfig::Local(LocalBackendConfig {
787                    memory: Some("0 GiB".to_string()),
788                    ..Default::default()
789                }),
790            )]
791            .into(),
792            ..Default::default()
793        };
794        assert_eq!(
795            config.validate().unwrap_err().to_string(),
796            "local backend configuration value `memory` cannot be zero"
797        );
798        let config = Config {
799            backends: [(
800                "default".to_string(),
801                BackendConfig::Local(LocalBackendConfig {
802                    memory: Some("100 meows".to_string()),
803                    ..Default::default()
804                }),
805            )]
806            .into(),
807            ..Default::default()
808        };
809        assert_eq!(
810            config.validate().unwrap_err().to_string(),
811            "local backend configuration value `memory` has invalid value `100 meows`"
812        );
813
814        let config = Config {
815            backends: [(
816                "default".to_string(),
817                BackendConfig::Local(LocalBackendConfig {
818                    memory: Some("1000 TiB".to_string()),
819                    ..Default::default()
820                }),
821            )]
822            .into(),
823            ..Default::default()
824        };
825        assert!(config.validate().unwrap_err().to_string().starts_with(
826            "local backend configuration value `memory` cannot exceed the total memory of the host"
827        ));
828
829        // Test missing TES URL
830        let config = Config {
831            backends: [(
832                "default".to_string(),
833                BackendConfig::Tes(Default::default()),
834            )]
835            .into(),
836            ..Default::default()
837        };
838        assert_eq!(
839            config.validate().unwrap_err().to_string(),
840            "TES backend configuration value `url` is required"
841        );
842
843        // Insecure TES URL
844        let config = Config {
845            backends: [(
846                "default".to_string(),
847                BackendConfig::Tes(
848                    TesBackendConfig {
849                        url: Some("http://example.com".parse().unwrap()),
850                        inputs: Some("http://example.com".parse().unwrap()),
851                        outputs: Some("http://example.com".parse().unwrap()),
852                        ..Default::default()
853                    }
854                    .into(),
855                ),
856            )]
857            .into(),
858            ..Default::default()
859        };
860        assert_eq!(
861            config.validate().unwrap_err().to_string(),
862            "TES backend configuration value `url` has invalid value `http://example.com/`: URL \
863             must use a HTTPS scheme"
864        );
865
866        // Allow insecure URL
867        let config = Config {
868            backends: [(
869                "default".to_string(),
870                BackendConfig::Tes(
871                    TesBackendConfig {
872                        url: Some("http://example.com".parse().unwrap()),
873                        inputs: Some("http://example.com".parse().unwrap()),
874                        outputs: Some("http://example.com".parse().unwrap()),
875                        insecure: true,
876                        ..Default::default()
877                    }
878                    .into(),
879                ),
880            )]
881            .into(),
882            ..Default::default()
883        };
884        config.validate().expect("configuration should validate");
885
886        // Test invalid TES basic auth
887        let config = Config {
888            backends: [(
889                "default".to_string(),
890                BackendConfig::Tes(Box::new(TesBackendConfig {
891                    url: Some(Url::parse("https://example.com").unwrap()),
892                    auth: Some(TesBackendAuthConfig::Basic(Default::default())),
893                    ..Default::default()
894                })),
895            )]
896            .into(),
897            ..Default::default()
898        };
899        assert_eq!(
900            config.validate().unwrap_err().to_string(),
901            "HTTP basic auth configuration value `username` is required"
902        );
903        let config = Config {
904            backends: [(
905                "default".to_string(),
906                BackendConfig::Tes(Box::new(TesBackendConfig {
907                    url: Some(Url::parse("https://example.com").unwrap()),
908                    auth: Some(TesBackendAuthConfig::Basic(BasicAuthConfig {
909                        username: Some("Foo".into()),
910                        ..Default::default()
911                    })),
912                    ..Default::default()
913                })),
914            )]
915            .into(),
916            ..Default::default()
917        };
918        assert_eq!(
919            config.validate().unwrap_err().to_string(),
920            "HTTP basic auth configuration value `password` is required"
921        );
922
923        // Test invalid TES bearer auth
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::Bearer(Default::default())),
930                    ..Default::default()
931                })),
932            )]
933            .into(),
934            ..Default::default()
935        };
936        assert_eq!(
937            config.validate().unwrap_err().to_string(),
938            "HTTP bearer auth configuration value `token` is required"
939        );
940
941        let mut config = Config::default();
942        config.http.max_concurrent_downloads = Some(0);
943        assert_eq!(
944            config.validate().unwrap_err().to_string(),
945            "configuration value `http.max_concurrent_downloads` cannot be zero"
946        );
947
948        let mut config = Config::default();
949        config.http.max_concurrent_downloads = Some(5);
950        assert!(
951            config.validate().is_ok(),
952            "should pass for valid configuration"
953        );
954
955        let mut config = Config::default();
956        config.http.max_concurrent_downloads = None;
957        assert!(config.validate().is_ok(), "should pass for default (None)");
958    }
959}