1use 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
18pub 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
30pub 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#[derive(Debug, Clone, PartialEq, Eq)]
44pub(crate) struct Unsized {
45 pub name: String,
46 pub usage: Option<i64>,
47}
48
49pub(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 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
78pub(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
98pub(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 assert_eq!(b["config"], body(root.clone())["config"]);
152 assert_eq!(b["devices"]["root"]["pool"], "default");
153
154 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 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}