use std::borrow::Cow;
use std::fmt;
use std::fs;
use std::marker::PhantomData;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::thread::available_parallelism;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use anyhow::ensure;
use bytesize::ByteSize;
use codespan_reporting::files::SimpleFile;
use codespan_reporting::term;
use codespan_reporting::term::termcolor::Buffer;
use indexmap::IndexMap;
use rowan::GreenNode;
use secrecy::ExposeSecret;
use tokio::process::Command;
use toml_spanner::Arena;
use toml_spanner::ErrorKind;
use toml_spanner::Failed;
use toml_spanner::FromToml;
use toml_spanner::Item;
use toml_spanner::Table;
use toml_spanner::ToToml;
use toml_spanner::ToTomlError;
use toml_spanner::Toml;
use toml_spanner::helper::display;
use toml_spanner::helper::flatten_any;
use toml_spanner::helper::parse_string;
use tracing::error;
use tracing::warn;
use url::Url;
use wdl_analysis::DiagnosticsConfig;
use wdl_analysis::diagnostics::unknown_name;
use wdl_analysis::diagnostics::unknown_type;
use wdl_analysis::document::Task;
use wdl_analysis::types::PrimitiveType;
use wdl_analysis::types::Type;
use wdl_analysis::types::v1::ExprTypeEvaluator;
use wdl_ast::AstNode;
use wdl_ast::Diagnostic;
use wdl_ast::Span;
use wdl_ast::SupportedVersion;
use wdl_ast::lexer::Lexer;
use wdl_ast::v1::Expr;
use wdl_grammar::construct_tree;
use wdl_grammar::grammar::v1;
use wdl_grammar::grammar::v1::Parser;
use crate::CancellationContext;
use crate::EvaluationContext;
use crate::EvaluationPath;
use crate::Events;
use crate::NoneValue;
use crate::Object;
use crate::SYSTEM;
use crate::Value;
use crate::backend::ExecuteTaskRequest;
use crate::backend::TaskExecutionBackend;
use crate::convert_unit_string;
use crate::diagnostics::unknown_enum_choice;
use crate::http::Transferer;
use crate::path::is_supported_url;
use crate::tree::SyntaxNode;
use crate::v1::DEFAULT_TASK_REQUIREMENT_MAX_RETRIES;
use crate::v1::ExprEvaluator;
pub(crate) const MAX_RETRIES: u64 = 100;
pub(crate) const DEFAULT_TASK_SHELL: &str = "bash";
pub(crate) const DEFAULT_TASK_CONTAINER: &str = "ubuntu:latest";
const DEFAULT_BACKEND_NAME: &str = "default";
const MAX_LSF_JOB_NAME_PREFIX: usize = 100;
const REDACTED: &str = "<REDACTED>";
const CACHE_DIR_SENTINEL: &str = "system";
const DEFAULT_HTTP_RETRIES: u32 = 5;
const DEFAULT_APPTAINER_EXECUTABLE: &str = "apptainer";
const DEFAULT_SCATTER_CONCURRENCY: u64 = 1000;
pub(crate) fn cache_dir() -> Result<PathBuf> {
const CACHE_DIR_ROOT: &str = "sprocket";
Ok(dirs::cache_dir()
.context("failed to determine user cache directory")?
.join(CACHE_DIR_ROOT))
}
fn escape_mapping(toml: &str) -> Vec<(usize, usize)> {
let mut iter = toml.char_indices();
let mut mapping = Vec::new();
let mut new = 0;
while let Some((old, c)) = iter.next() {
if c != '\\' {
new += c.len_utf8();
continue;
}
match iter.next() {
Some((_, 'u')) => {
let c = u32::from_str_radix(&toml[old + 2 ..old + 6 ], 16)
.map(char::from_u32)
.expect("invalid TOML escape sequence")
.expect("invalid TOML escape character");
new += c.len_utf8();
iter.nth(3);
mapping.push((new, old + 6 ));
}
Some((_, 'U')) => {
let c = u32::from_str_radix(&toml[old + 2 ..old + 10 ], 16)
.map(char::from_u32)
.expect("invalid TOML escape sequence")
.expect("invalid TOML escape character");
new += c.len_utf8();
iter.nth(7);
mapping.push((new, old + 10 ));
}
Some(_) => {
new += 1;
mapping.push((new, old + 2 ));
}
None => break,
}
}
mapping
}
#[derive(Default, Debug, Clone)]
pub struct SecretString {
inner: secrecy::SecretString,
redacted: bool,
}
impl SecretString {
pub fn redact(mut self) -> Self {
self.redacted = true;
self
}
pub fn inner(&self) -> &secrecy::SecretString {
&self.inner
}
}
impl From<String> for SecretString {
fn from(s: String) -> Self {
Self {
inner: s.into(),
redacted: false,
}
}
}
impl From<&str> for SecretString {
fn from(s: &str) -> Self {
Self {
inner: s.into(),
redacted: false,
}
}
}
impl<'de> FromToml<'de> for SecretString {
fn from_toml(ctx: &mut toml_spanner::Context<'de>, item: &Item<'de>) -> Result<Self, Failed> {
Ok(String::from_toml(ctx, item)?.into())
}
}
impl ToToml for SecretString {
fn to_toml<'a>(&'a self, arena: &'a Arena) -> Result<Item<'a>, ToTomlError> {
use secrecy::ExposeSecret;
if self.redacted {
REDACTED.to_toml(arena)
} else {
self.inner.expose_secret().to_toml(arena)
}
}
}
impl PartialEq for SecretString {
fn eq(&self, other: &Self) -> bool {
use secrecy::ExposeSecret;
self.inner.expose_secret() == other.inner.expose_secret()
}
}
impl Eq for SecretString {}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Toml)]
#[toml(Toml, rename_all = "snake_case")]
pub enum FailureMode {
#[default]
Slow,
Fast,
}
mod index_map {
use indexmap::IndexMap;
use toml_spanner::Arena;
use toml_spanner::Context;
use toml_spanner::Failed;
use toml_spanner::FromToml;
use toml_spanner::Item;
use toml_spanner::Key;
use toml_spanner::Table;
use toml_spanner::TableStyle;
use toml_spanner::ToToml;
use toml_spanner::ToTomlError;
pub fn from_toml<'de, V>(
ctx: &mut Context<'de>,
item: &Item<'de>,
) -> Result<IndexMap<String, V>, Failed>
where
V: FromToml<'de>,
{
let table = item.require_table(ctx)?;
let mut map = IndexMap::default();
let mut had_error = false;
for (key, item) in table {
match V::from_toml(ctx, item) {
Ok(v) => {
map.insert(key.name.into(), v);
}
Err(_) => had_error = true,
}
}
if had_error { Err(Failed) } else { Ok(map) }
}
pub fn to_toml<'a, V>(
value: &'a IndexMap<String, V>,
arena: &'a Arena,
) -> Result<Item<'a>, ToTomlError>
where
V: ToToml,
{
let Some(mut table) = Table::try_with_capacity(value.len(), arena) else {
return Err(ToTomlError::from(
"length of table exceeded maximum capacity",
));
};
table.set_style(TableStyle::Implicit);
for (k, v) in value {
table.insert_unique(Key::new(k), v.to_toml(arena)?, arena);
}
Ok(table.into_item())
}
}
#[derive(Debug, Clone, Toml, PartialEq, Eq)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct Config {
#[toml(default, style = Header)]
pub http: HttpConfig,
#[toml(default, style = Header)]
pub workflow: WorkflowConfig,
#[toml(default, style = Header)]
pub task: TaskConfig,
#[toml(default = DEFAULT_BACKEND_NAME.into())]
pub backend: String,
#[toml(default, with = index_map)]
pub backends: IndexMap<String, BackendConfig>,
#[toml(default, style = Header)]
pub storage: StorageConfig,
#[toml(default)]
pub suppress_env_specific_output: bool,
#[toml(default)]
pub experimental_features_enabled: bool,
#[toml(default, rename = "fail")]
pub failure_mode: FailureMode,
}
impl Default for Config {
fn default() -> Self {
Self {
http: Default::default(),
workflow: Default::default(),
task: Default::default(),
backend: DEFAULT_BACKEND_NAME.into(),
backends: Default::default(),
storage: Default::default(),
suppress_env_specific_output: Default::default(),
experimental_features_enabled: Default::default(),
failure_mode: Default::default(),
}
}
}
impl Config {
pub fn builder() -> ConfigBuilder<Self> {
ConfigBuilder::default()
}
pub async fn validate(&self) -> Result<()> {
self.http.validate()?;
self.workflow.validate()?;
self.task.validate()?;
if self.backends.is_empty() && self.backend == DEFAULT_BACKEND_NAME {
} else {
let backend = &self.backend;
if !self.backends.contains_key(backend) {
bail!("a backend named `{backend}` is not present in the configuration");
}
}
for backend in self.backends.values() {
backend.validate().await?;
}
self.storage.validate()?;
if self.suppress_env_specific_output && !self.experimental_features_enabled {
bail!("`suppress_env_specific_output` requires enabling experimental features");
}
Ok(())
}
pub fn redact(mut self) -> Self {
for backend in self.backends.values_mut() {
*backend = std::mem::take(backend).redact();
}
if let Some(auth) = self.storage.azure.auth.take() {
self.storage.azure.auth = Some(auth.redact());
}
if let Some(auth) = self.storage.s3.auth.take() {
self.storage.s3.auth = Some(auth.redact());
}
if let Some(auth) = self.storage.google.auth.take() {
self.storage.google.auth = Some(auth.redact());
}
self
}
pub fn backend(&self) -> Result<Cow<'_, BackendConfig>> {
if !self.backends.is_empty() {
let backend = &self.backend;
return Ok(Cow::Borrowed(self.backends.get(backend).ok_or_else(
|| anyhow!("a backend named `{backend}` is not present in the configuration"),
)?));
}
Ok(Cow::Owned(BackendConfig::default()))
}
pub(crate) async fn create_backend(
self: &Arc<Self>,
run_root_dir: &Path,
events: Events,
cancellation: CancellationContext,
) -> Result<Arc<dyn TaskExecutionBackend>> {
use crate::backend::*;
match self.backend()?.as_ref() {
BackendConfig::Local { .. } => {
warn!(
"the engine is configured to use the local backend: tasks will not be run \
inside of a container"
);
Ok(Arc::new(LocalBackend::new(
self.clone(),
events,
cancellation,
)?))
}
BackendConfig::Docker { .. } => Ok(Arc::new(
DockerBackend::new(self.clone(), events, cancellation).await?,
)),
BackendConfig::Tes { .. } => Ok(Arc::new(
TesBackend::new(self.clone(), events, cancellation).await?,
)),
BackendConfig::LsfApptainer { .. } => Ok(Arc::new(LsfApptainerBackend::new(
self.clone(),
run_root_dir,
events,
cancellation,
)?)),
BackendConfig::SlurmApptainer { .. } => Ok(Arc::new(SlurmApptainerBackend::new(
self.clone(),
run_root_dir,
events,
cancellation,
)?)),
}
}
}
#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
pub enum Parallelism {
#[default]
Available,
Use(usize),
}
impl From<usize> for Parallelism {
fn from(value: usize) -> Self {
Self::Use(value)
}
}
impl From<Parallelism> for usize {
fn from(value: Parallelism) -> Self {
match value {
Parallelism::Available => available_parallelism().map(Into::into).unwrap_or(1),
Parallelism::Use(value) => value,
}
}
}
impl<'de> FromToml<'de> for Parallelism {
fn from_toml(ctx: &mut toml_spanner::Context<'de>, item: &Item<'de>) -> Result<Self, Failed> {
if let Some("available") = item.as_str() {
return Ok(Self::Available);
}
if let Some(n) = item.as_u64().and_then(|n| usize::try_from(n).ok())
&& n > 0
{
return Ok(Self::Use(n));
}
Err(ctx.report_custom_error(
"expected a positive integer or `available` for parallelism",
item,
))
}
}
impl ToToml for Parallelism {
fn to_toml<'a>(&'a self, _: &'a Arena) -> Result<Item<'a>, ToTomlError> {
match self {
Self::Available => Ok(Item::string("available")),
Self::Use(n) => Ok(i64::try_from(*n)
.map_err(|e| ToTomlError {
message: format!("invalid parallelism: {e}").into(),
})?
.into()),
}
}
}
#[derive(Debug, Clone, Toml, PartialEq, Eq)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct HttpConfig {
#[toml(default = CACHE_DIR_SENTINEL.into())]
pub cache_dir: String,
#[toml(default = DEFAULT_HTTP_RETRIES)]
pub retries: u32,
#[toml(default)]
pub parallelism: Parallelism,
#[toml(default, FromToml with = parse_string, ToToml with = display)]
pub hash_algorithm: cloud_copy::HashAlgorithm,
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
cache_dir: CACHE_DIR_SENTINEL.into(),
retries: DEFAULT_HTTP_RETRIES,
parallelism: Default::default(),
hash_algorithm: Default::default(),
}
}
}
impl HttpConfig {
pub fn validate(&self) -> Result<()> {
if let Parallelism::Use(parallelism) = self.parallelism
&& parallelism == 0
{
bail!("configuration value `http.parallelism` cannot be zero");
}
Ok(())
}
pub fn cache_dir(&self) -> Result<PathBuf> {
const DOWNLOADS_CACHE_SUBDIR: &str = "downloads";
if self.using_system_cache_dir() {
cache_dir().map(|d| d.join(DOWNLOADS_CACHE_SUBDIR))
} else {
Ok(PathBuf::from(&self.cache_dir))
}
}
pub fn using_system_cache_dir(&self) -> bool {
self.cache_dir == CACHE_DIR_SENTINEL
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct StorageConfig {
#[toml(default, style = Header)]
pub azure: AzureStorageConfig,
#[toml(default, style = Header)]
pub s3: S3StorageConfig,
#[toml(default, style = Header)]
pub google: GoogleStorageConfig,
}
impl StorageConfig {
pub fn validate(&self) -> Result<()> {
self.azure.validate()?;
self.s3.validate()?;
self.google.validate()?;
Ok(())
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct AzureStorageAuthConfig {
pub account_name: String,
pub access_key: SecretString,
}
impl AzureStorageAuthConfig {
pub fn validate(&self) -> Result<()> {
if self.account_name.is_empty() {
bail!("configuration value `storage.azure.auth.account_name` is required");
}
if self.access_key.inner.expose_secret().is_empty() {
bail!("configuration value `storage.azure.auth.access_key` is required");
}
Ok(())
}
pub fn redact(mut self) -> Self {
self.access_key = self.access_key.redact();
self
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct AzureStorageConfig {
#[toml(style = Header)]
pub auth: Option<AzureStorageAuthConfig>,
}
impl AzureStorageConfig {
pub fn validate(&self) -> Result<()> {
if let Some(auth) = &self.auth {
auth.validate()?;
}
Ok(())
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct S3StorageAuthConfig {
pub access_key_id: String,
pub secret_access_key: SecretString,
}
impl S3StorageAuthConfig {
pub fn validate(&self) -> Result<()> {
if self.access_key_id.is_empty() {
bail!("configuration value `storage.s3.auth.access_key_id` is required");
}
if self.secret_access_key.inner.expose_secret().is_empty() {
bail!("configuration value `storage.s3.auth.secret_access_key` is required");
}
Ok(())
}
pub fn redact(mut self) -> Self {
self.secret_access_key = self.secret_access_key.redact();
self
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct S3StorageConfig {
pub region: Option<String>,
#[toml(style = Header)]
pub auth: Option<S3StorageAuthConfig>,
}
impl S3StorageConfig {
pub fn validate(&self) -> Result<()> {
if let Some(auth) = &self.auth {
auth.validate()?;
}
Ok(())
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct GoogleStorageAuthConfig {
pub access_key: String,
pub secret: SecretString,
}
impl GoogleStorageAuthConfig {
pub fn validate(&self) -> Result<()> {
if self.access_key.is_empty() {
bail!("configuration value `storage.google.auth.access_key` is required");
}
if self.secret.inner.expose_secret().is_empty() {
bail!("configuration value `storage.google.auth.secret` is required");
}
Ok(())
}
pub fn redact(mut self) -> Self {
self.secret = self.secret.redact();
self
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct GoogleStorageConfig {
#[toml(style = Header)]
pub auth: Option<GoogleStorageAuthConfig>,
}
impl GoogleStorageConfig {
pub fn validate(&self) -> Result<()> {
if let Some(auth) = &self.auth {
auth.validate()?;
}
Ok(())
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct WorkflowConfig {
#[toml(default, style = Header)]
pub scatter: ScatterConfig,
}
impl WorkflowConfig {
pub fn validate(&self) -> Result<()> {
self.scatter.validate()?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct ScatterConfig {
#[toml(default = DEFAULT_SCATTER_CONCURRENCY)]
pub concurrency: u64,
}
impl Default for ScatterConfig {
fn default() -> Self {
Self {
concurrency: DEFAULT_SCATTER_CONCURRENCY,
}
}
}
impl ScatterConfig {
pub fn validate(&self) -> Result<()> {
if self.concurrency == 0 {
bail!("configuration value `workflow.scatter.concurrency` cannot be zero");
}
Ok(())
}
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Toml)]
#[toml(Toml, rename_all = "snake_case")]
pub enum CallCachingMode {
#[default]
Off,
On,
Explicit,
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Hash, Toml)]
#[toml(Toml, rename_all = "snake_case")]
pub enum ContentDigestMode {
Strong,
#[default]
Weak,
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
pub enum Retries {
#[default]
Default,
Use(u64),
}
impl From<u64> for Retries {
fn from(value: u64) -> Self {
Self::Use(value)
}
}
impl From<Retries> for u64 {
fn from(value: Retries) -> Self {
match value {
Retries::Default => DEFAULT_TASK_REQUIREMENT_MAX_RETRIES,
Retries::Use(value) => value,
}
}
}
impl<'de> FromToml<'de> for Retries {
fn from_toml(ctx: &mut toml_spanner::Context<'de>, item: &Item<'de>) -> Result<Self, Failed> {
if let Some("default") = item.as_str() {
return Ok(Self::Default);
}
if let Some(n) = item.as_u64()
&& n < MAX_RETRIES
{
return Ok(Self::Use(n));
}
Err(ctx.report_custom_error(
format!("expected an integer less than {MAX_RETRIES} or `default` for retries"),
item,
))
}
}
impl ToToml for Retries {
fn to_toml<'a>(&'a self, _: &'a Arena) -> Result<Item<'a>, ToTomlError> {
match self {
Self::Default => Ok(Item::string("default")),
Self::Use(n) => Ok(i64::try_from(*n)
.map_err(|e| ToTomlError {
message: format!("invalid retries: {e}").into(),
})?
.into()),
}
}
}
#[derive(Debug, Clone, Toml, PartialEq, Eq)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct TaskConfig {
#[toml(default)]
pub retries: Retries,
#[toml(default = DEFAULT_TASK_CONTAINER.into())]
pub container: String,
#[toml(default = DEFAULT_TASK_SHELL.into())]
pub shell: String,
#[toml(default)]
pub cpu_limit_behavior: TaskResourceLimitBehavior,
#[toml(default)]
pub memory_limit_behavior: TaskResourceLimitBehavior,
#[toml(default = CACHE_DIR_SENTINEL.into())]
pub cache_dir: String,
#[toml(default)]
pub cache: CallCachingMode,
#[toml(default)]
pub digests: ContentDigestMode,
#[toml(default)]
pub excluded_cache_requirements: Vec<String>,
#[toml(default)]
pub excluded_cache_hints: Vec<String>,
#[toml(default)]
pub excluded_cache_inputs: Vec<String>,
}
impl Default for TaskConfig {
fn default() -> Self {
Self {
retries: Default::default(),
container: DEFAULT_TASK_CONTAINER.into(),
shell: DEFAULT_TASK_SHELL.into(),
cpu_limit_behavior: Default::default(),
memory_limit_behavior: Default::default(),
cache_dir: CACHE_DIR_SENTINEL.into(),
cache: Default::default(),
digests: Default::default(),
excluded_cache_requirements: Default::default(),
excluded_cache_hints: Default::default(),
excluded_cache_inputs: Default::default(),
}
}
}
impl TaskConfig {
pub fn validate(&self) -> Result<()> {
if let Retries::Use(value) = self.retries
&& value >= MAX_RETRIES
{
bail!("configuration value `task.retries` cannot exceed {MAX_RETRIES}");
}
Ok(())
}
pub fn cache_dir(&self) -> Option<PathBuf> {
if self.cache_dir == CACHE_DIR_SENTINEL {
None
} else {
Some(PathBuf::from(&self.cache_dir))
}
}
}
#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub enum TaskResourceLimitBehavior {
TryWithMax,
#[default]
Deny,
}
#[derive(Debug, Clone, Toml, PartialEq, Eq)]
#[toml(Toml, rename_all = "snake_case", tag = "type")]
pub enum BackendConfig {
Local {
#[toml(default, style = Header, flatten, with = flatten_any)]
config: LocalBackendConfig,
},
Docker {
#[toml(default, style = Header, flatten, with = flatten_any)]
config: DockerBackendConfig,
},
Tes {
#[toml(default, style = Header, flatten, with = flatten_any)]
config: TesBackendConfig,
},
LsfApptainer {
#[toml(default, style = Header, flatten, with = flatten_any)]
config: LsfApptainerBackendConfig,
},
SlurmApptainer {
#[toml(default, style = Header, flatten, with = flatten_any)]
config: SlurmApptainerBackendConfig,
},
}
impl Default for BackendConfig {
fn default() -> Self {
Self::Docker {
config: Default::default(),
}
}
}
impl BackendConfig {
pub async fn validate(&self) -> Result<()> {
match self {
Self::Local { config } => config.validate(),
Self::Docker { config } => config.validate(),
Self::Tes { config } => config.validate(),
Self::LsfApptainer { config } => config.validate().await,
Self::SlurmApptainer { config } => config.validate().await,
}
}
pub fn as_local(&self) -> Option<&LocalBackendConfig> {
match self {
Self::Local { config } => Some(config),
_ => None,
}
}
pub fn as_docker(&self) -> Option<&DockerBackendConfig> {
match self {
Self::Docker { config } => Some(config),
_ => None,
}
}
pub fn as_tes(&self) -> Option<&TesBackendConfig> {
match self {
Self::Tes { config } => Some(config),
_ => None,
}
}
pub fn as_lsf_apptainer(&self) -> Option<&LsfApptainerBackendConfig> {
match self {
Self::LsfApptainer { config } => Some(config),
_ => None,
}
}
pub fn as_slurm_apptainer(&self) -> Option<&SlurmApptainerBackendConfig> {
match self {
Self::SlurmApptainer { config } => Some(config),
_ => None,
}
}
pub fn redact(self) -> Self {
match self {
Self::Local { .. }
| Self::Docker { .. }
| Self::LsfApptainer { .. }
| Self::SlurmApptainer { .. } => self,
Self::Tes { config } => Self::Tes {
config: config.redact(),
},
}
}
}
impl From<LocalBackendConfig> for BackendConfig {
fn from(config: LocalBackendConfig) -> Self {
Self::Local { config }
}
}
impl From<DockerBackendConfig> for BackendConfig {
fn from(config: DockerBackendConfig) -> Self {
Self::Docker { config }
}
}
impl From<TesBackendConfig> for BackendConfig {
fn from(config: TesBackendConfig) -> Self {
Self::Tes { config }
}
}
impl From<LsfApptainerBackendConfig> for BackendConfig {
fn from(config: LsfApptainerBackendConfig) -> Self {
Self::LsfApptainer { config }
}
}
impl From<SlurmApptainerBackendConfig> for BackendConfig {
fn from(config: SlurmApptainerBackendConfig) -> Self {
Self::SlurmApptainer { config }
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct LocalBackendConfig {
pub cpu: Option<u64>,
pub memory: Option<String>,
}
impl LocalBackendConfig {
pub fn validate(&self) -> Result<()> {
if let Some(cpu) = self.cpu {
if cpu == 0 {
bail!("local backend configuration value `cpu` cannot be zero");
}
let total = SYSTEM.cpus().len() as u64;
if cpu > total {
bail!(
"local backend configuration value `cpu` cannot exceed the virtual CPUs \
available to the host ({total})"
);
}
}
if let Some(memory) = &self.memory {
let memory = convert_unit_string(memory).with_context(|| {
format!("local backend configuration value `memory` has invalid value `{memory}`")
})?;
if memory == 0 {
bail!("local backend configuration value `memory` cannot be zero");
}
let total = SYSTEM.total_memory();
if memory > total {
bail!(
"local backend configuration value `memory` cannot exceed the total memory of \
the host ({total} bytes)"
);
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct DockerBackendConfig {
#[toml(default = true)]
pub cleanup: bool,
}
impl DockerBackendConfig {
pub fn validate(&self) -> Result<()> {
Ok(())
}
}
impl Default for DockerBackendConfig {
fn default() -> Self {
Self { cleanup: true }
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct BasicAuthConfig {
pub username: String,
pub password: SecretString,
}
impl BasicAuthConfig {
pub fn validate(&self) -> Result<()> {
Ok(())
}
pub fn redact(mut self) -> Self {
self.password = self.password.redact();
self
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct BearerAuthConfig {
pub token: SecretString,
}
impl BearerAuthConfig {
pub fn validate(&self) -> Result<()> {
Ok(())
}
pub fn redact(mut self) -> Self {
self.token = self.token.redact();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", tag = "type")]
pub enum TesBackendAuthConfig {
Basic {
#[toml(default, style = Header, flatten, with = flatten_any)]
config: BasicAuthConfig,
},
Bearer {
#[toml(default, style = Header, flatten, with = flatten_any)]
config: BearerAuthConfig,
},
}
impl TesBackendAuthConfig {
pub fn validate(&self) -> Result<()> {
match self {
Self::Basic { config } => config.validate(),
Self::Bearer { config } => config.validate(),
}
}
pub fn redact(self) -> Self {
match self {
Self::Basic { config } => Self::Basic {
config: config.redact(),
},
Self::Bearer { config } => Self::Bearer {
config: config.redact(),
},
}
}
}
impl From<BasicAuthConfig> for TesBackendAuthConfig {
fn from(config: BasicAuthConfig) -> Self {
Self::Basic { config }
}
}
impl From<BearerAuthConfig> for TesBackendAuthConfig {
fn from(config: BearerAuthConfig) -> Self {
Self::Bearer { config }
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct TesBackendConfig {
#[toml(FromToml with = parse_string, ToToml with = display)]
pub url: Option<Url>,
#[toml(style = Header)]
pub auth: Option<TesBackendAuthConfig>,
#[toml(FromToml with = parse_string, ToToml with = display)]
pub inputs: Option<Url>,
#[toml(FromToml with = parse_string, ToToml with = display)]
pub outputs: Option<Url>,
pub interval: Option<u64>,
pub retries: Option<u32>,
pub max_concurrency: Option<u32>,
#[toml(default)]
pub insecure: bool,
}
impl TesBackendConfig {
pub fn validate(&self) -> Result<()> {
match &self.url {
Some(url) => {
if !self.insecure && url.scheme() != "https" {
bail!(
"TES backend configuration value `url` has invalid value `{url}`: URL \
must use a HTTPS scheme"
);
}
}
None => bail!("TES backend configuration value `url` is required"),
}
if let Some(auth) = &self.auth {
auth.validate()?;
}
if let Some(max_concurrency) = self.max_concurrency
&& max_concurrency == 0
{
bail!("TES backend configuration value `max_concurrency` cannot be zero");
}
match &self.inputs {
Some(url) => {
if !is_supported_url(url.as_str()) {
bail!(
"TES backend storage configuration value `inputs` has invalid value \
`{url}`: URL scheme is not supported"
);
}
if !url.path().ends_with('/') {
bail!(
"TES backend storage configuration value `inputs` has invalid value \
`{url}`: URL path must end with a slash"
);
}
}
None => bail!("TES backend configuration value `inputs` is required"),
}
match &self.outputs {
Some(url) => {
if !is_supported_url(url.as_str()) {
bail!(
"TES backend storage configuration value `outputs` has invalid value \
`{url}`: URL scheme is not supported"
);
}
if !url.path().ends_with('/') {
bail!(
"TES backend storage configuration value `outputs` has invalid value \
`{url}`: URL path must end with a slash"
);
}
}
None => bail!("TES backend storage configuration value `outputs` is required"),
}
Ok(())
}
pub fn redact(mut self) -> Self {
if let Some(auth) = self.auth.take() {
self.auth = Some(auth.redact());
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct ApptainerConfig {
#[toml(default = DEFAULT_APPTAINER_EXECUTABLE.into())]
pub executable: String,
pub image_cache_dir: Option<PathBuf>,
#[toml(default)]
pub extra_args: Vec<String>,
}
impl Default for ApptainerConfig {
fn default() -> Self {
Self {
executable: DEFAULT_APPTAINER_EXECUTABLE.into(),
image_cache_dir: None,
extra_args: Default::default(),
}
}
}
impl ApptainerConfig {
pub async fn validate(&self) -> Result<(), anyhow::Error> {
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Condition {
pub raw: String,
expr: GreenNode,
}
impl Condition {
pub fn new(raw: impl Into<String>) -> Result<Self, Vec<Diagnostic>> {
#[derive(Default)]
struct Context(Vec<Diagnostic>);
impl wdl_analysis::types::v1::EvaluationContext for Context {
fn version(&self) -> SupportedVersion {
Default::default()
}
fn resolve_name(&mut self, name: &str, span: Span) -> Option<Type> {
match name {
"cpu" => Some(PrimitiveType::Float.into()),
"memory" => Some(PrimitiveType::Integer.into()),
"gpu" | "fpga" => Some(PrimitiveType::Boolean.into()),
"disks" => Some(PrimitiveType::Integer.into()),
"hint" => Some(Type::Object),
_ => {
self.add_diagnostic(unknown_name(name, span));
None
}
}
}
fn resolve_type_name(&mut self, name: &str, span: Span) -> Result<Type, Diagnostic> {
Err(unknown_type(name, span))
}
fn task(&self) -> Option<&Task> {
None
}
fn diagnostics_config(&self) -> DiagnosticsConfig {
Default::default()
}
fn add_diagnostic(&mut self, diagnostic: Diagnostic) {
self.0.push(diagnostic);
}
}
let raw = raw.into();
let mut parser = Parser::new(Lexer::new(&raw));
let marker = parser.start();
match v1::expr(&mut parser, marker) {
Ok(()) => {
if let Some((_, span)) = parser.next() {
return Err(vec![
Diagnostic::error("expected a single WDL expression")
.with_label("extraneous WDL source starts here", span),
]);
}
let output = parser.finish();
if !output.diagnostics.is_empty() {
return Err(output.diagnostics);
}
let expr = Expr::cast(construct_tree(raw.as_ref(), output.events))
.expect("node should cast");
let mut context = Context::default();
let ty = ExprTypeEvaluator::new(&mut context)
.evaluate_expr(&expr)
.unwrap_or(Type::Union);
if !context.0.is_empty() {
return Err(context.0);
}
match ty {
Type::Primitive(PrimitiveType::Boolean, false) | Type::Union => {}
_ => {
return Err(vec![
Diagnostic::error(format!(
"conditional expression is expected to be type `Boolean`, but \
found type `{ty}`",
))
.with_highlight(expr.span()),
]);
}
}
Ok(Self {
raw,
expr: expr.inner().green().into_owned(),
})
}
Err((marker, diagnostic)) => {
marker.abandon(&mut parser);
Err(vec![diagnostic.into()])
}
}
}
pub(crate) async fn evaluate(
&self,
request: &ExecuteTaskRequest<'_>,
transferer: &dyn Transferer,
) -> Result<bool> {
struct Context<'a> {
request: &'a ExecuteTaskRequest<'a>,
transferer: &'a dyn Transferer,
}
impl EvaluationContext for Context<'_> {
fn version(&self) -> SupportedVersion {
Default::default()
}
fn resolve_name(&self, name: &str, span: Span) -> Result<Value, Diagnostic> {
match name {
"cpu" => Ok(self.request.constraints.cpu.into()),
"memory" => Ok((self.request.constraints.memory as i64).into()),
"gpu" => Ok((!self.request.constraints.gpu.is_empty()).into()),
"fpga" => Ok((!self.request.constraints.fpga.is_empty()).into()),
"disks" => Ok(self
.request
.constraints
.disks
.iter()
.map(|(_, s)| *s)
.sum::<i64>()
.into()),
"hint" => Ok(self.request.hints.clone().into()),
_ => Err(unknown_name(name, span)),
}
}
fn resolve_type_name(&self, name: &str, span: Span) -> Result<Type, Diagnostic> {
Err(unknown_type(name, span))
}
fn enum_choice_value(
&self,
enum_name: &str,
choice_name: &str,
) -> Result<Value, Diagnostic> {
Err(unknown_enum_choice(enum_name, choice_name))
}
fn base_dir(&self) -> &EvaluationPath {
self.request.base_dir
}
fn temp_dir(&self) -> &Path {
self.request.temp_dir
}
fn transferer(&self) -> &dyn Transferer {
self.transferer
}
fn object_access(&self, object: &Object, name: &str) -> Option<Value> {
if !Arc::ptr_eq(&object.members, &self.request.hints.members) {
return None;
}
Some(
self.request
.inputs
.hint(name)
.or_else(|| object.get(name))
.cloned()
.unwrap_or_else(|| NoneValue::untyped().into()),
)
}
}
async fn eval(context: Context<'_>, expr: &Expr<SyntaxNode>) -> Result<bool, Diagnostic> {
let mut evaluator = ExprEvaluator::new(context);
let value = evaluator.evaluate_expr(expr).await?;
match value.as_boolean() {
Some(res) => Ok(res),
None => Err(Diagnostic::error(format!(
"conditional expression is expected to be type `Boolean`, but found type \
`{ty}`",
ty = value.ty()
))
.with_highlight(expr.span())),
}
}
let expr = Expr::cast(self.expr.clone().into()).expect("should be an expression node");
match eval(
Context {
request,
transferer,
},
&expr,
)
.await
{
Ok(res) => Ok(res),
Err(diagnostic) => {
let file: SimpleFile<_, _> = SimpleFile::new("<condition>", &self.raw);
let mut buffer = Buffer::no_color();
term::emit_to_write_style(
&mut buffer,
&Default::default(),
&file,
&diagnostic.to_codespan(()),
)
.context("failed to write diagnostic to buffer")?;
let diagnostic = String::from_utf8(buffer.into_inner())
.context("diagnostic buffer contents are not UTF-8")?;
bail!(
"failed to evaluate backend dynamic arguments condition: {diagnostic}",
diagnostic = diagnostic
.strip_prefix("error: ")
.unwrap_or(&diagnostic)
.trim()
);
}
}
}
}
impl ToToml for Condition {
fn to_toml<'a>(&'a self, arena: &'a Arena) -> Result<Item<'a>, ToTomlError> {
self.raw.to_toml(arena)
}
}
impl<'de> FromToml<'de> for Condition {
fn from_toml(ctx: &mut toml_spanner::Context<'de>, item: &Item<'de>) -> Result<Self, Failed> {
fn remap(mapping: &[(usize, usize)], unescaped: usize) -> usize {
match mapping.binary_search_by_key(&unescaped, |x| x.0) {
Ok(i) => {
mapping[i].1
}
Err(i) => {
unescaped
+ if i == 0 {
0
} else {
mapping[i - 1].1 - mapping[i - 1].0
}
}
}
}
fn push_errors(
ctx: &mut toml_spanner::Context<'_>,
item: &Item<'_>,
diagnostics: Vec<Diagnostic>,
) -> Failed {
for diagnostic in diagnostics {
let span = if let Some(label) = diagnostic.labels().next() {
let label_span = label.span();
let span = item.span();
let source = &ctx.source()[span.start as usize..span.end as usize];
let offset = if source.starts_with(r#"""""#) | source.starts_with("'''") {
3
} else {
1
};
let mapping = escape_mapping(
source
.get(offset..(source.len() - offset))
.expect("invalid TOML string"),
);
let label_start = remap(&mapping, label_span.start());
let label_end = remap(&mapping, label_span.end());
toml_spanner::Span::new(
span.start + offset as u32 + label_start as u32,
span.start + offset as u32 + label_end as u32,
)
} else {
item.span()
};
ctx.errors
.push(toml_spanner::Error::custom(diagnostic.message(), span));
}
Failed
}
Self::new(String::from_toml(ctx, item)?).map_err(|diags| push_errors(ctx, item, diags))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct ConditionalArgs {
pub condition: Condition,
#[toml(default)]
pub args: Vec<String>,
}
impl ConditionalArgs {
pub fn validate(&self) -> Result<()> {
if self.args.is_empty() {
bail!("backend conditional arguments must have at least one argument specified");
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct AdditionalArgs {
#[toml(default)]
pub args: Vec<String>,
#[toml(default)]
pub conditional: Vec<ConditionalArgs>,
}
impl AdditionalArgs {
pub fn validate(&self) -> Result<()> {
for arg in &self.conditional {
arg.validate()?;
}
Ok(())
}
}
mod byte_size {
use bytesize::ByteSize;
use toml_spanner::Context;
use toml_spanner::Failed;
use toml_spanner::Item;
pub fn from_toml<'de>(ctx: &mut Context<'de>, item: &Item<'de>) -> Result<ByteSize, Failed> {
if let Some(s) = item.as_u64() {
return Ok(ByteSize(s));
}
if let Some(s) = item.as_str() {
return s
.parse()
.map_err(|e| ctx.report_custom_error(format!("invalid byte size: {e}"), item));
}
Err(ctx.report_expected_but_found(&"integer or string", item))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct LsfQueueConfig {
pub name: String,
pub max_cpu_per_task: Option<u64>,
#[toml(FromToml with = byte_size, ToToml with = display)]
pub max_memory_per_task: Option<ByteSize>,
}
impl LsfQueueConfig {
pub async fn validate(&self, name: &str) -> Result<(), anyhow::Error> {
let queue = &self.name;
ensure!(!queue.is_empty(), "{name}_lsf_queue name cannot be empty");
if let Some(max_cpu_per_task) = self.max_cpu_per_task {
ensure!(
max_cpu_per_task > 0,
"{name}_lsf_queue `{queue}` must allow at least 1 CPU to be provisioned"
);
}
if let Some(max_memory_per_task) = self.max_memory_per_task {
ensure!(
max_memory_per_task.as_u64() > 0,
"{name}_lsf_queue `{queue}` must allow at least some memory to be provisioned"
);
}
match tokio::time::timeout(
std::time::Duration::from_secs(10),
Command::new("bqueues").arg(queue).output(),
)
.await
{
Ok(output) => {
let output = output.context("validating LSF queue")?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
error!(%stdout, %stderr, %queue, "failed to validate {name}_lsf_queue");
Err(anyhow!("failed to validate {name}_lsf_queue `{queue}`"))
} else {
Ok(())
}
}
Err(_) => Err(anyhow!(
"timed out trying to validate {name}_lsf_queue `{queue}`"
)),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct LsfApptainerBackendConfig {
pub interval: Option<u64>,
pub max_concurrency: Option<u32>,
#[toml(style = Header)]
pub default_lsf_queue: Option<LsfQueueConfig>,
#[toml(style = Header)]
pub short_task_lsf_queue: Option<LsfQueueConfig>,
#[toml(style = Header)]
pub gpu_lsf_queue: Option<LsfQueueConfig>,
#[toml(style = Header)]
pub fpga_lsf_queue: Option<LsfQueueConfig>,
pub job_name_prefix: Option<String>,
#[toml(default)]
pub bsub: AdditionalArgs,
#[toml(default)]
pub apptainer: ApptainerConfig,
}
impl LsfApptainerBackendConfig {
pub async fn validate(&self) -> Result<(), anyhow::Error> {
if cfg!(not(unix)) {
bail!("LSF + Apptainer backend is not supported on non-unix platforms");
}
if let Some(queue) = &self.default_lsf_queue {
queue.validate("default").await?;
}
if let Some(queue) = &self.short_task_lsf_queue {
queue.validate("short_task").await?;
}
if let Some(queue) = &self.gpu_lsf_queue {
queue.validate("gpu").await?;
}
if let Some(queue) = &self.fpga_lsf_queue {
queue.validate("fpga").await?;
}
if let Some(prefix) = &self.job_name_prefix
&& prefix.len() > MAX_LSF_JOB_NAME_PREFIX
{
bail!(
"LSF job name prefix `{prefix}` exceeds the maximum {MAX_LSF_JOB_NAME_PREFIX} \
bytes"
);
}
self.bsub.validate()?;
self.apptainer.validate().await?;
Ok(())
}
pub(crate) fn lsf_queue_for_task(
&self,
requirements: &Object,
hints: &Object,
) -> Option<&LsfQueueConfig> {
if let Some(queue) = self.fpga_lsf_queue.as_ref()
&& let Some(true) = requirements
.get(wdl_ast::v1::TASK_REQUIREMENT_FPGA)
.and_then(Value::as_boolean)
{
return Some(queue);
}
if let Some(queue) = self.gpu_lsf_queue.as_ref()
&& let Some(true) = requirements
.get(wdl_ast::v1::TASK_REQUIREMENT_GPU)
.and_then(Value::as_boolean)
{
return Some(queue);
}
if let Some(queue) = self.short_task_lsf_queue.as_ref()
&& let Some(true) = hints
.get(wdl_ast::v1::TASK_HINT_SHORT_TASK)
.and_then(Value::as_boolean)
{
return Some(queue);
}
self.default_lsf_queue.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct SlurmPartitionConfig {
pub name: String,
pub max_cpu_per_task: Option<u64>,
#[toml(FromToml with = byte_size, ToToml with = display)]
pub max_memory_per_task: Option<ByteSize>,
}
impl SlurmPartitionConfig {
pub async fn validate(&self, name: &str) -> Result<(), anyhow::Error> {
let partition = &self.name;
ensure!(
!partition.is_empty(),
"{name}_slurm_partition name cannot be empty"
);
if let Some(max_cpu_per_task) = self.max_cpu_per_task {
ensure!(
max_cpu_per_task > 0,
"{name}_slurm_partition `{partition}` must allow at least 1 CPU to be provisioned"
);
}
if let Some(max_memory_per_task) = self.max_memory_per_task {
ensure!(
max_memory_per_task.as_u64() > 0,
"{name}_slurm_partition `{partition}` must allow at least some memory to be \
provisioned"
);
}
match tokio::time::timeout(
std::time::Duration::from_secs(10),
Command::new("scontrol")
.arg("show")
.arg("partition")
.arg(partition)
.output(),
)
.await
{
Ok(output) => {
let output = output.context("validating Slurm partition")?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
error!(%stdout, %stderr, %partition, "failed to validate {name}_slurm_partition");
Err(anyhow!(
"failed to validate {name}_slurm_partition `{partition}`"
))
} else {
Ok(())
}
}
Err(_) => Err(anyhow!(
"timed out trying to validate {name}_slurm_partition `{partition}`"
)),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Toml)]
#[toml(Toml, rename_all = "snake_case", deny_unknown_fields)]
pub struct SlurmApptainerBackendConfig {
pub interval: Option<u64>,
pub max_concurrency: Option<u32>,
#[toml(style = Header)]
pub default_slurm_partition: Option<SlurmPartitionConfig>,
#[toml(style = Header)]
pub short_task_slurm_partition: Option<SlurmPartitionConfig>,
#[toml(style = Header)]
pub gpu_slurm_partition: Option<SlurmPartitionConfig>,
#[toml(style = Header)]
pub fpga_slurm_partition: Option<SlurmPartitionConfig>,
#[toml(default)]
pub sbatch: AdditionalArgs,
pub job_name_prefix: Option<String>,
#[toml(default)]
pub apptainer: ApptainerConfig,
}
impl SlurmApptainerBackendConfig {
pub async fn validate(&self) -> Result<(), anyhow::Error> {
if cfg!(not(unix)) {
bail!("Slurm + Apptainer backend is not supported on non-unix platforms");
}
if let Some(partition) = &self.default_slurm_partition {
partition.validate("default").await?;
}
if let Some(partition) = &self.short_task_slurm_partition {
partition.validate("short_task").await?;
}
if let Some(partition) = &self.gpu_slurm_partition {
partition.validate("gpu").await?;
}
if let Some(partition) = &self.fpga_slurm_partition {
partition.validate("fpga").await?;
}
self.sbatch.validate()?;
self.apptainer.validate().await?;
Ok(())
}
pub(crate) fn slurm_partition_for_task(
&self,
requirements: &Object,
hints: &Object,
) -> Option<&SlurmPartitionConfig> {
if let Some(partition) = self.fpga_slurm_partition.as_ref()
&& let Some(true) = requirements
.get(wdl_ast::v1::TASK_REQUIREMENT_FPGA)
.and_then(Value::as_boolean)
{
return Some(partition);
}
if let Some(partition) = self.gpu_slurm_partition.as_ref()
&& let Some(true) = requirements
.get(wdl_ast::v1::TASK_REQUIREMENT_GPU)
.and_then(Value::as_boolean)
{
return Some(partition);
}
if let Some(partition) = self.short_task_slurm_partition.as_ref()
&& let Some(true) = hints
.get(wdl_ast::v1::TASK_HINT_SHORT_TASK)
.and_then(Value::as_boolean)
{
return Some(partition);
}
self.default_slurm_partition.as_ref()
}
}
#[derive(Debug, thiserror::Error)]
pub enum BuilderMergeError {
#[error("failed to serialize after merging configuration")]
Serialize(#[from] ToTomlError),
#[error("failed to parse after merging configuration")]
Parse {
source: String,
#[source]
error: toml_spanner::Error,
},
#[error("failed to deserialize after merging configuration")]
Deserialize {
source: String,
#[source]
error: toml_spanner::FromTomlError,
},
}
struct BuilderErrorDisplay<'a>(&'static str, &'a Option<PathBuf>);
impl fmt::Display for BuilderErrorDisplay<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.1 {
Some(path) => write!(
f,
"failed to {op} configuration file `{path}`",
op = self.0,
path = path.display()
),
None => write!(
f,
"failed to {op} in-memory configuration string",
op = self.0
),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum BuilderError {
#[error("failed to read configuration file `{path}`")]
Io {
path: PathBuf,
#[source]
error: std::io::Error,
},
#[error("{}", BuilderErrorDisplay("parse", .path))]
Parse {
path: Option<PathBuf>,
source: String,
#[source]
error: toml_spanner::Error,
},
#[error("{}", BuilderErrorDisplay("deserialize", .path))]
Deserialize {
path: Option<PathBuf>,
source: String,
#[source]
error: toml_spanner::FromTomlError,
},
#[error(transparent)]
Merge(#[from] BuilderMergeError),
}
impl BuilderError {
pub fn path(&self) -> impl fmt::Display + Clone {
#[derive(Clone)]
struct Helper<'a>(&'a BuilderError);
impl fmt::Display for Helper<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.0 {
BuilderError::Io { path, .. }
| BuilderError::Parse {
path: Some(path), ..
}
| BuilderError::Deserialize {
path: Some(path), ..
} => path.display().fmt(f),
BuilderError::Parse { path: None, .. }
| BuilderError::Deserialize { path: None, .. } => write!(f, "<string>"),
BuilderError::Merge(_) => write!(f, "<merged>"),
}
}
}
Helper(self)
}
pub fn toml_error(&self) -> Option<&toml_spanner::Error> {
match &self {
Self::Parse { error, .. } | Self::Merge(BuilderMergeError::Parse { error, .. }) => {
Some(error)
}
Self::Deserialize { error, .. }
| Self::Merge(BuilderMergeError::Deserialize { error, .. }) => error.errors.first(),
_ => None,
}
}
pub fn source(&self) -> Option<&str> {
match &self {
Self::Parse { source, .. }
| Self::Deserialize { source, .. }
| Self::Merge(BuilderMergeError::Parse { source, .. })
| Self::Merge(BuilderMergeError::Deserialize { source, .. }) => Some(source),
_ => None,
}
}
pub fn to_diagnostic(&self) -> Diagnostic {
let mut diagnostic = Diagnostic::error(self.to_string());
if let Some(e) = self.toml_error() {
for (span, text) in [e.primary_label(), e.secondary_label()]
.into_iter()
.flatten()
{
let span = Span::new(span.start as usize, (span.end - span.start) as usize);
let text: &str = text.trim();
let text = if text.is_empty() {
match e.kind() {
ErrorKind::UnexpectedEof => "unexpected end of file",
ErrorKind::RedefineAsArray { .. } => {
"a previously defined table was redefined as an array"
}
ErrorKind::FileTooLarge => "file is too large",
ErrorKind::Custom(message) => message,
_ => text,
}
} else {
text
};
if text.is_empty() {
diagnostic = diagnostic.with_highlight(span);
} else {
diagnostic = diagnostic.with_label(text, span);
}
}
}
if matches!(self, Self::Merge(_)) {
diagnostic = diagnostic.with_help("reported line numbers reflect merged TOML source");
}
diagnostic
}
}
#[derive(Debug)]
enum Source {
Path(PathBuf),
String(String),
}
#[derive(Default, Debug)]
pub struct ConfigBuilder<T> {
sources: Vec<Source>,
_phantom: PhantomData<T>,
}
impl<T> ConfigBuilder<T> {
pub fn with_string_source(mut self, toml: impl Into<String>) -> Self {
self.sources.push(Source::String(toml.into()));
self
}
pub fn with_file_source(mut self, path: impl Into<PathBuf>) -> Self {
self.sources.push(Source::Path(path.into()));
self
}
pub fn try_build(self) -> Result<T, BuilderError>
where
T: ToToml + for<'de> FromToml<'de>,
{
let sources: Vec<(Option<PathBuf>, String)> = self
.sources
.into_iter()
.map(|s| match s {
Source::Path(path) => {
let source = fs::read_to_string(&path).map_err(|e| BuilderError::Io {
path: path.clone(),
error: e,
})?;
Ok((Some(path), source))
}
Source::String(source) => Ok((None, source)),
})
.collect::<Result<_, BuilderError>>()?;
let arena = Arena::new();
let documents = sources
.iter()
.map(|(path, source)| {
toml_spanner::parse(source, &arena).map_err(|e| BuilderError::Parse {
path: path.clone(),
source: source.clone(),
error: e,
})
})
.collect::<Result<Vec<_>, _>>()?;
let mut merged_table: Table<'_> = Table::new();
for (index, mut document) in documents.into_iter().enumerate() {
document.to::<T>().map_err(|e| {
let (path, source) = &sources[index];
BuilderError::Deserialize {
path: path.clone(),
source: source.clone(),
error: e,
}
})?;
Self::merge_tables(document.into_table(), &mut merged_table, &arena);
}
let source =
toml_spanner::to_string(&merged_table).map_err(BuilderMergeError::Serialize)?;
Ok(toml_spanner::parse(&source, &arena)
.map_err(|e| BuilderMergeError::Parse {
source: source.clone(),
error: e,
})?
.to()
.map_err(|e| BuilderMergeError::Deserialize {
source: source.clone(),
error: e,
})?)
}
fn merge_tables<'de>(src: Table<'de>, dest: &mut Table<'de>, arena: &'de Arena) {
for (key, src_item) in src {
let Some(dest_item) = dest.get_mut(key.name) else {
dest.insert(key, src_item, arena);
continue;
};
if let Some(src_array) = src_item.as_array()
&& let Some(dest_array) = dest_item.as_array_mut()
{
for element in src_array {
dest_array.push(element.clone_in(arena), arena);
}
continue;
}
if let Some(_) = src_item.as_table()
&& let Some(dest_table) = dest_item.as_table_mut()
{
Self::merge_tables(src_item.into_table().unwrap(), dest_table, arena);
continue;
}
dest.insert(key, src_item, arena);
}
}
}
#[cfg(test)]
mod test {
use std::collections::HashMap;
use std::io::Write;
use codespan_reporting::files::SimpleFile;
use codespan_reporting::term::DisplayStyle;
use codespan_reporting::term::emit_into_string;
use codespan_reporting::term::{self};
use futures::future::BoxFuture;
use pretty_assertions::assert_eq;
use tempfile::TempPath;
use tempfile::tempdir;
use super::*;
use crate::ONE_GIBIBYTE;
use crate::TaskInputs;
use crate::backend::TaskExecutionConstraints;
use crate::http::Location;
use crate::v1::DEFAULT_TASK_REQUIREMENT_CPU;
use crate::v1::DEFAULT_TASK_REQUIREMENT_DISKS;
use crate::v1::DEFAULT_TASK_REQUIREMENT_MEMORY;
#[test]
fn redacted_secret() {
let mut map: HashMap<_, SecretString> = HashMap::new();
map.insert(
"foo",
SecretString {
inner: "secret".into(),
redacted: false,
},
);
assert_eq!(
toml_spanner::to_string(&map).unwrap().trim(),
format!(r#"foo = "secret""#)
);
map.insert(
"foo",
SecretString {
inner: "secret".into(),
redacted: true,
},
);
assert_eq!(
toml_spanner::to_string(&map).unwrap().trim(),
format!(r#"foo = "{REDACTED}""#)
);
}
#[test]
fn redacted_config() {
let config = Config {
backends: [
(
"first".to_string(),
TesBackendConfig {
auth: Some(TesBackendAuthConfig::Basic {
config: BasicAuthConfig {
username: "foo".into(),
password: "secret".into(),
},
}),
..Default::default()
}
.into(),
),
(
"second".to_string(),
TesBackendConfig {
auth: Some(
BearerAuthConfig {
token: "secret".into(),
}
.into(),
),
..Default::default()
}
.into(),
),
]
.into(),
storage: StorageConfig {
azure: AzureStorageConfig {
auth: Some(AzureStorageAuthConfig {
account_name: "foo".into(),
access_key: "secret".into(),
}),
},
s3: S3StorageConfig {
auth: Some(S3StorageAuthConfig {
access_key_id: "foo".into(),
secret_access_key: "secret".into(),
}),
..Default::default()
},
google: GoogleStorageConfig {
auth: Some(GoogleStorageAuthConfig {
access_key: "foo".into(),
secret: "secret".into(),
}),
},
},
..Default::default()
};
let toml = toml_spanner::to_string(&config).unwrap();
assert!(toml.contains("secret"), "`{toml}` contains a secret");
}
#[tokio::test]
async fn test_config_validate() {
let mut config = Config::default();
config.task.retries = 255.into();
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"configuration value `task.retries` cannot exceed 100"
);
let mut config = Config::default();
config.workflow.scatter.concurrency = 0;
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"configuration value `workflow.scatter.concurrency` cannot be zero"
);
let config = Config {
backend: "foo".into(),
..Default::default()
};
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"a backend named `foo` is not present in the configuration"
);
let config = Config {
backend: "bar".into(),
backends: [("foo".to_string(), BackendConfig::default())].into(),
..Default::default()
};
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"a backend named `bar` is not present in the configuration"
);
let config = Config {
backend: "foo".to_string(),
backends: [("foo".to_string(), BackendConfig::default())].into(),
..Default::default()
};
config.validate().await.expect("config should validate");
let config = Config {
backends: [(
"default".to_string(),
LocalBackendConfig {
cpu: Some(0),
..Default::default()
}
.into(),
)]
.into(),
..Default::default()
};
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"local backend configuration value `cpu` cannot be zero"
);
let config = Config {
backends: [(
"default".to_string(),
LocalBackendConfig {
cpu: Some(10000000),
..Default::default()
}
.into(),
)]
.into(),
..Default::default()
};
assert!(
config
.validate()
.await
.unwrap_err()
.to_string()
.starts_with(
"local backend configuration value `cpu` cannot exceed the virtual CPUs \
available to the host"
)
);
let config = Config {
backends: [(
"default".to_string(),
LocalBackendConfig {
memory: Some("0 GiB".to_string()),
..Default::default()
}
.into(),
)]
.into(),
..Default::default()
};
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"local backend configuration value `memory` cannot be zero"
);
let config = Config {
backends: [(
"default".to_string(),
LocalBackendConfig {
memory: Some("100 meows".to_string()),
..Default::default()
}
.into(),
)]
.into(),
..Default::default()
};
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"local backend configuration value `memory` has invalid value `100 meows`"
);
let config = Config {
backends: [(
"default".to_string(),
LocalBackendConfig {
memory: Some("1000 TiB".to_string()),
..Default::default()
}
.into(),
)]
.into(),
..Default::default()
};
assert!(
config
.validate()
.await
.unwrap_err()
.to_string()
.starts_with(
"local backend configuration value `memory` cannot exceed the total memory of \
the host"
)
);
let config = Config {
backends: [("default".to_string(), TesBackendConfig::default().into())].into(),
..Default::default()
};
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"TES backend configuration value `url` is required"
);
let config = Config {
backends: [(
"default".to_string(),
TesBackendConfig {
url: Some("https://example.com".parse().unwrap()),
max_concurrency: Some(0),
..Default::default()
}
.into(),
)]
.into(),
..Default::default()
};
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"TES backend configuration value `max_concurrency` cannot be zero"
);
let config = Config {
backends: [(
"default".to_string(),
TesBackendConfig {
url: Some("http://example.com".parse().unwrap()),
inputs: Some("http://example.com".parse().unwrap()),
outputs: Some("http://example.com".parse().unwrap()),
..Default::default()
}
.into(),
)]
.into(),
..Default::default()
};
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"TES backend configuration value `url` has invalid value `http://example.com/`: URL \
must use a HTTPS scheme"
);
let config = Config {
backends: [(
"default".to_string(),
TesBackendConfig {
url: Some("http://example.com".parse().unwrap()),
inputs: Some("http://example.com".parse().unwrap()),
outputs: Some("http://example.com".parse().unwrap()),
insecure: true,
..Default::default()
}
.into(),
)]
.into(),
..Default::default()
};
config
.validate()
.await
.expect("configuration should validate");
let mut config = Config::default();
config.http.parallelism = 0.into();
assert_eq!(
config.validate().await.unwrap_err().to_string(),
"configuration value `http.parallelism` cannot be zero"
);
let mut config = Config::default();
config.http.parallelism = 5.into();
assert!(
config.validate().await.is_ok(),
"should pass for valid configuration"
);
let mut config = Config::default();
config.http.parallelism = Parallelism::default();
assert!(config.validate().await.is_ok(), "should pass for default");
#[cfg(unix)]
{
let job_name_prefix = "A".repeat(MAX_LSF_JOB_NAME_PREFIX * 2);
let mut config = Config {
experimental_features_enabled: true,
..Default::default()
};
config.backends.insert(
"default".to_string(),
LsfApptainerBackendConfig {
job_name_prefix: Some(job_name_prefix.clone()),
..Default::default()
}
.into(),
);
assert_eq!(
config.validate().await.unwrap_err().to_string(),
format!("LSF job name prefix `{job_name_prefix}` exceeds the maximum 100 bytes")
);
}
}
fn create_temp_file(contents: &str) -> TempPath {
let mut file: tempfile::NamedTempFile =
tempfile::NamedTempFile::new().expect("failed to create temporary file");
file.write_all(contents.as_bytes())
.expect("failed to write temporary file");
file.into_temp_path()
}
#[test]
fn it_builds_with_no_sources() {
let config = Config::builder().try_build().expect("should build");
assert_eq!(config, Config::default(), "should be equal");
}
#[test]
fn it_builds_with_one_source() {
let path = create_temp_file("backend = 'foo'");
let config = Config::builder()
.with_file_source(&path)
.try_build()
.expect("should build");
assert_eq!(
config,
Config {
backend: "foo".into(),
..Default::default()
},
"should be equal"
);
}
#[test]
fn it_errors_on_invalid_parse() {
let path = create_temp_file("invalid");
let e = Config::builder()
.with_file_source(&path)
.try_build()
.expect_err("should fail");
let source = e.source().expect("should have source");
let diagnostic = e.to_diagnostic();
let error = emit_into_string(
&term::Config {
display_style: DisplayStyle::Rich,
..Default::default()
},
&SimpleFile::new(e.path(), source),
&diagnostic.to_codespan(()),
)
.expect("should emit");
assert!(
error.contains("expected an equals"),
"the error `{error}` does not contain the expected message"
);
}
#[test]
fn it_errors_on_invalid_deserialization() {
let path = create_temp_file("backend = 42");
let e = Config::builder()
.with_file_source(&path)
.try_build()
.expect_err("should fail");
let source = e.source().expect("should have source");
let diagnostic = e.to_diagnostic();
let error = emit_into_string(
&term::Config {
display_style: DisplayStyle::Rich,
..Default::default()
},
&SimpleFile::new(e.path(), source),
&diagnostic.to_codespan(()),
)
.expect("should emit");
assert!(
error.contains("expected a string"),
"the error `{error}` does not contain the expected message"
);
}
#[test]
fn it_merges_sources() {
let first = create_temp_file(
r#"
backend = 'foo'
[task]
excluded_cache_inputs = ['1', '2', '3']
[backends.foo]
type = 'local'
"#,
);
let second = create_temp_file(
r#"
[task]
excluded_cache_inputs = ['4', '5']
[backends.bar]
type = 'docker'
"#,
);
let third: TempPath = create_temp_file(
r#"
backend = 'baz'
[task]
excluded_cache_inputs = ['6', '7', '8']
[backends.baz]
type = 'tes'
"#,
);
let fourth = r#"
backend = 'qux'
[task]
excluded_cache_inputs = ['9', '10']
[backends.qux]
type = 'lsf_apptainer'
"#;
let config = Config::builder()
.with_file_source(&first)
.with_file_source(&second)
.with_file_source(&third)
.with_string_source(fourth)
.try_build()
.expect("should build");
assert_eq!(
config,
Config {
backend: "qux".into(),
task: TaskConfig {
excluded_cache_inputs: vec![
"1".to_string(),
"2".to_string(),
"3".to_string(),
"4".to_string(),
"5".to_string(),
"6".to_string(),
"7".to_string(),
"8".to_string(),
"9".to_string(),
"10".to_string(),
],
..Default::default()
},
backends: IndexMap::from_iter([
("foo".to_string(), LocalBackendConfig::default().into()),
("bar".to_string(), DockerBackendConfig::default().into()),
("baz".to_string(), TesBackendConfig::default().into()),
(
"qux".to_string(),
LsfApptainerBackendConfig::default().into()
),
]),
..Default::default()
},
"should be equal"
);
}
#[test]
fn parallelism_serialization() {
let map: HashMap<&str, Parallelism> =
HashMap::from_iter([("value", Parallelism::Available)]);
assert_eq!(
toml_spanner::to_string(&map).unwrap(),
format!("value = \"available\"\n")
);
let map: HashMap<&str, Parallelism> =
HashMap::from_iter([("value", Parallelism::Use(123))]);
assert_eq!(toml_spanner::to_string(&map).unwrap(), "value = 123\n");
}
#[test]
fn parallelism_deserialization() {
let map: HashMap<String, Parallelism> =
toml_spanner::from_str("value = 'available'").unwrap();
assert_eq!(map["value"], Parallelism::Available);
let map: HashMap<String, Parallelism> = toml_spanner::from_str("value = 123").unwrap();
assert_eq!(map["value"], Parallelism::Use(123));
let expected_error =
"expected a positive integer or `available` for parallelism at `value`";
let error =
toml_spanner::from_str::<HashMap<String, Parallelism>>("value = 'wrong'").unwrap_err();
assert_eq!(error.to_string(), expected_error);
let error =
toml_spanner::from_str::<HashMap<String, Parallelism>>("value = 0").unwrap_err();
assert_eq!(error.to_string(), expected_error);
let error =
toml_spanner::from_str::<HashMap<String, Parallelism>>("value = -10").unwrap_err();
assert_eq!(error.to_string(), expected_error);
}
#[test]
fn retries_serialization() {
let map: HashMap<&str, Retries> = HashMap::from_iter([("value", Retries::Default)]);
assert_eq!(
toml_spanner::to_string(&map).unwrap(),
format!("value = \"default\"\n")
);
let map: HashMap<&str, Retries> = HashMap::from_iter([("value", Retries::Use(123))]);
assert_eq!(toml_spanner::to_string(&map).unwrap(), "value = 123\n");
}
#[test]
fn retries_deserialization() {
let map: HashMap<String, Retries> = toml_spanner::from_str("value = 'default'").unwrap();
assert_eq!(map["value"], Retries::Default);
let map: HashMap<String, Retries> = toml_spanner::from_str("value = 12").unwrap();
assert_eq!(map["value"], Retries::Use(12));
let map: HashMap<String, Retries> = toml_spanner::from_str("value = 0").unwrap();
assert_eq!(map["value"], Retries::Use(0));
let expected_error = format!(
"expected an integer less than {MAX_RETRIES} or `default` for retries at `value`"
);
let error =
toml_spanner::from_str::<HashMap<String, Retries>>("value = 'wrong'").unwrap_err();
assert_eq!(error.to_string(), expected_error);
let error = toml_spanner::from_str::<HashMap<String, Retries>>("value = 101").unwrap_err();
assert_eq!(error.to_string(), expected_error);
let error = toml_spanner::from_str::<HashMap<String, Retries>>("value = -10").unwrap_err();
assert_eq!(error.to_string(), expected_error);
}
#[test]
fn mapping_escape_indexes() {
assert!(escape_mapping("").is_empty());
assert!(escape_mapping("hello world!").is_empty());
assert_eq!(escape_mapping(r#"\"\""#), &[(1, 2), (2, 4)]);
assert_eq!(escape_mapping(r#"\u0022\u0022"#), &[(1, 6), (2, 12)]);
assert_eq!(
escape_mapping(r#"\U00000022\U00000022"#),
&[(1, 10), (2, 20)]
);
assert_eq!(
escape_mapping(r#"\"foo\u0022 == \U00000022bar\" && \"\" == \"\n\""#),
&[
(1, 2), (5, 11), (10, 25), (14, 30), (19, 36), (20, 38), (25, 44), (26, 46), (27, 48) ]
);
}
#[test]
fn conditional_args_serialization() {
let error = toml_spanner::from_str::<ConditionalArgs>("condition = 1").unwrap_err();
assert_eq!(
error.to_string(),
"expected a string, found integer at `condition`"
);
let error =
toml_spanner::from_str::<ConditionalArgs>(r#"condition = "foo bar""#).unwrap_err();
assert_eq!(error.to_string(), "expected a single WDL expression");
let error =
toml_spanner::from_str::<ConditionalArgs>(r#"condition = "{ foo: }""#).unwrap_err();
assert_eq!(error.to_string(), "expected expression, but found `}`");
let error = toml_spanner::from_str::<ConditionalArgs>(r#"condition = "1""#).unwrap_err();
assert_eq!(
error.to_string(),
"conditional expression is expected to be type `Boolean`, but found type `Int`"
);
let error = toml_spanner::from_str::<ConditionalArgs>(r#"condition = "hint""#).unwrap_err();
assert_eq!(
error.to_string(),
"conditional expression is expected to be type `Boolean`, but found type `Object`"
);
let error = toml_spanner::from_str::<ConditionalArgs>(r#"condition = "foo""#).unwrap_err();
assert_eq!(error.to_string(), "unknown name `foo`");
let error =
toml_spanner::from_str::<ConditionalArgs>(r#"condition = "Foo {}""#).unwrap_err();
assert_eq!(error.to_string(), "unknown type name `Foo`");
let args: ConditionalArgs = toml_spanner::from_str(r#"condition = "true""#).unwrap();
assert_eq!(args.condition.raw, "true");
assert_eq!(
toml_spanner::to_string(&args).unwrap(),
"condition = \"true\"\nargs = []\n"
);
let args: ConditionalArgs =
toml_spanner::from_str(r#"condition = "cpu == 1 && hint.bar == \"foo\"""#).unwrap();
assert_eq!(args.condition.raw, r#"cpu == 1 && hint.bar == "foo""#);
assert_eq!(
toml_spanner::to_string(&args).unwrap(),
"condition = 'cpu == 1 && hint.bar == \"foo\"'\nargs = []\n"
);
}
#[test]
fn validate_conditional_args() {
let args: ConditionalArgs = toml_spanner::from_str(r#"condition = "true""#).unwrap();
assert_eq!(
args.validate().unwrap_err().to_string(),
"backend conditional arguments must have at least one argument specified"
);
}
#[tokio::test]
async fn evaluate_conditions() {
struct Context {
cpu: f64,
memory: u64,
gpu: bool,
fpga: bool,
disks: i64,
inputs: TaskInputs,
hints: Object,
}
impl Default for Context {
fn default() -> Self {
Self {
cpu: DEFAULT_TASK_REQUIREMENT_CPU,
memory: DEFAULT_TASK_REQUIREMENT_MEMORY as u64,
gpu: false,
fpga: false,
disks: (DEFAULT_TASK_REQUIREMENT_DISKS * ONE_GIBIBYTE) as i64,
inputs: Default::default(),
hints: Default::default(),
}
}
}
struct Transferer;
impl crate::http::Transferer for Transferer {
fn download<'a>(&'a self, _: &'a Url) -> BoxFuture<'a, Result<Location>> {
unimplemented!()
}
fn upload<'a>(&'a self, _: &'a Path, _: &'a Url) -> BoxFuture<'a, Result<()>> {
unimplemented!()
}
fn size<'a>(&'a self, _: &'a Url) -> BoxFuture<'a, anyhow::Result<Option<u64>>> {
unimplemented!()
}
fn walk<'a>(&'a self, _: &'a Url) -> BoxFuture<'a, Result<Arc<[String]>>> {
unimplemented!()
}
fn exists<'a>(&'a self, _: &'a Url) -> BoxFuture<'a, Result<bool>> {
unimplemented!()
}
fn digest<'a>(
&'a self,
_: &'a Url,
) -> BoxFuture<'a, Result<Option<Arc<cloud_copy::ContentDigest>>>> {
unimplemented!()
}
}
async fn eval(context: Context, expression: &str) -> Result<bool> {
let dir = tempdir().context("failed to create temporary directory")?;
let condition = Condition::new(expression).expect("invalid expression");
condition
.evaluate(
&ExecuteTaskRequest {
id: "test",
command: "",
inputs: &context.inputs,
backend_inputs: &[],
requirements: &Object::empty(),
hints: &context.hints,
env: &Default::default(),
constraints: &TaskExecutionConstraints {
container: None,
cpu: context.cpu,
memory: context.memory,
gpu: if context.gpu {
vec![String::new()]
} else {
Default::default()
},
fpga: if context.fpga {
vec![String::new()]
} else {
Default::default()
},
disks: IndexMap::from_iter([("".into(), context.disks)]),
},
base_dir: &EvaluationPath::from_local_path(dir.path().into()),
attempt_dir: &dir.path().join("0"),
temp_dir: &dir.path().join("tmp"),
},
&Transferer,
)
.await
}
assert_eq!(eval(Context::default(), "true").await.unwrap(), true);
assert_eq!(eval(Context::default(), "false").await.unwrap(), false);
assert_eq!(eval(Context::default(), "cpu == 1").await.unwrap(), true);
assert_eq!(
eval(Context::default(), "memory == 2147483648")
.await
.unwrap(),
true
);
assert_eq!(eval(Context::default(), "gpu").await.unwrap(), false);
assert_eq!(eval(Context::default(), "fpga").await.unwrap(), false);
assert_eq!(
eval(Context::default(), "disks == 1073741824")
.await
.unwrap(),
true
);
assert_eq!(
eval(Context::default(), "defined(hint.foo)").await.unwrap(),
false
);
assert_eq!(
eval(
Context {
cpu: 10.,
memory: 10 * 1024 * 1024,
gpu: true,
fpga: true,
disks: 1024 * 1024,
inputs: Default::default(),
hints: Object::new(IndexMap::from_iter([(
"foo".into(),
"hi".to_string().into()
)]))
},
r#"cpu == 10 && memory == 10*1024*1024 && gpu && fpga && disks == 1024 * 1024 && hint.foo == "hi""#
)
.await
.unwrap(),
true
);
let mut context = Context {
hints: Object::new(IndexMap::from_iter([(
"foo".into(),
"hi".to_string().into(),
)])),
..Default::default()
};
context
.inputs
.override_hint("foo", "overridden!".to_string());
assert_eq!(
eval(context, r#"hint.foo == "overridden!""#).await.unwrap(),
true
);
}
}