Skip to main content

alien_core/
container_resources.rs

1//! Resolve deployment-time container allocations before planning or provisioning.
2
3use crate::{
4    instance_catalog::{parse_cpu, parse_memory_bytes},
5    ComputeSettings, Container, ErrorData, ResourceChoiceRange, ResourceSpec, Stack, StackState,
6};
7use alien_error::{AlienError, Result};
8
9/// Apply and validate selections without changing the release's declared resource ranges.
10/// The same resolver is used by compute planning and deployment preparation.
11pub fn resolve_container_resources(
12    stack: &mut Stack,
13    settings: Option<&ComputeSettings>,
14) -> Result<(), ErrorData> {
15    if let Some(settings) = settings {
16        let mut ids: Vec<_> = settings.containers.keys().collect();
17        ids.sort();
18        for id in ids {
19            if !stack
20                .resources
21                .get(id)
22                .is_some_and(|entry| entry.config.downcast_ref::<Container>().is_some())
23            {
24                return Err(invalid(
25                    id,
26                    "resources",
27                    "no such container in this release",
28                ));
29            }
30        }
31    }
32
33    let mut ids: Vec<_> = stack.resources.keys().cloned().collect();
34    ids.sort();
35    for id in ids {
36        let entry = stack.resources.get_mut(&id).expect("id came from map");
37        let Some(container) = entry.config.downcast_mut::<Container>() else {
38            continue;
39        };
40        let selected = settings.and_then(|settings| settings.containers.get(&id));
41        let cpu = selected
42            .and_then(|selected| selected.cpu.as_ref())
43            .map(ToString::to_string);
44        let memory = selected.and_then(|selected| selected.memory.as_deref());
45        if let Some(size) = selected.and_then(|selected| selected.volume_size_gib) {
46            let storage = container.persistent_storage.as_mut().ok_or_else(|| {
47                invalid(
48                    &id,
49                    "volumeSizeGib",
50                    "the release declares no persistent volume",
51                )
52            })?;
53            if size == 0 || size > 65536 {
54                return Err(invalid(
55                    &id,
56                    "volumeSizeGib",
57                    "capacity must be between 1 and 65536 GiB",
58                ));
59            }
60            let selected = format!("{size}Gi");
61            // Preserve equivalent release quantities, including immutable PVC templates.
62            if parse_memory_bytes(&storage.size) != parse_memory_bytes(&selected) {
63                storage.size = selected;
64            }
65        }
66        let choices = container.resource_choices.as_ref();
67        resolve_dimension(
68            &id,
69            "cpu",
70            &mut container.cpu,
71            choices.and_then(|choices| choices.cpu.as_ref()),
72            cpu.as_deref(),
73            parse_positive_cpu,
74        )?;
75        resolve_dimension(
76            &id,
77            "memory",
78            &mut container.memory,
79            choices.and_then(|choices| choices.memory.as_ref()),
80            memory,
81            parse_positive_memory,
82        )?;
83    }
84    Ok(())
85}
86
87/// The persistent volume capacity, in GiB, that `state` records as installed for container `id`.
88/// `None` when the container has no installed persistent storage or its size does not parse.
89pub fn installed_volume_size_gib(state: &StackState, id: &str) -> Option<u32> {
90    let storage = state
91        .resources
92        .get(id)?
93        .config
94        .downcast_ref::<Container>()?
95        .persistent_storage
96        .as_ref()?;
97    parse_volume_size_gib(&storage.size).ok()
98}
99
100/// A setup-source volume size raised to the size the deployment already has or chose.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct KeptVolumeSize {
103    /// Container resource ID.
104    pub container_id: String,
105    /// Capacity the setup source asked for, in GiB.
106    pub requested_gib: u32,
107    /// Capacity kept instead, in GiB.
108    pub kept_gib: u32,
109}
110
111/// Raise volume sizes from a setup source (a CloudFormation parameter, a Terraform variable or a
112/// CLI config file) that are below an installed volume's capacity, or below the size the
113/// deployment currently selects for it, to the larger of the two.
114///
115/// Volumes only grow, and a compute update can grow one without the setup source learning about
116/// it, so a lower value from a setup source is stale, not a request to shrink. A container with
117/// no installed volume is left alone: before anything is installed a smaller value is a real
118/// correction. Returns the raised selections so callers can tell the user which values their
119/// setup source should be updated to.
120pub fn keep_volume_sizes_over_setup_source(
121    requested: &mut ComputeSettings,
122    installed: Option<&StackState>,
123    current: Option<&ComputeSettings>,
124) -> Vec<KeptVolumeSize> {
125    let mut kept = Vec::new();
126    let mut ids: Vec<_> = requested.containers.keys().cloned().collect();
127    ids.sort();
128    for id in ids {
129        let selection = requested.containers.get_mut(&id).expect("id came from map");
130        let Some(requested_gib) = selection.volume_size_gib else {
131            continue;
132        };
133        let Some(installed_gib) = installed.and_then(|state| installed_volume_size_gib(state, &id))
134        else {
135            continue;
136        };
137        let chosen_gib = current
138            .and_then(|current| current.containers.get(&id))
139            .and_then(|selection| selection.volume_size_gib);
140        let kept_gib = chosen_gib.map_or(installed_gib, |chosen| chosen.max(installed_gib));
141        if requested_gib < kept_gib {
142            selection.volume_size_gib = Some(kept_gib);
143            kept.push(KeptVolumeSize {
144                container_id: id,
145                requested_gib,
146                kept_gib,
147            });
148        }
149    }
150    kept
151}
152
153/// Keep installed volumes at their installed capacity when the deployment selects no size and
154/// the release default is smaller. Without a selection nobody asked for a size, so a release
155/// that lowers its default must not turn into a shrink of every installed deployment. Explicit
156/// selections are left to the planner's shrink check.
157pub fn retain_installed_volume_sizes(
158    stack: &mut Stack,
159    settings: Option<&ComputeSettings>,
160    installed: &StackState,
161) {
162    for (id, entry) in stack.resources.iter_mut() {
163        if settings
164            .and_then(|settings| settings.containers.get(id))
165            .is_some_and(|selection| selection.volume_size_gib.is_some())
166        {
167            continue;
168        }
169        let Some(storage) = entry
170            .config
171            .downcast_mut::<Container>()
172            .and_then(|container| container.persistent_storage.as_mut())
173        else {
174            continue;
175        };
176        let Some(installed_storage) = installed
177            .resources
178            .get(id)
179            .and_then(|resource| resource.config.downcast_ref::<Container>())
180            .and_then(|container| container.persistent_storage.as_ref())
181        else {
182            continue;
183        };
184        if let (Ok(release_gib), Ok(installed_gib)) = (
185            parse_volume_size_gib(&storage.size),
186            parse_volume_size_gib(&installed_storage.size),
187        ) {
188            if release_gib < installed_gib {
189                storage.size = installed_storage.size.clone();
190            }
191        }
192    }
193}
194
195fn resolve_dimension<T: PartialOrd>(
196    id: &str,
197    dimension: &str,
198    spec: &mut ResourceSpec,
199    choices: Option<&ResourceChoiceRange>,
200    selected: Option<&str>,
201    parse: fn(&str) -> std::result::Result<T, String>,
202) -> Result<(), ErrorData> {
203    let parse_value = |value: &str| parse(value).map_err(|reason| invalid(id, dimension, &reason));
204    let Some(choices) = choices else {
205        if let Some(selected) = selected {
206            if parse_value(selected)? != parse_value(&spec.desired)? {
207                return Err(invalid(
208                    id,
209                    dimension,
210                    "the release declares a fixed allocation",
211                ));
212            }
213        }
214        return Ok(());
215    };
216    let min = parse_value(&choices.min)?;
217    let max = parse_value(&choices.max)?;
218    let default = parse_value(&choices.default)?;
219    if min > max || default < min || default > max {
220        return Err(invalid(
221            id,
222            dimension,
223            "range must satisfy min <= default <= max",
224        ));
225    }
226    let value = selected.unwrap_or(&choices.default);
227    let allocation = parse_value(value)?;
228    if allocation < min || allocation > max {
229        return Err(invalid(
230            id,
231            dimension,
232            &format!(
233                "'{value}' is outside the allowed range {}..{}",
234                choices.min, choices.max,
235            ),
236        ));
237    }
238    // Both scheduling reservation and runtime limit reflect the deployer's allocation.
239    spec.min = value.to_string();
240    spec.desired = value.to_string();
241    Ok(())
242}
243
244fn parse_positive_cpu(value: &str) -> std::result::Result<f64, String> {
245    let cpu = parse_cpu(value)?;
246    if !cpu.is_finite() || cpu <= 0.0 {
247        return Err(format!("CPU must be finite and positive, got '{value}'"));
248    }
249    Ok(cpu)
250}
251
252fn parse_positive_memory(value: &str) -> std::result::Result<u64, String> {
253    // Keep the public contract in binary units and reject negative/non-finite values
254    // before the catalog's float-to-integer conversion can saturate them.
255    let numeric = ["Ki", "Mi", "Gi", "Ti"]
256        .iter()
257        .find_map(|suffix| value.strip_suffix(suffix))
258        .ok_or_else(|| format!("Memory must use Ki, Mi, Gi, or Ti, got '{value}'"))?;
259    let amount: f64 = numeric
260        .parse()
261        .map_err(|_| format!("Invalid memory '{value}'"))?;
262    if !amount.is_finite() || amount <= 0.0 {
263        return Err(format!("Memory must be finite and positive, got '{value}'"));
264    }
265    let bytes = parse_memory_bytes(value)?;
266    if bytes == 0 || bytes == u64::MAX {
267        return Err(format!("Memory is outside the supported range: '{value}'"));
268    }
269    Ok(bytes)
270}
271
272pub(crate) fn invalid(id: &str, dimension: &str, reason: &str) -> AlienError<ErrorData> {
273    AlienError::new(ErrorData::ContainerResourceSelectionInvalid {
274        resource_id: id.to_string(),
275        dimension: dimension.to_string(),
276        reason: reason.to_string(),
277    })
278}
279
280/// Parse whole-volume capacities used by cloud block disks. Legacy GB/TB
281/// spellings retain their existing GiB allocation semantics.
282pub fn parse_volume_size_gib(value: &str) -> std::result::Result<u32, String> {
283    let value = value.trim();
284    // Keep historical cloud GB/TB semantics while accepting existing Kubernetes
285    // quantities. Cloud disks allocate whole GiB, so round release quantities up;
286    // deployment selections themselves are unsigned integer GiB.
287    let bytes = if let Some(number) = value.strip_suffix("TB") {
288        parse_memory_bytes(&format!("{number}Gi"))?
289            .checked_mul(1000)
290            .ok_or_else(|| format!("Volume capacity '{value}' is too large"))?
291    } else if let Some(number) = value
292        .strip_suffix("GB")
293        .or_else(|| value.strip_suffix("GiB"))
294    {
295        parse_memory_bytes(&format!("{number}Gi"))?
296    } else if let Some(number) = value.strip_suffix("TiB") {
297        parse_memory_bytes(&format!("{number}Ti"))?
298    } else {
299        parse_memory_bytes(value)?
300    };
301    let gib = bytes.div_ceil(1024 * 1024 * 1024);
302    u32::try_from(gib)
303        .ok()
304        .filter(|n| (1..=65536).contains(n))
305        .ok_or_else(|| format!("Volume capacity '{value}' must be between 1 and 65536 GiB"))
306}