Skip to main content

template_model/
fargate.rs

1use crate::coercion::coerce_string_or_integer_to_string;
2use serde_json::Value;
3
4pub const CPU_UNIT_LABELS: &[&str] = &["256", "512", "1024", "2048", "4096", "8192", "16384", "32768"];
5
6pub const VCPU_SIZES: &[(&str, i64)] =
7    &[("0.25", 256), ("0.5", 512), ("1", 1024), ("2", 2048), ("4", 4096), ("8", 8192), ("16", 16384), ("32", 32768)];
8const MIB_PER_GIB: i64 = 1024;
9
10/// Returns whether an authored string-or-integer Cpu value names a Fargate size.
11/// Values of another JSON shape return `None` so schema type validation remains
12/// the sole owner of those findings.
13pub fn cpu_is_offered(value: &Value) -> Option<bool> {
14    let authored = coerce_string_or_integer_to_string(value)?;
15    Some(cpu_units(&authored).is_some())
16}
17
18/// Returns whether authored string-or-integer Cpu and Memory values form a size
19/// Fargate offers. Unsupported JSON shapes return `None`; malformed scalar
20/// spellings return `Some(false)`.
21pub fn task_size_is_offered(cpu: &Value, memory: &Value) -> Option<bool> {
22    let cpu_text = coerce_string_or_integer_to_string(cpu)?;
23    let memory_text = coerce_string_or_integer_to_string(memory)?;
24    Some(match (cpu_units(&cpu_text), memory_mib(&memory_text)) {
25        (Some(cpu_units), Some(memory_mib)) => valid_size_pair(cpu_units, memory_mib),
26        _ => false,
27    })
28}
29
30fn normalize_unsigned_decimal(authored: &str) -> Option<String> {
31    let unsigned = authored.strip_prefix('+').unwrap_or(authored);
32    let (whole, fraction) = match unsigned.split_once('.') {
33        Some((whole, fraction)) if !fraction.contains('.') => (whole, Some(fraction)),
34        Some(_) => return None,
35        None => (unsigned, None),
36    };
37    if whole.is_empty() && fraction.is_none_or(str::is_empty) {
38        return None;
39    }
40    if !whole.bytes().all(|byte| byte.is_ascii_digit())
41        || fraction.is_some_and(|digits| !digits.bytes().all(|byte| byte.is_ascii_digit()))
42    {
43        return None;
44    }
45
46    let significant_whole = whole.trim_start_matches('0');
47    let normalized_whole = if significant_whole.is_empty() { "0" } else { significant_whole };
48    let significant_fraction = fraction.unwrap_or_default().trim_end_matches('0');
49    if significant_fraction.is_empty() {
50        Some(normalized_whole.to_string())
51    } else {
52        Some(format!("{normalized_whole}.{significant_fraction}"))
53    }
54}
55
56fn cpu_units(authored: &str) -> Option<i64> {
57    if authored.trim() != authored {
58        return None;
59    }
60    if authored.bytes().all(|byte| byte.is_ascii_digit()) && !authored.is_empty() {
61        return CPU_UNIT_LABELS.iter().find(|offered| **offered == authored).and_then(|offered| offered.parse().ok());
62    }
63
64    let lower = authored.to_ascii_lowercase();
65    let normalized_vcpu = normalize_unsigned_decimal(lower.strip_suffix("vcpu")?.trim_end())?;
66    VCPU_SIZES.iter().find(|(label, _)| *label == normalized_vcpu).map(|(_, units)| *units)
67}
68
69fn memory_mib(authored: &str) -> Option<i64> {
70    if authored.trim() != authored {
71        return None;
72    }
73    if !authored.is_empty() && authored.bytes().all(|byte| byte.is_ascii_digit()) {
74        return authored.parse().ok();
75    }
76
77    let lower = authored.to_ascii_lowercase();
78    let normalized_gib = normalize_unsigned_decimal(lower.strip_suffix("gb")?.trim_end())?;
79    if normalized_gib == "0.5" {
80        return Some(MIB_PER_GIB / 2);
81    }
82    if normalized_gib.contains('.') {
83        return None;
84    }
85    normalized_gib.parse::<i64>().ok()?.checked_mul(MIB_PER_GIB)
86}
87
88fn valid_size_pair(cpu: i64, memory: i64) -> bool {
89    let (minimum, maximum, step) = match cpu {
90        256 => return [512, 1024, 2048].contains(&memory),
91        512 => (1024, 4096, 1024),
92        1024 => (2048, 8192, 1024),
93        2048 => (4096, 16384, 1024),
94        4096 => (8192, 30720, 1024),
95        8192 => (16384, 61440, 4096),
96        16384 => (32768, 122880, 8192),
97        32768 => return [61440, 122880, 249856].contains(&memory),
98        _ => return false,
99    };
100    (minimum..=maximum).contains(&memory) && memory % step == 0
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde_json::json;
107
108    #[test]
109    fn cpu_accepts_ecs_decimal_spellings_without_scientific_notation() {
110        for value in [
111            json!(256),
112            json!("256"),
113            json!(".25 vCPU"),
114            json!("0.250 vCPU"),
115            json!("0.5vCPU"),
116            json!("02 VCPU"),
117            json!("+2.00 vCpu"),
118            json!("2. vCPU"),
119            json!("2\tvCPU"),
120            json!("2\nvCPU"),
121            json!("16vcpu"),
122            json!("32 VCPU"),
123        ] {
124            assert_eq!(cpu_is_offered(&value), Some(true), "{value} must name an offered Cpu size");
125        }
126    }
127
128    #[test]
129    fn cpu_rejects_unoffered_and_unsupported_spellings_and_defers_other_shapes() {
130        for value in [
131            json!("0512"),
132            json!("3 vCPU"),
133            json!("2e0 vCPU"),
134            json!(" 2 vCPU"),
135            json!("2 vCPU "),
136            json!("-2 vCPU"),
137            json!("bananas"),
138            json!(128),
139        ] {
140            assert_eq!(cpu_is_offered(&value), Some(false), "{value} must not name an offered Cpu size");
141        }
142        for value in [json!(256.0), json!(true), json!([256]), json!({"Cpu": 256}), json!(null)] {
143            assert_eq!(cpu_is_offered(&value), None, "{value} must be left to schema type validation");
144        }
145    }
146
147    #[test]
148    fn task_sizes_accept_ecs_decimal_suffixes_without_scientific_notation() {
149        for (cpu, memory) in [
150            (json!(256), json!(512)),
151            (json!("0.25 vCPU"), json!("0.5 GB")),
152            (json!("02 vCPU"), json!("04 GB")),
153            (json!("+2.0 vCPU"), json!("+4.0 GB")),
154            (json!("2. vCPU"), json!("4. GB")),
155            (json!("8 vCPU"), json!("60GB")),
156            (json!("16 vCPU"), json!("120 GB")),
157            (json!(32768), json!("60 GB")),
158            (json!("32 vCPU"), json!("120 GB")),
159            (json!("32768"), json!("244 GB")),
160        ] {
161            assert_eq!(task_size_is_offered(&cpu, &memory), Some(true), "{cpu}/{memory} must be offered");
162        }
163    }
164
165    #[test]
166    fn task_sizes_reject_scientific_notation_outer_whitespace_and_invalid_combinations() {
167        for (cpu, memory) in [
168            (json!("2e0 vCPU"), json!("4 GB")),
169            (json!("2 vCPU"), json!("4e0 GB")),
170            (json!(" 2 vCPU"), json!("4 GB")),
171            (json!("2 vCPU"), json!(" 4 GB")),
172            (json!("2 vCPU "), json!("4 GB")),
173            (json!("2 vCPU"), json!("4 GB ")),
174            (json!("2 vCPU"), json!("4096.0")),
175            (json!(8192), json!(17408)),
176            (json!("8 vCPU"), json!("17 GB")),
177            (json!(16384), json!(36864)),
178            (json!(32768), json!(65536)),
179            (json!(32768), json!(245760)),
180            (json!("32 vCPU"), json!("240 GB")),
181        ] {
182            assert_eq!(task_size_is_offered(&cpu, &memory), Some(false), "{cpu}/{memory} must not be offered");
183        }
184    }
185
186    #[test]
187    fn task_size_conversion_rejects_overflow_without_panicking() {
188        assert_eq!(task_size_is_offered(&json!(8192), &json!("9999999999999999GB")), Some(false));
189        assert_eq!(task_size_is_offered(&json!(8192), &json!("9223372036854775807GB")), Some(false));
190    }
191
192    #[test]
193    fn task_size_defers_non_string_non_integer_shapes() {
194        assert_eq!(task_size_is_offered(&json!(256.0), &json!(512)), None);
195        assert_eq!(task_size_is_offered(&json!(256), &json!([512])), None);
196    }
197}