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