1mod inputs;
13mod operations;
14mod pool;
15mod types;
16
17pub use inputs::*;
18pub use operations::*;
19pub use pool::*;
20pub use types::*;
21
22use crate::error::{ErrorData, Result};
23use crate::instance_catalog::Architecture;
24use crate::{
25 CapacityGroup, CapacityGroupScalePolicy, ComputeChoiceRange, DaemonRuntime, DaemonRuntimeMount,
26 DataDiskRequirement, GpuSpec, MachineProfile, ResourceSpec, StackInputDefaultValue,
27 StackInputKind, StackInputValidation,
28};
29use alien_error::AlienError;
30use serde::{Deserialize, Serialize};
31
32const GIB: u64 = 1024 * 1024 * 1024;
33
34pub const DEFAULT_EPHEMERAL_STORAGE: &str = "20Gi";
36
37pub const MAX_STOP_GRACE_PERIOD_SECONDS: u32 = 86_400;
39
40fn invalid(field: &str, reason: impl Into<String>) -> AlienError<ErrorData> {
41 AlienError::new(ErrorData::StackDefinitionInvalid {
42 field: field.to_string(),
43 reason: reason.into(),
44 })
45}
46
47pub fn parse_quantity_bytes(field: &str, value: &str) -> Result<u64> {
53 quantity_bytes(value).map_err(|reason| invalid(field, reason))
54}
55
56pub fn quantity_bytes(value: &str) -> std::result::Result<u64, String> {
58 let (number, multiplier) = [
59 ("Ki", 1024_f64),
60 ("Mi", 1024_f64.powi(2)),
61 ("Gi", 1024_f64.powi(3)),
62 ("Ti", 1024_f64.powi(4)),
63 ("k", 1e3),
64 ("M", 1e6),
65 ("G", 1e9),
66 ("T", 1e12),
67 ]
68 .into_iter()
69 .find_map(|(suffix, multiplier)| Some((value.strip_suffix(suffix)?, multiplier)))
70 .unwrap_or((value, 1.0));
71
72 let (whole, fraction) = match number.split_once('.') {
73 Some((whole, fraction)) => (whole, Some(fraction)),
74 None => (number, None),
75 };
76 let is_digits = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit());
77 if !is_digits(whole) || fraction.is_some_and(|fraction| !is_digits(fraction)) {
78 return Err(format!(
79 "'{value}' is not a quantity; use a number with an optional unit such as \"512Mi\", \"16Gi\" or \"1Ti\""
80 ));
81 }
82
83 let amount: f64 = number
84 .parse()
85 .map_err(|_| format!("'{value}' is not a quantity"))?;
86 let bytes = (amount * multiplier).round();
87 if bytes >= u64::MAX as f64 {
88 return Err(format!("'{value}' is too large"));
89 }
90 Ok(bytes as u64)
91}
92
93pub fn parse_duration_seconds(field: &str, value: &str) -> Result<u64> {
98 let millis = duration_millis(value).map_err(|reason| invalid(field, reason))?;
99 if millis % 1_000 != 0 {
100 return Err(invalid(
101 field,
102 format!("'{value}' is not a whole number of seconds"),
103 ));
104 }
105 Ok(millis / 1_000)
106}
107
108pub fn duration_millis(value: &str) -> std::result::Result<u64, String> {
111 let malformed = || {
112 format!("'{value}' is not a duration; use whole numbers with units, such as \"30s\", \"10m\" or \"24h\"")
113 };
114 if value.is_empty() {
115 return Err(malformed());
116 }
117
118 let mut total: u64 = 0;
119 let mut rest = value;
120 while !rest.is_empty() {
121 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
122 if digits == 0 {
123 return Err(malformed());
124 }
125 let amount: u64 = rest[..digits].parse().map_err(|_| malformed())?;
126 let unit_length = rest[digits..]
127 .bytes()
128 .take_while(u8::is_ascii_alphabetic)
129 .count();
130 let millis_per_unit = match &rest[digits..digits + unit_length] {
131 "ms" => 1,
132 "s" => 1_000,
133 "m" => 60_000,
134 "h" => 3_600_000,
135 "d" => 86_400_000,
136 _ => return Err(malformed()),
137 };
138 total = amount
139 .checked_mul(millis_per_unit)
140 .and_then(|millis| total.checked_add(millis))
141 .ok_or_else(|| format!("'{value}' is too long"))?;
142 rest = &rest[digits + unit_length..];
143 }
144 Ok(total)
145}
146
147pub fn validate_resource_id(id: &str) -> std::result::Result<(), &'static str> {
153 let (Some(&first), Some(&last)) = (id.as_bytes().first(), id.as_bytes().last()) else {
154 return Err("must not be empty");
155 };
156 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
157 return Err("must start with a lowercase letter or digit");
158 }
159 if !last.is_ascii_lowercase() && !last.is_ascii_digit() {
160 return Err("must end with a lowercase letter or digit");
161 }
162 if !id
163 .bytes()
164 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
165 {
166 return Err("must only contain lowercase letters, digits, and hyphens");
167 }
168 if id.contains("--") {
169 return Err("must not contain consecutive hyphens");
170 }
171 Ok(())
172}
173
174pub const MAX_DYNAMIC_CONTAINER_REPOSITORIES: usize = 16;
176
177pub fn is_repository_name(repository: &str) -> bool {
180 let is_alphanumeric = |byte: u8| byte.is_ascii_lowercase() || byte.is_ascii_digit();
181 let is_segment = |part: &str, separators: &[u8]| {
182 let bytes = part.as_bytes();
183 matches!((bytes.first(), bytes.last()), (Some(&first), Some(&last))
184 if is_alphanumeric(first) && is_alphanumeric(last))
185 && bytes
186 .iter()
187 .all(|&byte| is_alphanumeric(byte) || separators.contains(&byte))
188 };
189
190 let Some((authority, path)) = repository.split_once('/') else {
191 return false;
192 };
193 if !path.split('/').all(|part| is_segment(part, b"._-")) {
194 return false;
195 }
196 let mut authority = authority.split(':');
197 let host = authority.next().unwrap_or_default();
198 let port = authority.next();
199 authority.next().is_none()
200 && port.is_none_or(|port| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()))
201 && host.split('.').all(|label| is_segment(label, b"-"))
202}
203
204pub fn validate_dynamic_container_repositories(repositories: &[String]) -> Result<()> {
208 const FIELD: &str = "dynamicContainerRepositories";
209 if repositories.is_empty() || repositories.len() > MAX_DYNAMIC_CONTAINER_REPOSITORIES {
210 return Err(invalid(
211 FIELD,
212 format!(
213 "must list 1 to {MAX_DYNAMIC_CONTAINER_REPOSITORIES} repositories, found {}",
214 repositories.len()
215 ),
216 ));
217 }
218 for (index, repository) in repositories.iter().enumerate() {
219 let field = format!("{FIELD}.{index}");
220 if !is_repository_name(repository) {
221 return Err(invalid(
222 &field,
223 format!(
224 "'{repository}' is not a fully qualified OCI repository; write the registry host and the path, such as \"ghcr.io/acme/tools\""
225 ),
226 ));
227 }
228 if repositories[..index].contains(repository) {
229 return Err(invalid(&field, format!("'{repository}' is listed twice")));
230 }
231 }
232 Ok(())
233}
234
235pub fn validate_dynamic_container_image_resources(resource_ids: &[String]) -> Result<()> {
238 const FIELD: &str = "dynamicContainerImageResources";
239 if resource_ids.is_empty() {
240 return Err(invalid(FIELD, "must list at least one container"));
241 }
242 for (index, id) in resource_ids.iter().enumerate() {
243 if resource_ids[..index].contains(id) {
244 return Err(invalid(
245 &format!("{FIELD}.{index}"),
246 format!("'{id}' is listed twice"),
247 ));
248 }
249 }
250 Ok(())
251}
252
253pub fn stop_grace_period_seconds(field: &str, seconds: u64) -> Result<u32> {
255 match u32::try_from(seconds) {
256 Ok(seconds) if (1..=MAX_STOP_GRACE_PERIOD_SECONDS).contains(&seconds) => Ok(seconds),
257 _ => Err(invalid(
258 field,
259 format!("{seconds} seconds is outside 1 second to 24 hours"),
260 )),
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
267#[serde(untagged)]
268pub enum CpuAmount {
269 Integer(i64),
271 Float(f64),
273 Quantity(String),
275}
276
277impl CpuAmount {
278 pub fn to_quantity(&self, field: &str) -> Result<String> {
280 let positive = |cores: f64| {
281 if cores > 0.0 && cores.is_finite() {
282 Ok(())
283 } else {
284 Err(invalid(field, "CPU must be greater than zero"))
285 }
286 };
287 match self {
288 CpuAmount::Integer(cores) => {
289 positive(*cores as f64)?;
290 Ok(cores.to_string())
291 }
292 CpuAmount::Float(cores) => {
293 positive(*cores)?;
294 Ok(cores.to_string())
295 }
296 CpuAmount::Quantity(quantity) => {
297 let cores = crate::instance_catalog::parse_cpu(quantity)
298 .map_err(|reason| invalid(field, reason))?;
299 positive(cores)?;
300 Ok(quantity.clone())
301 }
302 }
303 }
304
305 pub fn to_resource_spec(&self, field: &str) -> Result<ResourceSpec> {
307 let quantity = self.to_quantity(field)?;
308 Ok(ResourceSpec {
309 min: quantity.clone(),
310 desired: quantity,
311 })
312 }
313}
314
315pub fn memory_resource_spec(field: &str, size: &str) -> Result<ResourceSpec> {
317 if parse_quantity_bytes(field, size)? == 0 {
318 return Err(invalid(field, "memory must be greater than zero"));
319 }
320 Ok(ResourceSpec {
321 min: size.to_string(),
322 desired: size.to_string(),
323 })
324}
325
326impl DaemonRuntime {
327 pub fn host_access() -> Self {
333 DaemonRuntime {
334 privileged: Some(true),
335 pid_namespace: Some("host".to_string()),
336 network_mode: Some("host".to_string()),
337 mounts: vec![DaemonRuntimeMount {
338 source: "/".to_string(),
339 target: "/host".to_string(),
340 options: None,
341 }],
342 user: Some("0".to_string()),
343 }
344 }
345}
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
350#[serde(untagged)]
351pub enum ComputeChoice {
352 Exactly(u32),
354 Range(ComputeChoiceRange),
356}
357
358impl ComputeChoice {
359 fn range(&self, field: &str) -> Result<ComputeChoiceRange> {
360 match self {
361 ComputeChoice::Exactly(count) => Ok(ComputeChoiceRange {
362 min: *count,
363 max: *count,
364 default: *count,
365 }),
366 ComputeChoice::Range(range) => {
367 if range.min <= range.default && range.default <= range.max {
368 Ok(range.clone())
369 } else {
370 Err(invalid(
371 field,
372 format!(
373 "needs min <= default <= max, got min {}, default {}, max {}",
374 range.min, range.default, range.max
375 ),
376 ))
377 }
378 }
379 }
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Eq)]
385pub enum PoolScale {
386 Fixed { machines: ComputeChoice },
388 Autoscale {
390 min: ComputeChoice,
391 max: ComputeChoice,
392 },
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
398#[serde(untagged)]
399pub enum DataDiskSize {
400 Fixed(String),
402 Range {
404 min: String,
406 default: String,
408 max: String,
410 },
411}
412
413#[derive(Debug, Clone, PartialEq)]
415pub struct PoolRequirements {
416 pub cpu: CpuAmount,
417 pub memory: String,
418 pub ephemeral_storage: Option<String>,
420 pub architecture: Option<Architecture>,
421 pub nested_virtualization: Option<bool>,
422 pub gpu: Option<GpuSpec>,
423 pub data_disk: Option<DataDiskSize>,
424}
425
426#[derive(Debug, Clone, PartialEq)]
428pub struct ComputePool {
429 pub requirements: PoolRequirements,
430 pub scale: PoolScale,
431}
432
433impl ComputePool {
434 pub fn to_capacity_group(&self, group_id: &str) -> Result<CapacityGroup> {
439 let requirements = &self.requirements;
440 let (scale_policy, min_size, max_size) = match &self.scale {
441 PoolScale::Fixed { machines } => {
442 let machines = machines.range("scale.machines")?;
443 let selected = machines.default;
444 (
445 CapacityGroupScalePolicy::Fixed { machines },
446 selected,
447 selected,
448 )
449 }
450 PoolScale::Autoscale { min, max } => {
451 let min = min.range("scale.min")?;
452 let max = max.range("scale.max")?;
453 if min.default > max.default {
454 return Err(invalid(
455 "scale",
456 format!(
457 "the default minimum ({}) is above the default maximum ({})",
458 min.default, max.default
459 ),
460 ));
461 }
462 let (min_size, max_size) = (min.default, max.default);
463 (
464 CapacityGroupScalePolicy::Autoscale { min, max },
465 min_size,
466 max_size,
467 )
468 }
469 };
470
471 let data_disk_gib_range = match &requirements.data_disk {
472 Some(DataDiskSize::Range { min, default, max }) => {
473 let range = ComputeChoiceRange {
474 min: whole_gib("dataDisk.size.min", min)?,
475 max: whole_gib("dataDisk.size.max", max)?,
476 default: whole_gib("dataDisk.size.default", default)?,
477 };
478 if !(range.min <= range.default && range.default <= range.max) {
479 return Err(invalid(
480 "dataDisk.size",
481 format!("needs min <= default <= max: {min}, {default}, {max}"),
482 ));
483 }
484 if range.max == 0 {
485 return Err(invalid("dataDisk.size.max", "must be greater than zero"));
486 }
487 Some(range)
488 }
489 Some(DataDiskSize::Fixed(_)) | None => None,
490 };
491 let data_disk = match &requirements.data_disk {
492 None => None,
493 Some(DataDiskSize::Fixed(size)) => {
494 let size_bytes = parse_quantity_bytes("dataDisk.size", size)?;
495 if size_bytes == 0 {
496 return Err(invalid(
497 "dataDisk.size",
498 format!("must be greater than zero: {size}"),
499 ));
500 }
501 Some(DataDiskRequirement { size_bytes })
502 }
503 Some(DataDiskSize::Range { .. }) => data_disk_gib_range
505 .as_ref()
506 .filter(|range| range.default > 0)
507 .map(|range| DataDiskRequirement {
508 size_bytes: u64::from(range.default) * GIB,
509 }),
510 };
511
512 let profile = MachineProfile {
513 cpu: requirements.cpu.to_quantity("cpu")?,
514 memory_bytes: parse_quantity_bytes("memory", &requirements.memory)?,
515 ephemeral_storage_bytes: parse_quantity_bytes(
516 "ephemeralStorage",
517 requirements
518 .ephemeral_storage
519 .as_deref()
520 .unwrap_or(DEFAULT_EPHEMERAL_STORAGE),
521 )?,
522 architecture: requirements.architecture,
523 gpu: requirements.gpu.clone(),
524 data_disk,
525 };
526
527 Ok(CapacityGroup {
528 group_id: group_id.to_string(),
529 instance_type: None,
530 profile: Some(profile),
531 min_size,
532 max_size,
533 scale_policy: Some(scale_policy),
534 nested_virtualization: requirements.nested_virtualization,
535 data_disk_gib_range,
536 })
537 }
538}
539
540fn whole_gib(field: &str, value: &str) -> Result<u32> {
541 let bytes = parse_quantity_bytes(field, value)?;
542 if bytes % GIB != 0 {
543 return Err(invalid(
544 field,
545 format!("must be a whole number of GiB: {value}"),
546 ));
547 }
548 u32::try_from(bytes / GIB).map_err(|_| invalid(field, format!("'{value}' is too large")))
549}
550
551pub fn failure_domain_spread(field: &str, spread: i64) -> Result<u8> {
553 u8::try_from(spread)
554 .ok()
555 .filter(|spread| *spread >= 1)
556 .ok_or_else(|| {
557 invalid(
558 field,
559 format!("must be an integer from 1 to 255, got {spread}"),
560 )
561 })
562}
563
564pub fn input_validation(
571 kind: &StackInputKind,
572 validation: StackInputValidation,
573) -> Option<StackInputValidation> {
574 match kind {
575 StackInputKind::Boolean => None,
576 _ => Some(validation),
577 }
578}
579
580#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
582#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
583#[serde(untagged)]
584pub enum InputValue {
585 String(String),
587 Integer(i64),
589 Float(f64),
591 Boolean(bool),
593 StringList(Vec<String>),
595}
596
597impl InputValue {
598 fn describe(&self) -> &'static str {
599 match self {
600 InputValue::String(_) => "a string",
601 InputValue::Integer(_) => "an integer",
602 InputValue::Float(_) => "a number",
603 InputValue::Boolean(_) => "a boolean",
604 InputValue::StringList(_) => "a list of strings",
605 }
606 }
607}
608
609pub fn input_default(kind: &StackInputKind, value: &InputValue) -> Result<StackInputDefaultValue> {
611 let mismatch = |expected: &str| {
612 invalid(
613 "default",
614 format!(
615 "expected {expected} for a {kind:?} input, got {}",
616 value.describe()
617 ),
618 )
619 };
620 match (kind, value) {
621 (StackInputKind::Secret, _) => Err(invalid(
622 "default",
623 "secret inputs cannot have a default value",
624 )),
625 (StackInputKind::String | StackInputKind::Enum, InputValue::String(text)) => {
626 Ok(StackInputDefaultValue::String(text.clone()))
627 }
628 (StackInputKind::String | StackInputKind::Enum, _) => Err(mismatch("a string")),
629 (StackInputKind::Number | StackInputKind::Integer, _) => Ok(
630 StackInputDefaultValue::Number(input_number("default", kind, value)?),
631 ),
632 (StackInputKind::Boolean, InputValue::Boolean(flag)) => {
633 Ok(StackInputDefaultValue::Boolean(*flag))
634 }
635 (StackInputKind::Boolean, _) => Err(mismatch("a boolean")),
636 (StackInputKind::StringList, InputValue::StringList(items)) => {
637 Ok(StackInputDefaultValue::StringList(items.clone()))
638 }
639 (StackInputKind::StringList, _) => Err(mismatch("a list of strings")),
640 }
641}
642
643pub fn input_number(field: &str, kind: &StackInputKind, value: &InputValue) -> Result<String> {
647 let number = match value {
648 InputValue::Integer(number) => return Ok(number.to_string()),
649 InputValue::Float(number) => *number,
650 other => {
651 return Err(invalid(
652 field,
653 format!("expected a number, got {}", other.describe()),
654 ))
655 }
656 };
657 if !number.is_finite() {
658 return Err(invalid(field, "must be a finite number"));
659 }
660 if *kind == StackInputKind::Integer && number.fract() != 0.0 {
661 return Err(invalid(field, format!("{number} is not a whole number")));
662 }
663 Ok(number.to_string())
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 fn field_of(error: AlienError<ErrorData>) -> String {
671 match error.error.expect("error data") {
672 ErrorData::StackDefinitionInvalid { field, .. } => field,
673 other => panic!("unexpected error {other:?}"),
674 }
675 }
676
677 #[test]
678 fn quantities_match_the_typescript_sdk() {
679 assert_eq!(parse_quantity_bytes("m", "16Gi").unwrap(), 17_179_869_184);
680 assert_eq!(parse_quantity_bytes("m", "1Ti").unwrap(), 1_099_511_627_776);
681 assert_eq!(parse_quantity_bytes("m", "500M").unwrap(), 500_000_000);
682 assert_eq!(parse_quantity_bytes("m", "1.5Gi").unwrap(), 1_610_612_736);
683 assert_eq!(parse_quantity_bytes("m", "2k").unwrap(), 2_000);
684 assert_eq!(parse_quantity_bytes("m", "1024").unwrap(), 1_024);
685 assert_eq!(parse_quantity_bytes("m", "0.3Ki").unwrap(), 307);
687 for bad in [
688 "", "Gi", "16 Gi", " 16Gi", "16gi", "-1Gi", "1e3", "1.Gi", ".5Gi", "16GiB",
689 ] {
690 assert_eq!(
691 field_of(parse_quantity_bytes("memory", bad).unwrap_err()),
692 "memory",
693 "{bad:?} must be rejected"
694 );
695 }
696 }
697
698 #[test]
699 fn repository_names_match_the_typescript_sdk() {
700 for good in [
701 "ghcr.io/acme/tools",
702 "localhost:5000/acme",
703 "registry.example.com:443/a/b-c/d.e_f",
704 "alpine/git",
705 "123456789012.dkr.ecr.us-east-1.amazonaws.com/acme",
706 ] {
707 assert!(is_repository_name(good), "{good:?} must be accepted");
708 }
709 for bad in [
710 "",
711 "alpine",
712 "ghcr.io/",
713 "ghcr.io//acme",
714 "/acme",
715 "GHCR.io/acme",
716 "ghcr.io/Acme",
717 "ghcr.io/acme:latest",
718 "ghcr.io/acme@sha256:abc",
719 "ghcr.io:/acme",
720 "ghcr.io:80:81/acme",
721 "ghcr..io/acme",
722 "-ghcr.io/acme",
723 "ghcr.io/-acme",
724 "ghcr.io/acme-",
725 "ghcr.io/acme/ tools",
726 "https://ghcr.io/acme",
727 ] {
728 assert!(!is_repository_name(bad), "{bad:?} must be rejected");
729 }
730 }
731
732 #[test]
733 fn dynamic_container_lists_are_bounded_and_distinct() {
734 let names = |items: &[&str]| {
735 items
736 .iter()
737 .map(|item| item.to_string())
738 .collect::<Vec<_>>()
739 };
740 validate_dynamic_container_repositories(&names(&["ghcr.io/acme/a", "ghcr.io/acme/b"]))
741 .unwrap();
742 assert_eq!(
743 field_of(validate_dynamic_container_repositories(&[]).unwrap_err()),
744 "dynamicContainerRepositories"
745 );
746 let seventeen: Vec<String> = (0..17).map(|i| format!("ghcr.io/acme/r{i}")).collect();
747 assert_eq!(
748 field_of(validate_dynamic_container_repositories(&seventeen).unwrap_err()),
749 "dynamicContainerRepositories"
750 );
751 validate_dynamic_container_repositories(&seventeen[..16]).unwrap();
752 assert_eq!(
753 field_of(
754 validate_dynamic_container_repositories(&names(&["ghcr.io/acme/a", "alpine"]))
755 .unwrap_err()
756 ),
757 "dynamicContainerRepositories.1"
758 );
759 assert_eq!(
760 field_of(
761 validate_dynamic_container_repositories(&names(&[
762 "ghcr.io/acme/a",
763 "ghcr.io/acme/a"
764 ]))
765 .unwrap_err()
766 ),
767 "dynamicContainerRepositories.1"
768 );
769
770 validate_dynamic_container_image_resources(&names(&["api", "web"])).unwrap();
771 assert_eq!(
772 field_of(validate_dynamic_container_image_resources(&[]).unwrap_err()),
773 "dynamicContainerImageResources"
774 );
775 assert_eq!(
776 field_of(
777 validate_dynamic_container_image_resources(&names(&["api", "api"])).unwrap_err()
778 ),
779 "dynamicContainerImageResources.1"
780 );
781 }
782
783 #[test]
784 fn durations() {
785 assert_eq!(parse_duration_seconds("d", "90s").unwrap(), 90);
786 assert_eq!(parse_duration_seconds("d", "10m").unwrap(), 600);
787 assert_eq!(parse_duration_seconds("d", "24h").unwrap(), 86_400);
788 assert_eq!(parse_duration_seconds("d", "1h30m").unwrap(), 5_400);
789 assert_eq!(parse_duration_seconds("d", "1d").unwrap(), 86_400);
790 assert_eq!(parse_duration_seconds("d", "2000ms").unwrap(), 2);
791 assert_eq!(duration_millis("250ms").unwrap(), 250);
792 assert_eq!(duration_millis("1h30m").unwrap(), 5_400_000);
793 for bad in ["", "10", "h", "1.5h", "10 m", "-1s", "1h30", "1x", "5mm"] {
794 assert!(
795 parse_duration_seconds("d", bad).is_err(),
796 "{bad:?} must be rejected"
797 );
798 }
799 assert_eq!(
800 field_of(parse_duration_seconds("grace", "1500ms").unwrap_err()),
801 "grace",
802 "a fraction of a second is not a whole number of seconds"
803 );
804 assert_eq!(stop_grace_period_seconds("g", 86_400).unwrap(), 86_400);
805 assert!(stop_grace_period_seconds("g", 86_401).is_err());
806 assert!(stop_grace_period_seconds("g", 0).is_err());
807 }
808
809 #[test]
810 fn cpu_amounts_print_like_javascript_numbers() {
811 assert_eq!(CpuAmount::Integer(2).to_quantity("cpu").unwrap(), "2");
812 assert_eq!(CpuAmount::Float(0.5).to_quantity("cpu").unwrap(), "0.5");
813 assert_eq!(CpuAmount::Float(2.0).to_quantity("cpu").unwrap(), "2");
814 assert_eq!(
815 CpuAmount::Quantity("500m".into())
816 .to_quantity("cpu")
817 .unwrap(),
818 "500m"
819 );
820 assert!(CpuAmount::Integer(0).to_quantity("cpu").is_err());
821 assert!(CpuAmount::Quantity("lots".into())
822 .to_quantity("cpu")
823 .is_err());
824 }
825
826 fn pool(scale: PoolScale, data_disk: Option<DataDiskSize>) -> ComputePool {
827 ComputePool {
828 requirements: PoolRequirements {
829 cpu: CpuAmount::Integer(4),
830 memory: "16Gi".into(),
831 ephemeral_storage: None,
832 architecture: Some(Architecture::X86_64),
833 nested_virtualization: Some(true),
834 gpu: None,
835 data_disk,
836 },
837 scale,
838 }
839 }
840
841 #[test]
842 fn fixed_pool_selects_the_default_machine_count() {
843 let group = pool(
844 PoolScale::Fixed {
845 machines: ComputeChoice::Range(ComputeChoiceRange {
846 min: 1,
847 max: 5,
848 default: 2,
849 }),
850 },
851 None,
852 )
853 .to_capacity_group("general")
854 .unwrap();
855
856 assert_eq!(
857 group,
858 CapacityGroup {
859 group_id: "general".into(),
860 instance_type: None,
861 profile: Some(MachineProfile {
862 cpu: "4".into(),
863 memory_bytes: 16 * GIB,
864 ephemeral_storage_bytes: 20 * GIB,
865 architecture: Some(Architecture::X86_64),
866 gpu: None,
867 data_disk: None,
868 }),
869 min_size: 2,
870 max_size: 2,
871 scale_policy: Some(CapacityGroupScalePolicy::Fixed {
872 machines: ComputeChoiceRange {
873 min: 1,
874 max: 5,
875 default: 2
876 }
877 }),
878 nested_virtualization: Some(true),
879 data_disk_gib_range: None,
880 }
881 );
882 }
883
884 #[test]
885 fn autoscale_pool_uses_default_bounds() {
886 let group = pool(
887 PoolScale::Autoscale {
888 min: ComputeChoice::Exactly(1),
889 max: ComputeChoice::Range(ComputeChoiceRange {
890 min: 2,
891 max: 10,
892 default: 4,
893 }),
894 },
895 None,
896 )
897 .to_capacity_group("workers")
898 .unwrap();
899 assert_eq!((group.min_size, group.max_size), (1, 4));
900
901 let inverted = pool(
902 PoolScale::Autoscale {
903 min: ComputeChoice::Exactly(5),
904 max: ComputeChoice::Exactly(2),
905 },
906 None,
907 );
908 assert_eq!(
909 field_of(inverted.to_capacity_group("w").unwrap_err()),
910 "scale"
911 );
912 }
913
914 #[test]
915 fn data_disk_ranges_resolve_their_default() {
916 let fixed = |machines| PoolScale::Fixed {
917 machines: ComputeChoice::Exactly(machines),
918 };
919 let range = |min: &str, default: &str, max: &str| DataDiskSize::Range {
920 min: min.into(),
921 default: default.into(),
922 max: max.into(),
923 };
924
925 let group = pool(fixed(1), Some(range("0Gi", "200Gi", "4Ti")))
926 .to_capacity_group("g")
927 .unwrap();
928 assert_eq!(
929 group.data_disk_gib_range,
930 Some(ComputeChoiceRange {
931 min: 0,
932 max: 4096,
933 default: 200
934 })
935 );
936 assert_eq!(
937 group.profile.unwrap().data_disk,
938 Some(DataDiskRequirement {
939 size_bytes: 200 * GIB
940 })
941 );
942
943 let no_default_disk = pool(fixed(1), Some(range("0Gi", "0Gi", "1Ti")))
944 .to_capacity_group("g")
945 .unwrap();
946 assert_eq!(no_default_disk.profile.unwrap().data_disk, None);
947
948 let decimal = pool(fixed(1), Some(DataDiskSize::Fixed("500G".into())))
950 .to_capacity_group("g")
951 .unwrap();
952 assert_eq!(decimal.data_disk_gib_range, None);
953 assert_eq!(
954 decimal.profile.unwrap().data_disk,
955 Some(DataDiskRequirement {
956 size_bytes: 500_000_000_000
957 })
958 );
959
960 let cases = [
961 (range("1Gi", "500M", "2Gi"), "dataDisk.size.default"),
962 (range("2Gi", "1Gi", "3Gi"), "dataDisk.size"),
963 (range("0Gi", "0Gi", "0Gi"), "dataDisk.size.max"),
964 (DataDiskSize::Fixed("0Gi".into()), "dataDisk.size"),
965 ];
966 for (data_disk, field) in cases {
967 let error = pool(fixed(1), Some(data_disk.clone()))
968 .to_capacity_group("g")
969 .unwrap_err();
970 assert_eq!(field_of(error), field, "{data_disk:?}");
971 }
972 }
973
974 #[test]
975 fn failure_domain_spread_bounds() {
976 assert_eq!(failure_domain_spread("f", 3).unwrap(), 3);
977 assert!(failure_domain_spread("f", 0).is_err());
978 assert!(failure_domain_spread("f", 256).is_err());
979 }
980
981 #[test]
982 fn host_access_is_the_full_host_runtime() {
983 let runtime = DaemonRuntime::host_access();
984 assert_eq!(runtime.privileged, Some(true));
985 assert_eq!(runtime.pid_namespace.as_deref(), Some("host"));
986 assert_eq!(runtime.network_mode.as_deref(), Some("host"));
987 assert_eq!(runtime.user.as_deref(), Some("0"));
988 assert_eq!(
989 runtime.mounts,
990 vec![DaemonRuntimeMount {
991 source: "/".into(),
992 target: "/host".into(),
993 options: None
994 }]
995 );
996 }
997
998 #[test]
999 fn input_defaults_are_typed_by_kind() {
1000 use StackInputDefaultValue as Default;
1001 let text = InputValue::String("eu".into());
1002 assert_eq!(
1003 input_default(&StackInputKind::Enum, &text).unwrap(),
1004 Default::String("eu".into())
1005 );
1006 assert_eq!(
1007 input_default(&StackInputKind::Number, &InputValue::Float(0.5)).unwrap(),
1008 Default::Number("0.5".into())
1009 );
1010 assert_eq!(
1011 input_default(&StackInputKind::Integer, &InputValue::Float(3.0)).unwrap(),
1012 Default::Number("3".into())
1013 );
1014 assert_eq!(
1015 input_default(&StackInputKind::Integer, &InputValue::Integer(180_000)).unwrap(),
1016 Default::Number("180000".into())
1017 );
1018 assert_eq!(
1019 input_default(&StackInputKind::StringList, &InputValue::StringList(vec![])).unwrap(),
1020 Default::StringList(vec![])
1021 );
1022 for (kind, value) in [
1023 (StackInputKind::Secret, text.clone()),
1024 (StackInputKind::Integer, InputValue::Float(1.5)),
1025 (StackInputKind::Boolean, text.clone()),
1026 (StackInputKind::String, InputValue::Integer(1)),
1027 (StackInputKind::StringList, text.clone()),
1028 ] {
1029 assert_eq!(
1030 field_of(input_default(&kind, &value).unwrap_err()),
1031 "default"
1032 );
1033 }
1034 }
1035}