Skip to main content

wdl_engine/
config.rs

1//! Implementation of engine configuration.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::fs;
6use std::marker::PhantomData;
7use std::path::Path;
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::thread::available_parallelism;
11
12use anyhow::Context;
13use anyhow::Result;
14use anyhow::anyhow;
15use anyhow::bail;
16use anyhow::ensure;
17use bytesize::ByteSize;
18use codespan_reporting::files::SimpleFile;
19use codespan_reporting::term;
20use codespan_reporting::term::termcolor::Buffer;
21use indexmap::IndexMap;
22use rowan::GreenNode;
23use schemars::JsonSchema;
24use secrecy::ExposeSecret;
25use tokio::process::Command;
26use toml_spanner::Arena;
27use toml_spanner::ErrorKind;
28use toml_spanner::Failed;
29use toml_spanner::FromToml;
30use toml_spanner::Item;
31use toml_spanner::Table;
32use toml_spanner::ToToml;
33use toml_spanner::ToTomlError;
34use toml_spanner::Toml;
35use toml_spanner::helper::display;
36use toml_spanner::helper::flatten_any;
37use toml_spanner::helper::parse_string;
38use tracing::error;
39use tracing::warn;
40use url::Url;
41use wdl_analysis::Diagnostics;
42use wdl_analysis::DiagnosticsConfig;
43use wdl_analysis::Exceptable;
44use wdl_analysis::diagnostics::unknown_name;
45use wdl_analysis::diagnostics::unknown_type;
46use wdl_analysis::document::Task;
47use wdl_analysis::types::PrimitiveType;
48use wdl_analysis::types::Type;
49use wdl_analysis::types::v1::ExprTypeEvaluator;
50use wdl_ast::AstNode;
51use wdl_ast::Diagnostic;
52use wdl_ast::Span;
53use wdl_ast::SupportedVersion;
54use wdl_ast::TreeNode;
55use wdl_ast::lexer::Lexer;
56use wdl_ast::v1::Expr;
57use wdl_grammar::SyntaxKind;
58use wdl_grammar::construct_tree;
59use wdl_grammar::grammar::v1;
60use wdl_grammar::grammar::v1::Parser;
61
62use crate::CancellationContext;
63use crate::EvaluationContext;
64use crate::EvaluationPath;
65use crate::Events;
66use crate::NoneValue;
67use crate::Object;
68use crate::SYSTEM;
69use crate::Value;
70use crate::backend::ExecuteTaskRequest;
71use crate::backend::TaskExecutionBackend;
72use crate::convert_unit_string;
73use crate::diagnostics::unknown_enum_choice;
74use crate::http::Transferer;
75use crate::path::is_supported_url;
76use crate::tree::SyntaxNode;
77use crate::v1::DEFAULT_TASK_REQUIREMENT_MAX_RETRIES;
78use crate::v1::ExprEvaluator;
79
80/// The inclusive maximum number of task retries the engine supports.
81pub(crate) const MAX_RETRIES: u64 = 100;
82
83/// The default task shell.
84pub(crate) const DEFAULT_TASK_SHELL: &str = "bash";
85
86/// The default task shell.
87pub(crate) const fn default_task_shell() -> &'static str {
88    DEFAULT_TASK_SHELL
89}
90
91/// The default task container.
92pub(crate) const DEFAULT_TASK_CONTAINER: &str = "ubuntu:latest";
93
94/// The default task container.
95pub(crate) const fn default_task_container() -> &'static str {
96    DEFAULT_TASK_CONTAINER
97}
98
99/// The default backend name.
100const fn default_backend_name() -> &'static str {
101    "default"
102}
103
104/// The maximum size, in bytes, for an LSF job name prefix.
105const MAX_LSF_JOB_NAME_PREFIX: usize = 100;
106
107/// The string that replaces redacted serialization fields.
108const REDACTED: &str = "<REDACTED>";
109
110/// Configuration sentinel value indicating use a system cache directory.
111const fn cache_dir_sentinel() -> &'static str {
112    "system"
113}
114
115/// The default for HTTP retries.
116///
117/// Same default as defined in `cloud_copy`
118const fn default_http_retries() -> u32 {
119    5
120}
121
122/// The default Apptainer executable name.
123const fn default_apptainer_executable() -> &'static str {
124    "apptainer"
125}
126
127/// The default number of elements to concurrently process for a scatter
128/// statement.
129const fn default_scatter_concurrency() -> u64 {
130    1000
131}
132
133/// Gets the default root cache directory for the user.
134pub(crate) fn cache_dir() -> Result<PathBuf> {
135    /// The subdirectory within the user's cache directory for all caches
136    const CACHE_DIR_ROOT: &str = "sprocket";
137
138    Ok(dirs::cache_dir()
139        .context("failed to determine user cache directory")?
140        .join(CACHE_DIR_ROOT))
141}
142
143/// Creates a mapping of byte indexes in an unescaped TOML string to the
144/// corresponding index in the escaped TOML string.
145///
146/// Only indexes that immediately follow an escape sequence are included in the
147/// set.
148///
149/// All other mapping indexes can be synthesized by doing a binary search for
150/// the unescaped index and either using the found entry's escaped index or
151/// offset the unescaped index by the difference between the escaped and
152/// unescaped indexes for the immediately preceding entry in the map at the
153/// binary search insertion index (or zero if the insertion index is 0).
154///
155/// This is used as part of generating diagnostics for WDL expressions stored as
156/// TOML strings.
157///
158/// The returned list is guaranteed sorted in both index spaces.
159///
160/// Note that if the string ends with an escape sequence, an additional mapping
161/// of the exclusive end of the string will be included in the set.
162///
163/// # Panics
164///
165/// Panics if the TOML string contains invalid escape sequences.
166///
167/// Only use this function after the TOML has been validated.
168///
169/// # Examples
170///
171/// `foo\tbar` -> [(4, 5)]
172/// `\"foo\" == \"bar\"` -> [(1, 2), (5, 7), (10, 13), (14, 18)]
173fn escape_mapping(toml: &str) -> Vec<(usize, usize)> {
174    let mut iter = toml.char_indices();
175    let mut mapping = Vec::new();
176    let mut new = 0;
177    while let Some((old, c)) = iter.next() {
178        if c != '\\' {
179            new += c.len_utf8();
180            continue;
181        }
182
183        match iter.next() {
184            Some((_, 'u')) => {
185                let c = u32::from_str_radix(&toml[old + 2 /* \u */..old + 6 /* \uXXXX */], 16)
186                    .map(char::from_u32)
187                    .expect("invalid TOML escape sequence")
188                    .expect("invalid TOML escape character");
189                new += c.len_utf8();
190
191                // Move past the rest of the sequence
192                iter.nth(3);
193                mapping.push((new, old + 6 /* \uXXXX */));
194            }
195            Some((_, 'U')) => {
196                let c = u32::from_str_radix(&toml[old + 2 /* \U */..old + 10 /* \UXXXXXXXX */], 16)
197                    .map(char::from_u32)
198                    .expect("invalid TOML escape sequence")
199                    .expect("invalid TOML escape character");
200                new += c.len_utf8();
201
202                // Move past the rest of the sequence
203                iter.nth(7);
204                mapping.push((new, old + 10 /* \UXXXXXXXX */));
205            }
206            Some(_) => {
207                // All other escape sequences are single byte replacements
208                new += 1;
209                mapping.push((new, old + 2 /* \? */));
210            }
211            None => break,
212        }
213    }
214
215    mapping
216}
217
218/// Represents a secret string that is, by default, redacted for serialization.
219///
220/// This type is a wrapper around [`secrecy::SecretString`].
221#[derive(Default, Debug, Clone, JsonSchema)]
222#[schemars(with = "String")]
223pub struct SecretString {
224    /// The inner secret string.
225    ///
226    /// This type is not serializable.
227    inner: secrecy::SecretString,
228    /// Whether or not the secret string is redacted for serialization.
229    ///
230    /// If `true`, `<REDACTED>` is serialized for the string's value.
231    ///
232    /// If `false`, the inner secret string is exposed for serialization.
233    ///
234    /// Defaults to unredacted; users should call `redacted` on the [`Config`]
235    /// prior to serialization.
236    redacted: bool,
237}
238
239impl SecretString {
240    /// Redacts the secret for serialization.
241    ///
242    /// By default, a [`SecretString`] is unredacted; when redacted, the string
243    /// is replaced with `<REDACTED>` when serialized.
244    pub fn redact(mut self) -> Self {
245        self.redacted = true;
246        self
247    }
248
249    /// Gets the inner [`secrecy::SecretString`].
250    pub fn inner(&self) -> &secrecy::SecretString {
251        &self.inner
252    }
253}
254
255impl From<String> for SecretString {
256    fn from(s: String) -> Self {
257        Self {
258            inner: s.into(),
259            redacted: false,
260        }
261    }
262}
263
264impl From<&str> for SecretString {
265    fn from(s: &str) -> Self {
266        Self {
267            inner: s.into(),
268            redacted: false,
269        }
270    }
271}
272
273impl<'de> FromToml<'de> for SecretString {
274    fn from_toml(ctx: &mut toml_spanner::Context<'de>, item: &Item<'de>) -> Result<Self, Failed> {
275        Ok(String::from_toml(ctx, item)?.into())
276    }
277}
278
279impl ToToml for SecretString {
280    fn to_toml<'a>(&'a self, arena: &'a Arena) -> Result<Item<'a>, ToTomlError> {
281        use secrecy::ExposeSecret;
282
283        if self.redacted {
284            REDACTED.to_toml(arena)
285        } else {
286            self.inner.expose_secret().to_toml(arena)
287        }
288    }
289}
290
291impl PartialEq for SecretString {
292    fn eq(&self, other: &Self) -> bool {
293        use secrecy::ExposeSecret;
294
295        // Compare just on the string, ignoring the redaction flag
296        self.inner.expose_secret() == other.inner.expose_secret()
297    }
298}
299
300impl Eq for SecretString {}
301
302/// Represents how an evaluation error or cancellation should be handled by the
303/// engine.
304#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Toml, JsonSchema)]
305#[toml(Toml, rename_all = "snake_case")]
306#[schemars(rename_all = "snake_case")]
307pub enum FailureMode {
308    /// When an error is encountered or evaluation is canceled, evaluation waits
309    /// for any outstanding tasks to complete.
310    #[default]
311    Slow,
312    /// When an error is encountered or evaluation is canceled, any outstanding
313    /// tasks that are executing are immediately canceled and evaluation waits
314    /// for cancellation to complete.
315    Fast,
316}
317
318/// Helper functions for `IndexMap` TOML serialization.
319mod index_map {
320    use indexmap::IndexMap;
321    use toml_spanner::Arena;
322    use toml_spanner::Context;
323    use toml_spanner::Failed;
324    use toml_spanner::FromToml;
325    use toml_spanner::Item;
326    use toml_spanner::Key;
327    use toml_spanner::Table;
328    use toml_spanner::TableStyle;
329    use toml_spanner::ToToml;
330    use toml_spanner::ToTomlError;
331
332    /// Helper function for serializing an `IndexMap` to TOML.
333    pub fn from_toml<'de, V>(
334        ctx: &mut Context<'de>,
335        item: &Item<'de>,
336    ) -> Result<IndexMap<String, V>, Failed>
337    where
338        V: FromToml<'de>,
339    {
340        let table = item.require_table(ctx)?;
341        let mut map = IndexMap::default();
342        let mut had_error = false;
343        for (key, item) in table {
344            match V::from_toml(ctx, item) {
345                Ok(v) => {
346                    map.insert(key.name.into(), v);
347                }
348                Err(_) => had_error = true,
349            }
350        }
351
352        if had_error { Err(Failed) } else { Ok(map) }
353    }
354
355    /// Helper function for deserializing an `IndexMap` from TOML.
356    pub fn to_toml<'a, V>(
357        value: &'a IndexMap<String, V>,
358        arena: &'a Arena,
359    ) -> Result<Item<'a>, ToTomlError>
360    where
361        V: ToToml,
362    {
363        let Some(mut table) = Table::try_with_capacity(value.len(), arena) else {
364            return Err(ToTomlError::from(
365                "length of table exceeded maximum capacity",
366            ));
367        };
368
369        table.set_style(TableStyle::Implicit);
370
371        for (k, v) in value {
372            table.insert_unique(Key::new(k), v.to_toml(arena)?, arena);
373        }
374
375        Ok(table.into_item())
376    }
377}
378
379/// Represents WDL evaluation configuration.
380///
381/// <div class="warning">
382///
383/// By default, serialization of [`Config`] will not redact the values of
384/// secrets.
385///
386/// Use the [`Config::redact`] method prior to serialization to redact secrets.
387///
388/// </div>
389#[derive(Debug, Clone, Toml, PartialEq, Eq, JsonSchema)]
390#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
391#[schemars(
392    rename = "WdlEngineConfig",
393    rename_all = "snake_case",
394    deny_unknown_fields
395)]
396pub struct Config {
397    /// HTTP configuration.
398    #[toml(default, style = Header)]
399    #[schemars(default)]
400    pub http: HttpConfig,
401    /// Workflow evaluation configuration.
402    #[toml(default, style = Header)]
403    #[schemars(default)]
404    pub workflow: WorkflowConfig,
405    /// Task evaluation configuration.
406    #[toml(default, style = Header)]
407    #[schemars(default)]
408    pub task: TaskConfig,
409    /// The name of the backend to use.
410    #[toml(default = String::from(default_backend_name()))]
411    #[schemars(default = "default_backend_name")]
412    pub backend: String,
413    /// Task execution backends configuration.
414    ///
415    /// If the collection is empty and `backend` has the default value, the
416    /// engine default backend is used.
417    #[toml(default, with = index_map)]
418    #[schemars(default)]
419    pub backends: IndexMap<String, BackendConfig>,
420    /// Storage configuration.
421    #[toml(default, style = Header)]
422    #[schemars(default)]
423    pub storage: StorageConfig,
424    /// (Experimental) Avoid environment-specific output; default is `false`.
425    ///
426    /// If this option is `true`, selected error messages and log output will
427    /// avoid emitting environment-specific output such as absolute paths
428    /// and system resource counts.
429    ///
430    /// This is largely meant to support "golden testing" where a test's success
431    /// depends on matching an expected set of outputs exactly. Cues that
432    /// help users overcome errors, such as the path to a temporary
433    /// directory or the number of CPUs available to the system, confound this
434    /// style of testing. This flag is a best-effort experimental attempt to
435    /// reduce the impact of these differences in order to allow a wider
436    /// range of golden tests to be written.
437    #[toml(default)]
438    #[schemars(default)]
439    pub suppress_env_specific_output: bool,
440    /// (Experimental) Whether experimental features are enabled; default is
441    /// `false`.
442    ///
443    /// Experimental features are provided to users with heavy caveats about
444    /// their stability and rough edges. Use at your own risk, but feedback
445    /// is quite welcome.
446    #[toml(default)]
447    #[schemars(default)]
448    pub experimental_features_enabled: bool,
449    /// The failure mode for workflow or task evaluation.
450    ///
451    /// A value of [`FailureMode::Slow`] will result in evaluation waiting for
452    /// executing tasks to complete upon error or interruption.
453    ///
454    /// A value of [`FailureMode::Fast`] will immediately attempt to cancel
455    /// executing tasks upon error or interruption.
456    #[toml(default, rename = "fail")]
457    #[schemars(default, rename = "fail")]
458    pub failure_mode: FailureMode,
459}
460
461impl Default for Config {
462    fn default() -> Self {
463        Self {
464            http: Default::default(),
465            workflow: Default::default(),
466            task: Default::default(),
467            backend: default_backend_name().into(),
468            backends: Default::default(),
469            storage: Default::default(),
470            suppress_env_specific_output: Default::default(),
471            experimental_features_enabled: Default::default(),
472            failure_mode: Default::default(),
473        }
474    }
475}
476
477impl Config {
478    /// Gets a builder for [`Config`].
479    pub fn builder() -> ConfigBuilder<Self> {
480        ConfigBuilder::default()
481    }
482
483    /// Validates the evaluation configuration.
484    pub async fn validate(&self) -> Result<()> {
485        self.http.validate()?;
486        self.workflow.validate()?;
487        self.task.validate()?;
488
489        if self.backends.is_empty() && self.backend == default_backend_name() {
490            // we'll use the default
491        } else {
492            let backend = &self.backend;
493            if !self.backends.contains_key(backend) {
494                bail!("a backend named `{backend}` is not present in the configuration");
495            }
496        }
497
498        for backend in self.backends.values() {
499            backend.validate().await?;
500        }
501
502        self.storage.validate()?;
503
504        if self.suppress_env_specific_output && !self.experimental_features_enabled {
505            bail!("`suppress_env_specific_output` requires enabling experimental features");
506        }
507
508        Ok(())
509    }
510
511    /// Redacts the secrets contained in the configuration.
512    ///
513    /// By default, secrets are redacted for serialization.
514    pub fn redact(mut self) -> Self {
515        for backend in self.backends.values_mut() {
516            *backend = std::mem::take(backend).redact();
517        }
518
519        if let Some(auth) = self.storage.azure.auth.take() {
520            self.storage.azure.auth = Some(auth.redact());
521        }
522
523        if let Some(auth) = self.storage.s3.auth.take() {
524            self.storage.s3.auth = Some(auth.redact());
525        }
526
527        if let Some(auth) = self.storage.google.auth.take() {
528            self.storage.google.auth = Some(auth.redact());
529        }
530
531        self
532    }
533
534    /// Gets the backend configuration.
535    ///
536    /// Returns an error if the configuration specifies a named backend that
537    /// isn't present in the configuration.
538    pub fn backend(&self) -> Result<Cow<'_, BackendConfig>> {
539        if !self.backends.is_empty() {
540            let backend = &self.backend;
541            return Ok(Cow::Borrowed(self.backends.get(backend).ok_or_else(
542                || anyhow!("a backend named `{backend}` is not present in the configuration"),
543            )?));
544        }
545        // Use the default
546        Ok(Cow::Owned(BackendConfig::default()))
547    }
548
549    /// Creates a new task execution backend based on this configuration.
550    pub(crate) async fn create_backend(
551        self: &Arc<Self>,
552        run_root_dir: &Path,
553        events: Events,
554        cancellation: CancellationContext,
555    ) -> Result<Arc<dyn TaskExecutionBackend>> {
556        use crate::backend::*;
557
558        match self.backend()?.as_ref() {
559            BackendConfig::Local { .. } => {
560                warn!(
561                    "the engine is configured to use the local backend: tasks will not be run \
562                     inside of a container"
563                );
564                Ok(Arc::new(LocalBackend::new(
565                    self.clone(),
566                    events,
567                    cancellation,
568                )?))
569            }
570            BackendConfig::Docker { .. } => Ok(Arc::new(
571                DockerBackend::new(self.clone(), events, cancellation).await?,
572            )),
573            BackendConfig::Tes { .. } => Ok(Arc::new(
574                TesBackend::new(self.clone(), events, cancellation).await?,
575            )),
576            BackendConfig::LsfApptainer { .. } => Ok(Arc::new(LsfApptainerBackend::new(
577                self.clone(),
578                run_root_dir,
579                events,
580                cancellation,
581            )?)),
582            BackendConfig::SlurmApptainer { .. } => Ok(Arc::new(SlurmApptainerBackend::new(
583                self.clone(),
584                run_root_dir,
585                events,
586                cancellation,
587            )?)),
588        }
589    }
590}
591
592/// Represents the parallelism for HTTP downloads.
593#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, JsonSchema)]
594#[schemars(rename_all = "lowercase")]
595pub enum Parallelism {
596    /// Use the available parallelism for the host system.
597    #[default]
598    Available,
599    /// Use the specified parallelism.
600    #[schemars(untagged)]
601    Use(usize),
602}
603
604impl From<usize> for Parallelism {
605    fn from(value: usize) -> Self {
606        Self::Use(value)
607    }
608}
609
610impl From<Parallelism> for usize {
611    fn from(value: Parallelism) -> Self {
612        match value {
613            Parallelism::Available => available_parallelism().map(Into::into).unwrap_or(1),
614            Parallelism::Use(value) => value,
615        }
616    }
617}
618
619impl<'de> FromToml<'de> for Parallelism {
620    fn from_toml(ctx: &mut toml_spanner::Context<'de>, item: &Item<'de>) -> Result<Self, Failed> {
621        if let Some("available") = item.as_str() {
622            return Ok(Self::Available);
623        }
624
625        if let Some(n) = item.as_u64().and_then(|n| usize::try_from(n).ok())
626            && n > 0
627        {
628            return Ok(Self::Use(n));
629        }
630
631        Err(ctx.report_custom_error(
632            "expected a positive integer or `available` for parallelism",
633            item,
634        ))
635    }
636}
637
638impl ToToml for Parallelism {
639    fn to_toml<'a>(&'a self, _: &'a Arena) -> Result<Item<'a>, ToTomlError> {
640        match self {
641            Self::Available => Ok(Item::string("available")),
642            Self::Use(n) => Ok(i64::try_from(*n)
643                .map_err(|e| ToTomlError {
644                    message: format!("invalid parallelism: {e}").into(),
645                })?
646                .into()),
647        }
648    }
649}
650
651/// Represents HTTP configuration.
652#[derive(Debug, Clone, Toml, PartialEq, Eq, JsonSchema)]
653#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
654#[schemars(rename_all = "snake_case", deny_unknown_fields)]
655pub struct HttpConfig {
656    /// The HTTP download cache location.
657    ///
658    /// Defaults to an operating system specific cache directory for the user.
659    #[toml(default = String::from(cache_dir_sentinel()))]
660    #[schemars(default = "cache_dir_sentinel")]
661    pub cache_dir: String,
662    /// The number of retries for transferring files.
663    #[toml(default = default_http_retries())]
664    #[schemars(default = "default_http_retries")]
665    pub retries: u32,
666    /// The maximum parallelism for file transfers.
667    ///
668    /// Defaults to the host's available parallelism.
669    #[toml(default)]
670    #[schemars(default)]
671    pub parallelism: Parallelism,
672    /// The hash algorithm to use for calculating content digests for file
673    /// uploads.
674    ///
675    /// Defaults to `sha256`.
676    #[toml(default, FromToml with = parse_string, ToToml with = display)]
677    #[schemars(default, with = "String")]
678    pub hash_algorithm: cloud_copy::HashAlgorithm,
679}
680
681impl Default for HttpConfig {
682    fn default() -> Self {
683        Self {
684            cache_dir: cache_dir_sentinel().into(),
685            retries: default_http_retries(),
686            parallelism: Default::default(),
687            hash_algorithm: Default::default(),
688        }
689    }
690}
691
692impl HttpConfig {
693    /// Validates the HTTP configuration.
694    pub fn validate(&self) -> Result<()> {
695        if let Parallelism::Use(parallelism) = self.parallelism
696            && parallelism == 0
697        {
698            bail!("configuration value `http.parallelism` cannot be zero");
699        }
700        Ok(())
701    }
702
703    /// Get the HTTP cache dir.
704    pub fn cache_dir(&self) -> Result<PathBuf> {
705        const DOWNLOADS_CACHE_SUBDIR: &str = "downloads";
706
707        if self.using_system_cache_dir() {
708            cache_dir().map(|d| d.join(DOWNLOADS_CACHE_SUBDIR))
709        } else {
710            Ok(PathBuf::from(&self.cache_dir))
711        }
712    }
713
714    /// Is this configuration using a system cache dir?
715    pub fn using_system_cache_dir(&self) -> bool {
716        self.cache_dir == cache_dir_sentinel()
717    }
718}
719
720/// Represents storage configuration.
721#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
722#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
723#[schemars(rename_all = "snake_case", deny_unknown_fields)]
724pub struct StorageConfig {
725    /// Azure Blob Storage configuration.
726    #[toml(default, style = Header)]
727    #[schemars(default)]
728    pub azure: AzureStorageConfig,
729    /// AWS S3 configuration.
730    #[toml(default, style = Header)]
731    #[schemars(default)]
732    pub s3: S3StorageConfig,
733    /// Google Cloud Storage configuration.
734    #[toml(default, style = Header)]
735    #[schemars(default)]
736    pub google: GoogleStorageConfig,
737}
738
739impl StorageConfig {
740    /// Validates the HTTP configuration.
741    pub fn validate(&self) -> Result<()> {
742        self.azure.validate()?;
743        self.s3.validate()?;
744        self.google.validate()?;
745        Ok(())
746    }
747}
748
749/// Represents authentication information for Azure Blob Storage.
750#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
751#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
752#[schemars(rename_all = "snake_case", deny_unknown_fields)]
753pub struct AzureStorageAuthConfig {
754    /// The Azure Storage account name to use.
755    pub account_name: String,
756    /// The Azure Storage access key to use.
757    pub access_key: SecretString,
758}
759
760impl AzureStorageAuthConfig {
761    /// Validates the Azure Blob Storage authentication configuration.
762    pub fn validate(&self) -> Result<()> {
763        if self.account_name.is_empty() {
764            bail!("configuration value `storage.azure.auth.account_name` is required");
765        }
766
767        if self.access_key.inner.expose_secret().is_empty() {
768            bail!("configuration value `storage.azure.auth.access_key` is required");
769        }
770
771        Ok(())
772    }
773
774    /// Redacts the secrets contained in the Azure Blob Storage storage
775    /// authentication configuration.
776    pub fn redact(mut self) -> Self {
777        self.access_key = self.access_key.redact();
778        self
779    }
780}
781
782/// Represents configuration for Azure Blob Storage.
783#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
784#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
785#[schemars(rename_all = "snake_case", deny_unknown_fields)]
786pub struct AzureStorageConfig {
787    /// The Azure Blob Storage authentication configuration.
788    #[toml(style = Header)]
789    pub auth: Option<AzureStorageAuthConfig>,
790}
791
792impl AzureStorageConfig {
793    /// Validates the Azure Blob Storage configuration.
794    pub fn validate(&self) -> Result<()> {
795        if let Some(auth) = &self.auth {
796            auth.validate()?;
797        }
798
799        Ok(())
800    }
801}
802
803/// Represents authentication information for AWS S3 storage.
804#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
805#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
806#[schemars(rename_all = "snake_case", deny_unknown_fields)]
807pub struct S3StorageAuthConfig {
808    /// The AWS Access Key ID to use.
809    pub access_key_id: String,
810    /// The AWS Secret Access Key to use.
811    pub secret_access_key: SecretString,
812}
813
814impl S3StorageAuthConfig {
815    /// Validates the AWS S3 storage authentication configuration.
816    pub fn validate(&self) -> Result<()> {
817        if self.access_key_id.is_empty() {
818            bail!("configuration value `storage.s3.auth.access_key_id` is required");
819        }
820
821        if self.secret_access_key.inner.expose_secret().is_empty() {
822            bail!("configuration value `storage.s3.auth.secret_access_key` is required");
823        }
824
825        Ok(())
826    }
827
828    /// Redacts the secrets contained in the AWS S3 storage authentication
829    /// configuration.
830    pub fn redact(mut self) -> Self {
831        self.secret_access_key = self.secret_access_key.redact();
832        self
833    }
834}
835
836/// Represents configuration for AWS S3 storage.
837#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
838#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
839#[schemars(rename_all = "snake_case", deny_unknown_fields)]
840pub struct S3StorageConfig {
841    /// The default region to use for S3-schemed URLs (e.g.
842    /// `s3://<bucket>/<blob>`).
843    ///
844    /// Defaults to `us-east-1`.
845    pub region: Option<String>,
846
847    /// The AWS S3 storage authentication configuration.
848    #[toml(style = Header)]
849    pub auth: Option<S3StorageAuthConfig>,
850}
851
852impl S3StorageConfig {
853    /// Validates the AWS S3 storage configuration.
854    pub fn validate(&self) -> Result<()> {
855        if let Some(auth) = &self.auth {
856            auth.validate()?;
857        }
858
859        Ok(())
860    }
861}
862
863/// Represents authentication information for Google Cloud Storage.
864#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
865#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
866#[schemars(rename_all = "snake_case", deny_unknown_fields)]
867pub struct GoogleStorageAuthConfig {
868    /// The HMAC Access Key to use.
869    pub access_key: String,
870    /// The HMAC Secret to use.
871    pub secret: SecretString,
872}
873
874impl GoogleStorageAuthConfig {
875    /// Validates the Google Cloud Storage authentication configuration.
876    pub fn validate(&self) -> Result<()> {
877        if self.access_key.is_empty() {
878            bail!("configuration value `storage.google.auth.access_key` is required");
879        }
880
881        if self.secret.inner.expose_secret().is_empty() {
882            bail!("configuration value `storage.google.auth.secret` is required");
883        }
884
885        Ok(())
886    }
887
888    /// Redacts the secrets contained in the Google Cloud Storage authentication
889    /// configuration.
890    pub fn redact(mut self) -> Self {
891        self.secret = self.secret.redact();
892        self
893    }
894}
895
896/// Represents configuration for Google Cloud Storage.
897#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
898#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
899#[schemars(rename_all = "snake_case", deny_unknown_fields)]
900pub struct GoogleStorageConfig {
901    /// The Google Cloud Storage authentication configuration.
902    #[toml(style = Header)]
903    pub auth: Option<GoogleStorageAuthConfig>,
904}
905
906impl GoogleStorageConfig {
907    /// Validates the Google Cloud Storage configuration.
908    pub fn validate(&self) -> Result<()> {
909        if let Some(auth) = &self.auth {
910            auth.validate()?;
911        }
912
913        Ok(())
914    }
915}
916
917/// Represents workflow evaluation configuration.
918#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
919#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
920#[schemars(rename_all = "snake_case", deny_unknown_fields)]
921pub struct WorkflowConfig {
922    /// Scatter statement evaluation configuration.
923    #[toml(default, style = Header)]
924    #[schemars(default)]
925    pub scatter: ScatterConfig,
926}
927
928impl WorkflowConfig {
929    /// Validates the workflow configuration.
930    pub fn validate(&self) -> Result<()> {
931        self.scatter.validate()?;
932        Ok(())
933    }
934}
935
936/// Represents scatter statement evaluation configuration.
937#[derive(Debug, Clone, PartialEq, Eq, Toml, JsonSchema)]
938#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
939#[schemars(rename_all = "snake_case", deny_unknown_fields)]
940pub struct ScatterConfig {
941    /// The number of scatter array elements to process concurrently.
942    ///
943    /// Defaults to `1000`.
944    ///
945    /// A value of `0` is invalid.
946    ///
947    /// Lower values use less memory for evaluation and higher values may better
948    /// saturate the task execution backend with tasks to execute for large
949    /// scatters.
950    ///
951    /// This setting does not change how many tasks an execution backend can run
952    /// concurrently, but may affect how many tasks are sent to the backend to
953    /// run at a time.
954    ///
955    /// For example, if `concurrency` was set to 10 and we evaluate the
956    /// following scatters:
957    ///
958    /// ```wdl
959    /// scatter (i in range(100)) {
960    ///     call my_task
961    /// }
962    ///
963    /// scatter (j in range(100)) {
964    ///     call my_task as my_task2
965    /// }
966    /// ```
967    ///
968    /// Here each scatter is independent and therefore there will be 20 calls
969    /// (10 for each scatter) made concurrently. If the task execution
970    /// backend can only execute 5 tasks concurrently, 5 tasks will execute
971    /// and 15 will be "ready" to execute and waiting for an executing task
972    /// to complete.
973    ///
974    /// If instead we evaluate the following scatters:
975    ///
976    /// ```wdl
977    /// scatter (i in range(100)) {
978    ///     scatter (j in range(100)) {
979    ///         call my_task
980    ///     }
981    /// }
982    /// ```
983    ///
984    /// Then there will be 100 calls (10*10 as 10 are made for each outer
985    /// element) made concurrently. If the task execution backend can only
986    /// execute 5 tasks concurrently, 5 tasks will execute and 95 will be
987    /// "ready" to execute and waiting for an executing task to complete.
988    ///
989    /// <div class="warning">
990    /// Warning: nested scatter statements cause exponential memory usage based
991    /// on this value, as each scatter statement evaluation requires allocating
992    /// new scopes for scatter array elements being processed. </div>
993    #[toml(default = default_scatter_concurrency())]
994    #[schemars(default = "default_scatter_concurrency")]
995    pub concurrency: u64,
996}
997
998impl Default for ScatterConfig {
999    fn default() -> Self {
1000        Self {
1001            concurrency: default_scatter_concurrency(),
1002        }
1003    }
1004}
1005
1006impl ScatterConfig {
1007    /// Validates the scatter configuration.
1008    pub fn validate(&self) -> Result<()> {
1009        if self.concurrency == 0 {
1010            bail!("configuration value `workflow.scatter.concurrency` cannot be zero");
1011        }
1012
1013        Ok(())
1014    }
1015}
1016
1017/// Represents the supported call caching modes.
1018#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Toml, JsonSchema)]
1019#[toml(Toml, rename_all = "snake_case")]
1020#[schemars(rename_all = "snake_case")]
1021pub enum CallCachingMode {
1022    /// Call caching is disabled.
1023    ///
1024    /// The call cache is not checked and new entries are not added to the
1025    /// cache.
1026    ///
1027    /// This is the default value.
1028    #[default]
1029    Off,
1030    /// Call caching is enabled.
1031    ///
1032    /// The call cache is checked and new entries are added to the cache.
1033    ///
1034    /// Defaults the `cacheable` task hint to `true`.
1035    On,
1036    /// Call caching is enabled only for tasks that explicitly have a
1037    /// `cacheable` hint set to `true`.
1038    ///
1039    /// The call cache is checked and new entries are added to the cache *only*
1040    /// for tasks that have the `cacheable` hint set to `true`.
1041    ///
1042    /// Defaults the `cacheable` task hint to `false`.
1043    Explicit,
1044}
1045
1046/// Represents the supported modes for calculating content digests.
1047#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Toml, JsonSchema)]
1048#[toml(Toml, rename_all = "snake_case")]
1049#[schemars(rename_all = "snake_case")]
1050pub enum ContentDigestMode {
1051    /// Use a strong digest for file content.
1052    ///
1053    /// Strong digests require hashing all of the contents of a file; this may
1054    /// noticeably impact performance for very large files.
1055    ///
1056    /// This setting guarantees that a modified file will be detected.
1057    Strong,
1058    /// Use a "strongish" digest for file content.
1059    ///
1060    /// A strongish digest is based off of the file's size, last modified
1061    /// time, and a hash of only the first 10 MiB of the file's contents;
1062    /// this is similar to Cromwell's `fingerprint` call caching strategy.
1063    ///
1064    /// This setting cannot guarantee the detection of modified files (e.g. a
1065    /// modification beyond the first 10 MiB of a file without a change to
1066    /// its size or last modified time will not be detected), but it is
1067    /// faster than using a strong digest for large files while still taking
1068    /// file content into account.
1069    Strongish,
1070    /// Use a weak digest for file content.
1071    ///
1072    /// A weak digest is based solely off of file metadata, such as size and
1073    /// last modified time.
1074    ///
1075    /// This setting cannot guarantee the detection of modified files and may
1076    /// result in a modified file not causing a call cache entry to be
1077    /// invalidated.
1078    ///
1079    /// However, it is substantially faster than using a strong digest.
1080    #[default]
1081    Weak,
1082}
1083
1084/// Represents the maximum number of retries for tasks.
1085#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)]
1086#[schemars(rename_all = "lowercase")]
1087pub enum Retries {
1088    /// Use the default number of retries for task execution.
1089    #[default]
1090    Default,
1091    /// Use the specified number of retries for task execution.
1092    #[schemars(untagged)]
1093    Use(u64),
1094}
1095
1096impl From<u64> for Retries {
1097    fn from(value: u64) -> Self {
1098        Self::Use(value)
1099    }
1100}
1101
1102impl From<Retries> for u64 {
1103    fn from(value: Retries) -> Self {
1104        match value {
1105            Retries::Default => DEFAULT_TASK_REQUIREMENT_MAX_RETRIES,
1106            Retries::Use(value) => value,
1107        }
1108    }
1109}
1110
1111impl<'de> FromToml<'de> for Retries {
1112    fn from_toml(ctx: &mut toml_spanner::Context<'de>, item: &Item<'de>) -> Result<Self, Failed> {
1113        if let Some("default") = item.as_str() {
1114            return Ok(Self::Default);
1115        }
1116
1117        if let Some(n) = item.as_u64()
1118            && n < MAX_RETRIES
1119        {
1120            return Ok(Self::Use(n));
1121        }
1122
1123        Err(ctx.report_custom_error(
1124            format!("expected an integer less than {MAX_RETRIES} or `default` for retries"),
1125            item,
1126        ))
1127    }
1128}
1129
1130impl ToToml for Retries {
1131    fn to_toml<'a>(&'a self, _: &'a Arena) -> Result<Item<'a>, ToTomlError> {
1132        match self {
1133            Self::Default => Ok(Item::string("default")),
1134            Self::Use(n) => Ok(i64::try_from(*n)
1135                .map_err(|e| ToTomlError {
1136                    message: format!("invalid retries: {e}").into(),
1137                })?
1138                .into()),
1139        }
1140    }
1141}
1142
1143/// Represents task evaluation configuration.
1144#[derive(Debug, Clone, Toml, PartialEq, Eq, JsonSchema)]
1145#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
1146#[schemars(rename_all = "snake_case", deny_unknown_fields)]
1147pub struct TaskConfig {
1148    /// The default maximum number of retries to attempt if a task fails.
1149    ///
1150    /// A task's `max_retries` requirement will override this value.
1151    #[toml(default)]
1152    #[schemars(default)]
1153    pub retries: Retries,
1154    /// The default container to use if a container is not specified in a task's
1155    /// requirements.
1156    #[toml(default = String::from(default_task_container()))]
1157    #[schemars(default = "default_task_container")]
1158    pub container: String,
1159    /// The default shell to use for tasks.
1160    ///
1161    /// <div class="warning">
1162    /// Warning: the use of a shell other than `bash` may lead to tasks that may
1163    /// not be portable to other execution engines.
1164    ///
1165    /// The shell must support a `-c` option to run a specific script file (i.e.
1166    /// an evaluated task command).
1167    ///
1168    /// Note that this option affects all task commands, so every container that
1169    /// is used must contain the specified shell.
1170    ///
1171    /// If using this setting causes your tasks to fail, please do not file an
1172    /// issue. </div>
1173    #[toml(default = String::from(default_task_shell()))]
1174    #[schemars(default = "default_task_shell")]
1175    pub shell: String,
1176    /// The behavior when a task's `cpu` requirement cannot be met.
1177    #[toml(default)]
1178    #[schemars(default)]
1179    pub cpu_limit_behavior: TaskResourceLimitBehavior,
1180    /// The behavior when a task's `memory` requirement cannot be met.
1181    #[toml(default)]
1182    #[schemars(default)]
1183    pub memory_limit_behavior: TaskResourceLimitBehavior,
1184    /// The call cache directory to use for caching task execution results.
1185    ///
1186    /// Defaults to an operating system specific cache directory for the user.
1187    #[toml(default = String::from(cache_dir_sentinel()))]
1188    #[schemars(default = "cache_dir_sentinel")]
1189    pub cache_dir: String,
1190    /// The call caching mode to use for tasks.
1191    #[toml(default)]
1192    #[schemars(default)]
1193    pub cache: CallCachingMode,
1194    /// The content digest mode to use.
1195    ///
1196    /// Used as part of call caching.
1197    #[toml(default)]
1198    #[schemars(default)]
1199    pub digests: ContentDigestMode,
1200    /// Keys of task requirements to exclude from call cache checking.
1201    ///
1202    /// When specified, these requirement keys will be ignored when
1203    /// calculating cache keys and validating cache entries.
1204    ///
1205    /// This can be useful for requirements that may vary between runs
1206    /// but should not invalidate the cache (e.g., dynamic resource
1207    /// allocation).
1208    #[toml(default)]
1209    #[schemars(default)]
1210    pub excluded_cache_requirements: Vec<String>,
1211    /// Keys of task hints to exclude from call cache checking.
1212    ///
1213    /// When specified, these hint keys will be ignored when
1214    /// calculating cache keys and validating cache entries.
1215    ///
1216    /// This can be useful for hints that may vary between runs
1217    /// but should not invalidate the cache.
1218    #[toml(default)]
1219    #[schemars(default)]
1220    pub excluded_cache_hints: Vec<String>,
1221    /// Keys of task inputs to exclude from call cache checking.
1222    ///
1223    /// When specified, these input keys will be ignored when
1224    /// calculating cache keys and validating cache entries.
1225    ///
1226    /// This can be useful for inputs that may vary between runs
1227    /// but should not affect the task's output.
1228    #[toml(default)]
1229    #[schemars(default)]
1230    pub excluded_cache_inputs: Vec<String>,
1231}
1232
1233impl Default for TaskConfig {
1234    fn default() -> Self {
1235        Self {
1236            retries: Default::default(),
1237            container: default_task_container().into(),
1238            shell: default_task_shell().into(),
1239            cpu_limit_behavior: Default::default(),
1240            memory_limit_behavior: Default::default(),
1241            cache_dir: cache_dir_sentinel().into(),
1242            cache: Default::default(),
1243            digests: Default::default(),
1244            excluded_cache_requirements: Default::default(),
1245            excluded_cache_hints: Default::default(),
1246            excluded_cache_inputs: Default::default(),
1247        }
1248    }
1249}
1250
1251impl TaskConfig {
1252    /// Validates the task evaluation configuration.
1253    pub fn validate(&self) -> Result<()> {
1254        if let Retries::Use(value) = self.retries
1255            && value >= MAX_RETRIES
1256        {
1257            bail!("configuration value `task.retries` cannot exceed {MAX_RETRIES}");
1258        }
1259
1260        Ok(())
1261    }
1262
1263    /// Get the configured cache dir if it is set.
1264    pub fn cache_dir(&self) -> Option<PathBuf> {
1265        if self.cache_dir == cache_dir_sentinel() {
1266            None
1267        } else {
1268            Some(PathBuf::from(&self.cache_dir))
1269        }
1270    }
1271}
1272
1273/// The behavior when a task resource requirement, such as `cpu` or `memory`,
1274/// cannot be met.
1275#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Toml, JsonSchema)]
1276#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
1277#[schemars(rename_all = "snake_case", deny_unknown_fields)]
1278pub enum TaskResourceLimitBehavior {
1279    /// Try executing a task with the maximum amount of the resource available
1280    /// when the task's corresponding requirement cannot be met.
1281    TryWithMax,
1282    /// Do not execute a task if its corresponding requirement cannot be met.
1283    ///
1284    /// This is the default behavior.
1285    #[default]
1286    Deny,
1287}
1288
1289/// Represents supported task execution backends.
1290#[derive(Debug, Clone, Toml, PartialEq, Eq, JsonSchema)]
1291#[toml(Toml, rename_all = "snake_case", tag = "type")]
1292#[schemars(rename_all = "snake_case", tag = "type")]
1293pub enum BackendConfig {
1294    /// Use the local task execution backend.
1295    Local {
1296        /// The inner local backend configuration.
1297        #[toml(default, style = Header, flatten, with = flatten_any)]
1298        #[schemars(default, flatten)]
1299        config: LocalBackendConfig,
1300    },
1301    /// Use the Docker task execution backend.
1302    Docker {
1303        /// The inner Docker backend configuration.
1304        #[toml(default, style = Header, flatten, with = flatten_any)]
1305        #[schemars(default, flatten)]
1306        config: DockerBackendConfig,
1307    },
1308    /// Use the TES task execution backend.
1309    Tes {
1310        /// The inner TES backend configuration.
1311        #[toml(default, style = Header, flatten, with = flatten_any)]
1312        #[schemars(default, flatten)]
1313        config: TesBackendConfig,
1314    },
1315    /// Use the experimental LSF + Apptainer task execution backend.
1316    ///
1317    /// Requires enabling experimental features.
1318    LsfApptainer {
1319        /// The inner LSF Apptainer backend configuration.
1320        #[toml(default, style = Header, flatten, with = flatten_any)]
1321        #[schemars(default, flatten)]
1322        config: LsfApptainerBackendConfig,
1323    },
1324    /// Use the experimental Slurm + Apptainer task execution backend.
1325    ///
1326    /// Requires enabling experimental features.
1327    SlurmApptainer {
1328        /// The inner Slurm Apptainer backend configuration.
1329        #[toml(default, style = Header, flatten, with = flatten_any)]
1330        #[schemars(default, flatten)]
1331        config: SlurmApptainerBackendConfig,
1332    },
1333}
1334
1335impl Default for BackendConfig {
1336    fn default() -> Self {
1337        Self::Docker {
1338            config: Default::default(),
1339        }
1340    }
1341}
1342
1343impl BackendConfig {
1344    /// Validates the backend configuration.
1345    pub async fn validate(&self) -> Result<()> {
1346        match self {
1347            Self::Local { config } => config.validate(),
1348            Self::Docker { config } => config.validate(),
1349            Self::Tes { config } => config.validate(),
1350            Self::LsfApptainer { config } => config.validate().await,
1351            Self::SlurmApptainer { config } => config.validate().await,
1352        }
1353    }
1354
1355    /// Converts the backend configuration into a local backend configuration
1356    ///
1357    /// Returns `None` if the backend configuration is not local.
1358    pub fn as_local(&self) -> Option<&LocalBackendConfig> {
1359        match self {
1360            Self::Local { config } => Some(config),
1361            _ => None,
1362        }
1363    }
1364
1365    /// Converts the backend configuration into a Docker backend configuration
1366    ///
1367    /// Returns `None` if the backend configuration is not Docker.
1368    pub fn as_docker(&self) -> Option<&DockerBackendConfig> {
1369        match self {
1370            Self::Docker { config } => Some(config),
1371            _ => None,
1372        }
1373    }
1374
1375    /// Converts the backend configuration into a TES backend configuration
1376    ///
1377    /// Returns `None` if the backend configuration is not TES.
1378    pub fn as_tes(&self) -> Option<&TesBackendConfig> {
1379        match self {
1380            Self::Tes { config } => Some(config),
1381            _ => None,
1382        }
1383    }
1384
1385    /// Converts the backend configuration into a LSF Apptainer backend
1386    /// configuration
1387    ///
1388    /// Returns `None` if the backend configuration is not LSF Apptainer.
1389    pub fn as_lsf_apptainer(&self) -> Option<&LsfApptainerBackendConfig> {
1390        match self {
1391            Self::LsfApptainer { config } => Some(config),
1392            _ => None,
1393        }
1394    }
1395
1396    /// Converts the backend configuration into a Slurm Apptainer backend
1397    /// configuration
1398    ///
1399    /// Returns `None` if the backend configuration is not Slurm Apptainer.
1400    pub fn as_slurm_apptainer(&self) -> Option<&SlurmApptainerBackendConfig> {
1401        match self {
1402            Self::SlurmApptainer { config } => Some(config),
1403            _ => None,
1404        }
1405    }
1406
1407    /// Redacts the secrets contained in the backend configuration.
1408    pub fn redact(self) -> Self {
1409        match self {
1410            Self::Local { .. }
1411            | Self::Docker { .. }
1412            | Self::LsfApptainer { .. }
1413            | Self::SlurmApptainer { .. } => self,
1414            Self::Tes { config } => Self::Tes {
1415                config: config.redact(),
1416            },
1417        }
1418    }
1419}
1420
1421impl From<LocalBackendConfig> for BackendConfig {
1422    fn from(config: LocalBackendConfig) -> Self {
1423        Self::Local { config }
1424    }
1425}
1426
1427impl From<DockerBackendConfig> for BackendConfig {
1428    fn from(config: DockerBackendConfig) -> Self {
1429        Self::Docker { config }
1430    }
1431}
1432
1433impl From<TesBackendConfig> for BackendConfig {
1434    fn from(config: TesBackendConfig) -> Self {
1435        Self::Tes { config }
1436    }
1437}
1438
1439impl From<LsfApptainerBackendConfig> for BackendConfig {
1440    fn from(config: LsfApptainerBackendConfig) -> Self {
1441        Self::LsfApptainer { config }
1442    }
1443}
1444
1445impl From<SlurmApptainerBackendConfig> for BackendConfig {
1446    fn from(config: SlurmApptainerBackendConfig) -> Self {
1447        Self::SlurmApptainer { config }
1448    }
1449}
1450
1451/// Represents configuration for the local task execution backend.
1452///
1453/// <div class="warning">
1454/// Warning: the local task execution backend spawns processes on the host
1455/// directly without the use of a container; only use this backend on trusted
1456/// WDL. </div>
1457#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
1458#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
1459#[schemars(rename_all = "snake_case", deny_unknown_fields)]
1460pub struct LocalBackendConfig {
1461    /// Set the number of CPUs available for task execution.
1462    ///
1463    /// Defaults to the number of logical CPUs for the host.
1464    ///
1465    /// The value cannot be zero or exceed the host's number of CPUs.
1466    pub cpu: Option<u64>,
1467
1468    /// Set the total amount of memory for task execution as a unit string (e.g.
1469    /// `2 GiB`).
1470    ///
1471    /// Defaults to the total amount of memory for the host.
1472    ///
1473    /// The value cannot be zero or exceed the host's total amount of memory.
1474    pub memory: Option<String>,
1475}
1476
1477impl LocalBackendConfig {
1478    /// Validates the local task execution backend configuration.
1479    pub fn validate(&self) -> Result<()> {
1480        if let Some(cpu) = self.cpu {
1481            if cpu == 0 {
1482                bail!("local backend configuration value `cpu` cannot be zero");
1483            }
1484
1485            let total = SYSTEM.cpus().len() as u64;
1486            if cpu > total {
1487                bail!(
1488                    "local backend configuration value `cpu` cannot exceed the virtual CPUs \
1489                     available to the host ({total})"
1490                );
1491            }
1492        }
1493
1494        if let Some(memory) = &self.memory {
1495            let memory = convert_unit_string(memory).with_context(|| {
1496                format!("local backend configuration value `memory` has invalid value `{memory}`")
1497            })?;
1498
1499            if memory == 0 {
1500                bail!("local backend configuration value `memory` cannot be zero");
1501            }
1502
1503            let total = SYSTEM.total_memory();
1504            if memory > total {
1505                bail!(
1506                    "local backend configuration value `memory` cannot exceed the total memory of \
1507                     the host ({total} bytes)"
1508                );
1509            }
1510        }
1511
1512        Ok(())
1513    }
1514}
1515
1516/// The default value for the `cleanup` field in [`DockerBackendConfig`].
1517fn default_docker_cleanup() -> bool {
1518    true
1519}
1520
1521/// Represents configuration for the Docker backend.
1522#[derive(Debug, Clone, PartialEq, Eq, Toml, JsonSchema)]
1523#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
1524#[schemars(rename_all = "snake_case", deny_unknown_fields)]
1525pub struct DockerBackendConfig {
1526    /// Whether or not to remove a task's container after the task completes.
1527    ///
1528    /// Defaults to `true`.
1529    #[toml(default = default_docker_cleanup())]
1530    #[schemars(default = "default_docker_cleanup")]
1531    pub cleanup: bool,
1532}
1533
1534impl DockerBackendConfig {
1535    /// Validates the Docker backend configuration.
1536    pub fn validate(&self) -> Result<()> {
1537        Ok(())
1538    }
1539}
1540
1541impl Default for DockerBackendConfig {
1542    fn default() -> Self {
1543        Self {
1544            cleanup: default_docker_cleanup(),
1545        }
1546    }
1547}
1548
1549/// Represents HTTP basic authentication configuration.
1550#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
1551#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
1552#[schemars(rename_all = "snake_case", deny_unknown_fields)]
1553pub struct BasicAuthConfig {
1554    /// The HTTP basic authentication username.
1555    pub username: String,
1556    /// The HTTP basic authentication password.
1557    pub password: SecretString,
1558}
1559
1560impl BasicAuthConfig {
1561    /// Validates the HTTP basic auth configuration.
1562    pub fn validate(&self) -> Result<()> {
1563        Ok(())
1564    }
1565
1566    /// Redacts the secrets contained in the HTTP basic auth configuration.
1567    pub fn redact(mut self) -> Self {
1568        self.password = self.password.redact();
1569        self
1570    }
1571}
1572
1573/// Represents HTTP bearer token authentication configuration.
1574#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
1575#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
1576#[schemars(rename_all = "snake_case", deny_unknown_fields)]
1577pub struct BearerAuthConfig {
1578    /// The HTTP bearer authentication token.
1579    pub token: SecretString,
1580}
1581
1582impl BearerAuthConfig {
1583    /// Validates the HTTP bearer auth configuration.
1584    pub fn validate(&self) -> Result<()> {
1585        Ok(())
1586    }
1587
1588    /// Redacts the secrets contained in the HTTP bearer auth configuration.
1589    pub fn redact(mut self) -> Self {
1590        self.token = self.token.redact();
1591        self
1592    }
1593}
1594
1595/// Represents the kind of authentication for a TES backend.
1596#[derive(Debug, Clone, PartialEq, Eq, Toml, JsonSchema)]
1597#[toml(Toml, rename_all = "snake_case", tag = "type")]
1598#[schemars(rename_all = "snake_case", tag = "type")]
1599pub enum TesBackendAuthConfig {
1600    /// Use basic authentication for the TES backend.
1601    Basic {
1602        /// The inner basic auth configuration.
1603        #[toml(default, style = Header, flatten, with = flatten_any)]
1604        #[schemars(default, flatten)]
1605        config: BasicAuthConfig,
1606    },
1607    /// Use bearer token authentication for the TES backend.
1608    Bearer {
1609        /// The inner bearer auth configuration.
1610        #[toml(default, style = Header, flatten, with = flatten_any)]
1611        #[schemars(default, flatten)]
1612        config: BearerAuthConfig,
1613    },
1614}
1615
1616impl TesBackendAuthConfig {
1617    /// Validates the TES backend authentication configuration.
1618    pub fn validate(&self) -> Result<()> {
1619        match self {
1620            Self::Basic { config } => config.validate(),
1621            Self::Bearer { config } => config.validate(),
1622        }
1623    }
1624
1625    /// Redacts the secrets contained in the TES backend authentication
1626    /// configuration.
1627    pub fn redact(self) -> Self {
1628        match self {
1629            Self::Basic { config } => Self::Basic {
1630                config: config.redact(),
1631            },
1632            Self::Bearer { config } => Self::Bearer {
1633                config: config.redact(),
1634            },
1635        }
1636    }
1637}
1638
1639impl From<BasicAuthConfig> for TesBackendAuthConfig {
1640    fn from(config: BasicAuthConfig) -> Self {
1641        Self::Basic { config }
1642    }
1643}
1644
1645impl From<BearerAuthConfig> for TesBackendAuthConfig {
1646    fn from(config: BearerAuthConfig) -> Self {
1647        Self::Bearer { config }
1648    }
1649}
1650
1651/// Represents configuration for the Task Execution Service (TES) backend.
1652#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
1653#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
1654#[schemars(rename_all = "snake_case", deny_unknown_fields)]
1655pub struct TesBackendConfig {
1656    /// The URL of the Task Execution Service.
1657    #[toml(FromToml with = parse_string, ToToml with = display)]
1658    pub url: Option<Url>,
1659
1660    /// The authentication configuration for the TES backend.
1661    #[toml(style = Header)]
1662    pub auth: Option<TesBackendAuthConfig>,
1663
1664    /// The root cloud storage URL for storing inputs.
1665    #[toml(FromToml with = parse_string, ToToml with = display)]
1666    pub inputs: Option<Url>,
1667
1668    /// The root cloud storage URL for storing outputs.
1669    #[toml(FromToml with = parse_string, ToToml with = display)]
1670    pub outputs: Option<Url>,
1671
1672    /// The polling interval, in seconds, for checking task status.
1673    ///
1674    /// Defaults to 1 second.
1675    pub interval: Option<u64>,
1676
1677    /// The number of retries after encountering an error communicating with the
1678    /// TES server.
1679    ///
1680    /// Defaults to no retries.
1681    pub retries: Option<u32>,
1682
1683    /// The maximum number of concurrent requests the backend will send to the
1684    /// TES server.
1685    ///
1686    /// Defaults to 10 concurrent requests.
1687    pub max_concurrency: Option<u32>,
1688
1689    /// Whether or not the TES server URL may use an insecure protocol like
1690    /// HTTP.
1691    #[toml(default)]
1692    #[schemars(default)]
1693    pub insecure: bool,
1694}
1695
1696impl TesBackendConfig {
1697    /// Validates the TES backend configuration.
1698    pub fn validate(&self) -> Result<()> {
1699        match &self.url {
1700            Some(url) => {
1701                if !self.insecure && url.scheme() != "https" {
1702                    bail!(
1703                        "TES backend configuration value `url` has invalid value `{url}`: URL \
1704                         must use a HTTPS scheme"
1705                    );
1706                }
1707            }
1708            None => bail!("TES backend configuration value `url` is required"),
1709        }
1710
1711        if let Some(auth) = &self.auth {
1712            auth.validate()?;
1713        }
1714
1715        if let Some(max_concurrency) = self.max_concurrency
1716            && max_concurrency == 0
1717        {
1718            bail!("TES backend configuration value `max_concurrency` cannot be zero");
1719        }
1720
1721        match &self.inputs {
1722            Some(url) => {
1723                if !is_supported_url(url.as_str()) {
1724                    bail!(
1725                        "TES backend storage configuration value `inputs` has invalid value \
1726                         `{url}`: URL scheme is not supported"
1727                    );
1728                }
1729
1730                if !url.path().ends_with('/') {
1731                    bail!(
1732                        "TES backend storage configuration value `inputs` has invalid value \
1733                         `{url}`: URL path must end with a slash"
1734                    );
1735                }
1736            }
1737            None => bail!("TES backend configuration value `inputs` is required"),
1738        }
1739
1740        match &self.outputs {
1741            Some(url) => {
1742                if !is_supported_url(url.as_str()) {
1743                    bail!(
1744                        "TES backend storage configuration value `outputs` has invalid value \
1745                         `{url}`: URL scheme is not supported"
1746                    );
1747                }
1748
1749                if !url.path().ends_with('/') {
1750                    bail!(
1751                        "TES backend storage configuration value `outputs` has invalid value \
1752                         `{url}`: URL path must end with a slash"
1753                    );
1754                }
1755            }
1756            None => bail!("TES backend storage configuration value `outputs` is required"),
1757        }
1758
1759        Ok(())
1760    }
1761
1762    /// Redacts the secrets contained in the TES backend configuration.
1763    pub fn redact(mut self) -> Self {
1764        if let Some(auth) = self.auth.take() {
1765            self.auth = Some(auth.redact());
1766        }
1767
1768        self
1769    }
1770}
1771
1772/// Configuration for the Apptainer container runtime.
1773#[derive(Debug, Clone, PartialEq, Eq, Toml, JsonSchema)]
1774#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
1775#[schemars(rename_all = "snake_case", deny_unknown_fields)]
1776pub struct ApptainerConfig {
1777    /// Path to the Apptainer (or Singularity) executable.
1778    ///
1779    /// Defaults to `"apptainer"`. Set to `"singularity"` or a full path
1780    /// (e.g., `/usr/local/bin/apptainer`) if the executable is not on `PATH`
1781    /// or if using Singularity instead.
1782    #[toml(default = String::from(default_apptainer_executable()))]
1783    #[schemars(default = "default_apptainer_executable")]
1784    pub executable: String,
1785
1786    /// Path to a shared directory for caching pulled `.sif` images.
1787    ///
1788    /// When set, pulled images are stored in this directory and shared
1789    /// across runs. When unset, images are stored in a per-run directory
1790    /// that is not shared.
1791    pub image_cache_dir: Option<PathBuf>,
1792
1793    /// Additional command-line arguments to pass to `apptainer exec` when
1794    /// executing tasks.
1795    #[toml(default)]
1796    #[schemars(default)]
1797    pub extra_args: Vec<String>,
1798}
1799
1800impl Default for ApptainerConfig {
1801    fn default() -> Self {
1802        Self {
1803            executable: default_apptainer_executable().into(),
1804            image_cache_dir: None,
1805            extra_args: Default::default(),
1806        }
1807    }
1808}
1809
1810impl ApptainerConfig {
1811    /// Validate that Apptainer is appropriately configured.
1812    pub async fn validate(&self) -> Result<(), anyhow::Error> {
1813        Ok(())
1814    }
1815}
1816
1817/// Represents a condition in a conditional argument.
1818///
1819/// The expression is evaluated in a context where a task's computed `cpu`,
1820/// `memory`, `gpu`, `fpga`, and `disks` values and evaluated `hint` object are
1821/// available as variables.
1822///
1823/// The expression is type checked during configuration deserialization to
1824/// ensure it is a valid WDL expression of type `Boolean`.
1825#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
1826#[schemars(with = "String")]
1827pub struct Condition {
1828    /// The raw WDL expression string.
1829    pub raw: String,
1830    /// The parsed and validated conditional expression.
1831    expr: GreenNode,
1832}
1833
1834impl Condition {
1835    /// Constructs a new `Condition` given a raw WDL expression string.
1836    pub fn new(raw: impl Into<String>) -> Result<Self, Vec<Diagnostic>> {
1837        /// Type evaluation context used for resolving the type of conditional
1838        /// expressions.
1839        #[derive(Default)]
1840        struct Context(Diagnostics);
1841
1842        impl wdl_analysis::types::v1::EvaluationContext for Context {
1843            fn version(&self) -> SupportedVersion {
1844                Default::default()
1845            }
1846
1847            fn resolve_name(&mut self, name: &str, span: Span) -> Option<Type> {
1848                match name {
1849                    "cpu" => Some(PrimitiveType::Float.into()),
1850                    "memory" => Some(PrimitiveType::Integer.into()),
1851                    "gpu" | "fpga" => Some(PrimitiveType::Boolean.into()),
1852                    "disks" => Some(PrimitiveType::Integer.into()),
1853                    "hint" => Some(Type::Object),
1854                    _ => {
1855                        self.add_diagnostic(unknown_name(name, span));
1856                        None
1857                    }
1858                }
1859            }
1860
1861            fn resolve_type_name(&mut self, name: &str, span: Span) -> Result<Type, Diagnostic> {
1862                Err(unknown_type(name, span))
1863            }
1864
1865            fn task(&self) -> Option<&Task> {
1866                None
1867            }
1868
1869            fn diagnostics_config(&self) -> DiagnosticsConfig {
1870                Default::default()
1871            }
1872
1873            fn add_diagnostic(&mut self, diagnostic: Diagnostic) {
1874                self.0.add(diagnostic);
1875            }
1876
1877            fn exceptable_add_diagnostic<N: TreeNode + Exceptable>(
1878                &mut self,
1879                diagnostic: Diagnostic,
1880                element: &N,
1881                exceptable_nodes: &Option<&'static [SyntaxKind]>,
1882            ) {
1883                self.0.exceptable_add(diagnostic, element, exceptable_nodes);
1884            }
1885        }
1886
1887        let raw = raw.into();
1888        let mut parser = Parser::new(Lexer::new(&raw));
1889        let marker = parser.start();
1890        match v1::expr(&mut parser, marker) {
1891            Ok(()) => {
1892                if let Some((_, span)) = parser.next() {
1893                    return Err(vec![
1894                        Diagnostic::error("expected a single WDL expression")
1895                            .with_label("extraneous WDL source starts here", span),
1896                    ]);
1897                }
1898
1899                let output = parser.finish();
1900                if !output.diagnostics.is_empty() {
1901                    return Err(output.diagnostics);
1902                }
1903
1904                let expr = Expr::cast(construct_tree(raw.as_ref(), output.events))
1905                    .expect("node should cast");
1906
1907                // Determine the type of the expression
1908                let mut context = Context::default();
1909                let ty = ExprTypeEvaluator::new(&mut context)
1910                    .evaluate_expr(&expr)
1911                    .unwrap_or(Type::Union);
1912
1913                if !context.0.is_empty() {
1914                    return Err(context.0.into());
1915                }
1916
1917                match ty {
1918                    Type::Primitive(PrimitiveType::Boolean, false) | Type::Union => {}
1919                    _ => {
1920                        return Err(vec![
1921                            Diagnostic::error(format!(
1922                                "conditional expression is expected to be type `Boolean`, but \
1923                                 found type `{ty}`",
1924                            ))
1925                            .with_highlight(expr.span()),
1926                        ]);
1927                    }
1928                }
1929
1930                Ok(Self {
1931                    raw,
1932                    expr: expr.inner().green().into_owned(),
1933                })
1934            }
1935            Err((marker, diagnostic)) => {
1936                marker.abandon(&mut parser);
1937                Err(vec![diagnostic.into()])
1938            }
1939        }
1940    }
1941
1942    /// Evaluates the condition's expression for the given execution request and
1943    /// returns the result.
1944    ///
1945    /// Returns an error if the evaluation resulted in an error.
1946    pub(crate) async fn evaluate(
1947        &self,
1948        request: &ExecuteTaskRequest<'_>,
1949        transferer: &dyn Transferer,
1950    ) -> Result<bool> {
1951        /// Helper that implements `EvaluationContext`.
1952        struct Context<'a> {
1953            /// The task execution request.
1954            request: &'a ExecuteTaskRequest<'a>,
1955            /// The file transferer for evaluation.
1956            transferer: &'a dyn Transferer,
1957        }
1958
1959        impl EvaluationContext for Context<'_> {
1960            fn version(&self) -> SupportedVersion {
1961                Default::default()
1962            }
1963
1964            fn resolve_name(&self, name: &str, span: Span) -> Result<Value, Diagnostic> {
1965                match name {
1966                    "cpu" => Ok(self.request.constraints.cpu.into()),
1967                    "memory" => Ok((self.request.constraints.memory as i64).into()),
1968                    "gpu" => Ok((!self.request.constraints.gpu.is_empty()).into()),
1969                    "fpga" => Ok((!self.request.constraints.fpga.is_empty()).into()),
1970                    "disks" => Ok(self
1971                        .request
1972                        .constraints
1973                        .disks
1974                        .iter()
1975                        .map(|(_, s)| *s)
1976                        .sum::<i64>()
1977                        .into()),
1978                    "hint" => Ok(self.request.hints.clone().into()),
1979                    _ => Err(unknown_name(name, span)),
1980                }
1981            }
1982
1983            fn resolve_type_name(&self, name: &str, span: Span) -> Result<Type, Diagnostic> {
1984                Err(unknown_type(name, span))
1985            }
1986
1987            fn enum_choice_value(
1988                &self,
1989                enum_name: &str,
1990                choice_name: &str,
1991            ) -> Result<Value, Diagnostic> {
1992                Err(unknown_enum_choice(enum_name, choice_name))
1993            }
1994
1995            fn base_dir(&self) -> &EvaluationPath {
1996                self.request.base_dir
1997            }
1998
1999            fn temp_dir(&self) -> &Path {
2000                self.request.temp_dir
2001            }
2002
2003            fn transferer(&self) -> &dyn Transferer {
2004                self.transferer
2005            }
2006
2007            fn object_access(&self, object: &Object, name: &str) -> Option<Value> {
2008                // If the object being accessed is not the hint object, let the access proceed
2009                // normally
2010                if !Arc::ptr_eq(&object.members, &self.request.hints.members) {
2011                    return None;
2012                }
2013
2014                // Access to the hints object first checks for a hint override in the inputs and
2015                // then falls back to the task's hints; if the name is not present in either, a
2016                // `None` value is returned instead of an error
2017                Some(
2018                    self.request
2019                        .inputs
2020                        .hint(name)
2021                        .or_else(|| object.get(name))
2022                        .cloned()
2023                        .unwrap_or_else(|| NoneValue::untyped().into()),
2024                )
2025            }
2026        }
2027
2028        /// Helper for evaluating the given expression.
2029        ///
2030        /// Returns a diagnostic that will be converted to any `anyhow::Error`
2031        /// by the caller.
2032        async fn eval(context: Context<'_>, expr: &Expr<SyntaxNode>) -> Result<bool, Diagnostic> {
2033            let mut evaluator = ExprEvaluator::new(context);
2034            let value = evaluator.evaluate_expr(expr).await?;
2035            match value.as_boolean() {
2036                Some(res) => Ok(res),
2037                None => Err(Diagnostic::error(format!(
2038                    "conditional expression is expected to be type `Boolean`, but found type \
2039                     `{ty}`",
2040                    ty = value.ty()
2041                ))
2042                .with_highlight(expr.span())),
2043            }
2044        }
2045
2046        let expr = Expr::cast(self.expr.clone().into()).expect("should be an expression node");
2047        match eval(
2048            Context {
2049                request,
2050                transferer,
2051            },
2052            &expr,
2053        )
2054        .await
2055        {
2056            Ok(res) => Ok(res),
2057            Err(diagnostic) => {
2058                let file: SimpleFile<_, _> = SimpleFile::new("<condition>", &self.raw);
2059                let mut buffer = Buffer::no_color();
2060                term::emit_to_write_style(
2061                    &mut buffer,
2062                    &Default::default(),
2063                    &file,
2064                    &diagnostic.to_codespan(()),
2065                )
2066                .context("failed to write diagnostic to buffer")?;
2067                let diagnostic = String::from_utf8(buffer.into_inner())
2068                    .context("diagnostic buffer contents are not UTF-8")?;
2069                bail!(
2070                    "failed to evaluate backend dynamic arguments condition: {diagnostic}",
2071                    diagnostic = diagnostic
2072                        .strip_prefix("error: ")
2073                        .unwrap_or(&diagnostic)
2074                        .trim()
2075                );
2076            }
2077        }
2078    }
2079}
2080
2081impl ToToml for Condition {
2082    fn to_toml<'a>(&'a self, arena: &'a Arena) -> Result<Item<'a>, ToTomlError> {
2083        self.raw.to_toml(arena)
2084    }
2085}
2086
2087impl<'de> FromToml<'de> for Condition {
2088    fn from_toml(ctx: &mut toml_spanner::Context<'de>, item: &Item<'de>) -> Result<Self, Failed> {
2089        /// Used to remap an unescaped string index to an index in the
2090        /// corresponding escaped TOML string.
2091        fn remap(mapping: &[(usize, usize)], unescaped: usize) -> usize {
2092            match mapping.binary_search_by_key(&unescaped, |x| x.0) {
2093                Ok(i) => {
2094                    // Found in the map, use the escaped index
2095                    mapping[i].1
2096                }
2097                Err(i) => {
2098                    // Not in the map, need to potentially offset the unescaped position
2099                    // based on a preceding map entry
2100                    unescaped
2101                        + if i == 0 {
2102                            // No need to offset as this position comes before any
2103                            // escape sequences
2104                            0
2105                        } else {
2106                            // Offset by the last delta
2107                            mapping[i - 1].1 - mapping[i - 1].0
2108                        }
2109                }
2110            }
2111        }
2112
2113        /// Helper for pushing diagnostics as `toml_spanner::Error` into the
2114        /// TOML parsing context.
2115        fn push_errors(
2116            ctx: &mut toml_spanner::Context<'_>,
2117            item: &Item<'_>,
2118            diagnostics: Vec<Diagnostic>,
2119        ) -> Failed {
2120            for diagnostic in diagnostics {
2121                let span = if let Some(label) = diagnostic.labels().next() {
2122                    let label_span = label.span();
2123                    let span = item.span();
2124
2125                    let source = &ctx.source()[span.start as usize..span.end as usize];
2126                    let offset = if source.starts_with(r#"""""#) | source.starts_with("'''") {
2127                        3
2128                    } else {
2129                        1
2130                    };
2131
2132                    let mapping = escape_mapping(
2133                        source
2134                            .get(offset..(source.len() - offset))
2135                            .expect("invalid TOML string"),
2136                    );
2137
2138                    // Remap the start and end of the label
2139                    let label_start = remap(&mapping, label_span.start());
2140                    let label_end = remap(&mapping, label_span.end());
2141
2142                    toml_spanner::Span::new(
2143                        span.start + offset as u32 + label_start as u32,
2144                        span.start + offset as u32 + label_end as u32,
2145                    )
2146                } else {
2147                    item.span()
2148                };
2149
2150                ctx.errors
2151                    .push(toml_spanner::Error::custom(diagnostic.message(), span));
2152            }
2153
2154            Failed
2155        }
2156
2157        Self::new(String::from_toml(ctx, item)?).map_err(|diags| push_errors(ctx, item, diags))
2158    }
2159}
2160
2161/// Represents a set of conditional arguments for the LSF and Slurm backends.
2162///
2163/// Conditional arguments are passed to the program responsible for queuing a
2164/// task when the associated conditional expression evaluates to `true`.
2165#[derive(Debug, Clone, PartialEq, Eq, Toml, JsonSchema)]
2166#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
2167#[schemars(rename_all = "snake_case", deny_unknown_fields)]
2168pub struct ConditionalArgs {
2169    /// The condition for including the arguments.
2170    pub condition: Condition,
2171    /// The arguments to use when the condition evaluates to `true`.
2172    #[toml(default)]
2173    #[schemars(default)]
2174    pub args: Vec<String>,
2175}
2176
2177impl ConditionalArgs {
2178    /// Validates the conditional arguments.
2179    ///
2180    /// This ensures that the conditional expression is valid WDL and the
2181    /// specified arguments are not empty.
2182    pub fn validate(&self) -> Result<()> {
2183        if self.args.is_empty() {
2184            bail!("backend conditional arguments must have at least one argument specified");
2185        }
2186
2187        Ok(())
2188    }
2189}
2190
2191/// Represents additional arguments to the Slurm and LSF backends.
2192///
2193/// These arguments are passed to the executable responsible for queuing a task.
2194#[derive(Debug, Clone, Default, PartialEq, Eq, Toml, JsonSchema)]
2195#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
2196#[schemars(rename_all = "snake_case", deny_unknown_fields)]
2197pub struct AdditionalArgs {
2198    /// The additional arguments to pass to the backend program.
2199    #[toml(default)]
2200    #[schemars(default)]
2201    pub args: Vec<String>,
2202    /// The conditional arguments to pass to the backend program.
2203    ///
2204    /// The first conditional argument with an associated conditional expression
2205    /// that evaluates to `true` will be passed to the backend program.
2206    #[toml(default)]
2207    #[schemars(default)]
2208    pub conditional: Vec<ConditionalArgs>,
2209}
2210
2211impl AdditionalArgs {
2212    /// Validates the additional arguments.
2213    pub fn validate(&self) -> Result<()> {
2214        for arg in &self.conditional {
2215            arg.validate()?;
2216        }
2217
2218        Ok(())
2219    }
2220}
2221
2222/// Helper functions for `ByteSize` TOML serialization.
2223mod byte_size {
2224    use bytesize::ByteSize;
2225    use toml_spanner::Context;
2226    use toml_spanner::Failed;
2227    use toml_spanner::Item;
2228
2229    /// Helper function for serializing `ByteSize` to TOML.
2230    pub fn from_toml<'de>(ctx: &mut Context<'de>, item: &Item<'de>) -> Result<ByteSize, Failed> {
2231        if let Some(s) = item.as_u64() {
2232            return Ok(ByteSize(s));
2233        }
2234
2235        if let Some(s) = item.as_str() {
2236            return s
2237                .parse()
2238                .map_err(|e| ctx.report_custom_error(format!("invalid byte size: {e}"), item));
2239        }
2240
2241        Err(ctx.report_expected_but_found(&"integer or string", item))
2242    }
2243}
2244
2245/// Configuration for an LSF queue.
2246///
2247/// Each queue can optionally have per-task CPU and memory limits set so that
2248/// tasks which are too large to be scheduled on that queue will fail
2249/// immediately instead of pending indefinitely. In the future, these limits may
2250/// be populated or validated by live information from the cluster, but
2251/// for now they must be manually based on the user's understanding of the
2252/// cluster configuration.
2253#[derive(Debug, Clone, PartialEq, Eq, Toml, JsonSchema)]
2254#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
2255#[schemars(rename_all = "snake_case", deny_unknown_fields)]
2256pub struct LsfQueueConfig {
2257    /// The name of the queue; this is the string passed to `bsub -q
2258    /// <queue_name>`.
2259    pub name: String,
2260    /// The maximum number of CPUs this queue can provision for a single task.
2261    pub max_cpu_per_task: Option<u64>,
2262    /// The maximum memory this queue can provision for a single task.
2263    #[toml(FromToml with = byte_size, ToToml with = display)]
2264    #[schemars(with = "Option<u64>")]
2265    pub max_memory_per_task: Option<ByteSize>,
2266}
2267
2268impl LsfQueueConfig {
2269    /// Validate that this LSF queue exists according to the local `bqueues`.
2270    pub async fn validate(&self, name: &str) -> Result<(), anyhow::Error> {
2271        let queue = &self.name;
2272        ensure!(!queue.is_empty(), "{name}_lsf_queue name cannot be empty");
2273        if let Some(max_cpu_per_task) = self.max_cpu_per_task {
2274            ensure!(
2275                max_cpu_per_task > 0,
2276                "{name}_lsf_queue `{queue}` must allow at least 1 CPU to be provisioned"
2277            );
2278        }
2279        if let Some(max_memory_per_task) = self.max_memory_per_task {
2280            ensure!(
2281                max_memory_per_task.as_u64() > 0,
2282                "{name}_lsf_queue `{queue}` must allow at least some memory to be provisioned"
2283            );
2284        }
2285        match tokio::time::timeout(
2286            // 10 seconds is rather arbitrary; `bqueues` ordinarily returns extremely quickly, but
2287            // we don't want things to run away on a misconfigured system
2288            std::time::Duration::from_secs(10),
2289            Command::new("bqueues").arg(queue).output(),
2290        )
2291        .await
2292        {
2293            Ok(output) => {
2294                let output = output.context("validating LSF queue")?;
2295                if !output.status.success() {
2296                    let stdout = String::from_utf8_lossy(&output.stdout);
2297                    let stderr = String::from_utf8_lossy(&output.stderr);
2298                    error!(%stdout, %stderr, %queue, "failed to validate {name}_lsf_queue");
2299                    Err(anyhow!("failed to validate {name}_lsf_queue `{queue}`"))
2300                } else {
2301                    Ok(())
2302                }
2303            }
2304            Err(_) => Err(anyhow!(
2305                "timed out trying to validate {name}_lsf_queue `{queue}`"
2306            )),
2307        }
2308    }
2309}
2310
2311/// Configuration for the LSF + Apptainer backend.
2312// TODO ACF 2025-09-23: add a Apptainer/Singularity mode config that switches around executable
2313// name, env var names, etc.
2314#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
2315#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
2316#[schemars(rename_all = "snake_case", deny_unknown_fields)]
2317pub struct LsfApptainerBackendConfig {
2318    /// The task monitor polling interval, in seconds.
2319    ///
2320    /// Defaults to 30 seconds.
2321    pub interval: Option<u64>,
2322    /// The maximum number of concurrent LSF operations the backend will
2323    /// perform.
2324    ///
2325    /// This controls the maximum concurrent number of `bsub` processes the
2326    /// backend will spawn to queue tasks.
2327    ///
2328    /// Defaults to 10 concurrent operations.
2329    pub max_concurrency: Option<u32>,
2330    /// Which queue, if any, to specify when submitting normal jobs to LSF.
2331    ///
2332    /// This may be superseded by
2333    /// [`short_task_lsf_queue`][Self::short_task_lsf_queue],
2334    /// [`gpu_lsf_queue`][Self::gpu_lsf_queue], or
2335    /// [`fpga_lsf_queue`][Self::fpga_lsf_queue] for corresponding tasks.
2336    #[toml(style = Header)]
2337    pub default_lsf_queue: Option<LsfQueueConfig>,
2338    /// Which queue, if any, to specify when submitting [short
2339    /// tasks](https://github.com/openwdl/wdl/blob/wdl-1.2/SPEC.md#short_task) to LSF.
2340    ///
2341    /// This may be superseded by [`gpu_lsf_queue`][Self::gpu_lsf_queue] or
2342    /// [`fpga_lsf_queue`][Self::fpga_lsf_queue] for tasks which require
2343    /// specialized hardware.
2344    #[toml(style = Header)]
2345    pub short_task_lsf_queue: Option<LsfQueueConfig>,
2346    /// Which queue, if any, to specify when submitting [tasks which require a
2347    /// GPU](https://github.com/openwdl/wdl/blob/wdl-1.2/SPEC.md#hardware-accelerators-gpu-and--fpga)
2348    /// to LSF.
2349    #[toml(style = Header)]
2350    pub gpu_lsf_queue: Option<LsfQueueConfig>,
2351    /// Which queue, if any, to specify when submitting [tasks which require an
2352    /// FPGA](https://github.com/openwdl/wdl/blob/wdl-1.2/SPEC.md#hardware-accelerators-gpu-and--fpga)
2353    /// to LSF.
2354    #[toml(style = Header)]
2355    pub fpga_lsf_queue: Option<LsfQueueConfig>,
2356    /// Prefix to add to every LSF job name before the task identifier. This is
2357    /// truncated as needed to satisfy the byte-oriented LSF job name limit.
2358    pub job_name_prefix: Option<String>,
2359    /// The additional arguments to `bsub` used to queue a new task.
2360    #[toml(default)]
2361    #[schemars(default)]
2362    pub bsub: AdditionalArgs,
2363    /// The configuration of Apptainer, which is used as the container runtime
2364    /// on the compute nodes where LSF dispatches tasks.
2365    ///
2366    /// Note that this will likely be replaced by an abstraction over multiple
2367    /// container execution runtimes in the future, rather than being
2368    /// hardcoded to Apptainer.
2369    #[toml(default)]
2370    #[schemars(default)]
2371    pub apptainer: ApptainerConfig,
2372}
2373
2374impl LsfApptainerBackendConfig {
2375    /// Validate that the backend is appropriately configured.
2376    pub async fn validate(&self) -> Result<(), anyhow::Error> {
2377        if cfg!(not(unix)) {
2378            bail!("LSF + Apptainer backend is not supported on non-unix platforms");
2379        }
2380
2381        // Do what we can to validate options that are dependent on the dynamic
2382        // environment. These are a bit fraught, particularly if the behavior of
2383        // the external tools changes based on where a job gets dispatched, but
2384        // querying from the perspective of the current node allows
2385        // us to get better error messages in circumstances typical to a cluster.
2386        if let Some(queue) = &self.default_lsf_queue {
2387            queue.validate("default").await?;
2388        }
2389
2390        if let Some(queue) = &self.short_task_lsf_queue {
2391            queue.validate("short_task").await?;
2392        }
2393
2394        if let Some(queue) = &self.gpu_lsf_queue {
2395            queue.validate("gpu").await?;
2396        }
2397
2398        if let Some(queue) = &self.fpga_lsf_queue {
2399            queue.validate("fpga").await?;
2400        }
2401
2402        if let Some(prefix) = &self.job_name_prefix
2403            && prefix.len() > MAX_LSF_JOB_NAME_PREFIX
2404        {
2405            bail!(
2406                "LSF job name prefix `{prefix}` exceeds the maximum {MAX_LSF_JOB_NAME_PREFIX} \
2407                 bytes"
2408            );
2409        }
2410
2411        // Validate the additional arguments
2412        self.bsub.validate()?;
2413
2414        // Validate the apptainer configuration
2415        self.apptainer.validate().await?;
2416
2417        Ok(())
2418    }
2419
2420    /// Get the appropriate LSF queue for a task under this configuration.
2421    ///
2422    /// Specialized hardware requirements are prioritized over other
2423    /// characteristics, with FPGA taking precedence over GPU.
2424    pub(crate) fn lsf_queue_for_task(
2425        &self,
2426        requirements: &Object,
2427        hints: &Object,
2428    ) -> Option<&LsfQueueConfig> {
2429        // Specialized hardware gets priority.
2430        if let Some(queue) = self.fpga_lsf_queue.as_ref()
2431            && let Some(true) = requirements
2432                .get(wdl_ast::v1::TASK_REQUIREMENT_FPGA)
2433                .and_then(Value::as_boolean)
2434        {
2435            return Some(queue);
2436        }
2437
2438        if let Some(queue) = self.gpu_lsf_queue.as_ref()
2439            && let Some(true) = requirements
2440                .get(wdl_ast::v1::TASK_REQUIREMENT_GPU)
2441                .and_then(Value::as_boolean)
2442        {
2443            return Some(queue);
2444        }
2445
2446        // Then short tasks.
2447        if let Some(queue) = self.short_task_lsf_queue.as_ref()
2448            && let Some(true) = hints
2449                .get(wdl_ast::v1::TASK_HINT_SHORT_TASK)
2450                .and_then(Value::as_boolean)
2451        {
2452            return Some(queue);
2453        }
2454
2455        // Finally the default queue. If this is `None`, `bsub` gets run without a queue
2456        // argument and the cluster's default is used.
2457        self.default_lsf_queue.as_ref()
2458    }
2459}
2460
2461/// Configuration for a Slurm partition.
2462///
2463/// Each partition can optionally have per-task CPU and memory limits set so
2464/// that tasks which are too large to be scheduled on that partition will fail
2465/// immediately instead of pending indefinitely. In the future, these limits may
2466/// be populated or validated by live information from the cluster, but
2467/// for now they must be manually based on the user's understanding of the
2468/// cluster configuration.
2469#[derive(Debug, Clone, PartialEq, Eq, Toml, JsonSchema)]
2470#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
2471#[schemars(rename_all = "snake_case", deny_unknown_fields)]
2472pub struct SlurmPartitionConfig {
2473    /// The name of the partition; this is the string passed to `sbatch
2474    /// --partition=<partition_name>`.
2475    pub name: String,
2476    /// The maximum number of CPUs this partition can provision for a single
2477    /// task.
2478    pub max_cpu_per_task: Option<u64>,
2479    /// The maximum memory this partition can provision for a single task.
2480    #[toml(FromToml with = byte_size, ToToml with = display)]
2481    #[schemars(with = "Option<u64>")]
2482    pub max_memory_per_task: Option<ByteSize>,
2483}
2484
2485impl SlurmPartitionConfig {
2486    /// Validate that this Slurm partition exists according to the local
2487    /// `sinfo`.
2488    pub async fn validate(&self, name: &str) -> Result<(), anyhow::Error> {
2489        let partition = &self.name;
2490        ensure!(
2491            !partition.is_empty(),
2492            "{name}_slurm_partition name cannot be empty"
2493        );
2494        if let Some(max_cpu_per_task) = self.max_cpu_per_task {
2495            ensure!(
2496                max_cpu_per_task > 0,
2497                "{name}_slurm_partition `{partition}` must allow at least 1 CPU to be provisioned"
2498            );
2499        }
2500        if let Some(max_memory_per_task) = self.max_memory_per_task {
2501            ensure!(
2502                max_memory_per_task.as_u64() > 0,
2503                "{name}_slurm_partition `{partition}` must allow at least some memory to be \
2504                 provisioned"
2505            );
2506        }
2507        match tokio::time::timeout(
2508            // 10 seconds is rather arbitrary; `scontrol` ordinarily returns extremely quickly, but
2509            // we don't want things to run away on a misconfigured system
2510            std::time::Duration::from_secs(10),
2511            Command::new("scontrol")
2512                .arg("show")
2513                .arg("partition")
2514                .arg(partition)
2515                .output(),
2516        )
2517        .await
2518        {
2519            Ok(output) => {
2520                let output = output.context("validating Slurm partition")?;
2521                if !output.status.success() {
2522                    let stdout = String::from_utf8_lossy(&output.stdout);
2523                    let stderr = String::from_utf8_lossy(&output.stderr);
2524                    error!(%stdout, %stderr, %partition, "failed to validate {name}_slurm_partition");
2525                    Err(anyhow!(
2526                        "failed to validate {name}_slurm_partition `{partition}`"
2527                    ))
2528                } else {
2529                    Ok(())
2530                }
2531            }
2532            Err(_) => Err(anyhow!(
2533                "timed out trying to validate {name}_slurm_partition `{partition}`"
2534            )),
2535        }
2536    }
2537}
2538
2539/// Configuration for the Slurm + Apptainer backend.
2540// TODO ACF 2025-09-23: add a Apptainer/Singularity mode config that switches around executable
2541// name, env var names, etc.
2542#[derive(Debug, Default, Clone, PartialEq, Eq, Toml, JsonSchema)]
2543#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
2544#[schemars(rename_all = "snake_case", deny_unknown_fields)]
2545pub struct SlurmApptainerBackendConfig {
2546    /// The task monitor polling interval, in seconds.
2547    ///
2548    /// Defaults to 30 seconds.
2549    pub interval: Option<u64>,
2550    /// The maximum number of concurrent Slurm operations the backend will
2551    /// perform.
2552    ///
2553    /// This controls the maximum concurrent number of `sbatch` processes the
2554    /// backend will spawn to queue tasks.
2555    ///
2556    /// Defaults to 10 concurrent operations.
2557    pub max_concurrency: Option<u32>,
2558    /// Which partition, if any, to specify when submitting normal jobs to
2559    /// Slurm.
2560    ///
2561    /// This may be superseded by
2562    /// [`short_task_slurm_partition`][Self::short_task_slurm_partition],
2563    /// [`gpu_slurm_partition`][Self::gpu_slurm_partition], or
2564    /// [`fpga_slurm_partition`][Self::fpga_slurm_partition] for corresponding
2565    /// tasks.
2566    #[toml(style = Header)]
2567    pub default_slurm_partition: Option<SlurmPartitionConfig>,
2568    /// Which partition, if any, to specify when submitting [short
2569    /// tasks](https://github.com/openwdl/wdl/blob/wdl-1.2/SPEC.md#short_task) to Slurm.
2570    ///
2571    /// This may be superseded by
2572    /// [`gpu_slurm_partition`][Self::gpu_slurm_partition] or
2573    /// [`fpga_slurm_partition`][Self::fpga_slurm_partition] for tasks which
2574    /// require specialized hardware.
2575    #[toml(style = Header)]
2576    pub short_task_slurm_partition: Option<SlurmPartitionConfig>,
2577    /// Which partition, if any, to specify when submitting [tasks which require
2578    /// a GPU](https://github.com/openwdl/wdl/blob/wdl-1.2/SPEC.md#hardware-accelerators-gpu-and--fpga)
2579    /// to Slurm.
2580    #[toml(style = Header)]
2581    pub gpu_slurm_partition: Option<SlurmPartitionConfig>,
2582    /// Which partition, if any, to specify when submitting [tasks which require
2583    /// an FPGA](https://github.com/openwdl/wdl/blob/wdl-1.2/SPEC.md#hardware-accelerators-gpu-and--fpga)
2584    /// to Slurm.
2585    #[toml(style = Header)]
2586    pub fpga_slurm_partition: Option<SlurmPartitionConfig>,
2587    /// The additional arguments to `sbatch` used to queue a new task.
2588    #[toml(default)]
2589    #[schemars(default)]
2590    pub sbatch: AdditionalArgs,
2591    /// Prefix to add to every Slurm job name before the task identifier.
2592    pub job_name_prefix: Option<String>,
2593    /// The configuration of Apptainer, which is used as the container runtime
2594    /// on the compute nodes where Slurm dispatches tasks.
2595    ///
2596    /// Note that this will likely be replaced by an abstraction over multiple
2597    /// container execution runtimes in the future, rather than being
2598    /// hardcoded to Apptainer.
2599    #[toml(default)]
2600    #[schemars(default)]
2601    pub apptainer: ApptainerConfig,
2602}
2603
2604impl SlurmApptainerBackendConfig {
2605    /// Validate that the backend is appropriately configured.
2606    pub async fn validate(&self) -> Result<(), anyhow::Error> {
2607        if cfg!(not(unix)) {
2608            bail!("Slurm + Apptainer backend is not supported on non-unix platforms");
2609        }
2610
2611        // Do what we can to validate options that are dependent on the dynamic
2612        // environment. These are a bit fraught, particularly if the behavior of
2613        // the external tools changes based on where a job gets dispatched, but
2614        // querying from the perspective of the current node allows
2615        // us to get better error messages in circumstances typical to a cluster.
2616        if let Some(partition) = &self.default_slurm_partition {
2617            partition.validate("default").await?;
2618        }
2619        if let Some(partition) = &self.short_task_slurm_partition {
2620            partition.validate("short_task").await?;
2621        }
2622        if let Some(partition) = &self.gpu_slurm_partition {
2623            partition.validate("gpu").await?;
2624        }
2625        if let Some(partition) = &self.fpga_slurm_partition {
2626            partition.validate("fpga").await?;
2627        }
2628
2629        // Validate the additional arguments
2630        self.sbatch.validate()?;
2631
2632        // Validate the apptainer configuration
2633        self.apptainer.validate().await?;
2634
2635        Ok(())
2636    }
2637
2638    /// Get the appropriate Slurm partition for a task under this configuration.
2639    ///
2640    /// Specialized hardware requirements are prioritized over other
2641    /// characteristics, with FPGA taking precedence over GPU.
2642    pub(crate) fn slurm_partition_for_task(
2643        &self,
2644        requirements: &Object,
2645        hints: &Object,
2646    ) -> Option<&SlurmPartitionConfig> {
2647        // TODO ACF 2025-09-26: what's the relationship between this code and
2648        // `TaskExecutionConstraints`? Should this be there instead, or be pulling
2649        // values from that instead of directly from `requirements` and `hints`?
2650
2651        // Specialized hardware gets priority.
2652        if let Some(partition) = self.fpga_slurm_partition.as_ref()
2653            && let Some(true) = requirements
2654                .get(wdl_ast::v1::TASK_REQUIREMENT_FPGA)
2655                .and_then(Value::as_boolean)
2656        {
2657            return Some(partition);
2658        }
2659
2660        if let Some(partition) = self.gpu_slurm_partition.as_ref()
2661            && let Some(true) = requirements
2662                .get(wdl_ast::v1::TASK_REQUIREMENT_GPU)
2663                .and_then(Value::as_boolean)
2664        {
2665            return Some(partition);
2666        }
2667
2668        // Then short tasks.
2669        if let Some(partition) = self.short_task_slurm_partition.as_ref()
2670            && let Some(true) = hints
2671                .get(wdl_ast::v1::TASK_HINT_SHORT_TASK)
2672                .and_then(Value::as_boolean)
2673        {
2674            return Some(partition);
2675        }
2676
2677        // Finally the default partition. If this is `None`, `sbatch` gets run without a
2678        // partition argument and the cluster's default is used.
2679        self.default_slurm_partition.as_ref()
2680    }
2681}
2682
2683/// Represents an error encountered during merging TOML configuration.
2684#[derive(Debug, thiserror::Error)]
2685pub enum BuilderMergeError {
2686    /// An error occurred while serializing merged configuration.
2687    #[error("failed to serialize after merging configuration")]
2688    Serialize(#[from] ToTomlError),
2689    /// An error occurred while attempting to parse merged configuration.
2690    #[error("failed to parse after merging configuration")]
2691    Parse {
2692        /// The merged source.
2693        source: String,
2694        /// The error that was encountered.
2695        #[source]
2696        error: toml_spanner::Error,
2697    },
2698    /// An error occurred while deserializing merged configuration.
2699    #[error("failed to deserialize after merging configuration")]
2700    Deserialize {
2701        /// The merged source.
2702        source: String,
2703        /// The error that was encountered.
2704        #[source]
2705        error: toml_spanner::FromTomlError,
2706    },
2707}
2708
2709/// Helper for displaying certain builder error messages.
2710struct BuilderErrorDisplay<'a>(&'static str, &'a Option<PathBuf>);
2711
2712impl fmt::Display for BuilderErrorDisplay<'_> {
2713    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2714        match self.1 {
2715            Some(path) => write!(
2716                f,
2717                "failed to {op} configuration file `{path}`",
2718                op = self.0,
2719                path = path.display()
2720            ),
2721            None => write!(
2722                f,
2723                "failed to {op} in-memory configuration string",
2724                op = self.0
2725            ),
2726        }
2727    }
2728}
2729
2730/// Represents an error encountered while building a configuration.
2731#[derive(Debug, thiserror::Error)]
2732pub enum BuilderError {
2733    /// Failed to read the provided configuration file.
2734    #[error("failed to read configuration file `{path}`")]
2735    Io {
2736        /// The path to the file.
2737        path: PathBuf,
2738        /// The error that was encountered.
2739        #[source]
2740        error: std::io::Error,
2741    },
2742    /// Failed to parse the provided configuration file.
2743    #[error("{}", BuilderErrorDisplay("parse", .path))]
2744    Parse {
2745        /// The path to the file.
2746        ///
2747        /// This is `None` when the source was a string.
2748        path: Option<PathBuf>,
2749        /// The TOML source that was parsed.
2750        source: String,
2751        /// The error that was encountered.
2752        #[source]
2753        error: toml_spanner::Error,
2754    },
2755    /// Failed to deserialize the provided configuration file.
2756    #[error("{}", BuilderErrorDisplay("deserialize", .path))]
2757    Deserialize {
2758        /// The path to the file.
2759        ///
2760        /// This is `None` when the source was a string.
2761        path: Option<PathBuf>,
2762        /// The TOML source that was parsed.
2763        source: String,
2764        /// The error that was encountered.
2765        #[source]
2766        error: toml_spanner::FromTomlError,
2767    },
2768    /// Failed to merge configuration.
2769    #[error(transparent)]
2770    Merge(#[from] BuilderMergeError),
2771}
2772
2773impl BuilderError {
2774    /// Gets the path associated with the error.
2775    ///
2776    /// If the error relates to parsing or deserializing an in-memory string,
2777    /// the path will be `<string>`.
2778    ///
2779    /// If the error relates to parsing or deserializing the merged
2780    /// configuration, the path will be `<merged>`.
2781    pub fn path(&self) -> impl fmt::Display + Clone {
2782        #[derive(Clone)]
2783        struct Helper<'a>(&'a BuilderError);
2784
2785        impl fmt::Display for Helper<'_> {
2786            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2787                match self.0 {
2788                    BuilderError::Io { path, .. }
2789                    | BuilderError::Parse {
2790                        path: Some(path), ..
2791                    }
2792                    | BuilderError::Deserialize {
2793                        path: Some(path), ..
2794                    } => path.display().fmt(f),
2795                    BuilderError::Parse { path: None, .. }
2796                    | BuilderError::Deserialize { path: None, .. } => write!(f, "<string>"),
2797                    BuilderError::Merge(_) => write!(f, "<merged>"),
2798                }
2799            }
2800        }
2801
2802        Helper(self)
2803    }
2804
2805    /// Gets the TOML error associated with the error.
2806    ///
2807    /// Returns `None` if the error is not associated with parsing or
2808    /// deserializing TOML.
2809    pub fn toml_error(&self) -> Option<&toml_spanner::Error> {
2810        match &self {
2811            Self::Parse { error, .. } | Self::Merge(BuilderMergeError::Parse { error, .. }) => {
2812                Some(error)
2813            }
2814            Self::Deserialize { error, .. }
2815            | Self::Merge(BuilderMergeError::Deserialize { error, .. }) => error.errors.first(),
2816            _ => None,
2817        }
2818    }
2819
2820    /// Gets the TOML source code associated with the error.
2821    ///
2822    /// Returns `None` if the error is not associated with parsing or
2823    /// deserializing TOML.
2824    pub fn source(&self) -> Option<&str> {
2825        match &self {
2826            Self::Parse { source, .. }
2827            | Self::Deserialize { source, .. }
2828            | Self::Merge(BuilderMergeError::Parse { source, .. })
2829            | Self::Merge(BuilderMergeError::Deserialize { source, .. }) => Some(source),
2830            _ => None,
2831        }
2832    }
2833
2834    /// Converts the error into a [`Diagnostic`].
2835    pub fn to_diagnostic(&self) -> Diagnostic {
2836        let mut diagnostic = Diagnostic::error(self.to_string());
2837
2838        if let Some(e) = self.toml_error() {
2839            for (span, text) in [e.primary_label(), e.secondary_label()]
2840                .into_iter()
2841                .flatten()
2842            {
2843                let span = Span::new(span.start as usize, (span.end - span.start) as usize);
2844                let text: &str = text.trim();
2845
2846                // For some reason label text isn't returned for certain errors
2847                let text = if text.is_empty() {
2848                    match e.kind() {
2849                        ErrorKind::UnexpectedEof => "unexpected end of file",
2850                        ErrorKind::RedefineAsArray { .. } => {
2851                            "a previously defined table was redefined as an array"
2852                        }
2853                        ErrorKind::FileTooLarge => "file is too large",
2854                        ErrorKind::Custom(message) => message,
2855                        _ => text,
2856                    }
2857                } else {
2858                    text
2859                };
2860
2861                if text.is_empty() {
2862                    diagnostic = diagnostic.with_highlight(span);
2863                } else {
2864                    diagnostic = diagnostic.with_label(text, span);
2865                }
2866            }
2867        }
2868
2869        if matches!(self, Self::Merge(_)) {
2870            diagnostic = diagnostic.with_help("reported line numbers reflect merged TOML source");
2871        }
2872
2873        diagnostic
2874    }
2875}
2876
2877/// Represents a possible source of configuration.
2878#[derive(Debug)]
2879enum Source {
2880    /// A configuration exists as a file on disk.
2881    Path(PathBuf),
2882    /// The configuration exists as a TOML string.
2883    String(String),
2884}
2885
2886/// Implements a configuration builder.
2887///
2888/// The builder supports merging multiple TOML configuration files together.
2889///
2890/// Merging works by the following:
2891///
2892/// * Matching table keys that are arrays get appended.
2893/// * Matching table keys that are tables are recursively merged.
2894/// * Otherwise, the value associated with the key is replaced by the
2895///   configuration being merged in.
2896///
2897/// The configuration builder is generic so that it can build any type that
2898/// supports TOML serialization.
2899#[derive(Default, Debug)]
2900pub struct ConfigBuilder<T> {
2901    /// The sources for building the configuration.
2902    sources: Vec<Source>,
2903    /// Phantom data to store the type parameter.
2904    _phantom: PhantomData<T>,
2905}
2906
2907impl<T> ConfigBuilder<T> {
2908    /// Adds a TOML string source to the builder.
2909    pub fn with_string_source(mut self, toml: impl Into<String>) -> Self {
2910        self.sources.push(Source::String(toml.into()));
2911        self
2912    }
2913
2914    /// Adds a TOML configuration file source to the builder.
2915    pub fn with_file_source(mut self, path: impl Into<PathBuf>) -> Self {
2916        self.sources.push(Source::Path(path.into()));
2917        self
2918    }
2919
2920    /// Attempts to build the configuration.
2921    ///
2922    /// Each configuration file is merged with the previous one in the order
2923    /// they were added to the builder.
2924    pub fn try_build(self) -> Result<T, BuilderError>
2925    where
2926        T: ToToml + for<'de> FromToml<'de>,
2927    {
2928        // Read all of the files up front so that the source outlives the arena
2929        let sources: Vec<(Option<PathBuf>, String)> = self
2930            .sources
2931            .into_iter()
2932            .map(|s| match s {
2933                Source::Path(path) => {
2934                    let source = fs::read_to_string(&path).map_err(|e| BuilderError::Io {
2935                        path: path.clone(),
2936                        error: e,
2937                    })?;
2938
2939                    Ok((Some(path), source))
2940                }
2941                Source::String(source) => Ok((None, source)),
2942            })
2943            .collect::<Result<_, BuilderError>>()?;
2944
2945        // Parse all of the documents
2946        let arena = Arena::new();
2947        let documents = sources
2948            .iter()
2949            .map(|(path, source)| {
2950                toml_spanner::parse(source, &arena).map_err(|e| BuilderError::Parse {
2951                    path: path.clone(),
2952                    source: source.clone(),
2953                    error: e,
2954                })
2955            })
2956            .collect::<Result<Vec<_>, _>>()?;
2957
2958        // Merge the documents as TOML tables
2959        let mut merged_table: Table<'_> = Table::new();
2960        for (index, mut document) in documents.into_iter().enumerate() {
2961            // Start by deserializing the document to ensure it is a valid standalone
2962            // configuration
2963            document.to::<T>().map_err(|e| {
2964                let (path, source) = &sources[index];
2965                BuilderError::Deserialize {
2966                    path: path.clone(),
2967                    source: source.clone(),
2968                    error: e,
2969                }
2970            })?;
2971
2972            // Merge the tables
2973            Self::merge_tables(document.into_table(), &mut merged_table, &arena);
2974        }
2975
2976        // Unfortunately, there's no way to go from `Table` to `T`, so we must
2977        // round-trip through a string; serialize the merged table back to a
2978        // string first
2979        let source =
2980            toml_spanner::to_string(&merged_table).map_err(BuilderMergeError::Serialize)?;
2981
2982        // Deserialize the merged contents back to the underlying config type
2983        Ok(toml_spanner::parse(&source, &arena)
2984            .map_err(|e| BuilderMergeError::Parse {
2985                source: source.clone(),
2986                error: e,
2987            })?
2988            .to()
2989            .map_err(|e| BuilderMergeError::Deserialize {
2990                source: source.clone(),
2991                error: e,
2992            })?)
2993    }
2994
2995    /// Merges the `src` table with the `dest` table.
2996    ///
2997    /// If a key in `src` exists in `dest` and both items are tables, the tables
2998    /// are recursively merged together.
2999    ///
3000    /// If a key in `src` exists in `dest` and both items are arrays, the array
3001    /// in `dest` is extended with the elements of the array in `src`.
3002    ///
3003    /// Otherwise, the item in the `src` table replaces the item in the `dest`
3004    /// table.
3005    fn merge_tables<'de>(src: Table<'de>, dest: &mut Table<'de>, arena: &'de Arena) {
3006        for (key, src_item) in src {
3007            let Some(dest_item) = dest.get_mut(key.name) else {
3008                dest.insert(key, src_item, arena);
3009                continue;
3010            };
3011
3012            // Merge arrays by appending
3013            if let Some(src_array) = src_item.as_array()
3014                && let Some(dest_array) = dest_item.as_array_mut()
3015            {
3016                for element in src_array {
3017                    dest_array.push(element.clone_in(arena), arena);
3018                }
3019                continue;
3020            }
3021
3022            // Merge tables by recursing
3023            if let Some(_) = src_item.as_table()
3024                && let Some(dest_table) = dest_item.as_table_mut()
3025            {
3026                Self::merge_tables(src_item.into_table().unwrap(), dest_table, arena);
3027                continue;
3028            }
3029
3030            // Overwrite the item
3031            dest.insert(key, src_item, arena);
3032        }
3033    }
3034}
3035
3036#[cfg(test)]
3037mod test {
3038    use std::collections::HashMap;
3039    use std::io::Write;
3040
3041    use codespan_reporting::files::SimpleFile;
3042    use codespan_reporting::term::DisplayStyle;
3043    use codespan_reporting::term::emit_into_string;
3044    use codespan_reporting::term::{self};
3045    use futures::future::BoxFuture;
3046    use pretty_assertions::assert_eq;
3047    use tempfile::TempPath;
3048    use tempfile::tempdir;
3049
3050    use super::*;
3051    use crate::ONE_GIBIBYTE;
3052    use crate::TaskInputs;
3053    use crate::backend::TaskExecutionConstraints;
3054    use crate::http::Location;
3055    use crate::v1::DEFAULT_TASK_REQUIREMENT_CPU;
3056    use crate::v1::DEFAULT_TASK_REQUIREMENT_DISKS;
3057    use crate::v1::DEFAULT_TASK_REQUIREMENT_MEMORY;
3058
3059    #[test]
3060    fn redacted_secret() {
3061        let mut map: HashMap<_, SecretString> = HashMap::new();
3062        map.insert(
3063            "foo",
3064            SecretString {
3065                inner: "secret".into(),
3066                redacted: false,
3067            },
3068        );
3069
3070        assert_eq!(
3071            toml_spanner::to_string(&map).unwrap().trim(),
3072            format!(r#"foo = "secret""#)
3073        );
3074
3075        map.insert(
3076            "foo",
3077            SecretString {
3078                inner: "secret".into(),
3079                redacted: true,
3080            },
3081        );
3082        assert_eq!(
3083            toml_spanner::to_string(&map).unwrap().trim(),
3084            format!(r#"foo = "{REDACTED}""#)
3085        );
3086    }
3087
3088    #[test]
3089    fn redacted_config() {
3090        let config = Config {
3091            backends: [
3092                (
3093                    "first".to_string(),
3094                    TesBackendConfig {
3095                        auth: Some(TesBackendAuthConfig::Basic {
3096                            config: BasicAuthConfig {
3097                                username: "foo".into(),
3098                                password: "secret".into(),
3099                            },
3100                        }),
3101                        ..Default::default()
3102                    }
3103                    .into(),
3104                ),
3105                (
3106                    "second".to_string(),
3107                    TesBackendConfig {
3108                        auth: Some(
3109                            BearerAuthConfig {
3110                                token: "secret".into(),
3111                            }
3112                            .into(),
3113                        ),
3114                        ..Default::default()
3115                    }
3116                    .into(),
3117                ),
3118            ]
3119            .into(),
3120            storage: StorageConfig {
3121                azure: AzureStorageConfig {
3122                    auth: Some(AzureStorageAuthConfig {
3123                        account_name: "foo".into(),
3124                        access_key: "secret".into(),
3125                    }),
3126                },
3127                s3: S3StorageConfig {
3128                    auth: Some(S3StorageAuthConfig {
3129                        access_key_id: "foo".into(),
3130                        secret_access_key: "secret".into(),
3131                    }),
3132                    ..Default::default()
3133                },
3134                google: GoogleStorageConfig {
3135                    auth: Some(GoogleStorageAuthConfig {
3136                        access_key: "foo".into(),
3137                        secret: "secret".into(),
3138                    }),
3139                },
3140            },
3141            ..Default::default()
3142        };
3143
3144        let toml = toml_spanner::to_string(&config).unwrap();
3145        assert!(toml.contains("secret"), "`{toml}` contains a secret");
3146    }
3147
3148    #[tokio::test]
3149    async fn test_config_validate() {
3150        // Test invalid task config
3151        let mut config = Config::default();
3152        config.task.retries = 255.into();
3153        assert_eq!(
3154            config.validate().await.unwrap_err().to_string(),
3155            "configuration value `task.retries` cannot exceed 100"
3156        );
3157
3158        // Test invalid scatter concurrency config
3159        let mut config = Config::default();
3160        config.workflow.scatter.concurrency = 0;
3161        assert_eq!(
3162            config.validate().await.unwrap_err().to_string(),
3163            "configuration value `workflow.scatter.concurrency` cannot be zero"
3164        );
3165
3166        // Test invalid backend name
3167        let config = Config {
3168            backend: "foo".into(),
3169            ..Default::default()
3170        };
3171        assert_eq!(
3172            config.validate().await.unwrap_err().to_string(),
3173            "a backend named `foo` is not present in the configuration"
3174        );
3175        let config = Config {
3176            backend: "bar".into(),
3177            backends: [("foo".to_string(), BackendConfig::default())].into(),
3178            ..Default::default()
3179        };
3180        assert_eq!(
3181            config.validate().await.unwrap_err().to_string(),
3182            "a backend named `bar` is not present in the configuration"
3183        );
3184
3185        // Test a singular backend
3186        let config = Config {
3187            backend: "foo".to_string(),
3188            backends: [("foo".to_string(), BackendConfig::default())].into(),
3189            ..Default::default()
3190        };
3191        config.validate().await.expect("config should validate");
3192
3193        // Test invalid local backend cpu config
3194        let config = Config {
3195            backends: [(
3196                "default".to_string(),
3197                LocalBackendConfig {
3198                    cpu: Some(0),
3199                    ..Default::default()
3200                }
3201                .into(),
3202            )]
3203            .into(),
3204            ..Default::default()
3205        };
3206        assert_eq!(
3207            config.validate().await.unwrap_err().to_string(),
3208            "local backend configuration value `cpu` cannot be zero"
3209        );
3210        let config = Config {
3211            backends: [(
3212                "default".to_string(),
3213                LocalBackendConfig {
3214                    cpu: Some(10000000),
3215                    ..Default::default()
3216                }
3217                .into(),
3218            )]
3219            .into(),
3220            ..Default::default()
3221        };
3222        assert!(
3223            config
3224                .validate()
3225                .await
3226                .unwrap_err()
3227                .to_string()
3228                .starts_with(
3229                    "local backend configuration value `cpu` cannot exceed the virtual CPUs \
3230                     available to the host"
3231                )
3232        );
3233
3234        // Test invalid local backend memory config
3235        let config = Config {
3236            backends: [(
3237                "default".to_string(),
3238                LocalBackendConfig {
3239                    memory: Some("0 GiB".to_string()),
3240                    ..Default::default()
3241                }
3242                .into(),
3243            )]
3244            .into(),
3245            ..Default::default()
3246        };
3247        assert_eq!(
3248            config.validate().await.unwrap_err().to_string(),
3249            "local backend configuration value `memory` cannot be zero"
3250        );
3251        let config = Config {
3252            backends: [(
3253                "default".to_string(),
3254                LocalBackendConfig {
3255                    memory: Some("100 meows".to_string()),
3256                    ..Default::default()
3257                }
3258                .into(),
3259            )]
3260            .into(),
3261            ..Default::default()
3262        };
3263        assert_eq!(
3264            config.validate().await.unwrap_err().to_string(),
3265            "local backend configuration value `memory` has invalid value `100 meows`"
3266        );
3267
3268        let config = Config {
3269            backends: [(
3270                "default".to_string(),
3271                LocalBackendConfig {
3272                    memory: Some("1000 TiB".to_string()),
3273                    ..Default::default()
3274                }
3275                .into(),
3276            )]
3277            .into(),
3278            ..Default::default()
3279        };
3280        assert!(
3281            config
3282                .validate()
3283                .await
3284                .unwrap_err()
3285                .to_string()
3286                .starts_with(
3287                    "local backend configuration value `memory` cannot exceed the total memory of \
3288                     the host"
3289                )
3290        );
3291
3292        // Test missing TES URL
3293        let config = Config {
3294            backends: [("default".to_string(), TesBackendConfig::default().into())].into(),
3295            ..Default::default()
3296        };
3297        assert_eq!(
3298            config.validate().await.unwrap_err().to_string(),
3299            "TES backend configuration value `url` is required"
3300        );
3301
3302        // Test TES invalid max concurrency
3303        let config = Config {
3304            backends: [(
3305                "default".to_string(),
3306                TesBackendConfig {
3307                    url: Some("https://example.com".parse().unwrap()),
3308                    max_concurrency: Some(0),
3309                    ..Default::default()
3310                }
3311                .into(),
3312            )]
3313            .into(),
3314            ..Default::default()
3315        };
3316        assert_eq!(
3317            config.validate().await.unwrap_err().to_string(),
3318            "TES backend configuration value `max_concurrency` cannot be zero"
3319        );
3320
3321        // Insecure TES URL
3322        let config = Config {
3323            backends: [(
3324                "default".to_string(),
3325                TesBackendConfig {
3326                    url: Some("http://example.com".parse().unwrap()),
3327                    inputs: Some("http://example.com".parse().unwrap()),
3328                    outputs: Some("http://example.com".parse().unwrap()),
3329                    ..Default::default()
3330                }
3331                .into(),
3332            )]
3333            .into(),
3334            ..Default::default()
3335        };
3336        assert_eq!(
3337            config.validate().await.unwrap_err().to_string(),
3338            "TES backend configuration value `url` has invalid value `http://example.com/`: URL \
3339             must use a HTTPS scheme"
3340        );
3341
3342        // Allow insecure URL
3343        let config = Config {
3344            backends: [(
3345                "default".to_string(),
3346                TesBackendConfig {
3347                    url: Some("http://example.com".parse().unwrap()),
3348                    inputs: Some("http://example.com".parse().unwrap()),
3349                    outputs: Some("http://example.com".parse().unwrap()),
3350                    insecure: true,
3351                    ..Default::default()
3352                }
3353                .into(),
3354            )]
3355            .into(),
3356            ..Default::default()
3357        };
3358        config
3359            .validate()
3360            .await
3361            .expect("configuration should validate");
3362
3363        // invalid Parallelism
3364        let mut config = Config::default();
3365        config.http.parallelism = 0.into();
3366        assert_eq!(
3367            config.validate().await.unwrap_err().to_string(),
3368            "configuration value `http.parallelism` cannot be zero"
3369        );
3370
3371        // valid Parallelism
3372        let mut config = Config::default();
3373        config.http.parallelism = 5.into();
3374        assert!(
3375            config.validate().await.is_ok(),
3376            "should pass for valid configuration"
3377        );
3378        let mut config = Config::default();
3379        config.http.parallelism = Parallelism::default();
3380        assert!(config.validate().await.is_ok(), "should pass for default");
3381
3382        // Test invalid LSF job name prefix
3383        #[cfg(unix)]
3384        {
3385            let job_name_prefix = "A".repeat(MAX_LSF_JOB_NAME_PREFIX * 2);
3386            let mut config = Config {
3387                experimental_features_enabled: true,
3388                ..Default::default()
3389            };
3390            config.backends.insert(
3391                "default".to_string(),
3392                LsfApptainerBackendConfig {
3393                    job_name_prefix: Some(job_name_prefix.clone()),
3394                    ..Default::default()
3395                }
3396                .into(),
3397            );
3398            assert_eq!(
3399                config.validate().await.unwrap_err().to_string(),
3400                format!("LSF job name prefix `{job_name_prefix}` exceeds the maximum 100 bytes")
3401            );
3402        }
3403    }
3404
3405    fn create_temp_file(contents: &str) -> TempPath {
3406        let mut file: tempfile::NamedTempFile =
3407            tempfile::NamedTempFile::new().expect("failed to create temporary file");
3408        file.write_all(contents.as_bytes())
3409            .expect("failed to write temporary file");
3410        file.into_temp_path()
3411    }
3412
3413    #[test]
3414    fn it_builds_with_no_sources() {
3415        let config = Config::builder().try_build().expect("should build");
3416        assert_eq!(config, Config::default(), "should be equal");
3417    }
3418
3419    #[test]
3420    fn it_builds_with_one_source() {
3421        let path = create_temp_file("backend = 'foo'");
3422
3423        let config = Config::builder()
3424            .with_file_source(&path)
3425            .try_build()
3426            .expect("should build");
3427        assert_eq!(
3428            config,
3429            Config {
3430                backend: "foo".into(),
3431                ..Default::default()
3432            },
3433            "should be equal"
3434        );
3435    }
3436
3437    #[test]
3438    fn it_errors_on_invalid_parse() {
3439        let path = create_temp_file("invalid");
3440
3441        let e = Config::builder()
3442            .with_file_source(&path)
3443            .try_build()
3444            .expect_err("should fail");
3445
3446        let source = e.source().expect("should have source");
3447
3448        let diagnostic = e.to_diagnostic();
3449        let error = emit_into_string(
3450            &term::Config {
3451                display_style: DisplayStyle::Rich,
3452                ..Default::default()
3453            },
3454            &SimpleFile::new(e.path(), source),
3455            &diagnostic.to_codespan(()),
3456        )
3457        .expect("should emit");
3458
3459        assert!(
3460            error.contains("expected an equals"),
3461            "the error `{error}` does not contain the expected message"
3462        );
3463    }
3464
3465    #[test]
3466    fn it_errors_on_invalid_deserialization() {
3467        let path = create_temp_file("backend = 42");
3468
3469        let e = Config::builder()
3470            .with_file_source(&path)
3471            .try_build()
3472            .expect_err("should fail");
3473
3474        let source = e.source().expect("should have source");
3475
3476        let diagnostic = e.to_diagnostic();
3477        let error = emit_into_string(
3478            &term::Config {
3479                display_style: DisplayStyle::Rich,
3480                ..Default::default()
3481            },
3482            &SimpleFile::new(e.path(), source),
3483            &diagnostic.to_codespan(()),
3484        )
3485        .expect("should emit");
3486
3487        assert!(
3488            error.contains("expected a string"),
3489            "the error `{error}` does not contain the expected message"
3490        );
3491    }
3492
3493    #[test]
3494    fn it_merges_sources() {
3495        let first = create_temp_file(
3496            r#"
3497backend = 'foo'
3498
3499[task]
3500excluded_cache_inputs = ['1', '2', '3']
3501
3502[backends.foo]
3503type = 'local'
3504"#,
3505        );
3506
3507        let second = create_temp_file(
3508            r#"
3509[task]
3510excluded_cache_inputs = ['4', '5']
3511
3512[backends.bar]
3513type = 'docker'
3514"#,
3515        );
3516
3517        let third: TempPath = create_temp_file(
3518            r#"
3519backend = 'baz'
3520
3521[task]
3522excluded_cache_inputs = ['6', '7', '8']
3523
3524[backends.baz]
3525type = 'tes'
3526"#,
3527        );
3528
3529        let fourth = r#"
3530backend = 'qux'
3531
3532[task]
3533excluded_cache_inputs = ['9', '10']
3534
3535[backends.qux]
3536type = 'lsf_apptainer'
3537"#;
3538
3539        let config = Config::builder()
3540            .with_file_source(&first)
3541            .with_file_source(&second)
3542            .with_file_source(&third)
3543            .with_string_source(fourth)
3544            .try_build()
3545            .expect("should build");
3546
3547        assert_eq!(
3548            config,
3549            Config {
3550                backend: "qux".into(),
3551                task: TaskConfig {
3552                    excluded_cache_inputs: vec![
3553                        "1".to_string(),
3554                        "2".to_string(),
3555                        "3".to_string(),
3556                        "4".to_string(),
3557                        "5".to_string(),
3558                        "6".to_string(),
3559                        "7".to_string(),
3560                        "8".to_string(),
3561                        "9".to_string(),
3562                        "10".to_string(),
3563                    ],
3564                    ..Default::default()
3565                },
3566                backends: IndexMap::from_iter([
3567                    ("foo".to_string(), LocalBackendConfig::default().into()),
3568                    ("bar".to_string(), DockerBackendConfig::default().into()),
3569                    ("baz".to_string(), TesBackendConfig::default().into()),
3570                    (
3571                        "qux".to_string(),
3572                        LsfApptainerBackendConfig::default().into()
3573                    ),
3574                ]),
3575                ..Default::default()
3576            },
3577            "should be equal"
3578        );
3579    }
3580
3581    #[test]
3582    fn parallelism_serialization() {
3583        let map: HashMap<&str, Parallelism> =
3584            HashMap::from_iter([("value", Parallelism::Available)]);
3585        assert_eq!(
3586            toml_spanner::to_string(&map).unwrap(),
3587            format!("value = \"available\"\n")
3588        );
3589
3590        let map: HashMap<&str, Parallelism> =
3591            HashMap::from_iter([("value", Parallelism::Use(123))]);
3592        assert_eq!(toml_spanner::to_string(&map).unwrap(), "value = 123\n");
3593    }
3594
3595    #[test]
3596    fn parallelism_deserialization() {
3597        let map: HashMap<String, Parallelism> =
3598            toml_spanner::from_str("value = 'available'").unwrap();
3599        assert_eq!(map["value"], Parallelism::Available);
3600
3601        let map: HashMap<String, Parallelism> = toml_spanner::from_str("value = 123").unwrap();
3602        assert_eq!(map["value"], Parallelism::Use(123));
3603
3604        let expected_error =
3605            "expected a positive integer or `available` for parallelism at `value`";
3606
3607        let error =
3608            toml_spanner::from_str::<HashMap<String, Parallelism>>("value = 'wrong'").unwrap_err();
3609        assert_eq!(error.to_string(), expected_error);
3610
3611        let error =
3612            toml_spanner::from_str::<HashMap<String, Parallelism>>("value = 0").unwrap_err();
3613        assert_eq!(error.to_string(), expected_error);
3614
3615        let error =
3616            toml_spanner::from_str::<HashMap<String, Parallelism>>("value = -10").unwrap_err();
3617        assert_eq!(error.to_string(), expected_error);
3618    }
3619
3620    #[test]
3621    fn retries_serialization() {
3622        let map: HashMap<&str, Retries> = HashMap::from_iter([("value", Retries::Default)]);
3623        assert_eq!(
3624            toml_spanner::to_string(&map).unwrap(),
3625            format!("value = \"default\"\n")
3626        );
3627
3628        let map: HashMap<&str, Retries> = HashMap::from_iter([("value", Retries::Use(123))]);
3629        assert_eq!(toml_spanner::to_string(&map).unwrap(), "value = 123\n");
3630    }
3631
3632    #[test]
3633    fn retries_deserialization() {
3634        let map: HashMap<String, Retries> = toml_spanner::from_str("value = 'default'").unwrap();
3635        assert_eq!(map["value"], Retries::Default);
3636
3637        let map: HashMap<String, Retries> = toml_spanner::from_str("value = 12").unwrap();
3638        assert_eq!(map["value"], Retries::Use(12));
3639
3640        let map: HashMap<String, Retries> = toml_spanner::from_str("value = 0").unwrap();
3641        assert_eq!(map["value"], Retries::Use(0));
3642
3643        let expected_error = format!(
3644            "expected an integer less than {MAX_RETRIES} or `default` for retries at `value`"
3645        );
3646
3647        let error =
3648            toml_spanner::from_str::<HashMap<String, Retries>>("value = 'wrong'").unwrap_err();
3649        assert_eq!(error.to_string(), expected_error);
3650
3651        let error = toml_spanner::from_str::<HashMap<String, Retries>>("value = 101").unwrap_err();
3652        assert_eq!(error.to_string(), expected_error);
3653
3654        let error = toml_spanner::from_str::<HashMap<String, Retries>>("value = -10").unwrap_err();
3655        assert_eq!(error.to_string(), expected_error);
3656    }
3657
3658    #[test]
3659    fn mapping_escape_indexes() {
3660        // Check for empty string
3661        assert!(escape_mapping("").is_empty());
3662
3663        // Check a string with no escape sequences
3664        assert!(escape_mapping("hello world!").is_empty());
3665
3666        // Check for a string containing only an escape sequences (should contain an
3667        // exclusive-end mapping)
3668        assert_eq!(escape_mapping(r#"\"\""#), &[(1, 2), (2, 4)]);
3669        assert_eq!(escape_mapping(r#"\u0022\u0022"#), &[(1, 6), (2, 12)]);
3670        assert_eq!(
3671            escape_mapping(r#"\U00000022\U00000022"#),
3672            &[(1, 10), (2, 20)]
3673        );
3674
3675        // Check a complex string
3676        assert_eq!(
3677            escape_mapping(r#"\"foo\u0022 == \U00000022bar\" && \"\" == \"\n\""#),
3678            &[
3679                (1, 2),   // f
3680                (5, 11),  // <space>
3681                (10, 25), // b
3682                (14, 30), // <space>
3683                (19, 36), // \"
3684                (20, 38), // <space>
3685                (25, 44), // \n
3686                (26, 46), // \"
3687                (27, 48)  // <end of string>
3688            ]
3689        );
3690    }
3691
3692    #[test]
3693    fn conditional_args_serialization() {
3694        // Test for invalid type
3695        let error = toml_spanner::from_str::<ConditionalArgs>("condition = 1").unwrap_err();
3696        assert_eq!(
3697            error.to_string(),
3698            "expected a string, found integer at `condition`"
3699        );
3700
3701        // Test for not a single WDL expression
3702        let error =
3703            toml_spanner::from_str::<ConditionalArgs>(r#"condition = "foo bar""#).unwrap_err();
3704        assert_eq!(error.to_string(), "expected a single WDL expression");
3705
3706        // Test for parse error
3707        let error =
3708            toml_spanner::from_str::<ConditionalArgs>(r#"condition = "{ foo: }""#).unwrap_err();
3709        assert_eq!(error.to_string(), "expected expression, but found `}`");
3710
3711        // Test for not a `Boolean` expression
3712        let error = toml_spanner::from_str::<ConditionalArgs>(r#"condition = "1""#).unwrap_err();
3713        assert_eq!(
3714            error.to_string(),
3715            "conditional expression is expected to be type `Boolean`, but found type `Int`"
3716        );
3717        let error = toml_spanner::from_str::<ConditionalArgs>(r#"condition = "hint""#).unwrap_err();
3718        assert_eq!(
3719            error.to_string(),
3720            "conditional expression is expected to be type `Boolean`, but found type `Object`"
3721        );
3722
3723        // Test for unknown name
3724        let error = toml_spanner::from_str::<ConditionalArgs>(r#"condition = "foo""#).unwrap_err();
3725        assert_eq!(error.to_string(), "unknown name `foo`");
3726
3727        // Test for unknown type name
3728        let error =
3729            toml_spanner::from_str::<ConditionalArgs>(r#"condition = "Foo {}""#).unwrap_err();
3730        assert_eq!(error.to_string(), "unknown type name `Foo`");
3731
3732        // Test for valid conditions
3733        let args: ConditionalArgs = toml_spanner::from_str(r#"condition = "true""#).unwrap();
3734        assert_eq!(args.condition.raw, "true");
3735        assert_eq!(
3736            toml_spanner::to_string(&args).unwrap(),
3737            "condition = \"true\"\nargs = []\n"
3738        );
3739
3740        let args: ConditionalArgs =
3741            toml_spanner::from_str(r#"condition = "cpu == 1 && hint.bar == \"foo\"""#).unwrap();
3742        assert_eq!(args.condition.raw, r#"cpu == 1 && hint.bar == "foo""#);
3743        assert_eq!(
3744            toml_spanner::to_string(&args).unwrap(),
3745            "condition = 'cpu == 1 && hint.bar == \"foo\"'\nargs = []\n"
3746        );
3747    }
3748
3749    #[test]
3750    fn validate_conditional_args() {
3751        // Check for empty args
3752        let args: ConditionalArgs = toml_spanner::from_str(r#"condition = "true""#).unwrap();
3753        assert_eq!(
3754            args.validate().unwrap_err().to_string(),
3755            "backend conditional arguments must have at least one argument specified"
3756        );
3757    }
3758
3759    #[tokio::test]
3760    async fn evaluate_conditions() {
3761        /// Helper to represent the context for `Condition` evaluation.
3762        struct Context {
3763            cpu: f64,
3764            memory: u64,
3765            gpu: bool,
3766            fpga: bool,
3767            disks: i64,
3768            inputs: TaskInputs,
3769            hints: Object,
3770        }
3771
3772        impl Default for Context {
3773            fn default() -> Self {
3774                Self {
3775                    cpu: DEFAULT_TASK_REQUIREMENT_CPU,
3776                    memory: DEFAULT_TASK_REQUIREMENT_MEMORY as u64,
3777                    gpu: false,
3778                    fpga: false,
3779                    disks: (DEFAULT_TASK_REQUIREMENT_DISKS * ONE_GIBIBYTE) as i64,
3780                    inputs: Default::default(),
3781                    hints: Default::default(),
3782                }
3783            }
3784        }
3785
3786        struct Transferer;
3787
3788        impl crate::http::Transferer for Transferer {
3789            fn download<'a>(&'a self, _: &'a Url) -> BoxFuture<'a, Result<Location>> {
3790                unimplemented!()
3791            }
3792
3793            fn upload<'a>(&'a self, _: &'a Path, _: &'a Url) -> BoxFuture<'a, Result<()>> {
3794                unimplemented!()
3795            }
3796
3797            fn size<'a>(&'a self, _: &'a Url) -> BoxFuture<'a, anyhow::Result<Option<u64>>> {
3798                unimplemented!()
3799            }
3800
3801            fn walk<'a>(&'a self, _: &'a Url) -> BoxFuture<'a, Result<Arc<[String]>>> {
3802                unimplemented!()
3803            }
3804
3805            fn exists<'a>(&'a self, _: &'a Url) -> BoxFuture<'a, Result<bool>> {
3806                unimplemented!()
3807            }
3808
3809            fn digest<'a>(
3810                &'a self,
3811                _: &'a Url,
3812            ) -> BoxFuture<'a, Result<Option<Arc<cloud_copy::ContentDigest>>>> {
3813                unimplemented!()
3814            }
3815        }
3816
3817        /// Helper for evaluating `Condition` from a WDL expression string.
3818        ///
3819        /// The string is expected to be a valid WDL expression.
3820        async fn eval(context: Context, expression: &str) -> Result<bool> {
3821            let dir = tempdir().context("failed to create temporary directory")?;
3822            let condition = Condition::new(expression).expect("invalid expression");
3823            condition
3824                .evaluate(
3825                    &ExecuteTaskRequest {
3826                        id: "test",
3827                        command: "",
3828                        inputs: &context.inputs,
3829                        backend_inputs: &[],
3830                        requirements: &Object::empty(),
3831                        hints: &context.hints,
3832                        env: &Default::default(),
3833                        constraints: &TaskExecutionConstraints {
3834                            container: None,
3835                            cpu: context.cpu,
3836                            memory: context.memory,
3837                            gpu: if context.gpu {
3838                                vec![String::new()]
3839                            } else {
3840                                Default::default()
3841                            },
3842                            fpga: if context.fpga {
3843                                vec![String::new()]
3844                            } else {
3845                                Default::default()
3846                            },
3847                            disks: IndexMap::from_iter([("".into(), context.disks)]),
3848                        },
3849                        base_dir: &EvaluationPath::from_local_path(dir.path().into()),
3850                        attempt_dir: &dir.path().join("0"),
3851                        temp_dir: &dir.path().join("tmp"),
3852                    },
3853                    &Transferer,
3854                )
3855                .await
3856        }
3857
3858        // Check for the simple expressions
3859        assert_eq!(eval(Context::default(), "true").await.unwrap(), true);
3860        assert_eq!(eval(Context::default(), "false").await.unwrap(), false);
3861        assert_eq!(eval(Context::default(), "cpu == 1").await.unwrap(), true);
3862        assert_eq!(
3863            eval(Context::default(), "memory == 2147483648")
3864                .await
3865                .unwrap(),
3866            true
3867        );
3868        assert_eq!(eval(Context::default(), "gpu").await.unwrap(), false);
3869        assert_eq!(eval(Context::default(), "fpga").await.unwrap(), false);
3870        assert_eq!(
3871            eval(Context::default(), "disks == 1073741824")
3872                .await
3873                .unwrap(),
3874            true
3875        );
3876        assert_eq!(
3877            eval(Context::default(), "defined(hint.foo)").await.unwrap(),
3878            false
3879        );
3880
3881        // Check a comprehensive expression
3882        assert_eq!(
3883            eval(
3884                Context {
3885                    cpu: 10.,
3886                    memory: 10 * 1024 * 1024,
3887                    gpu: true,
3888                    fpga: true,
3889                    disks: 1024 * 1024,
3890                    inputs: Default::default(),
3891                    hints: Object::new(IndexMap::from_iter([(
3892                        "foo".into(),
3893                        "hi".to_string().into()
3894                    )]))
3895                },
3896                r#"cpu == 10 && memory == 10*1024*1024 && gpu && fpga && disks == 1024 * 1024 && hint.foo == "hi""#
3897            )
3898            .await
3899            .unwrap(),
3900            true
3901        );
3902
3903        // Check for input hint override
3904        let mut context = Context {
3905            hints: Object::new(IndexMap::from_iter([(
3906                "foo".into(),
3907                "hi".to_string().into(),
3908            )])),
3909            ..Default::default()
3910        };
3911        context
3912            .inputs
3913            .override_hint("foo", "overridden!".to_string());
3914        assert_eq!(
3915            eval(context, r#"hint.foo == "overridden!""#).await.unwrap(),
3916            true
3917        );
3918    }
3919}