Skip to main content

alien_core/stack_authoring/
mod.rs

1//! Authoring conveniences shared by stack frontends.
2//!
3//! A serialized [`Stack`](crate::Stack) is precise but unpleasant to write: memory in bytes,
4//! CPU as `{ min, desired }`, scale policies spelled out in full. Frontends let developers
5//! write `memory = "16Gi"` or `machines = 2` instead and resolve it here, so every frontend
6//! produces the same `Stack` from the same intent.
7//!
8//! The TypeScript SDK (`packages/core`) predates this module and still carries its own
9//! copy of these rules. The `alien.toml` differential tests resolve the same stacks through
10//! both and require identical JSON, which keeps the two copies from drifting.
11
12mod 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
34/// Root disk size a pool asks for when it does not say.
35pub const DEFAULT_EPHEMERAL_STORAGE: &str = "20Gi";
36
37/// Largest stop grace period a daemon accepts, in seconds (24 hours).
38pub 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
47/// Parses a memory or storage quantity such as `"16Gi"`, `"500M"` or `"1024"` into bytes.
48///
49/// Binary (`Ki`, `Mi`, `Gi`, `Ti`) and decimal (`k`, `M`, `G`, `T`) suffixes are accepted;
50/// a bare number is bytes. Fractions round to the nearest byte. Whitespace, signs and
51/// exponents are rejected, so a typo never turns into a surprising size.
52pub fn parse_quantity_bytes(field: &str, value: &str) -> Result<u64> {
53    quantity_bytes(value).map_err(|reason| invalid(field, reason))
54}
55
56/// [`parse_quantity_bytes`] without a field name; the error is what is wrong with `value`.
57pub 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
93/// Parses a duration such as `"90s"`, `"10m"`, `"24h"` or `"1h30m"` into whole seconds.
94///
95/// Units are `ms`, `s`, `m`, `h` and `d`; each part is a whole number followed by its unit.
96/// A duration that is not a whole number of seconds (`"1500ms"`) is rejected.
97pub 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
108/// A duration such as `"90s"`, `"250ms"` or `"1h30m"` in milliseconds; the error is what is
109/// wrong with `value`.
110pub 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
147/// Validates a resource ID against the common cloud naming constraints.
148///
149/// Resource IDs become part of cloud resource names, so the strictest common pattern across
150/// GCP, AWS and Azure applies: `[a-z0-9]([a-z0-9-]*[a-z0-9])?` with no consecutive hyphens.
151/// The error says what is wrong.
152pub 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
174/// Most repositories a stack may approve for containers created after installation.
175pub const MAX_DYNAMIC_CONTAINER_REPOSITORIES: usize = 16;
176
177/// Whether `repository` is a fully qualified OCI repository such as `ghcr.io/acme/tools`:
178/// a registry host (with an optional port) followed by at least one lowercase path segment.
179pub 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
204/// Checks the OCI repositories a stack approves for containers created after installation:
205/// 1 to 16 distinct, fully qualified names. The error field names the offending entry
206/// (`dynamicContainerRepositories.2`) when one entry is at fault.
207pub 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
235/// Checks the IDs of the container resources whose images a stack approves: non-empty and
236/// distinct. The error field names the offending entry when one entry is at fault.
237pub 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
253/// Validates a daemon stop grace period: 1 second through 24 hours.
254pub 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/// A CPU amount written as a number of cores (`2`, `0.5`) or a quantity string (`"500m"`).
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
267#[serde(untagged)]
268pub enum CpuAmount {
269    /// Whole cores.
270    Integer(i64),
271    /// Fractional cores.
272    Float(f64),
273    /// A Kubernetes-style quantity, passed through verbatim.
274    Quantity(String),
275}
276
277impl CpuAmount {
278    /// The canonical string form: numbers print like JavaScript does (`2`, `0.5`).
279    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    /// A workload request that asks for exactly this much CPU.
306    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
315/// A workload memory request that asks for exactly `size` (for example `"4Gi"`).
316pub 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    /// Full access to the host: privileged, the host's PID namespace and network,
328    /// the host root mounted at `/host`, running as root.
329    ///
330    /// This is what host agents and loaders that install or supervise native host
331    /// processes need. On AWS, GCP and Azure it cannot be combined with `permissions`.
332    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/// A machine count: one exact number, or a range a deployment chooses from.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
350#[serde(untagged)]
351pub enum ComputeChoice {
352    /// Every deployment uses this number.
353    Exactly(u32),
354    /// Each deployment picks from `min..=max`, `default` when it does not pick.
355    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/// How many machines a pool runs.
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub enum PoolScale {
386    /// A fixed number of machines.
387    Fixed { machines: ComputeChoice },
388    /// An autoscaling group between `min` and `max` machines.
389    Autoscale {
390        min: ComputeChoice,
391        max: ComputeChoice,
392    },
393}
394
395/// The extra empty data disk each machine must provide.
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
398#[serde(untagged)]
399pub enum DataDiskSize {
400    /// One size for every deployment, for example `"1Ti"`.
401    Fixed(String),
402    /// Sizes a deployment chooses from. A `min` of `"0Gi"` lets a deployment go without one.
403    Range {
404        /// Smallest size a deployment may choose.
405        min: String,
406        /// Size used when a deployment does not choose.
407        default: String,
408        /// Largest size a deployment may choose.
409        max: String,
410    },
411}
412
413/// Hardware a pool's machines must have.
414#[derive(Debug, Clone, PartialEq)]
415pub struct PoolRequirements {
416    pub cpu: CpuAmount,
417    pub memory: String,
418    /// Size of the disk holding the operating system. Defaults to [`DEFAULT_EPHEMERAL_STORAGE`].
419    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/// A compute pool as a developer declares it: what the machines need and how many run.
427#[derive(Debug, Clone, PartialEq)]
428pub struct ComputePool {
429    pub requirements: PoolRequirements,
430    pub scale: PoolScale,
431}
432
433impl ComputePool {
434    /// Resolves the pool into the capacity group the stack carries.
435    ///
436    /// Errors name the failing field the way the TypeScript SDK does (`memory`,
437    /// `ephemeralStorage`, `dataDisk.size.min`, `scale.machines`, ...).
438    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            // A range's default stands for the pool; a default of 0 means no data disk.
504            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
551/// Validates a pool's failure-domain spread: an integer from 1 to 255.
552pub 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
564/// The validation block a stack input carries.
565///
566/// Every kind except boolean accepts constraints, and its definition carries a validation
567/// block even when no constraint is set (`{}`); a boolean carries none. This is the shape
568/// the TypeScript SDK has always produced, kept so a stack does not change when it moves
569/// between frontends.
570pub 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/// A stack input value as written: a default, or a numeric constraint.
581#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
582#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
583#[serde(untagged)]
584pub enum InputValue {
585    /// Text.
586    String(String),
587    /// A whole number.
588    Integer(i64),
589    /// A number with a fractional part.
590    Float(f64),
591    /// A flag.
592    Boolean(bool),
593    /// A list of text values.
594    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
609/// The default value of an input of `kind`, typed the way the stack stores it.
610pub 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
643/// A number for a number or integer input (a default, `min` or `max`), as the stack stores
644/// it: decimal text printed the way JavaScript prints numbers. Integer inputs take whole
645/// numbers only.
646pub 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        // Math.round(0.3 * 1024) in the TS SDK.
686        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        // A fixed size stands to the byte, unlike a range.
949        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}