mod inputs;
mod operations;
mod pool;
mod types;
pub use inputs::*;
pub use operations::*;
pub use pool::*;
pub use types::*;
use crate::error::{ErrorData, Result};
use crate::instance_catalog::Architecture;
use crate::{
CapacityGroup, CapacityGroupScalePolicy, ComputeChoiceRange, DaemonRuntime, DaemonRuntimeMount,
DataDiskRequirement, GpuSpec, MachineProfile, ResourceSpec, StackInputDefaultValue,
StackInputKind, StackInputValidation,
};
use alien_error::AlienError;
use serde::{Deserialize, Serialize};
const GIB: u64 = 1024 * 1024 * 1024;
pub const DEFAULT_EPHEMERAL_STORAGE: &str = "20Gi";
pub const MAX_STOP_GRACE_PERIOD_SECONDS: u32 = 86_400;
fn invalid(field: &str, reason: impl Into<String>) -> AlienError<ErrorData> {
AlienError::new(ErrorData::StackDefinitionInvalid {
field: field.to_string(),
reason: reason.into(),
})
}
pub fn parse_quantity_bytes(field: &str, value: &str) -> Result<u64> {
quantity_bytes(value).map_err(|reason| invalid(field, reason))
}
pub fn quantity_bytes(value: &str) -> std::result::Result<u64, String> {
let (number, multiplier) = [
("Ki", 1024_f64),
("Mi", 1024_f64.powi(2)),
("Gi", 1024_f64.powi(3)),
("Ti", 1024_f64.powi(4)),
("k", 1e3),
("M", 1e6),
("G", 1e9),
("T", 1e12),
]
.into_iter()
.find_map(|(suffix, multiplier)| Some((value.strip_suffix(suffix)?, multiplier)))
.unwrap_or((value, 1.0));
let (whole, fraction) = match number.split_once('.') {
Some((whole, fraction)) => (whole, Some(fraction)),
None => (number, None),
};
let is_digits = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit());
if !is_digits(whole) || fraction.is_some_and(|fraction| !is_digits(fraction)) {
return Err(format!(
"'{value}' is not a quantity; use a number with an optional unit such as \"512Mi\", \"16Gi\" or \"1Ti\""
));
}
let amount: f64 = number
.parse()
.map_err(|_| format!("'{value}' is not a quantity"))?;
let bytes = (amount * multiplier).round();
if bytes >= u64::MAX as f64 {
return Err(format!("'{value}' is too large"));
}
Ok(bytes as u64)
}
pub fn parse_duration_seconds(field: &str, value: &str) -> Result<u64> {
let millis = duration_millis(value).map_err(|reason| invalid(field, reason))?;
if millis % 1_000 != 0 {
return Err(invalid(
field,
format!("'{value}' is not a whole number of seconds"),
));
}
Ok(millis / 1_000)
}
pub fn duration_millis(value: &str) -> std::result::Result<u64, String> {
let malformed = || {
format!("'{value}' is not a duration; use whole numbers with units, such as \"30s\", \"10m\" or \"24h\"")
};
if value.is_empty() {
return Err(malformed());
}
let mut total: u64 = 0;
let mut rest = value;
while !rest.is_empty() {
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
if digits == 0 {
return Err(malformed());
}
let amount: u64 = rest[..digits].parse().map_err(|_| malformed())?;
let unit_length = rest[digits..]
.bytes()
.take_while(u8::is_ascii_alphabetic)
.count();
let millis_per_unit = match &rest[digits..digits + unit_length] {
"ms" => 1,
"s" => 1_000,
"m" => 60_000,
"h" => 3_600_000,
"d" => 86_400_000,
_ => return Err(malformed()),
};
total = amount
.checked_mul(millis_per_unit)
.and_then(|millis| total.checked_add(millis))
.ok_or_else(|| format!("'{value}' is too long"))?;
rest = &rest[digits + unit_length..];
}
Ok(total)
}
pub fn validate_resource_id(id: &str) -> std::result::Result<(), &'static str> {
let (Some(&first), Some(&last)) = (id.as_bytes().first(), id.as_bytes().last()) else {
return Err("must not be empty");
};
if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
return Err("must start with a lowercase letter or digit");
}
if !last.is_ascii_lowercase() && !last.is_ascii_digit() {
return Err("must end with a lowercase letter or digit");
}
if !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err("must only contain lowercase letters, digits, and hyphens");
}
if id.contains("--") {
return Err("must not contain consecutive hyphens");
}
Ok(())
}
pub const MAX_DYNAMIC_CONTAINER_REPOSITORIES: usize = 16;
pub fn is_repository_name(repository: &str) -> bool {
let is_alphanumeric = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
let is_segment = |part: &str, separators: &[u8]| {
let bytes = part.as_bytes();
matches!((bytes.first(), bytes.last()), (Some(&first), Some(&last))
if is_alphanumeric(first) && is_alphanumeric(last))
&& bytes
.iter()
.all(|&byte| is_alphanumeric(byte) || separators.contains(&byte))
};
let Some((authority, path)) = repository.split_once('/') else {
return false;
};
if !path.split('/').all(|part| is_segment(part, b"._-")) {
return false;
}
let mut authority = authority.split(':');
let host = authority.next().unwrap_or_default();
let port = authority.next();
authority.next().is_none()
&& port.is_none_or(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()))
&& host.split('.').all(|label| is_segment(label, b"-"))
}
pub fn validate_dynamic_container_repositories(repositories: &[String]) -> Result<()> {
const FIELD: &str = "dynamicContainerRepositories";
if repositories.is_empty() || repositories.len() > MAX_DYNAMIC_CONTAINER_REPOSITORIES {
return Err(invalid(
FIELD,
format!(
"must list 1 to {MAX_DYNAMIC_CONTAINER_REPOSITORIES} repositories, found {}",
repositories.len()
),
));
}
for (index, repository) in repositories.iter().enumerate() {
let field = format!("{FIELD}.{index}");
if !is_repository_name(repository) {
return Err(invalid(
&field,
format!(
"'{repository}' is not a fully qualified OCI repository; write the registry host and the path, such as \"ghcr.io/acme/tools\""
),
));
}
if repositories[..index].contains(repository) {
return Err(invalid(&field, format!("'{repository}' is listed twice")));
}
}
Ok(())
}
pub fn validate_dynamic_container_image_resources(resource_ids: &[String]) -> Result<()> {
const FIELD: &str = "dynamicContainerImageResources";
if resource_ids.is_empty() {
return Err(invalid(FIELD, "must list at least one container"));
}
for (index, id) in resource_ids.iter().enumerate() {
if resource_ids[..index].contains(id) {
return Err(invalid(
&format!("{FIELD}.{index}"),
format!("'{id}' is listed twice"),
));
}
}
Ok(())
}
pub fn stop_grace_period_seconds(field: &str, seconds: u64) -> Result<u32> {
match u32::try_from(seconds) {
Ok(seconds) if (1..=MAX_STOP_GRACE_PERIOD_SECONDS).contains(&seconds) => Ok(seconds),
_ => Err(invalid(
field,
format!("{seconds} seconds is outside 1 second to 24 hours"),
)),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(untagged)]
pub enum CpuAmount {
Integer(i64),
Float(f64),
Quantity(String),
}
impl CpuAmount {
pub fn to_quantity(&self, field: &str) -> Result<String> {
let positive = |cores: f64| {
if cores > 0.0 && cores.is_finite() {
Ok(())
} else {
Err(invalid(field, "CPU must be greater than zero"))
}
};
match self {
CpuAmount::Integer(cores) => {
positive(*cores as f64)?;
Ok(cores.to_string())
}
CpuAmount::Float(cores) => {
positive(*cores)?;
Ok(cores.to_string())
}
CpuAmount::Quantity(quantity) => {
let cores = crate::instance_catalog::parse_cpu(quantity)
.map_err(|reason| invalid(field, reason))?;
positive(cores)?;
Ok(quantity.clone())
}
}
}
pub fn to_resource_spec(&self, field: &str) -> Result<ResourceSpec> {
let quantity = self.to_quantity(field)?;
Ok(ResourceSpec {
min: quantity.clone(),
desired: quantity,
})
}
}
pub fn memory_resource_spec(field: &str, size: &str) -> Result<ResourceSpec> {
if parse_quantity_bytes(field, size)? == 0 {
return Err(invalid(field, "memory must be greater than zero"));
}
Ok(ResourceSpec {
min: size.to_string(),
desired: size.to_string(),
})
}
impl DaemonRuntime {
pub fn host_access() -> Self {
DaemonRuntime {
privileged: Some(true),
pid_namespace: Some("host".to_string()),
network_mode: Some("host".to_string()),
mounts: vec![DaemonRuntimeMount {
source: "/".to_string(),
target: "/host".to_string(),
options: None,
}],
user: Some("0".to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(untagged)]
pub enum ComputeChoice {
Exactly(u32),
Range(ComputeChoiceRange),
}
impl ComputeChoice {
fn range(&self, field: &str) -> Result<ComputeChoiceRange> {
match self {
ComputeChoice::Exactly(count) => Ok(ComputeChoiceRange {
min: *count,
max: *count,
default: *count,
}),
ComputeChoice::Range(range) => {
if range.min <= range.default && range.default <= range.max {
Ok(range.clone())
} else {
Err(invalid(
field,
format!(
"needs min <= default <= max, got min {}, default {}, max {}",
range.min, range.default, range.max
),
))
}
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PoolScale {
Fixed { machines: ComputeChoice },
Autoscale {
min: ComputeChoice,
max: ComputeChoice,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(untagged)]
pub enum DataDiskSize {
Fixed(String),
Range {
min: String,
default: String,
max: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct PoolRequirements {
pub cpu: CpuAmount,
pub memory: String,
pub ephemeral_storage: Option<String>,
pub architecture: Option<Architecture>,
pub nested_virtualization: Option<bool>,
pub gpu: Option<GpuSpec>,
pub data_disk: Option<DataDiskSize>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ComputePool {
pub requirements: PoolRequirements,
pub scale: PoolScale,
}
impl ComputePool {
pub fn to_capacity_group(&self, group_id: &str) -> Result<CapacityGroup> {
let requirements = &self.requirements;
let (scale_policy, min_size, max_size) = match &self.scale {
PoolScale::Fixed { machines } => {
let machines = machines.range("scale.machines")?;
let selected = machines.default;
(
CapacityGroupScalePolicy::Fixed { machines },
selected,
selected,
)
}
PoolScale::Autoscale { min, max } => {
let min = min.range("scale.min")?;
let max = max.range("scale.max")?;
if min.default > max.default {
return Err(invalid(
"scale",
format!(
"the default minimum ({}) is above the default maximum ({})",
min.default, max.default
),
));
}
let (min_size, max_size) = (min.default, max.default);
(
CapacityGroupScalePolicy::Autoscale { min, max },
min_size,
max_size,
)
}
};
let data_disk_gib_range = match &requirements.data_disk {
Some(DataDiskSize::Range { min, default, max }) => {
let range = ComputeChoiceRange {
min: whole_gib("dataDisk.size.min", min)?,
max: whole_gib("dataDisk.size.max", max)?,
default: whole_gib("dataDisk.size.default", default)?,
};
if !(range.min <= range.default && range.default <= range.max) {
return Err(invalid(
"dataDisk.size",
format!("needs min <= default <= max: {min}, {default}, {max}"),
));
}
if range.max == 0 {
return Err(invalid("dataDisk.size.max", "must be greater than zero"));
}
Some(range)
}
Some(DataDiskSize::Fixed(_)) | None => None,
};
let data_disk = match &requirements.data_disk {
None => None,
Some(DataDiskSize::Fixed(size)) => {
let size_bytes = parse_quantity_bytes("dataDisk.size", size)?;
if size_bytes == 0 {
return Err(invalid(
"dataDisk.size",
format!("must be greater than zero: {size}"),
));
}
Some(DataDiskRequirement { size_bytes })
}
Some(DataDiskSize::Range { .. }) => data_disk_gib_range
.as_ref()
.filter(|range| range.default > 0)
.map(|range| DataDiskRequirement {
size_bytes: u64::from(range.default) * GIB,
}),
};
let profile = MachineProfile {
cpu: requirements.cpu.to_quantity("cpu")?,
memory_bytes: parse_quantity_bytes("memory", &requirements.memory)?,
ephemeral_storage_bytes: parse_quantity_bytes(
"ephemeralStorage",
requirements
.ephemeral_storage
.as_deref()
.unwrap_or(DEFAULT_EPHEMERAL_STORAGE),
)?,
architecture: requirements.architecture,
gpu: requirements.gpu.clone(),
data_disk,
};
Ok(CapacityGroup {
group_id: group_id.to_string(),
instance_type: None,
profile: Some(profile),
min_size,
max_size,
scale_policy: Some(scale_policy),
nested_virtualization: requirements.nested_virtualization,
data_disk_gib_range,
})
}
}
fn whole_gib(field: &str, value: &str) -> Result<u32> {
let bytes = parse_quantity_bytes(field, value)?;
if bytes % GIB != 0 {
return Err(invalid(
field,
format!("must be a whole number of GiB: {value}"),
));
}
u32::try_from(bytes / GIB).map_err(|_| invalid(field, format!("'{value}' is too large")))
}
pub fn failure_domain_spread(field: &str, spread: i64) -> Result<u8> {
u8::try_from(spread)
.ok()
.filter(|spread| *spread >= 1)
.ok_or_else(|| {
invalid(
field,
format!("must be an integer from 1 to 255, got {spread}"),
)
})
}
pub fn input_validation(
kind: &StackInputKind,
validation: StackInputValidation,
) -> Option<StackInputValidation> {
match kind {
StackInputKind::Boolean => None,
_ => Some(validation),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(untagged)]
pub enum InputValue {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
StringList(Vec<String>),
}
impl InputValue {
fn describe(&self) -> &'static str {
match self {
InputValue::String(_) => "a string",
InputValue::Integer(_) => "an integer",
InputValue::Float(_) => "a number",
InputValue::Boolean(_) => "a boolean",
InputValue::StringList(_) => "a list of strings",
}
}
}
pub fn input_default(kind: &StackInputKind, value: &InputValue) -> Result<StackInputDefaultValue> {
let mismatch = |expected: &str| {
invalid(
"default",
format!(
"expected {expected} for a {kind:?} input, got {}",
value.describe()
),
)
};
match (kind, value) {
(StackInputKind::Secret, _) => Err(invalid(
"default",
"secret inputs cannot have a default value",
)),
(StackInputKind::String | StackInputKind::Enum, InputValue::String(text)) => {
Ok(StackInputDefaultValue::String(text.clone()))
}
(StackInputKind::String | StackInputKind::Enum, _) => Err(mismatch("a string")),
(StackInputKind::Number | StackInputKind::Integer, _) => Ok(
StackInputDefaultValue::Number(input_number("default", kind, value)?),
),
(StackInputKind::Boolean, InputValue::Boolean(flag)) => {
Ok(StackInputDefaultValue::Boolean(*flag))
}
(StackInputKind::Boolean, _) => Err(mismatch("a boolean")),
(StackInputKind::StringList, InputValue::StringList(items)) => {
Ok(StackInputDefaultValue::StringList(items.clone()))
}
(StackInputKind::StringList, _) => Err(mismatch("a list of strings")),
}
}
pub fn input_number(field: &str, kind: &StackInputKind, value: &InputValue) -> Result<String> {
let number = match value {
InputValue::Integer(number) => return Ok(number.to_string()),
InputValue::Float(number) => *number,
other => {
return Err(invalid(
field,
format!("expected a number, got {}", other.describe()),
))
}
};
if !number.is_finite() {
return Err(invalid(field, "must be a finite number"));
}
if *kind == StackInputKind::Integer && number.fract() != 0.0 {
return Err(invalid(field, format!("{number} is not a whole number")));
}
Ok(number.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn field_of(error: AlienError<ErrorData>) -> String {
match error.error.expect("error data") {
ErrorData::StackDefinitionInvalid { field, .. } => field,
other => panic!("unexpected error {other:?}"),
}
}
#[test]
fn quantities_match_the_typescript_sdk() {
assert_eq!(parse_quantity_bytes("m", "16Gi").unwrap(), 17_179_869_184);
assert_eq!(parse_quantity_bytes("m", "1Ti").unwrap(), 1_099_511_627_776);
assert_eq!(parse_quantity_bytes("m", "500M").unwrap(), 500_000_000);
assert_eq!(parse_quantity_bytes("m", "1.5Gi").unwrap(), 1_610_612_736);
assert_eq!(parse_quantity_bytes("m", "2k").unwrap(), 2_000);
assert_eq!(parse_quantity_bytes("m", "1024").unwrap(), 1_024);
assert_eq!(parse_quantity_bytes("m", "0.3Ki").unwrap(), 307);
for bad in [
"", "Gi", "16 Gi", " 16Gi", "16gi", "-1Gi", "1e3", "1.Gi", ".5Gi", "16GiB",
] {
assert_eq!(
field_of(parse_quantity_bytes("memory", bad).unwrap_err()),
"memory",
"{bad:?} must be rejected"
);
}
}
#[test]
fn repository_names_match_the_typescript_sdk() {
for good in [
"ghcr.io/acme/tools",
"localhost:5000/acme",
"registry.example.com:443/a/b-c/d.e_f",
"alpine/git",
"123456789012.dkr.ecr.us-east-1.amazonaws.com/acme",
] {
assert!(is_repository_name(good), "{good:?} must be accepted");
}
for bad in [
"",
"alpine",
"ghcr.io/",
"ghcr.io//acme",
"/acme",
"GHCR.io/acme",
"ghcr.io/Acme",
"ghcr.io/acme:latest",
"ghcr.io/acme@sha256:abc",
"ghcr.io:/acme",
"ghcr.io:80:81/acme",
"ghcr..io/acme",
"-ghcr.io/acme",
"ghcr.io/-acme",
"ghcr.io/acme-",
"ghcr.io/acme/ tools",
"https://ghcr.io/acme",
] {
assert!(!is_repository_name(bad), "{bad:?} must be rejected");
}
}
#[test]
fn dynamic_container_lists_are_bounded_and_distinct() {
let names = |items: &[&str]| {
items
.iter()
.map(|item| item.to_string())
.collect::<Vec<_>>()
};
validate_dynamic_container_repositories(&names(&["ghcr.io/acme/a", "ghcr.io/acme/b"]))
.unwrap();
assert_eq!(
field_of(validate_dynamic_container_repositories(&[]).unwrap_err()),
"dynamicContainerRepositories"
);
let seventeen: Vec<String> = (0..17).map(|i| format!("ghcr.io/acme/r{i}")).collect();
assert_eq!(
field_of(validate_dynamic_container_repositories(&seventeen).unwrap_err()),
"dynamicContainerRepositories"
);
validate_dynamic_container_repositories(&seventeen[..16]).unwrap();
assert_eq!(
field_of(
validate_dynamic_container_repositories(&names(&["ghcr.io/acme/a", "alpine"]))
.unwrap_err()
),
"dynamicContainerRepositories.1"
);
assert_eq!(
field_of(
validate_dynamic_container_repositories(&names(&[
"ghcr.io/acme/a",
"ghcr.io/acme/a"
]))
.unwrap_err()
),
"dynamicContainerRepositories.1"
);
validate_dynamic_container_image_resources(&names(&["api", "web"])).unwrap();
assert_eq!(
field_of(validate_dynamic_container_image_resources(&[]).unwrap_err()),
"dynamicContainerImageResources"
);
assert_eq!(
field_of(
validate_dynamic_container_image_resources(&names(&["api", "api"])).unwrap_err()
),
"dynamicContainerImageResources.1"
);
}
#[test]
fn durations() {
assert_eq!(parse_duration_seconds("d", "90s").unwrap(), 90);
assert_eq!(parse_duration_seconds("d", "10m").unwrap(), 600);
assert_eq!(parse_duration_seconds("d", "24h").unwrap(), 86_400);
assert_eq!(parse_duration_seconds("d", "1h30m").unwrap(), 5_400);
assert_eq!(parse_duration_seconds("d", "1d").unwrap(), 86_400);
assert_eq!(parse_duration_seconds("d", "2000ms").unwrap(), 2);
assert_eq!(duration_millis("250ms").unwrap(), 250);
assert_eq!(duration_millis("1h30m").unwrap(), 5_400_000);
for bad in ["", "10", "h", "1.5h", "10 m", "-1s", "1h30", "1x", "5mm"] {
assert!(
parse_duration_seconds("d", bad).is_err(),
"{bad:?} must be rejected"
);
}
assert_eq!(
field_of(parse_duration_seconds("grace", "1500ms").unwrap_err()),
"grace",
"a fraction of a second is not a whole number of seconds"
);
assert_eq!(stop_grace_period_seconds("g", 86_400).unwrap(), 86_400);
assert!(stop_grace_period_seconds("g", 86_401).is_err());
assert!(stop_grace_period_seconds("g", 0).is_err());
}
#[test]
fn cpu_amounts_print_like_javascript_numbers() {
assert_eq!(CpuAmount::Integer(2).to_quantity("cpu").unwrap(), "2");
assert_eq!(CpuAmount::Float(0.5).to_quantity("cpu").unwrap(), "0.5");
assert_eq!(CpuAmount::Float(2.0).to_quantity("cpu").unwrap(), "2");
assert_eq!(
CpuAmount::Quantity("500m".into())
.to_quantity("cpu")
.unwrap(),
"500m"
);
assert!(CpuAmount::Integer(0).to_quantity("cpu").is_err());
assert!(CpuAmount::Quantity("lots".into())
.to_quantity("cpu")
.is_err());
}
fn pool(scale: PoolScale, data_disk: Option<DataDiskSize>) -> ComputePool {
ComputePool {
requirements: PoolRequirements {
cpu: CpuAmount::Integer(4),
memory: "16Gi".into(),
ephemeral_storage: None,
architecture: Some(Architecture::X86_64),
nested_virtualization: Some(true),
gpu: None,
data_disk,
},
scale,
}
}
#[test]
fn fixed_pool_selects_the_default_machine_count() {
let group = pool(
PoolScale::Fixed {
machines: ComputeChoice::Range(ComputeChoiceRange {
min: 1,
max: 5,
default: 2,
}),
},
None,
)
.to_capacity_group("general")
.unwrap();
assert_eq!(
group,
CapacityGroup {
group_id: "general".into(),
instance_type: None,
profile: Some(MachineProfile {
cpu: "4".into(),
memory_bytes: 16 * GIB,
ephemeral_storage_bytes: 20 * GIB,
architecture: Some(Architecture::X86_64),
gpu: None,
data_disk: None,
}),
min_size: 2,
max_size: 2,
scale_policy: Some(CapacityGroupScalePolicy::Fixed {
machines: ComputeChoiceRange {
min: 1,
max: 5,
default: 2
}
}),
nested_virtualization: Some(true),
data_disk_gib_range: None,
}
);
}
#[test]
fn autoscale_pool_uses_default_bounds() {
let group = pool(
PoolScale::Autoscale {
min: ComputeChoice::Exactly(1),
max: ComputeChoice::Range(ComputeChoiceRange {
min: 2,
max: 10,
default: 4,
}),
},
None,
)
.to_capacity_group("workers")
.unwrap();
assert_eq!((group.min_size, group.max_size), (1, 4));
let inverted = pool(
PoolScale::Autoscale {
min: ComputeChoice::Exactly(5),
max: ComputeChoice::Exactly(2),
},
None,
);
assert_eq!(
field_of(inverted.to_capacity_group("w").unwrap_err()),
"scale"
);
}
#[test]
fn data_disk_ranges_resolve_their_default() {
let fixed = |machines| PoolScale::Fixed {
machines: ComputeChoice::Exactly(machines),
};
let range = |min: &str, default: &str, max: &str| DataDiskSize::Range {
min: min.into(),
default: default.into(),
max: max.into(),
};
let group = pool(fixed(1), Some(range("0Gi", "200Gi", "4Ti")))
.to_capacity_group("g")
.unwrap();
assert_eq!(
group.data_disk_gib_range,
Some(ComputeChoiceRange {
min: 0,
max: 4096,
default: 200
})
);
assert_eq!(
group.profile.unwrap().data_disk,
Some(DataDiskRequirement {
size_bytes: 200 * GIB
})
);
let no_default_disk = pool(fixed(1), Some(range("0Gi", "0Gi", "1Ti")))
.to_capacity_group("g")
.unwrap();
assert_eq!(no_default_disk.profile.unwrap().data_disk, None);
let decimal = pool(fixed(1), Some(DataDiskSize::Fixed("500G".into())))
.to_capacity_group("g")
.unwrap();
assert_eq!(decimal.data_disk_gib_range, None);
assert_eq!(
decimal.profile.unwrap().data_disk,
Some(DataDiskRequirement {
size_bytes: 500_000_000_000
})
);
let cases = [
(range("1Gi", "500M", "2Gi"), "dataDisk.size.default"),
(range("2Gi", "1Gi", "3Gi"), "dataDisk.size"),
(range("0Gi", "0Gi", "0Gi"), "dataDisk.size.max"),
(DataDiskSize::Fixed("0Gi".into()), "dataDisk.size"),
];
for (data_disk, field) in cases {
let error = pool(fixed(1), Some(data_disk.clone()))
.to_capacity_group("g")
.unwrap_err();
assert_eq!(field_of(error), field, "{data_disk:?}");
}
}
#[test]
fn failure_domain_spread_bounds() {
assert_eq!(failure_domain_spread("f", 3).unwrap(), 3);
assert!(failure_domain_spread("f", 0).is_err());
assert!(failure_domain_spread("f", 256).is_err());
}
#[test]
fn host_access_is_the_full_host_runtime() {
let runtime = DaemonRuntime::host_access();
assert_eq!(runtime.privileged, Some(true));
assert_eq!(runtime.pid_namespace.as_deref(), Some("host"));
assert_eq!(runtime.network_mode.as_deref(), Some("host"));
assert_eq!(runtime.user.as_deref(), Some("0"));
assert_eq!(
runtime.mounts,
vec![DaemonRuntimeMount {
source: "/".into(),
target: "/host".into(),
options: None
}]
);
}
#[test]
fn input_defaults_are_typed_by_kind() {
use StackInputDefaultValue as Default;
let text = InputValue::String("eu".into());
assert_eq!(
input_default(&StackInputKind::Enum, &text).unwrap(),
Default::String("eu".into())
);
assert_eq!(
input_default(&StackInputKind::Number, &InputValue::Float(0.5)).unwrap(),
Default::Number("0.5".into())
);
assert_eq!(
input_default(&StackInputKind::Integer, &InputValue::Float(3.0)).unwrap(),
Default::Number("3".into())
);
assert_eq!(
input_default(&StackInputKind::Integer, &InputValue::Integer(180_000)).unwrap(),
Default::Number("180000".into())
);
assert_eq!(
input_default(&StackInputKind::StringList, &InputValue::StringList(vec![])).unwrap(),
Default::StringList(vec![])
);
for (kind, value) in [
(StackInputKind::Secret, text.clone()),
(StackInputKind::Integer, InputValue::Float(1.5)),
(StackInputKind::Boolean, text.clone()),
(StackInputKind::String, InputValue::Integer(1)),
(StackInputKind::StringList, text.clone()),
] {
assert_eq!(
field_of(input_default(&kind, &value).unwrap_err()),
"default"
);
}
}
}