This commit is contained in:
@@ -15,12 +15,21 @@ jobs:
|
|||||||
sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev
|
sudo apt-get install -y pkg-config libx11-dev libxkbcommon-dev libwayland-dev libgl1-mesa-dev
|
||||||
- name: Rust installieren
|
- name: Rust installieren
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
- name: Rust testen
|
- name: Rust testen
|
||||||
run: |
|
run: |
|
||||||
|
cargo fmt --check --manifest-path scanner/Cargo.toml
|
||||||
|
cargo fmt --check --manifest-path controller/Cargo.toml
|
||||||
|
cargo fmt --check --manifest-path gui/Cargo.toml
|
||||||
|
cargo clippy --locked --manifest-path scanner/Cargo.toml --all-targets -- -D warnings
|
||||||
|
cargo clippy --locked --manifest-path controller/Cargo.toml --all-targets -- -D warnings
|
||||||
|
cargo clippy --locked --manifest-path gui/Cargo.toml --all-targets -- -D warnings
|
||||||
cargo test --locked --manifest-path scanner/Cargo.toml
|
cargo test --locked --manifest-path scanner/Cargo.toml
|
||||||
cargo test --locked --manifest-path controller/Cargo.toml
|
cargo test --locked --manifest-path controller/Cargo.toml
|
||||||
cargo test --locked --manifest-path gui/Cargo.toml
|
cargo test --locked --manifest-path gui/Cargo.toml
|
||||||
- name: Python und Rauchtests
|
- name: Python und Rauchtests
|
||||||
run: |
|
run: |
|
||||||
python3 -m unittest discover -s tests -p 'test_*.py'
|
python3 -W error -m unittest discover -s tests -p 'test_*.py'
|
||||||
|
bash -n scripts/*.sh live/*.sh tests/*.sh
|
||||||
bash tests/smoke.sh
|
bash tests/smoke.sh
|
||||||
|
|||||||
@@ -1,62 +1,115 @@
|
|||||||
//! Optional technician workstations; no write access to internal devices.
|
//! Optional technician workstations; no write access to internal devices.
|
||||||
use std::{fs, io::{self, Write}, path::Path, process::Command, time::{SystemTime, UNIX_EPOCH}};
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::{self, Write},
|
||||||
|
path::Path,
|
||||||
|
process::Command,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
fn ask(prompt: &str) -> String {
|
fn ask(prompt: &str) -> String {
|
||||||
print!("{}", prompt);
|
print!("{}", prompt);
|
||||||
let _ = io::stdout().flush();
|
let _ = io::stdout().flush();
|
||||||
let mut answer = String::new();
|
let mut answer = String::new();
|
||||||
if io::stdin().read_line(&mut answer).is_err() { return String::new(); }
|
if io::stdin().read_line(&mut answer).is_err() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
answer.trim().to_string()
|
answer.trim().to_string()
|
||||||
}
|
}
|
||||||
fn read_trim(path: &str) -> Option<String> {
|
fn read_trim(path: &str) -> Option<String> {
|
||||||
fs::read_to_string(path).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
|
fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
}
|
}
|
||||||
fn output(exe: &str, args: &[&str]) -> Option<String> {
|
fn output(exe: &str, args: &[&str]) -> Option<String> {
|
||||||
let r = Command::new(exe).args(args).output().ok()?;
|
let r = Command::new(exe).args(args).output().ok()?;
|
||||||
if r.status.success() { Some(String::from_utf8_lossy(&r.stdout).trim().to_owned()) } else { None }
|
if r.status.success() {
|
||||||
|
Some(String::from_utf8_lossy(&r.stdout).trim().to_owned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn escape_json(s: &str) -> String {
|
fn escape_json(s: &str) -> String {
|
||||||
let mut result = String::from("\"");
|
let mut result = String::from("\"");
|
||||||
for c in s.chars() { match c {
|
for c in s.chars() {
|
||||||
'"' => result.push_str("\\\""), '\\' => result.push_str("\\\\"),
|
match c {
|
||||||
'\n' => result.push_str("\\n"), '\r' => result.push_str("\\r"), '\t' => result.push_str("\\t"),
|
'"' => result.push_str("\\\""),
|
||||||
|
'\\' => result.push_str("\\\\"),
|
||||||
|
'\n' => result.push_str("\\n"),
|
||||||
|
'\r' => result.push_str("\\r"),
|
||||||
|
'\t' => result.push_str("\\t"),
|
||||||
c if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)),
|
c if c.is_control() => result.push_str(&format!("\\u{:04x}", c as u32)),
|
||||||
c => result.push(c),
|
c => result.push(c),
|
||||||
}}
|
}
|
||||||
result.push('"'); result
|
}
|
||||||
|
result.push('"');
|
||||||
|
result
|
||||||
}
|
}
|
||||||
fn valid_asset(id: &str) -> bool {
|
fn valid_asset(id: &str) -> bool {
|
||||||
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
!id.is_empty()
|
||||||
|
&& id.len() <= 64
|
||||||
|
&& id
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||||
}
|
}
|
||||||
fn normalize_hostname(model: &str, asset: &str) -> String {
|
fn normalize_hostname(model: &str, asset: &str) -> String {
|
||||||
let source = format!("{}-{}", model, asset).to_ascii_lowercase();
|
let source = format!("{}-{}", model, asset).to_ascii_lowercase();
|
||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
let mut last_dash = false;
|
let mut last_dash = false;
|
||||||
for c in source.chars() {
|
for c in source.chars() {
|
||||||
if c.is_ascii_alphanumeric() { result.push(c); last_dash = false; }
|
if c.is_ascii_alphanumeric() {
|
||||||
else if !last_dash && !result.is_empty() { result.push('-'); last_dash = true; }
|
result.push(c);
|
||||||
|
last_dash = false;
|
||||||
|
} else if !last_dash && !result.is_empty() {
|
||||||
|
result.push('-');
|
||||||
|
last_dash = true;
|
||||||
}
|
}
|
||||||
result.trim_matches('-').chars().take(63).collect::<String>().trim_end_matches('-').to_owned()
|
}
|
||||||
|
result
|
||||||
|
.trim_matches('-')
|
||||||
|
.chars()
|
||||||
|
.take(63)
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end_matches('-')
|
||||||
|
.to_owned()
|
||||||
}
|
}
|
||||||
fn net_interfaces() -> Vec<(String, String, String, String)> {
|
fn net_interfaces() -> Vec<(String, String, String, String)> {
|
||||||
let mut list = Vec::new();
|
let mut list = Vec::new();
|
||||||
if let Ok(entries) = fs::read_dir("/sys/class/net") {
|
if let Ok(entries) = fs::read_dir("/sys/class/net") {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let name = entry.file_name().to_string_lossy().into_owned();
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
if name == "lo" { continue; }
|
if name == "lo" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let base = format!("/sys/class/net/{name}");
|
let base = format!("/sys/class/net/{name}");
|
||||||
let kind = if Path::new(&format!("{base}/wireless")).exists() { "Wi-Fi" } else { "LAN/other" };
|
let kind = if Path::new(&format!("{base}/wireless")).exists() {
|
||||||
|
"Wi-Fi"
|
||||||
|
} else {
|
||||||
|
"LAN/other"
|
||||||
|
};
|
||||||
let carrier = read_trim(&format!("{base}/carrier")).unwrap_or_else(|| "unknown".into());
|
let carrier = read_trim(&format!("{base}/carrier")).unwrap_or_else(|| "unknown".into());
|
||||||
let mac = read_trim(&format!("{base}/address")).unwrap_or_else(|| "unknown".into());
|
let mac = read_trim(&format!("{base}/address")).unwrap_or_else(|| "unknown".into());
|
||||||
list.push((name, kind.to_string(), carrier, mac));
|
list.push((name, kind.to_string(), carrier, mac));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
list.sort(); list
|
list.sort();
|
||||||
|
list
|
||||||
|
}
|
||||||
|
fn ethernet_connected() -> bool {
|
||||||
|
net_interfaces()
|
||||||
|
.iter()
|
||||||
|
.any(|(_, kind, carrier, _)| kind == "LAN/other" && carrier == "1")
|
||||||
}
|
}
|
||||||
fn ethernet_connected() -> bool { net_interfaces().iter().any(|(_,kind,carrier,_)| kind == "LAN/other" && carrier == "1") }
|
|
||||||
fn memory_dmi() -> (String, String) {
|
fn memory_dmi() -> (String, String) {
|
||||||
let text = match output("dmidecode", &["-t", "memory"]) {
|
let text = match output("dmidecode", &["-t", "memory"]) {
|
||||||
Some(s) => s, None => return ("UNKNOWN (dmidecode unavailable or permissions missing)".into(), "UNKNOWN".into()),
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
"UNKNOWN (dmidecode unavailable or permissions missing)".into(),
|
||||||
|
"UNKNOWN".into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let mut slots = 0usize;
|
let mut slots = 0usize;
|
||||||
let mut occupied = 0usize;
|
let mut occupied = 0usize;
|
||||||
@@ -64,12 +117,35 @@ fn memory_dmi() -> (String, String) {
|
|||||||
let mut in_device = false;
|
let mut in_device = false;
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let t = line.trim();
|
let t = line.trim();
|
||||||
if t == "Memory Device" { slots += 1; in_device = true; continue; }
|
if t == "Memory Device" {
|
||||||
if t == "Physical Memory Array" { in_device = false; continue; }
|
slots += 1;
|
||||||
if t.starts_with("Maximum Capacity:") && max_cap == "UNKNOWN" { max_cap = t.trim_start_matches("Maximum Capacity:").trim().into(); }
|
in_device = true;
|
||||||
if in_device && t.starts_with("Size:") && !t.contains("No Module Installed") && !t.ends_with("Unknown") { occupied += 1; in_device = false; }
|
continue;
|
||||||
}
|
}
|
||||||
let summary = if slots == 0 { "UNKNOWN".into() } else { format!("{occupied}/{slots} occupied; {} free", slots.saturating_sub(occupied)) };
|
if t == "Physical Memory Array" {
|
||||||
|
in_device = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if t.starts_with("Maximum Capacity:") && max_cap == "UNKNOWN" {
|
||||||
|
max_cap = t.trim_start_matches("Maximum Capacity:").trim().into();
|
||||||
|
}
|
||||||
|
if in_device
|
||||||
|
&& t.starts_with("Size:")
|
||||||
|
&& !t.contains("No Module Installed")
|
||||||
|
&& !t.ends_with("Unknown")
|
||||||
|
{
|
||||||
|
occupied += 1;
|
||||||
|
in_device = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let summary = if slots == 0 {
|
||||||
|
"UNKNOWN".into()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{occupied}/{slots} occupied; {} free",
|
||||||
|
slots.saturating_sub(occupied)
|
||||||
|
)
|
||||||
|
};
|
||||||
(summary, max_cap)
|
(summary, max_cap)
|
||||||
}
|
}
|
||||||
fn display_info() -> String {
|
fn display_info() -> String {
|
||||||
@@ -77,22 +153,35 @@ fn display_info() -> String {
|
|||||||
if let Ok(entries) = fs::read_dir("/sys/class/drm") {
|
if let Ok(entries) = fs::read_dir("/sys/class/drm") {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.join("status").is_file() { continue; }
|
if !path.join("status").is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let status = fs::read_to_string(path.join("status")).unwrap_or_default();
|
let status = fs::read_to_string(path.join("status")).unwrap_or_default();
|
||||||
if status.trim() != "connected" { continue; }
|
if status.trim() != "connected" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
let modes = fs::read_to_string(path.join("modes")).unwrap_or_default();
|
let modes = fs::read_to_string(path.join("modes")).unwrap_or_default();
|
||||||
let mode = modes.lines().next().unwrap_or("resolution unknown");
|
let mode = modes.lines().next().unwrap_or("resolution unknown");
|
||||||
let size = fs::read(path.join("edid")).ok().and_then(|e| {
|
let size = fs::read(path.join("edid"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|e| {
|
||||||
if e.len() >= 23 && e[21] > 0 && e[22] > 0 {
|
if e.len() >= 23 && e[21] > 0 && e[22] > 0 {
|
||||||
let diag = ((e[21] as f64).powi(2) + (e[22] as f64).powi(2)).sqrt() / 2.54;
|
let diag = ((e[21] as f64).powi(2) + (e[22] as f64).powi(2)).sqrt() / 2.54;
|
||||||
Some(format!("{diag:.1} inch (EDID estimate)"))
|
Some(format!("{diag:.1} inch (EDID estimate)"))
|
||||||
} else { None }
|
} else {
|
||||||
}).unwrap_or_else(|| "size UNKNOWN".into());
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "size UNKNOWN".into());
|
||||||
displays.push(format!("{name}: {mode}, {size}"));
|
displays.push(format!("{name}: {mode}, {size}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if displays.is_empty() { "UNKNOWN (no active DRM connector)".into() } else { displays.join("; ") }
|
if displays.is_empty() {
|
||||||
|
"UNKNOWN (no active DRM connector)".into()
|
||||||
|
} else {
|
||||||
|
displays.join("; ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn save(data: &Path, asset: &str, stem: &str, content: &str) -> Result<(), String> {
|
fn save(data: &Path, asset: &str, stem: &str, content: &str) -> Result<(), String> {
|
||||||
let folder = data.join("reports").join(asset).join("supplemental");
|
let folder = data.join("reports").join(asset).join("supplemental");
|
||||||
@@ -104,60 +193,121 @@ fn save(data: &Path, asset: &str, stem: &str, content: &str) -> Result<(),String
|
|||||||
}
|
}
|
||||||
pub fn device_details(data: &Path) {
|
pub fn device_details(data: &Path) {
|
||||||
let asset = ask("Asset ID / Geraetenummer: ");
|
let asset = ask("Asset ID / Geraetenummer: ");
|
||||||
if !valid_asset(&asset) { println!("Invalid asset ID"); return; }
|
if !valid_asset(&asset) {
|
||||||
let manufacturer = read_trim("/sys/class/dmi/id/sys_vendor").unwrap_or_else(|| "UNKNOWN".into());
|
println!("Invalid asset ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let manufacturer =
|
||||||
|
read_trim("/sys/class/dmi/id/sys_vendor").unwrap_or_else(|| "UNKNOWN".into());
|
||||||
let model = read_trim("/sys/class/dmi/id/product_name").unwrap_or_else(|| "UNKNOWN".into());
|
let model = read_trim("/sys/class/dmi/id/product_name").unwrap_or_else(|| "UNKNOWN".into());
|
||||||
let uuid = read_trim("/sys/class/dmi/id/product_uuid").unwrap_or_else(|| "UNKNOWN".into());
|
let uuid = read_trim("/sys/class/dmi/id/product_uuid").unwrap_or_else(|| "UNKNOWN".into());
|
||||||
let cpu = read_trim("/proc/cpuinfo").and_then(|s|s.lines().find_map(|line|line.strip_prefix("model name").and_then(|v|v.split_once(':').map(|(_,v)|v.trim().to_owned())))).unwrap_or_else(|| "UNKNOWN".into());
|
let cpu = read_trim("/proc/cpuinfo")
|
||||||
|
.and_then(|s| {
|
||||||
|
s.lines().find_map(|line| {
|
||||||
|
line.strip_prefix("model name")
|
||||||
|
.and_then(|v| v.split_once(':').map(|(_, v)| v.trim().to_owned()))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "UNKNOWN".into());
|
||||||
let (slots, max_ram) = memory_dmi();
|
let (slots, max_ram) = memory_dmi();
|
||||||
let display = display_info();
|
let display = display_info();
|
||||||
let host = normalize_hostname(&model, &asset);
|
let host = normalize_hostname(&model, &asset);
|
||||||
let connections = net_interfaces();
|
let connections = net_interfaces();
|
||||||
println!("\nManufacturer: {manufacturer}\nModel: {model}\nCPU: {cpu}\nUUID: {uuid}\nRAM slots: {slots}\nMax RAM (BIOS estimate): {max_ram}\nDisplay: {display}\nProposed hostname: {host}");
|
println!("\nManufacturer: {manufacturer}\nModel: {model}\nCPU: {cpu}\nUUID: {uuid}\nRAM slots: {slots}\nMax RAM (BIOS estimate): {max_ram}\nDisplay: {display}\nProposed hostname: {host}");
|
||||||
for (name,kind,carrier,mac) in &connections { println!("Interface {name}: {kind}, carrier={carrier}, MAC={mac}"); }
|
for (name, kind, carrier, mac) in &connections {
|
||||||
|
println!("Interface {name}: {kind}, carrier={carrier}, MAC={mac}");
|
||||||
|
}
|
||||||
println!("Storage sizes and device type are collected by the main scanner; don't include the boot USB.");
|
println!("Storage sizes and device type are collected by the main scanner; don't include the boot USB.");
|
||||||
let mut json = format!("{{\"asset_id\":{},\"manufacturer\":{},\"model\":{},\"cpu\":{},\"uuid\":{},\"ram_slots\":{},\"ram_max\":{},\"display\":{},\"proposed_hostname\":{},\"network\":[",escape_json(&asset),escape_json(&manufacturer),escape_json(&model),escape_json(&cpu),escape_json(&uuid),escape_json(&slots),escape_json(&max_ram),escape_json(&display),escape_json(&host));
|
let mut json = format!("{{\"asset_id\":{},\"manufacturer\":{},\"model\":{},\"cpu\":{},\"uuid\":{},\"ram_slots\":{},\"ram_max\":{},\"display\":{},\"proposed_hostname\":{},\"network\":[",escape_json(&asset),escape_json(&manufacturer),escape_json(&model),escape_json(&cpu),escape_json(&uuid),escape_json(&slots),escape_json(&max_ram),escape_json(&display),escape_json(&host));
|
||||||
for (i, (name, kind, carrier, mac)) in connections.iter().enumerate() {
|
for (i, (name, kind, carrier, mac)) in connections.iter().enumerate() {
|
||||||
if i>0 { json.push(','); }
|
if i > 0 {
|
||||||
json.push_str(&format!("{{\"interface\":{},\"type\":{},\"carrier\":{},\"mac\":{}}}",escape_json(name),escape_json(kind),escape_json(carrier),escape_json(mac)));
|
json.push(',');
|
||||||
|
}
|
||||||
|
json.push_str(&format!(
|
||||||
|
"{{\"interface\":{},\"type\":{},\"carrier\":{},\"mac\":{}}}",
|
||||||
|
escape_json(name),
|
||||||
|
escape_json(kind),
|
||||||
|
escape_json(carrier),
|
||||||
|
escape_json(mac)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
json.push_str("]}\n");
|
json.push_str("]}\n");
|
||||||
if let Err(e)=save(data,&asset,"hardware-details.json",&json){ eprintln!("Save failed: {e}"); }
|
if let Err(e) = save(data, &asset, "hardware-details.json", &json) {
|
||||||
|
eprintln!("Save failed: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn check_prompt(label: &str) -> &'static str {
|
fn check_prompt(label: &str) -> &'static str {
|
||||||
println!("{label}");
|
println!("{label}");
|
||||||
loop { match ask("Result [p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase().as_str() {
|
loop {
|
||||||
"p" => return "PASS", "f" => return "FAIL", "u" | "" => return "NOT_TESTED", "n" => return "NOT_APPLICABLE", _ => println!("Use p/f/u/n"),
|
match ask("Result [p]ass / [f]ail / [u]ntested / [n]ot applicable: ")
|
||||||
}}
|
.to_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
"p" => return "PASS",
|
||||||
|
"f" => return "FAIL",
|
||||||
|
"u" | "" => return "NOT_TESTED",
|
||||||
|
"n" => return "NOT_APPLICABLE",
|
||||||
|
_ => println!("Use p/f/u/n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn guided_tests(data: &Path) {
|
pub fn guided_tests(data: &Path) {
|
||||||
let asset=ask("Asset ID: "); if !valid_asset(&asset) { println!("Invalid asset ID"); return; }
|
let asset = ask("Asset ID: ");
|
||||||
|
if !valid_asset(&asset) {
|
||||||
|
println!("Invalid asset ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
println!("\nNETWORK PRECHECK: Before Wi-Fi test, unplug Ethernet if one is connected.");
|
println!("\nNETWORK PRECHECK: Before Wi-Fi test, unplug Ethernet if one is connected.");
|
||||||
if ethernet_connected() {
|
if ethernet_connected() {
|
||||||
println!("WARNING: Wired network link detected. Unplug LAN cable before Wi-Fi test.");
|
println!("WARNING: Wired network link detected. Unplug LAN cable before Wi-Fi test.");
|
||||||
let _ = ask("After unplugging LAN press Enter to recheck...");
|
let _ = ask("After unplugging LAN press Enter to recheck...");
|
||||||
if ethernet_connected() { println!("LAN link remains active: Wi-Fi connectivity cannot be confirmed until disconnected."); }
|
if ethernet_connected() {
|
||||||
} else { println!("No active wired link detected. Wireless testing may proceed."); }
|
println!("LAN link remains active: Wi-Fi connectivity cannot be confirmed until disconnected.");
|
||||||
let wifi = if ethernet_connected() { "NOT_TESTED" } else {
|
}
|
||||||
println!("Wi-Fi interfaces: {:?}",net_interfaces().iter().filter(|x|x.1=="Wi-Fi").map(|x|&x.0).collect::<Vec<_>>());
|
} else {
|
||||||
|
println!("No active wired link detected. Wireless testing may proceed.");
|
||||||
|
}
|
||||||
|
let wifi = if ethernet_connected() {
|
||||||
|
"NOT_TESTED"
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"Wi-Fi interfaces: {:?}",
|
||||||
|
net_interfaces()
|
||||||
|
.iter()
|
||||||
|
.filter(|x| x.1 == "Wi-Fi")
|
||||||
|
.map(|x| &x.0)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
println!("Connect using your network manager and verify actual traffic over Wi-Fi. Interface detection alone is not enough.");
|
println!("Connect using your network manager and verify actual traffic over Wi-Fi. Interface detection alone is not enough.");
|
||||||
check_prompt("Wi-Fi connection and data transfer verified?")
|
check_prompt("Wi-Fi connection and data transfer verified?")
|
||||||
};
|
};
|
||||||
println!("\nAUDIO: We'll attempt a stereo test sound if speaker-test exists. Confirm left/right audibly.");
|
println!("\nAUDIO: We'll attempt a stereo test sound if speaker-test exists. Confirm left/right audibly.");
|
||||||
if ask("Run short speaker test? [y/N] ").eq_ignore_ascii_case("y") {
|
if ask("Run short speaker test? [y/N] ").eq_ignore_ascii_case("y") {
|
||||||
let result=Command::new("speaker-test").args(["-c","2","-t","wav","-l","1"]).status();
|
let result = Command::new("speaker-test")
|
||||||
println!("speaker-test outcome: {:?} (not proof that sound was audible)",result);
|
.args(["-c", "2", "-t", "wav", "-l", "1"])
|
||||||
|
.status();
|
||||||
|
println!(
|
||||||
|
"speaker-test outcome: {:?} (not proof that sound was audible)",
|
||||||
|
result
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let audio = check_prompt("Speakers: both channels audible, without distortion?");
|
let audio = check_prompt("Speakers: both channels audible, without distortion?");
|
||||||
println!("\nMICROPHONE: make a short recording then play it back, confirming sound clarity.");
|
println!("\nMICROPHONE: make a short recording then play it back, confirming sound clarity.");
|
||||||
let mic = check_prompt("Microphone recorded and replayed clearly?");
|
let mic = check_prompt("Microphone recorded and replayed clearly?");
|
||||||
println!("\nCAMERA: look for /dev/video*, and preview live picture if a desktop is running.");
|
println!("\nCAMERA: look for /dev/video*, and preview live picture if a desktop is running.");
|
||||||
if let Ok(devices) = fs::read_dir("/dev") {
|
if let Ok(devices) = fs::read_dir("/dev") {
|
||||||
for dev in devices.flatten() { let n=dev.file_name().to_string_lossy().to_string(); if n.starts_with("video") {println!("Found /dev/{n}");} }
|
for dev in devices.flatten() {
|
||||||
|
let n = dev.file_name().to_string_lossy().to_string();
|
||||||
|
if n.starts_with("video") {
|
||||||
|
println!("Found /dev/{n}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let webcam = check_prompt("Webcam picture inspected and working?");
|
let webcam = check_prompt("Webcam picture inspected and working?");
|
||||||
let mut json=format!("{{\"asset_id\":{},\"timestamp\":{},\"wifi\":{},\"audio\":{},\"microphone\":{},\"webcam\":{}}}\n",escape_json(&asset),SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),escape_json(wifi),escape_json(audio),escape_json(mic),escape_json(webcam));
|
let mut json=format!("{{\"asset_id\":{},\"timestamp\":{},\"wifi\":{},\"audio\":{},\"microphone\":{},\"webcam\":{}}}\n",escape_json(&asset),SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(),escape_json(wifi),escape_json(audio),escape_json(mic),escape_json(webcam));
|
||||||
if let Err(e)=save(data,&asset,"guided-tests.json",&json){eprintln!("Save failed: {e}");}
|
if let Err(e) = save(data, &asset, "guided-tests.json", &json) {
|
||||||
|
eprintln!("Save failed: {e}");
|
||||||
|
}
|
||||||
json.clear();
|
json.clear();
|
||||||
}
|
}
|
||||||
pub fn maintenance_guidance() {
|
pub fn maintenance_guidance() {
|
||||||
@@ -170,25 +320,72 @@ pub fn maintenance_guidance() {
|
|||||||
println!("Do not chroot or rename another machine's OS without selecting and validating its target disk. Proposed hostname is stored in hardware-details.json for controlled provisioning.");
|
println!("Do not chroot or rename another machine's OS without selecting and validating its target disk. Proposed hostname is stored in hardware-details.json for controlled provisioning.");
|
||||||
let _ = ask("Press Enter to return to menu...");
|
let _ = ask("Press Enter to return to menu...");
|
||||||
}
|
}
|
||||||
#[cfg(test)] mod tests {
|
|
||||||
use super::*;
|
|
||||||
#[test] fn valid_asset_ids(){assert!(valid_asset("PC-0042")); assert!(!valid_asset("../evil"));}
|
|
||||||
#[test] fn hostnames(){assert_eq!(normalize_hostname("EliteBook 745 G6","PC-0001"),"elitebook-745-g6-pc-0001");}
|
|
||||||
#[test] fn escape(){assert_eq!(escape_json("a\"b"),"\"a\\\"b\"");}
|
|
||||||
}
|
|
||||||
/// Read-only CPU stress plus allocated userspace memory checks. Not a full RAM test.
|
/// Read-only CPU stress plus allocated userspace memory checks. Not a full RAM test.
|
||||||
pub fn stress_test(data: &Path) {
|
pub fn stress_test(data: &Path) {
|
||||||
let asset=ask("Asset ID: "); if !valid_asset(&asset) { println!("Invalid asset ID"); return; }
|
let asset = ask("Asset ID: ");
|
||||||
|
if !valid_asset(&asset) {
|
||||||
|
println!("Invalid asset ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
println!("\nThis uses stress-ng (120 seconds) and memtester on up to 50% of MemAvailable, capped at 8 GiB.");
|
println!("\nThis uses stress-ng (120 seconds) and memtester on up to 50% of MemAvailable, capped at 8 GiB.");
|
||||||
println!("Do not use on a machine with unstable cooling. This cannot test all physical RAM or prove absence of faults.");
|
println!("Do not use on a machine with unstable cooling. This cannot test all physical RAM or prove absence of faults.");
|
||||||
if !ask("Run stress tests now? Type YES: ").eq("YES") {println!("Cancelled");return;}
|
if !ask("Run stress tests now? Type YES: ").eq("YES") {
|
||||||
let cpu=Command::new("stress-ng").args(["--cpu","0","--timeout","120s","--metrics-brief"]).status();
|
println!("Cancelled");
|
||||||
println!("CPU stress result: {cpu:?}");
|
return;
|
||||||
let mem_kib=read_trim("/proc/meminfo").and_then(|t|t.lines().find(|l|l.starts_with("MemAvailable:")).and_then(|line| line.split_whitespace().nth(1)).and_then(|n|n.parse::<u64>().ok())).unwrap_or(0);
|
}
|
||||||
let mem_mib=std::cmp::min(mem_kib/2048,8192);
|
let cpu = Command::new("stress-ng")
|
||||||
let ram=if mem_mib>=128 { Command::new("memtester").args([&format!("{mem_mib}M"),"1"]).status() } else { println!("Not enough free RAM for safe test");return; };
|
.args(["--cpu", "0", "--timeout", "120s", "--metrics-brief"])
|
||||||
println!("RAM stress result: {ram:?}");
|
.status();
|
||||||
let status=|r: &io::Result<std::process::ExitStatus>|match r {Ok(s) if s.success()=>"PASS",Ok(_)=>"FAIL",Err(_)=>"UNAVAILABLE"};
|
println!("CPU stress result: {cpu:?}");
|
||||||
let report=format!("{{\"asset_id\":{},\"cpu_stress\":{},\"ram_stress\":{},\"ram_test_mib\":{},\"coverage\":\"PARTIAL_USERSPACE\"}}\n",escape_json(&asset),escape_json(status(&cpu)),escape_json(status(&ram)),mem_mib);
|
let mem_kib = read_trim("/proc/meminfo")
|
||||||
if let Err(e)=save(data,&asset,"stress-test.json",&report){eprintln!("Save failed: {e}");}
|
.and_then(|t| {
|
||||||
|
t.lines()
|
||||||
|
.find(|l| l.starts_with("MemAvailable:"))
|
||||||
|
.and_then(|line| line.split_whitespace().nth(1))
|
||||||
|
.and_then(|n| n.parse::<u64>().ok())
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mem_mib = std::cmp::min(mem_kib / 2048, 8192);
|
||||||
|
let ram = if mem_mib >= 128 {
|
||||||
|
Command::new("memtester")
|
||||||
|
.args([&format!("{mem_mib}M"), "1"])
|
||||||
|
.status()
|
||||||
|
} else {
|
||||||
|
println!("Not enough free RAM for safe test");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
println!("RAM stress result: {ram:?}");
|
||||||
|
let status = |r: &io::Result<std::process::ExitStatus>| match r {
|
||||||
|
Ok(s) if s.success() => "PASS",
|
||||||
|
Ok(_) => "FAIL",
|
||||||
|
Err(_) => "UNAVAILABLE",
|
||||||
|
};
|
||||||
|
let report=format!("{{\"asset_id\":{},\"cpu_stress\":{},\"ram_stress\":{},\"ram_test_mib\":{},\"coverage\":\"PARTIAL_USERSPACE\"}}\n",escape_json(&asset),escape_json(status(&cpu)),escape_json(status(&ram)),mem_mib);
|
||||||
|
if let Err(e) = save(data, &asset, "stress-test.json", &report) {
|
||||||
|
eprintln!("Save failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_asset_ids() {
|
||||||
|
assert!(valid_asset("PC-0042"));
|
||||||
|
assert!(!valid_asset("../evil"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostnames() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_hostname("EliteBook 745 G6", "PC-0001"),
|
||||||
|
"elitebook-745-g6-pc-0001"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape() {
|
||||||
|
assert_eq!(escape_json("a\"b"), "\"a\\\"b\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,220 @@
|
|||||||
mod extended;
|
mod extended;
|
||||||
use std::{env, fs, io::{self, Write}, path::{Path, PathBuf}, process::Command, time::{SystemTime, UNIX_EPOCH}};
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
io::{self, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::Command,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
const DATA: &str = "/mnt/intake-data";
|
const DATA: &str = "/mnt/intake-data";
|
||||||
fn prompt(q: &str) -> String { print!("{}",q); let _=io::stdout().flush(); let mut s=String::new(); let _=io::stdin().read_line(&mut s); s.trim().to_string() }
|
fn prompt(q: &str) -> String {
|
||||||
fn run(program:&str,args:&[&str])->Result<(),String>{let st=Command::new(program).args(args).status().map_err(|e|format!("{}: {}",program,e))?;if st.success(){Ok(())}else{Err(format!("{} exited with {}",program,st))}}
|
print!("{}", q);
|
||||||
fn capture(program:&str,args:&[&str])->Result<String,String>{let o=Command::new(program).args(args).output().map_err(|e|e.to_string())?;if !o.status.success(){return Err(String::from_utf8_lossy(&o.stderr).to_string())}Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())}
|
let _ = io::stdout().flush();
|
||||||
fn sqlquote(s:&str)->String{format!("'{}'",s.replace('\'',"''"))}
|
let mut s = String::new();
|
||||||
fn db_path(data:&Path)->PathBuf{data.join("database/intake.sqlite3")}
|
let _ = io::stdin().read_line(&mut s);
|
||||||
fn sql(data:&Path,statement:&str)->Result<String,String>{capture("sqlite3",&[db_path(data).to_str().ok_or("Invalid DB path")?,statement])}
|
s.trim().to_string()
|
||||||
fn init(data:&Path)->Result<(),String>{for part in ["database","reports","checklists","config"]{fs::create_dir_all(data.join(part)).map_err(|e|e.to_string())?;}let probe=data.join(".write-test");fs::write(&probe,b"ok").map_err(|e|format!("Persistent partition not writable: {}",e))?;let _=fs::remove_file(probe);sql(data,"PRAGMA journal_mode=DELETE; CREATE TABLE IF NOT EXISTS scans (id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id TEXT NOT NULL, timestamp INTEGER NOT NULL, report_path TEXT NOT NULL UNIQUE, inspection_path TEXT, inspection_status TEXT NOT NULL DEFAULT 'NOT_TESTED', sync_status TEXT NOT NULL DEFAULT 'PENDING', synced_inventory_id TEXT);")?;Ok(())}
|
}
|
||||||
fn valid_id(s:&str)->bool{!s.is_empty()&&s.len()<=64&&s.bytes().all(|b|b.is_ascii_alphanumeric()||b==b'-'||b==b'_')}
|
fn run(program: &str, args: &[&str]) -> Result<(), String> {
|
||||||
fn latest(data:&Path,id:&str)->Option<PathBuf>{let root=data.join("reports").join(id);let mut matches=Vec::new();for d in fs::read_dir(root).ok()?.flatten(){let p=d.path().join("report.json");if p.is_file(){let mt=p.metadata().ok()?.modified().ok()?;matches.push((mt,p));}}matches.sort_by_key(|(m,_)|*m);matches.pop().map(|(_,p)|p)}
|
let st = Command::new(program)
|
||||||
fn get_time()->u64{SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()}
|
.args(args)
|
||||||
fn scan(data:&Path,quick:bool){let id=prompt("Asset ID: ");if !valid_id(&id){println!("Invalid ID (letters, digits, underscore and hyphen only)");return;}let out=data.join("reports");let outstr=out.to_string_lossy().to_string();let mut cmd=Command::new("/usr/local/bin/pc-intake");cmd.args(["--asset-id",&id,"--output",&outstr]);if quick{cmd.arg("--quick");}else{cmd.arg("--compile");}let checklist=data.join("checklists").join(format!("{}.txt",id));if checklist.exists(){cmd.args(["--checklist",&checklist.to_string_lossy()]);}println!("Running {} scan...",if quick{"quick"}else{"full"});match cmd.status(){Ok(status) if status.success()=>{if let Some(report)=latest(data,&id){let result=sql(data,&format!("INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{});",sqlquote(&id),get_time(),sqlquote(&report.to_string_lossy())));match result{Ok(_)=>{println!("Saved: {}",report.display());println!("Inventory sync: PENDING (not uploaded)");},Err(e)=>println!("Database write failed: {}. JSON report remains saved.",e)}}else{println!("Scanner exited successfully, but no report found");}},Ok(status)=>println!("Scanner failed: {}",status),Err(e)=>println!("Could not launch scanner: {}",e)}}
|
.status()
|
||||||
const CHECKS:&[(&str,&str)]=&[("display","Show colors/brightness and inspect for dead pixels"),("keyboard","Check every key, including modifiers and function keys"),("touchpad","Verify movement, taps, buttons and scrolling; N/A for desktop"),("wifi","Connect to a network and open a local or remote test service"),("audio","Play left/right sound and check for distortion"),("microphone","Record voice and play it back"),("webcam","Preview image and verify focus and quality; N/A if absent"),("usb_ports","Insert a known-good test device in each physical port"),("charging","Verify AC adapter, charging status, battery runtime; N/A for desktop"),("thermals","Verify fans, heat and sustained load"),("stability","Check for crashes, unexpected resets or kernel errors")];
|
.map_err(|e| format!("{}: {}", program, e))?;
|
||||||
fn technician(data:&Path){let id=prompt("Asset ID for inspection: ");if !valid_id(&id){println!("Invalid ID");return;}let mut rows=Vec::new();for (key,desc) in CHECKS{println!("\n{}: {}",key,desc);if *key=="audio"{println!("Optional speaker test (if installed): speaker-test -c 2 -t wav");}if *key=="webcam"{println!("Optional camera detection: ls /dev/video*; preview requires graphical session");}loop{let ans=prompt("[p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase();if let Some(st)=match ans.as_str(){"p"=>Some("PASS"),"f"=>Some("FAIL"),"u"|""=>Some("NOT_TESTED"),"n"=>Some("NOT_APPLICABLE"),_=>None}{rows.push((key.to_string(),st.to_string()));break;}println!("Enter p, f, u or n");}}
|
if st.success() {
|
||||||
let target=data.join("checklists").join(format!("{}.txt",id));let mut raw=String::new();for (k,v) in &rows{raw.push_str(&format!("{}={}\n",k,v));}if let Err(e)=fs::write(&target,raw){println!("Could not save checklist: {}",e);return;}let fail=rows.iter().any(|(_,s)|s=="FAIL");let unknown=rows.iter().any(|(_,s)|s=="NOT_TESTED");let verdict=if fail{"FAIL"}else if unknown{"INCOMPLETE"}else{"COMPLETED"};let _=sql(data,&format!("UPDATE scans SET inspection_path={},inspection_status={} WHERE id=(SELECT MAX(id) FROM scans WHERE asset_id={});",sqlquote(&target.to_string_lossy()),sqlquote(verdict),sqlquote(&id)));println!("Inspection {}. Saved {}; re-scan to embed checklist in hardware report.",verdict,target.display());}
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{} exited with {}", program, st))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn capture(program: &str, args: &[&str]) -> Result<String, String> {
|
||||||
|
let o = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !o.status.success() {
|
||||||
|
return Err(String::from_utf8_lossy(&o.stderr).to_string());
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
}
|
||||||
|
fn sqlquote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', "''"))
|
||||||
|
}
|
||||||
|
fn db_path(data: &Path) -> PathBuf {
|
||||||
|
data.join("database/intake.sqlite3")
|
||||||
|
}
|
||||||
|
fn sql(data: &Path, statement: &str) -> Result<String, String> {
|
||||||
|
capture(
|
||||||
|
"sqlite3",
|
||||||
|
&[db_path(data).to_str().ok_or("Invalid DB path")?, statement],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn init(data: &Path) -> Result<(), String> {
|
||||||
|
for part in ["database", "reports", "checklists", "config"] {
|
||||||
|
fs::create_dir_all(data.join(part)).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let probe = data.join(".write-test");
|
||||||
|
fs::write(&probe, b"ok").map_err(|e| format!("Persistent partition not writable: {}", e))?;
|
||||||
|
let _ = fs::remove_file(probe);
|
||||||
|
sql(data,"PRAGMA journal_mode=DELETE; CREATE TABLE IF NOT EXISTS scans (id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id TEXT NOT NULL, timestamp INTEGER NOT NULL, report_path TEXT NOT NULL UNIQUE, inspection_path TEXT, inspection_status TEXT NOT NULL DEFAULT 'NOT_TESTED', sync_status TEXT NOT NULL DEFAULT 'PENDING', synced_inventory_id TEXT);")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn valid_id(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.len() <= 64
|
||||||
|
&& s.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||||
|
}
|
||||||
|
fn latest(data: &Path, id: &str) -> Option<PathBuf> {
|
||||||
|
let root = data.join("reports").join(id);
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
for d in fs::read_dir(root).ok()?.flatten() {
|
||||||
|
let p = d.path().join("report.json");
|
||||||
|
if p.is_file() {
|
||||||
|
let mt = p.metadata().ok()?.modified().ok()?;
|
||||||
|
matches.push((mt, p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches.sort_by_key(|(m, _)| *m);
|
||||||
|
matches.pop().map(|(_, p)| p)
|
||||||
|
}
|
||||||
|
fn get_time() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
}
|
||||||
|
fn scan(data: &Path, quick: bool) {
|
||||||
|
let id = prompt("Asset ID: ");
|
||||||
|
if !valid_id(&id) {
|
||||||
|
println!("Invalid ID (letters, digits, underscore and hyphen only)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let out = data.join("reports");
|
||||||
|
let outstr = out.to_string_lossy().to_string();
|
||||||
|
let mut cmd = Command::new("/usr/local/bin/pc-intake");
|
||||||
|
cmd.args(["--asset-id", &id, "--output", &outstr]);
|
||||||
|
if quick {
|
||||||
|
cmd.arg("--quick");
|
||||||
|
} else {
|
||||||
|
cmd.arg("--compile");
|
||||||
|
}
|
||||||
|
let checklist = data.join("checklists").join(format!("{}.txt", id));
|
||||||
|
if checklist.exists() {
|
||||||
|
cmd.args(["--checklist", &checklist.to_string_lossy()]);
|
||||||
|
}
|
||||||
|
println!("Running {} scan...", if quick { "quick" } else { "full" });
|
||||||
|
match cmd.status() {
|
||||||
|
Ok(status) if status.success() => {
|
||||||
|
if let Some(report) = latest(data, &id) {
|
||||||
|
let result=sql(data,&format!("INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{});",sqlquote(&id),get_time(),sqlquote(&report.to_string_lossy())));
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("Saved: {}", report.display());
|
||||||
|
println!("Inventory sync: PENDING (not uploaded)");
|
||||||
|
}
|
||||||
|
Err(e) => println!("Database write failed: {}. JSON report remains saved.", e),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Scanner exited successfully, but no report found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(status) => println!("Scanner failed: {}", status),
|
||||||
|
Err(e) => println!("Could not launch scanner: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const CHECKS: &[(&str, &str)] = &[
|
||||||
|
(
|
||||||
|
"display",
|
||||||
|
"Show colors/brightness and inspect for dead pixels",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"keyboard",
|
||||||
|
"Check every key, including modifiers and function keys",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"touchpad",
|
||||||
|
"Verify movement, taps, buttons and scrolling; N/A for desktop",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wifi",
|
||||||
|
"Connect to a network and open a local or remote test service",
|
||||||
|
),
|
||||||
|
("audio", "Play left/right sound and check for distortion"),
|
||||||
|
("microphone", "Record voice and play it back"),
|
||||||
|
(
|
||||||
|
"webcam",
|
||||||
|
"Preview image and verify focus and quality; N/A if absent",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"usb_ports",
|
||||||
|
"Insert a known-good test device in each physical port",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"charging",
|
||||||
|
"Verify AC adapter, charging status, battery runtime; N/A for desktop",
|
||||||
|
),
|
||||||
|
("thermals", "Verify fans, heat and sustained load"),
|
||||||
|
(
|
||||||
|
"stability",
|
||||||
|
"Check for crashes, unexpected resets or kernel errors",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
fn technician(data: &Path) {
|
||||||
|
let id = prompt("Asset ID for inspection: ");
|
||||||
|
if !valid_id(&id) {
|
||||||
|
println!("Invalid ID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (key, desc) in CHECKS {
|
||||||
|
println!("\n{}: {}", key, desc);
|
||||||
|
if *key == "audio" {
|
||||||
|
println!("Optional speaker test (if installed): speaker-test -c 2 -t wav");
|
||||||
|
}
|
||||||
|
if *key == "webcam" {
|
||||||
|
println!(
|
||||||
|
"Optional camera detection: ls /dev/video*; preview requires graphical session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let ans = prompt("[p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase();
|
||||||
|
if let Some(st) = match ans.as_str() {
|
||||||
|
"p" => Some("PASS"),
|
||||||
|
"f" => Some("FAIL"),
|
||||||
|
"u" | "" => Some("NOT_TESTED"),
|
||||||
|
"n" => Some("NOT_APPLICABLE"),
|
||||||
|
_ => None,
|
||||||
|
} {
|
||||||
|
rows.push((key.to_string(), st.to_string()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
println!("Enter p, f, u or n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let target = data.join("checklists").join(format!("{}.txt", id));
|
||||||
|
let mut raw = String::new();
|
||||||
|
for (k, v) in &rows {
|
||||||
|
raw.push_str(&format!("{}={}\n", k, v));
|
||||||
|
}
|
||||||
|
if let Err(e) = fs::write(&target, raw) {
|
||||||
|
println!("Could not save checklist: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let fail = rows.iter().any(|(_, s)| s == "FAIL");
|
||||||
|
let unknown = rows.iter().any(|(_, s)| s == "NOT_TESTED");
|
||||||
|
let verdict = if fail {
|
||||||
|
"FAIL"
|
||||||
|
} else if unknown {
|
||||||
|
"INCOMPLETE"
|
||||||
|
} else {
|
||||||
|
"COMPLETED"
|
||||||
|
};
|
||||||
|
let _=sql(data,&format!("UPDATE scans SET inspection_path={},inspection_status={} WHERE id=(SELECT MAX(id) FROM scans WHERE asset_id={});",sqlquote(&target.to_string_lossy()),sqlquote(verdict),sqlquote(&id)));
|
||||||
|
println!(
|
||||||
|
"Inspection {}. Saved {}; re-scan to embed checklist in hardware report.",
|
||||||
|
verdict,
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
fn recent(data: &Path) {
|
fn recent(data: &Path) {
|
||||||
let query = "SELECT id || ' | ' || asset_id || ' | ' || inspection_status || ' | ' || sync_status || ' | ' || report_path FROM scans ORDER BY id DESC LIMIT 20;";
|
let query = "SELECT id || ' | ' || asset_id || ' | ' || inspection_status || ' | ' || sync_status || ' | ' || report_path FROM scans ORDER BY id DESC LIMIT 20;";
|
||||||
match sql(data, query) {
|
match sql(data, query) {
|
||||||
@@ -23,6 +223,90 @@ fn recent(data: &Path) {
|
|||||||
Err(err) => println!("Database error: {}", err),
|
Err(err) => println!("Database error: {}", err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn help(){println!("PC Intake AIO v1.7.0 — offline-first technician controller");println!("Usage: intake-controller [--data PATH] [--self-check]");}
|
fn help() {
|
||||||
fn main(){let args:Vec<String>=env::args().collect();if args.iter().any(|x|x=="--help"){help();return;}if args.iter().any(|x|x=="--self-check"){for tool in ["sqlite3","smartctl","ffmpeg","mpv","glmark2","stress-ng","memtester","speaker-test"]{println!("{}: {}",tool,if Command::new("sh").args(["-c",&format!("command -v {} >/dev/null",tool)]).status().map(|s|s.success()).unwrap_or(false){"FOUND"}else{"MISSING"});}return;}let data=if let Some(p)=args.iter().position(|x|x=="--data"){PathBuf::from(args.get(p+1).unwrap_or(&DATA.to_owned()))}else{PathBuf::from(DATA)};if let Err(e)=init(&data){eprintln!("FATAL: {} — refusing to scan without persistent storage",e);std::process::exit(1)}loop{println!("\n==== PC INTAKE AIO v1.7.0 ====\nStorage: {}\n1 Full scan and local queue\n2 Quick inventory\n3 Guided technician checklist\n4 Recent scans and sync states\n5 Check diagnostic tools\n6 Safely power off\n7 Hardware details (UUID / MAC / RAM slots / screen)\n8 Guided audio / webcam / Wi-Fi test\n9 Installed-system maintenance instructions\n10 CPU and RAM stress test (opt-in)\n",data.display());match prompt("Select: ").as_str(){"1"=>scan(&data,false),"2"=>scan(&data,true),"3"=>technician(&data),"4"=>recent(&data),"5"=>{let _=Command::new(env::current_exe().unwrap_or_else(|_|PathBuf::from("intake-controller"))).arg("--self-check").status();},"6"=>{let _=run("sync",&[]);let _=run("systemctl",&["poweroff"]);break;},"7"=>extended::device_details(&data),"8"=>extended::guided_tests(&data),"9"=>extended::maintenance_guidance(),"10"=>extended::stress_test(&data),_=>println!("Unknown selection")}}}
|
println!("PC Intake AIO v1.7.0 — offline-first technician controller");
|
||||||
#[cfg(test)]mod tests{use super::*;#[test]fn id_validation(){assert!(valid_id("PC-0123"));assert!(!valid_id("../../evil"));}#[test]fn sql_escape(){assert_eq!(sqlquote("O'Reilly"),"'O''Reilly'");}}
|
println!("Usage: intake-controller [--data PATH] [--self-check]");
|
||||||
|
}
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
if args.iter().any(|x| x == "--help") {
|
||||||
|
help();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if args.iter().any(|x| x == "--self-check") {
|
||||||
|
for tool in [
|
||||||
|
"sqlite3",
|
||||||
|
"smartctl",
|
||||||
|
"ffmpeg",
|
||||||
|
"mpv",
|
||||||
|
"glmark2",
|
||||||
|
"stress-ng",
|
||||||
|
"memtester",
|
||||||
|
"speaker-test",
|
||||||
|
] {
|
||||||
|
println!(
|
||||||
|
"{}: {}",
|
||||||
|
tool,
|
||||||
|
if Command::new("sh")
|
||||||
|
.args(["-c", &format!("command -v {} >/dev/null", tool)])
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
"FOUND"
|
||||||
|
} else {
|
||||||
|
"MISSING"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = if let Some(p) = args.iter().position(|x| x == "--data") {
|
||||||
|
PathBuf::from(args.get(p + 1).unwrap_or(&DATA.to_owned()))
|
||||||
|
} else {
|
||||||
|
PathBuf::from(DATA)
|
||||||
|
};
|
||||||
|
if let Err(e) = init(&data) {
|
||||||
|
eprintln!("FATAL: {} — refusing to scan without persistent storage", e);
|
||||||
|
std::process::exit(1)
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
println!("\n==== PC INTAKE AIO v1.7.0 ====\nStorage: {}\n1 Full scan and local queue\n2 Quick inventory\n3 Guided technician checklist\n4 Recent scans and sync states\n5 Check diagnostic tools\n6 Safely power off\n7 Hardware details (UUID / MAC / RAM slots / screen)\n8 Guided audio / webcam / Wi-Fi test\n9 Installed-system maintenance instructions\n10 CPU and RAM stress test (opt-in)\n",data.display());
|
||||||
|
match prompt("Select: ").as_str() {
|
||||||
|
"1" => scan(&data, false),
|
||||||
|
"2" => scan(&data, true),
|
||||||
|
"3" => technician(&data),
|
||||||
|
"4" => recent(&data),
|
||||||
|
"5" => {
|
||||||
|
let _ = Command::new(
|
||||||
|
env::current_exe().unwrap_or_else(|_| PathBuf::from("intake-controller")),
|
||||||
|
)
|
||||||
|
.arg("--self-check")
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
"6" => {
|
||||||
|
let _ = run("sync", &[]);
|
||||||
|
let _ = run("systemctl", &["poweroff"]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
"7" => extended::device_details(&data),
|
||||||
|
"8" => extended::guided_tests(&data),
|
||||||
|
"9" => extended::maintenance_guidance(),
|
||||||
|
"10" => extended::stress_test(&data),
|
||||||
|
_ => println!("Unknown selection"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn id_validation() {
|
||||||
|
assert!(valid_id("PC-0123"));
|
||||||
|
assert!(!valid_id("../../evil"));
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn sql_escape() {
|
||||||
|
assert_eq!(sqlquote("O'Reilly"), "'O''Reilly'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -448,13 +448,12 @@ pub fn run(tool: &str, settings: &crate::workflow::Settings) -> Diagnostic {
|
|||||||
|| (tool == "thermals" && text.contains("Messpunkte: 0"))
|
|| (tool == "thermals" && text.contains("Messpunkte: 0"))
|
||||||
{
|
{
|
||||||
Outcome::Unavailable
|
Outcome::Unavailable
|
||||||
} else if peak_temperature(&text)
|
} else if ok
|
||||||
|
|| peak_temperature(&text)
|
||||||
.map(|v| v >= settings.max_temperature_c)
|
.map(|v| v >= settings.max_temperature_c)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
Outcome::NeedsReview
|
Outcome::NeedsReview
|
||||||
} else if ok {
|
|
||||||
Outcome::NeedsReview
|
|
||||||
} else {
|
} else {
|
||||||
Outcome::SetupProblem
|
Outcome::SetupProblem
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -740,8 +740,8 @@ impl App {
|
|||||||
.min_size(egui::vec2(ui.available_width(), 48.0)),
|
.min_size(egui::vec2(ui.available_width(), 48.0)),
|
||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
|
&& self.persist()
|
||||||
{
|
{
|
||||||
if self.persist() {
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
self.receiver = Some(rx);
|
self.receiver = Some(rx);
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
@@ -759,7 +759,6 @@ impl App {
|
|||||||
let _ = tx.send(Work::Power(result));
|
let _ = tx.send(Work::Power(result));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
if !self.station_ready || self.inspection_upload_pending || self.intake_upload_pending || self.flush_pending {
|
if !self.station_ready || self.inspection_upload_pending || self.intake_upload_pending || self.flush_pending {
|
||||||
ui.colored_label(ORANGE,tr(en,"Noch nicht vollständig synchronisiert. Ergebnisse bleiben auf diesem USB gespeichert.","Not fully synced yet. Results remain saved on this USB."));
|
ui.colored_label(ORANGE,tr(en,"Noch nicht vollständig synchronisiert. Ergebnisse bleiben auf diesem USB gespeichert.","Not fully synced yet. Results remain saved on this USB."));
|
||||||
|
|||||||
@@ -713,9 +713,12 @@ mod workflow_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_network_password_and_result_states() {
|
fn renders_network_password_and_result_states() {
|
||||||
let mut wifi = Wifi::default();
|
let mut wifi = Wifi {
|
||||||
wifi.networks =
|
networks: parse_networks(
|
||||||
parse_networks(":Workshop:AA\\:BB:80:WPA2:wlan0\n*:Guest:CC\\:DD:30:--:wlan0");
|
":Workshop:AA\\:BB:80:WPA2:wlan0\n*:Guest:CC\\:DD:30:--:wlan0",
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let ctx = egui::Context::default();
|
let ctx = egui::Context::default();
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
wifi.selected = wifi.networks.get(i % 2).cloned();
|
wifi.selected = wifi.networks.get(i % 2).cloned();
|
||||||
@@ -743,9 +746,11 @@ mod workflow_tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn completion_and_worker_failure_release_busy_state() {
|
fn completion_and_worker_failure_release_busy_state() {
|
||||||
let mut wifi = Wifi::default();
|
let mut wifi = Wifi {
|
||||||
wifi.password = "keep-for-retry".into();
|
password: "keep-for-retry".into(),
|
||||||
wifi.selected = Some(parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0));
|
selected: Some(parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
wifi.receiver = Some(rx);
|
wifi.receiver = Some(rx);
|
||||||
tx.send(Event::Connected(Err("Passwort prüfen".into())))
|
tx.send(Event::Connected(Err("Passwort prüfen".into())))
|
||||||
|
|||||||
@@ -880,8 +880,10 @@ mod regression_tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_settings_are_rejected() {
|
fn invalid_settings_are_rejected() {
|
||||||
let mut s = Settings::default();
|
let mut s = Settings {
|
||||||
s.stress_seconds = 0;
|
stress_seconds: 0,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
assert!(s.validate().is_err());
|
assert!(s.validate().is_err());
|
||||||
s.stress_seconds = 120;
|
s.stress_seconds = 120;
|
||||||
s.max_temperature_c = f64::NAN;
|
s.max_temperature_c = f64::NAN;
|
||||||
|
|||||||
1868
scanner/src/main.rs
1868
scanner/src/main.rs
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|||||||
export CSR_BUILD_ID
|
export CSR_BUILD_ID
|
||||||
CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id)
|
CSR_BUILD_ID=$(python3 "$ROOT/scripts/release-manifest.py" --source-id)
|
||||||
for component in scanner controller gui; do
|
for component in scanner controller gui; do
|
||||||
|
cargo fmt --check --manifest-path "$ROOT/$component/Cargo.toml"
|
||||||
|
cargo clippy --locked --manifest-path "$ROOT/$component/Cargo.toml" --all-targets -- -D warnings
|
||||||
cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml"
|
cargo test --locked --manifest-path "$ROOT/$component/Cargo.toml"
|
||||||
cargo build --locked --release --manifest-path "$ROOT/$component/Cargo.toml"
|
cargo build --locked --release --manifest-path "$ROOT/$component/Cargo.toml"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# DESTRUCTIVE: explicitly invoked, strongly confirmed, never automatic.
|
# DESTRUCTIVE: explicitly invoked, strongly confirmed, never automatic.
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
||||||
DEVICE=${1:-}
|
DEVICE=${1:-}
|
||||||
ISO=${2:-}
|
ISO=${2:-}
|
||||||
if [[ ! "$DEVICE" =~ ^/dev/[a-zA-Z0-9/_-]+$ || ! -f "$ISO" ]]; then
|
if [[ ! "$DEVICE" =~ ^/dev/[a-zA-Z0-9/_-]+$ || ! -f "$ISO" ]]; then
|
||||||
echo "Usage: sudo bash scripts/install-usb.sh /dev/sdb /absolute/path/to/pc-intake.iso" >&2; exit 2
|
echo "Usage: sudo bash scripts/install-usb.sh /dev/sdb /absolute/path/to/pc-intake.iso" >&2; exit 2
|
||||||
fi
|
fi
|
||||||
[[ $EUID -eq 0 ]] || { echo 'Run as root.' >&2; exit 2; }
|
[[ $EUID -eq 0 ]] || { echo 'Run as root.' >&2; exit 2; }
|
||||||
for cmd in lsblk blkid sgdisk mkfs.vfat mkfs.ext4 grub-install mount umount findmnt wipefs dd sync awk bsdtar blockdev partprobe udevadm; do
|
for cmd in lsblk sgdisk mkfs.vfat mkfs.ext4 grub-install mount umount findmnt wipefs sync bsdtar blockdev partprobe udevadm; do
|
||||||
command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 2; }
|
command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 2; }
|
||||||
done
|
done
|
||||||
DEVICE=$(readlink -f "$DEVICE")
|
DEVICE=$(readlink -f "$DEVICE")
|
||||||
@@ -24,6 +23,29 @@ size_bytes=$(blockdev --getsize64 "$DEVICE")
|
|||||||
((size_bytes >= 12*1024*1024*1024)) || { echo 'USB must be at least 12 GiB.' >&2; exit 2; }
|
((size_bytes >= 12*1024*1024*1024)) || { echo 'USB must be at least 12 GiB.' >&2; exit 2; }
|
||||||
iso_bytes=$(stat -c %s "$ISO")
|
iso_bytes=$(stat -c %s "$ISO")
|
||||||
((iso_bytes < 4294967295)) || { echo 'ISO exceeds the FAT32 single-file limit.' >&2; exit 2; }
|
((iso_bytes < 4294967295)) || { echo 'ISO exceeds the FAT32 single-file limit.' >&2; exit 2; }
|
||||||
|
ISO_LIST=$(mktemp)
|
||||||
|
TMP=
|
||||||
|
cleanup(){
|
||||||
|
if [[ -n $TMP ]]; then
|
||||||
|
umount "$TMP/boot" 2>/dev/null || true
|
||||||
|
umount "$TMP/data" 2>/dev/null || true
|
||||||
|
rmdir "$TMP/boot" "$TMP/data" "$TMP" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "$ISO_LIST"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
bsdtar -tf "$ISO" > "$ISO_LIST" 2>/dev/null || {
|
||||||
|
echo 'ISO cannot be read as an archive.' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
grep -qx 'arch/boot/x86_64/vmlinuz-linux' "$ISO_LIST" || {
|
||||||
|
echo 'ISO layout does not match GRUB config (kernel missing).' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
grep -qx 'arch/boot/x86_64/initramfs-linux.img' "$ISO_LIST" || {
|
||||||
|
echo 'ISO layout does not match GRUB config (initramfs missing).' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
model=$(lsblk -dn -o MODEL "$DEVICE" | xargs)
|
model=$(lsblk -dn -o MODEL "$DEVICE" | xargs)
|
||||||
serial=$(lsblk -dn -o SERIAL "$DEVICE" | xargs)
|
serial=$(lsblk -dn -o SERIAL "$DEVICE" | xargs)
|
||||||
echo '===== DESTRUCTIVE USB INSTALL ====='
|
echo '===== DESTRUCTIVE USB INSTALL ====='
|
||||||
@@ -55,8 +77,6 @@ done
|
|||||||
mkfs.vfat -F32 -n INTAKE_BOOT "$p2"
|
mkfs.vfat -F32 -n INTAKE_BOOT "$p2"
|
||||||
mkfs.ext4 -F -L INTAKE_DATA "$p3"
|
mkfs.ext4 -F -L INTAKE_DATA "$p3"
|
||||||
TMP=$(mktemp -d)
|
TMP=$(mktemp -d)
|
||||||
cleanup(){ umount "$TMP/boot" 2>/dev/null || true; umount "$TMP/data" 2>/dev/null || true; rm -rf "$TMP"; }
|
|
||||||
trap cleanup EXIT
|
|
||||||
mkdir -p "$TMP/boot" "$TMP/data"
|
mkdir -p "$TMP/boot" "$TMP/data"
|
||||||
mount "$p2" "$TMP/boot"; mount "$p3" "$TMP/data"
|
mount "$p2" "$TMP/boot"; mount "$p3" "$TMP/data"
|
||||||
mkdir -p "$TMP/boot/boot/grub" "$TMP/data/reports" "$TMP/data/database" "$TMP/data/checklists" "$TMP/data/reference"
|
mkdir -p "$TMP/boot/boot/grub" "$TMP/data/reports" "$TMP/data/database" "$TMP/data/checklists" "$TMP/data/reference"
|
||||||
@@ -69,7 +89,6 @@ if ! cp "$ISO" "$TMP/boot/pc-intake.iso" || ! sync -f "$TMP/boot"; then
|
|||||||
fi
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Copy video reference files separately under INTAKE_DATA/reference (optional).
|
|
||||||
cat > "$TMP/boot/boot/grub/grub.cfg" <<'GRUBCFG'
|
cat > "$TMP/boot/boot/grub/grub.cfg" <<'GRUBCFG'
|
||||||
set timeout=5
|
set timeout=5
|
||||||
set default=0
|
set default=0
|
||||||
@@ -83,13 +102,6 @@ menuentry 'PC Intake Live USB (offline technician mode)' {
|
|||||||
menuentry 'Reboot' { reboot }
|
menuentry 'Reboot' { reboot }
|
||||||
menuentry 'Power off' { halt }
|
menuentry 'Power off' { halt }
|
||||||
GRUBCFG
|
GRUBCFG
|
||||||
# Ensure expected kernel layout exists *in the built ISO* before proceeding.
|
|
||||||
if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/vmlinuz-linux'; then
|
|
||||||
echo 'ISO layout does not match GRUB config (kernel missing). Aborting bootloader setup.' >&2; exit 1
|
|
||||||
fi
|
|
||||||
if ! bsdtar -tf "$ISO" 2>/dev/null | grep -qx 'arch/boot/x86_64/initramfs-linux.img'; then
|
|
||||||
echo 'ISO layout does not match GRUB config (initramfs missing).' >&2; exit 1
|
|
||||||
fi
|
|
||||||
grub-install --target=x86_64-efi --efi-directory="$TMP/boot" --boot-directory="$TMP/boot/boot" --removable --no-nvram --recheck
|
grub-install --target=x86_64-efi --efi-directory="$TMP/boot" --boot-directory="$TMP/boot/boot" --removable --no-nvram --recheck
|
||||||
grub-install --target=i386-pc --boot-directory="$TMP/boot/boot" --recheck "$DEVICE"
|
grub-install --target=i386-pc --boot-directory="$TMP/boot/boot" --recheck "$DEVICE"
|
||||||
sync
|
sync
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Stdlib only; server assigns numbers and immutable reports stay unchanged.
|
Stdlib only; server assigns numbers and immutable reports stay unchanged.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import contextlib
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import hashlib
|
import hashlib
|
||||||
import time
|
import time
|
||||||
@@ -116,7 +117,8 @@ def credentials(config):
|
|||||||
base = config.get('url', '')
|
base = config.get('url', '')
|
||||||
if not base: raise ValueError('Inventory URL is not configured; reports remain offline')
|
if not base: raise ValueError('Inventory URL is not configured; reports remain offline')
|
||||||
parsed = urllib.parse.urlparse(base)
|
parsed = urllib.parse.urlparse(base)
|
||||||
if parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
if (parsed.scheme != 'https' or not parsed.hostname or parsed.username or parsed.password
|
||||||
|
or parsed.path not in ('', '/') or parsed.params or parsed.query or parsed.fragment):
|
||||||
raise ValueError('Inventory URL must be an HTTPS origin (e.g. https://inventory.example.org)')
|
raise ValueError('Inventory URL must be an HTTPS origin (e.g. https://inventory.example.org)')
|
||||||
key = os.environ.get('CSR_API_KEY', '')
|
key = os.environ.get('CSR_API_KEY', '')
|
||||||
if not key:
|
if not key:
|
||||||
@@ -124,7 +126,7 @@ def credentials(config):
|
|||||||
key = secret.read_text(encoding='utf-8').strip()
|
key = secret.read_text(encoding='utf-8').strip()
|
||||||
if not key:
|
if not key:
|
||||||
raise ValueError('No API key configured')
|
raise ValueError('No API key configured')
|
||||||
return base, key
|
return base.rstrip('/'), key
|
||||||
|
|
||||||
def connection_error(ex):
|
def connection_error(ex):
|
||||||
# Never include server responses, request headers, or raw URLs in errors.
|
# Never include server responses, request headers, or raw URLs in errors.
|
||||||
@@ -252,6 +254,8 @@ def process_queue(root, db, config, retry=False, selected=None, retry_seconds=60
|
|||||||
failures += 1
|
failures += 1
|
||||||
# Error strings deliberately exclude URLs, API keys and raw server responses.
|
# Error strings deliberately exclude URLs, API keys and raw server responses.
|
||||||
error = connection_error(ex)
|
error = connection_error(ex)
|
||||||
|
if isinstance(ex, urllib.error.HTTPError):
|
||||||
|
ex.close()
|
||||||
delay = min(3600, retry_seconds * (2 ** min(attempts, 6)))
|
delay = min(3600, retry_seconds * (2 ** min(attempts, 6)))
|
||||||
with db:
|
with db:
|
||||||
db.execute("UPDATE inventory_queue SET status='ERROR', error=?, next_attempt=? WHERE report_sha256=?", (error, stamp + delay, sha))
|
db.execute("UPDATE inventory_queue SET status='ERROR', error=?, next_attempt=? WHERE report_sha256=?", (error, stamp + delay, sha))
|
||||||
@@ -347,7 +351,7 @@ def run_station(root,action,report_path):
|
|||||||
'hwid':hardware_id(report),'expected_revision':device['revision'],'specifications':map_fields(report)})
|
'hwid':hardware_id(report),'expected_revision':device['revision'],'specifications':map_fields(report)})
|
||||||
if action == 'intake' and report_path:
|
if action == 'intake' and report_path:
|
||||||
raw=report_path.read_bytes(); sha=hashlib.sha256(raw).hexdigest()
|
raw=report_path.read_bytes(); sha=hashlib.sha256(raw).hexdigest()
|
||||||
with sqlite3.connect(root/'database/intake.sqlite3') as db:
|
with contextlib.closing(sqlite3.connect(root/'database/intake.sqlite3')) as db, db:
|
||||||
init_db(db);discover_queue(root,db)
|
init_db(db);discover_queue(root,db)
|
||||||
db.execute("INSERT OR REPLACE INTO inventory_sync(report_sha256,asset_id,inventory_number,status,last_attempt_utc,response) VALUES(?,?,?,'SYNCED',?,'OK')",(sha,report.get('asset_id',''),device['number'],dt.datetime.now(dt.timezone.utc).isoformat()))
|
db.execute("INSERT OR REPLACE INTO inventory_sync(report_sha256,asset_id,inventory_number,status,last_attempt_utc,response) VALUES(?,?,?,'SYNCED',?,'OK')",(sha,report.get('asset_id',''),device['number'],dt.datetime.now(dt.timezone.utc).isoformat()))
|
||||||
db.execute("UPDATE inventory_queue SET status='SYNCED',error='',next_attempt=0 WHERE report_sha256=?",(sha,))
|
db.execute("UPDATE inventory_queue SET status='SYNCED',error='',next_attempt=0 WHERE report_sha256=?",(sha,))
|
||||||
@@ -388,10 +392,12 @@ def main():
|
|||||||
return 2
|
return 2
|
||||||
if args.station:
|
if args.station:
|
||||||
try: return run_station(root,args.station,args.report)
|
try: return run_station(root,args.station,args.report)
|
||||||
except (OSError,ValueError,KeyError) as ex:
|
except (OSError,ValueError,KeyError,urllib.error.URLError) as ex:
|
||||||
print(connection_error(ex),file=sys.stderr)
|
print(connection_error(ex),file=sys.stderr)
|
||||||
|
if isinstance(ex, urllib.error.HTTPError):
|
||||||
|
ex.close()
|
||||||
return 2
|
return 2
|
||||||
with sqlite3.connect(root / 'database' / 'intake.sqlite3', timeout=20) as db:
|
with contextlib.closing(sqlite3.connect(root / 'database' / 'intake.sqlite3', timeout=20)) as db, db:
|
||||||
init_db(db)
|
init_db(db)
|
||||||
discover_queue(root, db)
|
discover_queue(root, db)
|
||||||
if args.list:
|
if args.list:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -31,7 +32,7 @@ class Tests(unittest.TestCase):
|
|||||||
for data in [{}, {'system_uuid':'00000000-0000-0000-0000-000000000000'}, {'serial_number':'To Be Filled By O.E.M.','manufacturer':'HP'}]:
|
for data in [{}, {'system_uuid':'00000000-0000-0000-0000-000000000000'}, {'serial_number':'To Be Filled By O.E.M.','manufacturer':'HP'}]:
|
||||||
with self.assertRaises(ValueError): bridge.hardware_id(data)
|
with self.assertRaises(ValueError): bridge.hardware_id(data)
|
||||||
def test_missing_hwid_never_uses_manual_number(self):
|
def test_missing_hwid_never_uses_manual_number(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp, sqlite3.connect(':memory:') as db:
|
with tempfile.TemporaryDirectory() as tmp, contextlib.closing(sqlite3.connect(':memory:')) as db:
|
||||||
bridge.init_db(db)
|
bridge.init_db(db)
|
||||||
path=Path(tmp)/'report.json'
|
path=Path(tmp)/'report.json'
|
||||||
path.write_text(json.dumps({'asset_id':'45','model':'Laptop'}))
|
path.write_text(json.dumps({'asset_id':'45','model':'Laptop'}))
|
||||||
@@ -48,7 +49,7 @@ class Tests(unittest.TestCase):
|
|||||||
if method=='POST':
|
if method=='POST':
|
||||||
return {'number':501,'revision':3,'model':'HP EliteBook 745 G6','cpu':'old','ram_gb':8,'ssd_gb':128}
|
return {'number':501,'revision':3,'model':'HP EliteBook 745 G6','cpu':'old','ram_gb':8,'ssd_gb':128}
|
||||||
return {'revision':4}
|
return {'revision':4}
|
||||||
with sqlite3.connect(':memory:') as db:
|
with contextlib.closing(sqlite3.connect(':memory:')) as db:
|
||||||
bridge.init_db(db)
|
bridge.init_db(db)
|
||||||
with patch.object(bridge,'api_request',side_effect=fake), patch.dict('os.environ',{'CSR_API_KEY':'fake'}):
|
with patch.object(bridge,'api_request',side_effect=fake), patch.dict('os.environ',{'CSR_API_KEY':'fake'}):
|
||||||
res=bridge.sync_one(path,{'url':'https://example.org'},db)
|
res=bridge.sync_one(path,{'url':'https://example.org'},db)
|
||||||
@@ -63,7 +64,7 @@ class Tests(unittest.TestCase):
|
|||||||
def test_mismatch_not_patched(self):
|
def test_mismatch_not_patched(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
path=Path(tmp)/'report.json';path.write_text(json.dumps(self.report))
|
path=Path(tmp)/'report.json';path.write_text(json.dumps(self.report))
|
||||||
with sqlite3.connect(':memory:') as db:
|
with contextlib.closing(sqlite3.connect(':memory:')) as db:
|
||||||
bridge.init_db(db)
|
bridge.init_db(db)
|
||||||
with patch.object(bridge,'api_request',return_value={'number':501,'revision':1,'model':'Different machine'}),patch.dict('os.environ',{'CSR_API_KEY':'fake'}):
|
with patch.object(bridge,'api_request',return_value={'number':501,'revision':1,'model':'Different machine'}),patch.dict('os.environ',{'CSR_API_KEY':'fake'}):
|
||||||
with self.assertRaisesRegex(ValueError,'MODEL_MISMATCH'):
|
with self.assertRaisesRegex(ValueError,'MODEL_MISMATCH'):
|
||||||
@@ -172,3 +173,19 @@ class ConnectionTests(unittest.TestCase):
|
|||||||
self.assertIn('API key', bridge.connection_error(error))
|
self.assertIn('API key', bridge.connection_error(error))
|
||||||
self.assertNotIn('secret', bridge.connection_error(error))
|
self.assertNotIn('secret', bridge.connection_error(error))
|
||||||
error.close()
|
error.close()
|
||||||
|
|
||||||
|
def test_inventory_url_must_be_an_https_origin(self):
|
||||||
|
with patch.dict('os.environ', {'CSR_API_KEY': 'test-key'}):
|
||||||
|
self.assertEqual(
|
||||||
|
bridge.credentials({'url': 'https://inventory.test/'}),
|
||||||
|
('https://inventory.test', 'test-key'),
|
||||||
|
)
|
||||||
|
for url in (
|
||||||
|
'http://inventory.test',
|
||||||
|
'https://user:secret@inventory.test',
|
||||||
|
'https://inventory.test/api',
|
||||||
|
'https://inventory.test?token=secret',
|
||||||
|
'https://inventory.test#fragment',
|
||||||
|
):
|
||||||
|
with self.subTest(url=url), self.assertRaisesRegex(ValueError, 'HTTPS origin'):
|
||||||
|
bridge.credentials({'url': url})
|
||||||
|
|||||||
Reference in New Issue
Block a user