Avaldatud allikas
Laadi alla allikas ZIP
CH-J Tools
Vaata katalooge ja faile konkreetse rakenduse vabastamiseks.
CH-J / Server Manager
1704 failid
0.1.2-beta
Lisatud: · Ehitatud:
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
const PLAN_TTL_MS: i64 = 30 * 60 * 1000;24
const MAX_PLANS: usize = 20;25
static JOB_ID: Lazy<Regex> = Lazy::new(|| Regex::new(r"^[a-z0-9-]{8,64}$").unwrap());26
static DEVICE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^/dev/[A-Za-z0-9][A-Za-z0-9._/:+-]{0,90}$").unwrap());27
static STEP_MARKER: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eSTEP (\d+)\|(\d+)\|([a-z]+)\|([^\n]*)").unwrap());28
static STEP_LINE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\x1eSTEP [^\n]*\n?").unwrap());29
static CONTROL_KEEP_LF: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\x00-\x08\x0b-\x1f\x7f]").unwrap());31
fn fail(code: &str, message: impl Into<String>) -> CoreError {32
CoreError::new(&format!("DISKS_{code}"), message)33
}35
fn now_ms() -> i64 {36
chrono::Utc::now().timestamp_millis()37
}39
fn 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()41
}43
/// diskService `text()`: control characters except TAB/LF → space, no trim.44
fn text(value: &str, limit: usize) -> String {45
super::js::cut(&CONTROL_KEEP_LF.replace_all(value, " "), limit)46
}48
fn 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
values57
}59
pub 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
})84
}86
fn 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()88
}90
pub 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::Null123
};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::Null130
};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
})152
}154
/// `text.split(/^@@/m)`.155
fn 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
parts169
}171
struct StoredPlan {172
session_id: String,173
identity: String,174
operations: Value,175
plan_text: String,176
title: Value,177
created_at: i64,178
}180
impl 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
}187
}189
#[derive(Default)]190
pub struct RunAuth {191
pub privileged: bool,192
pub sudo_password: Option<String>,193
pub remember: bool,194
}196
fn 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 }198
}200
pub 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>>,206
}208
impl 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.executor228
.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
.await245
}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"] })).await338
}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 the346
/// reviewed steps; a plan is single-use, so a retry after a lost connection can never start the347
/// 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
result373
}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> = section406
.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
}444
}446
#[cfg(test)]447
mod 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(§ions);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
}475
}SHA-256
f08f47c77f256db16c286db37bbe51c2000800ed51bd352e6428343b79f5eb27CH-J Proprietary Software License 1.14 · Allikas on esitatud CH-J Proprietary Software License 1.14 all. Selle kättesaadavus ei muuda litsentsitingimusi ega anna täiendavaid õigusi.
Arhiiv SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f