From aa641cba2f2525d0bd82b168dd838bbde882bc23 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Sun, 7 Jun 2026 11:41:20 +0300 Subject: [PATCH] fix(ops): harden worktime recovery and collector guard --- README.md | 1 + .../crates/aw-windows-telemetry/src/main.rs | 130 ++++++++++++++++- .../crates/rebuild-install-kit/src/main.rs | 4 + .../crates/validate-install-kit/src/main.rs | 92 ++++++------ adk-rust/crates/worktime-api/src/main.rs | 84 ++++++++++- ansible/deploy_aw_server.yml | 6 + ansible/group_vars/all.example.yml | 6 + ansible/group_vars/all.yml | 6 + aw-server/aw-rus-healthd.timer | 6 +- aw-server/aw-server.env.example | 7 + aw-server/aw-worktime-autoheal.service | 4 +- aw-server/aw-worktime-autoheal.timer | 4 +- aw-server/aw-worktime-prewarm.timer | 2 +- aw-server/aw-worktime-ui-bridge.service | 2 +- aw-server/aw-worktime-ui-bridge.timer | 6 +- aw-server/install_aw_server.sh | 40 ++++++ docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md | 70 +++++---- docs/NETWORK_PERIMETER_PFSENSE_RU.md | 40 +++--- ...RODUCTION_INCIDENT_REPORT_2026-06-07_RU.md | 136 ++++++++++++++++++ ops/detmir-ai/detmir-auto.service | 6 +- ops/detmir-ai/detmir-auto.timer | 4 +- 21 files changed, 537 insertions(+), 119 deletions(-) create mode 100644 docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md diff --git a/README.md b/README.md index 5c42522..24a6f17 100755 --- a/README.md +++ b/README.md @@ -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) diff --git a/adk-rust/crates/aw-windows-telemetry/src/main.rs b/adk-rust/crates/aw-windows-telemetry/src/main.rs index 0303333..71e0ce0 100644 --- a/adk-rust/crates/aw-windows-telemetry/src/main.rs +++ b/adk-rust/crates/aw-windows-telemetry/src/main.rs @@ -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::>(), "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 { + 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 { + 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 { 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 diff --git a/adk-rust/crates/rebuild-install-kit/src/main.rs b/adk-rust/crates/rebuild-install-kit/src/main.rs index 1f47402..0d58ad9 100644 --- a/adk-rust/crates/rebuild-install-kit/src/main.rs +++ b/adk-rust/crates/rebuild-install-kit/src/main.rs @@ -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", diff --git a/adk-rust/crates/validate-install-kit/src/main.rs b/adk-rust/crates/validate-install-kit/src/main.rs index 82474ea..8333224 100644 --- a/adk-rust/crates/validate-install-kit/src/main.rs +++ b/adk-rust/crates/validate-install-kit/src/main.rs @@ -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)> { + let mut files: Vec<(&'static str, Vec)> = 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(); diff --git a/adk-rust/crates/worktime-api/src/main.rs b/adk-rust/crates/worktime-api/src/main.rs index d358d11..243357b 100644 --- a/adk-rust/crates/worktime-api/src/main.rs +++ b/adk-rust/crates/worktime-api/src/main.rs @@ -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, Vec) { + 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!({ diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 48d0bf5..d4710c9 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -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 }} diff --git a/ansible/group_vars/all.example.yml b/ansible/group_vars/all.example.yml index 7716c5d..1a76822 100644 --- a/ansible/group_vars/all.example.yml +++ b/ansible/group_vars/all.example.yml @@ -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://:8086" aw_worktime_influx_org: "proxmox" diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 192e549..87e2593 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -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" diff --git a/aw-server/aw-rus-healthd.timer b/aw-server/aw-rus-healthd.timer index 08e74a0..20989cb 100644 --- a/aw-server/aw-rus-healthd.timer +++ b/aw-server/aw-rus-healthd.timer @@ -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 diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index 69c8db8..bdf4a33 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -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 diff --git a/aw-server/aw-worktime-autoheal.service b/aw-server/aw-worktime-autoheal.service index 9c257db..f84546a 100644 --- a/aw-server/aw-worktime-autoheal.service +++ b/aw-server/aw-worktime-autoheal.service @@ -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 diff --git a/aw-server/aw-worktime-autoheal.timer b/aw-server/aw-worktime-autoheal.timer index 58b9c76..494b6eb 100644 --- a/aw-server/aw-worktime-autoheal.timer +++ b/aw-server/aw-worktime-autoheal.timer @@ -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 diff --git a/aw-server/aw-worktime-prewarm.timer b/aw-server/aw-worktime-prewarm.timer index 5167be5..f7ddc74 100644 --- a/aw-server/aw-worktime-prewarm.timer +++ b/aw-server/aw-worktime-prewarm.timer @@ -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 diff --git a/aw-server/aw-worktime-ui-bridge.service b/aw-server/aw-worktime-ui-bridge.service index dc9eb15..a4b608d 100644 --- a/aw-server/aw-worktime-ui-bridge.service +++ b/aw-server/aw-worktime-ui-bridge.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 diff --git a/aw-server/aw-worktime-ui-bridge.timer b/aw-server/aw-worktime-ui-bridge.timer index 768215a..7add022 100644 --- a/aw-server/aw-worktime-ui-bridge.timer +++ b/aw-server/aw-worktime-ui-bridge.timer @@ -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 diff --git a/aw-server/install_aw_server.sh b/aw-server/install_aw_server.sh index e85c7fa..67512b6 100755 --- a/aw-server/install_aw_server.sh +++ b/aw-server/install_aw_server.sh @@ -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 diff --git a/docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md b/docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md index 1c9f9db..63af6ae 100644 --- a/docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md +++ b/docs/CUSTOMER_PILOT_ACCEPTANCE_RU.md @@ -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 | `` | `` | -| Readiness bundle | `` | `` | -| Workforce report | `` | `` | -| DLP-lite incident | `` | `` | -| Evidence preview/download | `` | `` | -| Grafana dashboards | `` | `` | -| Alerting | `` | `` | +| Portal access | `` | `` | +| Executive View | `` | `` | +| Workforce / Manager View | `` | `` | +| Security View | `` | `` | +| Forensics View | `` | `` | +| Operations/Admin View | `` | `` | +| Role gates / API 403 | `` | `` | +| `/api/reports` JSON | `` | `` | +| `/api/pfsense` contract_only | `` | `` | +| Demo data sanitization | `` | `` | ## 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. Решение diff --git a/docs/NETWORK_PERIMETER_PFSENSE_RU.md b/docs/NETWORK_PERIMETER_PFSENSE_RU.md index 8d9dd58..47de651 100644 --- a/docs/NETWORK_PERIMETER_PFSENSE_RU.md +++ b/docs/NETWORK_PERIMETER_PFSENSE_RU.md @@ -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 diff --git a/docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md b/docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md new file mode 100644 index 0000000..5cfe948 --- /dev/null +++ b/docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md @@ -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_` через 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_/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_` и локальный 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_`. +- 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. diff --git a/ops/detmir-ai/detmir-auto.service b/ops/detmir-ai/detmir-auto.service index 24de0d6..d4942af 100644 --- a/ops/detmir-ai/detmir-auto.service +++ b/ops/detmir-ai/detmir-auto.service @@ -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 diff --git a/ops/detmir-ai/detmir-auto.timer b/ops/detmir-ai/detmir-auto.timer index 94b73b0..323b3c3 100644 --- a/ops/detmir-ai/detmir-auto.timer +++ b/ops/detmir-ai/detmir-auto.timer @@ -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