CH-J ToolsSerververwaltung über SSH
Menü
Veröffentlichte Quellen

CH-J Tools

Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.

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

CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.

SHA-256 des Archivs77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Dateien