| 1 | use crate::*; |
| 2 | |
| 3 | async fn datasets() -> Result<Value> { |
| 4 | host::call(json!({"operation":"storage.datasets"})).await |
| 5 | } |
| 6 | fn leaves(vdev: &Value, out: &mut Vec<Value>) { |
| 7 | if let Some(children) = vdev["vdevs"].as_object() { |
| 8 | for child in children.values() { |
| 9 | leaves(child, out); |
| 10 | } |
| 11 | } else { |
| 12 | let errors = number(&vdev["read_errors"]) |
| 13 | + number(&vdev["write_errors"]) |
| 14 | + number(&vdev["checksum_errors"]); |
| 15 | let mut issues = Vec::new(); |
| 16 | if vdev["state"] != "ONLINE" { |
| 17 | issues.push(string(&vdev["state"]).to_lowercase()); |
| 18 | } |
| 19 | if errors > 0.0 { |
| 20 | issues.push(format!( |
| 21 | "{errors} error{}", |
| 22 | if errors == 1.0 { "" } else { "s" } |
| 23 | )); |
| 24 | } |
| 25 | out.push(json!({"name":vdev["name"],"state":vdev["state"],"read":vdev["read_errors"],"write":vdev["write_errors"],"checksum":vdev["checksum_errors"],"smart":null,"issue":if issues.is_empty() {None} else {Some(issues.join(", "))}})); |
| 26 | } |
| 27 | } |
| 28 | async fn pool() -> Result<Value> { |
| 29 | let value = host::call(json!({"operation":"storage.pool"})).await?; |
| 30 | let name = string(&value["name"]); |
| 31 | let pool = &value["status"]["pools"][name]; |
| 32 | let mut output = value["summary"].clone(); |
| 33 | output["name"] = json!(name); |
| 34 | output["state"] = pool["state"].clone(); |
| 35 | output["scan"] = Value::Null; |
| 36 | let mut vdevs = Vec::new(); |
| 37 | let mut striped = Vec::new(); |
| 38 | for vdev in pool["vdevs"][name]["vdevs"] |
| 39 | .as_object() |
| 40 | .into_iter() |
| 41 | .flat_map(|v| v.values()) |
| 42 | { |
| 43 | if vdev["vdevs"].is_object() { |
| 44 | let mut disks = Vec::new(); |
| 45 | leaves(vdev, &mut disks); |
| 46 | vdevs.push(json!({"name":vdev["name"],"state":vdev["state"],"disks":disks})); |
| 47 | } else { |
| 48 | leaves(vdev, &mut striped); |
| 49 | } |
| 50 | } |
| 51 | if !striped.is_empty() { |
| 52 | vdevs.insert( |
| 53 | 0, |
| 54 | json!({"name":name,"state":pool["state"],"disks":striped}), |
| 55 | ); |
| 56 | } |
| 57 | for kind in ["logs", "l2cache", "special", "dedup", "spares"] { |
| 58 | let children: Vec<_> = pool[kind] |
| 59 | .as_object() |
| 60 | .into_iter() |
| 61 | .flat_map(|v| v.values()) |
| 62 | .collect(); |
| 63 | if children.is_empty() { |
| 64 | continue; |
| 65 | } |
| 66 | let worst = children |
| 67 | .iter() |
| 68 | .find(|v| v["state"] != "ONLINE" && v["state"] != "AVAIL") |
| 69 | .unwrap_or(&children[0]); |
| 70 | let mut disks = Vec::new(); |
| 71 | for child in &children { |
| 72 | leaves(child, &mut disks); |
| 73 | } |
| 74 | vdevs.push(json!({"name":if kind=="l2cache" {"cache"} else {kind},"state":worst["state"],"disks":disks})); |
| 75 | } |
| 76 | output["vdevs"] = json!(vdevs); |
| 77 | let scan = &pool["scan_stats"]; |
| 78 | if ["SCANNING", "FINISHED", "CANCELED"].contains(&string(&scan["state"])) { |
| 79 | output["scan"] = json!({"kind":if scan["function"]=="RESILVER" {"resilver"} else {"scrub"},"state":string(&scan["state"]).to_lowercase(),"start":scan["start_time"],"end":if scan["state"]=="SCANNING" {Value::Null} else {scan["end_time"].clone()},"examined":scan["examined"],"total":scan["to_examine"],"repaired":scan["processed"],"errors":scan["errors"]}); |
| 80 | } |
| 81 | output["errors"] = json!(if number(&pool["error_count"]) > 0.0 { |
| 82 | format!("{} data errors", number(&pool["error_count"])) |
| 83 | } else { |
| 84 | "No known data errors".into() |
| 85 | }); |
| 86 | Ok(output) |
| 87 | } |
| 88 | pub async fn snapshots(dataset: &str) -> Result<Value> { |
| 89 | validate(dataset, false)?; |
| 90 | host::call(json!({"operation":"storage.snapshots","dataset":dataset})).await |
| 91 | } |
| 92 | pub fn validate(name: &str, snapshot: bool) -> Result<()> { |
| 93 | let regex = if snapshot { |
| 94 | r"^[\w.: ][\w.: -]*$" |
| 95 | } else { |
| 96 | r"^[\w.: ][\w.: -]*(/[\w.: -]+)*$" |
| 97 | }; |
| 98 | if regex::Regex::new(regex).unwrap().is_match(name) { |
| 99 | Ok(()) |
| 100 | } else { |
| 101 | Err(Error::new( |
| 102 | 400, |
| 103 | "That isn't a ZFS name. Pick one from the list.", |
| 104 | )) |
| 105 | } |
| 106 | } |
| 107 | async fn existing(dataset: &str, from: &str, to: &str) -> Result<()> { |
| 108 | validate(from, true)?; |
| 109 | validate(to, true)?; |
| 110 | let values = snapshots(dataset).await?; |
| 111 | for name in [from, to] { |
| 112 | if !array(&values).iter().any(|s| s["name"] == name) { |
| 113 | return Err(Error::new(404, format!("No snapshot {dataset}@{name}"))); |
| 114 | } |
| 115 | } |
| 116 | Ok(()) |
| 117 | } |
| 118 | pub fn unescape(name: &str) -> String { |
| 119 | let bytes = name.as_bytes(); |
| 120 | let mut out = Vec::new(); |
| 121 | let mut i = 0; |
| 122 | while i < bytes.len() { |
| 123 | if bytes[i] == b'\\' |
| 124 | && i + 4 < bytes.len() |
| 125 | && bytes[i + 1..i + 5] |
| 126 | .iter() |
| 127 | .all(|b| (b'0'..=b'7').contains(b)) |
| 128 | { |
| 129 | let octal = std::str::from_utf8(&bytes[i + 1..i + 5]).unwrap(); |
| 130 | out.push(u16::from_str_radix(octal, 8).unwrap() as u8); |
| 131 | i += 5; |
| 132 | } else { |
| 133 | out.push(bytes[i]); |
| 134 | i += 1; |
| 135 | } |
| 136 | } |
| 137 | String::from_utf8_lossy(&out).into_owned() |
| 138 | } |
| 139 | pub async fn route( |
| 140 | app: Arc<App>, |
| 141 | method: &Method, |
| 142 | parts: &[&str], |
| 143 | query: &HashMap<String, String>, |
| 144 | body: Value, |
| 145 | ) -> Result<Response> { |
| 146 | let value = match parts { |
| 147 | [] if method == Method::GET => { |
| 148 | let found=app.cache.get("storage".into(),Duration::from_secs(30),move || async move { |
| 149 | let (pool,mut datasets)=tokio::try_join!(pool(),datasets())?;let root=array(&datasets).iter().find(|d| d["name"]==pool["name"]).ok_or_else(|| Error::new(502,"The storage pool has no root dataset."))?;let held:f64=array(&datasets).iter().map(|d| number(&d["usedbysnapshots"])).sum();let space=json!({"live":number(&root["used"])-held,"held":held,"free":root["available"]}); |
| 150 | let media=env("STUDIO_MEDIA_ROOT",&format!("{}/clover/Media",env("STUDIO_STORE_ROOT","/srv")));for dataset in datasets.as_array_mut().unwrap() {let mount=dataset["mountpoint"].as_str().map(str::to_owned);dataset["link"]=mount.as_ref().map(|p| apps::file_link(p,false)).unwrap_or(Value::Null);dataset["media"]=mount.and_then(|p| std::path::Path::new(&p).strip_prefix(&media).ok().map(|p| p.to_string_lossy().into_owned())).map(|p| json!(p)).unwrap_or(Value::Null);}Ok(json!({"pool":pool,"datasets":datasets,"space":space})) |
| 151 | }).await?; |
| 152 | return Ok(found.response()); |
| 153 | } |
| 154 | ["snapshots"] if method == Method::GET => { |
| 155 | let dataset = query |
| 156 | .get("dataset") |
| 157 | .ok_or_else(|| Error::new(400, "Pick a dataset."))?; |
| 158 | json!( |
| 159 | array(&snapshots(dataset).await?) |
| 160 | .iter() |
| 161 | .filter(|s| !string(&s["name"]).starts_with("index-")) |
| 162 | .cloned() |
| 163 | .collect::<Vec<_>>() |
| 164 | ) |
| 165 | } |
| 166 | ["reclaim"] if method == Method::GET => { |
| 167 | let dataset = query |
| 168 | .get("dataset") |
| 169 | .ok_or_else(|| Error::new(400, "Pick a dataset."))?; |
| 170 | let from = query |
| 171 | .get("from") |
| 172 | .ok_or_else(|| Error::new(400, "Pick a snapshot."))?; |
| 173 | let to = query |
| 174 | .get("to") |
| 175 | .ok_or_else(|| Error::new(400, "Pick a snapshot."))?; |
| 176 | existing(dataset, from, to).await?; |
| 177 | let value = host::call( |
| 178 | json!({"operation":"storage.reclaim","dataset":dataset,"from":from,"to":to}), |
| 179 | ) |
| 180 | .await?; |
| 181 | let bytes = string(&value) |
| 182 | .lines() |
| 183 | .find_map(|line| line.strip_prefix("reclaim\t")) |
| 184 | .and_then(|s| s.parse::<f64>().ok()) |
| 185 | .unwrap_or(0.0); |
| 186 | json!({"bytes":bytes}) |
| 187 | } |
| 188 | ["destroy"] if method == Method::POST => { |
| 189 | let (dataset, from, to) = ( |
| 190 | string(&body["dataset"]), |
| 191 | string(&body["from"]), |
| 192 | string(&body["to"]), |
| 193 | ); |
| 194 | existing(dataset, from, to).await?; |
| 195 | host::call( |
| 196 | json!({"operation":"storage.destroy","dataset":dataset,"from":from,"to":to}), |
| 197 | ) |
| 198 | .await?; |
| 199 | app.cache.invalidate("storage"); |
| 200 | Value::Null |
| 201 | } |
| 202 | ["removed"] if method == Method::GET => { |
| 203 | let dataset = query |
| 204 | .get("dataset") |
| 205 | .ok_or_else(|| Error::new(400, "Pick a dataset."))?; |
| 206 | let snapshot = query |
| 207 | .get("snapshot") |
| 208 | .ok_or_else(|| Error::new(400, "Pick a snapshot."))?; |
| 209 | validate(dataset, false)?; |
| 210 | validate(snapshot, true)?; |
| 211 | let values = datasets().await?; |
| 212 | let mount = array(&values) |
| 213 | .iter() |
| 214 | .find(|d| d["name"] == *dataset) |
| 215 | .and_then(|d| d["mountpoint"].as_str()) |
| 216 | .ok_or_else(|| { |
| 217 | Error::new( |
| 218 | 409, |
| 219 | format!("{dataset} isn't mounted, so its deleted files can't be listed"), |
| 220 | ) |
| 221 | })?; |
| 222 | let value = host::call( |
| 223 | json!({"operation":"storage.removed","dataset":dataset,"snapshot":snapshot}), |
| 224 | ) |
| 225 | .await?; |
| 226 | let text = string(&value); |
| 227 | let mut files = Vec::new(); |
| 228 | for line in text.lines() { |
| 229 | if let Some(path) = line.strip_prefix("-\t") { |
| 230 | let path = unescape(path); |
| 231 | let Ok(relative) = std::path::Path::new(&path).strip_prefix(mount) else { |
| 232 | continue; |
| 233 | }; |
| 234 | let saved = std::path::Path::new(mount) |
| 235 | .join(".zfs/snapshot") |
| 236 | .join(snapshot) |
| 237 | .join(relative); |
| 238 | if let Ok(info) = tokio::fs::symlink_metadata(saved).await |
| 239 | && info.is_file() |
| 240 | { |
| 241 | files.push(json!({"path":relative,"size":info.len()})); |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | files.sort_by(|a, b| number(&b["size"]).total_cmp(&number(&a["size"]))); |
| 246 | json!({"count":files.len(),"largest":files.into_iter().take(4).collect::<Vec<_>>()}) |
| 247 | } |
| 248 | _ => return Err(Error::new(404, "Not Found")), |
| 249 | }; |
| 250 | Ok(if value.is_null() { |
| 251 | StatusCode::NO_CONTENT.into_response() |
| 252 | } else { |
| 253 | Document::new(value).response() |
| 254 | }) |
| 255 | } |