CH-J Toolspengurusan pelayan melalui SSH
Menu
Sumber yang diterbitkan

CH-J Tools

Layari direktori dan fail untuk keluaran aplikasi tertentu.

Muat turun ZIP sumber
CH-J / Server Manager 1704 fail
0.1.2-beta Ditambah: · Dibina:
service.rs Ditambah: Tarikh tidak direkodkan 25.8 KB · 475 baris
Kod Muat turun fail
1//! Core service behind chj.disks (port of `disks/diskService.js`). Linux only; other platforms
2//! get an explicit "unsupported" result and no Linux tool is ever started on them. Reads can run
3//! without root; every change goes through the PrivilegedExecutor with the fixed
4//! `remoteDisks.sh`, as a detached remote job with persistent state and log.
6use super::inventory::{build_inventory, split_sections};
7use super::js::{number_str, truthy};
8use super::planner::{plan_operations, suggestions};
9use crate::core::error::{CoreError, CoreResult};
10use crate::core::logging::Logger;
11use crate::core::ssh::platform::detect_platform;
12use crate::core::ssh::privileged::{Frame, PrivilegedExecutor, RunOptions, RunResult};
13use crate::core::ssh::session::SessionManager;
14use indexmap::IndexMap;
15use once_cell::sync::Lazy;
16use parking_lot::Mutex;
17use regex::Regex;
18use serde_json::{json, Map, Value};
19use std::collections::HashSet;
20use std::sync::Arc;
21use std::time::Duration;
23const PLAN_TTL_MS: i64 = 30 * 60 * 1000;
24const MAX_PLANS: usize = 20;
25static JOB_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9-]{8,64}$").unwrap());
26static DEVICE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/dev/[A-Za-z0-9][A-Za-z0-9._/:+-]{0,90}$").unwrap());
27static STEP_MARKER: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eSTEP (\d+)\|(\d+)\|([a-z]+)\|([^\n]*)").unwrap());
28static STEP_LINE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eSTEP [^\n]*\n?").unwrap());
29static CONTROL_KEEP_LF: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\x00-\x08\x0b-\x1f\x7f]").unwrap());
31fn fail(code: &str, message: impl Into<String>) -> CoreError {
32 CoreError::new(&format!("DISKS_{code}"), message)
35fn now_ms() -> i64 {
36 chrono::Utc::now().timestamp_millis()
39fn iso(ms: i64) -> String {
40 chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms).map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)).unwrap_or_default()
43/// diskService `text()`: control characters except TAB/LF → space, no trim.
44fn text(value: &str, limit: usize) -> String {
45 super::js::cut(&CONTROL_KEEP_LF.replace_all(value, " "), limit)
48fn key_values(section: &str) -> Map<String, Value> {
49 let mut values = Map::new();
50 for line in section.split('\n') {
51 let line = line.strip_suffix('\r').unwrap_or(line);
52 if let Some(i) = line.find('=').filter(|i| *i > 0) {
53 values.insert(line[..i].to_string(), json!(&line[i + 1..]));
54 }
55 }
56 values
59pub fn public_job(values: &Map<String, Value>) -> Value {
60 let get = |k: &str| values.get(k).and_then(Value::as_str).unwrap_or("").to_string();
61 let error = get("error");
62 let mut parts = error.split('|');
63 let code = parts.next().unwrap_or("").to_string();
64 let message = parts.collect::<Vec<_>>().join("|");
65 let state = get("state");
66 let alive = get("alive") == "1";
67 let num = |k: &str| {
68 let n = number_str(&get(k));
69 if n.is_finite() && n != 0.0 { super::js::num(n) } else { json!(0) }
70 };
71 let or_null = |s: String| if s.is_empty() { Value::Null } else { json!(s) };
72 let running = state == "running" || state == "queued";
73 json!({
74 "id": text(&get("id"), 64),
75 "state": if text(&state, 20).is_empty() { "unknown".to_string() } else { text(&state, 20) },
76 "step": num("step"), "total": num("total"),
77 "created": or_null(text(&get("created"), 30)), "started": or_null(text(&get("started"), 30)), "finished": or_null(text(&get("finished"), 30)),
78 "runner": or_null(text(&get("runner"), 16)), "title": text(&get("title"), 300),
79 "alive": alive,
80 "error": if code.is_empty() { Value::Null } else { json!({ "code": format!("DISKS_{}", text(&code, 64)), "message": text(&message, 600) }) },
81 // "running" without a live runner means the outcome is unknown (crash/reboot): never restart it automatically.
82 "outcome": if ["succeeded", "failed", "pending-reboot"].contains(&state.as_str()) { state.clone() } else if running && !alive { "unknown".into() } else if running { "running".into() } else { "unknown".into() }
83 })
86fn parse_step_markers(log: &str) -> Vec<Value> {
87 STEP_MARKER.captures_iter(log).map(|c| json!({ "index": super::js::num(number_str(&c[1])), "total": super::js::num(number_str(&c[2])), "status": &c[3], "step": text(&c[4], 400) })).collect()
90pub fn smart_summary(sections: &std::collections::HashMap<String, String>) -> Value {
91 let smart: Value = sections.get("smartctl").and_then(|s| serde_json::from_str(s).ok()).unwrap_or(Value::Null);
92 let (mut nvme_log, mut nvme_id) = (Value::Null, Value::Null);
93 if let Some(nvme) = sections.get("nvme").filter(|s| !s.is_empty()) {
94 for part in split_at_line_start(nvme, "@@") {
95 let body = part.find('\n').map(|i| &part[i + 1..]).unwrap_or(part);
96 if let Ok(value) = serde_json::from_str::<Value>(body) {
97 if part.starts_with("smart") {
98 nvme_log = value;
99 } else if part.starts_with("id") {
100 nvme_id = value;
101 }
102 }
103 }
104 }
105 let messages: Vec<String> = smart["smartctl"]["messages"].as_array().map(|m| m.iter().map(|e| text(&super::js::string(e.get("string")), 200)).collect()).unwrap_or_default();
106 let nvme_health = if truthy(&smart["nvme_smart_health_information_log"]) { smart["nvme_smart_health_information_log"].clone() } else { nvme_log.clone() };
107 let attributes = smart["ata_smart_attributes"]["table"].as_array().cloned().unwrap_or_default();
108 let attribute = |id: i64| attributes.iter().find(|e| e["id"] == id).map(|e| e["raw"]["value"].clone()).filter(|v| !v.is_null()).unwrap_or(Value::Null);
109 let available = truthy(&smart["smart_status"]) || truthy(&nvme_health) || smart["smart_support"]["enabled"] == true;
110 let first = |values: &[&Value]| values.iter().find(|v| !v.is_null()).map(|v| (*v).clone()).unwrap_or(Value::Null);
111 let pick_text = |a: &Value, b: &Value, limit: usize| {
112 let source = if truthy(a) { super::js::string(Some(a)) } else if truthy(b) { super::js::string(Some(b)) } else { String::new() };
113 let t = text(&source, limit).trim().to_string();
114 if t.is_empty() { Value::Null } else { json!(t) }
115 };
116 let temperature = if !smart["temperature"]["current"].is_null() {
117 smart["temperature"]["current"].clone()
118 } else if nvme_health.get("temperature").is_some() {
119 let t = super::js::number(&nvme_health["temperature"]);
120 super::js::num(if t > 200.0 { t - 273.0 } else { t })
121 } else {
122 Value::Null
123 };
124 let passed = if smart["smart_status"]["passed"].is_boolean() {
125 smart["smart_status"]["passed"].clone()
126 } else if truthy(&nvme_health) {
127 json!(super::js::number(&nvme_health["critical_warning"]) == 0.0)
128 } else {
129 Value::Null
130 };
131 json!({
132 "available": available,
133 "tool": if truthy(&smart) && !truthy(&smart["chj_unavailable"]) { json!("smartctl") } else if truthy(&nvme_log) { json!("nvme-cli") } else { Value::Null },
134 "reason": if available { Value::Null } else if truthy(&smart["chj_unavailable"]) { json!("SMARTCTL_MISSING") } else { json!(messages.first().filter(|m| !m.is_empty()).cloned().unwrap_or_else(|| "SMART_UNAVAILABLE".into())) },
135 "messages": messages,
136 "model": pick_text(&smart["model_name"], &nvme_id["mn"], 80),
137 "serial": pick_text(&smart["serial_number"], &nvme_id["sn"], 80),
138 "firmware": pick_text(&smart["firmware_version"], &nvme_id["fr"], 40),
139 "capacity": smart["user_capacity"]["bytes"].clone(),
140 "passed": passed,
141 "temperatureC": temperature,
142 "powerOnHours": first(&[&smart["power_on_time"]["hours"], &nvme_health["power_on_hours"]]),
143 "powerCycles": first(&[&smart["power_cycle_count"], &nvme_health["power_cycles"]]),
144 "reallocatedSectors": attribute(5), "pendingSectors": attribute(197), "offlineUncorrectable": attribute(198), "crcErrors": attribute(199),
145 "percentageUsed": first(&[&nvme_health["percentage_used"], &nvme_health["percent_used"]]),
146 "availableSpare": nvme_health["available_spare"].clone(),
147 "mediaErrors": nvme_health["media_errors"].clone(),
148 "errorLogEntries": first(&[&nvme_health["num_err_log_entries"], &smart["ata_smart_error_log"]["summary"]["count"]]),
149 "dataUnitsWritten": nvme_health["data_units_written"].clone(),
150 "selfTestPassed": smart["ata_smart_self_test_log"]["standard"]["table"][0]["status"]["passed"].clone()
151 })
154/// `text.split(/^@@/m)`.
155fn split_at_line_start<'a>(text: &'a str, marker: &str) -> Vec<&'a str> {
156 let mut parts = Vec::new();
157 let mut start = 0;
158 let mut search = 0;
159 while let Some(found) = text[search..].find(marker) {
160 let at = search + found;
161 if at == 0 || text.as_bytes()[at - 1] == b'\n' {
162 parts.push(&text[start..at]);
163 start = at + marker.len();
164 }
165 search = at + marker.len();
166 }
167 parts.push(&text[start..]);
168 parts
171struct StoredPlan {
172 session_id: String,
173 identity: String,
174 operations: Value,
175 plan_text: String,
176 title: Value,
177 created_at: i64,
180impl StoredPlan {
181 fn check_session(&self, session_id: &str, identity: &str) -> CoreResult<()> {
182 if self.session_id != session_id || self.identity != identity {
183 return Err(fail("PLAN_SERVER_CHANGED", "The plan belongs to a different server or connection."));
184 }
185 Ok(())
186 }
189#[derive(Default)]
190pub struct RunAuth {
191 pub privileged: bool,
192 pub sudo_password: Option<String>,
193 pub remember: bool,
196fn auth(payload: &Value, privileged: bool) -> RunAuth {
197 RunAuth { privileged, sudo_password: payload["sudoPassword"].as_str().filter(|s| !s.is_empty()).map(str::to_string), remember: payload["rememberSudo"] == true }
200pub struct DiskService {
201 sessions: Arc<SessionManager>,
202 executor: Arc<PrivilegedExecutor>,
203 logger: Arc<Logger>,
204 plans: Mutex<IndexMap<String, StoredPlan>>,
205 busy: Mutex<HashSet<String>>,
208impl DiskService {
209 pub fn new(sessions: Arc<SessionManager>, executor: Arc<PrivilegedExecutor>, logger: Arc<Logger>) -> Self {
210 Self { sessions, executor, logger, plans: Mutex::new(IndexMap::new()), busy: Mutex::new(HashSet::new()) }
211 }
213 fn identity(&self, session_id: &str) -> CoreResult<String> {
214 let record = self.sessions.require_connected(session_id)?;
215 Ok(json!([record.profile_id, record.host, record.port, record.username, record.public_status()["jump"]]).to_string())
216 }
218 async fn platform(&self, session_id: &str) -> CoreResult<(String, Value)> {
219 let record = self.sessions.require_connected(session_id)?;
220 let identity = self.identity(session_id)?;
221 let platform = detect_platform(&self.sessions, &record, false).await?;
222 Ok((identity, platform))
223 }
225 async fn run(&self, session_id: &str, operation: &str, args: Vec<String>, auth: RunAuth, frames: Vec<Frame>, timeout_ms: u64) -> CoreResult<RunResult> {
226 let purpose = operation.to_string();
227 self.executor
228 .run(
229 session_id,
230 RunOptions {
231 operation: operation.into(),
232 args,
233 frames,
234 privileged: auth.privileged,
235 sudo_password: auth.sudo_password,
236 remember_password: auth.remember,
237 use_cached_password: auth.privileged,
238 purpose,
239 error_prefix: "DISKS_".into(),
240 timeout: Duration::from_millis(timeout_ms),
241 output_limit: 8 * 1024 * 1024,
242 },
243 )
244 .await
245 }
247 /// `options`: `{ privileged?, sudoPassword?, rememberSudo? }`.
248 pub async fn inspect(&self, session_id: &str, options: &Value) -> CoreResult<Value> {
249 let (identity, platform) = self.platform(session_id).await?;
250 if platform["os"] != "linux" {
251 let caption = if truthy(&platform["caption"]) { super::js::string(Some(&platform["caption"])) } else { super::js::string(platform.get("kernel")) };
252 return Ok(json!({
253 "platform": platform["os"], "supported": false, "identity": identity,
254 "os": super::js::text(&caption, 120),
255 "reason": match platform["os"].as_str() { Some("windows") => "WINDOWS", Some("posix") => "POSIX_NOT_LINUX", _ => "UNKNOWN" },
256 "collectedAt": iso(now_ms())
257 }));
258 }
259 let privilege = self.executor.probe(session_id, false).await?;
260 let cached = self.executor.has_cached_password(session_id);
261 let privileged = options["privileged"] == true || truthy(&options["sudoPassword"]) || ["root", "sudo-nopasswd"].contains(&privilege["mode"].as_str().unwrap_or("")) || cached;
262 let run = self.run(session_id, "disk.inventory.read", vec![], auth(options, privileged), vec![], 120_000).await?;
263 let inventory = build_inventory(&run.stdout);
264 let suggestions = suggestions(&inventory);
265 let mut out = inventory.as_object().cloned().unwrap_or_default();
266 out.insert("identity".into(), json!(identity));
267 out.insert("privilege".into(), self.executor.probe(session_id, false).await?);
268 out.insert("canManage".into(), json!(privilege["mode"] != "none"));
269 out.insert("sudoCached".into(), json!(self.executor.has_cached_password(session_id)));
270 out.insert("suggestions".into(), Value::Array(suggestions));
271 out.insert("collectedAt".into(), json!(iso(now_ms())));
272 Ok(Value::Object(out))
273 }
275 pub async fn smart(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
276 let (_, platform) = self.platform(session_id).await?;
277 if platform["os"] != "linux" {
278 return Err(fail("PLATFORM_UNSUPPORTED", "SMART is read only on Linux servers."));
279 }
280 let device = super::js::string(payload.get("device").filter(|v| truthy(v)));
281 if !DEVICE.is_match(&device) || device.contains("..") {
282 return Err(fail("ARGUMENTS_INVALID", "Invalid device."));
283 }
284 let run = self.run(session_id, "disk.health.read", vec![device.clone()], auth(payload, true), vec![], 60_000).await?;
285 let mut out = Map::new();
286 out.insert("device".into(), json!(device));
287 for (k, v) in smart_summary(&split_sections(&run.stdout)).as_object().cloned().unwrap_or_default() {
288 out.insert(k, v);
289 }
290 out.insert("collectedAt".into(), json!(iso(now_ms())));
291 Ok(Value::Object(out))
292 }
294 /// Validates and simulates a queue of operations against a fresh privileged inventory. Nothing changes on disk.
295 pub async fn plan(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
296 let (identity, platform) = self.platform(session_id).await?;
297 if platform["os"] != "linux" {
298 return Err(fail("PLATFORM_UNSUPPORTED", "Disk changes are supported only on Linux servers."));
299 }
300 let operations = if payload["operations"].is_array() { payload["operations"].clone() } else { json!([]) };
301 let inventory = self.inspect(session_id, &json!({ "privileged": true, "sudoPassword": payload["sudoPassword"], "rememberSudo": payload["rememberSudo"] })).await?;
302 let plan = plan_operations(&inventory, &operations, payload.get("title"))?;
303 let plan_id = uuid::Uuid::new_v4().to_string();
304 {
305 let mut plans = self.plans.lock();
306 let now = now_ms();
307 plans.retain(|_, p| now - p.created_at <= PLAN_TTL_MS);
308 while plans.len() >= MAX_PLANS {
309 plans.shift_remove_index(0);
310 }
311 plans.insert(
312 plan_id.clone(),
313 StoredPlan { session_id: session_id.into(), identity, operations: plan["operations"].clone(), plan_text: super::js::string(plan.get("planText")), title: plan["title"].clone(), created_at: now },
314 );
315 }
316 let mut out = Map::new();
317 out.insert("planId".into(), json!(plan_id));
318 for (k, v) in plan.as_object().cloned().unwrap_or_default() {
319 out.insert(k, v);
320 }
321 out.insert("expiresAt".into(), json!(iso(now_ms() + PLAN_TTL_MS)));
322 Ok(Value::Object(out))
323 }
325 pub async fn auto_expand(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
326 let inventory = self.inspect(session_id, &json!({ "privileged": true, "sudoPassword": payload["sudoPassword"], "rememberSudo": payload["rememberSudo"] })).await?;
327 if !truthy(&inventory["supported"]) {
328 return Err(fail("PLATFORM_UNSUPPORTED", "Disk changes are supported only on Linux servers."));
329 }
330 let wanted = super::js::string(payload.get("suggestionId").filter(|v| truthy(v)));
331 let suggestion = inventory["suggestions"].as_array().and_then(|s| s.iter().find(|e| e["id"] == wanted.as_str()).cloned()).ok_or_else(|| fail("SUGGESTION_GONE", "The expansion is no longer possible with the current layout; the view was refreshed."))?;
332 if !truthy(&suggestion["available"]) {
333 let code = if truthy(&suggestion["blocked"]["code"]) { super::js::string(Some(&suggestion["blocked"]["code"])) } else { "DISKS_FAILED".into() };
334 let message = if truthy(&suggestion["blocked"]["message"]) { super::js::string(Some(&suggestion["blocked"]["message"])) } else { "The expansion is not possible.".into() };
335 return Err(fail(code.strip_prefix("DISKS_").unwrap_or(&code), message));
336 }
337 self.plan(session_id, &json!({ "operations": suggestion["operations"], "title": format!("Auto-expand {}", super::js::string(suggestion.get("target"))), "sudoPassword": payload["sudoPassword"] })).await
338 }
340 pub fn cancel_plan(&self, plan_id: &Value) -> Value {
341 let id = super::js::string(Some(plan_id).filter(|v| truthy(v)));
342 json!({ "cancelled": self.plans.lock().shift_remove(&id).is_some() })
343 }
345 /// Starts a confirmed plan. The plan is rebuilt from a fresh inventory and must produce exactly the
346 /// reviewed steps; a plan is single-use, so a retry after a lost connection can never start the
347 /// same destructive job twice (the remote side also refuses an existing job ID).
348 pub async fn apply(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
349 let plan_id = super::js::string(payload.get("planId").filter(|v| truthy(v)));
350 {
351 let mut plans = self.plans.lock();
352 let expired = plans.get(&plan_id).is_none_or(|p| now_ms() - p.created_at > PLAN_TTL_MS);
353 if expired {
354 plans.shift_remove(&plan_id);
355 return Err(fail("PLAN_EXPIRED", "The plan expired or was already used; plan the operations again."));
356 }
357 }
358 if payload["confirm"] != true {
359 return Err(fail("CONFIRMATION_REQUIRED", "The plan must be confirmed explicitly."));
360 }
361 let identity = self.identity(session_id)?;
362 {
363 let plans = self.plans.lock();
364 let stored = plans.get(&plan_id).ok_or_else(|| fail("PLAN_EXPIRED", "The plan expired or was already used; plan the operations again."))?;
365 stored.check_session(session_id, &identity)?;
366 }
367 if !self.busy.lock().insert(session_id.to_string()) {
368 return Err(fail("BUSY", "Another disk job is being started for this server."));
369 }
370 let result = self.apply_inner(session_id, &plan_id, payload).await;
371 self.busy.lock().remove(session_id);
372 result
373 }
375 async fn apply_inner(&self, session_id: &str, plan_id: &str, payload: &Value) -> CoreResult<Value> {
376 let Some(stored) = self.plans.lock().shift_remove(plan_id) else { return Err(fail("PLAN_EXPIRED", "The plan expired or was already used; plan the operations again.")) };
377 stored.check_session(session_id, &self.identity(session_id)?)?;
378 let remember = payload["rememberSudo"] != false;
379 let inventory = self.inspect(session_id, &json!({ "privileged": true, "sudoPassword": payload["sudoPassword"], "rememberSudo": remember })).await?;
380 let fresh = plan_operations(&inventory, &stored.operations, Some(&stored.title)).map_err(|e| fail("PLAN_STALE", format!("The layout changed since the plan was reviewed: {}", e.message)))?;
381 let plan_text = super::js::string(fresh.get("planText"));
382 if plan_text != stored.plan_text {
383 return Err(fail("PLAN_STALE", "The layout changed since the plan was reviewed; review the new plan."));
384 }
385 let job_id = uuid::Uuid::new_v4().to_string();
386 let steps = fresh["steps"].clone();
387 let title = super::js::string(Some(&stored.title));
388 self.logger.warn("Disk job submitted.", json!({ "sessionId": session_id, "jobId": job_id, "title": title, "steps": steps.as_array().map(Vec::len) }));
389 let frames = vec![Frame::text("PLAN", &format!("{plan_text}\n")), Frame::text("HELPER", crate::core::matrix::script("remoteDisks.sh")), Frame::text("TITLE", &title)];
390 let mut run_auth = auth(payload, true);
391 run_auth.remember = remember;
392 let run = self.run(session_id, "disk.job.start", vec![job_id.clone()], run_auth, frames, 90_000).await?;
393 let values = key_values(split_sections(&run.stdout).get("job").map(String::as_str).unwrap_or(""));
394 let get = |k: &str| values.get(k).and_then(Value::as_str).unwrap_or("").to_string();
395 Ok(json!({ "jobId": job_id, "state": text(&get("state"), 20), "runner": text(&get("runner"), 16), "steps": steps }))
396 }
398 pub async fn jobs(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
399 let (_, platform) = self.platform(session_id).await?;
400 if platform["os"] != "linux" {
401 return Ok(json!({ "jobs": [] }));
402 }
403 let run = self.run(session_id, "disk.jobs.list", vec![], RunAuth { privileged: true, sudo_password: auth(payload, true).sudo_password, remember: false }, vec![], 30_000).await?;
404 let section = split_sections(&run.stdout).get("jobs").cloned().unwrap_or_default();
405 let jobs: Vec<Value> = section
406 .split('\n')
407 .map(|l| l.strip_suffix('\r').unwrap_or(l))
408 .filter(|l| !l.is_empty())
409 .map(|line| {
410 let f: Vec<&str> = line.split('|').collect();
411 let mut values = Map::new();
412 for (i, key) in ["id", "state", "step", "total", "created", "finished", "alive", "title"].iter().enumerate() {
413 if let Some(v) = f.get(i) {
414 values.insert((*key).into(), json!(v));
415 }
416 }
417 public_job(&values)
418 })
419 .filter(|job| JOB_ID.is_match(job["id"].as_str().unwrap_or("")))
420 .collect();
421 Ok(json!({ "jobs": jobs }))
422 }
424 pub async fn job_status(&self, session_id: &str, payload: &Value) -> CoreResult<Value> {
425 let job_id = super::js::string(payload.get("jobId").filter(|v| truthy(v)));
426 if !JOB_ID.is_match(&job_id) {
427 return Err(fail("ARGUMENTS_INVALID", "Invalid job ID."));
428 }
429 let requested = super::js::number(&payload["offset"]);
430 let offset = if requested.is_finite() && requested != 0.0 { requested } else { 0.0 }.clamp(0.0, 1e12).floor();
431 let run = self.run(session_id, "disk.job.read", vec![job_id, super::js::num_string(offset)], RunAuth { privileged: true, sudo_password: auth(payload, true).sudo_password, remember: false }, vec![], 30_000).await?;
432 let sections = split_sections(&run.stdout);
433 let values = key_values(sections.get("job").map(String::as_str).unwrap_or(""));
434 let log = sections.get("log").cloned().unwrap_or_default();
435 let mut out = public_job(&values).as_object().cloned().unwrap_or_default();
436 out.insert("plan".into(), json!(sections.get("plan").map(|p| p.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).filter(|l| !l.is_empty()).map(|l| text(l, 400)).collect::<Vec<_>>()).unwrap_or_default()));
437 out.insert("log".into(), json!(CONTROL_KEEP_LF.replace_all(&STEP_LINE.replace_all(&log, ""), "").to_string()));
438 out.insert("steps".into(), json!(parse_step_markers(&log)));
439 out.insert("logOffset".into(), super::js::num(offset + log.len() as f64));
440 let size = number_str(values.get("logSize").and_then(Value::as_str).unwrap_or(""));
441 out.insert("logSize".into(), super::js::num(if size.is_finite() { size } else { 0.0 }));
442 Ok(Value::Object(out))
443 }
446#[cfg(test)]
447mod tests {
448 use super::*;
450 #[test]
451 fn plan_is_bound_to_connection_and_identity() {
452 let stored = StoredPlan { session_id: "A".into(), identity: "server-A".into(), operations: json!([]), plan_text: String::new(), title: Value::Null, created_at: now_ms() };
453 assert!(stored.check_session("A", "server-A").is_ok());
454 assert_eq!(stored.check_session("B", "server-A").unwrap_err().code, "DISKS_PLAN_SERVER_CHANGED");
455 assert_eq!(stored.check_session("A", "server-B").unwrap_err().code, "DISKS_PLAN_SERVER_CHANGED");
456 assert!(stored.check_session("B", "server-B").is_err());
457 }
459 #[test]
460 fn job_and_smart_parsing() {
461 let job = public_job(&key_values("id=abcd-1234\nstate=running\nstep=2\ntotal=5\nalive=0\nerror=SFDISK_FAILED|bad|table\n"));
462 assert_eq!(job["outcome"], "unknown");
463 assert_eq!(job["error"]["code"], "DISKS_SFDISK_FAILED");
464 assert_eq!(job["error"]["message"], "bad|table");
465 let mut sections = std::collections::HashMap::new();
466 sections.insert("smartctl".to_string(), r#"{"smart_status":{"passed":true},"model_name":"QEMU","temperature":{"current":35},"ata_smart_attributes":{"table":[{"id":5,"raw":{"value":0}}]}}"#.to_string());
467 let smart = smart_summary(&sections);
468 assert_eq!(smart["passed"], true);
469 assert_eq!(smart["tool"], "smartctl");
470 assert_eq!(smart["reallocatedSectors"], 0);
471 assert_eq!(smart["temperatureC"], 35);
472 let steps = parse_step_markers("x\n\x1eSTEP 1|3|ok|create-part /dev/vdb\nmore");
473 assert_eq!(steps[0]["status"], "ok");
474 }
SHA-256f08f47c77f256db16c286db37bbe51c2000800ed51bd352e6428343b79f5eb27

CH-J Proprietary Software License 1.14 · Kod sumber disediakan di bawah CH-J Proprietary Software License 1.14. Ketersediaannya tidak mengubah terma lesen atau memberikan hak tambahan.

SHA-256 arkibac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Fail