Skip to main content

isb_core/org/
disk.rs

1//! Root disk sizes under an org's disk limit (`limits.disk`).
2//!
3//! incus counts every root disk's `size` against the limit, refuses to
4//! create an instance whose root disk has none, and refuses to set the limit
5//! while one has none. isb sizes each instance it creates there itself, in
6//! the create request: the spec's `raw_devices.root.size`, else
7//! [`DEFAULT_ROOT_SIZE`]. It never writes a size on the org's default
8//! profile: incus applies a profile's size to every instance that takes its
9//! root disk from the profile, so one there would resize them all.
10
11use serde_json::{Value, json};
12
13use super::OrgId;
14use super::limits::{DEFAULT_ROOT_SIZE, bytes};
15use crate::client::{Client, encode_segment};
16use crate::error::{Error, Result};
17
18/// Whether `c`'s project has `limits.disk`. An unreadable project counts as
19/// none: incus itself refuses what the limit forbids.
20pub fn disk_limited(c: &Client) -> bool {
21    let path = format!("/1.0/projects/{}", encode_segment(c.project_name()));
22    c.clone()
23        .project("default")
24        .get_opt(&path)
25        .ok()
26        .flatten()
27        .is_some_and(|p| p["config"]["limits.disk"].is_string())
28}
29
30/// An instance create `body` for `c`'s project: a root disk of its own
31/// without a size gets [`DEFAULT_ROOT_SIZE`] while the project has a disk
32/// limit. Only the request changes, never the spec it came from (a stack's
33/// revision hashes the spec).
34pub fn sized_root(c: &Client, mut body: Value) -> Value {
35    let root = &body["devices"]["root"];
36    if root.is_object() && root.get("size").is_none() && disk_limited(c) {
37        body["devices"]["root"]["size"] = json!(DEFAULT_ROOT_SIZE);
38    }
39    body
40}
41
42/// An instance whose root disk has no size, and what that disk holds.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub(crate) struct Unsized {
45    pub name: String,
46    pub usage: Option<i64>,
47}
48
49/// The instances of `oc`'s project whose root disk (their own, else their
50/// profiles') has no size: the ones that block a disk limit.
51pub(crate) fn unsized_roots(oc: &Client) -> Result<Vec<Unsized>> {
52    let list = oc.get("/1.0/instances?recursion=1")?;
53    let names: Vec<String> = list
54        .as_array()
55        .into_iter()
56        .flatten()
57        .filter(|i| i["expanded_devices"]["root"].get("size").is_none())
58        .filter_map(|i| i["name"].as_str().map(String::from))
59        .collect();
60    // What each holds, read for the first few only: it is advice.
61    Ok(names
62        .into_iter()
63        .enumerate()
64        .map(|(n, name)| {
65            let usage = (n < 20)
66                .then(|| {
67                    oc.get_opt(&format!("/1.0/instances/{}/state", encode_segment(&name)))
68                        .ok()
69                        .flatten()
70                })
71                .flatten()
72                .and_then(|s| s["disk"]["root"]["usage"].as_i64());
73            Unsized { name, usage }
74        })
75        .collect())
76}
77
78/// The refusal to set a disk limit on `org` while `list` have no root
79/// size: incus would refuse it too, saying less.
80pub(crate) fn refuse_unsized(org: &OrgId, list: &[Unsized]) -> Error {
81    let each: Vec<String> = list
82        .iter()
83        .map(|u| match u.usage {
84            Some(b) => format!("{} (holds {})", u.name, bytes(b)),
85            None => u.name.clone(),
86        })
87        .collect();
88    Error::invalid(format!(
89        "org {org}: a disk limit needs every instance's root disk to have a size, and {} {} none: {}. \
90         Give each one's service `raw_devices: {{root: {{size: ...}}}}` (at least what it holds) \
91         and redeploy it, or delete the instance; then set the limit again",
92        each.len(),
93        if each.len() == 1 { "has" } else { "have" },
94        each.join(", ")
95    ))
96}
97
98/// The instances of `oc`'s project that take their root disk from the
99/// default profile (they have none of their own), so a size there is theirs.
100pub(crate) fn on_profile_root(oc: &Client) -> Result<Vec<String>> {
101    let list = oc.get("/1.0/instances?recursion=1")?;
102    Ok(list
103        .as_array()
104        .into_iter()
105        .flatten()
106        .filter(|i| {
107            i["devices"].get("root").is_none()
108                && i["profiles"]
109                    .as_array()
110                    .is_some_and(|p| p.iter().any(|p| p == "default"))
111        })
112        .filter_map(|i| i["name"].as_str().map(String::from))
113        .collect())
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::client::fake::{Route, serve};
120
121    fn project(limited: bool) -> Route {
122        let config = if limited {
123            json!({"limits.disk": "100GiB"})
124        } else {
125            json!({})
126        };
127        Route {
128            prefix: "GET /1.0/projects/isb-lab",
129            status: 200,
130            body: json!({"name": "isb-lab", "config": config}),
131        }
132    }
133
134    fn body(root: Value) -> Value {
135        json!({"name": "web-1", "config": {"user.isb.rev": "d59025b7"}, "devices": {"root": root}})
136    }
137
138    #[test]
139    fn a_new_root_disk_is_sized_only_under_a_disk_limit() {
140        let root = json!({"type": "disk", "path": "/", "pool": "default"});
141        let (_d, c) = serve(vec![project(false)]);
142        let c = c.project("isb-lab");
143        let b = sized_root(&c, body(root.clone()));
144        assert!(b["devices"]["root"].get("size").is_none(), "{b}");
145
146        let (_d, c) = serve(vec![project(true)]);
147        let c = c.project("isb-lab");
148        let b = sized_root(&c, body(root.clone()));
149        assert_eq!(b["devices"]["root"]["size"], "10GiB");
150        // The rest of the request (the revision label with it) is as it was.
151        assert_eq!(b["config"], body(root.clone())["config"]);
152        assert_eq!(b["devices"]["root"]["pool"], "default");
153
154        // The service's own size wins.
155        let mut own = root.clone();
156        own["size"] = json!("30GiB");
157        let b = sized_root(&c, body(own));
158        assert_eq!(b["devices"]["root"]["size"], "30GiB");
159
160        // No root of its own: left to its profile (and to incus).
161        let b = sized_root(&c, json!({"name": "x", "devices": {}}));
162        assert!(b["devices"].get("root").is_none(), "{b}");
163    }
164
165    #[test]
166    fn an_unreadable_project_has_no_disk_limit() {
167        let (_d, c) = serve(vec![]);
168        assert!(!disk_limited(&c.project("isb-lab")));
169    }
170
171    #[test]
172    fn instances_without_a_root_size_are_named_with_what_they_hold() {
173        let (_d, c) = serve(vec![
174            Route {
175                prefix: "GET /1.0/instances?recursion=1",
176                status: 200,
177                body: json!([
178                    {"name": "sized", "profiles": ["default"], "devices": {"root": {"size": "10GiB"}},
179                     "expanded_devices": {"root": {"size": "10GiB"}}},
180                    {"name": "by-profile", "profiles": ["default"], "devices": {},
181                     "expanded_devices": {"root": {"size": "10GiB"}}},
182                    {"name": "web-1", "profiles": ["default"], "devices": {"root": {"path": "/"}},
183                     "expanded_devices": {"root": {"path": "/"}}},
184                    {"name": "db-1", "profiles": ["default"], "devices": {},
185                     "expanded_devices": {"root": {"path": "/"}}},
186                ]),
187            },
188            Route {
189                prefix: "GET /1.0/instances/web-1/state",
190                status: 200,
191                body: json!({"disk": {"root": {"usage": 3_221_225_472_i64}}}),
192            },
193        ]);
194        let c = c.project("isb-lab");
195        let u = unsized_roots(&c).unwrap();
196        assert_eq!(
197            u,
198            vec![
199                Unsized {
200                    name: "web-1".into(),
201                    usage: Some(3_221_225_472)
202                },
203                Unsized {
204                    name: "db-1".into(),
205                    usage: None
206                },
207            ]
208        );
209        let e = refuse_unsized(&OrgId::new("lab").unwrap(), &u).to_string();
210        assert!(e.contains("2 have none: web-1 (holds 3GiB), db-1."), "{e}");
211        assert!(e.contains("raw_devices: {root: {size: ...}}"), "{e}");
212        assert_eq!(on_profile_root(&c).unwrap(), vec!["by-profile", "db-1"]);
213    }
214}