Vydané zdroje
Stáhnout zdroje ZIP
CH-J Tools
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / Server Manager
872 souborů
core-20261030T220507Z-61e4558d889f
Přidáno: · Sestaveno:
1
//! Core service behind chj.disks (port of `disks/diskService.js`). Linux only; other platforms2
//! get an explicit "unsupported" result and no Linux tool is ever started on them. Reads can run3
//! without root; every change goes through the PrivilegedExecutor with the fixed4
//! `remoteDisks.sh`, as a detached remote job with persistent state and log.6
use super::inventory::{build_inventory, split_sections};7
use super::js::{number_str, truthy};8
use super::planner::{plan_operations, suggestions};9
use crate::core::error::{CoreError, CoreResult};10
use crate::core::logging::Logger;11
use crate::core::ssh::platform::detect_platform;12
use crate::core::ssh::privileged::{Frame, PrivilegedExecutor, RunOptions, RunResult};13
use crate::core::ssh::session::SessionManager;14
use indexmap::IndexMap;15
use once_cell::sync::Lazy;16
use parking_lot::Mutex;17
use regex::Regex;18
use serde_json::{json, Map, Value};19
use std::collections::HashSet;20
use std::sync::Arc;21
use std::time::Duration;23
pub const SCRIPT: &str = include_str!("../../../resources/remote/remoteDisks.sh");24
const PLAN_TTL_MS: i64 = 30 * 60 * 1000;25
const MAX_PLANS: usize = 20;26
static JOB_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9-]{8,64}$").unwrap());27
static DEVICE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/dev/[A-Za-z0-9][A-Za-z0-9._/:+-]{0,90}$").unwrap());28
static STEP_MARKER: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eSTEP (\d+)\|(\d+)\|([a-z]+)\|([^\n]*)").unwrap());29
static STEP_LINE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eSTEP [^\n]*\n?").unwrap());30
static CONTROL_KEEP_LF: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\x00-\x08\x0b-\x1f\x7f]").unwrap());32
fn fail(code: &str, message: impl Into<String>) -> CoreError {33
CoreError::new(&format!("DISKS_{code}"), message)34
}36
fn now_ms() -> i64 {37
chrono::Utc::now().timestamp_millis()38
}40
fn 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()42
}44
/// diskService `text()`: control characters except TAB/LF → space, no trim.45
fn text(value: &str, limit: usize) -> String {46
super::js::cut(&CONTROL_KEEP_LF.replace_all(value, " "), limit)47
}49
fn 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
values58
}60
pub 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
})85
}87
fn 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()89
}91
pub 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::Null124
};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::Null131
};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
})153
}155
/// `text.split(/^@@/m)`.156
fn 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
parts170
}172
struct StoredPlan {173
session_id: String,174
identity: String,175
operations: Value,176
plan_text: String,177
title: Value,178
created_at: i64,179
}181
impl 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
}188
}190
#[derive(Default)]191
pub struct RunAuth {192
pub privileged: bool,193
pub sudo_password: Option<String>,194
pub remember: bool,195
}197
fn 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 }199
}201
pub 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>>,207
}209
impl 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.executor229
.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
.await246
}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"] })).await339
}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 the347
/// reviewed steps; a plan is single-use, so a retry after a lost connection can never start the348
/// 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
result374
}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> = section407
.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
}445
}447
#[cfg(test)]448
mod 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(§ions);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
}476
}SHA-256
995ab710ec88f0886d5ef15a0391ee6887f397e59dd63c3bc3cddb9914860a3aCH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df