fix(ops): harden worktime recovery and collector guard
This commit is contained in:
@@ -197,6 +197,7 @@ collectors.
|
||||
- [Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md)
|
||||
- [Pilot v1.0 acceptance checklist](docs/PILOT_V1_ACCEPTANCE_CHECKLIST_RU.md)
|
||||
- [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md)
|
||||
- [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md)
|
||||
- [Позиционирование продукта](docs/PRODUCT_POSITIONING_RU.md)
|
||||
- [Экосистема сборщиков](docs/COLLECTOR_ECOSYSTEM_RU.md)
|
||||
- [Стратегия внедрения](docs/DEPLOYMENT_STRATEGY_RU.md)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
collections::{BTreeMap, BTreeSet, HashSet},
|
||||
env,
|
||||
ffi::OsStr,
|
||||
fs::{self, File},
|
||||
@@ -4033,6 +4033,9 @@ fn run_collector_guard_cycle(args: &CollectorGuard, runtime: &mut GuardRuntime)
|
||||
.unwrap_or_else(|| "powershell_primary".to_string());
|
||||
let worktime_legacy_fallback_enabled =
|
||||
json_bool(&config, &["collectors", "worktimeLegacyFallbackEnabled"]).unwrap_or(true);
|
||||
let file_ops_enabled = json_bool(&config, &["collectors", "fileOpsEnabled"]).unwrap_or(true);
|
||||
let file_ops_mode = json_string(&config, &["collectors", "fileOpsMode"])
|
||||
.unwrap_or_else(|| "powershell_primary".to_string());
|
||||
|
||||
let worktime_bucket = get_bucket_health(
|
||||
&api_base,
|
||||
@@ -4078,9 +4081,24 @@ fn run_collector_guard_cycle(args: &CollectorGuard, runtime: &mut GuardRuntime)
|
||||
}
|
||||
|
||||
let task_defs = guard_task_definitions(&config);
|
||||
if interactive_stale {
|
||||
let missing_fileops_sessions =
|
||||
if file_ops_enabled && file_ops_mode.eq_ignore_ascii_case("rust_primary") {
|
||||
missing_rust_collector_sessions(
|
||||
&process_snapshot.processes,
|
||||
"file-operations-collector",
|
||||
&["browser-domains-collector", "dlp-endpoint-collector"],
|
||||
)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let launch_needed = interactive_stale || !missing_fileops_sessions.is_empty();
|
||||
if launch_needed {
|
||||
let active_legacy_collectors = active_legacy_collector_count(&process_snapshot.processes);
|
||||
if active_legacy_collectors > 0 && process_snapshot.command_line_query_ok {
|
||||
if interactive_stale
|
||||
&& missing_fileops_sessions.is_empty()
|
||||
&& active_legacy_collectors > 0
|
||||
&& process_snapshot.command_line_query_ok
|
||||
{
|
||||
problems.push(
|
||||
"interactive bucket stale but legacy collectors are already running; skip launch tasks to avoid duplicates"
|
||||
.to_string(),
|
||||
@@ -4092,6 +4110,14 @@ fn run_collector_guard_cycle(args: &CollectorGuard, runtime: &mut GuardRuntime)
|
||||
"activeLegacyCollectors": active_legacy_collectors
|
||||
}));
|
||||
} else {
|
||||
if !missing_fileops_sessions.is_empty() {
|
||||
actions.push(json!({
|
||||
"action": "detect-missing-fileops",
|
||||
"applied": false,
|
||||
"mode": "diagnostic",
|
||||
"missingSessions": missing_fileops_sessions.clone()
|
||||
}));
|
||||
}
|
||||
for task in &task_defs {
|
||||
if !task.task_name.starts_with("ActivityWatch Launch ") {
|
||||
problems.push(format!("refuse non-allowlisted task {}", task.task_name));
|
||||
@@ -4160,6 +4186,11 @@ fn run_collector_guard_cycle(args: &CollectorGuard, runtime: &mut GuardRuntime)
|
||||
"userId": task.user_id
|
||||
})).collect::<Vec<_>>(),
|
||||
"interactiveStale": interactive_stale,
|
||||
"fileOperationsPresence": {
|
||||
"enabled": file_ops_enabled,
|
||||
"mode": file_ops_mode,
|
||||
"missingSessions": missing_fileops_sessions
|
||||
},
|
||||
"actions": actions,
|
||||
"problems": problems,
|
||||
"quarantine": runtime.quarantine.clone(),
|
||||
@@ -4273,6 +4304,44 @@ fn active_legacy_collector_count(processes: &[ProcessInfo]) -> usize {
|
||||
.count()
|
||||
}
|
||||
|
||||
fn missing_rust_collector_sessions(
|
||||
processes: &[ProcessInfo],
|
||||
required_subcommand: &str,
|
||||
peer_subcommands: &[&str],
|
||||
) -> Vec<u32> {
|
||||
let required_sessions = rust_collector_sessions(processes, required_subcommand);
|
||||
let mut expected_sessions = BTreeSet::new();
|
||||
for subcommand in peer_subcommands {
|
||||
expected_sessions.extend(rust_collector_sessions(processes, subcommand));
|
||||
}
|
||||
expected_sessions
|
||||
.into_iter()
|
||||
.filter(|session_id| !required_sessions.contains(session_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rust_collector_sessions(processes: &[ProcessInfo], subcommand: &str) -> BTreeSet<u32> {
|
||||
let subcommand = subcommand.to_ascii_lowercase();
|
||||
processes
|
||||
.iter()
|
||||
.filter_map(|process| {
|
||||
let name = process.name.as_deref().unwrap_or_default();
|
||||
if !name.eq_ignore_ascii_case("aw-windows-telemetry.exe") {
|
||||
return None;
|
||||
}
|
||||
let command_line = process
|
||||
.command_line
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if !command_line.contains(&subcommand) {
|
||||
return None;
|
||||
}
|
||||
process.session_id
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn duplicate_legacy_collectors(processes: &[ProcessInfo]) -> Vec<LegacyCollectorDuplicate> {
|
||||
let mut groups: BTreeMap<(&'static str, u32), Vec<&ProcessInfo>> = BTreeMap::new();
|
||||
for process in processes {
|
||||
@@ -6227,6 +6296,61 @@ SERVICE_NAME: AWatchRusCollectorGuard
|
||||
assert_eq!(active_legacy_collector_count(&processes), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collector_guard_detects_missing_rust_fileops_by_session() {
|
||||
let processes = vec![
|
||||
ProcessInfo {
|
||||
name: Some("aw-windows-telemetry.exe".to_string()),
|
||||
pid: Some(100),
|
||||
session_id: Some(2),
|
||||
created_unix_seconds: Some(10),
|
||||
command_line: Some(
|
||||
r#""C:\Program Files\AWatch-rus\windows\aw-windows-telemetry.exe" browser-domains-collector --config-path C:\ProgramData\AWatch-rus\deployment-config.json"#
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
ProcessInfo {
|
||||
name: Some("aw-windows-telemetry.exe".to_string()),
|
||||
pid: Some(101),
|
||||
session_id: Some(2),
|
||||
created_unix_seconds: Some(11),
|
||||
command_line: Some(
|
||||
r#""C:\Program Files\AWatch-rus\windows\aw-windows-telemetry.exe" dlp-endpoint-collector --config-path C:\ProgramData\AWatch-rus\deployment-config.json"#
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
ProcessInfo {
|
||||
name: Some("aw-windows-telemetry.exe".to_string()),
|
||||
pid: Some(200),
|
||||
session_id: Some(3),
|
||||
created_unix_seconds: Some(20),
|
||||
command_line: Some(
|
||||
r#""C:\Program Files\AWatch-rus\windows\aw-windows-telemetry.exe" browser-domains-collector --config-path C:\ProgramData\AWatch-rus\deployment-config.json"#
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
ProcessInfo {
|
||||
name: Some("aw-windows-telemetry.exe".to_string()),
|
||||
pid: Some(201),
|
||||
session_id: Some(3),
|
||||
created_unix_seconds: Some(21),
|
||||
command_line: Some(
|
||||
r#""C:\Program Files\AWatch-rus\windows\aw-windows-telemetry.exe" file-operations-collector --config-path C:\ProgramData\AWatch-rus\deployment-config.json"#
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
missing_rust_collector_sessions(
|
||||
&processes,
|
||||
"file-operations-collector",
|
||||
&["browser-domains-collector", "dlp-endpoint-collector"],
|
||||
),
|
||||
vec![2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_deployment_parses_wmic_process_csv() {
|
||||
let csv = br#"Node,CommandLine,Name,ProcessId,SessionId
|
||||
|
||||
@@ -62,9 +62,13 @@ const AW_SERVER_FILES: &[&str] = &[
|
||||
"aw-server/aw-server.env.example",
|
||||
"aw-server/aw-sw-cleanup.js",
|
||||
"aw-server/aw-worktime-api.service",
|
||||
"aw-server/aw-worktime-autoheal.service",
|
||||
"aw-server/aw-worktime-autoheal.timer",
|
||||
"aw-server/aw-worktime-prewarm.service",
|
||||
"aw-server/aw-worktime-prewarm.timer",
|
||||
"aw-server/aw-worktime-panel.js",
|
||||
"aw-server/aw-worktime-ui-bridge.service",
|
||||
"aw-server/aw-worktime-ui-bridge.timer",
|
||||
"aw-server/install_aw_server.sh",
|
||||
"aw-server/settings/classes-worktime.json",
|
||||
"aw-server/settings/views-default.json",
|
||||
|
||||
@@ -21,6 +21,15 @@ const REQUIRED_RELATIVE_FILES: &[&str] = &[
|
||||
"windows/validate-deployment.ps1",
|
||||
"ansible/deploy_aw_windows.yml",
|
||||
"aw-server/install_aw_server.sh",
|
||||
"aw-server/aw-worktime-api.service",
|
||||
"aw-server/aw-worktime-autoheal.service",
|
||||
"aw-server/aw-worktime-autoheal.timer",
|
||||
"aw-server/aw-worktime-prewarm.service",
|
||||
"aw-server/aw-worktime-prewarm.timer",
|
||||
"aw-server/aw-worktime-ui-bridge.service",
|
||||
"aw-server/aw-worktime-ui-bridge.timer",
|
||||
"aw-server/aw-rus-healthd.service",
|
||||
"aw-server/aw-rus-healthd.timer",
|
||||
"scripts/rebuild_install_kit.sh",
|
||||
"scripts/validate_install_kit.sh",
|
||||
"scripts/check_install_kit_vs_repo.sh",
|
||||
@@ -451,7 +460,10 @@ mod tests {
|
||||
use tempfile::tempdir;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use super::{Config, DEFAULT_KIT_DIR, check_archive_composition, read_manifest, validate};
|
||||
use super::{
|
||||
Config, DEFAULT_KIT_DIR, REQUIRED_RELATIVE_FILES, check_archive_composition, read_manifest,
|
||||
validate,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn manifest_parser_rejects_bad_line() {
|
||||
@@ -474,8 +486,14 @@ mod tests {
|
||||
);
|
||||
let report = validate(&cfg);
|
||||
assert!(report.ok, "{report:#?}");
|
||||
assert_eq!(report.manifest_completeness.tracked_files, 10);
|
||||
assert_eq!(report.archive_composition.files, 11);
|
||||
assert_eq!(
|
||||
report.manifest_completeness.tracked_files,
|
||||
REQUIRED_RELATIVE_FILES.len() - 1
|
||||
);
|
||||
assert_eq!(
|
||||
report.archive_composition.files,
|
||||
REQUIRED_RELATIVE_FILES.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -520,36 +538,13 @@ mod tests {
|
||||
|
||||
fn create_fixture(root: &std::path::Path, matching_archives: bool) {
|
||||
let kit = root.join(DEFAULT_KIT_DIR);
|
||||
for rel in [
|
||||
"windows/deploy-ensemble.ps1",
|
||||
"windows/aw-windows-telemetry.exe",
|
||||
"windows/validate-deployment.ps1",
|
||||
"ansible/deploy_aw_windows.yml",
|
||||
"aw-server/install_aw_server.sh",
|
||||
"scripts/rebuild_install_kit.sh",
|
||||
"scripts/validate_install_kit.sh",
|
||||
"scripts/check_install_kit_vs_repo.sh",
|
||||
"scripts/quality-gate.sh",
|
||||
] {
|
||||
for rel in fixture_files_without_manifest() {
|
||||
let path = kit.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, rel).unwrap();
|
||||
}
|
||||
fs::write(kit.join("README-INSTALL-KIT.txt"), "readme").unwrap();
|
||||
let files = [
|
||||
"README-INSTALL-KIT.txt",
|
||||
"windows/deploy-ensemble.ps1",
|
||||
"windows/aw-windows-telemetry.exe",
|
||||
"windows/validate-deployment.ps1",
|
||||
"ansible/deploy_aw_windows.yml",
|
||||
"aw-server/install_aw_server.sh",
|
||||
"scripts/rebuild_install_kit.sh",
|
||||
"scripts/validate_install_kit.sh",
|
||||
"scripts/check_install_kit_vs_repo.sh",
|
||||
"scripts/quality-gate.sh",
|
||||
];
|
||||
let mut manifest = String::new();
|
||||
for rel in files {
|
||||
for rel in fixture_files_without_manifest() {
|
||||
let path = kit.join(rel);
|
||||
let digest = super::sha256_file(&path).unwrap();
|
||||
manifest.push_str(&format!("{digest} {DEFAULT_KIT_DIR}/{rel}\n"));
|
||||
@@ -559,26 +554,23 @@ mod tests {
|
||||
write_tar(root, matching_archives);
|
||||
}
|
||||
|
||||
fn archive_files(matching: bool) -> Vec<(&'static str, &'static [u8])> {
|
||||
let mut files = vec![
|
||||
("README-INSTALL-KIT.txt", b"readme".as_slice()),
|
||||
("MANIFEST.txt", b"manifest".as_slice()),
|
||||
("windows/deploy-ensemble.ps1", b"deploy".as_slice()),
|
||||
("windows/aw-windows-telemetry.exe", b"rust-exe".as_slice()),
|
||||
("windows/validate-deployment.ps1", b"validate".as_slice()),
|
||||
("ansible/deploy_aw_windows.yml", b"ansible".as_slice()),
|
||||
("aw-server/install_aw_server.sh", b"server".as_slice()),
|
||||
("scripts/rebuild_install_kit.sh", b"rebuild".as_slice()),
|
||||
(
|
||||
"scripts/validate_install_kit.sh",
|
||||
b"validate-kit".as_slice(),
|
||||
),
|
||||
("scripts/check_install_kit_vs_repo.sh", b"check".as_slice()),
|
||||
("scripts/quality-gate.sh", b"quality".as_slice()),
|
||||
];
|
||||
fn fixture_files_without_manifest() -> Vec<&'static str> {
|
||||
REQUIRED_RELATIVE_FILES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|rel| *rel != "MANIFEST.txt")
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn archive_files(matching: bool) -> Vec<(&'static str, Vec<u8>)> {
|
||||
let mut files: Vec<(&'static str, Vec<u8>)> = REQUIRED_RELATIVE_FILES
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|rel| (rel, rel.as_bytes().to_vec()))
|
||||
.collect();
|
||||
if !matching {
|
||||
files.pop();
|
||||
files.push(("scripts/other.sh", b"other".as_slice()));
|
||||
files.push(("scripts/other.sh", b"other".to_vec()));
|
||||
}
|
||||
files
|
||||
}
|
||||
@@ -591,7 +583,7 @@ mod tests {
|
||||
for (rel, data) in archive_files(true) {
|
||||
zip.start_file(format!("{DEFAULT_KIT_DIR}/{rel}"), options)
|
||||
.unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
zip.write_all(&data).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
@@ -607,7 +599,11 @@ mod tests {
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, format!("{DEFAULT_KIT_DIR}/{rel}"), data)
|
||||
.append_data(
|
||||
&mut header,
|
||||
format!("{DEFAULT_KIT_DIR}/{rel}"),
|
||||
data.as_slice(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
builder.finish().unwrap();
|
||||
|
||||
@@ -327,7 +327,7 @@ fn load_config() -> Config {
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect(),
|
||||
events_cache_ttl_seconds: env_i64("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", 30).max(0),
|
||||
worktime_events_limit: env_usize("AW_WORKTIME_EVENTS_LIMIT", 50_000).max(1000),
|
||||
worktime_events_limit: env_usize("AW_WORKTIME_EVENTS_LIMIT", 50_000).clamp(100, 50_000),
|
||||
aw_http_timeout_seconds: env_f64("AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS", 8.0).max(0.5),
|
||||
source_http_timeout_seconds: env_f64("AW_WORKTIME_SOURCE_HTTP_TIMEOUT_SECONDS", 1.5)
|
||||
.max(0.25),
|
||||
@@ -1771,11 +1771,34 @@ impl App {
|
||||
host: &str,
|
||||
interactive_required: bool,
|
||||
) -> (Vec<Value>, Vec<Value>) {
|
||||
let critical_max_age = self.config.manager_critical_source_max_age_seconds;
|
||||
let rust_sessions_bucket = sessions_bucket(host);
|
||||
let watcher_afk_bucket = format!("aw-watcher-afk_{host}");
|
||||
let rust_sessions_fresh = self
|
||||
.latest_bucket_event(&rust_sessions_bucket)
|
||||
.and_then(|event| {
|
||||
event
|
||||
.timestamp
|
||||
.as_deref()
|
||||
.and_then(parse_iso_utc)
|
||||
.map(|dt| (Utc::now() - dt).num_seconds().max(0) <= critical_max_age)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let watcher_afk_fresh = self
|
||||
.latest_bucket_event(&watcher_afk_bucket)
|
||||
.and_then(|event| {
|
||||
event
|
||||
.timestamp
|
||||
.as_deref()
|
||||
.and_then(parse_iso_utc)
|
||||
.map(|dt| (Utc::now() - dt).num_seconds().max(0) <= critical_max_age)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let specs = vec![
|
||||
(
|
||||
"worktime_sessions",
|
||||
"RDP worktime sessions",
|
||||
sessions_bucket(host),
|
||||
rust_sessions_bucket,
|
||||
self.config.manager_critical_source_max_age_seconds,
|
||||
true,
|
||||
false,
|
||||
@@ -1891,6 +1914,18 @@ impl App {
|
||||
} else {
|
||||
format!("stale ({}s)", age.unwrap())
|
||||
};
|
||||
let legacy_rdp_covered = legacy_rdp_covered_by_rust_sources(
|
||||
source_id,
|
||||
status,
|
||||
rust_sessions_fresh,
|
||||
watcher_afk_fresh,
|
||||
);
|
||||
let effective_required = required && !legacy_rdp_covered;
|
||||
if legacy_rdp_covered {
|
||||
status = "inactive";
|
||||
summary =
|
||||
"legacy RDP source covered by fresh Rust worktime/watcher sources".to_string();
|
||||
}
|
||||
if interactive_only && !interactive_required && status != "ok" {
|
||||
status = "inactive";
|
||||
summary = "inactive: no active interactive users".to_string();
|
||||
@@ -1903,7 +1938,7 @@ impl App {
|
||||
"bucket_id": bucket,
|
||||
"timestamp": event.as_ref().and_then(|e| e.timestamp.clone()).unwrap_or_default(),
|
||||
"age_seconds": age,
|
||||
"required": required,
|
||||
"required": effective_required,
|
||||
"interactive_only": interactive_only,
|
||||
"interactive_required": interactive_required,
|
||||
"max_age_seconds": max_age,
|
||||
@@ -1913,13 +1948,13 @@ impl App {
|
||||
if !matches!(status, "ok" | "inactive") {
|
||||
actions.push(action(
|
||||
"source_freshness_review",
|
||||
if required { "critical" } else { "medium" },
|
||||
if effective_required { "critical" } else { "medium" },
|
||||
"ops",
|
||||
if required { "today" } else { "3d" },
|
||||
if effective_required { "today" } else { "3d" },
|
||||
&format!("Источник '{label}' в состоянии {}: {}.", source_status_label(status), summary),
|
||||
"Проверить collector/service, причину отставания и подтвердить, что управленческие выводы по данным ещё надёжны.",
|
||||
"",
|
||||
json!({"source_id": source_id, "bucket_id": bucket, "age_seconds": age, "required": required}),
|
||||
json!({"source_id": source_id, "bucket_id": bucket, "age_seconds": age, "required": effective_required}),
|
||||
));
|
||||
}
|
||||
sources.push(source);
|
||||
@@ -1975,6 +2010,18 @@ fn source_status_label(status: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_rdp_covered_by_rust_sources(
|
||||
source_id: &str,
|
||||
status: &str,
|
||||
rust_sessions_fresh: bool,
|
||||
watcher_afk_fresh: bool,
|
||||
) -> bool {
|
||||
matches!(source_id, "rdp_window" | "rdp_afk")
|
||||
&& status != "ok"
|
||||
&& rust_sessions_fresh
|
||||
&& watcher_afk_fresh
|
||||
}
|
||||
|
||||
fn source_summary(event: &AwEvent) -> String {
|
||||
let d = &event.data;
|
||||
let signal = value_string(d, "signalType");
|
||||
@@ -3459,6 +3506,31 @@ mod tests {
|
||||
assert_eq!(rollups[0]["actions_count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_legacy_rdp_sources_are_covered_by_fresh_rust_sources() {
|
||||
assert!(legacy_rdp_covered_by_rust_sources(
|
||||
"rdp_window",
|
||||
"fail",
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(legacy_rdp_covered_by_rust_sources(
|
||||
"rdp_afk", "warn", true, true
|
||||
));
|
||||
assert!(!legacy_rdp_covered_by_rust_sources(
|
||||
"rdp_window",
|
||||
"fail",
|
||||
true,
|
||||
false
|
||||
));
|
||||
assert!(!legacy_rdp_covered_by_rust_sources(
|
||||
"worktime_sessions",
|
||||
"fail",
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_trend_point_removes_legacy_action_only_rollups() {
|
||||
let point = sanitize_trend_point(json!({
|
||||
|
||||
@@ -725,6 +725,12 @@
|
||||
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
|
||||
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
|
||||
AW_WORKTIME_HOST={{ aw_effective_worktime_host | default(aw_effective_monitored_windows_hostname | default('HOST-EXAMPLE')) }}
|
||||
AW_WORKTIME_EVENTS_LIMIT={{ aw_worktime_events_limit | default(250) }}
|
||||
AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS={{ aw_worktime_aw_http_timeout_seconds | default(6) }}
|
||||
AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS={{ aw_worktime_events_cache_ttl_seconds | default(300) }}
|
||||
AW_WORKTIME_REPORT_CACHE_TTL_SECONDS={{ aw_worktime_report_cache_ttl_seconds | default(300) }}
|
||||
AW_WORKTIME_REPORT_STALE_TTL_SECONDS={{ aw_worktime_report_stale_ttl_seconds | default(3600) }}
|
||||
AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS={{ aw_worktime_report_disk_stale_ttl_seconds | default(86400) }}
|
||||
AW_DLP_IOC_DIR={{ aw_dlp_ioc_workdir }}/output
|
||||
AW_DLP_POLICY_ENGINE_BIND_HOST={{ aw_dlp_policy_engine_bind_host }}
|
||||
AW_DLP_POLICY_ENGINE_PORT={{ aw_dlp_policy_engine_port }}
|
||||
|
||||
@@ -12,6 +12,12 @@ aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | defa
|
||||
aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}"
|
||||
aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610"
|
||||
aw_worktime_timezone: "Europe/Moscow"
|
||||
aw_worktime_events_limit: 250
|
||||
aw_worktime_aw_http_timeout_seconds: 6
|
||||
aw_worktime_events_cache_ttl_seconds: 300
|
||||
aw_worktime_report_cache_ttl_seconds: 300
|
||||
aw_worktime_report_stale_ttl_seconds: 3600
|
||||
aw_worktime_report_disk_stale_ttl_seconds: 86400
|
||||
aw_worktime_influx_enabled: false
|
||||
aw_worktime_influx_url: "http://<INFLUXDB_HOST>:8086"
|
||||
aw_worktime_influx_org: "proxmox"
|
||||
|
||||
@@ -12,6 +12,12 @@ aw_server_inventory_host: "{{ (groups['aw_server'] | default([]) | first) | defa
|
||||
aw_server_public_host: "{{ (hostvars[aw_server_inventory_host].ansible_host | default(aw_server_inventory_host, true)) if (aw_server_inventory_host | length) > 0 else 'aw-server' }}"
|
||||
aw_worktime_report_base: "http://{{ aw_server_public_host }}:5610"
|
||||
aw_worktime_timezone: "Europe/Moscow"
|
||||
aw_worktime_events_limit: 250
|
||||
aw_worktime_aw_http_timeout_seconds: 6
|
||||
aw_worktime_events_cache_ttl_seconds: 300
|
||||
aw_worktime_report_cache_ttl_seconds: 300
|
||||
aw_worktime_report_stale_ttl_seconds: 3600
|
||||
aw_worktime_report_disk_stale_ttl_seconds: 86400
|
||||
aw_worktime_influx_enabled: true
|
||||
aw_worktime_influx_url: "http://192.0.2.10:8086"
|
||||
aw_worktime_influx_org: "proxmox"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[Unit]
|
||||
Description=Run AW-RUS unified health orchestrator every 2 minutes
|
||||
Description=Run AW-RUS unified health orchestrator every 5 minutes
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/2:25
|
||||
AccuracySec=15s
|
||||
OnCalendar=*:0/5:25
|
||||
AccuracySec=30s
|
||||
RandomizedDelaySec=15s
|
||||
Unit=aw-rus-healthd.service
|
||||
Persistent=false
|
||||
|
||||
@@ -16,6 +16,13 @@ AW_SERVER_GROUP=activitywatch
|
||||
AW_SERVER_PUBLIC_HOST=aw-server
|
||||
AW_WORKTIME_REPORT_BASE=http://aw-server:5610
|
||||
AW_WORKTIME_TZ=Europe/Moscow
|
||||
AW_WORKTIME_HOST=HOST-EXAMPLE
|
||||
AW_WORKTIME_EVENTS_LIMIT=250
|
||||
AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS=6
|
||||
AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS=300
|
||||
AW_WORKTIME_REPORT_CACHE_TTL_SECONDS=300
|
||||
AW_WORKTIME_REPORT_STALE_TTL_SECONDS=3600
|
||||
AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS=86400
|
||||
AW_SERVER_URL=http://127.0.0.1:5600
|
||||
AW_DLP_AW_API_BASE=http://127.0.0.1:5600/api/0
|
||||
AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS=300
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
[Unit]
|
||||
Description=AW Worktime Autoheal (HOST-EXAMPLE)
|
||||
Description=AW Worktime Autoheal
|
||||
After=network-online.target activitywatch-server.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
EnvironmentFile=/etc/activitywatch/aw-server.env
|
||||
Environment=AW_URL=http://127.0.0.1:5600
|
||||
Environment=AW_WORKTIME_HOST=HOST-EXAMPLE
|
||||
ExecStart=/usr/local/bin/aw-worktime-autoheal-rust
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[Unit]
|
||||
Description=Run AW Worktime Autoheal every 3 minutes
|
||||
Description=Run AW Worktime Autoheal every 15 minutes
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:1/3:35
|
||||
OnCalendar=*:0/15:35
|
||||
AccuracySec=30s
|
||||
Unit=aw-worktime-autoheal.service
|
||||
Persistent=false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Description=Run AW Worktime Report Cache Prewarm
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:2/5:40
|
||||
OnCalendar=*:0/15:40
|
||||
AccuracySec=30s
|
||||
Persistent=false
|
||||
Unit=aw-worktime-prewarm.service
|
||||
|
||||
@@ -7,8 +7,8 @@ StartLimitIntervalSec=120
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/activitywatch/aw-server.env
|
||||
Environment=AW_SERVER_URL=http://127.0.0.1:5600
|
||||
Environment=AW_WORKTIME_HOST=HOST-EXAMPLE
|
||||
ExecStart=/usr/local/bin/aw-worktime-ui-bridge-rust
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[Unit]
|
||||
Description=Run AW Worktime UI bridge every 30 seconds
|
||||
Description=Run AW Worktime UI bridge every 5 minutes
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/1:15
|
||||
AccuracySec=10s
|
||||
OnCalendar=*:0/5:15
|
||||
AccuracySec=15s
|
||||
Unit=aw-worktime-ui-bridge.service
|
||||
Persistent=false
|
||||
|
||||
|
||||
@@ -27,6 +27,12 @@ CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json"
|
||||
WORKTIME_API_RUST_SRC="$BOOTSTRAP_DIR/worktime-api"
|
||||
WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service"
|
||||
WORKTIME_ALIASES_SRC="$BOOTSTRAP_DIR/worktime-manager-aliases.example.json"
|
||||
WORKTIME_AUTOHEAL_RUST_SRC="$BOOTSTRAP_DIR/worktime-autoheal"
|
||||
WORKTIME_AUTOHEAL_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-autoheal.service"
|
||||
WORKTIME_AUTOHEAL_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-autoheal.timer"
|
||||
WORKTIME_PREWARM_RUST_SRC="$BOOTSTRAP_DIR/worktime-prewarm"
|
||||
WORKTIME_PREWARM_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-prewarm.service"
|
||||
WORKTIME_PREWARM_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-prewarm.timer"
|
||||
WORKTIME_UI_BRIDGE_RUST_SRC="$BOOTSTRAP_DIR/worktime-ui-bridge"
|
||||
WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service"
|
||||
WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer"
|
||||
@@ -119,6 +125,40 @@ if [[ -f "$WORKTIME_ALIASES_SRC" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$WORKTIME_AUTOHEAL_RUST_SRC" ]]; then
|
||||
install -m 0755 "$WORKTIME_AUTOHEAL_RUST_SRC" /usr/local/bin/aw-worktime-autoheal-rust
|
||||
fi
|
||||
|
||||
if [[ -f "$WORKTIME_AUTOHEAL_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-autoheal-rust ]]; then
|
||||
install -m 0644 "$WORKTIME_AUTOHEAL_SERVICE_SRC" /etc/systemd/system/aw-worktime-autoheal.service
|
||||
fi
|
||||
|
||||
if [[ -f "$WORKTIME_AUTOHEAL_TIMER_SRC" && -f /usr/local/bin/aw-worktime-autoheal-rust ]]; then
|
||||
install -m 0644 "$WORKTIME_AUTOHEAL_TIMER_SRC" /etc/systemd/system/aw-worktime-autoheal.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable aw-worktime-autoheal.timer
|
||||
systemctl restart aw-worktime-autoheal.timer
|
||||
systemctl start aw-worktime-autoheal.service || true
|
||||
systemctl --no-pager --full status aw-worktime-autoheal.timer || true
|
||||
fi
|
||||
|
||||
if [[ -f "$WORKTIME_PREWARM_RUST_SRC" ]]; then
|
||||
install -m 0755 "$WORKTIME_PREWARM_RUST_SRC" /usr/local/bin/aw-worktime-prewarm-rust
|
||||
fi
|
||||
|
||||
if [[ -f "$WORKTIME_PREWARM_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-prewarm-rust ]]; then
|
||||
install -m 0644 "$WORKTIME_PREWARM_SERVICE_SRC" /etc/systemd/system/aw-worktime-prewarm.service
|
||||
fi
|
||||
|
||||
if [[ -f "$WORKTIME_PREWARM_TIMER_SRC" && -f /usr/local/bin/aw-worktime-prewarm-rust ]]; then
|
||||
install -m 0644 "$WORKTIME_PREWARM_TIMER_SRC" /etc/systemd/system/aw-worktime-prewarm.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable aw-worktime-prewarm.timer
|
||||
systemctl restart aw-worktime-prewarm.timer
|
||||
systemctl start aw-worktime-prewarm.service || true
|
||||
systemctl --no-pager --full status aw-worktime-prewarm.timer || true
|
||||
fi
|
||||
|
||||
if [[ -f "$WORKTIME_UI_BRIDGE_RUST_SRC" ]]; then
|
||||
install -m 0755 "$WORKTIME_UI_BRIDGE_RUST_SRC" /usr/local/bin/aw-worktime-ui-bridge-rust
|
||||
fi
|
||||
|
||||
@@ -19,36 +19,41 @@
|
||||
|
||||
Проверить применимость AWatch-rus для:
|
||||
|
||||
- мониторинга активности рабочих мест;
|
||||
- управленческой аналитики Workforce;
|
||||
- технического аудита ИТ-контура;
|
||||
- фиксации DLP-lite/ИБ-событий;
|
||||
- просмотра evidence и отчетов в портале;
|
||||
- контроля готовности системы через signed readiness bundle.
|
||||
- Workforce Analytics: активность, подразделения, нагрузка и управленческий
|
||||
Markdown-отчет;
|
||||
- Security Analytics: кандидаты на проверку, объяснимый risk score, события
|
||||
безопасности и аудит решений;
|
||||
- Forensics: карточка расследования, timeline, evidence package и экспорт
|
||||
Markdown-отчета;
|
||||
- Operations/Admin: качество данных, полнота данных, ClickHouse/fallback-статус
|
||||
и ошибки сбора;
|
||||
- проверки ролевых ограничений на сервере, а не только в интерфейсе.
|
||||
|
||||
## 3. Состав поставки
|
||||
|
||||
| Компонент | Проверка |
|
||||
|---|---|
|
||||
| AWatch-rus portal | вход, роли оператора/руководителя/владельца |
|
||||
| ActivityWatch telemetry | актуальность bucket/event данных |
|
||||
| Workforce analytics | индекс активности, веса приложений, drill-down |
|
||||
| DLP-lite incidents | USB/print/clipboard/file/email/browser signals, если включены |
|
||||
| Evidence workflow | preview/download/view audit |
|
||||
| Grafana dashboards | наличие данных и отсутствие query errors |
|
||||
| Readiness bundle | checksum/signature/fingerprint |
|
||||
| Prometheus alerts | readiness/signature alerts настроены |
|
||||
| AWatch-rus portal | вход, `/portal`, переключение ролей `executive` / `manager` / `security` / `forensics` / `admin` |
|
||||
| Executive Dashboard | главный вывод отображается первым, затем риски подразделений и краткий статус |
|
||||
| Workforce analytics | индекс активности, сравнение подразделений, тренды, перегруз/недогруз, Markdown-отчет |
|
||||
| Security analytics | кандидаты на проверку, UEBA Score v1, severity, аудит решений |
|
||||
| Forensics workflow | карточка расследования, timeline, evidence package, Markdown export |
|
||||
| Operations/Admin | полнота данных, качество данных, ClickHouse/fallback-статус, ошибки сбора |
|
||||
| pfSense readiness | только `contract_only`: contracts/fixtures/API-заготовка, без production ingestion |
|
||||
|
||||
## 4. Критерии приемки
|
||||
|
||||
Пилот считается успешным, если:
|
||||
|
||||
- портал доступен ответственным пользователям заказчика;
|
||||
- telemetry freshness находится в согласованных пределах;
|
||||
- readiness status = `OK` или все `WARN` имеют согласованный план устранения;
|
||||
- signed readiness bundle проходит проверку;
|
||||
- главный вывод в Executive View отображается первым;
|
||||
- роли ограничивают доступ на API-уровне;
|
||||
- качество и полнота данных имеют понятный статус;
|
||||
- не менее одного управленческого отчета сформировано и принято заказчиком;
|
||||
- не менее одного test incident/evidence workflow пройден end-to-end;
|
||||
- не менее одного security candidate / investigation / evidence workflow
|
||||
пройден end-to-end;
|
||||
- UEBA Score v1 остается rule-based и не заявляет ML/LLM;
|
||||
- pfSense readiness остается `contract_only` и не заявляет production ingestion;
|
||||
- заказчик подтвердил, что состав данных и уведомлений соответствует правилам
|
||||
внутреннего контроля и локальным нормативным документам.
|
||||
|
||||
@@ -56,24 +61,31 @@
|
||||
|
||||
| Проверка | Результат | Комментарий |
|
||||
|---|---|---|
|
||||
| Portal login | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Readiness bundle | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Workforce report | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| DLP-lite incident | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Evidence preview/download | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Grafana dashboards | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Alerting | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Portal access | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Executive View | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Workforce / Manager View | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Security View | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Forensics View | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Operations/Admin View | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Role gates / API 403 | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| `/api/reports` JSON | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| `/api/pfsense` contract_only | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
| Demo data sanitization | `<OK/WARN/FAIL>` | `<COMMENT>` |
|
||||
|
||||
## 6. Ограничения пилота
|
||||
|
||||
- AWatch-rus не заявляется как сертифицированная СЗИ, SIEM, EDR/XDR или
|
||||
enterprise DLP.
|
||||
- pfSense/network quarantine интеграции являются опциональным интеграционным
|
||||
слоем и не входят в обязательный состав пилота.
|
||||
- Telegram runtime может использоваться как интеграционный канал уведомлений,
|
||||
но не является ядром продукта.
|
||||
- pfSense readiness в Pilot v1 является `contract_only`: contracts, fixtures,
|
||||
docs и API-заготовка без production ingestion, NAC, SOAR, quarantine и
|
||||
изменения firewall/VPN/routing.
|
||||
- Grafana, Prometheus, Telegram и внешние интеграции могут использоваться в
|
||||
отдельных эксплуатационных контурах, но не являются обязательной частью
|
||||
приемки Pilot v1 demo pack.
|
||||
- Результаты Workforce analytics являются управленческими proxy-метриками и
|
||||
должны трактоваться с учетом ролей, весов приложений и локальных регламентов.
|
||||
- UEBA Score v1 ранжирует риск для ручной проверки и не принимает
|
||||
автоматических решений.
|
||||
|
||||
## 7. Решение
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Network perimeter и pfSense
|
||||
|
||||
Документ описывает роль pfSense в архитектуре AWatch-rus для
|
||||
коммерческих внедрений и экспертной оценки. pfSense рассматривается как
|
||||
опциональный интеграционный слой сетевого периметра, а не как обязательная
|
||||
часть продукта.
|
||||
коммерческих внедрений и экспертной оценки. Для Pilot v1 pfSense
|
||||
рассматривается только как `contract_only/readiness`: контракт данных, fixture
|
||||
и API-заготовка без заявления production ingestion или управления сетевыми
|
||||
политиками.
|
||||
|
||||
## 1. Позиция продукта
|
||||
|
||||
@@ -15,17 +16,18 @@ AWatch-rus поставляет:
|
||||
- DLP-lite/ИБ evidence workflow;
|
||||
- readiness checks и portal reporting.
|
||||
|
||||
Сетевой шлюз, firewall, NAT, VPN и quarantine enforcement могут быть
|
||||
интегрированы с AWatch-rus, но не входят в минимальный состав продукта.
|
||||
Сетевой шлюз, firewall, NAT, VPN и quarantine enforcement являются внешним
|
||||
периметром. Их можно рассматривать как будущие интеграционные направления, но
|
||||
они не входят в приемочный контур Pilot v1 и не заявляются как реализованный
|
||||
runtime.
|
||||
|
||||
## 2. Роль pfSense
|
||||
|
||||
pfSense может использоваться как:
|
||||
|
||||
- источник сетевого контекста;
|
||||
- внешний policy enforcement point;
|
||||
- шлюз для ограничений VLAN/alias/rules;
|
||||
- источник логов для корреляции с endpoint activity.
|
||||
- будущий источник сетевого контекста;
|
||||
- будущий внешний policy enforcement point после отдельного change request;
|
||||
- будущий источник логов для корреляции с endpoint activity.
|
||||
|
||||
AWatch-rus не требует pfSense для базовой работы портала, readiness, workforce,
|
||||
DLP-lite evidence и отчетов.
|
||||
@@ -36,16 +38,19 @@ DLP-lite evidence и отчетов.
|
||||
|---|---|
|
||||
| Endpoint telemetry | обязательный слой AWatch-rus |
|
||||
| Server-side checks/readiness | обязательный слой AWatch-rus |
|
||||
| Portal/Grafana/Prometheus | обязательный слой AWatch-rus |
|
||||
| pfSense logs/context | опциональная интеграция |
|
||||
| pfSense policy enforcement | опциональная интеграция с отдельным решением |
|
||||
| Portal/API/report layer | обязательный слой Pilot v1 |
|
||||
| pfSense contracts/fixtures/API-заготовка | `contract_only/readiness` |
|
||||
| pfSense logs/context production ingestion | не заявляется для Pilot v1 |
|
||||
| pfSense policy enforcement | future, только с отдельным решением |
|
||||
| Автоматический quarantine | не включать без отдельного согласования |
|
||||
|
||||
## 4. Безопасный режим внедрения
|
||||
|
||||
По умолчанию:
|
||||
По умолчанию в Pilot v1:
|
||||
|
||||
- AWatch-rus только читает сетевой контекст, если интеграция включена;
|
||||
- AWatch-rus показывает только контрактный readiness-слой pfSense;
|
||||
- production ingestion, если он появится позже, должен включаться отдельным
|
||||
решением после проверки источника, свежести данных и sanitization;
|
||||
- любые изменения firewall/NAT/VPN/quarantine запрещены без отдельного change
|
||||
request;
|
||||
- pfSense credentials хранятся вне Git и вне public release assets;
|
||||
@@ -63,9 +68,10 @@ DLP-lite evidence и отчетов.
|
||||
|
||||
Корректная формулировка:
|
||||
|
||||
> AWatch-rus поддерживает интеграцию с сетевым периметром заказчика, включая
|
||||
> pfSense-compatible gateways, как внешний источник контекста и опциональную
|
||||
> точку применения политик.
|
||||
> AWatch-rus архитектурно предусматривает интеграцию с сетевым периметром
|
||||
> заказчика, включая pfSense-compatible gateways. В Pilot v1 этот слой имеет
|
||||
> статус `contract_only/readiness` и не является production ingestion или
|
||||
> механизмом изменения сетевых политик.
|
||||
|
||||
## 6. Будущий roadmap
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Итог расследования production-инцидента 2026-06-07
|
||||
|
||||
Документ фиксирует результат расследования нестабильности рабочего контура
|
||||
AWatch-rus перед пилотной демонстрацией. Все значения ниже обезличены: реальные
|
||||
IP-адреса, hostname, учетные записи, домены и приватные пути не приводятся.
|
||||
|
||||
## Краткий вывод
|
||||
|
||||
Причина инцидента была не в ClickHouse и не в портале. Узкое место находилось в
|
||||
цепочке `ActivityWatch Server -> aw-worktime-api-rust -> worktime reports`.
|
||||
|
||||
`aw-worktime-api-rust` при построении отчетов запрашивал слишком большой объем
|
||||
events из bucket `aw-worktime-sessions_<HOST>` через ActivityWatch HTTP API.
|
||||
На рабочей SQLite-базе это приводило к холодным запросам с таймаутами,
|
||||
росту memory/CPU у `aw-server-rust`, зависанию прогрева отчетов и периодическим
|
||||
таймаутам executive API в портале.
|
||||
|
||||
## Наблюдаемые симптомы
|
||||
|
||||
- `/portal/api/health` мог оставаться зеленым за счет кэша, но
|
||||
`/portal/api/reports?role=executive` периодически уходил в долгий ответ или
|
||||
таймаут.
|
||||
- `aw-worktime-api-rust` писал ошибки построения отчетов на запросах вида
|
||||
`/buckets/aw-worktime-sessions_<HOST>/events?limit=5000`.
|
||||
- `aw-server-rust` накапливал высокое потребление памяти и CPU, после чего даже
|
||||
metadata-запросы к bucket могли отвечать медленно.
|
||||
- У file-operation telemetry наблюдался рост `sendFailures` в health-событиях:
|
||||
это указывало на сбои HTTP-доставки событий наблюдения во время перегруза,
|
||||
а не на потерю или копирование самих файлов.
|
||||
- `aw-worktime-autoheal` и `aw-worktime-ui-bridge` в исходной оркестрации были
|
||||
чувствительны к placeholder-host, если runtime env не был применен.
|
||||
- Operations View показывал legacy `rdp_window`/`rdp_afk` как критические
|
||||
источники, хотя фактический Rust-путь сбора `worktime_sessions` и
|
||||
`watcher_afk` был свежим.
|
||||
|
||||
## Что было исключено
|
||||
|
||||
- ClickHouse: контейнер был доступен, health-check после повторного запуска
|
||||
проходил успешно, данные читались.
|
||||
- Nginx/gateway: локальные portal endpoints отвечали.
|
||||
- Ролевая модель портала: smoke-тесты подтвердили серверные запреты.
|
||||
- Отсутствие данных 1C: не являлось причиной таймаута worktime reports.
|
||||
|
||||
## Корневая причина
|
||||
|
||||
1. Верхний лимит `AW_WORKTIME_EVENTS_LIMIT` фактически не позволял задать
|
||||
малое production-значение: нижняя граница была `1000`, а код дополнительно
|
||||
ограничивал запрос к ActivityWatch значением до `5000`.
|
||||
2. Worktime reports фильтровали дневной диапазон уже после получения событий,
|
||||
поэтому холодный запрос зависел от стоимости чтения большого bucket через AW
|
||||
HTTP API.
|
||||
3. Несколько timer/service-компонентов могли одновременно прогревать отчеты или
|
||||
запускать health/autoheal, усиливая нагрузку.
|
||||
4. Legacy RDP freshness checks не учитывали, что текущий промышленный путь уже
|
||||
идет через Rust-сбор `aw-worktime-sessions_<HOST>` и локальный watcher AFK.
|
||||
|
||||
## Внесенные исправления
|
||||
|
||||
- В `worktime-api` разрешен меньший безопасный лимит events:
|
||||
`AW_WORKTIME_EVENTS_LIMIT` теперь ограничивается диапазоном `100..50000`.
|
||||
- Production baseline установлен на:
|
||||
- `AW_WORKTIME_EVENTS_LIMIT=250`;
|
||||
- `AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS=6`;
|
||||
- `AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS=300`;
|
||||
- `AW_WORKTIME_REPORT_CACHE_TTL_SECONDS=300`;
|
||||
- `AW_WORKTIME_REPORT_STALE_TTL_SECONDS=3600`.
|
||||
- Legacy `rdp_window` и `rdp_afk` больше не создают критический источник
|
||||
Operations View, если свежие `worktime_sessions` и `watcher_afk` уже
|
||||
подтверждают рабочий Rust-путь.
|
||||
- `aw-worktime-autoheal.service` и `aw-worktime-ui-bridge.service` теперь берут
|
||||
`AW_WORKTIME_HOST` из `/etc/activitywatch/aw-server.env`, а не из
|
||||
hardcoded placeholder.
|
||||
- Таймеры worktime-цепочки разведены по более щадящему графику:
|
||||
- `aw-worktime-autoheal.timer`: 15 минут;
|
||||
- `aw-worktime-prewarm.timer`: 15 минут;
|
||||
- `aw-worktime-ui-bridge.timer`: 5 минут;
|
||||
- `aw-rus-healthd.timer`: 5 минут.
|
||||
- Gateway orchestration `detmir-auto.timer` переведен на 30 минут, а service
|
||||
source зафиксирован на Rust binary с bounded timeouts.
|
||||
- File-operation collectors проверены отдельно: активного роста `sendFailures`
|
||||
после стабилизации сервера не было, локальные очереди были пустыми. Для
|
||||
снятия ложного аварийного хвоста перезапущены только file-operation
|
||||
collectors в активных пользовательских сессиях и восстановлены штатными
|
||||
`ActivityWatch Launch [...]` задачами.
|
||||
|
||||
## Проверка после исправления
|
||||
|
||||
Подтверждено на рабочем контуре:
|
||||
|
||||
- failed systemd units: `0`;
|
||||
- ClickHouse container health: `healthy`;
|
||||
- `/portal/api/health`: `ok=true`;
|
||||
- `/reports/worktime/today`: `200`;
|
||||
- `/reports/worktime/management`: `200`;
|
||||
- full prewarm: все URL завершились `200` без `build-error`;
|
||||
- `node scripts/detmir-pilot-demo-smoke.mjs`: OK;
|
||||
- `node scripts/detmir-portal-tabs-smoke.mjs`: OK;
|
||||
- role gates: executive/manager/security/forensics ограничения подтверждены.
|
||||
- file-operation telemetry после перезапуска: по одному collector-процессу на
|
||||
активную сессию, `queueDepth=0`, `sendFailures=0`, свежие события приходят в
|
||||
bucket `aw-file-operations_<HOST>`.
|
||||
- collector-guard до исправления не контролировал наличие Rust
|
||||
file-operation collector в активных сессиях: после принудительной остановки
|
||||
он продолжал писать `status=ok actions=0`. Исправление добавило проверку
|
||||
`fileOperationsPresence`: если в активной сессии есть Rust DLP/browser
|
||||
collector, но нет Rust file-operation collector, guard запускает штатные
|
||||
`ActivityWatch Launch [...]` задачи.
|
||||
- Исправление collector-guard проверено fault-injection: один file-operation
|
||||
collector был принудительно остановлен, следующий цикл guard выполнил
|
||||
recovery (`actions>0`), после чего `missingSessions=[]`, `queueDepth=0`,
|
||||
`sendFailures=0`.
|
||||
|
||||
Локальные проверки кода:
|
||||
|
||||
- `cargo fmt --all --check`;
|
||||
- `cargo test -p worktime-api`;
|
||||
- `cargo clippy -p worktime-api --all-targets -- -D warnings`;
|
||||
- `cargo build --release -p worktime-api`;
|
||||
- `git diff --check`.
|
||||
|
||||
## Остаточные наблюдения
|
||||
|
||||
- Исторические `sendFailures` в старых health-событиях остаются в bucket как
|
||||
архивная телеметрия. Текущее состояние после перезапуска collectors чистое:
|
||||
рост `sendFailures` не подтвержден.
|
||||
- ActivityWatch SQLite остается чувствительным к широким историческим
|
||||
`events`-запросам. Для production-демо и пилота надо держать bounded limits,
|
||||
кэш и предварительный прогрев.
|
||||
- Реальные runtime значения должны оставаться в private inventory/env, а не в
|
||||
tracked repository files.
|
||||
|
||||
## Итоговое решение
|
||||
|
||||
Инцидент закрыт как operational performance regression в worktime reporting
|
||||
chain. Исправление принято в код, оркестрацию и установочный пакет. Пилотный
|
||||
контур можно показывать при выполненном преддемо-прогреве и зеленом smoke.
|
||||
@@ -11,8 +11,10 @@ Environment=DETMIR_AUTO_HEAL=1
|
||||
Environment=DETMIR_AI_STATE_DIR=/var/lib/detmir-ai
|
||||
Environment=DETMIR_AI_RUN_DIR=/var/lib/detmir-ai/locks
|
||||
EnvironmentFile=-/etc/detmir/detmir-check.env
|
||||
ExecStart=/usr/local/bin/detmir-auto
|
||||
TimeoutStartSec=300
|
||||
Environment=no_proxy=localhost,127.0.0.1,198.51.100.18,192.0.2.13,192.0.2.2,192.0.2.0/24,198.51.100.0/24
|
||||
Environment=NO_PROXY=localhost,127.0.0.1,198.51.100.18,192.0.2.13,192.0.2.2,192.0.2.0/24,198.51.100.0/24
|
||||
ExecStart=/usr/local/bin/detmir-auto-rust --command-timeout-seconds 120 --report-timeout-seconds 60
|
||||
TimeoutStartSec=600
|
||||
Nice=5
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[Unit]
|
||||
Description=Run DetMir autonomous health loop every 15 minutes
|
||||
Description=Run DetMir autonomous health loop every 30 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=15min
|
||||
OnUnitActiveSec=30min
|
||||
AccuracySec=1min
|
||||
Persistent=true
|
||||
Unit=detmir-auto.service
|
||||
|
||||
Reference in New Issue
Block a user