251 lines
9.6 KiB
Rust
251 lines
9.6 KiB
Rust
use std::io::{self, Write};
|
|
use std::process::Command;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use chrono::Utc;
|
|
use clap::Parser;
|
|
|
|
const SERVER_UNITS: &[&str] = &[
|
|
"activitywatch-server",
|
|
"aw-worktime-api",
|
|
"aw-worktime-ui-bridge.timer",
|
|
"aw-dlp-policy-engine.service",
|
|
"aw-dlp-aggregator.timer",
|
|
"activitywatch-dlp-aggregator.timer",
|
|
];
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(about = "Run AW/DLP diagnostics and optionally perform manual restart recovery")]
|
|
struct Cli {
|
|
#[arg(long)]
|
|
with_windows: bool,
|
|
|
|
#[arg(long)]
|
|
yes: bool,
|
|
|
|
#[arg(long, default_value = "ansible/inventory.ini")]
|
|
inventory: String,
|
|
}
|
|
|
|
fn main() {
|
|
let code = match run() {
|
|
Ok(code) => code,
|
|
Err(err) => {
|
|
eprintln!("{err:#}");
|
|
1
|
|
}
|
|
};
|
|
std::process::exit(code);
|
|
}
|
|
|
|
fn run() -> Result<i32> {
|
|
let cli = Cli::parse();
|
|
require_command("ansible")?;
|
|
require_command("ansible-playbook")?;
|
|
if !std::path::Path::new(&cli.inventory).is_file() {
|
|
bail!("inventory not found: {}", cli.inventory);
|
|
}
|
|
|
|
log("Running diagnostics on aw_server...");
|
|
if run_health_check(&cli.inventory) {
|
|
log("Diagnostics: healthy. Restart not needed.");
|
|
return Ok(0);
|
|
}
|
|
|
|
log("Diagnostics: FAILED.");
|
|
if !confirm_restart(cli.yes)? {
|
|
log("Restart declined.");
|
|
return Ok(1);
|
|
}
|
|
|
|
restart_server_components(&cli.inventory);
|
|
if cli.with_windows {
|
|
restart_windows_collectors(&cli.inventory);
|
|
seed_windows_dlp_events(&cli.inventory);
|
|
}
|
|
seed_server_dlp_events(&cli.inventory)?;
|
|
|
|
log("Waiting 15 seconds before re-check...");
|
|
std::thread::sleep(std::time::Duration::from_secs(15));
|
|
|
|
log("Running post-restart diagnostics...");
|
|
if run_health_check(&cli.inventory) {
|
|
log("Post-restart diagnostics: healthy.");
|
|
return Ok(0);
|
|
}
|
|
log("Post-restart diagnostics: still failing.");
|
|
Ok(1)
|
|
}
|
|
|
|
fn log(message: &str) {
|
|
eprintln!("{} {}", Utc::now().format("%Y-%m-%d %H:%M:%S"), message);
|
|
}
|
|
|
|
fn require_command(name: &str) -> Result<()> {
|
|
let status = Command::new("sh")
|
|
.arg("-c")
|
|
.arg(format!("command -v {name} >/dev/null 2>&1"))
|
|
.status()
|
|
.with_context(|| format!("check command {name}"))?;
|
|
if status.success() {
|
|
Ok(())
|
|
} else {
|
|
bail!("{name} not found")
|
|
}
|
|
}
|
|
|
|
fn run_health_check(inventory: &str) -> bool {
|
|
ansible_command(
|
|
inventory,
|
|
"aw_server",
|
|
"-b",
|
|
"ansible.builtin.command",
|
|
"/usr/local/bin/aw-health-check",
|
|
) && ansible_command(
|
|
inventory,
|
|
"aw_server",
|
|
"-b",
|
|
"ansible.builtin.command",
|
|
"/usr/local/bin/dlp-health-check",
|
|
)
|
|
}
|
|
|
|
fn restart_server_components(inventory: &str) {
|
|
log("Restarting server components on aw_server...");
|
|
for unit in SERVER_UNITS {
|
|
if ansible_command(
|
|
inventory,
|
|
"aw_server",
|
|
"-b",
|
|
"ansible.builtin.command",
|
|
&format!("systemctl status {unit}"),
|
|
) {
|
|
let _ = ansible_command(
|
|
inventory,
|
|
"aw_server",
|
|
"-b",
|
|
"ansible.builtin.systemd",
|
|
&format!("name={unit} state=restarted enabled=true"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn seed_server_dlp_events(inventory: &str) -> Result<()> {
|
|
log("Seeding DLP freshness events on aw_server...");
|
|
let ts = Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
|
let script = format!(
|
|
r#"cat >/tmp/aw-endpoint-seed.json <<'JSON'
|
|
{{"timestamp":"{ts}","duration":0.0,"data":{{"hostname":"HOST-EXAMPLE","signalType":"self_test","source":"diag_and_manual_restart","username":"system","queueDepth":0,"eventsEnqueued":0,"eventsFlushed":0,"sendFailures":0}}}}
|
|
JSON
|
|
cat >/tmp/aw-fileops-seed-host.json <<'JSON'
|
|
{{"timestamp":"{ts}","duration":0.0,"data":{{"hostname":"HOST-EXAMPLE","operation":"self_test","source":"diag_and_manual_restart"}}}}
|
|
JSON
|
|
cat >/tmp/aw-fileops-seed-server.json <<'JSON'
|
|
{{"timestamp":"{ts}","duration":0.0,"data":{{"hostname":"192.0.2.13","operation":"self_test","source":"diag_and_manual_restart"}}}}
|
|
JSON
|
|
curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-endpoint-signals_HOST-EXAMPLE' -H 'Content-Type: application/json' -d '{{"client":"aw-dlp-endpoint-signals","type":"aw.dlp.endpoint.signal","hostname":"HOST-EXAMPLE"}}' >/dev/null 2>&1 || true
|
|
curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_HOST-EXAMPLE' -H 'Content-Type: application/json' -d '{{"client":"aw-file-operations","type":"aw.file.operation","hostname":"HOST-EXAMPLE"}}' >/dev/null 2>&1 || true
|
|
curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_192.0.2.13' -H 'Content-Type: application/json' -d '{{"client":"aw-file-operations","type":"aw.file.operation","hostname":"192.0.2.13"}}' >/dev/null 2>&1 || true
|
|
curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-dlp-endpoint-signals_HOST-EXAMPLE/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-endpoint-seed.json >/dev/null
|
|
curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_HOST-EXAMPLE/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-fileops-seed-host.json >/dev/null
|
|
curl -sS -X POST 'http://127.0.0.1:5600/api/0/buckets/aw-file-operations_192.0.2.13/heartbeat?pulsetime=30' -H 'Content-Type: application/json' --data-binary @/tmp/aw-fileops-seed-server.json >/dev/null
|
|
"#
|
|
);
|
|
let _ = ansible_command(
|
|
inventory,
|
|
"aw_server",
|
|
"-b",
|
|
"ansible.builtin.shell",
|
|
&script,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn restart_windows_collectors(inventory: &str) {
|
|
log("Restarting Windows recovery/launch tasks on aw_windows...");
|
|
let script = r#"powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; try { Start-ScheduledTask -TaskName 'ActivityWatch Recovery' -ErrorAction Stop | Out-Null } catch {}; Get-ScheduledTask | Where-Object TaskName -like 'ActivityWatch Launch *' | ForEach-Object { try { Start-ScheduledTask -TaskName $_.TaskName -ErrorAction Stop | Out-Null } catch {} }; Write-Output 'windows-tasks-restarted'""#;
|
|
let _ = ansible_windows_shell(inventory, script);
|
|
}
|
|
|
|
fn seed_windows_dlp_events(inventory: &str) {
|
|
log("Seeding endpoint/file-ops events from aw_windows...");
|
|
let script = r#"powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference = 'Stop'; $ts = (Get-Date).ToUniversalTime().ToString('o'); $api='http://192.0.2.13:5600/api/0'; $endpoint=@{timestamp=$ts;duration=0.0;data=@{hostname='HOST-EXAMPLE';signalType='self_test';source='diag_and_manual_restart';username=$env:USERNAME;queueDepth=0;eventsEnqueued=0;eventsFlushed=0;sendFailures=0}} | ConvertTo-Json -Depth 8 -Compress; $fileops=@{timestamp=$ts;duration=0.0;data=@{hostname='HOST-EXAMPLE';operation='self_test';source='diag_and_manual_restart';username=$env:USERNAME}} | ConvertTo-Json -Depth 8 -Compress; Invoke-RestMethod -Method Post -Uri $api'/buckets/aw-dlp-endpoint-signals_HOST-EXAMPLE' -ContentType 'application/json' -Body '{\"client\":\"aw-dlp-endpoint-signals\",\"type\":\"aw.dlp.endpoint.signal\",\"hostname\":\"HOST-EXAMPLE\"}' -TimeoutSec 15 -DisableKeepAlive -ErrorAction SilentlyContinue | Out-Null; Invoke-RestMethod -Method Post -Uri $api'/buckets/aw-file-operations_HOST-EXAMPLE' -ContentType 'application/json' -Body '{\"client\":\"aw-file-operations\",\"type\":\"aw.file.operation\",\"hostname\":\"HOST-EXAMPLE\"}' -TimeoutSec 15 -DisableKeepAlive -ErrorAction SilentlyContinue | Out-Null; Invoke-RestMethod -Method Post -Uri $api'/buckets/aw-dlp-endpoint-signals_HOST-EXAMPLE/heartbeat?pulsetime=30' -ContentType 'application/json' -Body $endpoint -TimeoutSec 15 -DisableKeepAlive | Out-Null; Invoke-RestMethod -Method Post -Uri $api'/buckets/aw-file-operations_HOST-EXAMPLE/heartbeat?pulsetime=30' -ContentType 'application/json' -Body $fileops -TimeoutSec 15 -DisableKeepAlive | Out-Null; Write-Output 'windows-dlp-seeded'""#;
|
|
let _ = ansible_windows_shell(inventory, script);
|
|
}
|
|
|
|
fn confirm_restart(auto_yes: bool) -> Result<bool> {
|
|
if auto_yes {
|
|
return Ok(true);
|
|
}
|
|
eprint!("Diagnostics failed. Restart required components now? [y/N]: ");
|
|
io::stderr().flush().ok();
|
|
let mut answer = String::new();
|
|
io::stdin()
|
|
.read_line(&mut answer)
|
|
.context("read confirmation")?;
|
|
Ok(matches!(answer.trim(), "y" | "Y"))
|
|
}
|
|
|
|
fn ansible_command(
|
|
inventory: &str,
|
|
group: &str,
|
|
become_flag: &str,
|
|
module: &str,
|
|
args: &str,
|
|
) -> bool {
|
|
run_command(
|
|
"ansible",
|
|
&[
|
|
group,
|
|
"-i",
|
|
inventory,
|
|
become_flag,
|
|
"-m",
|
|
module,
|
|
"-a",
|
|
args,
|
|
],
|
|
)
|
|
}
|
|
|
|
fn ansible_windows_shell(inventory: &str, args: &str) -> bool {
|
|
run_command(
|
|
"ansible",
|
|
&[
|
|
"aw_windows",
|
|
"-i",
|
|
inventory,
|
|
"-m",
|
|
"ansible.windows.win_shell",
|
|
"-a",
|
|
args,
|
|
],
|
|
)
|
|
}
|
|
|
|
fn run_command(cmd: &str, args: &[&str]) -> bool {
|
|
match Command::new(cmd).args(args).output() {
|
|
Ok(output) => {
|
|
print!("{}", String::from_utf8_lossy(&output.stdout));
|
|
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
|
output.status.success()
|
|
}
|
|
Err(err) => {
|
|
eprintln!("{cmd}: {err}");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn server_units_keep_legacy_order() {
|
|
assert_eq!(SERVER_UNITS[0], "activitywatch-server");
|
|
assert!(SERVER_UNITS.contains(&"activitywatch-dlp-aggregator.timer"));
|
|
}
|
|
}
|