From dbef90a09e71a328d67f39b8dd3ec598bef759aa Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Wed, 3 Jun 2026 03:27:52 +0300 Subject: [PATCH] refactor(detmir): retire python runtime paths --- AGENTS.md | 4 +- adk-rust/RUNBOOK.md | 43 +- .../crates/aw-ensure-reliability/src/main.rs | 85 +- adk-rust/crates/aw-slo-monitor/src/main.rs | 2 +- .../crates/prod-backup-restore/src/main.rs | 51 +- .../crates/rebuild-install-kit/src/main.rs | 5 - ansible/deploy_aw_server.yml | 886 ++--- ansible/deploy_tsj_guardian_bot_proxmox.yml | 4 +- ansible/group_vars/proxmox-bot.example.yml | 2 +- .../provision_proxmox_ct_and_deploy_aw.yml | 50 +- ...vision_proxmox_ct_matrix_and_deploy_aw.yml | 60 +- .../roles/dlp-case-management/tasks/main.yml | 60 +- ansible/roles/dlp-compliance/tasks/main.yml | 59 +- ansible/roles/dlp-integrations/tasks/main.yml | 199 +- .../roles/dlp-policy-engine/tasks/main.yml | 51 +- aw-server/aw-dlp-influx-exporter.py | 417 --- aw-server/aw-dlp-influx-exporter.service | 2 +- aw-server/aw-health-check-wrapper.sh | 3 +- aw-server/aw-rus-healthd.py | 512 --- aw-server/aw-rus-healthd.service | 2 +- aw-server/aw-slo-monitor.py | 465 --- aw-server/aw-slo-monitor.service | 2 +- aw-server/aw-worktime-api.py | 3039 ----------------- aw-server/aw-worktime-api.service | 2 +- aw-server/aw-worktime-autoheal.service | 3 +- aw-server/aw-worktime-autoheal.sh | 243 -- aw-server/aw-worktime-influx-exporter.py | 181 - aw-server/aw-worktime-influx-exporter.service | 3 +- aw-server/aw-worktime-prewarm.service | 3 +- aw-server/aw-worktime-ui-bridge.py | 424 --- aw-server/aw-worktime-ui-bridge.service | 2 +- .../dlp-case-management/case-service.service | 4 +- aw-server/dlp-case-management/case_rules.py | 8 - aw-server/dlp-case-management/case_schema.py | 72 - aw-server/dlp-case-management/case_service.py | 91 - aw-server/dlp-case-management/case_storage.py | 351 -- .../dlp-case-management/evidence_chain.py | 52 - .../dlp-case-management/test_case_rules.py | 21 - .../dlp-case-management/test_case_storage.py | 93 - .../dlp-compliance/compliance_scheduler.py | 24 - .../dlp-compliance/report-scheduler.service | 4 +- aw-server/dlp-compliance/report_generator.py | 201 -- .../dlp-integrations/cef-exporter.service | 4 +- aw-server/dlp-integrations/cef_exporter.py | 165 - .../dlp-integrations/syslog-forwarder.service | 4 +- .../dlp-integrations/syslog_forwarder.py | 146 - .../dlp-integrations/test_syslog_forwarder.py | 59 - .../dlp-integrations/webhook-sender.service | 4 +- aw-server/dlp-integrations/webhook_sender.py | 155 - aw-server/dlp-monitoring/metrics_exporter.py | 19 - .../dlp-policy-engine.service | 4 +- .../dlp-policy-engine/policy_distributor.py | 26 - aw-server/dlp-policy-engine/policy_schema.py | 71 - aw-server/dlp-policy-engine/policy_service.py | 214 -- aw-server/dlp-policy-engine/policy_storage.py | 367 -- aw-server/ensure-reliability.sh | 12 +- aw-server/hayabusa/aw-hayabusa-autoprocess.py | 136 - aw-server/hayabusa/aw-hayabusa-case-alert.py | 361 -- .../hayabusa/aw-hayabusa-from-windows.py | 111 - aw-server/hayabusa/aw-hayabusa-link-case.py | 76 - aw-server/hayabusa/aw-hayabusa.sh | 56 +- aw-server/health-check.sh | 173 - aw-server/install_aw_server.sh | 28 +- aw-server/test_aw_dlp_influx_exporter.py | 96 - aw-server/test_aw_dlp_report_generator.py | 39 - aw-server/test_aw_rus_healthd.py | 211 -- aw-server/test_aw_slo_monitor.py | 268 -- aw-server/test_aw_worktime_api.py | 1078 ------ aw-server/test_aw_worktime_influx_exporter.py | 67 - aw-server/test_aw_worktime_ui_bridge.py | 244 -- docs/DETMIR_PYTHON_RETIREMENT_RU.md | 54 + install-kit-awindows-20260427-211240.tar.gz | Bin 185718 -> 146391 bytes install-kit-awindows-20260427-211240.zip | Bin 220469 -> 176728 bytes .../MANIFEST.txt | 31 +- .../ansible/deploy_aw_server.yml | 950 ++---- .../ansible/deploy_aw_windows.yml | 1 + .../provision_proxmox_ct_and_deploy_aw.yml | 50 +- ...vision_proxmox_ct_matrix_and_deploy_aw.yml | 60 +- .../aw-server/aw-rus-healthd.py | 512 --- .../aw-server/aw-rus-healthd.service | 2 +- .../aw-server/aw-slo-monitor.py | 465 --- .../aw-server/aw-slo-monitor.service | 2 +- .../aw-server/aw-worktime-api.py | 3039 ----------------- .../aw-server/aw-worktime-api.service | 2 +- .../aw-server/aw-worktime-prewarm.service | 3 +- .../aw-server/aw-worktime-prewarm.sh | 98 - .../aw-server/health-check.sh | 173 - .../aw-server/install_aw_server.sh | 28 +- .../scripts/aw-webui-browser-smoke.sh | 13 + .../scripts/check_install_kit_vs_repo.sh | 58 +- .../scripts/rebuild_install_kit.sh | 149 +- .../scripts/validate_install_kit.sh | 74 +- scripts/aggregate_dlp_events.py | 517 --- scripts/build_dlp_ioc_from_hayabusa.sh | 7 +- scripts/check_install_kit_vs_repo.sh | 58 +- scripts/dlp-admin-cli.py | 158 - scripts/dlp-health-check.py | 1023 ------ scripts/extract_ioc_from_sigma.py | 235 -- scripts/merge_aw_server_dbs.py | 236 -- scripts/prod_backup_restore.sh | 93 +- scripts/rebuild_install_kit.sh | 149 +- scripts/test_dlp_health_check.py | 421 --- scripts/validate_install_kit.sh | 74 +- 103 files changed, 914 insertions(+), 19800 deletions(-) delete mode 100644 aw-server/aw-dlp-influx-exporter.py delete mode 100644 aw-server/aw-rus-healthd.py delete mode 100644 aw-server/aw-slo-monitor.py delete mode 100644 aw-server/aw-worktime-api.py delete mode 100644 aw-server/aw-worktime-autoheal.sh delete mode 100644 aw-server/aw-worktime-influx-exporter.py delete mode 100644 aw-server/aw-worktime-ui-bridge.py delete mode 100644 aw-server/dlp-case-management/case_rules.py delete mode 100644 aw-server/dlp-case-management/case_schema.py delete mode 100644 aw-server/dlp-case-management/case_service.py delete mode 100644 aw-server/dlp-case-management/case_storage.py delete mode 100644 aw-server/dlp-case-management/evidence_chain.py delete mode 100644 aw-server/dlp-case-management/test_case_rules.py delete mode 100644 aw-server/dlp-case-management/test_case_storage.py delete mode 100644 aw-server/dlp-compliance/compliance_scheduler.py delete mode 100644 aw-server/dlp-compliance/report_generator.py delete mode 100644 aw-server/dlp-integrations/cef_exporter.py delete mode 100644 aw-server/dlp-integrations/syslog_forwarder.py delete mode 100644 aw-server/dlp-integrations/test_syslog_forwarder.py delete mode 100644 aw-server/dlp-integrations/webhook_sender.py delete mode 100644 aw-server/dlp-monitoring/metrics_exporter.py delete mode 100644 aw-server/dlp-policy-engine/policy_distributor.py delete mode 100644 aw-server/dlp-policy-engine/policy_schema.py delete mode 100644 aw-server/dlp-policy-engine/policy_service.py delete mode 100644 aw-server/dlp-policy-engine/policy_storage.py delete mode 100644 aw-server/hayabusa/aw-hayabusa-autoprocess.py delete mode 100644 aw-server/hayabusa/aw-hayabusa-case-alert.py delete mode 100644 aw-server/hayabusa/aw-hayabusa-from-windows.py delete mode 100644 aw-server/hayabusa/aw-hayabusa-link-case.py delete mode 100644 aw-server/health-check.sh delete mode 100644 aw-server/test_aw_dlp_influx_exporter.py delete mode 100644 aw-server/test_aw_dlp_report_generator.py delete mode 100644 aw-server/test_aw_rus_healthd.py delete mode 100644 aw-server/test_aw_slo_monitor.py delete mode 100644 aw-server/test_aw_worktime_api.py delete mode 100644 aw-server/test_aw_worktime_influx_exporter.py delete mode 100644 aw-server/test_aw_worktime_ui_bridge.py create mode 100644 docs/DETMIR_PYTHON_RETIREMENT_RU.md delete mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py delete mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py delete mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py delete mode 100644 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh delete mode 100644 install-kit-awindows-20260427-211240/aw-server/health-check.sh delete mode 100755 scripts/aggregate_dlp_events.py delete mode 100644 scripts/dlp-admin-cli.py delete mode 100644 scripts/dlp-health-check.py delete mode 100644 scripts/extract_ioc_from_sigma.py delete mode 100644 scripts/merge_aw_server_dbs.py delete mode 100644 scripts/test_dlp_health_check.py diff --git a/AGENTS.md b/AGENTS.md index 84b4279..8b35631 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ AGENTS for OpenCode Keep this file minimal and high-signal: only include facts an agent would otherwise miss. 1) Repo purpose (one line) -- This repository bundles an ActivityWatch Server deployment, RU WebUI patch, Windows collectors (PowerShell), and small Python utilities for aggregation and monitoring. +- This repository bundles an ActivityWatch Server deployment, RU WebUI patch, Windows collectors (PowerShell), and Rust operational utilities for aggregation, monitoring, health checks, SLO, DLP helpers, and DetMir automation. Python is retained only for explicit exceptions: Telegram bot runtime, pfSense tooling, OCR/content-analysis, 1C AI/ETL, and detmir-mcp. 2) Highest-value entrypoints & commands - Read README.md and docs/preparation.md first (they are the authoritative onboarding flow). @@ -12,7 +12,7 @@ Keep this file minimal and high-signal: only include facts an agent would otherw - Install AW server on the CT (runs inside CT): `aw-server/install_aw_server.sh` (requires `/etc/activitywatch/aw-server.env`). - Apply RU WebUI patch (must run on the CT and after webui is present): `aw-server/apply_webui_ru_patch.sh`. - Run the Windows ensemble deploy from a Windows admin host: `windows/deploy-ensemble.ps1` (see its parameters; it calls `deploy-domain-users.ps1`). -- Quick DLP aggregation (local): `python3 scripts/aggregate_dlp_events.py`. +- Quick DLP aggregation (local): `adk-rust/target/release/dlp-aggregator`. 3) Exact env/secrets behavior agents often miss - Secrets live in `secrets/deploy.secrets.env` (actual file is intentionally local-only). Many scripts default to that path if no arg provided. Never add real secrets to commits. Use `.example` files as templates. diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index 658eaab..0ad7fa4 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -52,8 +52,8 @@ Ansible, PowerShell или Playwright, он остается там до поя | `detmir-heal-safe` | Rust binary deployed + legacy script retained | switched for Rust auto | | `tsj_guardian_watchdog.sh` | Rust service via systemd drop-in + legacy shell retained | switched | | Telegram `/status`/decision backend | Rust helper + permanent Python bot runtime | backend only | -| `aw-rus-healthd.py` | Rust production via systemd drop-in + legacy Python retained | switched | -| `scripts/dlp-health-check.py` | Rust production binary + legacy Python backup retained | switched | +| `aw-rus-healthd` | Rust production unit, Python entrypoint removed from repo | done | +| `dlp-health-check` | Rust production binary, Python entrypoint removed from repo | done | | DLP aggregator | Rust production via systemd drop-in + legacy Python retained | switched | | AW DLP Influx exporter | Rust production via systemd drop-in + legacy Python retained | switched | | AW worktime Influx exporter | Rust production via systemd drop-in + legacy Python retained | switched | @@ -763,7 +763,8 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg `cases list/create`, `health check`; - HTTP client использует no-proxy для локальных AW/DLP сервисов; - `/usr/local/bin/dlp-admin-cli` установлен как Rust production CLI, - legacy `/usr/local/bin/dlp-admin-cli.py` оставлен для rollback/reference; + legacy `/usr/local/bin/dlp-admin-cli.py` больше не является repo/runtime + path; рабочий CLI: `/usr/local/bin/dlp-admin-cli`; - parity на AW server совпал с Python для `health`, `policies active`, `incidents list --since-hours 24 --limit 5`, `cases list --limit 5`; - production verification зеленый: AW failed units 0, @@ -885,9 +886,9 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg freshness через `dlp-health-check --json`, и drift checks для `startOfDay`, `always_active_pattern`, `landingpage`; - env contract читается из процесса и `/etc/activitywatch/aw-server.env`; - - production `/usr/local/bin/aw-health-check` теперь wrapper, который - предпочитает `/usr/local/bin/aw-health-check-rust` и fallback на - `/opt/activitywatch/aw-rus-ops/health-check.sh`; + - production `/usr/local/bin/aw-health-check` теперь Rust-required wrapper: + вызывает `/usr/local/bin/aw-health-check-rust` и падает с ошибкой, если + Rust binary отсутствует; - shadow parity на AW server совпал с shell по exit code и ключевым health-строкам; - production verification зеленый: `aw-health-check` OK, @@ -1380,32 +1381,24 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg warnings`, release build OK, `bash -n` OK, artifact check OK, `quality-gate.sh` OK, DetMir read-only OK with `dlp_counts={ok:22,warn:0,fail:0}` and `ok_for_operator=true`. -50. `[done]` Перенести `scripts/merge_aw_server_dbs.py` merge engine на Rust: +50. `[done]` Перенести merge engine на Rust: - добавлен crate `merge-aw-server-dbs`; - - `scripts/merge_aw_server_dbs.py` остается совместимым с текущим - `python3 merge_aw_server_dbs.py` вызовом, но в начале exec'ает Rust - binary при наличии `MERGE_AW_SERVER_DBS_RUST`, - `$CARGO_TARGET_DIR/release/merge-aw-server-dbs`, - `adk-rust/target/release/merge-aw-server-dbs` или + - рабочий runtime path: `adk-rust/target/release/merge-aw-server-dbs` и `/usr/local/bin/merge-aw-server-dbs`; - - `MERGE_AW_SERVER_DBS_FORCE_LEGACY=1` форсирует Python fallback; - - `ansible/deploy_aw_server.yml` теперь optional устанавливает - `/usr/local/bin/merge-aw-server-dbs`, если release artifact существует, - сохраняя прежний `python3 /usr/local/bin/merge_aw_server_dbs.py` command; + - legacy `scripts/merge_aw_server_dbs.py` удален из repo/runtime path; + - `ansible/deploy_aw_server.yml` устанавливает + `/usr/local/bin/merge-aw-server-dbs` и использует его без Python; - Rust сохраняет контракт `--base`, `--overlay`, `--output`, SQLite backup-copy base DB, bucket matching by id/key/name, duplicate event suppression by `(starttime,endtime,data)`, JSON summary; - - fallback bugfix: Python legacy больше не падает на `UNIQUE(name)` - name-conflict/update path и считает только реально вставленные buckets; - - offline parity fixture: Rust и Python fallback дали одинаковый JSON + - offline parity fixture: Rust replacement дал ожидаемый JSON `inserted_buckets=1`, `inserted_events=3` и одинаковые SQLite table rows (`buckets=3`, `events=5`) на кейсе duplicate event + name-conflict bucket; - gates: `cargo fmt --all -- --check`, `cargo test -p merge-aw-server-dbs` (`2 passed`), `cargo clippy -p merge-aw-server-dbs --all-targets -- -D warnings`, release build OK, - `python3 -m py_compile scripts/merge_aw_server_dbs.py`, artifact check - OK, `quality-gate.sh` OK, DetMir read-only OK after DLP baseline retry + artifact check OK, `quality-gate.sh` OK, DetMir read-only OK after DLP baseline retry with `dlp_counts={ok:22,warn:0,fail:0}` and `ok_for_operator=true`. 51. `[done]` Перенести `scripts/prod_backup_restore.sh` в safe-by-default Rust planner/checker: @@ -1419,13 +1412,13 @@ systemctl is-active tsj-guardian-bot tsj-guardian-watchdog gost-tg он печатает plan-only flow и помечает destructive steps; - если Rust artifact отсутствует, wrapper отказывается запускать destructive legacy случайно и просит собрать Rust planner; - - старый destructive flow доступен только явно: - `scripts/prod_backup_restore.sh --apply-legacy`; + - старый Python destructive flow удален; `--apply-legacy` больше не + поддерживается; - Rust `--apply` намеренно запрещен на этом этапе; - `--check-inputs --json` валидирует env/files/commands без вывода секретов, проверяет `AW_SSH_PASSWORD`, `AW_WINRM_PASSWORD`, `sshpass`, - `ansible-playbook`, inventory и `merge_aw_server_dbs.py`; - - план сохраняет порядок legacy flow: scp merge script, remote backup dir, + `ansible-playbook`, inventory и `merge-aw-server-dbs`; + - план сохраняет порядок restore flow: scp Rust merge binary, remote backup dir, DB existence checks, DB backups, stop `activitywatch-server`, merge DB, install merged DB, 3 ansible playbooks, final AW validation; - safe smoke: plan text OK, JSON valid (`steps=12`, `missing_count=0`), diff --git a/adk-rust/crates/aw-ensure-reliability/src/main.rs b/adk-rust/crates/aw-ensure-reliability/src/main.rs index 6af41c4..55ad4eb 100644 --- a/adk-rust/crates/aw-ensure-reliability/src/main.rs +++ b/adk-rust/crates/aw-ensure-reliability/src/main.rs @@ -56,9 +56,6 @@ struct Cli { #[arg(long, default_value = "/etc/systemd/system/aw-health-check.service")] health_service_target: PathBuf, - #[arg(long, default_value = "/opt/activitywatch/aw-rus-ops/health-check.sh")] - health_script_source: PathBuf, - #[arg(long, default_value_t = false)] apply: bool, @@ -72,7 +69,6 @@ enum StepKind { Check, Chown, Chmod, - Copy, Write, Systemd, Sleep, @@ -138,11 +134,10 @@ fn build_report(cli: &Cli) -> Report { if !cli.env_file.is_file() { missing_required.push(format!("env file missing: {}", cli.env_file.display())); } - if !cli.health_script_target.is_file() && !cli.health_script_source.is_file() { + if !cli.health_script_target.is_file() { missing_required.push(format!( - "health script target and source missing: {}, {}", - cli.health_script_target.display(), - cli.health_script_source.display() + "health script target missing: {}", + cli.health_script_target.display() )); } @@ -193,17 +188,13 @@ fn build_report(cli: &Cli) -> Report { ); push_step( &mut steps, - "install-health-script", - StepKind::Copy, - format!( - "copy {} {}", - shell_quote(&cli.health_script_source), - shell_quote(&cli.health_script_target) - ), - true, + "check-health-script", + StepKind::Check, + format!("test -x {}", shell_quote(&cli.health_script_target)), + false, !cli.health_script_target.is_file(), if cli.health_script_target.is_file() { - "health script already installed".to_string() + "health script already installed by Ansible".to_string() } else { "health script target missing".to_string() }, @@ -331,8 +322,16 @@ fn apply_steps(report: &mut Report) -> Result<()> { 1 }), }, + "check-health-script" => { + let target = Path::new("/usr/local/bin/aw-health-check"); + ExecResult { + order: step.order, + name: step.name.clone(), + ok: target.is_file(), + exit_code: Some(if target.is_file() { 0 } else { 1 }), + } + } "install-logrotate" => write_file_result(step, report, LOGROTATE_CONTENT, 0o644)?, - "install-health-script" => copy_health_script(step, report)?, "install-health-timer" => write_file_result(step, report, HEALTH_TIMER_CONTENT, 0o644)?, "install-health-service" => { write_file_result(step, report, HEALTH_SERVICE_CONTENT, 0o644)? @@ -371,24 +370,6 @@ fn write_file_result(step: &Step, report: &Report, content: &str, mode: u32) -> }) } -fn copy_health_script(step: &Step, report: &Report) -> Result { - let source = source_from_copy_command(&step.command)?; - let target = copy_target_from_command(&step.command)?; - let _ = report; - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; - } - fs::copy(&source, &target) - .with_context(|| format!("copy {} -> {}", source.display(), target.display()))?; - set_mode(&target, 0o755).with_context(|| format!("chmod 0755 {}", target.display()))?; - Ok(ExecResult { - order: step.order, - name: step.name.clone(), - ok: true, - exit_code: Some(0), - }) -} - fn run_shell_step(step: &Step) -> Result { let status = Command::new("sh") .arg("-c") @@ -412,22 +393,6 @@ fn target_from_command(command: &str) -> Result { Ok(PathBuf::from(raw.trim_matches('\''))) } -fn source_from_copy_command(command: &str) -> Result { - let raw = command - .split_whitespace() - .nth(1) - .context("parse source from copy command")?; - Ok(PathBuf::from(raw.trim_matches('\''))) -} - -fn copy_target_from_command(command: &str) -> Result { - let raw = command - .split_whitespace() - .nth(2) - .context("parse target from copy command")?; - Ok(PathBuf::from(raw.trim_matches('\''))) -} - #[cfg(unix)] fn set_mode(path: &Path, mode: u32) -> Result<()> { use std::os::unix::fs::PermissionsExt; @@ -494,19 +459,19 @@ mod tests { fn dry_run_marks_legacy_mutations() { let dir = tempfile::tempdir().unwrap(); let env_file = dir.path().join("aw-server.env"); - let health = dir.path().join("health-check.sh"); + let health_target = dir.path().join("bin/aw-health-check"); fs::write(&env_file, "AW_BASE_URL=http://127.0.0.1:5600\n").unwrap(); - fs::write(&health, "#!/bin/sh\nexit 0\n").unwrap(); + fs::create_dir_all(health_target.parent().unwrap()).unwrap(); + fs::write(&health_target, "#!/bin/sh\nexit 0\n").unwrap(); let cli = Cli { env_file, data_dir: dir.path().join("data"), log_dir: dir.path().join("log"), opt_dir: dir.path().join("opt"), logrotate_target: dir.path().join("logrotate/activitywatch"), - health_script_target: dir.path().join("bin/aw-health-check"), + health_script_target: health_target, health_timer_target: dir.path().join("systemd/aw-health-check.timer"), health_service_target: dir.path().join("systemd/aw-health-check.service"), - health_script_source: health, apply: false, json: true, }; @@ -525,18 +490,18 @@ mod tests { #[test] fn missing_env_blocks_apply() { let dir = tempfile::tempdir().unwrap(); - let health = dir.path().join("health-check.sh"); - fs::write(&health, "#!/bin/sh\nexit 0\n").unwrap(); + let health_target = dir.path().join("bin/aw-health-check"); + fs::create_dir_all(health_target.parent().unwrap()).unwrap(); + fs::write(&health_target, "#!/bin/sh\nexit 0\n").unwrap(); let cli = Cli { env_file: dir.path().join("missing.env"), data_dir: dir.path().join("data"), log_dir: dir.path().join("log"), opt_dir: dir.path().join("opt"), logrotate_target: dir.path().join("logrotate/activitywatch"), - health_script_target: dir.path().join("bin/aw-health-check"), + health_script_target: health_target, health_timer_target: dir.path().join("systemd/aw-health-check.timer"), health_service_target: dir.path().join("systemd/aw-health-check.service"), - health_script_source: health, apply: true, json: true, }; diff --git a/adk-rust/crates/aw-slo-monitor/src/main.rs b/adk-rust/crates/aw-slo-monitor/src/main.rs index bb1fe14..3a7f77e 100644 --- a/adk-rust/crates/aw-slo-monitor/src/main.rs +++ b/adk-rust/crates/aw-slo-monitor/src/main.rs @@ -20,7 +20,7 @@ use serde_json::{Value, json}; const ENV_FILE: &str = "/etc/activitywatch/aw-server.env"; const DEFAULT_STATE_DIR: &str = "/var/lib/activitywatch/slo"; -const DEFAULT_HEALTHD_CMD: &str = "/usr/local/bin/aw-rus-healthd.py --json"; +const DEFAULT_HEALTHD_CMD: &str = "/usr/local/bin/aw-rus-healthd-rust --json"; const DEFAULT_HEALTHD_STATE_FILE: &str = "/var/lib/activitywatch/health/aw-rus-health.json"; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] diff --git a/adk-rust/crates/prod-backup-restore/src/main.rs b/adk-rust/crates/prod-backup-restore/src/main.rs index 1d4a00f..5767bf5 100644 --- a/adk-rust/crates/prod-backup-restore/src/main.rs +++ b/adk-rust/crates/prod-backup-restore/src/main.rs @@ -13,7 +13,7 @@ const DEFAULT_SERVER_USER: &str = "igor"; const DEFAULT_LEGACY_DB: &str = "/root/.local/share/activitywatch/aw-server-rust/sqlite.db"; const DEFAULT_TARGET_DB: &str = "/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db"; -const DEFAULT_REMOTE_MERGE_SCRIPT: &str = "/tmp/merge_aw_server_dbs.py"; +const DEFAULT_REMOTE_MERGE_BIN: &str = "/tmp/merge-aw-server-dbs"; #[derive(Debug, Parser)] #[command(about = "Safe planner/checker for the destructive prod backup restore flow")] @@ -45,8 +45,8 @@ struct Cli { #[arg(long, default_value = DEFAULT_TARGET_DB)] target_db: String, - #[arg(long, default_value = DEFAULT_REMOTE_MERGE_SCRIPT)] - remote_merge_script: String, + #[arg(long, default_value = DEFAULT_REMOTE_MERGE_BIN)] + remote_merge_bin: String, #[arg(long)] apply: bool, @@ -63,7 +63,7 @@ struct Plan { remote_backup_dir: String, legacy_db: String, target_db: String, - remote_merge_script: String, + remote_merge_bin: String, required_env: Vec, required_commands: Vec, required_files: Vec, @@ -101,7 +101,7 @@ fn run() -> Result { let cli = Cli::parse(); if cli.apply { bail!( - "Rust apply is intentionally disabled for this stage; use scripts/prod_backup_restore.sh --apply-legacy only for explicit legacy restore" + "Rust apply is intentionally disabled for this stage; run without --apply to review the safe restore plan" ); } let root = cli @@ -128,7 +128,7 @@ fn run() -> Result { } else { root.join(&cli.inventory) }; - let merge_script = root.join("scripts/merge_aw_server_dbs.py"); + let merge_bin = root.join("adk-rust/target/release/merge-aw-server-dbs"); let plan = build_plan( &root, &env_file, @@ -139,9 +139,9 @@ fn run() -> Result { &remote_backup_dir, &cli.legacy_db, &cli.target_db, - &cli.remote_merge_script, + &cli.remote_merge_bin, &inventory, - &merge_script, + &merge_bin, ); if cli.json { @@ -168,9 +168,9 @@ fn build_plan( remote_backup_dir: &str, legacy_db: &str, target_db: &str, - remote_merge_script: &str, + remote_merge_bin: &str, inventory: &Path, - merge_script: &Path, + merge_bin: &Path, ) -> Plan { let required_env = ["AW_SSH_PASSWORD", "AW_WINRM_PASSWORD"] .into_iter() @@ -199,24 +199,21 @@ fn build_plan( }, }) .collect::>(); - let required_files = [ - ("inventory", inventory), - ("merge_aw_server_dbs.py", merge_script), - ] - .into_iter() - .map(|(name, path)| Requirement { - name: name.to_string(), - ok: path.is_file(), - detail: path.display().to_string(), - }) - .collect::>(); + let required_files = [("inventory", inventory), ("merge-aw-server-dbs", merge_bin)] + .into_iter() + .map(|(name, path)| Requirement { + name: name.to_string(), + ok: path.is_file(), + detail: path.display().to_string(), + }) + .collect::>(); let mut steps = Vec::new(); push_step( &mut steps, "scp", format!( - "sshpass scp scripts/merge_aw_server_dbs.py {server_user}@{server_host}:{remote_merge_script}" + "sshpass scp adk-rust/target/release/merge-aw-server-dbs {server_user}@{server_host}:{remote_merge_bin}" ), false, ); @@ -258,7 +255,7 @@ fn build_plan( &mut steps, "ssh", format!( - "sudo python3 '{remote_merge_script}' --base '{legacy_db}' --overlay '{target_db}' --output '{remote_backup_dir}/sqlite.merged.db'" + "sudo chmod 0755 '{remote_merge_bin}' && sudo '{remote_merge_bin}' --base '{legacy_db}' --overlay '{target_db}' --output '{remote_backup_dir}/sqlite.merged.db'" ), true, ); @@ -307,7 +304,7 @@ fn build_plan( remote_backup_dir: remote_backup_dir.to_string(), legacy_db: legacy_db.to_string(), target_db: target_db.to_string(), - remote_merge_script: remote_merge_script.to_string(), + remote_merge_bin: remote_merge_bin.to_string(), required_env, required_commands, required_files, @@ -443,7 +440,9 @@ mod tests { fn plan_marks_destructive_steps() { let dir = tempfile::tempdir().unwrap(); let inventory = dir.path().join("ansible/inventory.ini"); - let merge = dir.path().join("scripts/merge_aw_server_dbs.py"); + let merge = dir + .path() + .join("adk-rust/target/release/merge-aw-server-dbs"); fs::create_dir_all(inventory.parent().unwrap()).unwrap(); fs::create_dir_all(merge.parent().unwrap()).unwrap(); fs::write(&inventory, "").unwrap(); @@ -461,7 +460,7 @@ mod tests { "/var/lib/activitywatch/backups/prod-restore-20260602-000000", DEFAULT_LEGACY_DB, DEFAULT_TARGET_DB, - DEFAULT_REMOTE_MERGE_SCRIPT, + DEFAULT_REMOTE_MERGE_BIN, &inventory, &merge, ); diff --git a/adk-rust/crates/rebuild-install-kit/src/main.rs b/adk-rust/crates/rebuild-install-kit/src/main.rs index 31c34db..74c39b4 100644 --- a/adk-rust/crates/rebuild-install-kit/src/main.rs +++ b/adk-rust/crates/rebuild-install-kit/src/main.rs @@ -47,23 +47,18 @@ const AW_SERVER_FILES: &[&str] = &[ "aw-server/apply_webui_ru_patch.sh", "aw-server/aw-host-groups.json", "aw-server/aw-ru-patch.js", - "aw-server/aw-rus-healthd.py", "aw-server/aw-rus-healthd.service", "aw-server/aw-rus-healthd.timer", "aw-server/aw-browser-smoke.service", "aw-server/aw-browser-smoke.timer", - "aw-server/aw-slo-monitor.py", "aw-server/aw-slo-monitor.service", "aw-server/aw-slo-monitor.timer", "aw-server/aw-server.env.example", "aw-server/aw-sw-cleanup.js", - "aw-server/aw-worktime-api.py", "aw-server/aw-worktime-api.service", - "aw-server/aw-worktime-prewarm.sh", "aw-server/aw-worktime-prewarm.service", "aw-server/aw-worktime-prewarm.timer", "aw-server/aw-worktime-panel.js", - "aw-server/health-check.sh", "aw-server/install_aw_server.sh", "aw-server/settings/classes-worktime.json", "aw-server/settings/views-default.json", diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 66defd1..6a81578 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -27,6 +27,7 @@ - tesseract-ocr - tesseract-ocr-rus - unzip + - jq state: present update_cache: true @@ -109,6 +110,64 @@ - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" + - name: Проверить существующие health state files + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ aw_rus_health_state_dir }}/aw-rus-health.json" + - "{{ aw_rus_health_state_dir }}/aw-rus-health.txt" + - "{{ aw_rus_health_state_dir }}/dlp-health-check-counters.json" + register: aw_rus_health_state_files + + - name: Нормализовать владельца существующих health state files + ansible.builtin.file: + path: "{{ item.stat.path }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + loop: "{{ aw_rus_health_state_files.results }}" + when: item.stat.exists | default(false) + + - name: Удалить устаревшие Python entrypoints после Rust migration + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /opt/activitywatch/aw-rus-ops/health-check.sh + - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py + - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py + - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py + - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py + - /opt/activitywatch/dlp-case-management/.venv + - /opt/activitywatch/dlp-case-management/case_rules.py + - /opt/activitywatch/dlp-case-management/case_schema.py + - /opt/activitywatch/dlp-case-management/case_service.py + - /opt/activitywatch/dlp-case-management/case_storage.py + - /opt/activitywatch/dlp-case-management/evidence_chain.py + - /opt/activitywatch/dlp-compliance/compliance_scheduler.py + - /opt/activitywatch/dlp-compliance/report_generator.py + - /opt/activitywatch/dlp-compliance/.venv + - /opt/activitywatch/dlp-integrations/cef_exporter.py + - /opt/activitywatch/dlp-integrations/syslog_forwarder.py + - /opt/activitywatch/dlp-integrations/webhook_sender.py + - /opt/activitywatch/dlp-integrations/.venv + - /opt/activitywatch/dlp-policy-engine/.venv + - /opt/activitywatch/dlp-policy-engine/policy_distributor.py + - /opt/activitywatch/dlp-policy-engine/policy_schema.py + - /opt/activitywatch/dlp-policy-engine/policy_service.py + - /opt/activitywatch/dlp-policy-engine/policy_storage.py + - /opt/activitywatch/scripts/aggregate_dlp_events.py + - /usr/local/bin/aw-dlp-influx-exporter.py + - /usr/local/bin/aw-extract-ioc-from-sigma.py + - /usr/local/bin/aw-rus-healthd.py + - /usr/local/bin/aw-slo-monitor.py + - /usr/local/bin/aw-worktime-afk-bridge.py + - /usr/local/bin/aw-worktime-api.py + - /usr/local/bin/aw-worktime-influx-exporter.py + - /usr/local/bin/aw-worktime-ui-bridge.py + - /usr/local/bin/dlp-admin-cli.py + - /usr/local/bin/merge_aw_server_dbs.py + - name: Установить prune script для локального state ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-prune-local-state.sh" @@ -431,14 +490,6 @@ group: root mode: "0755" - - name: Положить legacy aw-health-check до Influx проверок - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/health-check.sh" - dest: /opt/activitywatch/aw-rus-ops/health-check.sh - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust aw-health-check до Influx проверок ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-health-check" @@ -455,6 +506,12 @@ mode: "0755" when: aw_health_check_rust_binary_early.stat.exists | default(false) + - name: Требовать Rust aw-health-check до Influx проверок + ansible.builtin.assert: + that: + - aw_health_check_rust_binary_early.stat.exists | default(false) + fail_msg: "Rust aw-health-check artifact is required at {{ aw_rust_release_dir }}/aw-health-check" + - name: Проверить локальный Rust aw-db-health до Influx проверок ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-db-health" @@ -650,34 +707,6 @@ mode: "0755" when: aw_dlp_policy_engine_enabled | default(false) | bool - - name: Скопировать файлы DLP policy engine - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/dlp-policy-engine/{{ item }}" - dest: "/opt/activitywatch/dlp-policy-engine/{{ item }}" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0644" - loop: - - policy_service.py - - policy_schema.py - - policy_storage.py - - policy_distributor.py - - requirements.txt - when: aw_dlp_policy_engine_enabled | default(false) | bool - - - name: Создать virtualenv DLP policy engine - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-policy-engine/.venv - args: - creates: /opt/activitywatch/dlp-policy-engine/.venv/bin/python - when: aw_dlp_policy_engine_enabled | default(false) | bool - - - name: Установить зависимости DLP policy engine - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-policy-engine/requirements.txt - virtualenv: /opt/activitywatch/dlp-policy-engine/.venv - when: aw_dlp_policy_engine_enabled | default(false) | bool - - name: Установить systemd unit DLP policy engine ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-policy-engine/dlp-policy-engine.service" @@ -695,6 +724,13 @@ become: false when: aw_dlp_policy_engine_enabled | default(false) | bool + - name: Требовать Rust DLP policy engine artifact + ansible.builtin.assert: + that: + - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-policy-engine" + when: aw_dlp_policy_engine_enabled | default(false) | bool + - name: Установить Rust DLP policy engine ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-policy-engine" @@ -706,31 +742,6 @@ - aw_dlp_policy_engine_enabled | default(false) | bool - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог DLP policy engine - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-policy-engine.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_policy_engine_enabled | default(false) | bool - - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) - - - name: Переключить DLP policy engine на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-policy-engine.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-dlp-policy-engine-rust - when: - - aw_dlp_policy_engine_enabled | default(false) | bool - - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) - - name: Создать каталог DLP content analysis ansible.builtin.file: path: /opt/activitywatch/dlp-content-analysis @@ -807,38 +818,9 @@ group: "{{ aw_server_group }}" mode: "0644" loop: - - cef_exporter.py - - syslog_forwarder.py - - webhook_sender.py - cef-config.yaml - syslog-forwarder-config.yaml - webhook-config.yaml - - requirements.txt - when: aw_dlp_integrations_enabled | default(true) | bool - - - name: Выставить executable для python scripts DLP integrations - ansible.builtin.file: - path: "/opt/activitywatch/dlp-integrations/{{ item }}" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0755" - loop: - - cef_exporter.py - - syslog_forwarder.py - - webhook_sender.py - when: aw_dlp_integrations_enabled | default(true) | bool - - - name: Создать virtualenv DLP integrations - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-integrations/.venv - args: - creates: /opt/activitywatch/dlp-integrations/.venv/bin/python - when: aw_dlp_integrations_enabled | default(true) | bool - - - name: Установить зависимости DLP integrations - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-integrations/requirements.txt - virtualenv: /opt/activitywatch/dlp-integrations/.venv when: aw_dlp_integrations_enabled | default(true) | bool - name: Создать state каталог DLP integrations @@ -876,6 +858,13 @@ become: false when: aw_dlp_integrations_enabled | default(true) | bool + - name: Требовать Rust CEF exporter artifact + ansible.builtin.assert: + that: + - dlp_cef_exporter_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-cef-exporter" + when: aw_dlp_integrations_enabled | default(true) | bool + - name: Установить Rust CEF exporter ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-cef-exporter" @@ -887,31 +876,6 @@ - aw_dlp_integrations_enabled | default(true) | bool - dlp_cef_exporter_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог CEF exporter - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-cef-exporter.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_cef_exporter_rust_binary.stat.exists | default(false) - - - name: Переключить CEF exporter на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-cef-exporter.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/dlp-cef-exporter-rust - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_cef_exporter_rust_binary.stat.exists | default(false) - - name: Установить systemd unit syslog forwarder ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-integrations/syslog-forwarder.service" @@ -938,6 +902,13 @@ become: false when: aw_dlp_integrations_enabled | default(true) | bool + - name: Требовать Rust syslog forwarder artifact + ansible.builtin.assert: + that: + - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-syslog-forwarder" + when: aw_dlp_integrations_enabled | default(true) | bool + - name: Установить Rust syslog forwarder ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-syslog-forwarder" @@ -949,31 +920,6 @@ - aw_dlp_integrations_enabled | default(true) | bool - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог syslog forwarder - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - - - name: Переключить syslog forwarder на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/dlp-syslog-forwarder-rust - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - - name: Установить systemd unit webhook sender ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-integrations/webhook-sender.service" @@ -1000,6 +946,13 @@ become: false when: aw_dlp_integrations_enabled | default(true) | bool + - name: Требовать Rust webhook sender artifact + ansible.builtin.assert: + that: + - dlp_webhook_sender_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-webhook-sender" + when: aw_dlp_integrations_enabled | default(true) | bool + - name: Установить Rust webhook sender ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-webhook-sender" @@ -1011,31 +964,6 @@ - aw_dlp_integrations_enabled | default(true) | bool - dlp_webhook_sender_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог webhook sender - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-webhook-sender.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_webhook_sender_rust_binary.stat.exists | default(false) - - - name: Переключить webhook sender на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-webhook-sender.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/dlp-webhook-sender-rust - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_webhook_sender_rust_binary.stat.exists | default(false) - - name: Создать каталог DLP case management ansible.builtin.file: path: /opt/activitywatch/dlp-case-management @@ -1045,43 +973,6 @@ mode: "0755" when: aw_dlp_case_management_enabled | default(true) | bool - - name: Скопировать файлы DLP case management - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/dlp-case-management/{{ item }}" - dest: "/opt/activitywatch/dlp-case-management/{{ item }}" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0644" - loop: - - case_service.py - - case_rules.py - - case_schema.py - - case_storage.py - - evidence_chain.py - - requirements.txt - when: aw_dlp_case_management_enabled | default(true) | bool - - - name: Выставить executable для case_service - ansible.builtin.file: - path: /opt/activitywatch/dlp-case-management/case_service.py - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0755" - when: aw_dlp_case_management_enabled | default(true) | bool - - - name: Создать virtualenv DLP case management - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-case-management/.venv - args: - creates: /opt/activitywatch/dlp-case-management/.venv/bin/python - when: aw_dlp_case_management_enabled | default(true) | bool - - - name: Установить зависимости DLP case management - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-case-management/requirements.txt - virtualenv: /opt/activitywatch/dlp-case-management/.venv - when: aw_dlp_case_management_enabled | default(true) | bool - - name: Установить systemd unit DLP case management ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-case-management/case-service.service" @@ -1099,6 +990,13 @@ become: false when: aw_dlp_case_management_enabled | default(true) | bool + - name: Требовать Rust DLP case management artifact + ansible.builtin.assert: + that: + - aw_dlp_case_management_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-case-management" + when: aw_dlp_case_management_enabled | default(true) | bool + - name: Установить Rust DLP case management ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-case-management" @@ -1110,31 +1008,6 @@ - aw_dlp_case_management_enabled | default(true) | bool - aw_dlp_case_management_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог DLP case management - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-case-management.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_case_management_enabled | default(true) | bool - - aw_dlp_case_management_rust_binary.stat.exists | default(false) - - - name: Переключить DLP case management на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-case-management.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-dlp-case-management-rust - when: - - aw_dlp_case_management_enabled | default(true) | bool - - aw_dlp_case_management_rust_binary.stat.exists | default(false) - - name: Создать каталоги DLP compliance ansible.builtin.file: path: "{{ item }}" @@ -1156,28 +1029,12 @@ group: "{{ aw_server_group }}" mode: "{{ item.mode }}" loop: - - { src: "report_generator.py", dest: "/opt/activitywatch/dlp-compliance/report_generator.py", mode: "0755" } - - { src: "compliance_scheduler.py", dest: "/opt/activitywatch/dlp-compliance/compliance_scheduler.py", mode: "0755" } - - { src: "requirements.txt", dest: "/opt/activitywatch/dlp-compliance/requirements.txt", mode: "0644" } - { src: "templates/152-fz-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/152-fz-report.html", mode: "0644" } - { src: "templates/pci-dss-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/pci-dss-report.html", mode: "0644" } - { src: "report-scheduler.service", dest: "/etc/systemd/system/aw-dlp-report-scheduler.service", mode: "0644" } - { src: "report-scheduler.timer", dest: "/etc/systemd/system/aw-dlp-report-scheduler.timer", mode: "0644" } when: aw_dlp_compliance_enabled | default(true) | bool - - name: Создать virtualenv DLP compliance - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-compliance/.venv - args: - creates: /opt/activitywatch/dlp-compliance/.venv/bin/python - when: aw_dlp_compliance_enabled | default(true) | bool - - - name: Установить зависимости DLP compliance - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-compliance/requirements.txt - virtualenv: /opt/activitywatch/dlp-compliance/.venv - when: aw_dlp_compliance_enabled | default(true) | bool - - name: Проверить локальный Rust DLP compliance ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/dlp-compliance" @@ -1186,6 +1043,13 @@ become: false when: aw_dlp_compliance_enabled | default(true) | bool + - name: Требовать Rust DLP compliance artifact + ansible.builtin.assert: + that: + - aw_dlp_compliance_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-compliance" + when: aw_dlp_compliance_enabled | default(true) | bool + - name: Установить Rust DLP compliance ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-compliance" @@ -1197,45 +1061,18 @@ - aw_dlp_compliance_enabled | default(true) | bool - aw_dlp_compliance_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог DLP compliance report scheduler - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-report-scheduler.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_compliance_enabled | default(true) | bool - - aw_dlp_compliance_rust_binary.stat.exists | default(false) - - - name: Переключить DLP compliance report scheduler на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-report-scheduler.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-dlp-compliance-rust - when: - - aw_dlp_compliance_enabled | default(true) | bool - - aw_dlp_compliance_rust_binary.stat.exists | default(false) - - - name: Установить dlp-admin-cli - ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/dlp-admin-cli.py" - dest: /usr/local/bin/dlp-admin-cli.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust dlp-admin-cli ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/dlp-admin-cli" delegate_to: localhost register: aw_dlp_admin_cli_rust_binary + - name: Требовать Rust dlp-admin-cli artifact + ansible.builtin.assert: + that: + - aw_dlp_admin_cli_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-admin-cli" + - name: Установить Rust dlp-admin-cli ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-admin-cli" @@ -1245,14 +1082,6 @@ mode: "0755" when: aw_dlp_admin_cli_rust_binary.stat.exists - - name: Установить скрипт AW worktime API - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py" - dest: /usr/local/bin/aw-worktime-api.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust AW worktime API ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/worktime-api" @@ -1260,6 +1089,12 @@ register: aw_worktime_api_rust_binary become: false + - name: Требовать Rust AW worktime API artifact + ansible.builtin.assert: + that: + - aw_worktime_api_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-api" + - name: Установить Rust AW worktime API ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-api" @@ -1277,35 +1112,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW worktime API - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-api.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_worktime_api_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime API на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-api.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-api-rust - when: aw_worktime_api_rust_binary.stat.exists | default(false) - - - name: Установить скрипт AW worktime UI bridge - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.py" - dest: /usr/local/bin/aw-worktime-ui-bridge.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust AW worktime UI bridge ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/worktime-ui-bridge" @@ -1313,6 +1119,12 @@ register: aw_worktime_ui_bridge_rust_binary become: false + - name: Требовать Rust AW worktime UI bridge artifact + ansible.builtin.assert: + that: + - aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-ui-bridge" + - name: Установить Rust AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-ui-bridge" @@ -1322,14 +1134,6 @@ mode: "0755" when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) - - name: Установить скрипт AW worktime autoheal - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.sh" - dest: /usr/local/bin/aw-worktime-autoheal.sh - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust AW worktime autoheal ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/worktime-autoheal" @@ -1337,6 +1141,12 @@ register: aw_worktime_autoheal_rust_binary become: false + - name: Требовать Rust AW worktime autoheal artifact + ansible.builtin.assert: + that: + - aw_worktime_autoheal_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-autoheal" + - name: Установить Rust AW worktime autoheal ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-autoheal" @@ -1346,14 +1156,6 @@ mode: "0755" when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) - - name: Установить скрипт AW worktime prewarm - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.sh" - dest: /usr/local/bin/aw-worktime-prewarm.sh - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust AW worktime prewarm ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/worktime-prewarm" @@ -1361,6 +1163,12 @@ register: aw_worktime_prewarm_rust_binary become: false + - name: Требовать Rust AW worktime prewarm artifact + ansible.builtin.assert: + that: + - aw_worktime_prewarm_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-prewarm" + - name: Установить Rust AW worktime prewarm ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-prewarm" @@ -1370,25 +1178,7 @@ mode: "0755" when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) - - name: Установить скрипт AW worktime Influx exporter - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.py" - dest: /usr/local/bin/aw-worktime-influx-exporter.py - owner: root - group: root - mode: "0755" - when: aw_worktime_influx_enabled | default(false) | bool - - - name: Установить скрипт AW DLP Influx exporter - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.py" - dest: /usr/local/bin/aw-dlp-influx-exporter.py - owner: root - group: root - mode: "0755" - when: aw_dlp_influx_enabled | default(false) | bool - - - name: Создать server-side ops bundle для legacy aw-health-check + - name: Создать server-side ops bundle для AW ops helpers ansible.builtin.file: path: /opt/activitywatch/aw-rus-ops state: directory @@ -1404,14 +1194,6 @@ group: root mode: "0755" - - name: Положить legacy aw-health-check в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/health-check.sh" - dest: /opt/activitywatch/aw-rus-ops/health-check.sh - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust aw-health-check ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-health-check" @@ -1428,6 +1210,12 @@ mode: "0755" when: aw_health_check_rust_binary.stat.exists | default(false) + - name: Требовать Rust aw-health-check artifact + ansible.builtin.assert: + that: + - aw_health_check_rust_binary.stat.exists | default(false) + fail_msg: "Rust aw-health-check artifact is required at {{ aw_rust_release_dir }}/aw-health-check" + - name: Проверить локальный Rust aw-db-health ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-db-health" @@ -1492,30 +1280,12 @@ mode: "0755" when: dlp_health_check_rust_binary.stat.exists | default(false) - - name: Установить legacy Python dlp-health-check - ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/dlp-health-check.py" - dest: /usr/local/bin/dlp-health-check - owner: root - group: root - mode: "0755" - when: not (dlp_health_check_rust_binary.stat.exists | default(false)) - - - name: Установить скрипт aw-rus-healthd - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.py" - dest: /usr/local/bin/aw-rus-healthd.py - owner: root - group: root - mode: "0755" - - - name: Установить скрипт aw-slo-monitor - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.py" - dest: /usr/local/bin/aw-slo-monitor.py - owner: root - group: root - mode: "0755" + - name: Проверить локальный Rust AW-RUS healthd + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-rus-healthd" + delegate_to: localhost + register: aw_rus_healthd_rust_binary + become: false - name: Проверить локальный Rust AW-RUS SLO monitor ansible.builtin.stat: @@ -1524,6 +1294,32 @@ register: aw_slo_monitor_rust_binary become: false + - name: Требовать Rust dlp-health-check artifact + ansible.builtin.assert: + that: + - dlp_health_check_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-health-check" + + - name: Требовать Rust AW-RUS healthd artifact + ansible.builtin.assert: + that: + - aw_rus_healthd_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-rus-healthd" + + - name: Требовать Rust AW-RUS SLO monitor artifact + ansible.builtin.assert: + that: + - aw_slo_monitor_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-slo-monitor" + + - name: Установить Rust AW-RUS healthd + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-rus-healthd" + dest: /usr/local/bin/aw-rus-healthd-rust + owner: root + group: root + mode: "0755" + - name: Установить Rust AW-RUS SLO monitor ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/aw-slo-monitor" @@ -1550,27 +1346,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW worktime UI bridge - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-ui-bridge.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime UI bridge на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-ui-bridge.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-ui-bridge-rust - when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.timer" @@ -1587,27 +1362,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW worktime autoheal - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-autoheal.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime autoheal на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-autoheal.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-autoheal-rust - when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW worktime autoheal ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.timer" @@ -1624,27 +1378,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW worktime prewarm - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-prewarm.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime prewarm на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-prewarm.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-prewarm-rust - when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW worktime prewarm ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.timer" @@ -1677,24 +1410,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW-RUS SLO monitor - ansible.builtin.file: - path: /etc/systemd/system/aw-slo-monitor.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_slo_monitor_rust_binary.stat.exists | default(false) - - - name: Переключить AW-RUS SLO monitor на Rust - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor-rust-switch.conf" - dest: /etc/systemd/system/aw-slo-monitor.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - when: aw_slo_monitor_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW-RUS SLO monitor ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.timer" @@ -1738,6 +1453,13 @@ become: false when: aw_worktime_influx_enabled | default(false) | bool + - name: Требовать Rust AW worktime Influx exporter artifact + ansible.builtin.assert: + that: + - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-influx-exporter" + when: aw_worktime_influx_enabled | default(false) | bool + - name: Установить Rust AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-influx-exporter" @@ -1749,31 +1471,6 @@ - aw_worktime_influx_enabled | default(false) | bool - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог AW worktime Influx exporter - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-influx-exporter.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_worktime_influx_enabled | default(false) | bool - - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime Influx exporter на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-influx-exporter.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-influx-exporter-rust - when: - - aw_worktime_influx_enabled | default(false) | bool - - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.timer" @@ -1800,6 +1497,13 @@ become: false when: aw_dlp_influx_enabled | default(false) | bool + - name: Требовать Rust AW DLP Influx exporter artifact + ansible.builtin.assert: + that: + - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-influx-exporter" + when: aw_dlp_influx_enabled | default(false) | bool + - name: Установить Rust AW DLP Influx exporter ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-influx-exporter" @@ -1811,31 +1515,6 @@ - aw_dlp_influx_enabled | default(false) | bool - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог AW DLP Influx exporter - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-influx-exporter.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_influx_enabled | default(false) | bool - - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) - - - name: Переключить AW DLP Influx exporter на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-influx-exporter.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-dlp-influx-exporter-rust - when: - - aw_dlp_influx_enabled | default(false) | bool - - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW DLP Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.timer" @@ -2044,14 +1723,6 @@ regexp: '' replace: '' - - name: Скопировать merge script AW DB на сервер - ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/merge_aw_server_dbs.py" - dest: /usr/local/bin/merge_aw_server_dbs.py - owner: root - group: root - mode: "0755" - - name: Check Rust merge AW DB binary artifact ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/merge-aw-server-dbs" @@ -2059,6 +1730,13 @@ delegate_to: localhost become: false + - name: Требовать Rust merge AW DB artifact + ansible.builtin.assert: + that: + - aw_merge_db_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/merge-aw-server-dbs" + when: aw_legacy_db_merge_enabled | default(false) | bool + - name: Install Rust merge AW DB binary ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/merge-aw-server-dbs" @@ -2125,8 +1803,7 @@ - name: Merge legacy root DB в target DB ansible.builtin.command: argv: - - python3 - - /usr/local/bin/merge_aw_server_dbs.py + - /usr/local/bin/merge-aw-server-dbs - --base - /root/.local/share/activitywatch/aw-server-rust/sqlite.db - --overlay @@ -2256,34 +1933,6 @@ group: root mode: "0755" - - name: Скопировать агрегатор событий DLP - ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/aggregate_dlp_events.py" - dest: "/opt/activitywatch/scripts/aggregate_dlp_events.py" - owner: root - group: root - mode: "0755" - - - name: Установить systemd unit для агрегатора - ansible.builtin.copy: - dest: /etc/systemd/system/activitywatch-dlp-aggregator.service - content: | - [Unit] - Description=ActivityWatch DLP Event Aggregator - After=activitywatch-server.service - - [Service] - Type=oneshot - User={{ aw_server_user }} - WorkingDirectory={{ aw_server_data_dir }} - ExecStart=/usr/bin/python3 /opt/activitywatch/scripts/aggregate_dlp_events.py \ - --aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \ - --sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \ - --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json - - [Install] - WantedBy=multi-user.target - - name: Проверить локальный Rust DLP aggregator ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/dlp-aggregator" @@ -2291,6 +1940,12 @@ register: dlp_aggregator_rust_binary become: false + - name: Требовать Rust DLP aggregator artifact + ansible.builtin.assert: + that: + - dlp_aggregator_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-aggregator" + - name: Установить Rust DLP aggregator ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-aggregator" @@ -2300,29 +1955,28 @@ mode: "0755" when: dlp_aggregator_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог DLP aggregator - ansible.builtin.file: - path: /etc/systemd/system/activitywatch-dlp-aggregator.service.d - state: directory - owner: root - group: root - mode: "0755" - when: dlp_aggregator_rust_binary.stat.exists | default(false) - - - name: Переключить DLP aggregator на Rust + - name: Установить systemd unit для агрегатора ansible.builtin.copy: - dest: /etc/systemd/system/activitywatch-dlp-aggregator.service.d/20-rust-switch.conf + dest: /etc/systemd/system/activitywatch-dlp-aggregator.service owner: root group: root mode: "0644" content: | + [Unit] + Description=ActivityWatch DLP Event Aggregator + After=activitywatch-server.service + [Service] - ExecStart= + Type=oneshot + User={{ aw_server_user }} + WorkingDirectory={{ aw_server_data_dir }} ExecStart=/usr/local/bin/dlp-aggregator-rust \ --aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \ --sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \ --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json - when: dlp_aggregator_rust_binary.stat.exists | default(false) + + [Install] + WantedBy=multi-user.target - name: Установить systemd timer для агрегатора ansible.builtin.copy: @@ -2350,13 +2004,6 @@ - name: Настроить IOC enrichment из Hayabusa Sigma when: aw_dlp_ioc_enabled | default(false) | bool block: - - name: Установить зависимости IOC refresh - ansible.builtin.apt: - name: - - python3-yaml - state: present - update_cache: true - - name: Создать каталог IOC enrichment ansible.builtin.file: path: "{{ aw_dlp_ioc_workdir }}/{{ item }}" @@ -2369,10 +2016,23 @@ - output - tmp - - name: Установить экстрактор IOC из Sigma + - name: Проверить локальный Rust extractor IOC из Sigma + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/extract-ioc-from-sigma" + delegate_to: localhost + register: aw_extract_ioc_from_sigma_rust_binary + become: false + + - name: Требовать Rust extractor IOC из Sigma + ansible.builtin.assert: + that: + - aw_extract_ioc_from_sigma_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/extract-ioc-from-sigma" + + - name: Установить Rust extractor IOC из Sigma ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/extract_ioc_from_sigma.py" - dest: /usr/local/bin/aw-extract-ioc-from-sigma.py + src: "{{ aw_rust_release_dir }}/extract-ioc-from-sigma" + dest: /usr/local/bin/aw-extract-ioc-from-sigma owner: root group: root mode: "0755" @@ -2404,7 +2064,7 @@ fi mv "${EXTRACTED_DIR}" "${RULES_DIR}" - /usr/bin/python3 /usr/local/bin/aw-extract-ioc-from-sigma.py \ + /usr/local/bin/aw-extract-ioc-from-sigma \ --rules-root "${RULES_DIR}" \ --out-dir "${OUTDIR}" @@ -2716,28 +2376,23 @@ mode: "0755" when: aw_hayabusa_link_case_rust_binary.stat.exists | default(false) + - name: Требовать Rust helper link-case для Hayabusa + ansible.builtin.assert: + that: + - aw_hayabusa_link_case_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-hayabusa-link-case-rust" + - name: Установить helper link-case для Hayabusa ansible.builtin.copy: content: | #!/usr/bin/env bash set -euo pipefail - if [[ -x /usr/local/bin/aw-hayabusa-link-case-rust ]]; then - exec /usr/local/bin/aw-hayabusa-link-case-rust "$@" - fi - exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py "$@" + exec /usr/local/bin/aw-hayabusa-link-case-rust "$@" dest: /usr/local/bin/aw-hayabusa-link-case owner: root group: root mode: "0755" - - name: Положить helper link-case в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-link-case.py" - dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust helper from-windows для Hayabusa ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-hayabusa-from-windows-rust" @@ -2754,28 +2409,23 @@ mode: "0755" when: aw_hayabusa_from_windows_rust_binary.stat.exists | default(false) + - name: Требовать Rust helper from-windows для Hayabusa + ansible.builtin.assert: + that: + - aw_hayabusa_from_windows_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-hayabusa-from-windows-rust" + - name: Установить helper from-windows для Hayabusa ansible.builtin.copy: content: | #!/usr/bin/env bash set -euo pipefail - if [[ -x /usr/local/bin/aw-hayabusa-from-windows-rust ]]; then - exec /usr/local/bin/aw-hayabusa-from-windows-rust "$@" - fi - exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py "$@" + exec /usr/local/bin/aw-hayabusa-from-windows-rust "$@" dest: /usr/local/bin/aw-hayabusa-from-windows owner: root group: root mode: "0755" - - name: Положить helper from-windows в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-from-windows.py" - dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py - owner: root - group: root - mode: "0755" - - name: Положить README Hayabusa в server-side ops bundle ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/hayabusa/README.md" @@ -2800,28 +2450,23 @@ mode: "0755" when: aw_hayabusa_autoprocess_rust_binary.stat.exists | default(false) + - name: Требовать Rust helper autoprocess для Hayabusa + ansible.builtin.assert: + that: + - aw_hayabusa_autoprocess_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-hayabusa-autoprocess-rust" + - name: Установить helper autoprocess для Hayabusa ansible.builtin.copy: content: | #!/usr/bin/env bash set -euo pipefail - if [[ -x /usr/local/bin/aw-hayabusa-autoprocess-rust ]]; then - exec /usr/local/bin/aw-hayabusa-autoprocess-rust "$@" - fi - exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py "$@" + exec /usr/local/bin/aw-hayabusa-autoprocess-rust "$@" dest: /usr/local/bin/aw-hayabusa-autoprocess owner: root group: root mode: "0755" - - name: Положить helper autoprocess в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-autoprocess.py" - dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust helper case-alert для Hayabusa ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-hayabusa-case-alert-rust" @@ -2838,28 +2483,23 @@ mode: "0755" when: aw_hayabusa_case_alert_rust_binary.stat.exists | default(false) + - name: Требовать Rust helper case-alert для Hayabusa + ansible.builtin.assert: + that: + - aw_hayabusa_case_alert_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-hayabusa-case-alert-rust" + - name: Установить helper case-alert для Hayabusa ansible.builtin.copy: content: | #!/usr/bin/env bash set -euo pipefail - if [[ -x /usr/local/bin/aw-hayabusa-case-alert-rust ]]; then - exec /usr/local/bin/aw-hayabusa-case-alert-rust "$@" - fi - exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py "$@" + exec /usr/local/bin/aw-hayabusa-case-alert-rust "$@" dest: /usr/local/bin/aw-hayabusa-case-alert owner: root group: root mode: "0755" - - name: Положить helper case-alert в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-case-alert.py" - dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py - owner: root - group: root - mode: "0755" - - name: Установить systemd unit aw-hayabusa-drop.service ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-hayabusa-drop.service" @@ -2907,9 +2547,9 @@ - not ansible_check_mode - aw_server_post_deploy_health_check_enabled | default(true) | bool block: - - name: Запустить /usr/local/bin/aw-rus-healthd.py --json + - name: Запустить /usr/local/bin/aw-rus-healthd-rust --json ansible.builtin.command: - cmd: /usr/local/bin/aw-rus-healthd.py --json + cmd: /usr/local/bin/aw-rus-healthd-rust --json register: aw_post_deploy_health changed_when: false failed_when: false diff --git a/ansible/deploy_tsj_guardian_bot_proxmox.yml b/ansible/deploy_tsj_guardian_bot_proxmox.yml index ed3e24e..bb93181 100644 --- a/ansible/deploy_tsj_guardian_bot_proxmox.yml +++ b/ansible/deploy_tsj_guardian_bot_proxmox.yml @@ -258,7 +258,7 @@ AW_RUS_WORKTIME_BASE={{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }} AW_DLP_POLICY_API_BASE={{ tsj_bot_aw_dlp_policy_api_base | default('http://10.10.10.13:5601/api/0') }} AW_DLP_POLICY_ACTOR={{ tsj_bot_aw_dlp_policy_actor | default('tsj-guardian-bot') }} - AW_RUS_WORKTIME_HEAL_CMD={{ tsj_bot_aw_rus_worktime_heal_cmd | default("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n /usr/local/bin/aw-worktime-autoheal.sh && sudo -n systemctl reset-failed aw-worktime-ui-bridge.service && sudo -n systemctl start aw-worktime-ui-bridge.service'") }} + AW_RUS_WORKTIME_HEAL_CMD={{ tsj_bot_aw_rus_worktime_heal_cmd | default("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n /usr/local/bin/aw-worktime-autoheal-rust && sudo -n systemctl reset-failed aw-worktime-ui-bridge.service && sudo -n systemctl start aw-worktime-ui-bridge.service'") }} AW_RUS_DLP_HEAL_CMD={{ tsj_bot_aw_rus_dlp_heal_cmd | default("ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n systemctl restart activitywatch-server.service && (sudo -n systemctl start activitywatch-dlp-aggregator.service || true) && sudo -n /usr/local/bin/aw-health-check && sudo -n /usr/local/bin/dlp-health-check'") }} AW_RUS_CASE_API_BASE={{ tsj_bot_aw_rus_case_api_base | default('http://10.10.10.13:5602') }} AW_RUS_HAYABUSA_ENABLED={{ tsj_bot_aw_rus_hayabusa_enabled | default('true') }} @@ -305,7 +305,7 @@ - { key: "AW_RUS_WORKTIME_BASE", value: "{{ tsj_bot_aw_rus_worktime_base | default('http://10.10.10.13:5610') }}" } - { key: "AW_DLP_POLICY_API_BASE", value: "{{ tsj_bot_aw_dlp_policy_api_base | default('http://10.10.10.13:5601/api/0') }}" } - { key: "AW_DLP_POLICY_ACTOR", value: "{{ tsj_bot_aw_dlp_policy_actor | default('tsj-guardian-bot') }}" } - - { key: "AW_RUS_WORKTIME_HEAL_CMD", value: "{{ tsj_bot_aw_rus_worktime_heal_cmd | default(\"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n /usr/local/bin/aw-worktime-autoheal.sh && sudo -n systemctl reset-failed aw-worktime-ui-bridge.service && sudo -n systemctl start aw-worktime-ui-bridge.service'\") }}" } + - { key: "AW_RUS_WORKTIME_HEAL_CMD", value: "{{ tsj_bot_aw_rus_worktime_heal_cmd | default(\"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n /usr/local/bin/aw-worktime-autoheal-rust && sudo -n systemctl reset-failed aw-worktime-ui-bridge.service && sudo -n systemctl start aw-worktime-ui-bridge.service'\") }}" } - { key: "AW_RUS_DLP_HEAL_CMD", value: "{{ tsj_bot_aw_rus_dlp_heal_cmd | default(\"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new igor@10.10.10.13 'sudo -n systemctl restart activitywatch-server.service && (sudo -n systemctl start activitywatch-dlp-aggregator.service || true) && sudo -n /usr/local/bin/aw-health-check && sudo -n /usr/local/bin/dlp-health-check'\") }}" } - { key: "AW_RUS_CASE_API_BASE", value: "{{ tsj_bot_aw_rus_case_api_base | default('http://10.10.10.13:5602') }}" } - { key: "AW_RUS_HAYABUSA_ENABLED", value: "{{ tsj_bot_aw_rus_hayabusa_enabled | default('true') }}" } diff --git a/ansible/group_vars/proxmox-bot.example.yml b/ansible/group_vars/proxmox-bot.example.yml index cf8a1a9..6c9c124 100644 --- a/ansible/group_vars/proxmox-bot.example.yml +++ b/ansible/group_vars/proxmox-bot.example.yml @@ -49,7 +49,7 @@ tsj_bot_aw_rus_api_base: "http://10.10.10.13:5600/api/0" tsj_bot_aw_rus_worktime_base: "http://10.10.10.13:5610" tsj_bot_aw_dlp_policy_api_base: "http://10.10.10.13:5601/api/0" tsj_bot_aw_dlp_policy_actor: "tsj-guardian-bot" -tsj_bot_aw_rus_worktime_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal.sh && sudo -S systemctl reset-failed aw-worktime-ui-bridge.service && sudo -S systemctl start aw-worktime-ui-bridge.service'" +tsj_bot_aw_rus_worktime_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S /usr/local/bin/aw-worktime-autoheal-rust && sudo -S systemctl reset-failed aw-worktime-ui-bridge.service && sudo -S systemctl start aw-worktime-ui-bridge.service'" tsj_bot_aw_rus_dlp_heal_cmd: "sshpass -p 'CHANGE_ME' ssh -o PubkeyAuthentication=no -o StrictHostKeyChecking=no igor@10.10.10.13 'sudo -S systemctl restart activitywatch-server.service && sudo -S systemctl start activitywatch-dlp-aggregator.service || true && sudo -S /usr/local/bin/aw-health-check && sudo -S /usr/local/bin/dlp-health-check'" tsj_bot_aw_rus_case_api_base: "http://10.10.10.13:5602" tsj_bot_aw_rus_hayabusa_enabled: "true" diff --git a/ansible/provision_proxmox_ct_and_deploy_aw.yml b/ansible/provision_proxmox_ct_and_deploy_aw.yml index 1ac786e..c6318db 100644 --- a/ansible/provision_proxmox_ct_and_deploy_aw.yml +++ b/ansible/provision_proxmox_ct_and_deploy_aw.yml @@ -1,46 +1,14 @@ --- -- name: Создать один Proxmox CT и развернуть AWatch-rus +- name: Deprecated Proxmox CT bootstrap path hosts: proxmox gather_facts: false - vars: - proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap" - aw_bootstrap_files: - - install_aw_server.sh - - apply_webui_ru_patch.sh - - activitywatch-server.service - - aw-worktime-api.py - - aw-worktime-api.service - - aw-worktime-ui-bridge.py - - aw-worktime-ui-bridge.service - - aw-worktime-ui-bridge.timer - - aw-worktime-panel.js - - aw-server.env.example - - aw-ru-patch.js - - aw-sw-cleanup.js - - aw-host-groups.json - - settings/classes-worktime.json - - settings/views-default.json - tasks: - - name: Выполнить workflow создания одного CT - ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml - vars: - ct_id: "{{ proxmox_ct_id }}" - ct_hostname: "{{ proxmox_ct_hostname }}" - ct_storage: "{{ proxmox_ct_storage }}" - ct_template: "{{ proxmox_ct_template }}" - ct_rootfs_size: "{{ proxmox_ct_rootfs_size }}" - ct_cores: "{{ proxmox_ct_cores }}" - ct_memory: "{{ proxmox_ct_memory }}" - ct_swap: "{{ proxmox_ct_swap }}" - ct_bridge: "{{ proxmox_ct_bridge }}" - ct_ip: "{{ proxmox_ct_ip }}" - ct_gw: "{{ proxmox_ct_gw }}" - ct_vlan: "{{ proxmox_ct_vlan | default('') }}" - ct_nameserver: "{{ proxmox_ct_nameserver | default('') }}" - ct_searchdomain: "{{ proxmox_ct_searchdomain | default('') }}" - ct_password: "{{ proxmox_ct_password }}" - ct_unprivileged: "{{ proxmox_ct_unprivileged }}" - ct_onboot: "{{ proxmox_ct_onboot }}" - ct_features: "{{ proxmox_ct_features }}" + - name: Stop deprecated Python-era CT bootstrap + ansible.builtin.fail: + msg: >- + ansible/provision_proxmox_ct_and_deploy_aw.yml is deprecated because + its bootstrap path copied removed Python service entrypoints. Use the + existing DetMir AW server and ansible/deploy_aw_server.yml for the + Rust application deploy. Rebuild this CT bootstrap path separately + before using it for new infrastructure. diff --git a/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml b/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml index e0ff9bc..3819d24 100644 --- a/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +++ b/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml @@ -1,56 +1,14 @@ --- -- name: Создать матрицу Proxmox CT и развернуть AWatch-rus с RU patch +- name: Deprecated Proxmox CT matrix bootstrap path hosts: proxmox gather_facts: false - vars: - proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap" - aw_bootstrap_files: - - install_aw_server.sh - - apply_webui_ru_patch.sh - - activitywatch-server.service - - aw-worktime-api.py - - aw-worktime-api.service - - aw-worktime-ui-bridge.py - - aw-worktime-ui-bridge.service - - aw-worktime-ui-bridge.timer - - aw-worktime-panel.js - - aw-server.env.example - - aw-ru-patch.js - - aw-sw-cleanup.js - - aw-host-groups.json - - settings/classes-worktime.json - - settings/views-default.json - tasks: - - name: Проверить, что матрица CT задана - ansible.builtin.assert: - that: - - proxmox_ct_matrix is defined - - proxmox_ct_matrix | length > 0 - fail_msg: "Задайте proxmox_ct_matrix в group_vars/proxmox-matrix.yml" - - - name: Выполнить workflow создания для каждого CT - ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml - vars: - ct_id: "{{ item.id }}" - ct_hostname: "{{ item.hostname }}" - ct_storage: "{{ item.storage }}" - ct_template: "{{ item.template }}" - ct_rootfs_size: "{{ item.rootfs_size }}" - ct_cores: "{{ item.cores }}" - ct_memory: "{{ item.memory }}" - ct_swap: "{{ item.swap }}" - ct_bridge: "{{ item.bridge }}" - ct_ip: "{{ item.ip }}" - ct_gw: "{{ item.gw }}" - ct_vlan: "{{ item.vlan | default('') }}" - ct_nameserver: "{{ item.nameserver | default('') }}" - ct_searchdomain: "{{ item.searchdomain | default('') }}" - ct_password: "{{ item.password }}" - ct_unprivileged: "{{ item.unprivileged }}" - ct_onboot: "{{ item.onboot }}" - ct_features: "{{ item.features }}" - loop: "{{ proxmox_ct_matrix }}" - loop_control: - label: "ct={{ item.id }} host={{ item.hostname }} ip={{ item.ip }}" + - name: Stop deprecated Python-era CT matrix bootstrap + ansible.builtin.fail: + msg: >- + ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml is deprecated + because its bootstrap path copied removed Python service entrypoints. + Use the existing DetMir AW server and ansible/deploy_aw_server.yml for + the Rust application deploy. Rebuild this CT bootstrap path separately + before using it for new infrastructure. diff --git a/ansible/roles/dlp-case-management/tasks/main.yml b/ansible/roles/dlp-case-management/tasks/main.yml index 0b121a5..b7aba1a 100644 --- a/ansible/roles/dlp-case-management/tasks/main.yml +++ b/ansible/roles/dlp-case-management/tasks/main.yml @@ -1,55 +1,7 @@ --- -- name: Ensure DLP case management directory - ansible.builtin.file: - path: /opt/activitywatch/dlp-case-management - state: directory - owner: "{{ aw_server_user | default('activitywatch') }}" - group: "{{ aw_server_group | default('activitywatch') }}" - mode: "0755" - -- name: Copy DLP case management files - ansible.builtin.copy: - src: "{{ playbook_dir }}/../aw-server/dlp-case-management/{{ item }}" - dest: "/opt/activitywatch/dlp-case-management/{{ item }}" - owner: "{{ aw_server_user | default('activitywatch') }}" - group: "{{ aw_server_group | default('activitywatch') }}" - mode: "0644" - loop: - - case_service.py - - case_schema.py - - case_storage.py - - evidence_chain.py - - requirements.txt - -- name: Make case service executable - ansible.builtin.file: - path: /opt/activitywatch/dlp-case-management/case_service.py - owner: "{{ aw_server_user | default('activitywatch') }}" - group: "{{ aw_server_group | default('activitywatch') }}" - mode: "0755" - -- name: Create venv for DLP case management - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-case-management/.venv - args: - creates: /opt/activitywatch/dlp-case-management/.venv/bin/python - -- name: Install Python dependencies for DLP case management - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-case-management/requirements.txt - virtualenv: /opt/activitywatch/dlp-case-management/.venv - -- name: Install systemd unit DLP case management - ansible.builtin.copy: - src: "{{ playbook_dir }}/../aw-server/dlp-case-management/case-service.service" - dest: /etc/systemd/system/aw-dlp-case-management.service - owner: root - group: root - mode: "0644" - -- name: Reload systemd and restart DLP case management - ansible.builtin.systemd: - name: aw-dlp-case-management.service - daemon_reload: true - enabled: true - state: restarted +- name: Deprecated role guard - DLP case management is Rust-only + ansible.builtin.fail: + msg: >- + ansible/roles/dlp-case-management is deprecated because it deployed the old + Python runtime. Use ansible/deploy_aw_server.yml, which installs + /usr/local/bin/aw-dlp-case-management-rust from adk-rust. diff --git a/ansible/roles/dlp-compliance/tasks/main.yml b/ansible/roles/dlp-compliance/tasks/main.yml index 3f681de..997d37f 100644 --- a/ansible/roles/dlp-compliance/tasks/main.yml +++ b/ansible/roles/dlp-compliance/tasks/main.yml @@ -1,54 +1,7 @@ --- -- name: Ensure DLP compliance directory tree - ansible.builtin.file: - path: "{{ item }}" - state: directory - owner: "{{ aw_server_user | default('activitywatch') }}" - group: "{{ aw_server_group | default('activitywatch') }}" - mode: "0755" - loop: - - /opt/activitywatch/dlp-compliance - - /opt/activitywatch/dlp-compliance/templates - - /opt/activitywatch/dlp-compliance/reports - -- name: Copy DLP compliance files - ansible.builtin.copy: - src: "{{ playbook_dir }}/../aw-server/dlp-compliance/{{ item.src }}" - dest: "{{ item.dest }}" - owner: "{{ aw_server_user | default('activitywatch') }}" - group: "{{ aw_server_group | default('activitywatch') }}" - mode: "{{ item.mode }}" - loop: - - { src: "report_generator.py", dest: "/opt/activitywatch/dlp-compliance/report_generator.py", mode: "0755" } - - { src: "compliance_scheduler.py", dest: "/opt/activitywatch/dlp-compliance/compliance_scheduler.py", mode: "0755" } - - { src: "requirements.txt", dest: "/opt/activitywatch/dlp-compliance/requirements.txt", mode: "0644" } - - { src: "templates/152-fz-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/152-fz-report.html", mode: "0644" } - - { src: "templates/pci-dss-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/pci-dss-report.html", mode: "0644" } - - { src: "report-scheduler.service", dest: "/etc/systemd/system/aw-dlp-report-scheduler.service", mode: "0644" } - - { src: "report-scheduler.timer", dest: "/etc/systemd/system/aw-dlp-report-scheduler.timer", mode: "0644" } - -- name: Create venv for DLP compliance - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-compliance/.venv - args: - creates: /opt/activitywatch/dlp-compliance/.venv/bin/python - -- name: Install Python dependencies for DLP compliance - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-compliance/requirements.txt - virtualenv: /opt/activitywatch/dlp-compliance/.venv - -- name: Reload systemd - ansible.builtin.systemd: - daemon_reload: true - -- name: Enable and start DLP compliance timer - ansible.builtin.systemd: - name: aw-dlp-report-scheduler.timer - enabled: true - state: restarted - -- name: Run DLP compliance report once - ansible.builtin.systemd: - name: aw-dlp-report-scheduler.service - state: started +- name: Deprecated role guard - DLP compliance is Rust-only + ansible.builtin.fail: + msg: >- + ansible/roles/dlp-compliance is deprecated because it deployed the old + Python runtime. Use ansible/deploy_aw_server.yml, which installs + /usr/local/bin/aw-dlp-compliance-rust from adk-rust. diff --git a/ansible/roles/dlp-integrations/tasks/main.yml b/ansible/roles/dlp-integrations/tasks/main.yml index fe0e340..915e150 100644 --- a/ansible/roles/dlp-integrations/tasks/main.yml +++ b/ansible/roles/dlp-integrations/tasks/main.yml @@ -1,194 +1,7 @@ --- -- name: Ensure DLP integrations directory - ansible.builtin.file: - path: /opt/activitywatch/dlp-integrations - state: directory - owner: "{{ aw_server_user | default('activitywatch') }}" - group: "{{ aw_server_group | default('activitywatch') }}" - mode: "0755" - -- name: Copy DLP integrations files - ansible.builtin.copy: - src: "{{ playbook_dir }}/../aw-server/dlp-integrations/{{ item }}" - dest: "/opt/activitywatch/dlp-integrations/{{ item }}" - owner: "{{ aw_server_user | default('activitywatch') }}" - group: "{{ aw_server_group | default('activitywatch') }}" - mode: "0644" - loop: - - cef_exporter.py - - syslog_forwarder.py - - webhook_sender.py - - cef-config.yaml - - syslog-forwarder-config.yaml - - webhook-config.yaml - - requirements.txt - -- name: Make DLP integrations scripts executable - ansible.builtin.file: - path: "/opt/activitywatch/dlp-integrations/{{ item }}" - owner: "{{ aw_server_user | default('activitywatch') }}" - group: "{{ aw_server_group | default('activitywatch') }}" - mode: "0755" - loop: - - cef_exporter.py - - syslog_forwarder.py - - webhook_sender.py - -- name: Create venv for DLP integrations - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-integrations/.venv - args: - creates: /opt/activitywatch/dlp-integrations/.venv/bin/python - -- name: Install Python dependencies for DLP integrations - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-integrations/requirements.txt - virtualenv: /opt/activitywatch/dlp-integrations/.venv - -- name: Ensure integrations state directory - ansible.builtin.file: - path: /var/lib/activitywatch/dlp-integrations - state: directory - owner: "{{ aw_server_user | default('activitywatch') }}" - group: "{{ aw_server_group | default('activitywatch') }}" - mode: "0755" - -- name: Install systemd units/timers for DLP integrations - ansible.builtin.copy: - src: "{{ playbook_dir }}/../aw-server/dlp-integrations/{{ item.src }}" - dest: "{{ item.dest }}" - owner: root - group: root - mode: "0644" - loop: - - { src: "cef-exporter.service", dest: "/etc/systemd/system/aw-dlp-cef-exporter.service" } - - { src: "cef-exporter.timer", dest: "/etc/systemd/system/aw-dlp-cef-exporter.timer" } - - { src: "syslog-forwarder.service", dest: "/etc/systemd/system/aw-dlp-syslog-forwarder.service" } - - { src: "syslog-forwarder.timer", dest: "/etc/systemd/system/aw-dlp-syslog-forwarder.timer" } - - { src: "webhook-sender.service", dest: "/etc/systemd/system/aw-dlp-webhook-sender.service" } - - { src: "webhook-sender.timer", dest: "/etc/systemd/system/aw-dlp-webhook-sender.timer" } - -- name: Check local Rust CEF exporter - ansible.builtin.stat: - path: "{{ playbook_dir }}/../adk-rust/target/release/dlp-cef-exporter" - delegate_to: localhost - register: dlp_cef_exporter_rust_binary - become: false - -- name: Install Rust CEF exporter - ansible.builtin.copy: - src: "{{ playbook_dir }}/../adk-rust/target/release/dlp-cef-exporter" - dest: /usr/local/bin/dlp-cef-exporter-rust - owner: root - group: root - mode: "0755" - when: dlp_cef_exporter_rust_binary.stat.exists | default(false) - -- name: Ensure CEF exporter drop-in directory - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-cef-exporter.service.d - state: directory - owner: root - group: root - mode: "0755" - when: dlp_cef_exporter_rust_binary.stat.exists | default(false) - -- name: Switch CEF exporter to Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-cef-exporter.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/dlp-cef-exporter-rust - when: dlp_cef_exporter_rust_binary.stat.exists | default(false) - -- name: Check local Rust syslog forwarder - ansible.builtin.stat: - path: "{{ playbook_dir }}/../adk-rust/target/release/dlp-syslog-forwarder" - delegate_to: localhost - register: dlp_syslog_forwarder_rust_binary - become: false - -- name: Install Rust syslog forwarder - ansible.builtin.copy: - src: "{{ playbook_dir }}/../adk-rust/target/release/dlp-syslog-forwarder" - dest: /usr/local/bin/dlp-syslog-forwarder-rust - owner: root - group: root - mode: "0755" - when: dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - -- name: Ensure syslog forwarder drop-in directory - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d - state: directory - owner: root - group: root - mode: "0755" - when: dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - -- name: Switch syslog forwarder to Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/dlp-syslog-forwarder-rust - when: dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - -- name: Check local Rust webhook sender - ansible.builtin.stat: - path: "{{ playbook_dir }}/../adk-rust/target/release/dlp-webhook-sender" - delegate_to: localhost - register: dlp_webhook_sender_rust_binary - become: false - -- name: Install Rust webhook sender - ansible.builtin.copy: - src: "{{ playbook_dir }}/../adk-rust/target/release/dlp-webhook-sender" - dest: /usr/local/bin/dlp-webhook-sender-rust - owner: root - group: root - mode: "0755" - when: dlp_webhook_sender_rust_binary.stat.exists | default(false) - -- name: Ensure webhook sender drop-in directory - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-webhook-sender.service.d - state: directory - owner: root - group: root - mode: "0755" - when: dlp_webhook_sender_rust_binary.stat.exists | default(false) - -- name: Switch webhook sender to Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-webhook-sender.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/dlp-webhook-sender-rust - when: dlp_webhook_sender_rust_binary.stat.exists | default(false) - -- name: Reload systemd - ansible.builtin.systemd: - daemon_reload: true - -- name: Enable and restart DLP integration timers - ansible.builtin.systemd: - name: "{{ item }}" - enabled: true - state: restarted - loop: - - aw-dlp-cef-exporter.timer - - aw-dlp-syslog-forwarder.timer - - aw-dlp-webhook-sender.timer +- name: Deprecated role guard - DLP integrations are Rust-only + ansible.builtin.fail: + msg: >- + ansible/roles/dlp-integrations is deprecated because it deployed the old + Python runtime. Use ansible/deploy_aw_server.yml, which installs the + Rust CEF, syslog, and webhook exporters from adk-rust. diff --git a/ansible/roles/dlp-policy-engine/tasks/main.yml b/ansible/roles/dlp-policy-engine/tasks/main.yml index ec4d99e..7dfa7d0 100644 --- a/ansible/roles/dlp-policy-engine/tasks/main.yml +++ b/ansible/roles/dlp-policy-engine/tasks/main.yml @@ -1,46 +1,7 @@ --- -- name: Ensure policy engine directory exists - become: true - ansible.builtin.file: - path: /opt/activitywatch/dlp-policy-engine - state: directory - owner: activitywatch - group: activitywatch - mode: "0755" - -- name: Deploy policy engine files - become: true - ansible.builtin.copy: - src: "{{ item.src }}" - dest: "{{ item.dest }}" - owner: activitywatch - group: activitywatch - mode: "{{ item.mode | default('0644') }}" - loop: - - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/policy_service.py", dest: "/opt/activitywatch/dlp-policy-engine/policy_service.py" } - - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/policy_schema.py", dest: "/opt/activitywatch/dlp-policy-engine/policy_schema.py" } - - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/policy_storage.py", dest: "/opt/activitywatch/dlp-policy-engine/policy_storage.py" } - - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/policy_distributor.py", dest: "/opt/activitywatch/dlp-policy-engine/policy_distributor.py" } - - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/requirements.txt", dest: "/opt/activitywatch/dlp-policy-engine/requirements.txt" } - - { src: "{{ playbook_dir }}/../aw-server/dlp-policy-engine/dlp-policy-engine.service", dest: "/etc/systemd/system/aw-dlp-policy-engine.service" } - -- name: Ensure policy engine venv exists - become: true - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-policy-engine/.venv - args: - creates: /opt/activitywatch/dlp-policy-engine/.venv/bin/python - -- name: Install python deps for policy engine venv - become: true - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-policy-engine/requirements.txt - virtualenv: /opt/activitywatch/dlp-policy-engine/.venv - -- name: Enable and restart policy engine - become: true - ansible.builtin.systemd: - daemon_reload: true - name: aw-dlp-policy-engine.service - enabled: true - state: restarted +- name: Deprecated role guard - DLP policy engine is Rust-only + ansible.builtin.fail: + msg: >- + ansible/roles/dlp-policy-engine is deprecated because it deployed the old + Python runtime. Use ansible/deploy_aw_server.yml, which installs + /usr/local/bin/aw-dlp-policy-engine-rust from adk-rust. diff --git a/aw-server/aw-dlp-influx-exporter.py b/aw-server/aw-dlp-influx-exporter.py deleted file mode 100644 index 85005a3..0000000 --- a/aw-server/aw-dlp-influx-exporter.py +++ /dev/null @@ -1,417 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import sys -import urllib.parse -import urllib.request -from datetime import UTC, datetime, timedelta - - -def _env_bool(name: str, default: bool = False) -> bool: - value = os.environ.get(name, "").strip().lower() - if not value: - return default - return value in {"1", "true", "yes", "on"} - - -AW_API_BASE = os.environ.get("AW_DLP_AW_API_BASE", "http://127.0.0.1:5600/api/0").strip().rstrip("/") -CASE_API_BASE = os.environ.get("AW_DLP_CASE_API_BASE", "http://127.0.0.1:5602/api/0/dlp/cases").strip().rstrip("/") -INFLUX_URL = os.environ.get("AW_DLP_INFLUX_URL", "").strip().rstrip("/") -INFLUX_ORG = os.environ.get("AW_DLP_INFLUX_ORG", "proxmox").strip() or "proxmox" -INFLUX_BUCKET = os.environ.get("AW_DLP_INFLUX_BUCKET", "aw_metrics").strip() or "aw_metrics" -INFLUX_TOKEN = os.environ.get("AW_DLP_INFLUX_TOKEN", "").strip() -INFLUX_ENABLED = _env_bool("AW_DLP_INFLUX_ENABLED", False) -HOSTS = [item.strip() for item in os.environ.get("AW_DLP_INFLUX_HOSTS", "SHARKON2025").split(",") if item.strip()] -LOOKBACK_DAYS = int(os.environ.get("AW_DLP_INFLUX_LOOKBACK_DAYS", "30") or "30") -EVENT_LIMIT = int(os.environ.get("AW_DLP_INFLUX_EVENT_LIMIT", "2000") or "2000") -CASE_LIMIT = int(os.environ.get("AW_DLP_CASE_LIMIT", "500") or "500") - - -def utc_now() -> datetime: - return datetime.now(tz=UTC) - - -def pts(value: str | None) -> datetime: - if not value: - return utc_now() - normalized = value.replace("Z", "+00:00") - parsed = datetime.fromisoformat(normalized) - if parsed.tzinfo is None: - return parsed.replace(tzinfo=UTC) - return parsed.astimezone(UTC) - - -def _escape_tag(value: object) -> str: - return ( - str(value or "") - .replace("\\", "\\\\") - .replace(" ", "\\ ") - .replace(",", "\\,") - .replace("=", "\\=") - ) - - -def _line(measurement: str, tags: dict[str, object], fields: dict[str, object], timestamp_ns: int) -> str: - tag_part = ",".join(f"{key}={_escape_tag(value)}" for key, value in sorted(tags.items()) if value not in (None, "")) - field_parts: list[str] = [] - for key, value in fields.items(): - if value is None: - continue - if isinstance(value, bool): - field_parts.append(f"{key}={'true' if value else 'false'}") - elif isinstance(value, int): - field_parts.append(f"{key}={value}i") - elif isinstance(value, float): - field_parts.append(f"{key}={value}") - else: - text = str(value).replace("\\", "\\\\").replace('"', '\\"') - field_parts.append(f'{key}="{text}"') - if not field_parts: - return "" - if tag_part: - return f"{measurement},{tag_part} {','.join(field_parts)} {timestamp_ns}" - return f"{measurement} {','.join(field_parts)} {timestamp_ns}" - - -def _timestamp_ns(dt: datetime) -> int: - return int(dt.astimezone(UTC).timestamp() * 1_000_000_000) - - -def _get_json(url: str) -> object: - req = urllib.request.Request(url, headers={"Accept": "application/json"}) - with urllib.request.urlopen(req, timeout=30) as response: - return json.loads(response.read().decode("utf-8")) - - -def fetch_bucket_events(bucket_id: str, start: datetime, end: datetime, limit: int) -> list[dict]: - query = urllib.parse.urlencode( - { - "start": start.astimezone(UTC).isoformat().replace("+00:00", "Z"), - "end": end.astimezone(UTC).isoformat().replace("+00:00", "Z"), - "limit": str(limit), - } - ) - url = f"{AW_API_BASE}/buckets/{urllib.parse.quote(bucket_id, safe='')}/events?{query}" - payload = _get_json(url) - return payload if isinstance(payload, list) else [] - - -def fetch_cases(host: str) -> list[dict]: - query = urllib.parse.urlencode({"host": host, "limit": str(CASE_LIMIT)}) - payload = _get_json(f"{CASE_API_BASE}?{query}") - return payload if isinstance(payload, list) else [] - - -def _s(value: object) -> str: - return str(value or "").strip() - - -def _first_nonempty(*values: object, default: str = "") -> str: - for value in values: - text = _s(value) - if text: - return text - return default - - -def normalize_incident(event: dict, default_host: str) -> dict[str, object]: - data = event.get("data") or {} - source_event = data.get("sourceEvent") or {} - source_data = source_event.get("data") or {} - nested = data.get("incident") or {} - - signal_type = _first_nonempty(data.get("signalType"), source_data.get("signalType"), default="unknown") - username = _first_nonempty( - data.get("username"), - source_data.get("username"), - source_data.get("owner"), - source_data.get("host"), - default="unknown", - ) - host = _first_nonempty(data.get("hostname"), data.get("host"), source_data.get("hostname"), default=default_host) - severity = _first_nonempty(data.get("severity"), nested.get("severity"), default="unknown") - action = _first_nonempty(data.get("action"), nested.get("verdict"), default="incident") - message = _first_nonempty( - data.get("message"), - source_data.get("documentName"), - source_data.get("documentNameOriginal"), - nested.get("comment"), - ) - return { - "host": host, - "signal_type": signal_type, - "username": username, - "severity": severity, - "action": action, - "message": message, - "rule_id": _first_nonempty(data.get("ruleId")), - "source": _first_nonempty(data.get("source"), source_data.get("source"), data.get("sourceBucket")), - "document_name": _first_nonempty(source_data.get("documentName"), source_data.get("documentNameOriginal")), - "printer_name": _first_nonempty(source_data.get("printerName")), - "incident_status": _first_nonempty(nested.get("status")), - "incident_verdict": _first_nonempty(nested.get("verdict")), - "regex_matches": len(data.get("regexMatches") or []), - "dictionary_matches": len(data.get("dictionaryMatches") or []), - "ocr_requested": bool(data.get("ocrRequested")), - } - - -def build_endpoint_lines(host: str, events: list[dict]) -> list[str]: - lines: list[str] = [] - for item in events: - data = item.get("data") or {} - signal_type = _first_nonempty(data.get("signalType"), default="unknown") - timestamp_ns = _timestamp_ns(pts(item.get("timestamp"))) - event_id = item.get("id") or f"{signal_type}-{timestamp_ns}" - username = _first_nonempty(data.get("username"), data.get("owner"), default="unknown") - if signal_type == "self_test": - lines.append( - _line( - "aw_dlp_endpoint_self_test", - { - "host": _first_nonempty(data.get("hostname"), default=host), - "event_id": event_id, - "username": username, - "policy_mode": _first_nonempty(data.get("policyMode"), default="unknown"), - "policy_source": _first_nonempty(data.get("policySource"), default="unknown"), - }, - { - "count": 1, - "queue_depth": int(data.get("queueDepth") or 0), - "events_enqueued": int(data.get("eventsEnqueued") or 0), - "events_flushed": int(data.get("eventsFlushed") or 0), - "send_failures": int(data.get("sendFailures") or 0), - "policy_enabled": bool(data.get("policyEnabled")), - }, - timestamp_ns, - ) - ) - continue - lines.append( - _line( - "aw_dlp_signal", - { - "host": _first_nonempty(data.get("hostname"), default=host), - "event_id": event_id, - "signal_type": signal_type, - "username": username, - "source": _first_nonempty(data.get("source"), default="unknown"), - }, - { - "count": 1, - "document_name": _first_nonempty(data.get("documentName"), data.get("documentNameOriginal")), - "printer_name": _first_nonempty(data.get("printerName")), - "owner": _first_nonempty(data.get("owner")), - "session_id": int(data.get("sessionId") or 0), - }, - timestamp_ns, - ) - ) - return [line for line in lines if line] - - -def build_incident_lines(host: str, events: list[dict]) -> list[str]: - lines: list[str] = [] - for item in events: - normalized = normalize_incident(item, host) - timestamp_ns = _timestamp_ns(pts(item.get("timestamp"))) - event_id = item.get("id") or f"incident-{timestamp_ns}" - lines.append( - _line( - "aw_dlp_incident", - { - "host": normalized["host"], - "event_id": event_id, - "signal_type": normalized["signal_type"], - "severity": normalized["severity"], - "action": normalized["action"], - "username": normalized["username"], - "source": normalized["source"], - }, - { - "count": 1, - "message": normalized["message"], - "rule_id": normalized["rule_id"], - "document_name": normalized["document_name"], - "printer_name": normalized["printer_name"], - "incident_status": normalized["incident_status"], - "incident_verdict": normalized["incident_verdict"], - "regex_matches": int(normalized["regex_matches"]), - "dictionary_matches": int(normalized["dictionary_matches"]), - "ocr_requested": bool(normalized["ocr_requested"]), - }, - timestamp_ns, - ) - ) - return [line for line in lines if line] - - -def build_review_lines(host: str, events: list[dict]) -> list[str]: - lines: list[str] = [] - for item in events: - data = item.get("data") or {} - review = data.get("review") or {} - source_data = (data.get("sourceEvent") or {}).get("data") or {} - timestamp_ns = _timestamp_ns(pts(item.get("timestamp"))) - review_id = _first_nonempty(review.get("reviewId"), default=f"review-{timestamp_ns}") - lines.append( - _line( - "aw_dlp_review", - { - "host": _first_nonempty(data.get("host"), source_data.get("hostname"), default=host), - "review_id": review_id, - "verdict": _first_nonempty(review.get("verdict"), default="unknown"), - "signal_type": _first_nonempty(source_data.get("signalType"), default="unknown"), - "username": _first_nonempty(source_data.get("username"), source_data.get("owner"), default="unknown"), - }, - { - "count": 1, - "archived": bool(review.get("archived")), - "comment": _first_nonempty(review.get("comment")), - "category": _first_nonempty(review.get("category")), - "document_name": _first_nonempty(source_data.get("documentName"), source_data.get("documentNameOriginal")), - "printer_name": _first_nonempty(source_data.get("printerName")), - }, - timestamp_ns, - ) - ) - return [line for line in lines if line] - - -def build_rule_lines(host: str, events: list[dict]) -> list[str]: - lines: list[str] = [] - for item in events: - data = item.get("data") or {} - match = data.get("match") or {} - timestamp_ns = _timestamp_ns(pts(item.get("timestamp"))) - rule_id = _first_nonempty(data.get("ruleId"), default=f"rule-{timestamp_ns}") - lines.append( - _line( - "aw_dlp_rule", - { - "host": _first_nonempty(data.get("host"), match.get("hostname"), default=host), - "rule_id": rule_id, - "action": _first_nonempty(data.get("action"), default="unknown"), - "signal_type": _first_nonempty(match.get("signalType"), default="unknown"), - "username": _first_nonempty(match.get("username"), match.get("owner"), default="unknown"), - "enabled": "true" if bool(data.get("enabled", True)) else "false", - }, - { - "count": 1, - "category": _first_nonempty(data.get("category")), - "comment": _first_nonempty(data.get("comment")), - "document_name": _first_nonempty(match.get("documentName")), - "printer_name": _first_nonempty(match.get("printerName")), - }, - timestamp_ns, - ) - ) - return [line for line in lines if line] - - -def build_fileops_lines(host: str, events: list[dict]) -> list[str]: - lines: list[str] = [] - for item in events: - data = item.get("data") or {} - signal_type = _first_nonempty(data.get("signalType"), default="unknown") - if signal_type != "collector_health": - continue - timestamp_ns = _timestamp_ns(pts(item.get("timestamp"))) - event_id = item.get("id") or f"fileops-{timestamp_ns}" - lines.append( - _line( - "aw_dlp_fileops_health", - { - "host": _first_nonempty(data.get("hostname"), default=host), - "event_id": event_id, - "username": _first_nonempty(data.get("username"), default="unknown"), - }, - { - "count": 1, - "queue_depth": int(data.get("queueDepth") or 0), - "events_enqueued": int(data.get("eventsEnqueued") or 0), - "events_flushed": int(data.get("eventsFlushed") or 0), - "send_failures": int(data.get("sendFailures") or 0), - "session_id": int(data.get("sessionId") or 0), - }, - timestamp_ns, - ) - ) - return [line for line in lines if line] - - -def build_case_lines(host: str, cases: list[dict]) -> list[str]: - lines: list[str] = [] - for item in cases: - timestamp_ns = _timestamp_ns(pts(item.get("updated_at") or item.get("created_at"))) - evidence = item.get("evidence") or {} - evidence_items = evidence.get("items") or [] - lines.append( - _line( - "aw_dlp_case", - { - "host": _first_nonempty(item.get("host"), default=host), - "case_id": item.get("id"), - "status": _first_nonempty(item.get("status"), default="unknown"), - "severity": _first_nonempty(item.get("severity"), default="unknown"), - "assignee": _first_nonempty(item.get("assignee"), default="unassigned"), - }, - { - "count": 1, - "title": _first_nonempty(item.get("title")), - "incident_id": _first_nonempty(item.get("incident_id")), - "has_forensics": item.get("forensics") is not None, - "evidence_items": len(evidence_items), - "chain_length": int(evidence.get("chain_length") or 0), - }, - timestamp_ns, - ) - ) - return [line for line in lines if line] - - -def build_lines_for_host(host: str, start: datetime, end: datetime) -> list[str]: - lines: list[str] = [] - lines.extend(build_endpoint_lines(host, fetch_bucket_events(f"aw-dlp-endpoint-signals_{host}", start, end, EVENT_LIMIT))) - lines.extend(build_incident_lines(host, fetch_bucket_events(f"aw-dlp-incidents_{host}", start, end, EVENT_LIMIT))) - lines.extend(build_review_lines(host, fetch_bucket_events(f"aw-dlp-review_{host}", start, end, EVENT_LIMIT))) - lines.extend(build_rule_lines(host, fetch_bucket_events(f"aw-dlp-rules_{host}", start, end, EVENT_LIMIT))) - lines.extend(build_fileops_lines(host, fetch_bucket_events(f"aw-file-operations_{host}", start, end, EVENT_LIMIT))) - lines.extend(build_case_lines(host, fetch_cases(host))) - return [line for line in lines if line] - - -def write_lines(lines: list[str]) -> int: - if not lines: - return 0 - if not INFLUX_URL or not INFLUX_TOKEN: - raise RuntimeError("InfluxDB destination is not configured") - payload = ("\n".join(lines) + "\n").encode("utf-8") - req = urllib.request.Request( - f"{INFLUX_URL}/api/v2/write?org={INFLUX_ORG}&bucket={INFLUX_BUCKET}&precision=ns", - data=payload, - method="POST", - headers={"Authorization": f"Token {INFLUX_TOKEN}", "Content-Type": "text/plain; charset=utf-8"}, - ) - with urllib.request.urlopen(req, timeout=30) as response: - if response.status not in {200, 204}: - raise RuntimeError(f"InfluxDB write failed with status={response.status}") - return len(lines) - - -def main() -> int: - if not INFLUX_ENABLED: - print("[aw-dlp-influx-exporter] disabled by AW_DLP_INFLUX_ENABLED", file=sys.stderr) - return 0 - end = utc_now() - start = end - timedelta(days=LOOKBACK_DAYS) - lines: list[str] = [] - for host in HOSTS: - lines.extend(build_lines_for_host(host, start, end)) - written = write_lines(lines) - print(f"[aw-dlp-influx-exporter] wrote {written} points to {INFLUX_BUCKET}", file=sys.stderr) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/aw-server/aw-dlp-influx-exporter.service b/aw-server/aw-dlp-influx-exporter.service index 3de628b..7d354c3 100644 --- a/aw-server/aw-dlp-influx-exporter.service +++ b/aw-server/aw-dlp-influx-exporter.service @@ -6,7 +6,7 @@ Wants=network-online.target aw-dlp-case-management.service [Service] Type=oneshot EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/bin/python3 /usr/local/bin/aw-dlp-influx-exporter.py +ExecStart=/usr/local/bin/aw-dlp-influx-exporter-rust User=activitywatch Group=activitywatch StandardOutput=journal diff --git a/aw-server/aw-health-check-wrapper.sh b/aw-server/aw-health-check-wrapper.sh index 98e6321..8d368e2 100644 --- a/aw-server/aw-health-check-wrapper.sh +++ b/aw-server/aw-health-check-wrapper.sh @@ -5,4 +5,5 @@ if [[ -x /usr/local/bin/aw-health-check-rust ]]; then exec /usr/local/bin/aw-health-check-rust "$@" fi -exec /opt/activitywatch/aw-rus-ops/health-check.sh "$@" +echo "ERROR: /usr/local/bin/aw-health-check-rust is missing or not executable" >&2 +exit 127 diff --git a/aw-server/aw-rus-healthd.py b/aw-server/aw-rus-healthd.py deleted file mode 100644 index 07d0538..0000000 --- a/aw-server/aw-rus-healthd.py +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import os -import socket -import subprocess -import sys -import tempfile -import time -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import Any -from urllib import request - - -ENV_FILE = Path("/etc/activitywatch/aw-server.env") - - -def load_env_file(path: Path) -> None: - if not path.exists(): - return - try: - lines = path.read_text(encoding="utf-8").splitlines() - except PermissionError: - # systemd EnvironmentFile has already injected the variables for service runs. - return - for raw_line in lines: - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip("'").strip('"') - os.environ.setdefault(key, value) - - -def env(name: str, default: str) -> str: - value = os.environ.get(name) - return value if value not in (None, "") else default - - -def now_utc() -> datetime: - return datetime.now(UTC) - - -def parse_ts(value: str | None) -> datetime | None: - if not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) - except ValueError: - return None - - -def age_seconds(ts: datetime | None, now: datetime) -> int | None: - if ts is None: - return None - return max(0, int((now - ts).total_seconds())) - - -def http_json(url: str, timeout: int = 20, attempts: int = 2, backoff_seconds: float = 0.5) -> Any: - last_exc: Exception | None = None - for attempt in range(max(1, attempts)): - try: - with request.urlopen(url, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")) - except Exception as exc: - last_exc = exc - if attempt + 1 >= max(1, attempts): - break - time.sleep(backoff_seconds * (2**attempt)) - assert last_exc is not None - raise last_exc - - -def run_command(cmd: list[str]) -> tuple[int, str]: - proc = subprocess.run( - cmd, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - return proc.returncode, proc.stdout.strip() - - -def tcp_connect(host: str, port: int, timeout: float) -> tuple[bool, str]: - try: - with socket.create_connection((host, port), timeout=timeout): - return True, "connected" - except OSError as exc: - return False, str(exc) - - -@dataclass -class CheckResult: - name: str - status: str - summary: str - details: dict[str, Any] - - -class Report: - def __init__(self) -> None: - self.results: list[CheckResult] = [] - - def add(self, name: str, status: str, summary: str, **details: Any) -> None: - self.results.append(CheckResult(name=name, status=status, summary=summary, details=details)) - - @property - def ok(self) -> bool: - return not any(item.status == "fail" for item in self.results) - - def as_dict(self) -> dict[str, Any]: - counts = {"ok": 0, "warn": 0, "fail": 0} - for item in self.results: - counts[item.status] = counts.get(item.status, 0) + 1 - return { - "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), - "ok": self.ok, - "counts": counts, - "results": [ - { - "name": item.name, - "status": item.status, - "summary": item.summary, - "details": item.details, - } - for item in self.results - ], - } - - def render_text(self) -> str: - icon = {"ok": "OK", "warn": "WARN", "fail": "FAIL"} - lines = ["=== AW-RUS Health ===", f"Timestamp: {now_utc().isoformat().replace('+00:00', 'Z')}", ""] - for item in self.results: - lines.append(f"[{icon.get(item.status, item.status.upper())}] {item.name}: {item.summary}") - lines.append("") - payload = self.as_dict() - lines.append( - "Counts: ok={ok} warn={warn} fail={fail}".format( - ok=payload["counts"]["ok"], - warn=payload["counts"]["warn"], - fail=payload["counts"]["fail"], - ) - ) - lines.append(f"Overall: {'OK' if payload['ok'] else 'FAIL'}") - return "\n".join(lines) - - -def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None: - events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=1", timeout=25) - if isinstance(events, list) and events: - events = [item for item in events if isinstance(item, dict)] - if not events: - return None - events.sort(key=lambda item: item.get("timestamp") or "", reverse=True) - return events[0] - return None - - -def bucket_metadata_ts(buckets: dict[str, Any], bucket_id: str) -> datetime | None: - bucket = buckets.get(bucket_id) if isinstance(buckets, dict) else None - if not isinstance(bucket, dict): - return None - metadata = bucket.get("metadata") - if not isinstance(metadata, dict): - return None - return parse_ts(metadata.get("end")) - - -def host_activity_from_worktime(event: dict[str, Any] | None, max_age_seconds: int) -> dict[str, Any]: - now = now_utc() - if not event: - return {"fresh": False, "active": False, "age_seconds": None, "timestamp": None} - ts = parse_ts(event.get("timestamp")) - age = age_seconds(ts, now) - data = event.get("data") or {} - is_fresh = age is not None and age <= max_age_seconds - is_active = bool(is_fresh and data.get("active")) - return { - "fresh": bool(is_fresh), - "active": bool(is_active), - "age_seconds": age, - "timestamp": event.get("timestamp"), - "data": data, - } - - -def bucket_health( - api_base: str, - bucket_id: str, - max_age_seconds: int, - missing_status: str, - stale_status: str, -) -> tuple[str, str, dict[str, Any]]: - try: - event = latest_bucket_event(api_base, bucket_id) - except Exception as exc: - return "fail", f"bucket query failed: {exc}", {"bucket": bucket_id} - - if not event: - return missing_status, "no events", {"bucket": bucket_id} - - ts = parse_ts(event.get("timestamp")) - age = age_seconds(ts, now_utc()) - details = {"bucket": bucket_id, "timestamp": event.get("timestamp"), "age_seconds": age} - if age is None: - return "warn", "timestamp parse failed", details - if age > max_age_seconds: - return stale_status, f"stale ({age}s)", details - return "ok", f"fresh ({age}s)", details - - -def bucket_timestamp_health( - api_base: str, - buckets: dict[str, Any], - bucket_id: str, - max_age_seconds: int, - missing_status: str, - stale_status: str, -) -> tuple[str, str, dict[str, Any]]: - metadata_ts = bucket_metadata_ts(buckets, bucket_id) - if metadata_ts is not None: - age = age_seconds(metadata_ts, now_utc()) - timestamp = metadata_ts.isoformat().replace("+00:00", "Z") - details = { - "bucket": bucket_id, - "timestamp": timestamp, - "age_seconds": age, - "timestamp_source": "bucket_metadata.end", - } - if age is None: - return "warn", "timestamp parse failed", details - if age > max_age_seconds: - return stale_status, f"stale ({age}s)", details - return "ok", f"fresh ({age}s)", details - - return bucket_health(api_base, bucket_id, max_age_seconds, missing_status, stale_status) - - -def guard_bucket_health( - api_base: str, - host: str, - max_age_seconds: int, - required: bool, -) -> tuple[str, str, dict[str, Any]]: - bucket_id = f"aw-rus-collector-guard_{host}" - try: - event = latest_bucket_event(api_base, bucket_id) - except Exception as exc: - status = "fail" if required else "warn" - return status, f"guard bucket query failed: {exc}", {"bucket": bucket_id} - - if not event: - status = "fail" if required else "warn" - return status, "no guard heartbeat", {"bucket": bucket_id, "required": required} - - ts = parse_ts(event.get("timestamp")) - age = age_seconds(ts, now_utc()) - data = event.get("data") if isinstance(event.get("data"), dict) else {} - guard_status = str(data.get("status") or "unknown").lower() - details = { - "bucket": bucket_id, - "timestamp": event.get("timestamp"), - "age_seconds": age, - "required": required, - "guard_status": guard_status, - "mode": data.get("mode"), - "live_session_count": data.get("liveSessionCount"), - "problems": data.get("problems", []), - "actions": data.get("actions", []), - } - - stale_status = "fail" if required else "warn" - if age is None: - return "warn", "guard timestamp parse failed", details - if age > max_age_seconds: - return stale_status, f"guard stale ({age}s)", details - if guard_status in ("fail", "error"): - return "fail" if required else "warn", f"guard reports {guard_status}", details - if guard_status == "warn": - return "warn", "guard reports warn", details - return "ok", f"guard fresh ({age}s)", details - - -def latest_validation_report(validation_dir: Path) -> Path | None: - candidates = sorted( - (path for path in validation_dir.glob("*-aw_validate_ansible.json") if path.is_file()), - key=lambda item: item.stat().st_mtime, - reverse=True, - ) - return candidates[0] if candidates else None - - -def write_atomic(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: - handle.write(content) - tmp_name = handle.name - os.replace(tmp_name, path) - - -def chmod_if_possible(path: Path, mode: int) -> None: - try: - path.chmod(mode) - except OSError: - pass - - -def check_wrapper( - report: Report, - name: str, - cmd: list[str], - json_mode: bool = False, - failure_status: str = "fail", -) -> None: - if not Path(cmd[0]).exists(): - report.add(name, "warn", "binary missing", command=cmd) - return - rc, output = run_command(cmd) - details: dict[str, Any] = {"command": cmd, "returncode": rc} - if json_mode: - try: - details["payload"] = json.loads(output) if output else {} - except json.JSONDecodeError: - details["raw_output"] = output - report.add(name, "fail", "invalid JSON output", **details) - return - else: - details["output"] = output - report.add(name, "ok" if rc == 0 else failure_status, "passed" if rc == 0 else "failed", **details) - - -def main() -> int: - load_env_file(ENV_FILE) - - parser = argparse.ArgumentParser(description="Unified AW-RUS health orchestrator") - parser.add_argument("--aw-server", default=env("AW_SERVER_URL", "http://127.0.0.1:5600")) - parser.add_argument( - "--worktime-api", - default=env("AW_RUS_HEALTH_WORKTIME_API", env("AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610")), - ) - parser.add_argument("--rdp-host", default=env("AW_MONITORED_WINDOWS_HOST", "192.168.100.18")) - parser.add_argument("--rdp-hostname", default=env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025")) - parser.add_argument("--state-dir", default=env("AW_RUS_HEALTH_STATE_DIR", "/var/lib/activitywatch/health")) - parser.add_argument("--validation-dir", default=env("AW_RUS_HEALTH_VALIDATION_DIR", "/var/lib/activitywatch/health/windows-validation")) - parser.add_argument("--session-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", "900"))) - parser.add_argument("--interactive-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", "900"))) - parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "86400"))) - parser.add_argument("--guard-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS", "300"))) - parser.add_argument("--guard-required", action="store_true", default=env("AW_RUS_HEALTH_GUARD_REQUIRED", "0").lower() in ("1", "true", "yes")) - parser.add_argument("--validation-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", "259200"))) - parser.add_argument("--tcp-timeout-seconds", type=float, default=float(env("AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", "3"))) - parser.add_argument("--json", action="store_true") - args = parser.parse_args() - - report = Report() - aw_api_base = args.aw_server.rstrip("/") - if not aw_api_base.endswith("/api/0"): - aw_api_base = aw_api_base.rstrip("/") + "/api/0" - - check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"], failure_status="warn") - check_wrapper( - report, - "wrapper:dlp-health-check", - ["/usr/local/bin/dlp-health-check", "--json"], - json_mode=True, - failure_status="warn", - ) - - try: - info = http_json(f"{aw_api_base}/info") - report.add("http:aw-server", "ok", "activitywatch API responded", version=info.get("version")) - except Exception as exc: - report.add("http:aw-server", "fail", f"activitywatch API failed: {exc}", url=f"{aw_api_base}/info") - - try: - payload = http_json(args.worktime_api.rstrip("/") + "/health") - report.add("http:worktime-api", "ok", "worktime API responded", payload=payload if isinstance(payload, dict) else {}) - except Exception as exc: - report.add("http:worktime-api", "fail", f"worktime API failed: {exc}", url=args.worktime_api) - - for port, label in ((5985, "winrm"), (3389, "rdp")): - ok, message = tcp_connect(args.rdp_host, port, args.tcp_timeout_seconds) - report.add(f"tcp:{label}", "ok" if ok else "fail", message if ok else f"unreachable: {message}", host=args.rdp_host, port=port) - - try: - buckets = http_json(f"{aw_api_base}/buckets") - if not isinstance(buckets, dict): - raise RuntimeError("bucket index is not a dict") - report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets)) - except Exception as exc: - report.add("aw:buckets-index", "fail", f"failed to load bucket index: {exc}") - buckets = {} - - host = args.rdp_hostname - guard_status, guard_summary, guard_details = guard_bucket_health( - aw_api_base, - host, - args.guard_max_age_seconds, - args.guard_required, - ) - report.add("bucket:collector-guard", guard_status, guard_summary, **guard_details) - - worktime_bucket = f"aw-worktime-sessions_{host}" - worktime_event = None - if buckets: - try: - worktime_event = latest_bucket_event(aw_api_base, worktime_bucket) - except Exception: - worktime_event = None - activity = host_activity_from_worktime(worktime_event, args.session_max_age_seconds) - if worktime_event: - status, summary, details = bucket_health( - aw_api_base, - worktime_bucket, - args.session_max_age_seconds, - missing_status="fail", - stale_status="fail", - ) - details["host_activity"] = activity - report.add("bucket:worktime-sessions", status, summary, **details) - else: - report.add("bucket:worktime-sessions", "fail", "no events", bucket=worktime_bucket, host_activity=activity) - - interactive_required = bool(activity["active"]) - for bucket_name, label in ( - ("aw-watcher-afk", "bucket:afk"), - ("aw-watcher-window", "bucket:window"), - ("aw-dlp-endpoint-signals", "bucket:endpoint-signals"), - ): - status, summary, details = bucket_health( - aw_api_base, - f"{bucket_name}_{host}", - args.interactive_max_age_seconds, - missing_status="fail" if interactive_required else "warn", - stale_status="fail" if interactive_required else "warn", - ) - details["interactive_required"] = interactive_required - details["host_activity"] = activity - if not interactive_required and status != "ok": - details["inactive_summary"] = summary - status = "ok" - summary = "inactive: no active interactive users" - report.add(label, status, summary, **details) - - session_status, session_summary, session_details = bucket_timestamp_health( - aw_api_base, - buckets, - f"aw-session-events_{host}", - args.session_events_max_age_seconds, - missing_status="fail", - stale_status="warn", - ) - if session_status == "warn" and session_details.get("age_seconds") is not None: - session_status = "ok" - session_summary = f"event-driven ({session_details['age_seconds']}s since last logon marker)" - report.add("bucket:session-events", session_status, session_summary, **session_details) - - validation_dir = Path(args.validation_dir) - validation_report = latest_validation_report(validation_dir) - if validation_report is None: - report.add("validation:windows", "warn", "no validation report snapshot", directory=str(validation_dir)) - else: - try: - payload = json.loads(validation_report.read_text(encoding="utf-8-sig")) - age = age_seconds(datetime.fromtimestamp(validation_report.stat().st_mtime, tz=UTC), now_utc()) - if age is not None and age > args.validation_max_age_seconds: - report.add( - "validation:windows", - "warn", - f"validation snapshot is stale ({age}s)", - path=str(validation_report), - overall_ok=payload.get("overallOk"), - failed_sections=payload.get("summary", {}).get("failedSections", []), - ) - elif payload.get("overallOk") is True: - report.add("validation:windows", "ok", "validation snapshot OK", path=str(validation_report), age_seconds=age) - else: - report.add( - "validation:windows", - "fail", - "validation snapshot reports failure", - path=str(validation_report), - age_seconds=age, - failed_sections=payload.get("summary", {}).get("failedSections", []), - ) - except Exception as exc: - report.add("validation:windows", "fail", f"invalid validation snapshot: {exc}", path=str(validation_report)) - - payload = report.as_dict() - state_dir = Path(args.state_dir) - health_json_path = state_dir / "aw-rus-health.json" - health_txt_path = state_dir / "aw-rus-health.txt" - write_atomic(health_json_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") - write_atomic(health_txt_path, report.render_text() + "\n") - chmod_if_possible(health_json_path, 0o644) - chmod_if_possible(health_txt_path, 0o644) - - if args.json: - print(json.dumps(payload, ensure_ascii=False, indent=2)) - else: - print(report.render_text()) - return 0 if payload["ok"] else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/aw-server/aw-rus-healthd.service b/aw-server/aw-rus-healthd.service index 015a906..b5ba629 100644 --- a/aw-server/aw-rus-healthd.service +++ b/aw-server/aw-rus-healthd.service @@ -6,7 +6,7 @@ Wants=network-online.target activitywatch-server.service aw-worktime-api.service [Service] Type=oneshot EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/bin/python3 /usr/local/bin/aw-rus-healthd.py +ExecStart=/usr/local/bin/aw-rus-healthd-rust User=root Group=root StandardOutput=journal diff --git a/aw-server/aw-slo-monitor.py b/aw-server/aw-slo-monitor.py deleted file mode 100644 index 854d6ff..0000000 --- a/aw-server/aw-slo-monitor.py +++ /dev/null @@ -1,465 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import tempfile -import time -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Any -from urllib import request - - -ENV_FILE = Path("/etc/activitywatch/aw-server.env") -DEFAULT_STATE_DIR = Path("/var/lib/activitywatch/slo") -DEFAULT_HEALTHD_CMD = "/usr/local/bin/aw-rus-healthd.py --json" -DEFAULT_HEALTHD_STATE_FILE = Path("/var/lib/activitywatch/health/aw-rus-health.json") - - -def load_env_file(path: Path) -> None: - if not path.exists(): - return - try: - lines = path.read_text(encoding="utf-8").splitlines() - except PermissionError: - # systemd EnvironmentFile has already loaded the variables for the service. - return - for raw_line in lines: - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - os.environ.setdefault(key.strip(), value.strip().strip("'").strip('"')) - - -def env(name: str, default: str) -> str: - value = os.environ.get(name) - return value if value not in (None, "") else default - - -def now_utc() -> datetime: - return datetime.now(UTC) - - -def parse_ts(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) - - -def iso(dt: datetime) -> str: - return dt.astimezone(UTC).isoformat().replace("+00:00", "Z") - - -def write_atomic(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: - handle.write(content) - tmp_name = handle.name - os.replace(tmp_name, path) - - -def chmod_if_possible(path: Path, mode: int) -> None: - try: - path.chmod(mode) - except OSError: - pass - - -def chown_like_parent_if_possible(path: Path) -> None: - try: - parent_stat = path.parent.stat() - os.chown(path, parent_stat.st_uid, parent_stat.st_gid) - except OSError: - pass - - -def fetch_url(url: str, timeout_seconds: float, *, accept: str | None = None, attempts: int = 1) -> dict[str, Any]: - started = now_utc() - last_error = "" - for attempt in range(1, max(1, attempts) + 1): - try: - req = request.Request(url, headers={"Accept": accept}) if accept else url - with request.urlopen(req, timeout=timeout_seconds) as resp: - body = resp.read() - status = int(resp.status) - content_type = resp.headers.get("Content-Type", "") - ok = 200 <= status < 300 - return { - "ok": ok, - "status": status, - "body_bytes": len(body), - "content_type": content_type, - "body": body, - "attempts": attempt, - "latency_ms": int((now_utc() - started).total_seconds() * 1000), - "url": url, - } - except Exception as exc: - last_error = str(exc) - if attempt < max(1, attempts): - time.sleep(1) - return { - "ok": False, - "error": last_error, - "body_bytes": 0, - "attempts": max(1, attempts), - "latency_ms": int((now_utc() - started).total_seconds() * 1000), - "url": url, - } - - -def public_probe_result(result: dict[str, Any]) -> dict[str, Any]: - return {key: value for key, value in result.items() if key != "body"} - - -def http_probe(url: str, timeout_seconds: float) -> dict[str, Any]: - return public_probe_result(fetch_url(url, timeout_seconds, attempts=2)) - - -def html_probe( - url: str, - timeout_seconds: float, - *, - min_bytes: int, - required_markers: tuple[str, ...], -) -> dict[str, Any]: - result = fetch_url(url, timeout_seconds, accept="text/html", attempts=2) - if not result.get("ok"): - return public_probe_result(result) - - body = result.get("body", b"") - text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else str(body) - missing = [marker for marker in required_markers if marker not in text] - content_type = str(result.get("content_type") or "") - if len(body) < min_bytes: - result["ok"] = False - result["error"] = f"body too small: {len(body)} < {min_bytes}" - elif "text/html" not in content_type.lower(): - result["ok"] = False - result["error"] = f"unexpected content-type: {content_type or 'unknown'}" - elif missing: - result["ok"] = False - result["error"] = "missing markers: " + ", ".join(missing) - result["missing_markers"] = missing - return public_probe_result(result) - - -def json_probe( - url: str, - timeout_seconds: float, - *, - expected_values: dict[str, Any], - required_keys: tuple[str, ...], -) -> dict[str, Any]: - result = fetch_url(url, timeout_seconds, accept="application/json", attempts=2) - if not result.get("ok"): - return public_probe_result(result) - - body = result.get("body", b"") - try: - payload = json.loads(body.decode("utf-8") if isinstance(body, bytes) else str(body)) - except Exception as exc: - result["ok"] = False - result["error"] = f"invalid json: {exc}" - return public_probe_result(result) - - if not isinstance(payload, dict): - result["ok"] = False - result["error"] = "json root is not object" - return public_probe_result(result) - - missing_keys = [key for key in required_keys if key not in payload] - mismatched = { - key: {"expected": expected, "actual": payload.get(key)} - for key, expected in expected_values.items() - if payload.get(key) != expected - } - if missing_keys: - result["ok"] = False - result["error"] = "missing json keys: " + ", ".join(missing_keys) - result["missing_keys"] = missing_keys - elif mismatched: - result["ok"] = False - result["error"] = "unexpected json values" - result["mismatched_values"] = mismatched - else: - result["json_keys"] = sorted(payload.keys()) - return public_probe_result(result) - - -def run_healthd(command: str, timeout_seconds: int) -> dict[str, Any]: - try: - proc = subprocess.run( - command, - shell=True, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - timeout=timeout_seconds, - ) - except subprocess.TimeoutExpired as exc: - return { - "ok": False, - "returncode": None, - "error": f"timeout after {timeout_seconds}s", - "output_tail": (exc.stdout or "")[-1000:] if isinstance(exc.stdout, str) else "", - } - - payload: dict[str, Any] = {} - try: - payload = json.loads(proc.stdout or "{}") - except json.JSONDecodeError: - pass - return { - "ok": proc.returncode == 0 and bool(payload.get("ok")), - "returncode": proc.returncode, - "counts": payload.get("counts", {}), - "payload": payload if isinstance(payload, dict) else {}, - "output_tail": (proc.stdout or "")[-1000:], - } - - -def read_healthd_state(path: Path, max_age_seconds: int) -> dict[str, Any]: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - generated = parse_ts(str(payload["generated_at_utc"])) - age = max(0, int((now_utc() - generated).total_seconds())) - except Exception as exc: - return {"ok": False, "error": str(exc), "path": str(path)} - return { - "ok": bool(payload.get("ok")) and age <= max_age_seconds, - "counts": payload.get("counts", {}), - "age_seconds": age, - "path": str(path), - "payload": payload if isinstance(payload, dict) else {}, - } - - -def load_samples(path: Path, cutoff: datetime) -> list[dict[str, Any]]: - samples: list[dict[str, Any]] = [] - if not path.exists(): - return samples - try: - lines = path.read_text(encoding="utf-8").splitlines() - except PermissionError: - return samples - for line in lines: - if not line.strip(): - continue - try: - item = json.loads(line) - ts = parse_ts(str(item["ts"])) - except Exception: - continue - if ts >= cutoff: - samples.append(item) - return samples - - -def append_and_trim_sample(path: Path, sample: dict[str, Any], retention_seconds: int) -> list[dict[str, Any]]: - cutoff = parse_ts(sample["ts"]) - timedelta(seconds=retention_seconds) - samples = load_samples(path, cutoff) - samples.append(sample) - content = "".join(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n" for item in samples) - write_atomic(path, content) - chmod_if_possible(path, 0o644) - chown_like_parent_if_possible(path) - return samples - - -def summarize_window( - samples: list[dict[str, Any]], - *, - now: datetime, - window_seconds: int, - sample_interval_seconds: int, - target_percent: float, -) -> dict[str, Any]: - cutoff = now - timedelta(seconds=window_seconds) - window = [] - for item in samples: - try: - if parse_ts(str(item["ts"])) >= cutoff: - window.append(item) - except Exception: - continue - - total = len(window) - good = sum(1 for item in window if item.get("ok") is True) - bad = total - good - availability = round((good / total) * 100, 5) if total else None - observed_bad_seconds = bad * sample_interval_seconds - budget_seconds = int(window_seconds * ((100.0 - target_percent) / 100.0)) - budget_remaining_seconds = budget_seconds - observed_bad_seconds - status = "unknown" - if total: - status = "ok" if budget_remaining_seconds >= 0 else "burning" - return { - "window_seconds": window_seconds, - "samples": total, - "good_samples": good, - "bad_samples": bad, - "availability_percent": availability, - "target_percent": target_percent, - "observed_bad_seconds": observed_bad_seconds, - "budget_seconds": budget_seconds, - "budget_remaining_seconds": budget_remaining_seconds, - "status": status, - } - - -def render_summary_text(summary: dict[str, Any]) -> str: - lines = [ - "=== AW-RUS SLO ===", - f"Timestamp: {summary['generated_at_utc']}", - f"Target: {summary['target_percent']}%", - "", - ] - for name, data in summary["windows"].items(): - availability = data["availability_percent"] - availability_text = "n/a" if availability is None else f"{availability:.5f}%" - remaining = data["budget_remaining_seconds"] - lines.append( - f"{name}: {data['status']} availability={availability_text} " - f"samples={data['samples']} bad={data['bad_samples']} " - f"bad_seconds={data['observed_bad_seconds']} budget_remaining_seconds={remaining}" - ) - lines.append("") - lines.append(f"Current sample: {'OK' if summary['current_sample']['ok'] else 'FAIL'}") - for name, probe in summary["current_sample"].get("probes", {}).items(): - marker = "OK" if probe.get("ok") else "FAIL" - detail = probe.get("status", probe.get("error", "")) - lines.append(f"- {name}: {marker} {detail}") - return "\n".join(lines) - - -def build_sample(args: argparse.Namespace) -> dict[str, Any]: - ts = iso(now_utc()) - if args.healthd_mode == "run": - health = run_healthd(args.healthd_cmd, args.health_timeout_seconds) - else: - health = read_healthd_state(Path(args.healthd_state_file), args.healthd_state_max_age_seconds) - probes = { - "aw_webui_index": html_probe( - args.aw_webui_url, - args.http_timeout_seconds, - min_bytes=1000, - required_markers=("ActivityWatch", 'id="app"', "ru-patch-v5.js"), - ), - "worktime_today_html": html_probe( - args.worktime_today_html_url, - args.http_timeout_seconds, - min_bytes=5000, - required_markers=("AW-rus", ""), - ), - "worktime_management_html": html_probe( - args.worktime_management_html_url, - args.http_timeout_seconds, - min_bytes=5000, - required_markers=("AW-rus", ""), - ), - "worktime_today_csv": http_probe(args.worktime_today_csv_url, args.http_timeout_seconds), - "worktime_management_json": json_probe( - args.worktime_management_json_url, - args.http_timeout_seconds, - expected_values={"host": args.host}, - required_keys=("generated_at_utc", "host", "summary", "rows", "workday"), - ), - } - ok = bool(health.get("ok")) and all(probe.get("ok") for probe in probes.values()) - return { - "ts": ts, - "ok": ok, - "healthd_ok": bool(health.get("ok")), - "healthd_counts": health.get("counts", {}), - "probes": probes, - } - - -def main() -> int: - load_env_file(ENV_FILE) - parser = argparse.ArgumentParser(description="AW-RUS rolling SLO sampler") - parser.add_argument("--state-dir", default=env("AW_RUS_SLO_STATE_DIR", str(DEFAULT_STATE_DIR))) - parser.add_argument("--healthd-cmd", default=env("AW_RUS_SLO_HEALTHD_CMD", DEFAULT_HEALTHD_CMD)) - parser.add_argument("--healthd-mode", choices=["state", "run"], default=env("AW_RUS_SLO_HEALTHD_MODE", "state")) - parser.add_argument("--healthd-state-file", default=env("AW_RUS_SLO_HEALTHD_STATE_FILE", str(DEFAULT_HEALTHD_STATE_FILE))) - parser.add_argument("--healthd-state-max-age-seconds", type=int, default=int(env("AW_RUS_SLO_HEALTHD_STATE_MAX_AGE_SECONDS", "180"))) - parser.add_argument("--aw-base", default=env("AW_RUS_SLO_AW_BASE", env("AW_SERVER_URL", "http://127.0.0.1:5600"))) - parser.add_argument("--worktime-base", default=env("AW_RUS_SLO_WORKTIME_BASE", env("AW_RUS_HEALTH_WORKTIME_API", "http://127.0.0.1:5610"))) - parser.add_argument("--host", default=env("AW_RUS_SLO_HOST", env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025"))) - parser.add_argument("--target-percent", type=float, default=float(env("AW_RUS_SLO_TARGET_PERCENT", "99.97"))) - parser.add_argument("--sample-interval-seconds", type=int, default=int(env("AW_RUS_SLO_SAMPLE_INTERVAL_SECONDS", "15"))) - parser.add_argument("--retention-days", type=int, default=int(env("AW_RUS_SLO_RETENTION_DAYS", "35"))) - parser.add_argument("--http-timeout-seconds", type=float, default=float(env("AW_RUS_SLO_HTTP_TIMEOUT_SECONDS", "15"))) - parser.add_argument("--health-timeout-seconds", type=int, default=int(env("AW_RUS_SLO_HEALTH_TIMEOUT_SECONDS", "90"))) - parser.add_argument("--json", action="store_true") - args = parser.parse_args() - - aw_base = args.aw_base.rstrip("/") - worktime_base = args.worktime_base.rstrip("/") - args.aw_webui_url = f"{aw_base}/" - args.worktime_today_html_url = f"{worktime_base}/reports/worktime/today?format=html&day=today&host={args.host}&allow_stale=1" - args.worktime_management_html_url = f"{worktime_base}/reports/worktime/management?format=html&day=today&host={args.host}&allow_stale=1" - args.worktime_today_csv_url = f"{worktime_base}/reports/worktime/today?format=csv&day=today&host={args.host}&allow_stale=1" - args.worktime_management_json_url = f"{worktime_base}/reports/worktime/management?format=json&day=today&host={args.host}&allow_stale=1" - - state_dir = Path(args.state_dir) - sample_path = state_dir / "aw-slo-samples.jsonl" - sample = build_sample(args) - samples = append_and_trim_sample( - sample_path, - sample, - retention_seconds=max(1, args.retention_days) * 86400, - ) - - generated_at = now_utc() - summary = { - "generated_at_utc": iso(generated_at), - "target_percent": args.target_percent, - "sample_interval_seconds": args.sample_interval_seconds, - "current_sample": sample, - "windows": { - "24h": summarize_window( - samples, - now=generated_at, - window_seconds=86400, - sample_interval_seconds=args.sample_interval_seconds, - target_percent=args.target_percent, - ), - "7d": summarize_window( - samples, - now=generated_at, - window_seconds=7 * 86400, - sample_interval_seconds=args.sample_interval_seconds, - target_percent=args.target_percent, - ), - "30d": summarize_window( - samples, - now=generated_at, - window_seconds=30 * 86400, - sample_interval_seconds=args.sample_interval_seconds, - target_percent=args.target_percent, - ), - }, - } - summary_json_path = state_dir / "aw-slo-summary.json" - summary_txt_path = state_dir / "aw-slo-summary.txt" - write_atomic(summary_json_path, json.dumps(summary, ensure_ascii=False, indent=2) + "\n") - text = render_summary_text(summary) - write_atomic(summary_txt_path, text + "\n") - chmod_if_possible(summary_json_path, 0o644) - chmod_if_possible(summary_txt_path, 0o644) - chown_like_parent_if_possible(summary_json_path) - chown_like_parent_if_possible(summary_txt_path) - if args.json: - print(json.dumps(summary, ensure_ascii=False, indent=2)) - else: - print(text) - return 0 if sample["ok"] else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/aw-server/aw-slo-monitor.service b/aw-server/aw-slo-monitor.service index a1bd724..0fbba5e 100644 --- a/aw-server/aw-slo-monitor.service +++ b/aw-server/aw-slo-monitor.service @@ -6,7 +6,7 @@ Wants=network-online.target activitywatch-server.service aw-worktime-api.service [Service] Type=oneshot EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/bin/python3 /usr/local/bin/aw-slo-monitor.py +ExecStart=/usr/local/bin/aw-slo-monitor-rust SuccessExitStatus=1 User=activitywatch Group=activitywatch diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py deleted file mode 100644 index caba965..0000000 --- a/aw-server/aw-worktime-api.py +++ /dev/null @@ -1,3039 +0,0 @@ -#!/usr/bin/env python3 -import base64 -import csv -import hashlib -import html -import io -import importlib.util -import json -import os -import sys -import tempfile -import threading -import time -import urllib.request -from datetime import datetime, timezone, timedelta -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from urllib.parse import parse_qs, urlencode, urlparse -from zoneinfo import ZoneInfo - - -def build_aw_api_base(raw_url): - url = (raw_url or "http://127.0.0.1:5600").strip().rstrip("/") - if url.endswith("/api/0"): - return url - return url + "/api/0" - - -AW_SERVER_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600") -AW = build_aw_api_base(AW_SERVER_URL) -REPORT_TZ = ZoneInfo(os.environ.get("AW_WORKTIME_TZ", "Europe/Moscow")) -IOC_DIR = os.environ.get("AW_DLP_IOC_DIR", "/opt/activitywatch/dlp-ioc/output") -DEFAULT_HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025").strip() or "SHARKON2025" -DEFAULT_SAMPLE_SECONDS = max(1.0, float(os.environ.get("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", "30"))) -MAX_SAMPLE_SECONDS = max(DEFAULT_SAMPLE_SECONDS, float(os.environ.get("AW_WORKTIME_MAX_SAMPLE_SECONDS", "300"))) -LISTEN_HOST = os.environ.get("AW_WORKTIME_LISTEN_HOST", "0.0.0.0") -LISTEN_PORT = int(os.environ.get("AW_WORKTIME_PORT", "5610")) -WORKDAY_START_HOUR = int(os.environ.get("AW_WORKTIME_MANAGER_START_HOUR", "9")) -WORKDAY_END_HOUR = int(os.environ.get("AW_WORKTIME_MANAGER_END_HOUR", "18")) -MANAGER_TARGET_COVERAGE_PCT = max(1, min(100, int(os.environ.get("AW_WORKTIME_MANAGER_TARGET_COVERAGE_PCT", "75")))) -MANAGER_LOW_COVERAGE_PCT = max(1, min(100, int(os.environ.get("AW_WORKTIME_MANAGER_LOW_COVERAGE_PCT", "35")))) -MANAGER_LATE_START_GRACE_MINUTES = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_LATE_START_GRACE_MINUTES", "60"))) -MANAGER_EARLY_FINISH_GRACE_MINUTES = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_EARLY_FINISH_GRACE_MINUTES", "90"))) -MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS = max(60, int(os.environ.get("AW_WORKTIME_MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS", "900"))) -MANAGER_WEB_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_WEB_SOURCE_MAX_AGE_SECONDS", "259200"))) -MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS", "604800"))) -MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS", "172800"))) -MANAGER_TREND_DAYS = max(3, min(31, int(os.environ.get("AW_WORKTIME_MANAGER_TREND_DAYS", "7")))) -MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS", "300"))) -MANAGER_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_MANAGER_CACHE_DIR", "/var/lib/activitywatch/worktime-cache")) -MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", "/etc/activitywatch/worktime-manager-aliases.json")) -MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()} -EVENTS_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", "30"))) -WORKTIME_EVENTS_LIMIT = max(1000, int(os.environ.get("AW_WORKTIME_EVENTS_LIMIT", "50000"))) -AW_HTTP_TIMEOUT_SECONDS = max(0.5, float(os.environ.get("AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS", "5"))) -REPORT_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_REPORT_CACHE_TTL_SECONDS", "60"))) -REPORT_STALE_TTL_SECONDS = max(REPORT_CACHE_TTL_SECONDS, int(os.environ.get("AW_WORKTIME_REPORT_STALE_TTL_SECONDS", "900"))) -REPORT_DISK_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_REPORT_DISK_CACHE_DIR", "/var/lib/activitywatch/worktime-report-cache")) -REPORT_DISK_STALE_TTL_SECONDS = max(REPORT_STALE_TTL_SECONDS, int(os.environ.get("AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS", "86400"))) -REPORT_BUSY_WAIT_SECONDS = max(0.0, float(os.environ.get("AW_WORKTIME_REPORT_BUSY_WAIT_SECONDS", "8"))) -REPORT_BUSY_WAIT_INTERVAL_SECONDS = max(0.05, float(os.environ.get("AW_WORKTIME_REPORT_BUSY_WAIT_INTERVAL_SECONDS", "0.2"))) -REPORT_MAX_CONCURRENT = max(1, int(os.environ.get("AW_WORKTIME_REPORT_MAX_CONCURRENT", "1"))) -TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS", "180"))) -TRUE_ACTIVE_MAX_EVENT_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_MAX_EVENT_SECONDS", "600"))) -MODULE_PATH = Path(__file__).resolve() -_ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}} -_EVENTS_CACHE_LOCK = threading.Lock() -_EVENTS_CACHE = {} -_REPORT_CACHE_LOCK = threading.Lock() -_REPORT_RESPONSE_CACHE = {} -_REPORT_REFRESHING = set() -_REPORT_BUILD_SEMAPHORE = threading.BoundedSemaphore(REPORT_MAX_CONCURRENT) -_MANAGEMENT_BUILD_LOCKS_LOCK = threading.Lock() -_MANAGEMENT_BUILD_LOCKS = {} - - -def get(u): - with urllib.request.urlopen(u, timeout=AW_HTTP_TIMEOUT_SECONDS) as r: - return json.loads(r.read().decode()) - - -def log_warning(message): - print(f"[aw-worktime-api] {message}", file=sys.stderr, flush=True) - - -def pts(s): - return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc) - - -def to_iso_utc(dt): - return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -def parse_iso_utc(value): - if not value: - return None - return pts(value) - - -def hhmm(total_seconds): - total_seconds = max(0, int(total_seconds)) - return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60) - - -def human_duration_ru(total_seconds): - total_seconds = max(0, int(total_seconds)) - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - if hours and minutes: - return f"{hours} ч {minutes} мин" - if hours: - return f"{hours} ч" - if minutes: - return f"{minutes} мин" - return f"{total_seconds} сек" - - -def now_utc(): - return datetime.now(timezone.utc) - - -def worktime_health_payload(): - return { - "ok": True, - "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), - "report_timezone": str(REPORT_TZ), - "default_host": DEFAULT_HOST, - "aw_api_base": AW, - } - - -def age_seconds(ts, now=None): - if ts is None: - return None - ref = now or now_utc() - return max(0, int((ref - ts).total_seconds())) - - -def write_atomic_json(path, payload): - path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: - json.dump(payload, handle, ensure_ascii=False, indent=2) - handle.write("\n") - tmp_name = handle.name - os.replace(tmp_name, path) - - -def safe_slug(value): - text = str(value or "").strip().lower() - slug = [] - for char in text: - if char.isalnum(): - slug.append(char) - else: - slug.append("-") - normalized = "".join(slug).strip("-") - while "--" in normalized: - normalized = normalized.replace("--", "-") - return normalized or "user" - - -def _normalize_identity_key(value): - return str(value or "").strip().lower() - - -def _default_display_name(user, user_id): - base = str(user_id or user or "").strip() - if "\\" in base: - base = base.split("\\", 1)[1] - if not base: - base = str(user or "").strip() - if base and base.isascii() and base.lower() == base and any(ch.isalpha() for ch in base): - return base.upper() - return base or str(user or "").strip() - - -def _load_manager_directory(): - try: - stat = MANAGER_ALIASES_JSON.stat() - except FileNotFoundError: - _ALIASES_CACHE["mtime"] = None - _ALIASES_CACHE["users"] = {} - _ALIASES_CACHE["owners"] = {} - _ALIASES_CACHE["raw"] = {} - return {"users": {}, "owners": {}, "raw": {}} - mtime = stat.st_mtime - if _ALIASES_CACHE["mtime"] == mtime: - return { - "users": _ALIASES_CACHE["users"], - "owners": _ALIASES_CACHE["owners"], - "raw": _ALIASES_CACHE["raw"], - } - try: - raw = json.loads(MANAGER_ALIASES_JSON.read_text(encoding="utf-8")) - except Exception as exc: - log_warning(f"failed to load manager aliases from {MANAGER_ALIASES_JSON}: {exc}") - raw = {} - users_payload = {} - owners_payload = {} - if isinstance(raw, dict): - users = raw.get("users", raw) - if isinstance(users, dict): - for key, value in users.items(): - norm_key = _normalize_identity_key(key) - if not norm_key: - continue - if isinstance(value, str): - users_payload[norm_key] = {"display_name": value} - elif isinstance(value, dict): - users_payload[norm_key] = dict(value) - owners = raw.get("owners", {}) - if isinstance(owners, dict): - for key, value in owners.items(): - norm_key = _normalize_identity_key(key) - if not norm_key: - continue - if isinstance(value, str): - owners_payload[norm_key] = {"display_name": value} - elif isinstance(value, dict): - owners_payload[norm_key] = dict(value) - _ALIASES_CACHE["mtime"] = mtime - _ALIASES_CACHE["users"] = users_payload - _ALIASES_CACHE["owners"] = owners_payload - _ALIASES_CACHE["raw"] = raw if isinstance(raw, dict) else {} - return { - "users": users_payload, - "owners": owners_payload, - "raw": _ALIASES_CACHE["raw"], - } - - -def load_manager_aliases(): - return _load_manager_directory()["users"] - - -def load_manager_owners(): - return _load_manager_directory()["owners"] - - -def resolve_user_alias(user, user_id, host): - aliases = load_manager_aliases() - candidates = [ - _normalize_identity_key(user_id), - _normalize_identity_key(f"{resolve_host(host)}\\{user}"), - _normalize_identity_key(user), - ] - alias = {} - for candidate in candidates: - if candidate and candidate in aliases: - alias = dict(aliases[candidate]) - break - display_name = str(alias.get("display_name") or alias.get("name") or _default_display_name(user, user_id)).strip() - manager_owner = str(alias.get("manager") or alias.get("owner") or display_name).strip() or display_name - department = str(alias.get("department") or "").strip() - role = str(alias.get("role") or "").strip() - notes = str(alias.get("notes") or "").strip() - canonical_user_id = str(alias.get("canonical_user_id") or user_id or "").strip() - exclude = bool(alias.get("exclude")) or _normalize_identity_key(user) in MANAGER_EXCLUDE_USERS or _normalize_identity_key(display_name) in MANAGER_EXCLUDE_USERS - return { - "display_name": display_name, - "manager_owner": manager_owner, - "department": department, - "role": role, - "notes": notes, - "canonical_user_id": canonical_user_id, - "exclude": exclude, - } - - -def resolve_owner_profile(owner_name): - owner_name = normalize_management_filter(owner_name) - if not owner_name: - owner_name = "unassigned" - owners = load_manager_owners() - profile = dict(owners.get(_normalize_identity_key(owner_name), {})) - display_name = str(profile.get("display_name") or profile.get("name") or owner_name).strip() or owner_name - title = str(profile.get("title") or profile.get("role") or "").strip() - department = str(profile.get("department") or "").strip() - escalation_to = str(profile.get("escalation_to") or profile.get("escalate_to") or "").strip() - contact = str(profile.get("contact") or profile.get("telegram") or profile.get("email") or "").strip() - notes = str(profile.get("notes") or "").strip() - return { - "owner_name": owner_name, - "display_name": display_name, - "title": title, - "department": department, - "escalation_to": escalation_to, - "contact": contact, - "notes": notes, - } - - -def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS): - try: - seconds = float(value) - except Exception: - seconds = float(fallback) - if seconds <= 0: - seconds = float(fallback) - return min(seconds, MAX_SAMPLE_SECONDS) - - -def resolve_host(request_host=None): - host = (request_host or DEFAULT_HOST).strip() - if not host: - host = DEFAULT_HOST - return host - - -def get_sessions_bucket_id(host): - return f"aw-worktime-sessions_{resolve_host(host)}" - - -def get_management_build_lock(host, report_date): - key = (resolve_host(host), report_date.isoformat()) - with _MANAGEMENT_BUILD_LOCKS_LOCK: - lock = _MANAGEMENT_BUILD_LOCKS.get(key) - if lock is None: - lock = threading.Lock() - _MANAGEMENT_BUILD_LOCKS[key] = lock - return lock - - -def resolve_report_date(day=None, date_text=None): - now_local = datetime.now(REPORT_TZ) - if date_text: - return datetime.strptime(date_text, "%Y-%m-%d").date() - if day == "yesterday": - return (now_local - timedelta(days=1)).date() - return now_local.date() - - -def get_report_bounds(report_date): - start_local = datetime(report_date.year, report_date.month, report_date.day, tzinfo=REPORT_TZ) - end_local = start_local + timedelta(days=1) - timedelta(seconds=1) - start = start_local.astimezone(timezone.utc) - end = end_local.astimezone(timezone.utc) - end_exclusive = end + timedelta(seconds=1) - return { - "start_local": start_local, - "end_local": end_local, - "start": start, - "end": end, - "end_exclusive": end_exclusive, - } - - -def get_workday_bounds(report_date): - start_local = datetime(report_date.year, report_date.month, report_date.day, WORKDAY_START_HOUR, 0, 0, tzinfo=REPORT_TZ) - end_local = datetime(report_date.year, report_date.month, report_date.day, WORKDAY_END_HOUR, 0, 0, tzinfo=REPORT_TZ) - if end_local <= start_local: - end_local = start_local + timedelta(hours=8) - return { - "start_local": start_local, - "end_local": end_local, - "duration_seconds": int((end_local - start_local).total_seconds()), - } - - -def _is_machine_user(user: str): - u = (user or "").strip().lower() - return u.endswith("$") or u in {"system", "localservice", "networkservice"} - - -def _is_active_sample(data: dict): - state = str(data.get("state") or "").strip().lower() - if isinstance(data.get("active"), bool) and data.get("active"): - return True - if ("актив" in state) or (state == "active"): - return True - if state == "unknown": - try: - sid = int(data.get("sessionId")) - except Exception: - sid = -1 - user = str(data.get("username") or "").strip() - session_name = str(data.get("sessionName") or "").strip().lower() - if sid > 0 and user and (not _is_machine_user(user)) and (session_name.startswith("rdp-") or session_name == "console"): - return True - return False - - -def _normalize_user_id(data, host, username): - user_id = str(data.get("userId") or "").strip() - if user_id: - left, sep, right = user_id.partition("\\") - if sep and right: - return f"{resolve_host(host)}\\{right}" - return user_id - return f"{resolve_host(host)}\\{username}" - - -def _event_sample_seconds(event, next_same_session_ts=None): - data = event.get("data") or {} - for key in ("sampleSeconds", "pollSeconds"): - value = data.get(key) - try: - if float(value) > 0: - return clamp_seconds(value) - except Exception: - pass - try: - duration = float(event.get("duration") or 0.0) - except Exception: - duration = 0.0 - if duration > 0: - return clamp_seconds(duration) - if next_same_session_ts is not None: - delta = (next_same_session_ts - event["_ts"]).total_seconds() - if delta > 0: - return clamp_seconds(delta) - return clamp_seconds(DEFAULT_SAMPLE_SECONDS) - - -def _merge_intervals(intervals): - if not intervals: - return [] - ordered = sorted(intervals, key=lambda item: item[0]) - merged = [ordered[0]] - for start, end in ordered[1:]: - last_start, last_end = merged[-1] - if start <= last_end: - if end > last_end: - merged[-1] = (last_start, end) - continue - merged.append((start, end)) - return merged - - -def _overlap_interval(left, right): - start = max(left[0], right[0]) - end = min(left[1], right[1]) - if end <= start: - return None - return start, end - - -def _interval_contains(interval, ts): - return interval[0] <= ts < interval[1] - - -def _event_timestamp(event): - try: - return pts(event.get("timestamp")) - except Exception: - return None - - -def _event_duration_seconds(event, default_seconds=DEFAULT_SAMPLE_SECONDS): - try: - duration = float(event.get("duration") or 0.0) - except Exception: - duration = 0.0 - if duration <= 0: - duration = default_seconds - return max(1.0, min(float(TRUE_ACTIVE_MAX_EVENT_SECONDS), duration)) - - -def _normalize_app_name(app, title=""): - app_raw = str(app or "").strip() - app_l = app_raw.lower() - title_raw = str(title or "").strip() - if app_l.startswith(("1cv8", "1cestart")): - return "1С" - if app_l in {"chrome.exe", "google chrome"} or "google chrome" in title_raw.lower(): - return "Chrome" - if app_l in {"msedge.exe", "microsoft edge"} or "microsoft edge" in title_raw.lower(): - return "Edge" - if app_l in {"browser.exe", "browser"} or "яндекс" in title_raw.lower(): - return "Яндекс Браузер" - if app_l in {"excel.exe"}: - return "Excel" - if app_l in {"winword.exe"}: - return "Word" - if app_l in {"powerpnt.exe"}: - return "PowerPoint" - if app_l in {"outlook.exe"}: - return "Outlook" - if app_l in {"explorer.exe"}: - return "Проводник" - if app_l in {"totalcmd.exe", "totalcmd64.exe"}: - return "Total Commander" - if app_l in {"cmd.exe"}: - return "Command Prompt" - if app_l in {"powershell.exe", "pwsh.exe"}: - return "PowerShell" - if app_l in {"acrord32.exe", "acrobat.exe"}: - return "Adobe Acrobat Reader" - if app_l in {"windowsterminal.exe", "windowsterminal"}: - return "Windows Terminal" - if app_raw: - return app_raw[:-4] if app_l.endswith(".exe") else app_raw - if title_raw: - return title_raw - return "Неизвестное приложение" - - -def _event_context(event): - data = event.get("data") or {} - for key in ("title", "url", "path", "filePath", "targetPath", "windowTitle", "foregroundTitle", "signalType"): - value = str(data.get(key) or "").strip() - if value: - return value - return "активность" - - -def _is_not_afk_event(event): - data = event.get("data") or {} - status = str(data.get("status") or data.get("state") or "").strip().lower() - return status in {"not-afk", "not_afk", "active", "активно"} - - -def _is_real_evidence_event(event): - data = event.get("data") or {} - signal_type = str(data.get("signalType") or data.get("type") or "").strip().lower() - if signal_type in {"collector_health", "self_test", "heartbeat", "health"}: - return False - if data.get("url") or data.get("title") or data.get("path") or data.get("filePath") or data.get("targetPath"): - return True - if signal_type: - return True - return False - - -def _events_for_bounds(events, start, end): - result = [] - for event in events: - ts = _event_timestamp(event) - if ts is None or ts < start or ts > end: - continue - result.append((ts, event)) - result.sort(key=lambda item: item[0]) - return result - - -def _build_window_intervals(window_events, start, end): - intervals = [] - previous_key = None - for ts, event in _events_for_bounds(window_events, start, end): - data = event.get("data") or {} - app = str(data.get("app") or data.get("process") or data.get("processName") or "").strip() - title = str(data.get("title") or data.get("windowTitle") or "").strip() - if not app and not title: - continue - duration = _event_duration_seconds(event, default_seconds=DEFAULT_SAMPLE_SECONDS) - interval = (max(ts, start), min(ts + timedelta(seconds=duration), end + timedelta(seconds=1))) - if interval[1] <= interval[0]: - continue - app_name = _normalize_app_name(app, title) - current_key = (app_name, title) - title_changed = previous_key is not None and current_key != previous_key - previous_key = current_key - intervals.append( - { - "app": app_name, - "raw_app": app, - "title": title, - "start": interval[0], - "end": interval[1], - "title_changed": title_changed, - "timestamp": ts, - } - ) - return intervals - - -def _build_not_afk_intervals(afk_events, start, end): - intervals = [] - for ts, event in _events_for_bounds(afk_events, start, end): - if not _is_not_afk_event(event): - continue - duration = _event_duration_seconds(event, default_seconds=5) - interval = (max(ts, start), min(ts + timedelta(seconds=duration), end + timedelta(seconds=1))) - if interval[1] > interval[0]: - intervals.append(interval) - return _merge_intervals(intervals) - - -def _find_window_at(window_intervals, ts): - for item in window_intervals: - if item["start"] <= ts < item["end"]: - return item - return None - - -def _add_app_evidence(evidence_by_app, app, ts, context): - evidence_by_app.setdefault(app, []).append((ts, str(context or "").strip() or "активность")) - - -def build_true_active_apps_from_events(window_events, afk_events, evidence_events_by_bucket, start, end): - window_intervals = _build_window_intervals(window_events, start, end) - not_afk_intervals = _build_not_afk_intervals(afk_events, start, end) - evidence_by_app = {} - - for item in window_intervals: - if item["title_changed"]: - _add_app_evidence(evidence_by_app, item["app"], item["timestamp"], item["title"] or item["raw_app"]) - - for events in evidence_events_by_bucket.values(): - for ts, event in _events_for_bounds(events, start, end): - if not _is_real_evidence_event(event): - continue - window = _find_window_at(window_intervals, ts) - if window is None: - continue - _add_app_evidence(evidence_by_app, window["app"], ts, _event_context(event)) - - rows = [] - evidence_delta = timedelta(seconds=TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS) - for app in sorted({item["app"] for item in window_intervals} | set(evidence_by_app)): - app_evidence = sorted(evidence_by_app.get(app, []), key=lambda item: item[0]) - if not app_evidence: - continue - evidence_windows = _merge_intervals([(ts - evidence_delta, ts + evidence_delta) for ts, _context in app_evidence]) - proved_intervals = [] - for window in [item for item in window_intervals if item["app"] == app]: - base = (window["start"], window["end"]) - for afk_interval in not_afk_intervals: - active_overlap = _overlap_interval(base, afk_interval) - if active_overlap is None: - continue - for evidence_interval in evidence_windows: - proved = _overlap_interval(active_overlap, evidence_interval) - if proved is not None: - proved_intervals.append(proved) - proved_intervals = _merge_intervals(proved_intervals) - proved_seconds = int(sum((right - left).total_seconds() for left, right in proved_intervals)) - if proved_seconds <= 0: - continue - last_ts, last_context = app_evidence[-1] - rows.append( - { - "application": app, - "proved_work_seconds": proved_seconds, - "proved_work_hhmm": hhmm(proved_seconds), - "proved_work_human": human_duration_ru(proved_seconds), - "last_action_utc": to_iso_utc(last_ts), - "last_action_local": last_ts.astimezone(REPORT_TZ).strftime("%H:%M"), - "last_action": last_context, - "evidence_events": len(app_evidence), - } - ) - rows.sort(key=lambda item: (-item["proved_work_seconds"], item["application"].lower())) - return rows - - -def build_true_active_apps(host, report_date): - bounds = get_report_bounds(report_date) - host = resolve_host(host) - window_events = fetch_bucket_events(f"aw-watcher-window_{host}", host) or fetch_bucket_events(f"aw-rdp-window_{host}", host) - afk_events = fetch_bucket_events(f"aw-watcher-afk_{host}", host) or fetch_bucket_events(f"aw-rdp-afk_{host}", host) - evidence_events_by_bucket = {} - for bucket_id in ( - f"aw-file-operations_{host}", - f"aw-dlp-endpoint-signals_{host}", - f"aw-watcher-web-chrome_{host}", - f"aw-watcher-web-edge_{host}", - f"aw-detmir-web-category_{host}", - ): - evidence_events_by_bucket[bucket_id] = fetch_bucket_events(bucket_id, host) - return build_true_active_apps_from_events( - window_events, - afk_events, - evidence_events_by_bucket, - bounds["start"], - bounds["end"], - ) - - -def _collect_user_rows(events, start, end, host): - end_exclusive = end + timedelta(seconds=1) - by_user = {} - by_identity = {} - - for event in events: - ts = pts(event.get("timestamp")) - if ts < start or ts > end: - continue - data = event.get("data") or {} - username = str(data.get("username") or "").strip() - if not username: - continue - session_id = str(data.get("sessionId") or "").strip() or "unknown" - event_copy = { - "_ts": ts, - "data": data, - "duration": event.get("duration"), - } - by_identity.setdefault((username, session_id), []).append(event_copy) - - for (username, session_id), samples in by_identity.items(): - ordered = sorted(samples, key=lambda item: item["_ts"]) - for idx, sample in enumerate(ordered): - data = sample["data"] - active = _is_active_sample(data) - next_ts = ordered[idx + 1]["_ts"] if idx + 1 < len(ordered) else None - sample_seconds = _event_sample_seconds(sample, next_ts) - row = by_user.setdefault( - username, - { - "user": username, - "user_id": _normalize_user_id(data, host, username), - "samples_count": 0, - "active_samples": 0, - "session_ids": set(), - "intervals": [], - }, - ) - row["samples_count"] += 1 - row["session_ids"].add(session_id) - if active: - row["active_samples"] += 1 - interval_start = sample["_ts"] - interval_end = min(sample["_ts"] + timedelta(seconds=sample_seconds), end_exclusive) - if interval_end > interval_start: - row["intervals"].append((interval_start, interval_end)) - return by_user - - -def aggregate_rows(events, start, end, host): - by_user = _collect_user_rows(events, start, end, host) - return _build_rows_from_user_map(by_user, start, end, include_intervals=False) - - -def aggregate_rows_with_intervals(events, start, end, host): - by_user = _collect_user_rows(events, start, end, host) - return _build_rows_from_user_map(by_user, start, end, include_intervals=True) - - -def _build_rows_from_user_map(by_user, start, end, include_intervals): - rows = [] - full_range = int((end - start).total_seconds()) + 1 - for username in sorted(by_user): - row = by_user[username] - merged = _merge_intervals(row["intervals"]) - active_seconds = int(sum((end_dt - start_dt).total_seconds() for start_dt, end_dt in merged)) - active_seconds = min(active_seconds, full_range) - first_activity = to_iso_utc(merged[0][0]) if merged else "" - last_activity = to_iso_utc(merged[-1][1]) if merged else "" - rows.append( - { - "user": row["user"], - "user_id": row["user_id"], - "active_seconds": active_seconds, - "active_hhmm": hhmm(active_seconds), - "first_activity": first_activity, - "last_activity": last_activity, - "idle_seconds": max(0, full_range - active_seconds), - "sessions_count": len(row["session_ids"]), - "samples_count": row["samples_count"], - "active_samples": row["active_samples"], - } - ) - if include_intervals: - rows[-1]["_intervals"] = merged - return rows - - -def aggregate_hourly_rows(events, start, end, host): - by_user = _collect_user_rows(events, start, end, host) - rows = [] - for username in sorted(by_user): - row = by_user[username] - merged = _merge_intervals(row["intervals"]) - per_bucket = {} - for interval_start, interval_end in merged: - cursor = interval_start - while cursor < interval_end: - bucket_local = cursor.astimezone(REPORT_TZ).replace(minute=0, second=0, microsecond=0) - bucket_start = bucket_local.astimezone(timezone.utc) - bucket_end = (bucket_local + timedelta(hours=1)).astimezone(timezone.utc) - overlap_start = max(interval_start, bucket_start) - overlap_end = min(interval_end, bucket_end) - if overlap_end > overlap_start: - key = bucket_start - per_bucket[key] = per_bucket.get(key, 0) + int((overlap_end - overlap_start).total_seconds()) - cursor = bucket_end - - for bucket_start in sorted(per_bucket): - active_seconds = per_bucket[bucket_start] - if active_seconds <= 0: - continue - bucket_local = bucket_start.astimezone(REPORT_TZ) - rows.append( - { - "user": row["user"], - "user_id": row["user_id"], - "bucket_start_utc": to_iso_utc(bucket_start), - "bucket_start_local": bucket_local.isoformat(), - "report_date": bucket_local.date().isoformat(), - "hour_local": bucket_local.strftime("%H:00"), - "active_seconds": active_seconds, - "active_hhmm": hhmm(active_seconds), - } - ) - return rows - - -def fetch_events_for_date(host, report_date): - bounds = get_report_bounds(report_date) - bucket_id = get_sessions_bucket_id(host) - events = fetch_bucket_events(bucket_id, host, start=bounds["start"], end=bounds["end"]) - return bounds, events - - -def fetch_bucket_events(bucket_id, host, start=None, end=None): - now = now_utc() - cache_key = bucket_id - params = {"limit": WORKTIME_EVENTS_LIMIT} - if start is not None and end is not None: - params["start"] = to_iso_utc(start) - params["end"] = to_iso_utc(end) - cache_key = f"{bucket_id}|{params['start']}|{params['end']}" - if EVENTS_CACHE_TTL_SECONDS > 0: - with _EVENTS_CACHE_LOCK: - cached = _EVENTS_CACHE.get(cache_key) - if cached is not None and age_seconds(cached["stored_at"], now=now) <= EVENTS_CACHE_TTL_SECONDS: - return cached["events"] - try: - get(f"{AW}/buckets/{bucket_id}") - except Exception: - log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") - return [] - try: - events = get(f"{AW}/buckets/{bucket_id}/events?{urlencode(params)}") - except Exception: - log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") - return [] - if EVENTS_CACHE_TTL_SECONDS > 0: - with _EVENTS_CACHE_LOCK: - _EVENTS_CACHE[cache_key] = {"stored_at": now, "events": events} - return events - - -def build_report_summary(rows): - if not rows: - return { - "users_count": 0, - "total_active_seconds": 0, - "total_active_hhmm": "00:00", - "first_activity": "", - "last_activity": "", - "top_user": "", - "top_user_active_hhmm": "00:00", - } - - total_active_seconds = sum(int(row.get("active_seconds", 0) or 0) for row in rows) - first_values = [row.get("first_activity") for row in rows if row.get("first_activity")] - last_values = [row.get("last_activity") for row in rows if row.get("last_activity")] - top_row = max(rows, key=lambda row: int(row.get("active_seconds", 0) or 0)) - return { - "users_count": len(rows), - "total_active_seconds": total_active_seconds, - "total_active_hhmm": hhmm(total_active_seconds), - "first_activity": min(first_values) if first_values else "", - "last_activity": max(last_values) if last_values else "", - "top_user": top_row.get("user", ""), - "top_user_active_hhmm": top_row.get("active_hhmm", "00:00"), - } - - -def latest_bucket_event(bucket_id): - try: - events = get(f"{AW}/buckets/{bucket_id}/events?limit=1") - except Exception: - events = None - if isinstance(events, list) and events: - valid = [item for item in events if isinstance(item, dict)] - if valid: - valid.sort(key=lambda item: item.get("timestamp") or "", reverse=True) - return valid[0] - try: - bucket = get(f"{AW}/buckets/{bucket_id}") - except Exception: - return None - end = ((bucket or {}).get("metadata") or {}).get("end") - if end: - return {"timestamp": end, "duration": 0, "data": {"source": "bucket_metadata"}} - return None - - -def _priority_rank(priority): - return {"critical": 0, "high": 1, "medium": 2, "low": 3}.get(priority, 9) - - -def _clamp_pct(value): - return round(min(100.0, max(0.0, float(value))), 2) - - -def _action(action_id, priority, owner, deadline_hint, reason, recommended_action, *, user_id="", evidence=None): - return { - "action_id": action_id, - "priority": priority, - "owner": owner, - "user_id": user_id, - "deadline_hint": deadline_hint, - "reason": reason, - "recommended_action": recommended_action, - "evidence": evidence or {}, - } - - -def build_executive_summary(summary, actions, sources): - critical = [action for action in actions if action.get("priority") == "critical"] - high = [action for action in actions if action.get("priority") == "high"] - stale_sources = [source for source in sources if source.get("status") != "ok"] - if critical: - portfolio_state = "critical" - headline = f"Есть {len(critical)} критичных вопроса, требующих решения сегодня." - elif high: - portfolio_state = "attention" - headline = f"Критичных провалов нет, но есть {len(high)} вопроса повышенного внимания." - elif summary.get("portfolio_coverage_pct", 0.0) < MANAGER_TARGET_COVERAGE_PCT: - portfolio_state = "attention" - headline = "Покрытие ниже целевого порога, но явных критичных кейсов не найдено." - else: - portfolio_state = "stable" - headline = "Критичных отклонений не найдено, рабочий день идёт в пределах нормы." - - message_parts = [ - f"Активны {summary.get('active_users', 0)} из {summary.get('users_count', 0)} сотрудников.", - f"Покрытие рабочего окна {summary.get('portfolio_coverage_pct', 0.0)}%.", - ] - if stale_sources: - message_parts.append(f"Есть {len(stale_sources)} проблем(ы) со свежестью источников.") - message = " ".join(message_parts) - - focus_items = [] - for action in actions[:5]: - focus_items.append( - { - "priority": action["priority"], - "owner": action["owner"], - "title": action["action_id"], - "reason": action["reason"], - "recommended_action": action["recommended_action"], - } - ) - stale_items = [] - for source in stale_sources[:3]: - stale_items.append( - { - "source_id": source["source_id"], - "label": source["label"], - "status": source["status_label"], - "summary": source["summary"], - } - ) - return { - "portfolio_state": portfolio_state, - "headline": headline, - "message": message, - "focus_items": focus_items, - "stale_sources": stale_items, - } - - -def _empty_rollup(name): - return { - "name": name or "unassigned", - "users_count": 0, - "active_users": 0, - "inactive_users": 0, - "below_target_users": 0, - "workday_total_active_seconds": 0, - "workday_total_active_hhmm": "00:00", - "portfolio_coverage_pct": 0.0, - "actions_count": 0, - "critical_actions_count": 0, - "high_actions_count": 0, - "medium_actions_count": 0, - "low_actions_count": 0, - "users": [], - } - - -def normalize_management_filter(value): - if value is None: - return "" - return " ".join(str(value).split()).strip() - - -def _filter_key(value): - return normalize_management_filter(value).casefold() - - -def _matches_management_filters(alias, owner_filter="", department_filter=""): - if owner_filter and _filter_key(alias.get("manager_owner")) != _filter_key(owner_filter): - return False - if department_filter and _filter_key(alias.get("department")) != _filter_key(department_filter): - return False - return True - - -def _action_department(action): - evidence = action.get("evidence") or {} - department = normalize_management_filter(evidence.get("department")) - if department: - return department - if normalize_management_filter(action.get("owner")) == "ops": - return "Инфраструктура" - return "" - - -def filter_management_actions(actions, rows, owner_filter="", department_filter=""): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - if not owner_filter and not department_filter: - return list(actions) - row_user_ids = { - normalize_management_filter(row.get("canonical_user_id") or row.get("user_id")) - for row in rows - if normalize_management_filter(row.get("canonical_user_id") or row.get("user_id")) - } - filtered = [] - for action in actions: - if owner_filter and _filter_key(action.get("owner")) != _filter_key(owner_filter): - continue - if department_filter: - action_department = _action_department(action) - if action_department: - if _filter_key(action_department) != _filter_key(department_filter): - continue - else: - user_id = normalize_management_filter(action.get("user_id")) - if not user_id or user_id not in row_user_ids: - continue - filtered.append(action) - return filtered - - -def _row_matches_payload_filters(row, owner_filter="", department_filter=""): - if owner_filter and _filter_key(row.get("manager_owner")) != _filter_key(owner_filter): - return False - if department_filter and _filter_key(row.get("department")) != _filter_key(department_filter): - return False - return True - - -def summarize_management_rows(rows, actions, workday): - expected_seconds_per_user = int(workday.get("expected_seconds_per_user", 0) or 0) - workday_total_active_seconds = sum(int(row.get("workday_active_seconds", 0) or 0) for row in rows) - calendar_total_active_seconds = sum(int(row.get("calendar_active_seconds", 0) or 0) for row in rows) - active_users = sum(1 for row in rows if row.get("status") != "inactive") - inactive_users = sum(1 for row in rows if row.get("status") == "inactive") - below_target_users = sum(1 for row in rows if row.get("status") == "below_target") - on_target_users = sum(1 for row in rows if row.get("status") == "ok") - workday_first_values = sorted(str(row.get("workday_first_activity_local") or "") for row in rows if row.get("workday_first_activity_local")) - workday_last_values = sorted(str(row.get("workday_last_activity_local") or "") for row in rows if row.get("workday_last_activity_local")) - calendar_first_values = sorted(str(row.get("first_activity_local") or "") for row in rows if row.get("first_activity_local")) - calendar_last_values = sorted(str(row.get("last_activity_local") or "") for row in rows if row.get("last_activity_local")) - top_row = max(rows, key=lambda row: int(row.get("workday_active_seconds", 0) or 0), default=None) - portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(rows))) * 100.0) if rows and expected_seconds_per_user > 0 else 0.0 - return { - "users_count": len(rows), - "active_users": active_users, - "inactive_users": inactive_users, - "on_target_users": on_target_users, - "below_target_users": below_target_users, - "portfolio_coverage_pct": portfolio_coverage_pct, - "actions_count": len(actions), - "critical_actions_count": sum(1 for action in actions if action.get("priority") == "critical"), - "high_actions_count": sum(1 for action in actions if action.get("priority") == "high"), - "calendar_total_active_seconds": calendar_total_active_seconds, - "calendar_total_active_hhmm": hhmm(calendar_total_active_seconds), - "calendar_first_activity": calendar_first_values[0] if calendar_first_values else "", - "calendar_last_activity": calendar_last_values[-1] if calendar_last_values else "", - "workday_total_active_seconds": workday_total_active_seconds, - "workday_total_active_hhmm": hhmm(workday_total_active_seconds), - "workday_first_activity": workday_first_values[0] if workday_first_values else "", - "workday_last_activity": workday_last_values[-1] if workday_last_values else "", - "total_active_seconds": workday_total_active_seconds, - "total_active_hhmm": hhmm(workday_total_active_seconds), - "first_activity": workday_first_values[0] if workday_first_values else "", - "last_activity": workday_last_values[-1] if workday_last_values else "", - "top_user": str((top_row or {}).get("user") or ""), - "top_user_active_hhmm": hhmm(int((top_row or {}).get("workday_active_seconds", 0) or 0)), - } - - -def apply_management_filters_to_payload(payload, owner_filter="", department_filter="", include_sources=True, include_source_actions=True): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - filtered_rows = [ - dict(row) - for row in payload.get("rows", []) - if _row_matches_payload_filters(row, owner_filter=owner_filter, department_filter=department_filter) - ] - base_actions = list(payload.get("actions", [])) - if not include_source_actions: - base_actions = [action for action in base_actions if action.get("action_id") != "source_freshness_review"] - filtered_actions = filter_management_actions(base_actions, filtered_rows, owner_filter=owner_filter, department_filter=department_filter) - filtered_payload = dict(payload) - filtered_payload["filters"] = { - "owner": owner_filter, - "department": department_filter, - } - filtered_payload["rows"] = filtered_rows - filtered_payload["actions"] = filtered_actions - filtered_payload["summary"] = summarize_management_rows(filtered_rows, filtered_actions, payload.get("workday") or {}) - filtered_payload["owner_rollups"] = build_owner_rollups(filtered_rows, filtered_actions) - filtered_payload["department_rollups"] = build_department_rollups(filtered_rows, filtered_actions) - filtered_payload["owner_roster"] = build_owner_roster(filtered_rows, filtered_actions) - if include_sources: - filtered_payload["sources"] = list(payload.get("sources", [])) - else: - filtered_payload["sources"] = [] - filtered_payload["executive"] = build_executive_summary(filtered_payload["summary"], filtered_actions, filtered_payload["sources"]) - return filtered_payload - - -def build_owner_rollups(rows, actions): - groups = {} - for row in rows: - owner = str(row.get("manager_owner") or row.get("user") or "unassigned").strip() or "unassigned" - group = groups.setdefault(owner, _empty_rollup(owner)) - group["users_count"] += 1 - if row.get("status") == "inactive": - group["inactive_users"] += 1 - else: - group["active_users"] += 1 - if row.get("status") == "below_target": - group["below_target_users"] += 1 - group["workday_total_active_seconds"] += int(row.get("workday_active_seconds", 0) or 0) - group["users"].append(row.get("user", "unknown")) - - for action in actions: - owner = str(action.get("owner") or "unassigned").strip() or "unassigned" - group = groups.setdefault(owner, _empty_rollup(owner)) - group["actions_count"] += 1 - prio = str(action.get("priority") or "").lower() - if prio == "critical": - group["critical_actions_count"] += 1 - elif prio == "high": - group["high_actions_count"] += 1 - elif prio == "medium": - group["medium_actions_count"] += 1 - elif prio == "low": - group["low_actions_count"] += 1 - - for group in groups.values(): - expected = group["users_count"] * max(1, WORKDAY_END_HOUR - WORKDAY_START_HOUR) * 3600 - group["workday_total_active_hhmm"] = hhmm(group["workday_total_active_seconds"]) - group["portfolio_coverage_pct"] = _clamp_pct((group["workday_total_active_seconds"] / expected) * 100.0) if expected > 0 else 0.0 - group["users"] = sorted(set(str(user or "").strip() for user in group["users"] if str(user or "").strip())) - - return sorted( - groups.values(), - key=lambda item: ( - -item["critical_actions_count"], - -item["high_actions_count"], - -item["inactive_users"], - item["name"].lower(), - ), - ) - - -def build_department_rollups(rows, actions): - groups = {} - for row in rows: - department = str(row.get("department") or "Без подразделения").strip() or "Без подразделения" - group = groups.setdefault(department, _empty_rollup(department)) - group["users_count"] += 1 - if row.get("status") == "inactive": - group["inactive_users"] += 1 - else: - group["active_users"] += 1 - if row.get("status") == "below_target": - group["below_target_users"] += 1 - group["workday_total_active_seconds"] += int(row.get("workday_active_seconds", 0) or 0) - group["users"].append(row.get("user", "unknown")) - - for action in actions: - evidence = action.get("evidence") or {} - department = str(evidence.get("department") or ("Инфраструктура" if str(action.get("owner") or "").strip().lower() == "ops" else "Без подразделения")).strip() - group = groups.setdefault(department, _empty_rollup(department)) - group["actions_count"] += 1 - prio = str(action.get("priority") or "").lower() - if prio == "critical": - group["critical_actions_count"] += 1 - elif prio == "high": - group["high_actions_count"] += 1 - elif prio == "medium": - group["medium_actions_count"] += 1 - elif prio == "low": - group["low_actions_count"] += 1 - - for group in groups.values(): - expected = group["users_count"] * max(1, WORKDAY_END_HOUR - WORKDAY_START_HOUR) * 3600 - group["workday_total_active_hhmm"] = hhmm(group["workday_total_active_seconds"]) - group["portfolio_coverage_pct"] = _clamp_pct((group["workday_total_active_seconds"] / expected) * 100.0) if expected > 0 else 0.0 - group["users"] = sorted(set(str(user or "").strip() for user in group["users"] if str(user or "").strip())) - - return sorted( - groups.values(), - key=lambda item: ( - -item["critical_actions_count"], - -item["high_actions_count"], - -item["inactive_users"], - item["name"].lower(), - ), - ) - - -def build_owner_roster(rows, actions): - owner_rollups = build_owner_rollups(rows, actions) - roster = [] - for item in owner_rollups: - profile = resolve_owner_profile(item["name"]) - roster.append( - { - **item, - "display_name": profile["display_name"], - "title": profile["title"], - "department": profile["department"], - "contact": profile["contact"], - "escalation_to": profile["escalation_to"], - "notes": profile["notes"], - } - ) - return roster - - -def _interval_overlap_seconds(intervals, start, end): - total = 0 - first = None - last = None - for interval_start, interval_end in intervals or []: - overlap_start = max(interval_start, start) - overlap_end = min(interval_end, end) - if overlap_end <= overlap_start: - continue - seconds = int((overlap_end - overlap_start).total_seconds()) - if seconds <= 0: - continue - total += seconds - if first is None or overlap_start < first: - first = overlap_start - if last is None or overlap_end > last: - last = overlap_end - return total, first, last - - -def _source_status_label(status): - return { - "ok": "fresh", - "warn": "stale", - "fail": "missing", - "inactive": "inactive", - }.get(status, status) - - -def _source_summary(event): - data = (event or {}).get("data") or {} - signal_type = str(data.get("signalType") or "").strip() - if signal_type == "collector_health": - return ( - f"queue={data.get('queueDepth', 0)} " - f"failures={data.get('sendFailures', 0)} " - f"flushed={data.get('eventsFlushed', 0)}" - ) - if data.get("domain"): - return f"{data.get('domain')} ({data.get('category', 'uncategorized')})" - if data.get("eventType"): - return f"{data.get('eventType')} {data.get('username', '')}".strip() - if data.get("action"): - return f"{data.get('action')} {data.get('result', '')}".strip() - if data.get("title"): - return str(data.get("title"))[:120] - if data.get("status"): - return str(data.get("status")) - if data.get("app"): - return str(data.get("app")) - return "" - - -def management_cache_path(host, report_date): - host_slug = safe_slug(host) - return MANAGER_CACHE_DIR / f"{host_slug}-{report_date.isoformat()}.json" - - -def load_management_cache(host, report_date): - if MANAGER_CACHE_TTL_SECONDS <= 0: - return None - path = management_cache_path(host, report_date) - if not path.exists(): - return None - if report_date >= datetime.now(REPORT_TZ).date(): - age = age_seconds(datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)) - if age is None or age > MANAGER_CACHE_TTL_SECONDS: - return None - try: - return json.loads(path.read_text(encoding="utf-8")) - except Exception: - return None - - -def save_management_cache(host, report_date, payload): - if MANAGER_CACHE_TTL_SECONDS <= 0: - return - write_atomic_json(management_cache_path(host, report_date), payload) - - -def make_report_cache_key(path, fmt, host, report_date, selected_day="", owner_filter="", department_filter=""): - return "|".join( - [ - str(path), - str(fmt), - resolve_host(host), - report_date.isoformat(), - str(selected_day or ""), - normalize_management_filter(owner_filter), - normalize_management_filter(department_filter), - ] - ) - - -def resolve_report_format(params, accept_header=""): - requested = str(params.get("format", [""])[0] or "").lower() - if requested in {"csv", "html", "json"}: - return requested - accept = str(accept_header or "").lower() - if "text/html" in accept and "application/json" not in accept: - return "html" - return "json" - - -def report_disk_cache_path(key): - digest = hashlib.sha256(key.encode("utf-8")).hexdigest() - return REPORT_DISK_CACHE_DIR / f"{digest}.json" - - -def load_report_response_disk_cache(key): - if REPORT_DISK_STALE_TTL_SECONDS <= 0: - return None - path = report_disk_cache_path(key) - if not path.exists(): - return None - try: - payload = json.loads(path.read_text(encoding="utf-8")) - stored_epoch = float(payload["stored_epoch"]) - age = max(0, int(time.time() - stored_epoch)) - if age > REPORT_DISK_STALE_TTL_SECONDS: - return None - data = base64.b64decode(payload["data_b64"].encode("ascii")) - content_type = str(payload["content_type"]) - except Exception as exc: - log_warning(f"failed to load report disk cache key={key}: {exc}") - return None - return { - "stored_monotonic": time.monotonic() - age, - "generated_at_utc": payload.get("generated_at_utc", ""), - "data": data, - "content_type": content_type, - "age_seconds": age, - "stale": True, - "cache_source": "disk", - } - - -def save_report_response_disk_cache(key, data, content_type, generated_at_utc): - if REPORT_DISK_STALE_TTL_SECONDS <= 0: - return - payload = { - "key": key, - "stored_epoch": time.time(), - "generated_at_utc": generated_at_utc, - "content_type": content_type, - "data_b64": base64.b64encode(data).decode("ascii"), - } - try: - write_atomic_json(report_disk_cache_path(key), payload) - except Exception as exc: - log_warning(f"failed to save report disk cache key={key}: {exc}") - - -def get_report_response_cache(key, *, allow_stale=False): - if REPORT_CACHE_TTL_SECONDS <= 0 and not allow_stale: - return None - now = time.monotonic() - with _REPORT_CACHE_LOCK: - cached = _REPORT_RESPONSE_CACHE.get(key) - if cached is not None: - age = now - cached["stored_monotonic"] - max_age = REPORT_STALE_TTL_SECONDS if allow_stale else REPORT_CACHE_TTL_SECONDS - if max_age > 0 and age <= max_age: - result = dict(cached) - result["age_seconds"] = int(age) - result["stale"] = age > REPORT_CACHE_TTL_SECONDS - result["cache_source"] = "memory" - return result - if allow_stale: - return load_report_response_disk_cache(key) - return None - - -def save_report_response_cache(key, data, content_type): - if REPORT_STALE_TTL_SECONDS <= 0: - return - generated_at_utc = now_utc().isoformat().replace("+00:00", "Z") - with _REPORT_CACHE_LOCK: - _REPORT_RESPONSE_CACHE[key] = { - "stored_monotonic": time.monotonic(), - "generated_at_utc": generated_at_utc, - "data": data, - "content_type": content_type, - } - save_report_response_disk_cache(key, data, content_type, generated_at_utc) - - -def trigger_report_background_refresh(key, path, params): - refresh_params = {name: list(values) for name, values in params.items()} - with _REPORT_CACHE_LOCK: - if key in _REPORT_REFRESHING: - return False - _REPORT_REFRESHING.add(key) - - def refresh(): - try: - if not _REPORT_BUILD_SEMAPHORE.acquire(blocking=False): - return - try: - data, content_type = build_worktime_report_response(path, refresh_params) - save_report_response_cache(key, data, content_type) - except Exception as exc: - log_warning(f"background report refresh failed path={path}: {exc}") - finally: - _REPORT_BUILD_SEMAPHORE.release() - finally: - with _REPORT_CACHE_LOCK: - _REPORT_REFRESHING.discard(key) - - thread = threading.Thread(target=refresh, name="worktime-report-refresh", daemon=True) - thread.start() - return True - - -def send_cached_report(handler, cached, *, reason): - handler.send_response(200) - handler.send_header("Content-Type", cached["content_type"]) - handler.send_header("Content-Length", str(len(cached["data"]))) - handler.send_header("X-AW-Worktime-Cache", "stale" if cached.get("stale") else "fresh") - handler.send_header("X-AW-Worktime-Cache-Reason", reason) - handler.send_header("X-AW-Worktime-Cache-Age", str(cached.get("age_seconds", 0))) - handler.send_header("X-AW-Worktime-Cache-Source", cached.get("cache_source", "memory")) - handler.end_headers() - try: - handler.wfile.write(cached["data"]) - except (BrokenPipeError, ConnectionResetError): - return False - return True - - -def send_report_unavailable(handler, message): - payload = { - "ok": False, - "error": "report_unavailable", - "message": message, - "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), - } - data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") - send_bytes(handler, data, "application/json; charset=utf-8", status=503) - - -def send_worktime_report(handler, path, params, cache_key, *, host, report_date): - cached = get_report_response_cache(cache_key) - if cached is not None: - send_cached_report(handler, cached, reason="ttl") - return - - allow_immediate_stale = str(params.get("allow_stale", [""])[0]).lower() in {"1", "true", "yes"} - if allow_immediate_stale: - cached = get_report_response_cache(cache_key, allow_stale=True) - if cached is not None: - refresh_stale = str(params.get("refresh_stale", [""])[0]).lower() in {"1", "true", "yes"} - if refresh_stale: - trigger_report_background_refresh(cache_key, path, params) - send_cached_report(handler, cached, reason="stale-allowed") - return - - if not _REPORT_BUILD_SEMAPHORE.acquire(blocking=False): - if not allow_immediate_stale and REPORT_BUSY_WAIT_SECONDS > 0: - deadline = time.monotonic() + REPORT_BUSY_WAIT_SECONDS - while True: - remaining = deadline - time.monotonic() - if remaining <= 0: - break - time.sleep(min(REPORT_BUSY_WAIT_INTERVAL_SECONDS, remaining)) - cached = get_report_response_cache(cache_key) - if cached is not None: - send_cached_report(handler, cached, reason="ttl-after-wait") - return - cached = get_report_response_cache(cache_key, allow_stale=True) - if cached is not None: - send_cached_report(handler, cached, reason="busy") - return - send_report_unavailable(handler, "report builder is busy and no cached response is available") - return - - try: - cached = get_report_response_cache(cache_key) - if cached is not None: - send_cached_report(handler, cached, reason="ttl-after-lock") - return - try: - data, content_type = build_worktime_report_response(path, params) - except Exception as exc: - log_warning(f"report build failed path={path} host={host} date={report_date}: {exc}") - cached = get_report_response_cache(cache_key, allow_stale=True) - if cached is not None: - send_cached_report(handler, cached, reason="build-error") - return - send_report_unavailable(handler, "report build failed and no cached response is available") - return - save_report_response_cache(cache_key, data, content_type) - send_bytes(handler, data, content_type) - finally: - _REPORT_BUILD_SEMAPHORE.release() - - -def build_source_freshness(host, interactive_required=True): - source_specs = [ - { - "source_id": "worktime_sessions", - "label": "RDP worktime sessions", - "bucket_candidates": [f"aw-worktime-sessions_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "owner": "ops", - }, - { - "source_id": "rdp_window", - "label": "RDP current window", - "bucket_candidates": [f"aw-rdp-window_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "owner": "ops", - }, - { - "source_id": "rdp_afk", - "label": "RDP AFK", - "bucket_candidates": [f"aw-rdp-afk_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "owner": "ops", - }, - { - "source_id": "watcher_window", - "label": "Local watcher window", - "bucket_candidates": [f"aw-watcher-window_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "interactive_only": True, - "owner": "ops", - }, - { - "source_id": "watcher_afk", - "label": "Local watcher AFK", - "bucket_candidates": [f"aw-watcher-afk_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "owner": "ops", - }, - { - "source_id": "file_operations", - "label": "File operations collector", - "bucket_candidates": [f"aw-file-operations_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "interactive_only": True, - "owner": "ops", - }, - { - "source_id": "web_categories", - "label": "Browser/web categories", - "bucket_candidates": [f"aw-detmir-web-category_{host}"], - "max_age_seconds": MANAGER_WEB_SOURCE_MAX_AGE_SECONDS, - "required": False, - "owner": "ops", - }, - { - "source_id": "session_events", - "label": "Windows session events", - "bucket_candidates": [f"aw-session-events_{host}"], - "max_age_seconds": MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS, - "required": False, - "owner": "ops", - }, - { - "source_id": "pve_tasks", - "label": "PVE task feed", - "bucket_candidates": ["aw-pve-task-events_pve-detmir"], - "max_age_seconds": MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS, - "required": False, - "owner": "ops", - }, - ] - now = now_utc() - sources = [] - actions = [] - for spec in source_specs: - matched_bucket = "" - matched_event = None - matched_age = None - for candidate in spec["bucket_candidates"]: - event = latest_bucket_event(candidate) - if not event: - continue - ts = parse_iso_utc(event.get("timestamp")) - candidate_age = age_seconds(ts, now=now) - if matched_event is None or (candidate_age is not None and (matched_age is None or candidate_age < matched_age)): - matched_bucket = candidate - matched_event = event - matched_age = candidate_age - ts = parse_iso_utc((matched_event or {}).get("timestamp")) - age = matched_age if matched_event is not None else age_seconds(ts, now=now) - if matched_event is None: - status = "fail" if spec["required"] else "warn" - summary = "bucket missing or empty" - elif age is None: - status = "warn" - summary = "timestamp parse failed" - elif age > spec["max_age_seconds"]: - status = "fail" if spec["required"] else "warn" - summary = f"stale ({age}s)" - else: - status = "ok" - summary = f"fresh ({age}s)" - if spec.get("interactive_only") and not interactive_required and status != "ok": - status = "inactive" - summary = "inactive: no active interactive users" - detail = { - "source_id": spec["source_id"], - "label": spec["label"], - "status": status, - "status_label": _source_status_label(status), - "bucket_id": matched_bucket or spec["bucket_candidates"][0], - "timestamp": (matched_event or {}).get("timestamp", ""), - "age_seconds": age, - "required": spec["required"], - "interactive_only": bool(spec.get("interactive_only")), - "interactive_required": bool(interactive_required), - "max_age_seconds": spec["max_age_seconds"], - "summary": summary, - "event_summary": _source_summary(matched_event), - } - sources.append(detail) - if status in {"ok", "inactive"}: - continue - priority = "critical" if spec["required"] else "medium" - actions.append( - _action( - "source_freshness_review", - priority, - spec["owner"], - "today" if spec["required"] else "3d", - f"Источник '{spec['label']}' в состоянии {detail['status_label']}: {summary}.", - "Проверить collector/service, причину отставания и подтвердить, что управленческие выводы по данным ещё надёжны.", - evidence={ - "source_id": spec["source_id"], - "bucket_id": detail["bucket_id"], - "age_seconds": age, - "required": spec["required"], - }, - ) - ) - return sources, actions - - -def _build_management_core(rows, host, report_date, owner_filter="", department_filter=""): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - report_bounds = get_report_bounds(report_date) - workday = get_workday_bounds(report_date) - now_local = datetime.now(REPORT_TZ) - is_today = report_date == now_local.date() - effective_end_local = min(now_local, workday["end_local"]) if is_today else workday["end_local"] - elapsed_seconds = max(0, int((effective_end_local - workday["start_local"]).total_seconds())) - if not is_today: - elapsed_seconds = workday["duration_seconds"] - expected_seconds_per_user = min(workday["duration_seconds"], max(0, elapsed_seconds)) - target_seconds = int(expected_seconds_per_user * (MANAGER_TARGET_COVERAGE_PCT / 100.0)) - low_seconds = int(expected_seconds_per_user * (MANAGER_LOW_COVERAGE_PCT / 100.0)) - late_start_local = workday["start_local"] + timedelta(minutes=MANAGER_LATE_START_GRACE_MINUTES) - early_finish_local = workday["end_local"] - timedelta(minutes=MANAGER_EARLY_FINISH_GRACE_MINUTES) - - roster = [] - actions = [] - active_users = 0 - on_target_users = 0 - below_target_users = 0 - workday_total_active_seconds = 0 - workday_first_values = [] - workday_last_values = [] - top_workday_user = "" - top_workday_seconds = -1 - filtered_rows = [] - - for row in rows: - alias = resolve_user_alias(row.get("user", ""), row.get("user_id", ""), host) - if alias["exclude"]: - continue - if not _matches_management_filters(alias, owner_filter=owner_filter, department_filter=department_filter): - continue - filtered_rows.append(row) - public_row = {key: value for key, value in row.items() if key != "_intervals"} - calendar_active_seconds = int(row.get("active_seconds", 0) or 0) - intervals = row.get("_intervals") or [] - workday_active_seconds, workday_first, workday_last = _interval_overlap_seconds( - intervals, - workday["start_local"].astimezone(timezone.utc), - effective_end_local.astimezone(timezone.utc), - ) - first_activity = parse_iso_utc(row.get("first_activity")) - last_activity = parse_iso_utc(row.get("last_activity")) - first_local = first_activity.astimezone(REPORT_TZ) if first_activity else None - last_local = last_activity.astimezone(REPORT_TZ) if last_activity else None - workday_first_local = workday_first.astimezone(REPORT_TZ) if workday_first else None - workday_last_local = workday_last.astimezone(REPORT_TZ) if workday_last else None - coverage_pct = _clamp_pct((workday_active_seconds / expected_seconds_per_user) * 100.0) if expected_seconds_per_user > 0 else 0.0 - status = "ok" - if workday_active_seconds <= 0: - status = "inactive" - elif workday_active_seconds < target_seconds: - status = "below_target" - if workday_active_seconds > 0: - active_users += 1 - workday_total_active_seconds += workday_active_seconds - if workday_first_local: - workday_first_values.append(workday_first_local.isoformat()) - if workday_last_local: - workday_last_values.append(workday_last_local.isoformat()) - if workday_active_seconds >= target_seconds and workday_active_seconds > 0: - on_target_users += 1 - elif workday_active_seconds > 0: - below_target_users += 1 - if workday_active_seconds > top_workday_seconds: - top_workday_seconds = workday_active_seconds - top_workday_user = alias["display_name"] - - roster.append( - { - **public_row, - "user": alias["display_name"], - "user_original": row.get("user", ""), - "manager_owner": alias["manager_owner"], - "department": alias["department"], - "role": alias["role"], - "notes": alias["notes"], - "canonical_user_id": alias["canonical_user_id"] or row.get("user_id", ""), - "calendar_active_seconds": calendar_active_seconds, - "calendar_active_hhmm": row.get("active_hhmm", "00:00"), - "workday_active_seconds": workday_active_seconds, - "workday_active_hhmm": hhmm(workday_active_seconds), - "coverage_pct": coverage_pct, - "status": status, - "first_activity_local": first_local.isoformat() if first_local else "", - "last_activity_local": last_local.isoformat() if last_local else "", - "workday_first_activity_local": workday_first_local.isoformat() if workday_first_local else "", - "workday_last_activity_local": workday_last_local.isoformat() if workday_last_local else "", - } - ) - - owner = alias["display_name"] - user_id = alias["canonical_user_id"] or row.get("user_id", "") - evidence = { - "calendar_active_hhmm": row.get("active_hhmm", "00:00"), - "workday_active_hhmm": hhmm(workday_active_seconds), - "coverage_pct": coverage_pct, - "first_activity": row.get("first_activity", ""), - "last_activity": row.get("last_activity", ""), - "sessions_count": row.get("sessions_count", 0), - "manager_owner": alias["manager_owner"], - "department": alias["department"], - "role": alias["role"], - } - if workday_active_seconds <= 0: - actions.append( - _action( - "missing_activity", - "critical", - alias["manager_owner"], - "today", - f"За {report_date.isoformat()} у сотрудника {owner} нет подтверждённой активности в рабочем окне RDP.", - f"Проверить сотрудника {owner}: работал ли он в рабочее время, была ли потеря сбора данных или отсутствие входа в систему.", - user_id=user_id, - evidence=evidence, - ) - ) - continue - if expected_seconds_per_user > 0 and workday_active_seconds < low_seconds: - actions.append( - _action( - "low_activity_review", - "high", - alias["manager_owner"], - "24h", - f"У сотрудника {owner} активное время в рабочем окне {hhmm(workday_active_seconds)} ниже {MANAGER_LOW_COVERAGE_PCT}% от ожидаемого окна.", - f"Проверить загрузку сотрудника {owner}, задачи и фактическое присутствие в рабочем процессе.", - user_id=user_id, - evidence=evidence, - ) - ) - elif expected_seconds_per_user > 0 and workday_active_seconds < target_seconds: - actions.append( - _action( - "target_gap_review", - "medium", - alias["manager_owner"], - "24h", - f"У сотрудника {owner} активное время в рабочем окне {hhmm(workday_active_seconds)} ниже управленческого целевого порога {MANAGER_TARGET_COVERAGE_PCT}%.", - f"Уточнить причину отклонения по сотруднику {owner} и подтвердить план работ.", - user_id=user_id, - evidence=evidence, - ) - ) - if workday_first_local and workday_first_local > late_start_local: - actions.append( - _action( - "late_start_review", - "medium", - alias["manager_owner"], - "24h", - f"У сотрудника {owner} первая активность в рабочем окне зафиксирована поздно: {workday_first_local.strftime('%H:%M')}.", - f"Проверить причину позднего старта сотрудника {owner} и подтвердить, что это не проблема доступа или дисциплины.", - user_id=user_id, - evidence=evidence, - ) - ) - if (not is_today) and workday_last_local and workday_last_local < early_finish_local: - actions.append( - _action( - "early_finish_review", - "medium", - alias["manager_owner"], - "24h", - f"У сотрудника {owner} последняя активность в рабочем окне завершилась рано: {workday_last_local.strftime('%H:%M')}.", - f"Проверить, было ли досрочное завершение рабочего дня сотрудника {owner} согласовано и чем оно объясняется.", - user_id=user_id, - evidence=evidence, - ) - ) - - actions.sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) - calendar_summary = build_report_summary(filtered_rows) - inactive_users = sum(1 for row in roster if row["status"] == "inactive") - portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(roster))) * 100.0) if roster and expected_seconds_per_user > 0 else 0.0 - calendar_first = calendar_summary.get("first_activity", "") - calendar_last = calendar_summary.get("last_activity", "") - return { - "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), - "host": resolve_host(host), - "report_date": report_date.isoformat(), - "report_timezone": str(REPORT_TZ), - "filters": { - "owner": owner_filter, - "department": department_filter, - }, - "workday": { - "start_local": workday["start_local"].isoformat(), - "end_local": workday["end_local"].isoformat(), - "expected_seconds_per_user": expected_seconds_per_user, - "expected_hhmm_per_user": hhmm(expected_seconds_per_user), - "target_coverage_pct": MANAGER_TARGET_COVERAGE_PCT, - "low_coverage_pct": MANAGER_LOW_COVERAGE_PCT, - }, - "summary": { - **calendar_summary, - "calendar_total_active_seconds": calendar_summary["total_active_seconds"], - "calendar_total_active_hhmm": calendar_summary["total_active_hhmm"], - "calendar_first_activity": calendar_first, - "calendar_last_activity": calendar_last, - "workday_total_active_seconds": workday_total_active_seconds, - "workday_total_active_hhmm": hhmm(workday_total_active_seconds), - "workday_first_activity": min(workday_first_values) if workday_first_values else "", - "workday_last_activity": max(workday_last_values) if workday_last_values else "", - "total_active_seconds": workday_total_active_seconds, - "total_active_hhmm": hhmm(workday_total_active_seconds), - "first_activity": min(workday_first_values) if workday_first_values else "", - "last_activity": max(workday_last_values) if workday_last_values else "", - "top_user": top_workday_user, - "top_user_active_hhmm": hhmm(top_workday_seconds if top_workday_seconds > 0 else 0), - "active_users": active_users, - "inactive_users": inactive_users, - "on_target_users": on_target_users, - "below_target_users": below_target_users, - "portfolio_coverage_pct": portfolio_coverage_pct, - "actions_count": len(actions), - "critical_actions_count": sum(1 for action in actions if action["priority"] == "critical"), - "high_actions_count": sum(1 for action in actions if action["priority"] == "high"), - }, - "actions": actions, - "rows": roster, - "bucket_id": get_sessions_bucket_id(host), - "report_bounds": { - "start_utc": to_iso_utc(report_bounds["start"]), - "end_utc": to_iso_utc(report_bounds["end"]), - }, - } - - -def _management_trend_item(payload, report_date): - summary = payload["summary"] - return { - "report_date": report_date.isoformat(), - "users_count": summary["users_count"], - "active_users": summary["active_users"], - "inactive_users": summary["inactive_users"], - "workday_total_active_seconds": summary["workday_total_active_seconds"], - "workday_total_active_hhmm": summary["workday_total_active_hhmm"], - "portfolio_coverage_pct": summary["portfolio_coverage_pct"], - "actions_count": summary["actions_count"], - "critical_actions_count": summary["critical_actions_count"], - } - - -def build_management_trend(host, anchor_date, owner_filter="", department_filter="", precomputed_payloads=None): - trend = [] - precomputed_payloads = precomputed_payloads or {} - for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): - current_date = anchor_date - timedelta(days=offset) - payload = precomputed_payloads.get(current_date) - if payload is None: - payload = load_management_cache(host, current_date) - if payload is None: - bounds, events = fetch_events_for_date(host, current_date) - rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) - payload = _build_management_core(rows, host, current_date, owner_filter=owner_filter, department_filter=department_filter) - elif owner_filter or department_filter: - payload = apply_management_filters_to_payload( - payload, - owner_filter=owner_filter, - department_filter=department_filter, - include_sources=False, - include_source_actions=False, - ) - trend.append(_management_trend_item(payload, current_date)) - return trend - - -def build_filtered_management_trend(host, anchor_date, owner_filter="", department_filter=""): - trend = [] - for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): - current_date = anchor_date - timedelta(days=offset) - base_payload = load_management_cache(host, current_date) - if base_payload is None: - bounds, events = fetch_events_for_date(host, current_date) - rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) - base_payload = _build_management_core(rows, host, current_date) - filtered_payload = apply_management_filters_to_payload( - base_payload, - owner_filter=owner_filter, - department_filter=department_filter, - include_sources=False, - include_source_actions=False, - ) - summary = filtered_payload["summary"] - trend.append( - { - "report_date": current_date.isoformat(), - "users_count": summary["users_count"], - "active_users": summary["active_users"], - "inactive_users": summary["inactive_users"], - "workday_total_active_seconds": summary["workday_total_active_seconds"], - "workday_total_active_hhmm": summary["workday_total_active_hhmm"], - "portfolio_coverage_pct": summary["portfolio_coverage_pct"], - "actions_count": summary["actions_count"], - "critical_actions_count": summary["critical_actions_count"], - } - ) - return trend - - -def build_management_payload(rows, host, report_date, owner_filter="", department_filter=""): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter) - interactive_required = int(payload.get("summary", {}).get("active_users", 0) or 0) > 0 - source_freshness, source_actions = build_source_freshness(resolve_host(host), interactive_required=interactive_required) - payload["sources"] = source_freshness - payload["trend"] = build_management_trend( - resolve_host(host), - report_date, - owner_filter=owner_filter, - department_filter=department_filter, - precomputed_payloads={report_date: payload}, - ) - payload["trend_scope"] = "portfolio" - if source_actions: - payload["actions"].extend(filter_management_actions(source_actions, payload["rows"], owner_filter=owner_filter, department_filter=department_filter)) - payload["actions"].sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) - payload["summary"]["actions_count"] = len(payload["actions"]) - payload["summary"]["critical_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "critical") - payload["summary"]["high_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "high") - payload["executive"] = build_executive_summary(payload["summary"], payload["actions"], payload["sources"]) - payload["owner_rollups"] = build_owner_rollups(payload["rows"], payload["actions"]) - payload["department_rollups"] = build_department_rollups(payload["rows"], payload["actions"]) - payload["owner_roster"] = build_owner_roster(payload["rows"], payload["actions"]) - return payload - - -def report_for_date(host, report_date): - bounds, events = fetch_events_for_date(host, report_date) - return aggregate_rows(events, bounds["start"], bounds["end"], host) - - -def management_report_for_date(host, report_date, owner_filter="", department_filter="", use_cache=True): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - if owner_filter or department_filter: - base_payload = management_report_for_date(host, report_date, use_cache=use_cache) - filtered_payload = apply_management_filters_to_payload( - base_payload, - owner_filter=owner_filter, - department_filter=department_filter, - include_sources=True, - include_source_actions=True, - ) - filtered_payload["trend"] = [] - filtered_payload["trend_scope"] = "filtered_current_only" - return filtered_payload - cache_allowed = use_cache or report_date < datetime.now(REPORT_TZ).date() - if cache_allowed: - cached = load_management_cache(host, report_date) - if cached is not None: - return cached - lock = get_management_build_lock(host, report_date) - with lock: - if cache_allowed: - cached = load_management_cache(host, report_date) - if cached is not None: - return cached - bounds, events = fetch_events_for_date(host, report_date) - rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) - payload = build_management_payload(rows, host, report_date) - save_management_cache(host, report_date, payload) - return payload - - -def report_today(host): - return report_for_date(host, resolve_report_date()) - - -def report_for_date_fresh(host, report_date): - spec = importlib.util.spec_from_file_location("aw_worktime_runtime", MODULE_PATH) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.report_for_date(host, report_date) - - -def render_html(rows, host, report_date, selected_day=None, true_active_apps=None): - generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - date_local = report_date.strftime("%Y-%m-%d") - day_query = f"&day={selected_day}" if selected_day in {"today", "yesterday"} else "" - date_query = f"&date={date_local}" if not day_query else "" - summary = build_report_summary(rows) - today_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "today"}) - yesterday_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "yesterday"}) - csv_url = "/reports/worktime/today?" + urlencode({"format": "csv", "host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) - json_url = "/reports/worktime/today?" + urlencode({"host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) - form_action = "/reports/worktime/today" - cards = [ - ("Пользователи", str(summary["users_count"])), - ("Активное время", summary["total_active_hhmm"]), - ("Лидер дня", f"{summary['top_user']} · {summary['top_user_active_hhmm']}" if summary["top_user"] else "н/д"), - ("Диапазон", f"{summary['first_activity']} -> {summary['last_activity']}" if summary["first_activity"] else "нет активности"), - ] - trs = [] - detail_cards = [] - true_active_apps = true_active_apps or [] - true_active_rows = [] - for app_row in true_active_apps: - last_action = app_row.get("last_action") or "-" - last_time = app_row.get("last_action_local") or "-" - true_active_rows.append( - "" - f"{html.escape(app_row.get('application') or '-')}" - f"{html.escape(app_row.get('proved_work_human') or app_row.get('proved_work_hhmm') or '0 сек')}" - f"{html.escape(last_time)} · {html.escape(last_action)}" - "" - ) - if not true_active_rows: - true_active_rows.append("Пока нет доказанной активной работы по приложениям за выбранную дату.") - for row in rows: - user_slug = safe_slug(row["user"]) - active_seconds = int(row.get("active_seconds", 0) or 0) - utilization = 0.0 - day_total = 24 * 3600 - if day_total > 0: - utilization = round((active_seconds / day_total) * 100.0, 2) - trs.append( - "" - f"{html.escape(row['user'])}" - f"{html.escape(row['user_id'])}" - f"{row['active_hhmm']}" - f"{row['active_seconds']}" - f"{html.escape(row['first_activity'])}" - f"{html.escape(row['last_activity'])}" - f"{row['idle_seconds']}" - f"{row['sessions_count']}" - f"{row['samples_count']}" - "" - ) - detail_cards.append( - "
" - "
" - "

{user}

" - "{active}" - "
" - "
" - "
Пользователь{user_id}
" - "
Загрузка{utilization}%
" - "
Начало активности{first_activity}
" - "
Конец активности{last_activity}
" - "
Сессии{sessions}
" - "
Активные сэмплы{active_samples} / {samples}
" - "
" - "
" - .format( - slug=user_slug, - user=html.escape(row["user"]), - active=html.escape(row["active_hhmm"]), - user_id=html.escape(row["user_id"]), - utilization=utilization, - first_activity=html.escape(row["first_activity"] or "н/д"), - last_activity=html.escape(row["last_activity"] or "н/д"), - sessions=row["sessions_count"], - active_samples=row["active_samples"], - samples=row["samples_count"], - )) - if not trs: - trs.append('За выбранную дату данных пока нет.') - detail_cards.append("

За выбранную дату нет активности пользователей.

") - return f""" - - - - - AW-rus Отчёт по работе в RDP - - - -
-
-

Отчёт по работе в RDP

-
Хост: {resolve_host(host)} · Дата: {date_local} · Часовой пояс: {REPORT_TZ} · Сформировано UTC: {generated}
- -
-
- - - - -
-
-
- {''.join(f"
{html.escape(label)}{html.escape(value)}
" for label, value in cards)} -
-
-
-

Доказанная работа по приложениям

- - - - - - - - - - {''.join(true_active_rows)} - -
ПриложениеДоказанная работаПоследнее действие
-
-
-

Таблица по пользователям

- - - - - - - - - - - - - - - - {''.join(trs)} - -
ПользовательУчётная записьАктивноАктивно, секНачало активностиКонец активностиПростой, секСессииСэмплы
-
-
-

Детали по пользователям

-
- {''.join(detail_cards)} -
-
-
- -""" - - -def build_management_report_url(host, report_date, selected_day=None, fmt=None, owner_filter="", department_filter=""): - params = {"host": resolve_host(host)} - if fmt: - params["format"] = fmt - if selected_day in {"today", "yesterday"}: - params["day"] = selected_day - else: - params["date"] = report_date - if normalize_management_filter(owner_filter): - params["owner"] = normalize_management_filter(owner_filter) - if normalize_management_filter(department_filter): - params["department"] = normalize_management_filter(department_filter) - return "/reports/worktime/management?" + urlencode(params) - - -def render_management_html(payload, selected_day=None): - summary = payload["summary"] - workday = payload["workday"] - report_date = payload["report_date"] - host = payload["host"] - executive = payload.get("executive") or {} - active_filters = payload.get("filters") or {} - owner_filter = normalize_management_filter(active_filters.get("owner")) - department_filter = normalize_management_filter(active_filters.get("department")) - today_url = build_management_report_url(host, report_date, selected_day="today", fmt="html", owner_filter=owner_filter, department_filter=department_filter) - yesterday_url = build_management_report_url(host, report_date, selected_day="yesterday", fmt="html", owner_filter=owner_filter, department_filter=department_filter) - json_url = build_management_report_url(host, report_date, selected_day=selected_day, owner_filter=owner_filter, department_filter=department_filter) - classic_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": host, **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": report_date})}) - reset_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html") - actions_html = [] - for action in payload["actions"]: - actions_html.append( - "" - f"{html.escape(action['priority'])}" - f"{html.escape(action['owner'])}" - f"{html.escape(action['action_id'])}" - f"{html.escape(action['deadline_hint'])}" - f"{html.escape(action['reason'])}" - f"{html.escape(action['recommended_action'])}" - "" - ) - if not actions_html: - actions_html.append("Отклонений по текущим правилам не найдено.") - - roster_html = [] - for row in payload["rows"]: - roster_html.append( - "" - f"{html.escape(row['user'])}" - f"{html.escape(row.get('canonical_user_id') or row['user_id'])}" - f"{html.escape(row.get('manager_owner') or row['user'])}" - f"{html.escape(row.get('department') or '-')}" - f"{html.escape(row['workday_active_hhmm'])}" - f"{html.escape(row['calendar_active_hhmm'])}" - f"{row['coverage_pct']}" - f"{html.escape(row['status'])}" - f"{html.escape(row.get('workday_first_activity_local') or '-')}" - f"{html.escape(row.get('workday_last_activity_local') or '-')}" - f"{row['sessions_count']}" - "" - ) - if not roster_html: - roster_html.append("За выбранную дату данных нет.") - - trend_html = [] - for row in payload.get("trend", []): - trend_html.append( - "" - f"{html.escape(row['report_date'])}" - f"{row['users_count']}" - f"{row['active_users']}" - f"{row['inactive_users']}" - f"{html.escape(row['workday_total_active_hhmm'])}" - f"{row['portfolio_coverage_pct']}" - f"{row['actions_count']}" - f"{row['critical_actions_count']}" - "" - ) - if not trend_html: - trend_message = "Тренд по выбранному фильтру пока отключён, чтобы current-scope отчёт отвечал быстро." if (owner_filter or department_filter) else "Тренд пока недоступен." - trend_html.append(f"{html.escape(trend_message)}") - - owner_rollup_html = [] - for item in payload.get("owner_rollups", []): - owner_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=item["name"], department_filter=department_filter) - owner_rollup_html.append( - "" - f"{html.escape(item['name'])}" - f"{item['users_count']}" - f"{item['inactive_users']}" - f"{item['below_target_users']}" - f"{item['critical_actions_count']}" - f"{item['high_actions_count']}" - f"{html.escape(item['workday_total_active_hhmm'])}" - f"{item['portfolio_coverage_pct']}" - f"{html.escape(', '.join(item['users']) if item['users'] else '-')}" - "" - ) - if not owner_rollup_html: - owner_rollup_html.append("Нет данных по ответственным.") - - department_rollup_html = [] - for item in payload.get("department_rollups", []): - department_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=owner_filter, department_filter=item["name"]) - department_rollup_html.append( - "" - f"{html.escape(item['name'])}" - f"{item['users_count']}" - f"{item['inactive_users']}" - f"{item['below_target_users']}" - f"{item['critical_actions_count']}" - f"{item['high_actions_count']}" - f"{html.escape(item['workday_total_active_hhmm'])}" - f"{item['portfolio_coverage_pct']}" - f"{html.escape(', '.join(item['users']) if item['users'] else '-')}" - "" - ) - if not department_rollup_html: - department_rollup_html.append("Нет данных по подразделениям.") - - owner_profile_html = [] - for item in payload.get("owner_roster", []): - owner_profile_html.append( - "
" - f"
0 else ('high' if item['high_actions_count'] > 0 else 'low')}'>{html.escape(item['display_name'])}
" - f"

{html.escape(item['title'] or item['name'])}

" - f"
Подразделение: {html.escape(item['department'] or '-')}
" - f"

Пользователи: {item['users_count']} · inactive: {item['inactive_users']} · actions: {item['actions_count']}

" - f"

Контакт: {html.escape(item['contact'] or '-')}

" - f"

Эскалация: {html.escape(item['escalation_to'] or '-')}

" - f"

{html.escape(item['notes'] or 'Без дополнительных заметок.')}

" - "
" - ) - if not owner_profile_html: - owner_profile_html.append("

Каталог ответственных пуст

Добавьте блок owners в worktime-manager-aliases.json, чтобы отчёт показывал роли, контакты и эскалацию.

") - - sources_html = [] - for source in payload.get("sources", []): - source_priority_class = "low" if source["status"] in {"ok", "inactive"} else ("critical" if source["required"] else "medium") - sources_html.append( - "" - f"{html.escape(source['label'])}" - f"{html.escape(source['status_label'])}" - f"{html.escape(source['bucket_id'])}" - f"{html.escape(source.get('timestamp') or '-')}" - f"{html.escape(str(source.get('age_seconds')) if source.get('age_seconds') is not None else '-')}" - f"{html.escape(source.get('event_summary') or source.get('summary') or '-')}" - "" - ) - if not sources_html: - sources_html.append("Статусы источников недоступны.") - - focus_html = [] - for item in executive.get("focus_items", []): - focus_html.append( - "
" - f"
{html.escape(item['priority'])}
" - f"

{html.escape(item['title'])}

" - f"
Ответственный: {html.escape(item['owner'])}
" - f"

{html.escape(item['reason'])}

" - f"{html.escape(item['recommended_action'])}" - "
" - ) - if not focus_html: - focus_html.append("

Критичных действий нет

На текущий момент менеджерских отклонений по активным правилам не найдено.

") - - stale_html = [] - for item in executive.get("stale_sources", []): - stale_html.append( - "
  • " - f"{html.escape(item['label'])}: {html.escape(item['status'])} · {html.escape(item['summary'])}" - "
  • " - ) - stale_block = ( - "
    Проблемы со свежестью источников:
      " - + "".join(stale_html) - + "
    " - ) if stale_html else "" - filter_parts = [] - if owner_filter: - filter_parts.append(f"ответственный: {html.escape(owner_filter)}") - if department_filter: - filter_parts.append(f"подразделение: {html.escape(department_filter)}") - filter_block = ( - "
    Фильтр: " - + " · ".join(filter_parts) - + f" Сбросить
    " - ) if filter_parts else "" - - cards = [ - ("Пользователи", str(summary["users_count"])), - ("Активны", str(summary["active_users"])), - ("Без активности", str(summary["inactive_users"])), - ("Ниже цели", str(summary["below_target_users"])), - ("Покрытие", f"{summary['portfolio_coverage_pct']}%"), - ("Действия", str(summary["actions_count"])), - ("Рабочее окно", summary["workday_total_active_hhmm"]), - ("Календарный день", summary["calendar_total_active_hhmm"]), - ] - - return f""" - - - - - AW-rus Управленческий отчёт по работе в RDP - - - -
    -
    -

    Управленческий отчёт по работе в RDP

    -
    Хост: {html.escape(host)} · Дата: {html.escape(report_date)} · Рабочее окно: {html.escape(workday['start_local'])} -> {html.escape(workday['end_local'])} · Сформировано UTC: {html.escape(payload['generated_at_utc'])}
    - -
    - {''.join(f"
    {html.escape(label)}{html.escape(value)}
    " for label, value in cards)} -
    -
    - Целевое покрытие: {MANAGER_TARGET_COVERAGE_PCT}% от ожидаемого рабочего окна на пользователя. - Критический провал: ниже {MANAGER_LOW_COVERAGE_PCT}% или полное отсутствие активности. - Рабочее окно считается отдельно от календарной активности, чтобы ночная работа не маскировала дневной провал. -
    -
    -
    -

    Что делать сегодня

    -
    - {html.escape(executive.get('headline') or 'Сводка недоступна')} -

    {html.escape(executive.get('message') or '')}

    - {filter_block} - {stale_block} -
    -
    - {''.join(focus_html)} -
    -
    -
    -

    Тренд за {MANAGER_TREND_DAYS} дней

    - - - - - - - - - - - - - - - {''.join(trend_html)} - -
    ДатаПользователиАктивныБез активностиРабочее окноПокрытие, %ДействияCritical
    -
    -
    -

    По ответственным

    - - - - - - - - - - - - - - - - {''.join(owner_rollup_html)} - -
    ОтветственныйСотрудникиБез активностиНиже целиCriticalHighРабочее окноПокрытие, %Кого затрагивает
    -
    -
    -

    Ответственные и эскалация

    -
    - {''.join(owner_profile_html)} -
    -
    -
    -

    По подразделениям

    - - - - - - - - - - - - - - - - {''.join(department_rollup_html)} - -
    ПодразделениеСотрудникиБез активностиНиже целиCriticalHighРабочее окноПокрытие, %Кого затрагивает
    -
    -
    -

    Очередь действий руководителя

    - - - - - - - - - - - - - {''.join(actions_html)} - -
    ПриоритетСотрудникТипСрокПочему это важноЧто сделать
    -
    -
    -

    Покрытие по сотрудникам

    - - - - - - - - - - - - - - - - - - {''.join(roster_html)} - -
    СотрудникУчётная записьОтветственныйПодразделениеАктивно в окнеАктивно за деньПокрытие, %СтатусПервая активность в окнеПоследняя активность в окнеСессии
    -
    -
    -

    Свежесть источников данных

    - - - - - - - - - - - - - {''.join(sources_html)} - -
    ИсточникСтатусБакетПоследнее событие UTCВозраст, секКонтекст
    -
    -
    - -""" - - -def build_worktime_report_response(path, params): - fmt = resolve_report_format(params) - host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) - day = params.get("day", ["today"])[0] - date_text = params.get("date", [None])[0] - owner_filter = normalize_management_filter(params.get("owner", [""])[0]) - department_filter = normalize_management_filter(params.get("department", [""])[0]) - report_date = resolve_report_date(day=day, date_text=date_text) - is_management = path == "/reports/worktime/management" - allow_immediate_stale = str(params.get("allow_stale", [""])[0]).lower() in {"1", "true", "yes"} - management_payload = ( - management_report_for_date( - host, - report_date, - owner_filter=owner_filter, - department_filter=department_filter, - use_cache=allow_immediate_stale, - ) - if is_management - else None - ) - rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] - true_active_apps = [] if is_management else build_true_active_apps(host, report_date) - - if fmt == "csv": - if is_management: - out = io.StringIO() - writer = csv.DictWriter( - out, - fieldnames=["priority", "owner", "user_id", "action_id", "deadline_hint", "reason", "recommended_action"], - extrasaction="ignore", - ) - writer.writeheader() - writer.writerows(management_payload["actions"]) - return out.getvalue().encode(), "text/csv; charset=utf-8" - out = io.StringIO() - writer = csv.DictWriter( - out, - fieldnames=[ - "user", - "user_id", - "active_seconds", - "active_hhmm", - "first_activity", - "last_activity", - "idle_seconds", - "sessions_count", - "samples_count", - "active_samples", - ], - ) - writer.writeheader() - writer.writerows(rows) - return out.getvalue().encode(), "text/csv; charset=utf-8" - - if fmt == "html": - if is_management: - data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") - else: - data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None, true_active_apps=true_active_apps).encode("utf-8") - return data, "text/html; charset=utf-8" - - if is_management: - data = json.dumps(management_payload, ensure_ascii=False, indent=2).encode("utf-8") - return data, "application/json; charset=utf-8" - - obj = { - "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), - "report_timezone": str(REPORT_TZ), - "host": host, - "report_date": report_date.isoformat(), - "bucket_id": get_sessions_bucket_id(host), - "rows": rows, - "true_active_apps": true_active_apps, - } - data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") - return data, "application/json; charset=utf-8" - - -def send_bytes(handler, data, content_type, status=200): - handler.send_response(status) - handler.send_header("Content-Type", content_type) - handler.send_header("Content-Length", str(len(data))) - handler.end_headers() - try: - handler.wfile.write(data) - except (BrokenPipeError, ConnectionResetError): - return False - return True - - -class H(BaseHTTPRequestHandler): - def do_GET(self): - parsed = urlparse(self.path) - if parsed.path in {"/health", "/api/health"}: - data = json.dumps(worktime_health_payload(), ensure_ascii=False, indent=2).encode("utf-8") - send_bytes(self, data, "application/json; charset=utf-8") - return - - if parsed.path.startswith("/dlp-ioc/"): - name = parsed.path.rsplit("/", 1)[-1] - if name not in {"ioc_blacklist.json", "ioc_blacklist.csv", "ioc_blacklist.sql"}: - self.send_response(404) - self.end_headers() - return - path = os.path.join(IOC_DIR, name) - if not os.path.isfile(path): - self.send_response(404) - self.end_headers() - return - with open(path, "rb") as f: - data = f.read() - if name.endswith(".json"): - ctype = "application/json; charset=utf-8" - elif name.endswith(".csv"): - ctype = "text/csv; charset=utf-8" - else: - ctype = "text/plain; charset=utf-8" - send_bytes(self, data, ctype) - return - - if parsed.path not in {"/reports/worktime/today", "/reports/worktime/management"}: - self.send_response(404) - self.end_headers() - return - - params = parse_qs(parsed.query, keep_blank_values=False) - fmt = resolve_report_format(params, self.headers.get("Accept", "")) - if not params.get("format") and fmt != "json": - params = {name: list(values) for name, values in params.items()} - params["format"] = [fmt] - host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) - day = params.get("day", ["today"])[0] - date_text = params.get("date", [None])[0] - owner_filter = normalize_management_filter(params.get("owner", [""])[0]) - department_filter = normalize_management_filter(params.get("department", [""])[0]) - report_date = resolve_report_date(day=day, date_text=date_text) - cache_key = make_report_cache_key( - parsed.path, - fmt, - host, - report_date, - selected_day=day if day in {"today", "yesterday"} else "", - owner_filter=owner_filter, - department_filter=department_filter, - ) - - send_worktime_report(self, parsed.path, params, cache_key, host=host, report_date=report_date) - - -class WorktimeHTTPServer(ThreadingHTTPServer): - daemon_threads = True - request_queue_size = 64 - - -def main(): - WorktimeHTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever(poll_interval=0.5) - - -if __name__ == "__main__": - main() diff --git a/aw-server/aw-worktime-api.service b/aw-server/aw-worktime-api.service index e85d3bf..07e32d8 100644 --- a/aw-server/aw-worktime-api.service +++ b/aw-server/aw-worktime-api.service @@ -8,7 +8,7 @@ StartLimitIntervalSec=60 [Service] Type=simple EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py +ExecStart=/usr/local/bin/aw-worktime-api-rust Restart=on-failure RestartSec=5 User=activitywatch diff --git a/aw-server/aw-worktime-autoheal.service b/aw-server/aw-worktime-autoheal.service index e5bd3ec..37244ff 100644 --- a/aw-server/aw-worktime-autoheal.service +++ b/aw-server/aw-worktime-autoheal.service @@ -7,10 +7,9 @@ Wants=network-online.target Type=oneshot Environment=AW_URL=http://127.0.0.1:5600 Environment=AW_WORKTIME_HOST=SHARKON2025 -ExecStart=/usr/local/bin/aw-worktime-autoheal.sh +ExecStart=/usr/local/bin/aw-worktime-autoheal-rust User=root Group=root StandardOutput=journal StandardError=journal SyslogIdentifier=aw-worktime-autoheal - diff --git a/aw-server/aw-worktime-autoheal.sh b/aw-server/aw-worktime-autoheal.sh deleted file mode 100644 index d027072..0000000 --- a/aw-server/aw-worktime-autoheal.sh +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -AW_URL="${AW_URL:-http://127.0.0.1:5600}" -HOST="${AW_WORKTIME_HOST:-SHARKON2025}" -PYTHON_BIN="${PYTHON_BIN:-/usr/bin/python3}" -WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_URL:-http://127.0.0.1:5610/health}" -WORKTIME_REPORT_TIMEOUT_SECONDS="${WORKTIME_REPORT_TIMEOUT_SECONDS:-20}" -WORKTIME_MANAGEMENT_WARM_ENABLED="${WORKTIME_MANAGEMENT_WARM_ENABLED:-1}" -WORKTIME_MANAGEMENT_WARM_URL="${WORKTIME_MANAGEMENT_WARM_URL:-http://127.0.0.1:5610/reports/worktime/management?day=today&format=json}" -WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-60}" -WORKTIME_TODAY_PROBE_ENABLED="${WORKTIME_TODAY_PROBE_ENABLED:-1}" -WORKTIME_TODAY_PROBE_URL="${WORKTIME_TODAY_PROBE_URL:-http://127.0.0.1:5610/reports/worktime/today?day=today&format=json}" -WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS="${WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS:-20}" -WORKTIME_SESSION_FRESHNESS_SECONDS="${WORKTIME_SESSION_FRESHNESS_SECONDS:-600}" -LOG_TAG="aw-worktime-autoheal" - -log() { - logger -t "$LOG_TAG" "$*" - printf '%s %s\n' "$(date '+%F %T')" "$*" -} - -probe_url() { - local url="$1" - local timeout="$2" - curl -fsS --max-time "$timeout" "$url" >/dev/null 2>&1 -} - -probe_reports() { - probe_url "$WORKTIME_HEALTH_URL" "$WORKTIME_REPORT_TIMEOUT_SECONDS" || return 1 - if [[ "$WORKTIME_TODAY_PROBE_ENABLED" == "1" ]]; then - probe_url "$WORKTIME_TODAY_PROBE_URL" "$WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS" || return 1 - fi -} - -worktime_ok=1 -if ! probe_reports; then - log "worktime API probe failed, restarting aw-worktime-api.service" - systemctl restart aw-worktime-api.service || true - sleep 2 - if ! probe_reports; then - log "worktime API still degraded after restart" - worktime_ok=0 - else - log "worktime API recovered after restart" - fi -fi - -if [[ "$worktime_ok" != "1" ]]; then - log "skip warm/heal because worktime API is still unavailable" - exit 0 -fi - -if [[ "$WORKTIME_MANAGEMENT_WARM_ENABLED" == "1" ]]; then - if probe_url "$WORKTIME_MANAGEMENT_WARM_URL" "$WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS"; then - log "management cache warm ok" - else - log "management cache warm failed" - fi -fi - -need_heal="$("$PYTHON_BIN" - <<'PY' -import json, urllib.request, datetime, os, sys -AW=os.environ.get("AW_URL","http://127.0.0.1:5600") -host=os.environ.get("HOST","SHARKON2025") -window_bucket=f"aw-rdp-window_{host}" -session_bucket=f"aw-worktime-sessions_{host}" -freshness=int(os.environ.get("WORKTIME_SESSION_FRESHNESS_SECONDS","600")) -msk=datetime.timezone(datetime.timedelta(hours=3)) -start=datetime.datetime.now(msk).replace(hour=0,minute=0,second=0,microsecond=0).astimezone(datetime.timezone.utc) -now=datetime.datetime.now(datetime.timezone.utc) - -def p(ts): - if ts.endswith("Z"): ts=ts[:-1] + "+00:00" - return datetime.datetime.fromisoformat(ts).astimezone(datetime.timezone.utc) - -def is_active(d): - if isinstance(d.get("active"), bool) and d.get("active"): - return True - st=str(d.get("state","")).strip().lower() - if st in ("active","активно"): - return True - if st == "unknown": - try: - sid=int(d.get("sessionId")) - except Exception: - sid=-1 - u=str(d.get("username","")).strip().lower() - sn=str(d.get("sessionName","")).strip().lower() - if sid > 0 and u and (not u.endswith("$")) and (sn.startswith("rdp-") or sn == "console"): - return True - return False - -try: - with urllib.request.urlopen(f"{AW}/api/0/buckets/{window_bucket}/events?limit=12000", timeout=25) as r: - window_events=json.loads(r.read().decode("utf-8")) - with urllib.request.urlopen(f"{AW}/api/0/buckets/{session_bucket}/events?limit=12000", timeout=25) as r: - session_events=json.loads(r.read().decode("utf-8")) -except Exception: - print("1") - sys.exit(0) - -latest_ts=None -active_users=set() -for e in session_events: - ts=e.get("timestamp") - if not ts: - continue - try: - cur=p(ts) - except Exception: - continue - if latest_ts is None or cur > latest_ts: - latest_ts=cur - active_users.clear() - if cur == latest_ts: - d=e.get("data") or {} - u=str(d.get("username","")).strip() - if u and is_active(d): - active_users.add(u) - -if latest_ts is None or (now - latest_ts).total_seconds() > freshness: - print("0") - sys.exit(0) - -if not active_users: - print("0") - sys.exit(0) - -active=0.0 -for e in window_events: - ts=e.get("timestamp") - if not ts: - continue - try: - if p(ts) < start: - continue - except Exception: - continue - d=float(e.get("duration",0) or 0) - title=((e.get("data") or {}).get("title") or "").strip().lower() - if d > 0 and "rdp active" in title: - active += d - -print("1" if active <= 0 else "0") -PY -)" - -if [[ "$need_heal" != "1" ]]; then - log "health ok: activity present for ${HOST}, no action" - exit 0 -fi - -log "detected zero activity for ${HOST}, running heal" -systemctl restart aw-worktime-ui-bridge.timer -systemctl start aw-worktime-ui-bridge.service || true - -"$PYTHON_BIN" - <<'PY' -import json, urllib.request, datetime, os -AW=os.environ.get("AW_URL","http://127.0.0.1:5600") -host=os.environ.get("HOST","SHARKON2025") -sb=f"aw-worktime-sessions_{host}" -afk=f"aw-rdp-afk_{host}" -win=f"aw-rdp-window_{host}" -msk=datetime.timezone(datetime.timedelta(hours=3)) -start=datetime.datetime.now(msk).replace(hour=0,minute=0,second=0,microsecond=0).astimezone(datetime.timezone.utc) - -def req(method,path,payload=None): - data=None; headers={} - if payload is not None: - data=json.dumps(payload,ensure_ascii=False).encode("utf-8"); headers["Content-Type"]="application/json" - r=urllib.request.Request(AW+path,data=data,headers=headers,method=method) - try: - with urllib.request.urlopen(r,timeout=30) as resp: - body=resp.read() - return json.loads(body.decode("utf-8")) if body else None - except urllib.error.HTTPError as e: - if e.code in (304, 409): - return None - raise - -def reset_bucket(bucket_id, event_type, client, hostname): - try: - req("DELETE", f"/api/0/buckets/{bucket_id}") - except Exception: - pass - req("POST", f"/api/0/buckets/{bucket_id}", { - "client": client, - "type": event_type, - "hostname": hostname, - }) - -def parse(ts): - if ts.endswith("Z"): ts=ts[:-1]+"+00:00" - return datetime.datetime.fromisoformat(ts).astimezone(datetime.timezone.utc) - -def is_active(d): - if isinstance(d.get("active"), bool) and d.get("active"): return True - st=str(d.get("state","")).strip().lower() - if st in ("active","активно"): return True - if st=="unknown": - try: sid=int(d.get("sessionId")) - except: sid=-1 - u=str(d.get("username","")).strip().lower() - sn=str(d.get("sessionName","")).strip().lower() - if sid>0 and u and (not u.endswith("$")) and (sn.startswith("rdp-") or sn=="console"): return True - return False - -rows=req("GET",f"/api/0/buckets/{sb}/events?limit=12000") or [] -rows=[e for e in rows if e.get("timestamp") and parse(e["timestamp"])>=start] -if not rows: - raise SystemExit(0) - -# Hard normalization: drop corrupted/mixed watcher buckets and rebuild from source sessions. -reset_bucket(afk, "afkstatus", "aw-worktime-ui-bridge", host) -reset_bucket(win, "currentwindow", "aw-worktime-ui-bridge", host) - -by={} -for e in rows: - by.setdefault(e["timestamp"],[]).append(e) -keys=sorted(by.keys()) -out_afk=[]; out_win=[] -for i,ts in enumerate(keys): - cur=parse(ts) - nxt=parse(keys[i+1]) if i+10 else 10.0) - act=[] - for r in by[ts]: - d=r.get("data") or {} - u=str(d.get("username","")).strip() - if u and is_active(d): act.append(u) - act=sorted(set(act)) - active=bool(act) - out_afk.append({"timestamp":ts,"duration":dur,"data":{"status":"not-afk" if active else "afk","source":"aw-worktime-autoheal"}}) - out_win.append({"timestamp":ts,"duration":dur,"data":{"app":"RDP","title":("RDP active (%d): %s"%(len(act),", ".join(act))) if active else "RDP idle","source":"aw-worktime-autoheal"}}) - -req("POST",f"/api/0/buckets/{afk}/events",out_afk) -req("POST",f"/api/0/buckets/{win}/events",out_win) -print(f"autoheal backfill posted afk={len(out_afk)} win={len(out_win)}") -PY - -log "heal completed for ${HOST}" diff --git a/aw-server/aw-worktime-influx-exporter.py b/aw-server/aw-worktime-influx-exporter.py deleted file mode 100644 index eb2993f..0000000 --- a/aw-server/aw-worktime-influx-exporter.py +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -import os -import sys -import urllib.request -from datetime import datetime, timezone -from pathlib import Path - - -MODULE_PATH = Path(__file__).with_name("aw-worktime-api.py") -SPEC = importlib.util.spec_from_file_location("aw_worktime_api", MODULE_PATH) -WORKTIME = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(WORKTIME) - -INFLUX_URL = os.environ.get("AW_WORKTIME_INFLUX_URL", "").strip().rstrip("/") -INFLUX_ORG = os.environ.get("AW_WORKTIME_INFLUX_ORG", "proxmox").strip() or "proxmox" -INFLUX_BUCKET = os.environ.get("AW_WORKTIME_INFLUX_BUCKET", "aw_metrics").strip() or "aw_metrics" -INFLUX_TOKEN = os.environ.get("AW_WORKTIME_INFLUX_TOKEN", "").strip() -INFLUX_ENABLED = os.environ.get("AW_WORKTIME_INFLUX_ENABLED", "").strip().lower() in {"1", "true", "yes", "on"} -HOSTS = [item.strip() for item in os.environ.get("AW_WORKTIME_INFLUX_HOSTS", WORKTIME.DEFAULT_HOST).split(",") if item.strip()] -DAYS = [item.strip() for item in os.environ.get("AW_WORKTIME_INFLUX_DAYS", "today,yesterday").split(",") if item.strip()] - - -def _escape_tag(value): - return ( - str(value or "") - .replace("\\", "\\\\") - .replace(" ", "\\ ") - .replace(",", "\\,") - .replace("=", "\\=") - ) - - -def _line(measurement, tags, fields, timestamp_ns): - tag_part = ",".join(f"{key}={_escape_tag(value)}" for key, value in sorted(tags.items()) if value is not None and value != "") - field_parts = [] - for key, value in fields.items(): - if isinstance(value, bool): - field_parts.append(f"{key}={'true' if value else 'false'}") - elif isinstance(value, int): - field_parts.append(f"{key}={value}i") - elif isinstance(value, float): - field_parts.append(f"{key}={value}") - else: - text = str(value or "").replace("\\", "\\\\").replace('"', '\\"') - field_parts.append(f'{key}="{text}"') - if not field_parts: - return "" - if tag_part: - return f"{measurement},{tag_part} {','.join(field_parts)} {timestamp_ns}" - return f"{measurement} {','.join(field_parts)} {timestamp_ns}" - - -def _timestamp_ns(dt): - return int(dt.astimezone(timezone.utc).timestamp() * 1_000_000_000) - - -def build_lines_for_day(host, report_date): - bounds, events = WORKTIME.fetch_events_for_date(host, report_date) - rows = WORKTIME.aggregate_rows(events, bounds["start"], bounds["end"], host) - hourly_rows = WORKTIME.aggregate_hourly_rows(events, bounds["start"], bounds["end"], host) - summary = WORKTIME.build_report_summary(rows) - true_active_apps = WORKTIME.build_true_active_apps(host, report_date) - - lines = [] - daily_ts = _timestamp_ns(bounds["start"]) - - for row in rows: - lines.append( - _line( - "aw_rdp_worktime_daily", - { - "host": host, - "user": row["user"], - "user_id": row["user_id"], - "report_date": report_date.isoformat(), - }, - { - "active_seconds": int(row["active_seconds"]), - "idle_seconds": int(row["idle_seconds"]), - "sessions_count": int(row["sessions_count"]), - "samples_count": int(row["samples_count"]), - "active_samples": int(row["active_samples"]), - }, - daily_ts, - ) - ) - - for row in hourly_rows: - lines.append( - _line( - "aw_rdp_worktime_hourly", - { - "host": host, - "user": row["user"], - "user_id": row["user_id"], - "report_date": row["report_date"], - "hour_local": row["hour_local"], - }, - { - "active_seconds": int(row["active_seconds"]), - }, - _timestamp_ns(WORKTIME.pts(row["bucket_start_utc"])), - ) - ) - - lines.append( - _line( - "aw_rdp_worktime_summary_daily", - { - "host": host, - "report_date": report_date.isoformat(), - }, - { - "users_count": int(summary["users_count"]), - "total_active_seconds": int(summary["total_active_seconds"]), - "top_user": summary["top_user"], - }, - daily_ts, - ) - ) - - for app in true_active_apps: - lines.append( - _line( - "aw_true_active_app_daily", - { - "host": host, - "application": app["application"], - "report_date": report_date.isoformat(), - }, - { - "proved_work_seconds": int(app.get("proved_work_seconds", 0) or 0), - "evidence_events": int(app.get("evidence_events", 0) or 0), - "proved_work_human": app.get("proved_work_human", ""), - "proved_work_hhmm": app.get("proved_work_hhmm", ""), - "last_action": app.get("last_action", ""), - "last_action_local": app.get("last_action_local", ""), - "last_action_utc": app.get("last_action_utc", ""), - }, - daily_ts, - ) - ) - return [line for line in lines if line] - - -def write_lines(lines): - if not lines: - return 0 - if not INFLUX_URL or not INFLUX_TOKEN: - raise RuntimeError("InfluxDB destination is not configured") - payload = ("\n".join(lines) + "\n").encode("utf-8") - req = urllib.request.Request( - f"{INFLUX_URL}/api/v2/write?org={INFLUX_ORG}&bucket={INFLUX_BUCKET}&precision=ns", - data=payload, - method="POST", - headers={"Authorization": f"Token {INFLUX_TOKEN}", "Content-Type": "text/plain; charset=utf-8"}, - ) - with urllib.request.urlopen(req, timeout=30) as response: - if response.status not in {204, 200}: - raise RuntimeError(f"InfluxDB write failed with status={response.status}") - return len(lines) - - -def main(): - if not INFLUX_ENABLED: - print("[aw-worktime-influx-exporter] disabled by AW_WORKTIME_INFLUX_ENABLED", file=sys.stderr) - return 0 - - lines = [] - for host in HOSTS: - for day in DAYS: - report_date = WORKTIME.resolve_report_date(day=day) - lines.extend(build_lines_for_day(host, report_date)) - written = write_lines(lines) - print(f"[aw-worktime-influx-exporter] wrote {written} points to {INFLUX_BUCKET}", file=sys.stderr) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/aw-server/aw-worktime-influx-exporter.service b/aw-server/aw-worktime-influx-exporter.service index 4cdf451..25ffc9f 100644 --- a/aw-server/aw-worktime-influx-exporter.service +++ b/aw-server/aw-worktime-influx-exporter.service @@ -6,10 +6,9 @@ Wants=network-online.target aw-worktime-api.service [Service] Type=oneshot EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-influx-exporter.py +ExecStart=/usr/local/bin/aw-worktime-influx-exporter-rust User=activitywatch Group=activitywatch StandardOutput=journal StandardError=journal SyslogIdentifier=aw-worktime-influx-exporter - diff --git a/aw-server/aw-worktime-prewarm.service b/aw-server/aw-worktime-prewarm.service index a013770..6f3cb29 100644 --- a/aw-server/aw-worktime-prewarm.service +++ b/aw-server/aw-worktime-prewarm.service @@ -6,10 +6,9 @@ Wants=network-online.target activitywatch-server.service aw-worktime-api.service [Service] Type=oneshot EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/local/bin/aw-worktime-prewarm.sh +ExecStart=/usr/local/bin/aw-worktime-prewarm-rust User=activitywatch Group=activitywatch StandardOutput=journal StandardError=journal SyslogIdentifier=aw-worktime-prewarm - diff --git a/aw-server/aw-worktime-ui-bridge.py b/aw-server/aw-worktime-ui-bridge.py deleted file mode 100644 index 5621fb5..0000000 --- a/aw-server/aw-worktime-ui-bridge.py +++ /dev/null @@ -1,424 +0,0 @@ -#!/usr/bin/env python3 -import json -import os -import urllib.error -import urllib.request -from datetime import datetime, timedelta, timezone - - -AW_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600") -HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025") -STATE_PATH = os.environ.get( - "AW_WORKTIME_UI_BRIDGE_STATE", - "/var/lib/activitywatch/aw-worktime-ui-bridge-state.json", -) -TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "60")) -WATCHER_FALLBACK_ENABLED = os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", "1").strip().lower() not in { - "0", - "false", - "no", - "off", -} -WATCHER_FALLBACK_STALE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_STALE_SECONDS", "600")) - - -SESSIONS_BUCKET = f"aw-worktime-sessions_{HOST}" -AFK_BUCKET = f"aw-rdp-afk_{HOST}" -WINDOW_BUCKET = f"aw-rdp-window_{HOST}" -WATCHER_AFK_BUCKET = f"aw-watcher-afk_{HOST}" -WATCHER_WINDOW_BUCKET = f"aw-watcher-window_{HOST}" -WEB_CATEGORY_BUCKET = f"aw-detmir-web-category_{HOST}" -COLLECTOR_HEALTH_MAX_AGE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS", "300")) -COLLECTOR_HEALTH_QUERY_LIMIT = int(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_QUERY_LIMIT", "200")) -FOREGROUND_CONTEXT_CACHE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_FOREGROUND_CACHE_SECONDS", "900")) - - -def _req(method: str, path: str, payload=None): - data = None - headers = {} - if payload is not None: - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - headers["Content-Type"] = "application/json" - req = urllib.request.Request(AW_URL + path, data=data, headers=headers, method=method) - with urllib.request.urlopen(req, timeout=TIMEOUT) as r: - raw = r.read() - if not raw: - return None - return json.loads(raw.decode("utf-8")) - - -def ensure_bucket(bucket_id: str, event_type: str, client: str): - payload = {"client": client, "type": event_type, "hostname": HOST} - try: - _req("POST", f"/api/0/buckets/{bucket_id}", payload) - except urllib.error.HTTPError as e: - if e.code != 304: - raise - - -def get_latest_bucket_event(bucket_id: str): - try: - events = _req("GET", f"/api/0/buckets/{bucket_id}/events?limit=1") or [] - except urllib.error.HTTPError as e: - if e.code == 404: - return None - raise - if not events: - return None - return events[0] - - -def get_latest_bucket_event_ts(bucket_id: str): - event = get_latest_bucket_event(bucket_id) - if not event: - return None - ts = event.get("timestamp") - if not ts: - return None - try: - return parse_iso_utc(ts) - except Exception: - return None - - -def bucket_needs_fallback(bucket_id: str, now_utc: datetime, stale_after_seconds: float): - latest_dt = get_latest_bucket_event_ts(bucket_id) - if latest_dt is None: - return True - return (now_utc - latest_dt).total_seconds() >= stale_after_seconds - - -def watcher_window_needs_bridge_sync(now_utc: datetime): - latest_event = get_latest_bucket_event(WATCHER_WINDOW_BUCKET) - if latest_event is None: - return True - latest_dt = get_latest_bucket_event_ts(WATCHER_WINDOW_BUCKET) - if latest_dt is None: - return True - if (now_utc - latest_dt).total_seconds() >= WATCHER_FALLBACK_STALE_SECONDS: - return True - - data = latest_event.get("data") or {} - source = str(data.get("source", "")).strip().lower() - app = str(data.get("app", "")).strip() - title = str(data.get("title", "")).strip() - if source == "aw-worktime-ui-bridge": - return True - if source != "aw-worktime-ui-bridge": - return False - if app.upper() == "RDP": - return True - if title == "RDP idle" or title.startswith("RDP active"): - return True - return False - - -def load_state(): - try: - with open(STATE_PATH, "r", encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, dict) and "last_ts" in data: - return data - except FileNotFoundError: - pass - except json.JSONDecodeError: - pass - return {"last_ts": "1970-01-01T00:00:00Z"} - - -def save_state(state): - os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True) - tmp = STATE_PATH + ".tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(state, f, ensure_ascii=False) - os.replace(tmp, STATE_PATH) - - -def to_iso_utc(ts): - if ts.endswith("Z"): - return ts - return ts.replace("+00:00", "Z") - - -def parse_iso_utc(ts: str): - if ts.endswith("Z"): - ts = ts[:-1] + "+00:00" - return datetime.fromisoformat(ts).astimezone(timezone.utc) - - -def build_window_title(users, active_count): - if not users: - return "RDP idle" - return f"RDP active ({active_count}): " + ", ".join(users) - - -def get_latest_active_session_ids(events): - grouped = {} - for event in events: - ts = event.get("timestamp") - if not ts: - continue - grouped.setdefault(ts, []).append(event) - if not grouped: - return set() - latest_ts = max(grouped.keys(), key=lambda item: parse_iso_utc(item)) - active_session_ids = set() - for event in grouped.get(latest_ts, []): - data = event.get("data") or {} - if not _is_session_active(data): - continue - try: - active_session_ids.add(int(data.get("sessionId"))) - except Exception: - continue - return active_session_ids - - -def _normalize_foreground_context(data): - foreground_process = str(data.get("foregroundProcess", "")).strip() - foreground_title = str(data.get("foregroundTitle", "")).strip() - if not foreground_process and not foreground_title: - return None - return { - "app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe", - "title": foreground_title or foreground_process, - } - - -def get_latest_foreground_context(now_utc: datetime, active_session_ids=None, state=None): - try: - events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit={COLLECTOR_HEALTH_QUERY_LIMIT}") or [] - except urllib.error.HTTPError as e: - if e.code == 404: - events = [] - else: - raise - - active_session_ids = set(active_session_ids or []) - recent_candidates = [] - for event in reversed(events): - data = event.get("data") or {} - if str(data.get("signalType", "")).strip().lower() != "collector_health": - continue - ts = event.get("timestamp") - if not ts: - continue - try: - event_dt = parse_iso_utc(ts) - except Exception: - continue - if (now_utc - event_dt).total_seconds() > COLLECTOR_HEALTH_MAX_AGE_SECONDS: - continue - normalized = _normalize_foreground_context(data) - if not normalized: - continue - session_id = data.get("sessionId") - try: - session_id = int(session_id) - except Exception: - session_id = None - recent_candidates.append((session_id, event_dt, normalized)) - - for session_id, event_dt, normalized in recent_candidates: - if active_session_ids and session_id in active_session_ids: - normalized["timestamp"] = to_iso_utc(event_dt.isoformat()) - return normalized - - if recent_candidates: - session_id, event_dt, normalized = recent_candidates[0] - normalized["timestamp"] = to_iso_utc(event_dt.isoformat()) - return normalized - - cached = (state or {}).get("last_foreground_context") - if isinstance(cached, dict): - cached_ts = str(cached.get("timestamp", "")).strip() - if cached_ts: - try: - cached_dt = parse_iso_utc(cached_ts) - except Exception: - cached_dt = None - if cached_dt and (now_utc - cached_dt).total_seconds() <= FOREGROUND_CONTEXT_CACHE_SECONDS: - app = str(cached.get("app", "")).strip() - title = str(cached.get("title", "")).strip() - if app or title: - return { - "app": app or "RDP", - "title": title or app or "RDP", - "timestamp": cached_ts, - } - return None - - -def _is_session_active(row_data): - if isinstance(row_data.get("active"), bool): - if row_data.get("active"): - return True - state = str(row_data.get("state", "")).strip().lower() - if state in {"active", "активно"}: - return True - # query user can intermittently return Unknown on RDP hosts. - if state == "unknown": - try: - sid = int(row_data.get("sessionId")) - except Exception: - sid = -1 - user = str(row_data.get("username", "")).strip().lower() - session_name = str(row_data.get("sessionName", "")).strip().lower() - if sid > 0 and user and (not user.endswith("$")) and (session_name.startswith("rdp-") or session_name == "console"): - return True - return False - - -def transform(events, foreground_context=None): - out_afk = [] - out_win = [] - last_ts = None - - grouped = {} - for e in events: - ts = e.get("timestamp") - if not ts: - continue - try: - normalized_ts = to_iso_utc(parse_iso_utc(ts).replace(microsecond=0).isoformat()) - except Exception: - normalized_ts = ts - grouped.setdefault(normalized_ts, []).append(e) - - ordered_ts = sorted(grouped.keys()) - parsed_ts = {} - for ts in ordered_ts: - try: - parsed_ts[ts] = parse_iso_utc(ts) - except Exception: - parsed_ts[ts] = None - - for idx, ts in enumerate(ordered_ts): - rows = grouped[ts] - src_duration = max(float(r.get("duration", 0.0)) for r in rows) - duration = src_duration - cur_dt = parsed_ts.get(ts) - next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None - next_gap = None - if cur_dt and next_dt: - next_gap = max(0.0, (next_dt - cur_dt).total_seconds()) - if duration <= 0: - if cur_dt and next_dt: - duration = next_gap or 0.0 - if duration <= 0: - duration = 10.0 - elif next_gap is not None and next_gap > 0: - # Do not let a sampled session interval extend past the next sample. - duration = min(duration, next_gap) - duration = min(duration, 30.0) - active_users = [] - for r in rows: - data = r.get("data") or {} - user = str(data.get("username", "")).strip() - if user and _is_session_active(data): - active_users.append(user) - active_users = sorted(set(active_users)) - active_count = len(active_users) - is_active = active_count > 0 - - afk_data = {"status": "not-afk" if is_active else "afk", "source": "aw-worktime-ui-bridge"} - out_afk.append({"timestamp": ts, "duration": duration, "data": afk_data}) - - if is_active and foreground_context: - title = str(foreground_context.get("title") or "").strip() - if active_count > 1: - title = f"{title} | {build_window_title(active_users, active_count)}" if title else build_window_title(active_users, active_count) - win_data = { - "app": str(foreground_context.get("app") or "RDP"), - "title": title or build_window_title(active_users, active_count), - "source": "aw-worktime-ui-bridge", - } - else: - win_data = { - "app": "RDP", - "title": build_window_title(active_users, active_count), - "source": "aw-worktime-ui-bridge", - } - out_win.append({"timestamp": ts, "duration": duration, "data": win_data}) - last_ts = ts - - return out_afk, out_win, last_ts - - -def normalize_watcher_window_events(win_events): - normalized = [] - for event in win_events: - cloned = dict(event) - data = dict(event.get("data") or {}) - app = str(data.get("app") or "").strip() - title = str(data.get("title") or "").strip() - if not app or app.upper() == "RDP": - continue - if app and app.upper() != "RDP" and " | RDP active (" in title: - data["title"] = title.split(" | RDP active (", 1)[0].strip() - cloned["data"] = data - normalized.append(cloned) - return normalized - - -def main(): - state = load_state() - last_ts = state.get("last_ts", "1970-01-01T00:00:00Z") - - ensure_bucket(AFK_BUCKET, "afkstatus", "aw-worktime-ui-bridge") - ensure_bucket(WINDOW_BUCKET, "currentwindow", "aw-worktime-ui-bridge") - - now_utc = datetime.now(timezone.utc) - recent = _req("GET", f"/api/0/buckets/{SESSIONS_BUCKET}/events?limit=5000") or [] - if not recent: - return - - try: - last_dt = parse_iso_utc(last_ts) - except Exception: - last_dt = datetime(1970, 1, 1, tzinfo=timezone.utc) - - events = [] - for e in recent: - ts = e.get("timestamp") - if not ts: - continue - try: - if parse_iso_utc(ts) > last_dt: - events.append(e) - except Exception: - continue - if not events: - return - - active_session_ids = get_latest_active_session_ids(events) - foreground_context = get_latest_foreground_context(now_utc, active_session_ids=active_session_ids, state=state) - afk_events, win_events, new_last_ts = transform(events, foreground_context=foreground_context) - if not afk_events or not win_events or not new_last_ts: - return - watcher_win_events = normalize_watcher_window_events(win_events) - - _req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events) - _req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events) - if WATCHER_FALLBACK_ENABLED: - if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS): - ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk") - _req("POST", f"/api/0/buckets/{WATCHER_AFK_BUCKET}/events", afk_events) - if watcher_win_events and watcher_window_needs_bridge_sync(now_utc): - ensure_bucket(WATCHER_WINDOW_BUCKET, "currentwindow", "aw-watcher-window") - _req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", watcher_win_events) - next_state = {"last_ts": new_last_ts} - if foreground_context: - next_state["last_foreground_context"] = { - "app": str(foreground_context.get("app") or ""), - "title": str(foreground_context.get("title") or ""), - "timestamp": str(foreground_context.get("timestamp") or to_iso_utc(now_utc.isoformat())), - } - elif isinstance(state.get("last_foreground_context"), dict): - next_state["last_foreground_context"] = state["last_foreground_context"] - save_state(next_state) - print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}") - - -if __name__ == "__main__": - main() diff --git a/aw-server/aw-worktime-ui-bridge.service b/aw-server/aw-worktime-ui-bridge.service index 3ca931d..b836e4f 100644 --- a/aw-server/aw-worktime-ui-bridge.service +++ b/aw-server/aw-worktime-ui-bridge.service @@ -9,7 +9,7 @@ StartLimitIntervalSec=120 Type=simple Environment=AW_SERVER_URL=http://127.0.0.1:5600 Environment=AW_WORKTIME_HOST=SHARKON2025 -ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-ui-bridge.py +ExecStart=/usr/local/bin/aw-worktime-ui-bridge-rust Restart=on-failure RestartSec=10 User=activitywatch diff --git a/aw-server/dlp-case-management/case-service.service b/aw-server/dlp-case-management/case-service.service index 054e7be..65fa604 100644 --- a/aw-server/dlp-case-management/case-service.service +++ b/aw-server/dlp-case-management/case-service.service @@ -5,8 +5,8 @@ After=network-online.target [Service] Type=simple EnvironmentFile=-/etc/activitywatch/aw-server.env -WorkingDirectory=/opt/activitywatch/dlp-case-management -ExecStart=/opt/activitywatch/dlp-case-management/.venv/bin/uvicorn case_service:APP --host ${AW_DLP_CASE_BIND_HOST} --port ${AW_DLP_CASE_PORT} +WorkingDirectory=/var/lib/activitywatch +ExecStart=/usr/local/bin/aw-dlp-case-management-rust Restart=on-failure RestartSec=3 User=activitywatch diff --git a/aw-server/dlp-case-management/case_rules.py b/aw-server/dlp-case-management/case_rules.py deleted file mode 100644 index 899d0c2..0000000 --- a/aw-server/dlp-case-management/case_rules.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - - -def is_self_test_case(incident_id: str | None, title: str | None) -> bool: - incident = str(incident_id or "").lower() - label = str(title or "").lower() - return "|self_test|" in incident or label.startswith("dlp self_test") diff --git a/aw-server/dlp-case-management/case_schema.py b/aw-server/dlp-case-management/case_schema.py deleted file mode 100644 index ec6493d..0000000 --- a/aw-server/dlp-case-management/case_schema.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -from datetime import datetime -from typing import Literal - -from pydantic import BaseModel, Field - -CaseStatus = Literal["open", "investigating", "resolved", "closed"] - - -class CaseHayabusaLink(BaseModel): - tool: Literal["hayabusa"] = "hayabusa" - host: str = Field(min_length=1, max_length=128) - mode: str = Field(min_length=1, max_length=32) - status: str = Field(min_length=1, max_length=64) - intake_id: str | None = Field(default=None, max_length=256) - package_path: str | None = Field(default=None, max_length=1024) - sha256: str | None = Field(default=None, max_length=128) - report_dir: str | None = Field(default=None, max_length=1024) - summary_html: str | None = Field(default=None, max_length=1024) - timeline_path: str | None = Field(default=None, max_length=1024) - manifest_path: str | None = Field(default=None, max_length=1024) - linked_at: str | None = Field(default=None, max_length=64) - link_source: str | None = Field(default=None, max_length=64) - - -class CaseCreate(BaseModel): - incident_id: str = Field(min_length=1, max_length=256) - host: str | None = Field(default=None, max_length=128) - title: str = Field(min_length=1, max_length=512) - severity: str = Field(default="medium", max_length=32) - assignee: str | None = Field(default=None, max_length=128) - source_bucket: str | None = Field(default=None, max_length=256) - source_event_ts: str | None = Field(default=None, max_length=64) - evidence: dict | None = None - - -class CaseUpdate(BaseModel): - status: CaseStatus | None = None - assignee: str | None = Field(default=None, max_length=128) - title: str | None = Field(default=None, max_length=512) - severity: str | None = Field(default=None, max_length=32) - - -class CaseCommentCreate(BaseModel): - comment: str = Field(min_length=1, max_length=2000) - author: str | None = Field(default=None, max_length=128) - - -class CaseComment(BaseModel): - id: int - case_id: int - comment: str - author: str | None - created_at: datetime - - -class CaseRecord(BaseModel): - id: int - incident_id: str - host: str | None - title: str - severity: str - assignee: str | None - status: CaseStatus - source_bucket: str | None - source_event_ts: str | None - evidence: dict | None - forensics: dict | None - created_at: datetime - updated_at: datetime diff --git a/aw-server/dlp-case-management/case_service.py b/aw-server/dlp-case-management/case_service.py deleted file mode 100644 index 8663a9c..0000000 --- a/aw-server/dlp-case-management/case_service.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import os -from pathlib import Path -from typing import Any - -from fastapi import FastAPI, HTTPException, Query -from fastapi.middleware.cors import CORSMiddleware - -from case_rules import is_self_test_case -from case_schema import CaseCommentCreate, CaseCreate, CaseHayabusaLink, CaseUpdate -from case_storage import CaseStorage, ForensicsHostMismatchError - -DB = Path(os.environ.get("AW_DLP_CASE_DB_PATH", "/opt/activitywatch/dlp-case-management/cases.db")) -APP = FastAPI(title="AWatch DLP Case Management") -APP.add_middleware( - CORSMiddleware, - allow_origins=["http://127.0.0.1:5600", "http://localhost:5600", "http://10.10.10.13:5600", "*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) -STORE = CaseStorage(DB) - - -@APP.get("/health") -def health() -> dict[str, Any]: - return {"ok": True, "db": str(DB)} - - -@APP.post("/api/0/dlp/cases") -def create_case(payload: CaseCreate) -> dict[str, Any]: - if is_self_test_case(payload.incident_id, payload.title): - raise HTTPException(status_code=422, detail="self_test cases are not allowed") - return STORE.create_case(payload.model_dump(exclude_none=True), actor="api") - - -@APP.get("/api/0/dlp/cases") -def list_cases( - status: str | None = Query(default=None), - host: str | None = Query(default=None), - limit: int = Query(default=200, ge=1, le=2000), -) -> list[dict[str, Any]]: - return STORE.list_cases(status=status, host=host, limit=limit) - - -@APP.get("/api/0/dlp/cases/{case_id}") -def get_case(case_id: int) -> dict[str, Any]: - try: - case = STORE.get_case(case_id) - except KeyError: - raise HTTPException(status_code=404, detail="case not found") - case["comments"] = STORE.list_comments(case_id, limit=200) - case["audit"] = STORE.list_audit(case_id, limit=200) - return case - - -@APP.patch("/api/0/dlp/cases/{case_id}") -def update_case(case_id: int, payload: CaseUpdate) -> dict[str, Any]: - patch = payload.model_dump(exclude_none=True) - if not patch: - return STORE.get_case(case_id) - try: - return STORE.update_case(case_id, patch=patch, actor="api") - except KeyError: - raise HTTPException(status_code=404, detail="case not found") - - -@APP.post("/api/0/dlp/cases/{case_id}/comments") -def add_comment(case_id: int, payload: CaseCommentCreate) -> dict[str, Any]: - try: - STORE.get_case(case_id) - except KeyError: - raise HTTPException(status_code=404, detail="case not found") - return STORE.add_comment(case_id=case_id, comment=payload.comment, author=payload.author) - - -@APP.get("/api/0/dlp/cases/{case_id}/comments") -def list_comments(case_id: int, limit: int = Query(default=200, ge=1, le=2000)) -> list[dict[str, Any]]: - return STORE.list_comments(case_id=case_id, limit=limit) - - -@APP.post("/api/0/dlp/cases/{case_id}/forensics/hayabusa") -def link_hayabusa(case_id: int, payload: CaseHayabusaLink) -> dict[str, Any]: - try: - return STORE.link_hayabusa(case_id=case_id, payload=payload.model_dump(exclude_none=True), actor="api") - except ForensicsHostMismatchError as exc: - raise HTTPException(status_code=409, detail=str(exc)) - except KeyError: - raise HTTPException(status_code=404, detail="case not found") diff --git a/aw-server/dlp-case-management/case_storage.py b/aw-server/dlp-case-management/case_storage.py deleted file mode 100644 index 3b3f8a4..0000000 --- a/aw-server/dlp-case-management/case_storage.py +++ /dev/null @@ -1,351 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import sqlite3 -from contextlib import contextmanager -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Iterator - -from evidence_chain import evidence_sha256, normalize_evidence_chain - - -class ForensicsHostMismatchError(ValueError): - pass - - -class CaseStorage: - def __init__(self, db_path: Path) -> None: - self.db_path = db_path - self.db_path.parent.mkdir(parents=True, exist_ok=True) - self._init_db() - - @contextmanager - def conn(self) -> Iterator[sqlite3.Connection]: - c = sqlite3.connect(self.db_path) - c.row_factory = sqlite3.Row - c.execute("PRAGMA journal_mode=WAL") - c.execute("PRAGMA foreign_keys=ON") - try: - yield c - finally: - c.close() - - def _init_db(self) -> None: - with self.conn() as c: - c.executescript( - """ - CREATE TABLE IF NOT EXISTS cases ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - incident_id TEXT NOT NULL, - host TEXT, - title TEXT NOT NULL, - severity TEXT NOT NULL DEFAULT 'medium', - assignee TEXT, - status TEXT NOT NULL DEFAULT 'open', - source_bucket TEXT, - source_event_ts TEXT, - evidence_json TEXT, - forensics_json TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_cases_incident_id ON cases(incident_id); - CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status); - - CREATE TABLE IF NOT EXISTS case_comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - case_id INTEGER NOT NULL, - comment TEXT NOT NULL, - author TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY(case_id) REFERENCES cases(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS case_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - case_id INTEGER NOT NULL, - action TEXT NOT NULL, - actor TEXT, - details_json TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY(case_id) REFERENCES cases(id) ON DELETE CASCADE - ); - """ - ) - self._ensure_column(c, "cases", "forensics_json", "TEXT") - c.commit() - - @staticmethod - def _ensure_column(c: sqlite3.Connection, table: str, column: str, definition: str) -> None: - columns = { - str(row["name"]) - for row in c.execute(f"PRAGMA table_info({table})").fetchall() - } - if column not in columns: - c.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}") - - @staticmethod - def _now() -> str: - return datetime.now(timezone.utc).isoformat() - - @staticmethod - def _normalize_host(value: Any) -> str: - return str(value or "").strip().lower() - - @staticmethod - def _load_json_field(raw: Any) -> dict[str, Any] | None: - if not raw: - return None - try: - return json.loads(raw) - except Exception: - return None - - @classmethod - def _to_case_dict(cls, row: sqlite3.Row) -> dict[str, Any]: - evidence = cls._load_json_field(row["evidence_json"]) - forensics = cls._load_json_field(row["forensics_json"]) - return { - "id": int(row["id"]), - "incident_id": row["incident_id"], - "host": row["host"], - "title": row["title"], - "severity": row["severity"], - "assignee": row["assignee"], - "status": row["status"], - "source_bucket": row["source_bucket"], - "source_event_ts": row["source_event_ts"], - "evidence": evidence, - "forensics": forensics, - "created_at": row["created_at"], - "updated_at": row["updated_at"], - } - - def create_case(self, payload: dict[str, Any], actor: str | None = None) -> dict[str, Any]: - now = self._now() - normalized_evidence = None - evidence_digest = None - if payload.get("evidence") is not None: - normalized_evidence = normalize_evidence_chain( - payload=payload.get("evidence"), - source_bucket=payload.get("source_bucket"), - source_event_ts=payload.get("source_event_ts"), - ) - evidence_digest = normalized_evidence.get("latest_sha256") or evidence_sha256(payload.get("evidence")) - with self.conn() as c: - existing = c.execute( - """ - SELECT * FROM cases - WHERE incident_id = ? AND COALESCE(host, '') = COALESCE(?, '') - ORDER BY id DESC - LIMIT 1 - """, - (payload["incident_id"], payload.get("host")), - ).fetchone() - if existing: - return self._to_case_dict(existing) - cur = c.execute( - """ - INSERT INTO cases ( - incident_id, host, title, severity, assignee, status, - source_bucket, source_event_ts, evidence_json, forensics_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?) - """, - ( - payload["incident_id"], - payload.get("host"), - payload["title"], - payload.get("severity", "medium"), - payload.get("assignee"), - payload.get("source_bucket"), - payload.get("source_event_ts"), - json.dumps(normalized_evidence, ensure_ascii=False) if normalized_evidence is not None else None, - None, - now, - now, - ), - ) - case_id = int(cur.lastrowid) - self._insert_audit( - c, - case_id=case_id, - action="create", - actor=actor, - details={ - "fields": {k: v for k, v in payload.items() if k != "evidence"}, - "evidence_sha256": evidence_digest, - }, - ) - c.commit() - return self.get_case(case_id, c) - - def list_cases(self, status: str | None = None, host: str | None = None, limit: int = 200) -> list[dict[str, Any]]: - q = "SELECT * FROM cases" - clauses = [] - args: list[Any] = [] - if status: - clauses.append("status = ?") - args.append(status) - if host: - clauses.append("host = ?") - args.append(host) - if clauses: - q += " WHERE " + " AND ".join(clauses) - q += " ORDER BY id DESC LIMIT ?" - args.append(int(limit)) - with self.conn() as c: - rows = c.execute(q, args).fetchall() - return [self._to_case_dict(r) for r in rows] - - def get_case(self, case_id: int, c: sqlite3.Connection | None = None) -> dict[str, Any]: - own = False - if c is None: - own = True - c = sqlite3.connect(self.db_path) - c.row_factory = sqlite3.Row - try: - row = c.execute("SELECT * FROM cases WHERE id = ?", (int(case_id),)).fetchone() - if not row: - raise KeyError(case_id) - return self._to_case_dict(row) - finally: - if own: - c.close() - - def update_case(self, case_id: int, patch: dict[str, Any], actor: str | None = None) -> dict[str, Any]: - fields = [] - args: list[Any] = [] - for key in ("status", "assignee", "title", "severity"): - if key in patch and patch[key] is not None: - fields.append(f"{key} = ?") - args.append(patch[key]) - if not fields: - return self.get_case(case_id) - fields.append("updated_at = ?") - args.append(self._now()) - args.append(int(case_id)) - with self.conn() as c: - c.execute(f"UPDATE cases SET {', '.join(fields)} WHERE id = ?", args) - self._insert_audit(c, case_id=case_id, action="update", actor=actor, details=patch) - c.commit() - return self.get_case(case_id, c) - - def link_hayabusa(self, case_id: int, payload: dict[str, Any], actor: str | None = None) -> dict[str, Any]: - now = self._now() - with self.conn() as c: - existing = self.get_case(case_id, c) - case_host = self._normalize_host(existing.get("host")) - forensic_host = self._normalize_host(payload.get("host")) - if case_host and forensic_host and case_host != forensic_host: - raise ForensicsHostMismatchError( - f"hayabusa host mismatch: case host={existing.get('host')} payload host={payload.get('host')}" - ) - forensics = existing.get("forensics") or {} - forensics["hayabusa"] = { - "tool": "hayabusa", - "host": payload["host"], - "mode": payload["mode"], - "status": payload["status"], - "intake_id": payload.get("intake_id"), - "package_path": payload.get("package_path"), - "sha256": payload.get("sha256"), - "report_dir": payload.get("report_dir"), - "summary_html": payload.get("summary_html"), - "timeline_path": payload.get("timeline_path"), - "manifest_path": payload.get("manifest_path"), - "linked_at": payload.get("linked_at") or now, - "link_source": payload.get("link_source") or "api", - } - c.execute( - "UPDATE cases SET forensics_json = ?, updated_at = ? WHERE id = ?", - (json.dumps(forensics, ensure_ascii=False), now, int(case_id)), - ) - self._insert_audit( - c, - case_id=case_id, - action="link_hayabusa", - actor=actor, - details={ - "host": payload["host"], - "mode": payload["mode"], - "status": payload["status"], - "intake_id": payload.get("intake_id"), - "report_dir": payload.get("report_dir"), - }, - ) - c.commit() - return self.get_case(case_id, c) - - def add_comment(self, case_id: int, comment: str, author: str | None = None) -> dict[str, Any]: - now = self._now() - with self.conn() as c: - cur = c.execute( - "INSERT INTO case_comments (case_id, comment, author, created_at) VALUES (?, ?, ?, ?)", - (int(case_id), comment, author, now), - ) - cid = int(cur.lastrowid) - self._insert_audit( - c, - case_id=case_id, - action="comment", - actor=author, - details={"comment_id": cid}, - ) - c.commit() - row = c.execute("SELECT id, case_id, comment, author, created_at FROM case_comments WHERE id = ?", (cid,)).fetchone() - return dict(row) - - def list_comments(self, case_id: int, limit: int = 200) -> list[dict[str, Any]]: - with self.conn() as c: - rows = c.execute( - "SELECT id, case_id, comment, author, created_at FROM case_comments WHERE case_id = ? ORDER BY id DESC LIMIT ?", - (int(case_id), int(limit)), - ).fetchall() - return [dict(r) for r in rows] - - def list_audit(self, case_id: int, limit: int = 200) -> list[dict[str, Any]]: - with self.conn() as c: - rows = c.execute( - "SELECT id, case_id, action, actor, details_json, created_at FROM case_audit WHERE case_id = ? ORDER BY id DESC LIMIT ?", - (int(case_id), int(limit)), - ).fetchall() - out: list[dict[str, Any]] = [] - for r in rows: - details = None - if r["details_json"]: - try: - details = json.loads(r["details_json"]) - except Exception: - details = None - out.append( - { - "id": int(r["id"]), - "case_id": int(r["case_id"]), - "action": r["action"], - "actor": r["actor"], - "details": details, - "created_at": r["created_at"], - } - ) - return out - - def _insert_audit( - self, - c: sqlite3.Connection, - case_id: int, - action: str, - actor: str | None, - details: dict[str, Any] | None = None, - ) -> None: - c.execute( - "INSERT INTO case_audit (case_id, action, actor, details_json, created_at) VALUES (?, ?, ?, ?, ?)", - ( - int(case_id), - action, - actor, - json.dumps(details, ensure_ascii=False) if details is not None else None, - self._now(), - ), - ) diff --git a/aw-server/dlp-case-management/evidence_chain.py b/aw-server/dlp-case-management/evidence_chain.py deleted file mode 100644 index c8c4294..0000000 --- a/aw-server/dlp-case-management/evidence_chain.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import hashlib -import json -from datetime import datetime, timezone -from typing import Any - - -def _utc_now() -> str: - return datetime.now(timezone.utc).isoformat() - - -def _canonical_json(payload: Any) -> str: - return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - - -def evidence_sha256(payload: Any) -> str: - return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest() - - -def build_evidence_record( - payload: Any, - source_bucket: str | None = None, - source_event_ts: str | None = None, -) -> dict[str, Any]: - return { - "recorded_at": _utc_now(), - "source_bucket": source_bucket, - "source_event_ts": source_event_ts, - "sha256": evidence_sha256(payload), - "payload": payload, - } - - -def normalize_evidence_chain( - payload: Any, - source_bucket: str | None = None, - source_event_ts: str | None = None, -) -> dict[str, Any]: - if isinstance(payload, dict) and isinstance(payload.get("items"), list): - return payload - record = build_evidence_record( - payload=payload, - source_bucket=source_bucket, - source_event_ts=source_event_ts, - ) - return { - "items": [record], - "latest_sha256": record["sha256"], - "chain_length": 1, - } diff --git a/aw-server/dlp-case-management/test_case_rules.py b/aw-server/dlp-case-management/test_case_rules.py deleted file mode 100644 index 7ca1eb3..0000000 --- a/aw-server/dlp-case-management/test_case_rules.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import unittest - -from case_rules import is_self_test_case - - -class CaseRulesTest(unittest.TestCase): - def test_is_self_test_case_by_incident_id(self) -> None: - self.assertTrue(is_self_test_case("2026-05-14T17:00:52.186Z|self_test|Администратор|||", "Normal")) - - def test_is_self_test_case_by_title(self) -> None: - self.assertTrue(is_self_test_case("inc-1", "DLP self_test · Администратор")) - - def test_is_self_test_case_false_for_normal_case(self) -> None: - self.assertFalse(is_self_test_case("inc-1", "DLP print incident")) - - -if __name__ == "__main__": - unittest.main() diff --git a/aw-server/dlp-case-management/test_case_storage.py b/aw-server/dlp-case-management/test_case_storage.py deleted file mode 100644 index faf6f72..0000000 --- a/aw-server/dlp-case-management/test_case_storage.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import tempfile -import unittest -from pathlib import Path - -from case_storage import CaseStorage, ForensicsHostMismatchError - - -class CaseStorageHayabusaLinkTest(unittest.TestCase): - def test_link_hayabusa_metadata(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - db_path = Path(tmpdir) / "cases.db" - storage = CaseStorage(db_path) - created = storage.create_case( - { - "incident_id": "inc-1", - "host": "SHARKON2025", - "title": "DLP print incident", - "severity": "high", - }, - actor="test", - ) - linked = storage.link_hayabusa( - case_id=int(created["id"]), - payload={ - "host": "SHARKON2025", - "mode": "incident", - "status": "ok", - "intake_id": "pkg-1", - "report_dir": "/opt/hayabusa/reports/SHARKON2025/run-1", - "package_path": "/opt/hayabusa/archive/packages/SHARKON2025/pkg-1.zip", - "sha256": "abc123", - "link_source": "unit-test", - }, - actor="test", - ) - hayabusa = (linked.get("forensics") or {}).get("hayabusa") or {} - self.assertEqual(hayabusa.get("tool"), "hayabusa") - self.assertEqual(hayabusa.get("host"), "SHARKON2025") - self.assertEqual(hayabusa.get("mode"), "incident") - self.assertEqual(hayabusa.get("status"), "ok") - self.assertEqual(hayabusa.get("intake_id"), "pkg-1") - self.assertEqual(hayabusa.get("link_source"), "unit-test") - audit = storage.list_audit(int(created["id"])) - self.assertTrue(any(row.get("action") == "link_hayabusa" for row in audit)) - - def test_create_case_deduplicates_by_incident_and_host(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - db_path = Path(tmpdir) / "cases.db" - storage = CaseStorage(db_path) - payload = { - "incident_id": "2026-05-14T17:00:52.186Z|self_test|Администратор|||", - "host": "SHARKON2025", - "title": "DLP self_test · Администратор", - "severity": "medium", - } - created = storage.create_case(payload, actor="test") - duplicate = storage.create_case(payload, actor="test") - self.assertEqual(created["id"], duplicate["id"]) - cases = storage.list_cases(host="SHARKON2025", limit=10) - self.assertEqual(len(cases), 1) - - def test_link_hayabusa_rejects_host_mismatch(self) -> None: - with tempfile.TemporaryDirectory() as tmpdir: - db_path = Path(tmpdir) / "cases.db" - storage = CaseStorage(db_path) - created = storage.create_case( - { - "incident_id": "inc-2", - "host": "SHARKON2025", - "title": "DLP print incident", - "severity": "high", - }, - actor="test", - ) - with self.assertRaises(ForensicsHostMismatchError): - storage.link_hayabusa( - case_id=int(created["id"]), - payload={ - "host": "stability", - "mode": "incident", - "status": "ok", - "intake_id": "pkg-2", - "report_dir": "/opt/hayabusa/reports/stability/run-1", - }, - actor="test", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/aw-server/dlp-compliance/compliance_scheduler.py b/aw-server/dlp-compliance/compliance_scheduler.py deleted file mode 100644 index fb55a9a..0000000 --- a/aw-server/dlp-compliance/compliance_scheduler.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json - -from report_generator import generate_report - - -def main() -> None: - parser = argparse.ArgumentParser(description="Run one or more DLP compliance report profiles") - parser.add_argument("--month", help="Month in YYYY-MM format") - parser.add_argument("--profiles", default="152-fz,pci-dss", help="Comma-separated profiles to generate") - parser.add_argument("--stdout-json", action="store_true") - args = parser.parse_args() - - profiles = [item.strip() for item in str(args.profiles).split(",") if item.strip()] - results = [generate_report(month=args.month, profile=profile) for profile in profiles] - if args.stdout_json: - print(json.dumps({"items": results}, ensure_ascii=False)) - - -if __name__ == "__main__": - main() diff --git a/aw-server/dlp-compliance/report-scheduler.service b/aw-server/dlp-compliance/report-scheduler.service index d0ad7da..3f380f3 100644 --- a/aw-server/dlp-compliance/report-scheduler.service +++ b/aw-server/dlp-compliance/report-scheduler.service @@ -5,7 +5,7 @@ After=network-online.target [Service] Type=oneshot EnvironmentFile=-/etc/activitywatch/aw-server.env -WorkingDirectory=/opt/activitywatch/dlp-compliance -ExecStart=/opt/activitywatch/dlp-compliance/.venv/bin/python /opt/activitywatch/dlp-compliance/compliance_scheduler.py +WorkingDirectory=/var/lib/activitywatch +ExecStart=/usr/local/bin/aw-dlp-compliance-rust User=activitywatch Group=activitywatch diff --git a/aw-server/dlp-compliance/report_generator.py b/aw-server/dlp-compliance/report_generator.py deleted file mode 100644 index c7b6905..0000000 --- a/aw-server/dlp-compliance/report_generator.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import os -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from urllib.parse import quote -from urllib.request import Request, urlopen - - -def _env(name: str, default: str) -> str: - value = os.environ.get(name) - return value if value not in (None, "") else default - - -def build_aw_api_base(raw_url: str | None) -> str: - url = (raw_url or "http://127.0.0.1:5600").strip().rstrip("/") - if url.endswith("/api/0"): - return url - return url + "/api/0" - - -AW_API_BASE = build_aw_api_base(_env("AW_DLP_AW_API_BASE", _env("AW_SERVER_URL", "http://127.0.0.1:5600"))) -OUTPUT_DIR = Path(_env("AW_DLP_COMPLIANCE_REPORT_DIR", "/opt/activitywatch/dlp-compliance/reports")) -BASE_DIR = Path(__file__).resolve().parent -PROFILE_TEMPLATE_MAP = { - "152-fz": BASE_DIR / "templates" / "152-fz-report.html", - "pci-dss": BASE_DIR / "templates" / "pci-dss-report.html", -} - - -@dataclass -class ReportStats: - total_incidents: int - high: int - medium: int - low: int - by_host: dict[str, int] - channels: dict[str, int] - - -def _http_json(url: str) -> object: - req = Request(url, headers={"Accept": "application/json"}) - with urlopen(req, timeout=30) as response: - return json.loads(response.read().decode("utf-8")) - - -def _parse_ts(value: str | None) -> datetime | None: - if not value: - return None - text = value.replace("Z", "+00:00") - try: - return datetime.fromisoformat(text).astimezone(UTC) - except ValueError: - return None - - -def _load_incidents(start: datetime, end: datetime) -> list[dict]: - buckets = _http_json(f"{AW_API_BASE}/buckets") - if not isinstance(buckets, dict): - return [] - bucket_ids = sorted([bid for bid in buckets.keys() if str(bid).startswith("aw-dlp-incidents_")]) - - incidents: list[dict] = [] - for bucket_id in bucket_ids: - encoded = quote(str(bucket_id), safe="") - events = _http_json(f"{AW_API_BASE}/buckets/{encoded}/events?limit=2000") - if not isinstance(events, list): - continue - for event in events: - if not isinstance(event, dict): - continue - ts = _parse_ts(event.get("timestamp")) - if ts is None or ts < start or ts > end: - continue - incidents.append(event) - return incidents - - -def _build_stats(incidents: list[dict]) -> ReportStats: - by_host: dict[str, int] = {} - channels: dict[str, int] = {} - high = medium = low = 0 - for event in incidents: - data = event.get("data") or {} - if not isinstance(data, dict): - data = {} - host = str(data.get("hostname") or "unknown") - by_host[host] = by_host.get(host, 0) + 1 - - severity = str(data.get("severity") or "low").lower() - if severity == "high": - high += 1 - elif severity == "medium": - medium += 1 - else: - low += 1 - - channel = str(data.get("signalType") or data.get("source") or "unknown") - channels[channel] = channels.get(channel, 0) + 1 - - return ReportStats( - total_incidents=len(incidents), - high=high, - medium=medium, - low=low, - by_host=dict(sorted(by_host.items(), key=lambda item: item[1], reverse=True)), - channels=dict(sorted(channels.items(), key=lambda item: item[1], reverse=True)), - ) - - -def _render_table(title: str, rows: list[tuple[str, int]]) -> str: - if not rows: - return f"

    {title}

    Нет данных

    " - body = "".join([f"{name}{count}" for name, count in rows]) - return f"

    {title}

    {body}
    ПараметрЗначение
    " - - -def _resolve_template_path(profile: str) -> Path: - if profile == "152-fz": - explicit = _env("AW_DLP_COMPLIANCE_TEMPLATE", str(PROFILE_TEMPLATE_MAP["152-fz"])) - return Path(explicit) - return PROFILE_TEMPLATE_MAP.get(profile, PROFILE_TEMPLATE_MAP["152-fz"]) - - -def _render_html(profile: str, period_label: str, stats: ReportStats, generated_at: str) -> str: - template = _resolve_template_path(profile).read_text(encoding="utf-8") - return ( - template.replace("{{PERIOD}}", period_label) - .replace("{{PROFILE}}", profile) - .replace("{{GENERATED_AT}}", generated_at) - .replace("{{TOTAL}}", str(stats.total_incidents)) - .replace("{{HIGH}}", str(stats.high)) - .replace("{{MEDIUM}}", str(stats.medium)) - .replace("{{LOW}}", str(stats.low)) - .replace("{{HOST_TABLE}}", _render_table("Инциденты по хостам", list(stats.by_host.items()))) - .replace("{{CHANNEL_TABLE}}", _render_table("Инциденты по каналам", list(stats.channels.items()))) - ) - - -def _period_bounds(month: str | None) -> tuple[datetime, datetime, str]: - if month: - start = datetime.fromisoformat(f"{month}-01T00:00:00+00:00").astimezone(UTC) - else: - now = datetime.now(UTC) - start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - if start.month == 12: - end = start.replace(year=start.year + 1, month=1) - else: - end = start.replace(month=start.month + 1) - end = end.replace(second=0, microsecond=0) - return start, end, start.strftime("%Y-%m") - - -def generate_report(month: str | None = None, profile: str = "152-fz") -> dict[str, object]: - start, end, period_label = _period_bounds(month) - incidents = _load_incidents(start, end) - stats = _build_stats(incidents) - - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - generated_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") - html_out = OUTPUT_DIR / f"{profile}-{period_label}.html" - html_out.write_text(_render_html(profile, period_label, stats, generated_at), encoding="utf-8") - - metadata = { - "profile": profile, - "period": period_label, - "generated_at": generated_at, - "aw_api_base": AW_API_BASE, - "report_path": str(html_out), - "stats": { - "total_incidents": stats.total_incidents, - "high": stats.high, - "medium": stats.medium, - "low": stats.low, - }, - } - (OUTPUT_DIR / f"{profile}-{period_label}.json").write_text( - json.dumps(metadata, ensure_ascii=False, indent=2), - encoding="utf-8", - ) - return metadata - - -def main() -> None: - parser = argparse.ArgumentParser(description="Generate DLP compliance report from AW DLP incidents") - parser.add_argument("--month", help="Month in YYYY-MM format (default: current month)") - parser.add_argument("--profile", default="152-fz", help="Profile name: 152-fz or pci-dss") - parser.add_argument("--stdout-json", action="store_true", help="Print report metadata as JSON") - args = parser.parse_args() - metadata = generate_report(month=args.month, profile=args.profile) - - if args.stdout_json: - print(json.dumps(metadata, ensure_ascii=False)) - - -if __name__ == "__main__": - main() diff --git a/aw-server/dlp-integrations/cef-exporter.service b/aw-server/dlp-integrations/cef-exporter.service index 41186a0..9c147c7 100644 --- a/aw-server/dlp-integrations/cef-exporter.service +++ b/aw-server/dlp-integrations/cef-exporter.service @@ -6,8 +6,8 @@ After=network-online.target Type=oneshot User=activitywatch Group=activitywatch -WorkingDirectory=/opt/activitywatch/dlp-integrations -ExecStart=/opt/activitywatch/dlp-integrations/.venv/bin/python /opt/activitywatch/dlp-integrations/cef_exporter.py +WorkingDirectory=/var/lib/activitywatch +ExecStart=/usr/local/bin/dlp-cef-exporter-rust [Install] WantedBy=multi-user.target diff --git a/aw-server/dlp-integrations/cef_exporter.py b/aw-server/dlp-integrations/cef_exporter.py deleted file mode 100644 index a21e966..0000000 --- a/aw-server/dlp-integrations/cef_exporter.py +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import logging -import socket -from datetime import datetime, timezone -from pathlib import Path -from typing import Any -from urllib import error, request - -import yaml - -LOG = logging.getLogger("aw.dlp.cef_exporter") - - -def setup_logging() -> None: - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") - - -def load_yaml(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return {} - return data - - -def load_json(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - try: - data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, dict): - return data - except Exception: - return {} - return {} - - -def save_json(path: Path, payload: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - - -def http_json(url: str, timeout: int = 15) -> Any: - req = request.Request(url, method="GET") - with request.urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8", errors="ignore")) - - -def escape_cef(v: Any) -> str: - s = "" if v is None else str(v) - return s.replace("\\", "\\\\").replace("|", "\\|").replace("=", "\\=").replace("\n", "\\n").replace("\r", "") - - -def map_severity(name: str, mapping: dict[str, int]) -> int: - return int(mapping.get((name or "").lower(), 3)) - - -def build_cef(event: dict[str, Any], mapping: dict[str, int]) -> str: - data = event.get("data") or {} - sev_name = str(data.get("severity") or "low").lower() - sev_num = map_severity(sev_name, mapping) - rt = event.get("timestamp") or datetime.now(timezone.utc).isoformat() - rule = data.get("ruleId") or "dlp-incident" - msg = data.get("message") or "AWatch DLP incident" - sig = data.get("signalType") or "unknown" - host = data.get("hostname") or "unknown" - user = data.get("username") or "unknown" - action = data.get("action") or "alert" - ext = ( - f"rt={escape_cef(rt)} " - f"shost={escape_cef(host)} " - f"suser={escape_cef(user)} " - f"cs1Label=signalType cs1={escape_cef(sig)} " - f"cs2Label=action cs2={escape_cef(action)} " - f"cs3Label=ruleId cs3={escape_cef(rule)}" - ) - return ( - f"CEF:0|AWatch-rus|DLP|1.0|{escape_cef(rule)}|{escape_cef(msg)}|{sev_num}|{ext}" - ) - - -def send_syslog_udp(line: str, host: str, port: int) -> None: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - sock.sendto(line.encode("utf-8", errors="ignore"), (host, port)) - finally: - sock.close() - - -def send_syslog_tcp(line: str, host: str, port: int, timeout: int = 10) -> None: - sock = socket.create_connection((host, port), timeout=timeout) - try: - sock.sendall((line + "\n").encode("utf-8", errors="ignore")) - finally: - sock.close() - - -def iter_new_incidents( - aw_base: str, - state: dict[str, Any], - per_bucket_limit: int, -) -> tuple[list[dict[str, Any]], dict[str, int]]: - buckets = http_json(f"{aw_base}/buckets/") - bucket_ids = sorted([bid for bid in buckets.keys() if bid.startswith("aw-dlp-incidents_")]) - last_ids = state.get("last_ids", {}) - if not isinstance(last_ids, dict): - last_ids = {} - max_ids: dict[str, int] = {} - out: list[dict[str, Any]] = [] - for bid in bucket_ids: - try: - events = http_json(f"{aw_base}/buckets/{bid}/events?limit={int(per_bucket_limit)}") - except error.HTTPError as exc: - LOG.warning("skip bucket %s: %s", bid, exc) - continue - prev = int(last_ids.get(bid, 0)) - bucket_max = prev - for ev in events: - eid = int(ev.get("id") or 0) - if eid <= prev: - continue - out.append(ev) - if eid > bucket_max: - bucket_max = eid - max_ids[bid] = bucket_max - out.sort(key=lambda x: int(x.get("id") or 0)) - return out, max_ids - - -def main() -> None: - setup_logging() - cfg_path = Path("/opt/activitywatch/dlp-integrations/cef-config.yaml") - cfg = load_yaml(cfg_path) - aw_base = str(cfg.get("aw_api_base", "http://127.0.0.1:5600/api/0")).rstrip("/") - syslog_host = str(cfg.get("syslog_host", "127.0.0.1")) - syslog_port = int(cfg.get("syslog_port", 514)) - syslog_proto = str(cfg.get("syslog_proto", "udp")).lower() - per_bucket_limit = int(cfg.get("per_bucket_limit", 300)) - state_path = Path(str(cfg.get("state_path", "/var/lib/activitywatch/dlp-integrations/cef-state.json"))) - sev_mapping = cfg.get("severity_mapping", {"low": 3, "medium": 6, "high": 10}) - if not isinstance(sev_mapping, dict): - sev_mapping = {"low": 3, "medium": 6, "high": 10} - - state = load_json(state_path) - incidents, max_ids = iter_new_incidents(aw_base=aw_base, state=state, per_bucket_limit=per_bucket_limit) - sent = 0 - for ev in incidents: - line = build_cef(ev, sev_mapping) - if syslog_proto == "tcp": - send_syslog_tcp(line, syslog_host, syslog_port) - else: - send_syslog_udp(line, syslog_host, syslog_port) - sent += 1 - state["last_ids"] = max_ids - state["updated_at"] = datetime.now(timezone.utc).isoformat() - save_json(state_path, state) - LOG.info("CEF exporter done: sent=%d buckets=%d target=%s:%d/%s", sent, len(max_ids), syslog_host, syslog_port, syslog_proto) - - -if __name__ == "__main__": - main() diff --git a/aw-server/dlp-integrations/syslog-forwarder.service b/aw-server/dlp-integrations/syslog-forwarder.service index d354551..bbd573d 100644 --- a/aw-server/dlp-integrations/syslog-forwarder.service +++ b/aw-server/dlp-integrations/syslog-forwarder.service @@ -4,7 +4,7 @@ After=network-online.target [Service] Type=oneshot -WorkingDirectory=/opt/activitywatch/dlp-integrations -ExecStart=/opt/activitywatch/dlp-integrations/.venv/bin/python /opt/activitywatch/dlp-integrations/syslog_forwarder.py +WorkingDirectory=/var/lib/activitywatch +ExecStart=/usr/local/bin/dlp-syslog-forwarder-rust User=activitywatch Group=activitywatch diff --git a/aw-server/dlp-integrations/syslog_forwarder.py b/aw-server/dlp-integrations/syslog_forwarder.py deleted file mode 100644 index 4ecec71..0000000 --- a/aw-server/dlp-integrations/syslog_forwarder.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import logging -import socket -from datetime import datetime, timezone -from pathlib import Path -from typing import Any -from urllib import error, request - -import yaml - -LOG = logging.getLogger("aw.dlp.syslog_forwarder") - - -def setup_logging() -> None: - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") - - -def load_yaml(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - return data if isinstance(data, dict) else {} - - -def load_json(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - try: - data = json.loads(path.read_text(encoding="utf-8")) - except Exception: - return {} - return data if isinstance(data, dict) else {} - - -def save_json(path: Path, payload: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - - -def http_json(url: str, timeout: int = 15) -> Any: - req = request.Request(url, method="GET") - with request.urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8", errors="ignore")) - - -def iter_new_incidents(aw_base: str, state: dict[str, Any], per_bucket_limit: int) -> tuple[list[dict[str, Any]], dict[str, int]]: - buckets = http_json(f"{aw_base}/buckets/") - bucket_ids = sorted([bid for bid in buckets.keys() if bid.startswith("aw-dlp-incidents_")]) - last_ids = state.get("last_ids", {}) - if not isinstance(last_ids, dict): - last_ids = {} - max_ids: dict[str, int] = {} - out: list[dict[str, Any]] = [] - for bid in bucket_ids: - try: - events = http_json(f"{aw_base}/buckets/{bid}/events?limit={int(per_bucket_limit)}") - except (TimeoutError, OSError, error.URLError, error.HTTPError) as exc: - LOG.warning("skip bucket %s: %s", bid, exc) - continue - prev = int(last_ids.get(bid, 0)) - bucket_max = prev - for ev in events: - eid = int(ev.get("id") or 0) - if eid <= prev: - continue - out.append(ev) - if eid > bucket_max: - bucket_max = eid - max_ids[bid] = bucket_max - out.sort(key=lambda x: int(x.get("id") or 0)) - return out, max_ids - - -def build_message(event: dict[str, Any], app_name: str, facility: int) -> str: - pri = facility * 8 + 6 - ts = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - data = event.get("data") or {} - host = str(data.get("hostname") or "unknown") - payload = json.dumps( - { - "event_id": event.get("id"), - "timestamp": event.get("timestamp"), - "host": host, - "severity": data.get("severity"), - "signalType": data.get("signalType"), - "username": data.get("username"), - "action": data.get("action"), - "message": data.get("message"), - "data": data, - }, - ensure_ascii=False, - separators=(",", ":"), - ) - return f"<{pri}>1 {ts} {host} {app_name} - - - {payload}" - - -def send_syslog(line: str, host: str, port: int, proto: str, timeout: int = 10) -> None: - if proto.lower() == "tcp": - sock = socket.create_connection((host, port), timeout=timeout) - try: - sock.sendall((line + "\n").encode("utf-8", errors="ignore")) - finally: - sock.close() - return - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - sock.sendto(line.encode("utf-8", errors="ignore"), (host, port)) - finally: - sock.close() - - -def main() -> None: - setup_logging() - cfg_path = Path("/opt/activitywatch/dlp-integrations/syslog-forwarder-config.yaml") - cfg = load_yaml(cfg_path) - aw_base = str(cfg.get("aw_api_base", "http://127.0.0.1:5600/api/0")).rstrip("/") - state_path = Path(str(cfg.get("state_path", "/var/lib/activitywatch/dlp-integrations/syslog-forwarder-state.json"))) - per_bucket_limit = int(cfg.get("per_bucket_limit", 300)) - syslog_host = str(cfg.get("syslog_host", "127.0.0.1")) - syslog_port = int(cfg.get("syslog_port", 514)) - syslog_proto = str(cfg.get("syslog_proto", "udp")) - facility = int(cfg.get("facility", 16)) - app_name = str(cfg.get("app_name", "aw-dlp")) - - state = load_json(state_path) - try: - incidents, max_ids = iter_new_incidents(aw_base=aw_base, state=state, per_bucket_limit=per_bucket_limit) - except (TimeoutError, OSError, error.URLError, error.HTTPError) as exc: - LOG.warning("skip syslog forwarder run: AW API unavailable: %s", exc) - return - - sent = 0 - for event in incidents: - line = build_message(event, app_name=app_name, facility=facility) - send_syslog(line=line, host=syslog_host, port=syslog_port, proto=syslog_proto) - sent += 1 - - save_json(state_path, {"last_ids": max_ids}) - LOG.info("syslog forwarder sent=%d buckets=%d", sent, len(max_ids)) - - -if __name__ == "__main__": - main() diff --git a/aw-server/dlp-integrations/test_syslog_forwarder.py b/aw-server/dlp-integrations/test_syslog_forwarder.py deleted file mode 100644 index 446cbe3..0000000 --- a/aw-server/dlp-integrations/test_syslog_forwarder.py +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -import json -import sys -from pathlib import Path - - -MODULE_PATH = Path(__file__).with_name("syslog_forwarder.py") -SPEC = importlib.util.spec_from_file_location("syslog_forwarder", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - - -def test_iter_new_incidents_skips_timed_out_bucket(monkeypatch): - def fake_http_json(url, timeout=15): - if url.endswith("/buckets/"): - return {"aw-dlp-incidents_SHARKON2025": {}} - raise TimeoutError("timed out") - - monkeypatch.setattr(MODULE, "http_json", fake_http_json) - - incidents, max_ids = MODULE.iter_new_incidents( - aw_base="http://127.0.0.1:5600/api/0", - state={"last_ids": {"aw-dlp-incidents_SHARKON2025": 42}}, - per_bucket_limit=300, - ) - - assert incidents == [] - assert max_ids == {} - - -def test_main_skips_aw_api_timeout_without_overwriting_state(monkeypatch, tmp_path): - state_path = tmp_path / "syslog-forwarder-state.json" - original_state = {"last_ids": {"aw-dlp-incidents_SHARKON2025": 99}} - state_path.write_text(json.dumps(original_state), encoding="utf-8") - - monkeypatch.setattr( - MODULE, - "load_yaml", - lambda path: { - "aw_api_base": "http://127.0.0.1:5600/api/0", - "state_path": str(state_path), - }, - ) - monkeypatch.setattr( - MODULE, - "iter_new_incidents", - lambda aw_base, state, per_bucket_limit: (_ for _ in ()).throw(TimeoutError("timed out")), - ) - monkeypatch.setattr( - MODULE, - "save_json", - lambda path, payload: (_ for _ in ()).throw(AssertionError("state should not be saved on AW API timeout")), - ) - - MODULE.main() - - assert json.loads(state_path.read_text(encoding="utf-8")) == original_state diff --git a/aw-server/dlp-integrations/webhook-sender.service b/aw-server/dlp-integrations/webhook-sender.service index 425dd98..5e6fa2d 100644 --- a/aw-server/dlp-integrations/webhook-sender.service +++ b/aw-server/dlp-integrations/webhook-sender.service @@ -6,8 +6,8 @@ After=network-online.target Type=oneshot User=activitywatch Group=activitywatch -WorkingDirectory=/opt/activitywatch/dlp-integrations -ExecStart=/opt/activitywatch/dlp-integrations/.venv/bin/python /opt/activitywatch/dlp-integrations/webhook_sender.py +WorkingDirectory=/var/lib/activitywatch +ExecStart=/usr/local/bin/dlp-webhook-sender-rust [Install] WantedBy=multi-user.target diff --git a/aw-server/dlp-integrations/webhook_sender.py b/aw-server/dlp-integrations/webhook_sender.py deleted file mode 100644 index 49ddd6c..0000000 --- a/aw-server/dlp-integrations/webhook_sender.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import json -import logging -import time -from datetime import datetime, timezone -from pathlib import Path -from typing import Any -from urllib import error, request - -import yaml - -LOG = logging.getLogger("aw.dlp.webhook_sender") - - -def setup_logging() -> None: - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") - - -def load_yaml(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} - if not isinstance(data, dict): - return {} - return data - - -def load_json(path: Path) -> dict[str, Any]: - if not path.exists(): - return {} - try: - data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, dict): - return data - except Exception: - return {} - return {} - - -def save_json(path: Path, payload: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - - -def http_json(url: str, timeout: int = 15) -> Any: - req = request.Request(url, method="GET") - with request.urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8", errors="ignore")) - - -def post_with_retry(url: str, payload: dict[str, Any], retries: int, timeout: int, backoff_base: float) -> bool: - body = json.dumps(payload, ensure_ascii=False).encode("utf-8") - headers = {"Content-Type": "application/json; charset=utf-8"} - for attempt in range(1, retries + 1): - try: - req = request.Request(url, data=body, headers=headers, method="POST") - with request.urlopen(req, timeout=timeout) as resp: - code = getattr(resp, "status", 200) - if 200 <= code < 300: - return True - except error.HTTPError as exc: - LOG.warning("webhook http error url=%s code=%s attempt=%d/%d", url, exc.code, attempt, retries) - except Exception as exc: - LOG.warning("webhook transport error url=%s err=%s attempt=%d/%d", url, exc, attempt, retries) - if attempt < retries: - time.sleep(backoff_base ** (attempt - 1)) - return False - - -def iter_new_incidents(aw_base: str, state: dict[str, Any], per_bucket_limit: int) -> tuple[list[dict[str, Any]], dict[str, int]]: - buckets = http_json(f"{aw_base}/buckets/") - bucket_ids = sorted([bid for bid in buckets.keys() if bid.startswith("aw-dlp-incidents_")]) - last_ids = state.get("last_ids", {}) - if not isinstance(last_ids, dict): - last_ids = {} - max_ids: dict[str, int] = {} - out: list[dict[str, Any]] = [] - for bid in bucket_ids: - events = http_json(f"{aw_base}/buckets/{bid}/events?limit={int(per_bucket_limit)}") - prev = int(last_ids.get(bid, 0)) - bucket_max = prev - for ev in events: - eid = int(ev.get("id") or 0) - if eid <= prev: - continue - out.append(ev) - if eid > bucket_max: - bucket_max = eid - max_ids[bid] = bucket_max - out.sort(key=lambda x: int(x.get("id") or 0)) - return out, max_ids - - -def should_send(severity: str, allowed: list[str]) -> bool: - return severity.lower() in {s.lower() for s in allowed} - - -def main() -> None: - setup_logging() - cfg_path = Path("/opt/activitywatch/dlp-integrations/webhook-config.yaml") - cfg = load_yaml(cfg_path) - aw_base = str(cfg.get("aw_api_base", "http://127.0.0.1:5600/api/0")).rstrip("/") - state_path = Path(str(cfg.get("state_path", "/var/lib/activitywatch/dlp-integrations/webhook-state.json"))) - retries = int(cfg.get("retries", 4)) - timeout = int(cfg.get("timeout_sec", 15)) - backoff_base = float(cfg.get("backoff_base", 2.0)) - per_bucket_limit = int(cfg.get("per_bucket_limit", 300)) - hooks = cfg.get("critical_webhooks", []) - if not isinstance(hooks, list): - hooks = [] - - state = load_json(state_path) - incidents, max_ids = iter_new_incidents(aw_base=aw_base, state=state, per_bucket_limit=per_bucket_limit) - - sent = 0 - for ev in incidents: - data = ev.get("data") or {} - severity = str(data.get("severity") or "low") - for hook in hooks: - if not isinstance(hook, dict): - continue - url = str(hook.get("url") or "").strip() - if not url: - continue - allowed = hook.get("severity", ["high"]) - if isinstance(allowed, str): - allowed = [allowed] - if not should_send(severity, [str(x) for x in allowed]): - continue - payload = { - "source": "AWatch-rus DLP", - "timestamp": ev.get("timestamp"), - "event_id": ev.get("id"), - "severity": severity, - "message": data.get("message"), - "ruleId": data.get("ruleId"), - "signalType": data.get("signalType"), - "hostname": data.get("hostname"), - "username": data.get("username"), - "action": data.get("action"), - "raw": data, - } - if post_with_retry(url=url, payload=payload, retries=retries, timeout=timeout, backoff_base=backoff_base): - sent += 1 - - state["last_ids"] = max_ids - state["updated_at"] = datetime.now(timezone.utc).isoformat() - save_json(state_path, state) - LOG.info("Webhook sender done: delivered=%d incidents_seen=%d", sent, len(incidents)) - - -if __name__ == "__main__": - main() diff --git a/aw-server/dlp-monitoring/metrics_exporter.py b/aw-server/dlp-monitoring/metrics_exporter.py deleted file mode 100644 index 8a52a8a..0000000 --- a/aw-server/dlp-monitoring/metrics_exporter.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -from fastapi import FastAPI -from fastapi.responses import PlainTextResponse - -app = FastAPI(title="AWatch DLP Metrics") - - -@app.get("/metrics", response_class=PlainTextResponse) -def metrics() -> str: - # Minimal exporter baseline for Prometheus scraping. - return "\n".join( - [ - "# HELP aw_dlp_exporter_up Exporter availability.", - "# TYPE aw_dlp_exporter_up gauge", - "aw_dlp_exporter_up 1", - ] - ) diff --git a/aw-server/dlp-policy-engine/dlp-policy-engine.service b/aw-server/dlp-policy-engine/dlp-policy-engine.service index 989be92..65c2c4b 100644 --- a/aw-server/dlp-policy-engine/dlp-policy-engine.service +++ b/aw-server/dlp-policy-engine/dlp-policy-engine.service @@ -8,8 +8,8 @@ StartLimitIntervalSec=60 [Service] Type=simple EnvironmentFile=/etc/activitywatch/aw-server.env -WorkingDirectory=/opt/activitywatch/dlp-policy-engine -ExecStart=/opt/activitywatch/dlp-policy-engine/.venv/bin/uvicorn policy_service:app --host ${AW_DLP_POLICY_ENGINE_BIND_HOST} --port ${AW_DLP_POLICY_ENGINE_PORT} +WorkingDirectory=/var/lib/activitywatch +ExecStart=/usr/local/bin/aw-dlp-policy-engine-rust Restart=on-failure RestartSec=5 User=activitywatch diff --git a/aw-server/dlp-policy-engine/policy_distributor.py b/aw-server/dlp-policy-engine/policy_distributor.py deleted file mode 100644 index 915bb13..0000000 --- a/aw-server/dlp-policy-engine/policy_distributor.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from typing import Any - - -def build_policy_bundle(record: dict[str, Any] | None) -> dict[str, Any]: - if not record: - return { - "active": False, - "policyId": None, - "name": None, - "version": None, - "checksum": None, - "updatedAtUtc": None, - "policy": None, - } - - return { - "active": True, - "policyId": record["id"], - "name": record["name"], - "version": record["current_version"], - "checksum": record["checksum"], - "updatedAtUtc": record["updated_at"], - "policy": record["policy"], - } diff --git a/aw-server/dlp-policy-engine/policy_schema.py b/aw-server/dlp-policy-engine/policy_schema.py deleted file mode 100644 index 3109f4e..0000000 --- a/aw-server/dlp-policy-engine/policy_schema.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from datetime import datetime -from typing import Any - -from pydantic import BaseModel, Field, ConfigDict - - -class PolicyDocument(BaseModel): - model_config = ConfigDict(extra="allow") - - version: int = 1 - defaults: dict[str, Any] = Field( - default_factory=lambda: { - "enabled": True, - "cooldownSeconds": 300, - "action": "alert", - "severity": "medium", - } - ) - endpoint: dict[str, list[dict[str, Any]]] = Field( - default_factory=lambda: { - "clipboard": [], - "usb": [], - "print": [], - } - ) - - -class PolicyCreateRequest(BaseModel): - name: str = Field(min_length=1, max_length=128) - description: str | None = Field(default=None, max_length=2048) - policy: PolicyDocument - activate: bool = False - actor: str | None = Field(default="api") - - -class PolicyUpdateRequest(BaseModel): - name: str | None = Field(default=None, min_length=1, max_length=128) - description: str | None = Field(default=None, max_length=2048) - policy: PolicyDocument | None = None - activate: bool = False - actor: str | None = Field(default="api") - - -class PolicyActivateRequest(BaseModel): - actor: str | None = Field(default="api") - - -class PolicyStatusRequest(BaseModel): - actor: str | None = Field(default="api") - comment: str | None = Field(default=None, max_length=2048) - - -class PolicyRecord(BaseModel): - id: int - name: str - description: str | None - is_active: bool - current_version: int - checksum: str - created_at: datetime - updated_at: datetime - - -class PolicyVersionRecord(BaseModel): - policy_id: int - version: int - checksum: str - created_at: datetime - created_by: str | None diff --git a/aw-server/dlp-policy-engine/policy_service.py b/aw-server/dlp-policy-engine/policy_service.py deleted file mode 100644 index e9dac58..0000000 --- a/aw-server/dlp-policy-engine/policy_service.py +++ /dev/null @@ -1,214 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import os -from pathlib import Path -from typing import Any - -from fastapi import FastAPI, HTTPException - -from policy_distributor import build_policy_bundle -from policy_schema import PolicyActivateRequest, PolicyCreateRequest, PolicyStatusRequest, PolicyUpdateRequest -from policy_storage import PolicyStorage - - -def _env(name: str, default: str) -> str: - value = os.environ.get(name) - return value if value not in (None, "") else default - - -APP_NAME = "aw-dlp-policy-engine" -APP_VERSION = "0.1.0" -DB_PATH = _env("AW_DLP_POLICY_ENGINE_DB_PATH", "/var/lib/activitywatch/dlp-policy-engine.sqlite") -storage = PolicyStorage(DB_PATH) - -app = FastAPI(title=APP_NAME, version=APP_VERSION) -AGENT_STATE: dict[str, dict[str, Any]] = {} - - -@app.get("/healthz") -def healthz() -> dict[str, str]: - return { - "status": "ok", - "service": APP_NAME, - "db_path": DB_PATH, - "db_exists": str(Path(DB_PATH).exists()).lower(), - } - - -@app.get("/api/0/dlp/policies") -def list_policies() -> dict[str, object]: - return {"items": storage.list_policies()} - - -@app.post("/api/0/dlp/policies", status_code=201) -def create_policy(payload: PolicyCreateRequest) -> dict[str, object]: - try: - item = storage.create_policy( - name=payload.name, - description=payload.description, - policy=payload.policy.model_dump(mode="json"), - activate=payload.activate, - actor=payload.actor, - ) - except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return {"item": item} - - -@app.get("/api/0/dlp/policies/active") -def get_active_policy() -> dict[str, object]: - item = storage.get_active_policy() - if not item: - raise HTTPException(status_code=404, detail="no active policy configured") - return build_policy_bundle(item) - - -@app.get("/api/0/dlp/policies/active/version") -def get_active_policy_version() -> dict[str, object]: - item = storage.get_active_policy() - if not item: - raise HTTPException(status_code=404, detail="no active policy configured") - return { - "active": True, - "policyId": item["id"], - "version": item["current_version"], - "checksum": item["checksum"], - "updatedAtUtc": item["updated_at"], - } - - -@app.post("/api/0/dlp/policies/agents/{agent_id}/heartbeat") -def update_agent_policy_heartbeat(agent_id: str, payload: dict[str, Any]) -> dict[str, object]: - AGENT_STATE[agent_id] = { - "agentId": agent_id, - "hostname": payload.get("hostname") or agent_id, - "version": payload.get("version"), - "checksum": payload.get("checksum"), - "updatedAtUtc": payload.get("updatedAtUtc"), - } - return {"ok": True, "agent": AGENT_STATE[agent_id]} - - -@app.get("/api/0/dlp/policies/agents/{agent_id}/desired") -def get_agent_desired_policy(agent_id: str) -> dict[str, object]: - item = storage.get_active_policy() - if not item: - raise HTTPException(status_code=404, detail="no active policy configured") - - current = AGENT_STATE.get(agent_id, {}) - current_version = current.get("version") - current_checksum = current.get("checksum") - desired_version = item["current_version"] - desired_checksum = item["checksum"] - refresh_now = (str(current_version) != str(desired_version)) or (str(current_checksum) != str(desired_checksum)) - - return { - "agentId": agent_id, - "refreshNow": refresh_now, - "reason": "mismatch" if refresh_now else "up-to-date", - "current": { - "version": current_version, - "checksum": current_checksum, - }, - "desired": { - "policyId": item["id"], - "version": desired_version, - "checksum": desired_checksum, - "updatedAtUtc": item["updated_at"], - }, - } - - -@app.post("/api/0/dlp/policies/rollback") -def rollback_active_policy(payload: PolicyActivateRequest) -> dict[str, object]: - item = storage.rollback_active_policy(actor=payload.actor) - if not item: - raise HTTPException(status_code=404, detail="no active policy configured") - return {"item": item} - - -@app.get("/api/0/dlp/policies/{policy_id}") -def get_policy(policy_id: int) -> dict[str, object]: - item = storage.get_policy(policy_id) - if not item: - raise HTTPException(status_code=404, detail="policy not found") - return {"item": item} - - -@app.put("/api/0/dlp/policies/{policy_id}") -def update_policy(policy_id: int, payload: PolicyUpdateRequest) -> dict[str, object]: - try: - item = storage.update_policy( - policy_id=policy_id, - name=payload.name, - description=payload.description, - policy=payload.policy.model_dump(mode="json") if payload.policy is not None else None, - activate=payload.activate, - actor=payload.actor, - ) - except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - if not item: - raise HTTPException(status_code=404, detail="policy not found") - return {"item": item} - - -@app.post("/api/0/dlp/policies/{policy_id}/activate") -def activate_policy(policy_id: int, payload: PolicyActivateRequest) -> dict[str, object]: - try: - item = storage.activate_policy(policy_id=policy_id, actor=payload.actor) - except Exception as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - if not item: - raise HTTPException(status_code=404, detail="policy not found") - return {"item": item} - - -@app.post("/api/0/dlp/policies/{policy_id}/submit") -def submit_policy_for_approval(policy_id: int, payload: PolicyStatusRequest) -> dict[str, object]: - item = storage.set_policy_status(policy_id, "pending_approval", payload.actor, payload.comment) - if not item: - raise HTTPException(status_code=404, detail="policy not found") - return {"item": item} - - -@app.post("/api/0/dlp/policies/{policy_id}/approve") -def approve_policy(policy_id: int, payload: PolicyStatusRequest) -> dict[str, object]: - item = storage.set_policy_status(policy_id, "approved", payload.actor, payload.comment) - if not item: - raise HTTPException(status_code=404, detail="policy not found") - return {"item": item} - - -@app.post("/api/0/dlp/policies/{policy_id}/draft") -def return_policy_to_draft(policy_id: int, payload: PolicyStatusRequest) -> dict[str, object]: - item = storage.set_policy_status(policy_id, "draft", payload.actor, payload.comment) - if not item: - raise HTTPException(status_code=404, detail="policy not found") - return {"item": item} - - -@app.delete("/api/0/dlp/policies/{policy_id}") -def delete_policy(policy_id: int) -> dict[str, bool]: - try: - deleted = storage.delete_policy(policy_id) - except ValueError as exc: - raise HTTPException(status_code=409, detail=str(exc)) from exc - - if not deleted: - raise HTTPException(status_code=404, detail="policy not found") - return {"deleted": True} - - -@app.get("/api/0/dlp/policies/audit") -def list_policy_audit(limit: int = 200) -> dict[str, object]: - return {"items": storage.list_audit(policy_id=None, limit=max(1, min(limit, 1000)))} - - -@app.get("/api/0/dlp/policies/{policy_id}/audit") -def list_single_policy_audit(policy_id: int, limit: int = 200) -> dict[str, object]: - if not storage.get_policy(policy_id): - raise HTTPException(status_code=404, detail="policy not found") - return {"items": storage.list_audit(policy_id=policy_id, limit=max(1, min(limit, 1000)))} diff --git a/aw-server/dlp-policy-engine/policy_storage.py b/aw-server/dlp-policy-engine/policy_storage.py deleted file mode 100644 index 3365579..0000000 --- a/aw-server/dlp-policy-engine/policy_storage.py +++ /dev/null @@ -1,367 +0,0 @@ -from __future__ import annotations - -import hashlib -import json -import sqlite3 -from contextlib import contextmanager -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Iterator - - -def utc_now() -> str: - return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - - -def canonical_policy_json(policy: dict[str, Any]) -> str: - return json.dumps(policy, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - - -def checksum_policy(policy: dict[str, Any]) -> str: - return hashlib.sha256(canonical_policy_json(policy).encode("utf-8")).hexdigest() - - -class PolicyStorage: - def __init__(self, db_path: str) -> None: - self.db_path = Path(db_path) - self.db_path.parent.mkdir(parents=True, exist_ok=True) - self._init_schema() - - @contextmanager - def connect(self) -> Iterator[sqlite3.Connection]: - conn = sqlite3.connect(self.db_path) - conn.row_factory = sqlite3.Row - try: - yield conn - conn.commit() - finally: - conn.close() - - def _init_schema(self) -> None: - with self.connect() as conn: - conn.executescript( - """ - PRAGMA journal_mode=WAL; - - CREATE TABLE IF NOT EXISTS policies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE, - description TEXT, - status TEXT NOT NULL DEFAULT 'draft', - is_active INTEGER NOT NULL DEFAULT 0, - current_version INTEGER NOT NULL DEFAULT 1, - checksum TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS policy_versions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - policy_id INTEGER NOT NULL, - version INTEGER NOT NULL, - policy_json TEXT NOT NULL, - checksum TEXT NOT NULL, - created_at TEXT NOT NULL, - created_by TEXT, - rollback_of_version INTEGER, - FOREIGN KEY(policy_id) REFERENCES policies(id), - UNIQUE(policy_id, version) - ); - - CREATE TABLE IF NOT EXISTS policy_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - policy_id INTEGER, - action TEXT NOT NULL, - actor TEXT, - comment TEXT, - details_json TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY(policy_id) REFERENCES policies(id) - ); - - CREATE INDEX IF NOT EXISTS idx_policies_active ON policies(is_active); - CREATE INDEX IF NOT EXISTS idx_policy_versions_policy ON policy_versions(policy_id, version DESC); - CREATE INDEX IF NOT EXISTS idx_policy_audit_policy ON policy_audit(policy_id, id DESC); - """ - ) - cols = [r["name"] for r in conn.execute("PRAGMA table_info(policies)").fetchall()] - if "status" not in cols: - conn.execute("ALTER TABLE policies ADD COLUMN status TEXT NOT NULL DEFAULT 'draft'") - - def _audit( - self, - conn: sqlite3.Connection, - policy_id: int | None, - action: str, - actor: str | None, - comment: str | None = None, - details: dict[str, Any] | None = None, - ) -> None: - conn.execute( - """ - INSERT INTO policy_audit(policy_id, action, actor, comment, details_json, created_at) - VALUES(?, ?, ?, ?, ?, ?) - """, - ( - policy_id, - action, - actor, - comment, - canonical_policy_json(details) if details is not None else None, - utc_now(), - ), - ) - - def list_policies(self) -> list[dict[str, Any]]: - with self.connect() as conn: - rows = conn.execute( - """ - SELECT id, name, description, status, is_active, current_version, checksum, created_at, updated_at - FROM policies - ORDER BY is_active DESC, updated_at DESC, id DESC - """ - ).fetchall() - return [dict(row) for row in rows] - - def get_policy(self, policy_id: int) -> dict[str, Any] | None: - with self.connect() as conn: - policy_row = conn.execute( - """ - SELECT id, name, description, status, is_active, current_version, checksum, created_at, updated_at - FROM policies - WHERE id = ? - """, - (policy_id,), - ).fetchone() - if not policy_row: - return None - - version_row = conn.execute( - """ - SELECT version, policy_json, checksum, created_at, created_by - FROM policy_versions - WHERE policy_id = ? AND version = ? - """, - (policy_id, policy_row["current_version"]), - ).fetchone() - if not version_row: - return None - - result = dict(policy_row) - result["policy"] = json.loads(version_row["policy_json"]) - result["version_created_at"] = version_row["created_at"] - result["version_created_by"] = version_row["created_by"] - return result - - def get_active_policy(self) -> dict[str, Any] | None: - with self.connect() as conn: - row = conn.execute("SELECT id FROM policies WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1").fetchone() - if not row: - return None - return self.get_policy(int(row["id"])) - - def create_policy(self, name: str, description: str | None, policy: dict[str, Any], activate: bool, actor: str | None) -> dict[str, Any]: - checksum = checksum_policy(policy) - now = utc_now() - status = "deployed" if activate else "draft" - policy_json = canonical_policy_json(policy) - with self.connect() as conn: - if activate: - conn.execute("UPDATE policies SET is_active = 0") - cursor = conn.execute( - """ - INSERT INTO policies(name, description, status, is_active, current_version, checksum, created_at, updated_at) - VALUES(?, ?, ?, ?, 1, ?, ?, ?) - """, - (name, description, status, 1 if activate else 0, checksum, now, now), - ) - policy_id = int(cursor.lastrowid) - conn.execute( - """ - INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version) - VALUES(?, 1, ?, ?, ?, ?, NULL) - """, - (policy_id, policy_json, checksum, now, actor), - ) - self._audit(conn, policy_id, "create", actor, details={"activate": activate, "status": status}) - return self.get_policy(policy_id) # type: ignore[return-value] - - def update_policy( - self, - policy_id: int, - name: str | None, - description: str | None, - policy: dict[str, Any] | None, - activate: bool, - actor: str | None, - ) -> dict[str, Any] | None: - current = self.get_policy(policy_id) - if not current: - return None - - with self.connect() as conn: - new_name = name if name is not None else current["name"] - new_description = description if description is not None else current["description"] - new_version = int(current["current_version"]) - new_checksum = current["checksum"] - new_status = current.get("status", "draft") - - if policy is not None: - new_version += 1 - new_checksum = checksum_policy(policy) - new_status = "draft" - policy_json = canonical_policy_json(policy) - conn.execute( - """ - INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version) - VALUES(?, ?, ?, ?, ?, ?, NULL) - """, - (policy_id, new_version, policy_json, new_checksum, utc_now(), actor), - ) - - if activate: - conn.execute("UPDATE policies SET is_active = 0") - new_status = "deployed" - - conn.execute( - """ - UPDATE policies - SET name = ?, description = ?, status = ?, is_active = ?, current_version = ?, checksum = ?, updated_at = ? - WHERE id = ? - """, - ( - new_name, - new_description, - new_status, - 1 if activate else current["is_active"], - new_version, - new_checksum, - utc_now(), - policy_id, - ), - ) - self._audit(conn, policy_id, "update", actor, details={"activate": activate, "status": new_status}) - return self.get_policy(policy_id) - - def activate_policy(self, policy_id: int, actor: str | None) -> dict[str, Any] | None: - current = self.get_policy(policy_id) - if not current: - return None - if current.get("status") != "approved": - raise ValueError("policy must be approved before deploy") - - with self.connect() as conn: - conn.execute("UPDATE policies SET is_active = 0") - conn.execute( - "UPDATE policies SET status = 'deployed', is_active = 1, updated_at = ? WHERE id = ?", - (utc_now(), policy_id), - ) - self._audit(conn, policy_id, "deploy", actor) - return self.get_policy(policy_id) - - def rollback_active_policy(self, actor: str | None) -> dict[str, Any] | None: - active = self.get_active_policy() - if not active: - return None - - with self.connect() as conn: - rows = conn.execute( - """ - SELECT version, policy_json - FROM policy_versions - WHERE policy_id = ? - ORDER BY version DESC - LIMIT 2 - """, - (active["id"],), - ).fetchall() - if len(rows) < 2: - return active - - previous_version = int(rows[1]["version"]) - previous_policy = json.loads(rows[1]["policy_json"]) - rollback_version = int(active["current_version"]) + 1 - rollback_checksum = checksum_policy(previous_policy) - now = utc_now() - - conn.execute( - """ - INSERT INTO policy_versions(policy_id, version, policy_json, checksum, created_at, created_by, rollback_of_version) - VALUES(?, ?, ?, ?, ?, ?, ?) - """, - ( - active["id"], - rollback_version, - canonical_policy_json(previous_policy), - rollback_checksum, - now, - actor, - previous_version, - ), - ) - conn.execute( - """ - UPDATE policies - SET status = 'draft', current_version = ?, checksum = ?, updated_at = ? - WHERE id = ? - """, - (rollback_version, rollback_checksum, now, active["id"]), - ) - self._audit(conn, int(active["id"]), "rollback", actor, details={"rollback_to": previous_version}) - return self.get_policy(int(active["id"])) - - def delete_policy(self, policy_id: int) -> bool: - current = self.get_policy(policy_id) - if not current: - return False - if current["is_active"]: - raise ValueError("cannot delete active policy") - - with self.connect() as conn: - self._audit(conn, policy_id, "delete", None) - conn.execute("DELETE FROM policy_versions WHERE policy_id = ?", (policy_id,)) - conn.execute("DELETE FROM policies WHERE id = ?", (policy_id,)) - return True - - def set_policy_status(self, policy_id: int, status: str, actor: str | None, comment: str | None = None) -> dict[str, Any] | None: - current = self.get_policy(policy_id) - if not current: - return None - allowed = {"draft", "pending_approval", "approved", "deployed"} - if status not in allowed: - raise ValueError(f"unsupported status: {status}") - with self.connect() as conn: - conn.execute( - "UPDATE policies SET status = ?, updated_at = ? WHERE id = ?", - (status, utc_now(), policy_id), - ) - self._audit(conn, policy_id, "status_change", actor, comment=comment, details={"status": status}) - return self.get_policy(policy_id) - - def list_audit(self, policy_id: int | None = None, limit: int = 200) -> list[dict[str, Any]]: - query = """ - SELECT id, policy_id, action, actor, comment, details_json, created_at - FROM policy_audit - """ - params: tuple[Any, ...] - if policy_id is None: - query += " ORDER BY id DESC LIMIT ?" - params = (limit,) - else: - query += " WHERE policy_id = ? ORDER BY id DESC LIMIT ?" - params = (policy_id, limit) - with self.connect() as conn: - rows = conn.execute(query, params).fetchall() - items: list[dict[str, Any]] = [] - for row in rows: - d = dict(row) - if d.get("details_json"): - try: - d["details"] = json.loads(d["details_json"]) - except Exception: - d["details"] = None - else: - d["details"] = None - d.pop("details_json", None) - items.append(d) - return items diff --git a/aw-server/ensure-reliability.sh b/aw-server/ensure-reliability.sh index 8b2c6ce..54c40d7 100644 --- a/aw-server/ensure-reliability.sh +++ b/aw-server/ensure-reliability.sh @@ -18,7 +18,7 @@ else "$ROOT_DIR/adk-rust/target/release/aw-ensure-reliability" \ "/usr/local/bin/aw-ensure-reliability"; do if [[ -n "$candidate" && -x "$candidate" ]]; then - exec "$candidate" --health-script-source "$SCRIPT_DIR/health-check.sh" "$@" + exec "$candidate" "$@" fi done cat >&2 <<'EOF' @@ -115,10 +115,12 @@ setup_health_check() { local health_service="/etc/systemd/system/aw-health-check.service" if [[ ! -f "$health_script" ]]; then - log "Setting up health check..." - cp "$SCRIPT_DIR/health-check.sh" "$health_script" - chmod +x "$health_script" - + log "ERROR: Rust-first health check wrapper not found: $health_script" + return 1 + fi + + if [[ ! -f "$health_timer" || ! -f "$health_service" ]]; then + log "Setting up health check timer..." # Create systemd timer for health checks cat > "$health_timer" << 'EOF' [Unit] diff --git a/aw-server/hayabusa/aw-hayabusa-autoprocess.py b/aw-server/hayabusa/aw-hayabusa-autoprocess.py deleted file mode 100644 index d42b733..0000000 --- a/aw-server/hayabusa/aw-hayabusa-autoprocess.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import fcntl -import json -import pathlib -import shutil -import subprocess -import sys -from typing import Optional - -DROP_DIR = pathlib.Path('/opt/activitywatch/aw-rus-ops/drop') -LOCK_PATH = pathlib.Path('/opt/hayabusa/state/aw-hayabusa-autoprocess.lock') -WRAPPER = pathlib.Path('/usr/local/bin/aw-hayabusa') -LINKER = pathlib.Path('/usr/local/bin/aw-hayabusa-link-case') -CASE_ALERT = pathlib.Path('/usr/local/bin/aw-hayabusa-case-alert') - - -def run(cmd): - print('RUN', ' '.join(str(x) for x in cmd), flush=True) - subprocess.run(cmd, check=True) - - -def run_capture(cmd): - print('RUN', ' '.join(str(x) for x in cmd), flush=True) - return subprocess.run(cmd, check=False, text=True, capture_output=True) - - -def read_latest_intake(): - return json.loads(pathlib.Path('/opt/hayabusa/state/latest-intake.json').read_text(encoding='utf-8')) - - -def load_sidecars(zip_path: pathlib.Path): - base = zip_path.with_suffix('') - caseid_path = base.with_suffix('.caseid') - meta_path = base.with_suffix('.meta.json') - meta = {} - if meta_path.is_file(): - meta = json.loads(meta_path.read_text(encoding='utf-8')) - case_id = meta.get('case_id') - if case_id is None and caseid_path.is_file(): - raw = caseid_path.read_text(encoding='utf-8').strip() - if raw: - case_id = int(raw) - return { - 'base': base, - 'case_id': case_id, - 'host': meta.get('host'), - 'mode': meta.get('mode', 'incident'), - 'link_source': meta.get('link_source', 'aw-rus-drop-autoprocess'), - 'caseid_path': caseid_path, - 'meta_path': meta_path, - } - - -def archive_sidecars(report_dir: pathlib.Path, sidecars): - target_dir = report_dir / 'input-sidecars' - target_dir.mkdir(parents=True, exist_ok=True) - for path in (sidecars['caseid_path'], sidecars['meta_path']): - if path.is_file(): - shutil.move(str(path), str(target_dir / path.name)) - - -def archive_drop_package(report_dir: pathlib.Path, zip_path: pathlib.Path): - target_dir = report_dir / 'input-drop' - target_dir.mkdir(parents=True, exist_ok=True) - target_path = target_dir / zip_path.name - if target_path.exists(): - target_path.unlink() - shutil.move(str(zip_path), str(target_path)) - - -def guess_host(zip_path: pathlib.Path, sidecars) -> Optional[str]: - if sidecars['host']: - return str(sidecars['host']) - name = zip_path.stem - if '-' in name: - return name.split('-', 1)[0] - return name or None - - -def process_one(zip_path: pathlib.Path): - sidecars = load_sidecars(zip_path) - host = guess_host(zip_path, sidecars) - mode = sidecars['mode'] or 'incident' - accept_cmd = [str(WRAPPER), 'accept', '--package', str(zip_path)] - if host: - accept_cmd += ['--host', host] - run(accept_cmd) - run([str(WRAPPER), 'process-inbox', '--mode', mode, '--limit', '1']) - latest = read_latest_intake() - report_dir = pathlib.Path(latest['report_dir']) - case_alert = None - if CASE_ALERT.is_file(): - alert_cmd = [str(CASE_ALERT), '--mode', mode, '--link-source', sidecars['link_source']] - if sidecars['case_id'] is not None: - alert_cmd += ['--case-id', str(sidecars['case_id'])] - result = run_capture(alert_cmd) - case_alert = { - 'returncode': result.returncode, - 'stdout': result.stdout.strip(), - 'stderr': result.stderr.strip(), - } - archive_sidecars(report_dir, sidecars) - archive_drop_package(report_dir, zip_path) - if sidecars['case_id'] is not None and not CASE_ALERT.is_file(): - run([str(LINKER), '--case-id', str(sidecars['case_id']), '--mode', mode, '--link-source', sidecars['link_source']]) - return {'latest_intake': latest, 'case_alert': case_alert} - - -def main(): - p = argparse.ArgumentParser(description='Auto-process Hayabusa zip packages dropped onto aw-rus server') - p.add_argument('--drop-dir', default=str(DROP_DIR)) - p.add_argument('--once', action='store_true', default=True) - args = p.parse_args() - - drop_dir = pathlib.Path(args.drop_dir) - drop_dir.mkdir(parents=True, exist_ok=True) - LOCK_PATH.parent.mkdir(parents=True, exist_ok=True) - with LOCK_PATH.open('w') as lock_fh: - try: - fcntl.flock(lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - print('autoprocess already running', file=sys.stderr) - return 0 - zips = sorted(drop_dir.glob('*.zip')) - if not zips: - print('no zip packages in drop dir') - return 0 - for zip_path in zips: - result = process_one(zip_path) - print(json.dumps({'processed': str(zip_path), **result}, ensure_ascii=False, indent=2)) - return 0 - - -if __name__ == '__main__': - raise SystemExit(main()) diff --git a/aw-server/hayabusa/aw-hayabusa-case-alert.py b/aw-server/hayabusa/aw-hayabusa-case-alert.py deleted file mode 100644 index fe0fa01..0000000 --- a/aw-server/hayabusa/aw-hayabusa-case-alert.py +++ /dev/null @@ -1,361 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import csv -import json -import os -import pathlib -import urllib.error -import urllib.parse -import urllib.request -from collections import Counter -from datetime import datetime, timezone -from typing import Any - - -SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4} -LEVEL_NORMALIZATION = { - "informational": "info", - "info": "info", - "low": "low", - "med": "med", - "medium": "med", - "high": "high", - "crit": "crit", - "critical": "crit", -} -LEVEL_WEIGHTS = { - "info": 1, - "low": 4, - "med": 12, - "high": 40, - "crit": 100, -} - - -def env_bool(name: str, default: bool) -> bool: - value = os.environ.get(name) - if value is None: - return default - return value.strip().lower() in {"1", "true", "yes", "on"} - - -def normalize_level(level: str | None) -> str: - if not level: - return "info" - return LEVEL_NORMALIZATION.get(level.strip().lower(), level.strip().lower()) - - -def severity_meets(actual: str, threshold: str) -> bool: - return SEVERITY_ORDER.get(actual, 0) >= SEVERITY_ORDER.get(threshold, 0) - - -def build_hayabusa_payload(intake: dict[str, Any], mode: str, link_source: str) -> dict[str, Any]: - report_dir = pathlib.Path(intake["report_dir"]) - return { - "tool": "hayabusa", - "host": intake["host"], - "mode": mode, - "status": intake["status"], - "intake_id": intake["intake_id"], - "package_path": intake["package_path"], - "sha256": intake["sha256"], - "report_dir": intake["report_dir"], - "summary_html": str(report_dir / "summary.html"), - "timeline_path": str(report_dir / "timeline.jsonl"), - "manifest_path": str(report_dir / "manifest.json"), - "link_source": link_source, - } - - -def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = urllib.request.Request(url, data=data, method="POST", headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req) as resp: - body = resp.read().decode("utf-8") - return json.loads(body) if body else {} - - -def patch_json(url: str, payload: dict[str, Any]) -> dict[str, Any]: - data = json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = urllib.request.Request(url, data=data, method="PATCH", headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req) as resp: - body = resp.read().decode("utf-8") - return json.loads(body) if body else {} - - -def get_json(url: str) -> dict[str, Any]: - with urllib.request.urlopen(url) as resp: - return json.loads(resp.read().decode("utf-8")) - - -def parse_timestamp(value: str | None) -> datetime | None: - if not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - - -def read_csv_rows(path: pathlib.Path) -> int: - if not path.is_file(): - return 0 - with path.open("r", encoding="utf-8-sig", newline="") as fh: - reader = csv.reader(fh) - rows = list(reader) - if not rows: - return 0 - return max(0, len(rows) - 1) - - -def analyze_report(report_dir: pathlib.Path) -> dict[str, Any]: - timeline_path = report_dir / "timeline.jsonl" - level_counts: Counter[str] = Counter() - title_counts: Counter[str] = Counter() - first_ts: datetime | None = None - last_ts: datetime | None = None - total_events = 0 - - if timeline_path.is_file(): - with timeline_path.open("r", encoding="utf-8") as fh: - for raw_line in fh: - line = raw_line.strip() - if not line: - continue - try: - event = json.loads(line) - except json.JSONDecodeError: - continue - total_events += 1 - level = normalize_level(str(event.get("Level", ""))) - level_counts[level] += 1 - title = str(event.get("RuleTitle") or "").strip() or "Unknown rule" - title_counts[title] += 1 - ts = parse_timestamp(event.get("Timestamp")) - if ts is not None: - first_ts = ts if first_ts is None or ts < first_ts else first_ts - last_ts = ts if last_ts is None or ts > last_ts else last_ts - - failed_logons = read_csv_rows(report_dir / "logon-summary-failed.csv") - successful_logons = read_csv_rows(report_dir / "logon-summary-successful.csv") - - suspicious_pwsh = sum( - count - for title, count in title_counts.items() - if "pwsh" in title.lower() or "powershell" in title.lower() or "obfuscation" in title.lower() - ) - credential_events = sum(count for title, count in title_counts.items() if "credential" in title.lower()) - timestomp_events = sum(count for title, count in title_counts.items() if "timestomp" in title.lower()) - logon_failure_events = sum(count for title, count in title_counts.items() if "logon failure" in title.lower()) - - score = ( - sum(LEVEL_WEIGHTS.get(level, 0) * count for level, count in level_counts.items()) - + min(failed_logons, 200) * 2 - + suspicious_pwsh * 6 - + credential_events * 8 - + timestomp_events * 12 - + logon_failure_events * 2 - ) - - crit_count = level_counts.get("crit", 0) - high_count = level_counts.get("high", 0) - med_count = level_counts.get("med", 0) - - if crit_count >= 1 or score >= 240 or (high_count >= 4 and suspicious_pwsh >= 4): - severity = "critical" - elif high_count >= 1 or score >= 120 or suspicious_pwsh >= 8 or credential_events >= 5: - severity = "high" - elif med_count >= 1 or score >= 40 or failed_logons >= 10: - severity = "medium" - else: - severity = "low" - - return { - "severity": severity, - "score": score, - "events_total": total_events, - "level_counts": dict(level_counts), - "top_rules": [{"title": title, "count": count} for title, count in title_counts.most_common(5)], - "first_timestamp": first_ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if first_ts else None, - "last_timestamp": last_ts.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if last_ts else None, - "failed_logon_rows": failed_logons, - "successful_logon_rows": successful_logons, - "suspicious_pwsh": suspicious_pwsh, - "credential_events": credential_events, - "timestomp_events": timestomp_events, - "logon_failure_events": logon_failure_events, - } - - -def build_case_title(host: str, summary: dict[str, Any]) -> str: - top_rule = summary.get("top_rules") or [] - suffix = top_rule[0]["title"] if top_rule else "No dominant rule" - return f"Hayabusa {summary['severity'].upper()} · {host} · {suffix}" - - -def build_case_payload(intake: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]: - return { - "incident_id": f"hayabusa:{intake['host']}:{intake['intake_id']}", - "host": intake["host"], - "title": build_case_title(intake["host"], summary), - "severity": summary["severity"], - "evidence": { - "hayabusa": { - "intake_id": intake["intake_id"], - "package_path": intake["package_path"], - "sha256": intake["sha256"], - "report_dir": intake["report_dir"], - "summary": summary, - } - }, - } - - -def build_comment(summary: dict[str, Any], intake: dict[str, Any]) -> str: - top = ", ".join(f"{item['title']} ({item['count']})" for item in summary.get("top_rules", [])[:3]) or "n/a" - return ( - f"Hayabusa auto-summary\n" - f"Severity: {summary['severity']} (score={summary['score']})\n" - f"Host: {intake['host']}\n" - f"Intake: {intake['intake_id']}\n" - f"Events: {summary['events_total']}, failed_logons={summary['failed_logon_rows']}, " - f"suspicious_pwsh={summary['suspicious_pwsh']}, credential_events={summary['credential_events']}\n" - f"Top rules: {top}\n" - f"Report: {intake['report_dir']}" - ) - - -def send_telegram(bot_token: str, chat_ids: list[str], text: str) -> list[dict[str, Any]]: - results = [] - for chat_id in chat_ids: - payload = urllib.parse.urlencode({"chat_id": chat_id, "text": text}).encode("utf-8") - url = f"https://api.telegram.org/bot{bot_token}/sendMessage" - req = urllib.request.Request(url, data=payload, method="POST") - try: - with urllib.request.urlopen(req) as resp: - body = json.loads(resp.read().decode("utf-8")) - results.append({"chat_id": chat_id, "ok": True, "response": body}) - except Exception as exc: # noqa: BLE001 - results.append({"chat_id": chat_id, "ok": False, "error": str(exc)}) - return results - - -def build_telegram_text(case_id: int | None, intake: dict[str, Any], summary: dict[str, Any]) -> str: - severity_label = { - "critical": "критичное событие", - "high": "опасное событие", - "medium": "подозрительное событие", - "low": "слабый сигнал", - }.get(summary["severity"], summary["severity"]) - top_rules = summary.get("top_rules", []) - top_rule = top_rules[0]["title"] if top_rules else "нет явного доминирующего правила" - lines = [ - f"Hayabusa: {severity_label}", - "", - f"Хост: {intake['host']}", - ] - if case_id is not None: - lines.append(f"Кейс: {case_id}") - lines.extend( - [ - f"Уровень: {summary['severity']}", - "", - "Что найдено:", - f"- {top_rule}: {top_rules[0]['count'] if top_rules else 0}", - f"- подозрительный PowerShell: {summary['suspicious_pwsh']}", - f"- ошибок входа: {summary['logon_failure_events']}", - f"- событий по учётным данным: {summary['credential_events']}", - ] - ) - if summary.get("timestomp_events", 0) > 0: - lines.append(f"- timestomp-подобных событий: {summary['timestomp_events']}") - lines.extend( - [ - "", - "Главный риск:", - "возможная активность вокруг учётных данных и PowerShell", - "", - "Отчёт:", - f"{intake['report_dir']}", - ] - ) - return "\n".join(lines) - - -def main() -> int: - p = argparse.ArgumentParser(description="Auto-create/update AW-rus case, compute Hayabusa severity, and send Telegram alerts") - p.add_argument("--case-id", type=int) - p.add_argument("--intake-json", default="/opt/hayabusa/state/latest-intake.json") - p.add_argument("--case-api-base", default=os.environ.get("AW_HAYABUSA_CASE_API_BASE", "http://127.0.0.1:5602")) - p.add_argument("--mode", default="incident") - p.add_argument("--link-source", default="aw-rus-drop-autoprocess") - p.add_argument("--auto-create", action="store_true", default=env_bool("AW_HAYABUSA_AUTO_CASE_ENABLED", True)) - p.add_argument("--auto-create-min-severity", default=os.environ.get("AW_HAYABUSA_AUTO_CASE_MIN_SEVERITY", "medium")) - p.add_argument("--telegram-enabled", action="store_true", default=env_bool("AW_HAYABUSA_TELEGRAM_ENABLED", False)) - p.add_argument("--telegram-min-severity", default=os.environ.get("AW_HAYABUSA_TELEGRAM_MIN_SEVERITY", "high")) - p.add_argument("--telegram-bot-token", default=os.environ.get("AW_HAYABUSA_TELEGRAM_BOT_TOKEN", "")) - p.add_argument("--telegram-chat-ids", default=os.environ.get("AW_HAYABUSA_TELEGRAM_CHAT_IDS", "")) - args = p.parse_args() - - intake_path = pathlib.Path(args.intake_json) - intake = json.loads(intake_path.read_text(encoding="utf-8")) - summary = analyze_report(pathlib.Path(intake["report_dir"])) - case_api_base = args.case_api_base.rstrip("/") - if case_api_base.endswith("/api/0/dlp/cases"): - case_api_base = case_api_base[: -len("/api/0/dlp/cases")] - case_id = args.case_id - created_case = None - case_error = None - linked = False - comment_added = False - - try: - if case_id is None and args.auto_create and severity_meets(summary["severity"], args.auto_create_min_severity): - created_case = post_json(f"{case_api_base}/api/0/dlp/cases", build_case_payload(intake, summary)) - case_id = int(created_case["id"]) - - if case_id is not None: - patch_json( - f"{case_api_base}/api/0/dlp/cases/{case_id}", - {"severity": summary["severity"]}, - ) - post_json( - f"{case_api_base}/api/0/dlp/cases/{case_id}/forensics/hayabusa", - build_hayabusa_payload(intake, args.mode, args.link_source), - ) - linked = True - post_json( - f"{case_api_base}/api/0/dlp/cases/{case_id}/comments", - {"comment": build_comment(summary, intake), "author": "aw-hayabusa-auto"}, - ) - comment_added = True - except Exception as exc: # noqa: BLE001 - case_error = str(exc) - - telegram_results: list[dict[str, Any]] = [] - if args.telegram_enabled and args.telegram_bot_token and severity_meets(summary["severity"], args.telegram_min_severity): - chat_ids = [item.strip() for item in args.telegram_chat_ids.split(",") if item.strip()] - if chat_ids: - telegram_results = send_telegram( - bot_token=args.telegram_bot_token, - chat_ids=chat_ids, - text=build_telegram_text(case_id, intake, summary), - ) - - result = { - "summary": summary, - "case_id": case_id, - "case_created": created_case, - "case_linked": linked, - "case_comment_added": comment_added, - "case_error": case_error, - "telegram_results": telegram_results, - } - print(json.dumps(result, ensure_ascii=False, indent=2)) - return 0 if case_error is None else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/aw-server/hayabusa/aw-hayabusa-from-windows.py b/aw-server/hayabusa/aw-hayabusa-from-windows.py deleted file mode 100644 index 6d157ee..0000000 --- a/aw-server/hayabusa/aw-hayabusa-from-windows.py +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import pathlib -import re -import subprocess -import sys - -OPS_ROOT = pathlib.Path('/opt/activitywatch/aw-rus-ops') -DEFAULT_INVENTORY = OPS_ROOT / 'ansible' / 'inventory.ini' -DEFAULT_DROP = OPS_ROOT / 'drop' -DEFAULT_ANSIBLE = OPS_ROOT / 'venv' / 'bin' / 'ansible' -DEFAULT_WRAPPER = pathlib.Path('/usr/local/bin/aw-hayabusa') -DEFAULT_LINKER = pathlib.Path('/usr/local/bin/aw-hayabusa-link-case') -WINDOWS_EXPORT_CMD = r"powershell.exe -ExecutionPolicy Bypass -File C:\ProgramData\AWatch-rus\export-evtx-for-hayabusa.ps1 -DaysBack {days_back} | ConvertTo-Json -Depth 8 -Compress" -WINDOWS_LATEST_ZIP_CMD = r"Get-ChildItem 'C:\ProgramData\AWatch-rus\forensics\evtx-exports' -File -Filter '*.zip' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 FullName,Length,LastWriteTime | ConvertTo-Json -Compress" - - -def run(cmd): - proc = subprocess.run(cmd, text=True, capture_output=True) - if proc.returncode != 0: - sys.stderr.write(proc.stdout) - sys.stderr.write(proc.stderr) - raise SystemExit(proc.returncode) - return proc.stdout - - -def extract_json_blob(text): - matches = re.findall(r'(\{.*\}|\[.*\])', text, re.S) - for candidate in reversed(matches): - try: - return json.loads(candidate) - except Exception: - continue - raise SystemExit('cannot parse JSON from ansible output:\n' + text) - - -def main(): - p = argparse.ArgumentParser(description='Run Windows EVTX export and Hayabusa intake directly from aw-server, without the laptop') - p.add_argument('--inventory', default=str(DEFAULT_INVENTORY)) - p.add_argument('--ansible-bin', default=str(DEFAULT_ANSIBLE)) - p.add_argument('--drop-dir', default=str(DEFAULT_DROP)) - p.add_argument('--days-back', type=int, default=1) - p.add_argument('--mode', default='incident', choices=['quick', 'incident', 'full']) - p.add_argument('--case-id', type=int) - p.add_argument('--link-source', default='aw-rus-ops-from-windows') - p.add_argument('--windows-group', default='aw_windows') - p.add_argument('--wrapper', default=str(DEFAULT_WRAPPER)) - p.add_argument('--linker', default=str(DEFAULT_LINKER)) - args = p.parse_args() - - inventory = pathlib.Path(args.inventory) - ansible_bin = pathlib.Path(args.ansible_bin) - drop_dir = pathlib.Path(args.drop_dir) - wrapper = pathlib.Path(args.wrapper) - linker = pathlib.Path(args.linker) - if not inventory.is_file(): - raise SystemExit(f'inventory not found: {inventory}') - if not ansible_bin.is_file(): - raise SystemExit(f'ansible binary not found: {ansible_bin}') - if not wrapper.is_file(): - raise SystemExit(f'wrapper not found: {wrapper}') - drop_dir.mkdir(parents=True, exist_ok=True) - - export_cmd = [str(ansible_bin), args.windows_group, '-i', str(inventory), '-m', 'win_shell', '-a', WINDOWS_EXPORT_CMD.format(days_back=args.days_back)] - print('RUN_EXPORT', ' '.join(export_cmd)) - export_out = run(export_cmd) - export_json = extract_json_blob(export_out) - - list_cmd = [str(ansible_bin), args.windows_group, '-i', str(inventory), '-m', 'win_shell', '-a', WINDOWS_LATEST_ZIP_CMD] - print('RUN_LIST', ' '.join(list_cmd)) - latest_out = run(list_cmd) - latest = extract_json_blob(latest_out) - if isinstance(latest, list): - latest = latest[0] - remote_zip = latest['FullName'] - filename = pathlib.PureWindowsPath(remote_zip).name - local_zip = drop_dir / filename - - remote_zip_posix = remote_zip.replace('\\', '/') - fetch_cmd = [str(ansible_bin), args.windows_group, '-i', str(inventory), '-m', 'fetch', '-a', f'src={remote_zip_posix} dest={drop_dir}/ flat=yes'] - print('RUN_FETCH', ' '.join(fetch_cmd)) - run(fetch_cmd) - if not local_zip.is_file(): - raise SystemExit(f'fetched zip not found: {local_zip}') - - accept_cmd = [str(wrapper), 'accept', '--package', str(local_zip)] - host = export_json.get('hostname') or pathlib.Path(filename).stem.split('-')[0] - if host: - accept_cmd += ['--host', str(host)] - print('RUN_ACCEPT', ' '.join(accept_cmd)) - subprocess.run(accept_cmd, check=True) - - process_cmd = [str(wrapper), 'process-inbox', '--mode', args.mode, '--limit', '1'] - print('RUN_PROCESS', ' '.join(process_cmd)) - subprocess.run(process_cmd, check=True) - - if args.case_id is not None: - if not linker.is_file(): - raise SystemExit(f'linker not found: {linker}') - link_cmd = [str(linker), '--case-id', str(args.case_id), '--mode', args.mode, '--link-source', args.link_source] - print('RUN_LINK', ' '.join(link_cmd)) - subprocess.run(link_cmd, check=True) - - latest_intake = pathlib.Path('/opt/hayabusa/state/latest-intake.json') - print('LATEST_INTAKE') - print(latest_intake.read_text(encoding='utf-8')) - - -if __name__ == '__main__': - main() diff --git a/aw-server/hayabusa/aw-hayabusa-link-case.py b/aw-server/hayabusa/aw-hayabusa-link-case.py deleted file mode 100644 index 1cb8d08..0000000 --- a/aw-server/hayabusa/aw-hayabusa-link-case.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import pathlib -import sys -import urllib.error -import urllib.request - - -def normalize_host(value): - return str(value or '').strip().lower() - - -def build_payload(intake, mode, link_source): - report_dir = pathlib.Path(intake['report_dir']) - return { - 'tool': 'hayabusa', - 'host': intake['host'], - 'mode': mode, - 'status': intake['status'], - 'intake_id': intake['intake_id'], - 'package_path': intake['package_path'], - 'sha256': intake['sha256'], - 'report_dir': intake['report_dir'], - 'summary_html': str(report_dir / 'summary.html'), - 'timeline_path': str(report_dir / 'timeline.jsonl'), - 'manifest_path': str(report_dir / 'manifest.json'), - 'link_source': link_source, - } - - -def post_json(url, payload): - data = json.dumps(payload, ensure_ascii=False).encode('utf-8') - req = urllib.request.Request(url, data=data, method='POST', headers={'Content-Type': 'application/json'}) - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read().decode('utf-8')) - - -def get_json(url): - with urllib.request.urlopen(url) as resp: - return json.loads(resp.read().decode('utf-8')) - - -def main(): - p = argparse.ArgumentParser(description='Link latest or specified Hayabusa intake metadata to AW-rus case management') - p.add_argument('--case-id', type=int, required=True) - p.add_argument('--intake-json', default='/opt/hayabusa/state/latest-intake.json') - p.add_argument('--case-api-base', default='http://127.0.0.1:5602') - p.add_argument('--mode', default='incident') - p.add_argument('--link-source', default='aw-rus-ops') - args = p.parse_args() - - intake_path = pathlib.Path(args.intake_json) - if not intake_path.is_file(): - raise SystemExit(f'intake json not found: {intake_path}') - intake = json.loads(intake_path.read_text(encoding='utf-8')) - case = get_json(f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}") - case_host = normalize_host(case.get('host')) - intake_host = normalize_host(intake.get('host')) - if case_host and intake_host and case_host != intake_host: - raise SystemExit( - f"hayabusa host mismatch: case host={case.get('host')} intake host={intake.get('host')}" - ) - payload = build_payload(intake, args.mode, args.link_source) - case_url = f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}/forensics/hayabusa" - try: - post_json(case_url, payload) - except urllib.error.HTTPError as exc: - body = exc.read().decode('utf-8', errors='replace') - raise SystemExit(f'case API POST failed: HTTP {exc.code}: {body}') - case = get_json(f"{args.case_api_base.rstrip('/')}/api/0/dlp/cases/{args.case_id}") - print(json.dumps({'case_id': args.case_id, 'intake': intake, 'forensics': case.get('forensics')}, ensure_ascii=False, indent=2)) - - -if __name__ == '__main__': - main() diff --git a/aw-server/hayabusa/aw-hayabusa.sh b/aw-server/hayabusa/aw-hayabusa.sh index b82a326..974f5d2 100644 --- a/aw-server/hayabusa/aw-hayabusa.sh +++ b/aw-server/hayabusa/aw-hayabusa.sh @@ -96,19 +96,8 @@ EOF json_field() { local json_path="$1" local field_name="$2" - python3 - "$json_path" "$field_name" <<'PY' -import json, sys -path, field = sys.argv[1], sys.argv[2] -try: - with open(path, 'r', encoding='utf-8') as fh: - obj = json.load(fh) -except Exception: - sys.exit(0) -value = obj.get(field) -if value is None: - sys.exit(0) -print(str(value)) -PY + command -v jq >/dev/null 2>&1 || fail "jq is required to read ${json_path}" + jq -er --arg field "${field_name}" '.[$field] // empty | tostring' "${json_path}" 2>/dev/null || true } detect_host_from_manifest() { @@ -124,34 +113,19 @@ detect_host_from_manifest() { extract_zip_normalized() { local package_path="$1" local dest_dir="$2" - python3 - "${package_path}" "${dest_dir}" <<'PY' -import pathlib -import shutil -import sys -import zipfile - -zip_path = pathlib.Path(sys.argv[1]) -dest_dir = pathlib.Path(sys.argv[2]) -dest_dir.mkdir(parents=True, exist_ok=True) - -with zipfile.ZipFile(zip_path) as zf: - for info in zf.infolist(): - raw_name = info.filename.replace('\\', '/') - normalized = pathlib.PurePosixPath(raw_name) - parts = [part for part in normalized.parts if part not in ('', '.')] - if any(part == '..' for part in parts): - raise SystemExit(f'unsafe zip entry: {info.filename}') - if not parts: - continue - target = dest_dir.joinpath(*parts) - is_dir = info.is_dir() or raw_name.endswith('/') - if is_dir: - target.mkdir(parents=True, exist_ok=True) - continue - target.parent.mkdir(parents=True, exist_ok=True) - with zf.open(info) as src, target.open('wb') as dst: - shutil.copyfileobj(src, dst) -PY + command -v zipinfo >/dev/null 2>&1 || fail "zipinfo is required to inspect ${package_path}" + command -v unzip >/dev/null 2>&1 || fail "unzip is required to extract ${package_path}" + mkdir -p "${dest_dir}" + local entry normalized + while IFS= read -r entry; do + normalized="${entry//\\//}" + case "${normalized}" in + ""|.|/*|*"/../"*|../*|*".."|*"/..") + fail "unsafe zip entry: ${entry}" + ;; + esac + done < <(zipinfo -1 "${package_path}") + unzip -q "${package_path}" -d "${dest_dir}" } write_package_manifest() { diff --git a/aw-server/health-check.sh b/aw-server/health-check.sh deleted file mode 100644 index 7df5693..0000000 --- a/aw-server/health-check.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Health check script for AW services -# Returns 0 if all services are healthy, 1 otherwise - -SERVICES=("activitywatch-server" "aw-worktime-api" "aw-worktime-ui-bridge") -UNHEALTHY_SERVICES=() -WARNINGS=() - -if [[ -f /etc/activitywatch/aw-server.env ]]; then - # shellcheck disable=SC1091 - source /etc/activitywatch/aw-server.env -fi - -WORKTIME_HEALTH_BASE="${AW_RUS_HEALTH_WORKTIME_API:-${AW_WORKTIME_REPORT_BASE:-http://127.0.0.1:5610}}" -WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_BASE%/}/health" -WORKTIME_HEALTH_TIMEOUT_SECONDS="${AW_RUS_HEALTH_WORKTIME_TIMEOUT_SECONDS:-15}" -AW_HEALTH_API_TIMEOUT_SECONDS="${AW_RUS_HEALTH_AW_TIMEOUT_SECONDS:-15}" -AW_HEALTH_API_ATTEMPTS="${AW_RUS_HEALTH_AW_ATTEMPTS:-3}" -SETTINGS_TIMEOUT_SECONDS="${AW_RUS_HEALTH_SETTINGS_TIMEOUT_SECONDS:-15}" -SETTINGS_ATTEMPTS="${AW_RUS_HEALTH_SETTINGS_ATTEMPTS:-3}" - -check_service() { - local service=$1 - if [[ "$service" == "aw-worktime-ui-bridge" ]]; then - if systemctl is-active --quiet aw-worktime-ui-bridge.timer && systemctl is-enabled --quiet aw-worktime-ui-bridge.timer; then - echo "✓ aw-worktime-ui-bridge.timer is running and enabled" - else - echo "✗ aw-worktime-ui-bridge.timer is not active/enabled" - UNHEALTHY_SERVICES+=("aw-worktime-ui-bridge.timer") - fi - return - fi - if systemctl is-active --quiet "$service"; then - echo "✓ $service is running" - else - echo "✗ $service is not running" - UNHEALTHY_SERVICES+=("$service") - fi -} - -check_api_endpoint() { - local url=$1 - local service_name=$2 - local timeout_seconds=${3:-5} - local attempts=${4:-1} - local attempt - - for ((attempt=1; attempt<=attempts; attempt++)); do - if curl -fsS --connect-timeout 3 --max-time "$timeout_seconds" "$url" >/dev/null 2>&1; then - echo "✓ $service_name API endpoint is responding" - return - fi - if (( attempt < attempts )); then - sleep 1 - fi - done - - echo "✗ $service_name API endpoint is not responding" - UNHEALTHY_SERVICES+=("$service_name-api") -} - -read_setting_value() { - local key=$1 - local attempt - for ((attempt=1; attempt<=SETTINGS_ATTEMPTS; attempt++)); do - if curl -fsS --connect-timeout 3 --max-time "$SETTINGS_TIMEOUT_SECONDS" "http://127.0.0.1:5600/api/0/settings/${key}" 2>/dev/null | \ - python3 -c 'import json,sys; print(json.load(sys.stdin))'; then - return 0 - fi - if (( attempt < SETTINGS_ATTEMPTS )); then - sleep 1 - fi - done - return 1 -} - -check_expected_setting() { - local key=$1 - local expected=$2 - local label=${3:-$1} - - if [[ -z "$expected" ]]; then - echo "⚠ expected value for ${label} is not configured, skipping drift check" - WARNINGS+=("${key}-expected-missing") - return - fi - - local actual - if ! actual="$(read_setting_value "$key" 2>/dev/null)"; then - echo "✗ failed to read setting ${label}" - UNHEALTHY_SERVICES+=("setting-${key}") - return - fi - - if [[ "$actual" == "$expected" ]]; then - echo "✓ ${label} matches expected value (${expected})" - else - echo "✗ ${label} drift detected: actual='${actual}' expected='${expected}'" - UNHEALTHY_SERVICES+=("setting-${key}") - fi -} - -check_dlp_transport_freshness() { - local dlp_health="${DLP_HEALTH_BIN:-/usr/local/bin/dlp-health-check}" - local result - - if [[ ! -x "$dlp_health" ]]; then - echo "⚠ dlp-health-check is not available, skipping DLP transport freshness checks" - WARNINGS+=("dlp-health-check-missing") - return - fi - - result="$("$dlp_health" --json 2>/dev/null || true)" - if [[ -z "$result" ]]; then - echo "⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks" - WARNINGS+=("dlp-health-check-empty") - return - fi - - local ok - ok="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); names={r["name"]:r for r in data.get("results", [])}; checks=["buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics"]; bad=[n for n in checks if names.get(n,{}).get("status")=="fail"]; print("1" if not bad else "0")' 2>/dev/null || echo "0")" - if [[ "$ok" == "1" ]]; then - echo "✓ DLP transport freshness check passed" - else - echo "✗ DLP transport freshness check failed" - UNHEALTHY_SERVICES+=("dlp-transport") - fi - - local errors warnings - errors="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="fail" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)" - warnings="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="warn" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)" - if [[ -n "$errors" ]]; then - echo " errors: $errors" - fi - if [[ -n "$warnings" ]]; then - echo " warnings: $warnings" - WARNINGS+=("$warnings") - fi -} - -echo "=== AW Services Health Check ===" -echo "Timestamp: $(date)" -echo - -# Check systemd services -for service in "${SERVICES[@]}"; do - check_service "$service" -done - -echo - -# Check API endpoints -check_api_endpoint "http://127.0.0.1:5600/api/0/info" "activitywatch-server" "$AW_HEALTH_API_TIMEOUT_SECONDS" "$AW_HEALTH_API_ATTEMPTS" -check_api_endpoint "$WORKTIME_HEALTH_URL" "aw-worktime-api" "$WORKTIME_HEALTH_TIMEOUT_SECONDS" 2 -check_dlp_transport_freshness "http://127.0.0.1:5600/api/0" "900" "${AW_HEALTH_STRICT_FILEOPS:-0}" -check_expected_setting "startOfDay" "${AW_EXPECT_START_OF_DAY:-}" "startOfDay" -check_expected_setting "always_active_pattern" "${AW_EXPECT_ALWAYS_ACTIVE_PATTERN:-}" "always_active_pattern" -check_expected_setting "landingpage" "${AW_EXPECT_LANDINGPAGE:-}" "landingpage" - -echo - -if [ ${#UNHEALTHY_SERVICES[@]} -eq 0 ]; then - echo "✓ All services are healthy" - if [ ${#WARNINGS[@]} -gt 0 ]; then - echo "⚠ Warnings: ${WARNINGS[*]}" - fi - exit 0 -else - echo "✗ Unhealthy services: ${UNHEALTHY_SERVICES[*]}" - exit 1 -fi diff --git a/aw-server/install_aw_server.sh b/aw-server/install_aw_server.sh index 34930d6..cdc1241 100755 --- a/aw-server/install_aw_server.sh +++ b/aw-server/install_aw_server.sh @@ -24,13 +24,13 @@ required_vars=( BOOTSTRAP_DIR="/root/bootstrap" VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json" CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json" -WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py" +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_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py" +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" -HEALTHD_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.py" +HEALTHD_RUST_SRC="$BOOTSTRAP_DIR/aw-rus-healthd" HEALTHD_SERVICE_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.service" HEALTHD_TIMER_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.timer" @@ -99,11 +99,11 @@ systemctl enable activitywatch-server.service systemctl restart activitywatch-server.service systemctl --no-pager --full status activitywatch-server.service || true -if [[ -f "$WORKTIME_API_SRC" ]]; then - install -m 0755 "$WORKTIME_API_SRC" /usr/local/bin/aw-worktime-api.py +if [[ -f "$WORKTIME_API_RUST_SRC" ]]; then + install -m 0755 "$WORKTIME_API_RUST_SRC" /usr/local/bin/aw-worktime-api-rust fi -if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then +if [[ -f "$WORKTIME_API_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-api-rust ]]; then install -m 0644 "$WORKTIME_API_SERVICE_SRC" /etc/systemd/system/aw-worktime-api.service systemctl daemon-reload systemctl enable aw-worktime-api.service @@ -119,15 +119,15 @@ if [[ -f "$WORKTIME_ALIASES_SRC" ]]; then fi fi -if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then - install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py +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 -if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" ]]; then +if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-ui-bridge-rust ]]; then install -m 0644 "$WORKTIME_UI_BRIDGE_SERVICE_SRC" /etc/systemd/system/aw-worktime-ui-bridge.service fi -if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then +if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" && -f /usr/local/bin/aw-worktime-ui-bridge-rust ]]; then install -m 0644 "$WORKTIME_UI_BRIDGE_TIMER_SRC" /etc/systemd/system/aw-worktime-ui-bridge.timer systemctl daemon-reload systemctl disable --now aw-worktime-afk-bridge.timer >/dev/null 2>&1 || true @@ -137,15 +137,15 @@ if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true fi -if [[ -f "$HEALTHD_SRC" ]]; then - install -m 0755 "$HEALTHD_SRC" /usr/local/bin/aw-rus-healthd.py +if [[ -f "$HEALTHD_RUST_SRC" ]]; then + install -m 0755 "$HEALTHD_RUST_SRC" /usr/local/bin/aw-rus-healthd-rust fi -if [[ -f "$HEALTHD_SERVICE_SRC" ]]; then +if [[ -f "$HEALTHD_SERVICE_SRC" && -f /usr/local/bin/aw-rus-healthd-rust ]]; then install -m 0644 "$HEALTHD_SERVICE_SRC" /etc/systemd/system/aw-rus-healthd.service fi -if [[ -f "$HEALTHD_TIMER_SRC" ]]; then +if [[ -f "$HEALTHD_TIMER_SRC" && -f /usr/local/bin/aw-rus-healthd-rust ]]; then install -m 0644 "$HEALTHD_TIMER_SRC" /etc/systemd/system/aw-rus-healthd.timer systemctl daemon-reload systemctl enable aw-rus-healthd.timer diff --git a/aw-server/test_aw_dlp_influx_exporter.py b/aw-server/test_aw_dlp_influx_exporter.py deleted file mode 100644 index a41c062..0000000 --- a/aw-server/test_aw_dlp_influx_exporter.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -from datetime import UTC, datetime -from pathlib import Path - - -MODULE_PATH = Path(__file__).with_name("aw-dlp-influx-exporter.py") -SPEC = importlib.util.spec_from_file_location("aw_dlp_influx_exporter", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -def test_build_endpoint_lines_emits_self_test_and_signal(): - events = [ - { - "id": 10, - "timestamp": "2026-05-15T10:00:00Z", - "data": { - "hostname": "SHARKON2025", - "username": "Администратор", - "signalType": "self_test", - "policyMode": "server", - "policySource": "local-fallback", - "queueDepth": 2, - "eventsEnqueued": 100, - "eventsFlushed": 99, - "sendFailures": 1, - "policyEnabled": True, - }, - }, - { - "id": 11, - "timestamp": "2026-05-15T10:01:00Z", - "data": { - "hostname": "SHARKON2025", - "username": "Администратор", - "signalType": "print_job", - "source": "endpoint-signals-phase2", - "documentName": "Документ.docx", - "printerName": "HP LaserJet", - }, - }, - ] - lines = MODULE.build_endpoint_lines("SHARKON2025", events) - assert any(line.startswith("aw_dlp_endpoint_self_test,") for line in lines) - assert any(line.startswith("aw_dlp_signal,") for line in lines) - - -def test_normalize_incident_handles_nested_source_event(): - item = { - "timestamp": "2026-05-15T10:02:00Z", - "data": { - "host": "SHARKON2025", - "incident": {"status": "open", "verdict": "incident"}, - "sourceBucket": "aw-dlp-endpoint-signals_SHARKON2025", - "sourceEvent": { - "data": { - "signalType": "print_job", - "hostname": "SHARKON2025", - "username": "Администратор", - "documentName": "Письмо", - "printerName": "HP", - "source": "endpoint-signals-phase2", - } - }, - }, - } - normalized = MODULE.normalize_incident(item, "SHARKON2025") - assert normalized["signal_type"] == "print_job" - assert normalized["username"] == "Администратор" - assert normalized["action"] == "incident" - assert normalized["incident_status"] == "open" - - -def test_build_case_lines_emits_case_state(): - cases = [ - { - "id": 28, - "host": "SHARKON2025", - "status": "open", - "severity": "medium", - "assignee": None, - "title": "DLP print_job · Администратор", - "incident_id": "case-1", - "evidence": {"items": [1], "chain_length": 1}, - "forensics": None, - "updated_at": "2026-05-15T10:03:00+00:00", - } - ] - lines = MODULE.build_case_lines("SHARKON2025", cases) - assert len(lines) == 1 - assert lines[0].startswith("aw_dlp_case,") - - -def test_timestamp_parser_accepts_zulu(): - assert MODULE.pts("2026-05-15T10:00:00Z") == datetime(2026, 5, 15, 10, 0, 0, tzinfo=UTC) diff --git a/aw-server/test_aw_dlp_report_generator.py b/aw-server/test_aw_dlp_report_generator.py deleted file mode 100644 index 214abaf..0000000 --- a/aw-server/test_aw_dlp_report_generator.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -import os -import sys -from pathlib import Path - - -MODULE_PATH = Path(__file__).parent / "dlp-compliance" / "report_generator.py" - - -def load_module(name: str): - spec = importlib.util.spec_from_file_location(name, MODULE_PATH) - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -MODULE = load_module("aw_dlp_report_generator") - - -def test_build_aw_api_base_accepts_root_and_api_urls(): - assert MODULE.build_aw_api_base("http://127.0.0.1:5600") == "http://127.0.0.1:5600/api/0" - assert MODULE.build_aw_api_base("http://127.0.0.1:5600/") == "http://127.0.0.1:5600/api/0" - assert MODULE.build_aw_api_base("http://127.0.0.1:5600/api/0") == "http://127.0.0.1:5600/api/0" - - -def test_aw_dlp_api_base_env_takes_precedence(monkeypatch): - monkeypatch.setenv("AW_SERVER_URL", "http://127.0.0.1:5600") - monkeypatch.setenv("AW_DLP_AW_API_BASE", "http://127.0.0.1:5600/api/0") - module = load_module("aw_dlp_report_generator_env_precedence") - assert module.AW_API_BASE == "http://127.0.0.1:5600/api/0" - - -def test_aw_server_url_is_normalized_when_dlp_api_base_is_missing(monkeypatch): - monkeypatch.setenv("AW_SERVER_URL", "http://127.0.0.1:5600") - monkeypatch.delenv("AW_DLP_AW_API_BASE", raising=False) - module = load_module("aw_dlp_report_generator_server_url_fallback") - assert module.AW_API_BASE == "http://127.0.0.1:5600/api/0" diff --git a/aw-server/test_aw_rus_healthd.py b/aw-server/test_aw_rus_healthd.py deleted file mode 100644 index 72fad82..0000000 --- a/aw-server/test_aw_rus_healthd.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -import sys -from pathlib import Path - - -MODULE_PATH = Path(__file__).with_name("aw-rus-healthd.py") -SPEC = importlib.util.spec_from_file_location("aw_rus_healthd", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - - -def test_host_activity_marks_recent_active_session(): - MODULE.now_utc = lambda: MODULE.parse_ts("2026-05-18T10:05:00Z") - activity = MODULE.host_activity_from_worktime( - { - "timestamp": "2026-05-18T10:00:00Z", - "data": {"active": True}, - }, - max_age_seconds=900, - ) - assert activity["active"] is True - assert activity["fresh"] is True - - -def test_bucket_health_reports_missing_events_without_crash(monkeypatch): - monkeypatch.setattr(MODULE, "latest_bucket_event", lambda api_base, bucket_id: None) - status, summary, details = MODULE.bucket_health( - "http://127.0.0.1:5600/api/0", - "aw-watcher-window_SHARKON2025", - 900, - missing_status="warn", - stale_status="fail", - ) - assert status == "warn" - assert summary == "no events" - assert details["bucket"] == "aw-watcher-window_SHARKON2025" - - -def test_inactive_interactive_bucket_can_be_downgraded_to_ok(monkeypatch): - monkeypatch.setattr( - MODULE, - "latest_bucket_event", - lambda api_base, bucket_id: {"timestamp": "2026-05-30T07:00:00Z"}, - ) - monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T12:00:00Z")) - activity = {"active": False, "fresh": True, "age_seconds": 5} - status, summary, details = MODULE.bucket_health( - "http://127.0.0.1:5600/api/0", - "aw-watcher-window_SHARKON2025", - 900, - missing_status="warn", - stale_status="warn", - ) - details["interactive_required"] = bool(activity["active"]) - details["host_activity"] = activity - if not activity["active"] and status != "ok": - details["inactive_summary"] = summary - status = "ok" - summary = "inactive: no active interactive users" - - assert status == "ok" - assert summary == "inactive: no active interactive users" - assert details["inactive_summary"] == "stale (18000s)" - - -def test_bucket_health_marks_old_session_events_as_event_driven(monkeypatch): - monkeypatch.setattr( - MODULE, - "latest_bucket_event", - lambda api_base, bucket_id: {"timestamp": "2026-05-22T15:53:08Z"}, - ) - monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-24T01:46:25Z")) - status, summary, details = MODULE.bucket_health( - "http://127.0.0.1:5600/api/0", - "aw-session-events_SHARKON2025", - 86400, - missing_status="fail", - stale_status="warn", - ) - if status == "warn" and details.get("age_seconds") is not None: - status = "ok" - summary = f"event-driven ({details['age_seconds']}s since last logon marker)" - assert status == "ok" - assert summary.startswith("event-driven (") - assert details["age_seconds"] > 86400 - - -def test_bucket_timestamp_health_uses_bucket_metadata_without_event_query(monkeypatch): - def fail_if_called(api_base, bucket_id): - raise AssertionError("latest event query should not run when bucket metadata has end timestamp") - - monkeypatch.setattr(MODULE, "latest_bucket_event", fail_if_called) - monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T17:05:00Z")) - - status, summary, details = MODULE.bucket_timestamp_health( - "http://127.0.0.1:5600/api/0", - {"aw-session-events_SHARKON2025": {"metadata": {"end": "2026-05-30T17:00:00Z"}}}, - "aw-session-events_SHARKON2025", - 86400, - missing_status="fail", - stale_status="warn", - ) - - assert status == "ok" - assert summary == "fresh (300s)" - assert details["timestamp_source"] == "bucket_metadata.end" - assert details["age_seconds"] == 300 - - -def test_bucket_timestamp_health_falls_back_to_event_query_without_metadata(monkeypatch): - monkeypatch.setattr( - MODULE, - "latest_bucket_event", - lambda api_base, bucket_id: {"timestamp": "2026-05-30T17:00:00Z"}, - ) - monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T17:05:00Z")) - - status, summary, details = MODULE.bucket_timestamp_health( - "http://127.0.0.1:5600/api/0", - {"aw-session-events_SHARKON2025": {}}, - "aw-session-events_SHARKON2025", - 86400, - missing_status="fail", - stale_status="warn", - ) - - assert status == "ok" - assert summary == "fresh (300s)" - assert "timestamp_source" not in details - - -def test_guard_bucket_missing_is_warn_until_required(monkeypatch): - monkeypatch.setattr(MODULE, "latest_bucket_event", lambda api_base, bucket_id: None) - status, summary, details = MODULE.guard_bucket_health( - "http://127.0.0.1:5600/api/0", - "SHARKON2025", - 300, - required=False, - ) - assert status == "warn" - assert summary == "no guard heartbeat" - assert details["required"] is False - - -def test_guard_bucket_required_stale_is_fail(monkeypatch): - monkeypatch.setattr( - MODULE, - "latest_bucket_event", - lambda api_base, bucket_id: { - "timestamp": "2026-05-30T10:00:00Z", - "data": {"status": "ok", "mode": "enforce"}, - }, - ) - monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T10:10:00Z")) - status, summary, details = MODULE.guard_bucket_health( - "http://127.0.0.1:5600/api/0", - "SHARKON2025", - 300, - required=True, - ) - assert status == "fail" - assert summary == "guard stale (600s)" - assert details["mode"] == "enforce" - - -def test_aw_wrapper_failure_can_be_advisory_warn(tmp_path): - report = MODULE.Report() - missing = tmp_path / "missing-wrapper" - MODULE.check_wrapper(report, "wrapper:test", [str(missing)], failure_status="warn") - assert report.results[0].status == "warn" - - -def test_dlp_wrapper_failure_can_be_advisory_warn(monkeypatch, tmp_path): - wrapper = tmp_path / "dlp-health-check" - wrapper.write_text("#!/bin/sh\nprintf '{\"ok\":false}'\nexit 1\n", encoding="utf-8") - wrapper.chmod(0o755) - monkeypatch.setattr(MODULE, "run_command", lambda cmd: (1, '{"ok": false}')) - - report = MODULE.Report() - MODULE.check_wrapper( - report, - "wrapper:dlp-health-check", - [str(wrapper), "--json"], - json_mode=True, - failure_status="warn", - ) - - assert report.results[0].status == "warn" - assert report.results[0].details["payload"] == {"ok": False} - - -def test_load_env_file_ignores_permission_denied(): - class DeniedPath: - def exists(self): - return True - - def read_text(self, encoding="utf-8"): - raise PermissionError("denied") - - MODULE.load_env_file(DeniedPath()) - - -def test_chmod_if_possible_sets_readable_mode(tmp_path): - path = tmp_path / "aw-rus-health.json" - path.write_text("{}", encoding="utf-8") - - MODULE.chmod_if_possible(path, 0o644) - - assert oct(path.stat().st_mode & 0o777) == "0o644" diff --git a/aw-server/test_aw_slo_monitor.py b/aw-server/test_aw_slo_monitor.py deleted file mode 100644 index 00b55ee..0000000 --- a/aw-server/test_aw_slo_monitor.py +++ /dev/null @@ -1,268 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -import sys -import tempfile -from pathlib import Path - - -MODULE_PATH = Path(__file__).with_name("aw-slo-monitor.py") -SPEC = importlib.util.spec_from_file_location("aw_slo_monitor", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - - -class FakeHeaders: - def __init__(self, values): - self.values = values - - def get(self, key, default=""): - return self.values.get(key, default) - - -class FakeResponse: - def __init__(self, body, status=200, content_type="text/plain"): - self.body = body - self.status = status - self.headers = FakeHeaders({"Content-Type": content_type}) - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def read(self): - return self.body - - -def test_summarize_window_calculates_9997_budget(): - now = MODULE.parse_ts("2026-05-30T12:00:00Z") - samples = [ - {"ts": "2026-05-30T11:58:00Z", "ok": True}, - {"ts": "2026-05-30T11:59:00Z", "ok": False}, - {"ts": "2026-05-30T12:00:00Z", "ok": True}, - ] - - summary = MODULE.summarize_window( - samples, - now=now, - window_seconds=86400, - sample_interval_seconds=60, - target_percent=99.97, - ) - - assert summary["samples"] == 3 - assert summary["good_samples"] == 2 - assert summary["bad_samples"] == 1 - assert summary["availability_percent"] == 66.66667 - assert summary["budget_seconds"] == 25 - assert summary["budget_remaining_seconds"] == -35 - assert summary["status"] == "burning" - - -def test_summarize_window_status_uses_remaining_error_budget_for_partial_window(): - now = MODULE.parse_ts("2026-05-30T12:00:00Z") - samples = [ - {"ts": "2026-05-30T11:59:30Z", "ok": False}, - {"ts": "2026-05-30T11:59:45Z", "ok": True}, - {"ts": "2026-05-30T12:00:00Z", "ok": True}, - ] - - summary = MODULE.summarize_window( - samples, - now=now, - window_seconds=86400, - sample_interval_seconds=15, - target_percent=99.97, - ) - - assert summary["availability_percent"] == 66.66667 - assert summary["budget_seconds"] == 25 - assert summary["budget_remaining_seconds"] == 10 - assert summary["status"] == "ok" - - -def test_append_and_trim_sample_keeps_retention_window(): - with tempfile.TemporaryDirectory() as tmp: - path = Path(tmp) / "samples.jsonl" - path.write_text( - '{"ts":"2026-05-01T00:00:00Z","ok":true}\n' - '{"ts":"2026-05-30T11:59:00Z","ok":true}\n', - encoding="utf-8", - ) - sample = {"ts": "2026-05-30T12:00:00Z", "ok": False} - - samples = MODULE.append_and_trim_sample(path, sample, retention_seconds=3600) - - assert [item["ts"] for item in samples] == [ - "2026-05-30T11:59:00Z", - "2026-05-30T12:00:00Z", - ] - assert "2026-05-01T00:00:00Z" not in path.read_text(encoding="utf-8") - - -def test_load_samples_treats_unreadable_file_as_empty(monkeypatch, tmp_path): - path = tmp_path / "samples.jsonl" - path.write_text('{"ts":"2026-05-30T12:00:00Z","ok":true}\n', encoding="utf-8") - original_read_text = MODULE.Path.read_text - - def denied(self, *args, **kwargs): - if self == path: - raise PermissionError("denied") - return original_read_text(self, *args, **kwargs) - - monkeypatch.setattr(MODULE.Path, "read_text", denied) - - assert MODULE.load_samples(path, MODULE.parse_ts("2026-05-30T11:00:00Z")) == [] - - -def test_render_summary_text_includes_budget_remaining(): - summary = { - "generated_at_utc": "2026-05-30T12:00:00Z", - "target_percent": 99.97, - "current_sample": { - "ok": True, - "probes": {"worktime_today_csv": {"ok": True, "status": 200}}, - }, - "windows": { - "24h": { - "status": "ok", - "availability_percent": 100.0, - "samples": 10, - "bad_samples": 0, - "observed_bad_seconds": 0, - "budget_remaining_seconds": 25, - } - }, - } - - text = MODULE.render_summary_text(summary) - - assert "Target: 99.97%" in text - assert "24h: ok availability=100.00000%" in text - assert "budget_remaining_seconds=25" in text - assert "- worktime_today_csv: OK 200" in text - - -def test_html_probe_requires_non_empty_html_with_markers(monkeypatch): - body = b'ActivityWatch
    ' - monkeypatch.setattr(MODULE.request, "urlopen", lambda req, timeout: FakeResponse(body, content_type="text/html; charset=utf-8")) - - result = MODULE.html_probe( - "http://127.0.0.1:5600/", - 5, - min_bytes=100, - required_markers=("ActivityWatch", 'id="app"', "ru-patch-v5.js"), - ) - - assert result["ok"] is True - assert result["status"] == 200 - assert result["body_bytes"] == len(body) - assert "body" not in result - - -def test_html_probe_fails_on_missing_marker(monkeypatch): - monkeypatch.setattr(MODULE.request, "urlopen", lambda req, timeout: FakeResponse(b"", content_type="text/html")) - - result = MODULE.html_probe( - "http://127.0.0.1:5600/", - 5, - min_bytes=1, - required_markers=("ActivityWatch",), - ) - - assert result["ok"] is False - assert result["missing_markers"] == ["ActivityWatch"] - - -def test_html_probe_retries_transient_fetch_error(monkeypatch): - calls = {"count": 0} - body = b"ActivityWatch" - - def fake_urlopen(req, timeout): - calls["count"] += 1 - if calls["count"] == 1: - raise TimeoutError("timed out") - return FakeResponse(body, content_type="text/html") - - monkeypatch.setattr(MODULE.request, "urlopen", fake_urlopen) - monkeypatch.setattr(MODULE.time, "sleep", lambda seconds: None) - - result = MODULE.html_probe( - "http://127.0.0.1:5600/", - 5, - min_bytes=1, - required_markers=("ActivityWatch",), - ) - - assert result["ok"] is True - assert result["attempts"] == 2 - assert calls["count"] == 2 - - -def test_json_probe_validates_shape_and_expected_values(monkeypatch): - body = b'{"generated_at_utc":"2026-05-30T12:00:00Z","host":"SHARKON2025","summary":{},"rows":[],"workday":{}}' - monkeypatch.setattr(MODULE.request, "urlopen", lambda req, timeout: FakeResponse(body, content_type="application/json")) - - result = MODULE.json_probe( - "http://127.0.0.1:5610/reports/worktime/management?format=json", - 5, - expected_values={"host": "SHARKON2025"}, - required_keys=("generated_at_utc", "host", "summary", "rows", "workday"), - ) - - assert result["ok"] is True - assert result["status"] == 200 - assert "body" not in result - - -def test_json_probe_fails_on_wrong_host(monkeypatch): - body = b'{"generated_at_utc":"2026-05-30T12:00:00Z","host":"OTHER","summary":{},"rows":[],"workday":{}}' - monkeypatch.setattr(MODULE.request, "urlopen", lambda req, timeout: FakeResponse(body, content_type="application/json")) - - result = MODULE.json_probe( - "http://127.0.0.1:5610/reports/worktime/management?format=json", - 5, - expected_values={"host": "SHARKON2025"}, - required_keys=("generated_at_utc", "host", "summary", "rows", "workday"), - ) - - assert result["ok"] is False - assert result["mismatched_values"]["host"]["actual"] == "OTHER" - - -def test_load_env_file_ignores_permission_denied(monkeypatch): - class DeniedPath: - def exists(self): - return True - - def read_text(self, encoding="utf-8"): - raise PermissionError("denied") - - MODULE.load_env_file(DeniedPath()) - - -def test_read_healthd_state_requires_fresh_ok_snapshot(monkeypatch, tmp_path): - path = tmp_path / "aw-rus-health.json" - path.write_text( - '{"generated_at_utc":"2026-05-30T12:00:00Z","ok":true,"counts":{"ok":1,"warn":0,"fail":0}}', - encoding="utf-8", - ) - monkeypatch.setattr(MODULE, "now_utc", lambda: MODULE.parse_ts("2026-05-30T12:01:00Z")) - - fresh = MODULE.read_healthd_state(path, max_age_seconds=180) - stale = MODULE.read_healthd_state(path, max_age_seconds=30) - - assert fresh["ok"] is True - assert fresh["age_seconds"] == 60 - assert stale["ok"] is False - - -def test_chmod_if_possible_sets_readable_mode(tmp_path): - path = tmp_path / "summary.json" - path.write_text("{}", encoding="utf-8") - - MODULE.chmod_if_possible(path, 0o644) - - assert oct(path.stat().st_mode & 0o777) == "0o644" diff --git a/aw-server/test_aw_worktime_api.py b/aw-server/test_aw_worktime_api.py deleted file mode 100644 index 02acff8..0000000 --- a/aw-server/test_aw_worktime_api.py +++ /dev/null @@ -1,1078 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -import json -import tempfile -from datetime import datetime, timezone -from pathlib import Path - - -MODULE_PATH = Path(__file__).with_name("aw-worktime-api.py") -SPEC = importlib.util.spec_from_file_location("aw_worktime_api", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class _FakeWriter: - def __init__(self, exc=None): - self.exc = exc - self.data = b"" - - def write(self, data): - if self.exc is not None: - raise self.exc - self.data += data - - -class _FakeHandler: - def __init__(self, exc=None): - self.wfile = _FakeWriter(exc=exc) - self.status = None - self.headers = {} - self.ended = False - - def send_response(self, status): - self.status = status - - def send_header(self, key, value): - self.headers[key] = value - - def end_headers(self): - self.ended = True - - -def _event(ts, username, session_id, active, **extra): - data = { - "username": username, - "userId": f"WORKGROUP\\{username}", - "sessionId": session_id, - "state": "Активно" if active else "Диск", - "active": active, - } - data.update(extra) - return {"timestamp": ts, "duration": 0.0, "data": data} - - -def test_aggregate_rows_uses_sample_seconds_and_merges_overlap(): - start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc) - end = datetime(2026, 5, 14, 6, 59, 59, tzinfo=timezone.utc) - rows = MODULE.aggregate_rows( - [ - _event("2026-05-14T06:00:00Z", "user5", 4, True, sampleSeconds=30), - _event("2026-05-14T06:00:30Z", "user5", 4, True, sampleSeconds=30), - _event("2026-05-14T06:00:15Z", "user5", 5, True, sampleSeconds=30), - _event("2026-05-14T06:01:00Z", "user5", 4, False, sampleSeconds=30), - ], - start, - end, - "SHARKON2025", - ) - assert len(rows) == 1 - row = rows[0] - assert row["user"] == "user5" - assert row["user_id"] == "SHARKON2025\\user5" - assert row["active_seconds"] == 60 - assert row["active_hhmm"] == "00:01" - assert row["sessions_count"] == 2 - assert row["samples_count"] == 4 - assert row["active_samples"] == 3 - assert row["first_activity"] == "2026-05-14T06:00:00Z" - assert row["last_activity"] == "2026-05-14T06:01:00Z" - - -def test_aggregate_rows_falls_back_to_next_sample_delta(): - start = datetime(2026, 5, 14, 7, 0, 0, tzinfo=timezone.utc) - end = datetime(2026, 5, 14, 7, 59, 59, tzinfo=timezone.utc) - rows = MODULE.aggregate_rows( - [ - _event("2026-05-14T07:00:00Z", "user1", 3, True), - _event("2026-05-14T07:00:05Z", "user1", 3, True), - _event("2026-05-14T07:00:10Z", "user1", 3, False), - ], - start, - end, - "SHARKON2025", - ) - row = rows[0] - assert row["active_seconds"] == 10 - assert row["active_hhmm"] == "00:00" - - -def test_build_aw_api_base_accepts_root_and_api_urls(): - assert MODULE.build_aw_api_base("http://127.0.0.1:5600") == "http://127.0.0.1:5600/api/0" - assert MODULE.build_aw_api_base("http://127.0.0.1:5600/") == "http://127.0.0.1:5600/api/0" - assert MODULE.build_aw_api_base("http://127.0.0.1:5600/api/0") == "http://127.0.0.1:5600/api/0" - - -def test_worktime_health_payload_is_lightweight_and_ok(): - payload = MODULE.worktime_health_payload() - assert payload["ok"] is True - assert payload["default_host"] == MODULE.DEFAULT_HOST - assert payload["aw_api_base"] == MODULE.AW - assert payload["report_timezone"] == str(MODULE.REPORT_TZ) - assert payload["generated_at_utc"].endswith("Z") - - -def test_aggregate_hourly_rows_splits_interval_by_local_hour(): - start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc) - end = datetime(2026, 5, 14, 8, 59, 59, tzinfo=timezone.utc) - rows = MODULE.aggregate_hourly_rows( - [ - _event("2026-05-14T06:50:00Z", "user5", 4, True, sampleSeconds=1800), - _event("2026-05-14T07:20:00Z", "user5", 4, True, sampleSeconds=1800), - ], - start, - end, - "SHARKON2025", - ) - assert [row["hour_local"] for row in rows] == ["09:00", "10:00"] - assert [row["active_seconds"] for row in rows] == [300, 300] - - -def test_build_true_active_apps_requires_foreground_not_afk_and_evidence(): - start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc) - end = datetime(2026, 5, 14, 7, 59, 59, tzinfo=timezone.utc) - window_events = [ - {"timestamp": "2026-05-14T06:00:00Z", "duration": 120, "data": {"app": "1cv8.exe", "title": "ИНФОВЕСТ"}}, - {"timestamp": "2026-05-14T06:02:00Z", "duration": 180, "data": {"app": "1cv8.exe", "title": "Счета учета: Материалы"}}, - {"timestamp": "2026-05-14T07:00:00Z", "duration": 600, "data": {"app": "totalcmd.exe", "title": "Total Commander"}}, - ] - afk_events = [ - {"timestamp": "2026-05-14T06:00:00Z", "duration": 600, "data": {"status": "not-afk"}}, - {"timestamp": "2026-05-14T07:00:00Z", "duration": 600, "data": {"status": "not-afk"}}, - ] - evidence_events_by_bucket = { - "aw-file-operations_SHARKON2025": [ - { - "timestamp": "2026-05-14T07:05:00Z", - "duration": 0, - "data": {"signalType": "file_write", "path": "C:\\data\\report.xlsx"}, - } - ], - "aw-dlp-endpoint-signals_SHARKON2025": [ - { - "timestamp": "2026-05-14T07:06:00Z", - "duration": 0, - "data": {"signalType": "collector_health", "eventsFlushed": 100}, - } - ], - } - - rows = MODULE.build_true_active_apps_from_events(window_events, afk_events, evidence_events_by_bucket, start, end) - - by_app = {row["application"]: row for row in rows} - assert "1С" in by_app - assert "Total Commander" in by_app - assert by_app["1С"]["proved_work_seconds"] == 300 - assert by_app["1С"]["last_action"] == "Счета учета: Материалы" - assert by_app["Total Commander"]["proved_work_seconds"] == 480 - assert by_app["Total Commander"]["last_action"] == "C:\\data\\report.xlsx" - - -def test_latest_bucket_event_falls_back_to_bucket_metadata_end(monkeypatch): - def fake_get(url): - if url.endswith("/events?limit=1"): - raise RuntimeError("events timeout") - if url.endswith("/buckets/aw-worktime-sessions_SHARKON2025"): - return {"metadata": {"end": "2026-05-30T10:38:07.530Z"}} - raise AssertionError(url) - - monkeypatch.setattr(MODULE, "get", fake_get) - - event = MODULE.latest_bucket_event("aw-worktime-sessions_SHARKON2025") - - assert event["timestamp"] == "2026-05-30T10:38:07.530Z" - assert event["data"]["source"] == "bucket_metadata" - - -def test_fetch_events_for_date_queries_aw_with_report_bounds(monkeypatch): - urls = [] - - def fake_get(url): - urls.append(url) - if url.endswith("/buckets/aw-worktime-sessions_SHARKON2025"): - return {"metadata": {"end": "2026-05-30T10:38:07.530Z"}} - if "/events?" in url: - return [] - raise AssertionError(url) - - monkeypatch.setattr(MODULE, "get", fake_get) - monkeypatch.setattr(MODULE, "REPORT_TZ", timezone.utc) - original_cache = MODULE._EVENTS_CACHE - try: - MODULE._EVENTS_CACHE = {} - MODULE.fetch_events_for_date("SHARKON2025", datetime(2026, 5, 30, tzinfo=timezone.utc).date()) - finally: - MODULE._EVENTS_CACHE = original_cache - - event_urls = [url for url in urls if "/events?" in url] - assert len(event_urls) == 1 - assert "start=2026-05-30T00%3A00%3A00Z" in event_urls[0] - assert "end=2026-05-30T23%3A59%3A59" in event_urls[0] - - -def test_render_html_contains_true_active_apps_table(): - html = MODULE.render_html( - [], - "SHARKON2025", - datetime(2026, 5, 14, tzinfo=timezone.utc).date(), - selected_day="today", - true_active_apps=[ - { - "application": "1С", - "proved_work_human": "34 мин", - "proved_work_hhmm": "00:34", - "last_action_local": "15:31", - "last_action": "Счета учета: Материалы", - } - ], - ) - assert "Доказанная работа по приложениям" in html - assert "Приложение" in html - assert "Доказанная работа" in html - assert "Последнее действие" in html - assert "Счета учета: Материалы" in html - - -def test_build_management_payload_creates_actions_for_missing_and_late_users(): - rows = [ - { - "user": "user1", - "user_id": "SHARKON2025\\user1", - "active_seconds": 0, - "active_hhmm": "00:00", - "first_activity": "", - "last_activity": "", - "idle_seconds": 86400, - "sessions_count": 1, - "samples_count": 10, - "active_samples": 0, - "_intervals": [], - }, - { - "user": "user5", - "user_id": "SHARKON2025\\user5", - "active_seconds": 5400, - "active_hhmm": "01:30", - "first_activity": "2026-05-14T08:30:00Z", - "last_activity": "2026-05-14T10:00:00Z", - "idle_seconds": 81000, - "sessions_count": 1, - "samples_count": 180, - "active_samples": 180, - "_intervals": [ - ( - datetime(2026, 5, 14, 8, 30, 0, tzinfo=timezone.utc), - datetime(2026, 5, 14, 10, 0, 0, tzinfo=timezone.utc), - ) - ], - }, - ] - payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) - assert payload["summary"]["users_count"] == 2 - assert payload["summary"]["inactive_users"] == 1 - assert payload["summary"]["actions_count"] >= 2 - action_ids = {action["action_id"] for action in payload["actions"]} - assert "missing_activity" in action_ids - assert "late_start_review" in action_ids - assert payload["actions"][0]["priority"] == "critical" - - -def test_build_management_payload_uses_workday_window_not_midnight_activity(): - rows = [ - { - "user": "администратор", - "user_id": "SHARKON2025\\Администратор", - "active_seconds": 39600, - "active_hhmm": "11:00", - "first_activity": "2026-05-14T00:00:00Z", - "last_activity": "2026-05-14T11:00:00Z", - "idle_seconds": 0, - "sessions_count": 1, - "samples_count": 100, - "active_samples": 100, - "_intervals": [ - ( - datetime(2026, 5, 14, 0, 0, 0, tzinfo=timezone.utc), - datetime(2026, 5, 14, 11, 0, 0, tzinfo=timezone.utc), - ) - ], - } - ] - payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) - roster = payload["rows"][0] - assert roster["calendar_active_hhmm"] == "11:00" - assert roster["workday_active_hhmm"] == "05:00" - assert roster["coverage_pct"] == 55.56 - assert payload["summary"]["calendar_total_active_hhmm"] == "11:00" - assert payload["summary"]["workday_total_active_hhmm"] == "05:00" - - -def test_build_management_payload_applies_alias_and_executive_summary(): - original = MODULE.load_manager_aliases - original_owners = MODULE.load_manager_owners - original_sources = MODULE.build_source_freshness - original_trend = MODULE.build_management_trend - try: - MODULE.load_manager_aliases = lambda: { - "sharkon2025\\user1": { - "display_name": "Иван Петров", - "manager": "Руководитель смены", - "department": "Бухгалтерия", - "role": "Оператор 1С", - } - } - MODULE.load_manager_owners = lambda: { - "руководитель смены": { - "display_name": "Сменный руководитель", - "title": "Руководитель смены 1С", - "department": "Операторы 1С", - "contact": "@shift-lead", - "escalation_to": "Финансовый директор", - "notes": "Дневной контур", - } - } - MODULE.build_source_freshness = lambda host, **kwargs: ([], []) - MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="", **kwargs: [] - rows = [ - { - "user": "user1", - "user_id": "SHARKON2025\\user1", - "active_seconds": 0, - "active_hhmm": "00:00", - "first_activity": "", - "last_activity": "", - "idle_seconds": 86400, - "sessions_count": 1, - "samples_count": 10, - "active_samples": 0, - "_intervals": [], - } - ] - payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) - row = payload["rows"][0] - assert row["user"] == "Иван Петров" - assert row["manager_owner"] == "Руководитель смены" - assert row["department"] == "Бухгалтерия" - assert payload["actions"][0]["owner"] == "Руководитель смены" - assert payload["executive"]["portfolio_state"] == "critical" - assert payload["executive"]["focus_items"][0]["owner"] == "Руководитель смены" - assert payload["owner_rollups"][0]["name"] == "Руководитель смены" - assert payload["owner_rollups"][0]["critical_actions_count"] >= 1 - assert payload["department_rollups"][0]["name"] == "Бухгалтерия" - assert payload["owner_roster"][0]["display_name"] == "Сменный руководитель" - assert payload["owner_roster"][0]["contact"] == "@shift-lead" - finally: - MODULE.load_manager_aliases = original - MODULE.load_manager_owners = original_owners - MODULE.build_source_freshness = original_sources - MODULE.build_management_trend = original_trend - - -def test_build_management_payload_filters_by_owner(): - original_aliases = MODULE.load_manager_aliases - original_owners = MODULE.load_manager_owners - original_trend = MODULE.build_management_trend - original_sources = MODULE.build_source_freshness - try: - MODULE.load_manager_aliases = lambda: { - "sharkon2025\\user1": { - "display_name": "Иван Петров", - "manager": "Руководитель смены", - "department": "Бухгалтерия", - "role": "Оператор 1С", - }, - "sharkon2025\\user2": { - "display_name": "Мария Соколова", - "manager": "Финансовый директор", - "department": "Финансы", - "role": "Главбух", - }, - } - MODULE.load_manager_owners = lambda: { - "руководитель смены": {"display_name": "Сменный руководитель", "title": "Руководитель смены 1С"} - } - MODULE.build_management_trend = lambda *args, **kwargs: [] - MODULE.build_source_freshness = lambda host, **kwargs: ([], []) - rows = [ - { - "user": "user1", - "user_id": "SHARKON2025\\user1", - "active_seconds": 0, - "active_hhmm": "00:00", - "first_activity": "", - "last_activity": "", - "idle_seconds": 86400, - "sessions_count": 1, - "samples_count": 10, - "active_samples": 0, - "_intervals": [], - }, - { - "user": "user2", - "user_id": "SHARKON2025\\user2", - "active_seconds": 3600, - "active_hhmm": "01:00", - "first_activity": "2026-05-14T07:00:00Z", - "last_activity": "2026-05-14T08:00:00Z", - "idle_seconds": 82800, - "sessions_count": 1, - "samples_count": 120, - "active_samples": 120, - "_intervals": [ - ( - datetime(2026, 5, 14, 7, 0, 0, tzinfo=timezone.utc), - datetime(2026, 5, 14, 8, 0, 0, tzinfo=timezone.utc), - ) - ], - }, - ] - payload = MODULE.build_management_payload( - rows, - "SHARKON2025", - datetime(2026, 5, 14, tzinfo=timezone.utc).date(), - owner_filter="Руководитель смены", - ) - assert payload["filters"]["owner"] == "Руководитель смены" - assert payload["filters"]["department"] == "" - assert payload["summary"]["users_count"] == 1 - assert [row["user"] for row in payload["rows"]] == ["Иван Петров"] - assert len(payload["owner_rollups"]) == 1 - assert payload["owner_rollups"][0]["name"] == "Руководитель смены" - assert len(payload["department_rollups"]) == 1 - assert payload["department_rollups"][0]["name"] == "Бухгалтерия" - assert payload["owner_roster"][0]["display_name"] == "Сменный руководитель" - assert all(action["owner"] == "Руководитель смены" for action in payload["actions"]) - finally: - MODULE.load_manager_aliases = original_aliases - MODULE.load_manager_owners = original_owners - MODULE.build_management_trend = original_trend - MODULE.build_source_freshness = original_sources - - -def _minimal_management_payload(report_date, users_count=1): - return { - "summary": { - "users_count": users_count, - "active_users": users_count, - "inactive_users": 0, - "workday_total_active_seconds": 3600 * users_count, - "workday_total_active_hhmm": f"0{users_count}:00", - "portfolio_coverage_pct": 100.0, - "actions_count": 0, - "critical_actions_count": 0, - }, - "rows": [], - "actions": [], - "sources": [], - "filters": {"owner": "", "department": ""}, - } - - -def test_load_management_cache_keeps_historical_reports_after_ttl(): - original_dir = MODULE.MANAGER_CACHE_DIR - original_ttl = MODULE.MANAGER_CACHE_TTL_SECONDS - try: - with tempfile.TemporaryDirectory() as tmp: - MODULE.MANAGER_CACHE_DIR = Path(tmp) - MODULE.MANAGER_CACHE_TTL_SECONDS = 1 - report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date() - payload = _minimal_management_payload(report_date) - path = MODULE.management_cache_path("SHARKON2025", report_date) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload), encoding="utf-8") - loaded = MODULE.load_management_cache("SHARKON2025", report_date) - assert loaded["summary"]["users_count"] == 1 - finally: - MODULE.MANAGER_CACHE_DIR = original_dir - MODULE.MANAGER_CACHE_TTL_SECONDS = original_ttl - - -def test_build_management_trend_reuses_precomputed_anchor_payload(): - original_trend_days = MODULE.MANAGER_TREND_DAYS - original_load = MODULE.load_management_cache - original_fetch = MODULE.fetch_events_for_date - original_aggregate = MODULE.aggregate_rows_with_intervals - fetch_dates = [] - try: - MODULE.MANAGER_TREND_DAYS = 3 - anchor = datetime(2026, 5, 14, tzinfo=timezone.utc).date() - - def fake_load(host, report_date): - if report_date < anchor: - return _minimal_management_payload(report_date) - return None - - def fake_fetch(host, report_date): - fetch_dates.append(report_date) - return MODULE.get_report_bounds(report_date), [] - - MODULE.load_management_cache = fake_load - MODULE.fetch_events_for_date = fake_fetch - MODULE.aggregate_rows_with_intervals = lambda events, start, end, host: [] - trend = MODULE.build_management_trend( - "SHARKON2025", - anchor, - precomputed_payloads={anchor: _minimal_management_payload(anchor, users_count=2)}, - ) - assert [item["report_date"] for item in trend] == ["2026-05-12", "2026-05-13", "2026-05-14"] - assert trend[-1]["users_count"] == 2 - assert fetch_dates == [] - finally: - MODULE.MANAGER_TREND_DAYS = original_trend_days - MODULE.load_management_cache = original_load - MODULE.fetch_events_for_date = original_fetch - MODULE.aggregate_rows_with_intervals = original_aggregate - - -def test_render_management_html_contains_action_queue(): - payload = { - "generated_at_utc": "2026-05-14T12:00:00Z", - "host": "SHARKON2025", - "report_date": "2026-05-14", - "report_timezone": "Europe/Moscow", - "filters": { - "owner": "Руководитель смены", - "department": "Бухгалтерия", - }, - "workday": { - "start_local": "2026-05-14T09:00:00+03:00", - "end_local": "2026-05-14T18:00:00+03:00", - "expected_seconds_per_user": 32400, - "expected_hhmm_per_user": "09:00", - "target_coverage_pct": 75, - "low_coverage_pct": 35, - }, - "summary": { - "users_count": 1, - "active_users": 0, - "inactive_users": 1, - "on_target_users": 0, - "below_target_users": 0, - "portfolio_coverage_pct": 0.0, - "actions_count": 1, - "critical_actions_count": 1, - "high_actions_count": 0, - "total_active_seconds": 0, - "total_active_hhmm": "00:00", - "calendar_total_active_hhmm": "00:00", - "workday_total_active_hhmm": "00:00", - "first_activity": "", - "last_activity": "", - "top_user": "", - "top_user_active_hhmm": "00:00", - }, - "actions": [ - { - "action_id": "missing_activity", - "priority": "critical", - "owner": "Руководитель смены", - "user_id": "SHARKON2025\\user1", - "deadline_hint": "today", - "reason": "Нет активности", - "recommended_action": "Проверить сотрудника", - "evidence": {}, - } - ], - "rows": [ - { - "user": "Иван Петров", - "user_id": "SHARKON2025\\user1", - "canonical_user_id": "SHARKON2025\\user1", - "manager_owner": "Руководитель смены", - "department": "Бухгалтерия", - "active_hhmm": "00:00", - "calendar_active_hhmm": "00:00", - "workday_active_hhmm": "00:00", - "coverage_pct": 0.0, - "status": "inactive", - "first_activity_local": "", - "last_activity_local": "", - "workday_first_activity_local": "", - "workday_last_activity_local": "", - "sessions_count": 1, - } - ], - "executive": { - "portfolio_state": "critical", - "headline": "Есть 1 критичный вопрос, требующий решения сегодня.", - "message": "Активны 0 из 1 сотрудников. Покрытие рабочего окна 0.0%.", - "focus_items": [ - { - "priority": "critical", - "owner": "Руководитель смены", - "title": "missing_activity", - "reason": "Нет активности", - "recommended_action": "Проверить сотрудника", - } - ], - "stale_sources": [], - }, - "owner_rollups": [ - { - "name": "Руководитель смены", - "users_count": 1, - "active_users": 0, - "inactive_users": 1, - "below_target_users": 0, - "workday_total_active_seconds": 0, - "workday_total_active_hhmm": "00:00", - "portfolio_coverage_pct": 0.0, - "actions_count": 1, - "critical_actions_count": 1, - "high_actions_count": 0, - "medium_actions_count": 0, - "low_actions_count": 0, - "users": ["Иван Петров"], - } - ], - "department_rollups": [ - { - "name": "Бухгалтерия", - "users_count": 1, - "active_users": 0, - "inactive_users": 1, - "below_target_users": 0, - "workday_total_active_seconds": 0, - "workday_total_active_hhmm": "00:00", - "portfolio_coverage_pct": 0.0, - "actions_count": 1, - "critical_actions_count": 1, - "high_actions_count": 0, - "medium_actions_count": 0, - "low_actions_count": 0, - "users": ["Иван Петров"], - } - ], - "owner_roster": [ - { - "name": "Руководитель смены", - "display_name": "Сменный руководитель", - "title": "Руководитель смены 1С", - "department": "Операторы 1С", - "contact": "@shift-lead", - "escalation_to": "Финансовый директор", - "notes": "Дневной контур", - "users_count": 1, - "active_users": 0, - "inactive_users": 1, - "below_target_users": 0, - "workday_total_active_seconds": 0, - "workday_total_active_hhmm": "00:00", - "portfolio_coverage_pct": 0.0, - "actions_count": 1, - "critical_actions_count": 1, - "high_actions_count": 0, - "medium_actions_count": 0, - "low_actions_count": 0, - "users": ["Иван Петров"] - } - ], - "trend": [ - { - "report_date": "2026-05-14", - "users_count": 1, - "active_users": 0, - "inactive_users": 1, - "workday_total_active_hhmm": "00:00", - "portfolio_coverage_pct": 0.0, - "actions_count": 1, - "critical_actions_count": 1, - } - ], - "sources": [ - { - "label": "RDP worktime sessions", - "status": "ok", - "status_label": "fresh", - "bucket_id": "aw-worktime-sessions_SHARKON2025", - "timestamp": "2026-05-14T12:00:00Z", - "age_seconds": 30, - "required": True, - "summary": "fresh (30s)", - "event_summary": "queue=0 failures=0 flushed=10", - } - ], - "bucket_id": "aw-worktime-sessions_SHARKON2025", - "report_bounds": { - "start_utc": "2026-05-13T21:00:00Z", - "end_utc": "2026-05-14T20:59:59Z", - }, - } - html = MODULE.render_management_html(payload, selected_day="today") - assert "Управленческий отчёт по работе в RDP" in html - assert "Очередь действий руководителя" in html - assert "missing_activity" in html - assert "Тренд за" in html - assert "По ответственным" in html - assert "Ответственные и эскалация" in html - assert "По подразделениям" in html - assert "Свежесть источников данных" in html - assert "Что делать сегодня" in html - assert "Фильтр:" in html - assert "Сбросить" in html - assert "@shift-lead" in html - assert "Иван Петров" in html - assert "Руководитель смены" in html - - -def test_build_source_freshness_uses_host_fileops_bucket_only(): - original = MODULE.latest_bucket_event - original_now_utc = MODULE.now_utc - try: - def fake_latest(bucket_id): - if bucket_id == "aw-file-operations_SHARKON2025": - return { - "timestamp": "2026-05-23T12:00:00Z", - "data": {"signalType": "collector_health", "queueDepth": 0, "sendFailures": 0, "eventsFlushed": 50}, - } - return None - - MODULE.latest_bucket_event = fake_latest - MODULE.now_utc = lambda: datetime(2026, 5, 23, 12, 5, 0, tzinfo=timezone.utc) - sources, actions = MODULE.build_source_freshness("SHARKON2025") - file_source = next(source for source in sources if source["source_id"] == "file_operations") - assert file_source["bucket_id"] == "aw-file-operations_SHARKON2025" - assert file_source["status"] == "ok" - assert not any(action["evidence"].get("source_id") == "file_operations" for action in actions) - finally: - MODULE.latest_bucket_event = original - MODULE.now_utc = original_now_utc - - -def test_build_source_freshness_marks_desktop_sources_inactive_without_active_users(): - original = MODULE.latest_bucket_event - original_now_utc = MODULE.now_utc - try: - def fake_latest(bucket_id): - if bucket_id in {"aw-watcher-window_SHARKON2025", "aw-file-operations_SHARKON2025"}: - return { - "timestamp": "2026-05-23T11:00:00Z", - "data": {"signalType": "collector_health", "queueDepth": 0, "sendFailures": 0, "eventsFlushed": 50}, - } - if bucket_id.startswith("aw-worktime-sessions_") or bucket_id.startswith("aw-rdp-window_") or bucket_id.startswith("aw-rdp-afk_"): - return { - "timestamp": "2026-05-23T12:00:00Z", - "data": {"status": "ok"}, - } - return None - - MODULE.latest_bucket_event = fake_latest - MODULE.now_utc = lambda: datetime(2026, 5, 23, 12, 5, 0, tzinfo=timezone.utc) - sources, actions = MODULE.build_source_freshness("SHARKON2025", interactive_required=False) - source_by_id = {source["source_id"]: source for source in sources} - assert source_by_id["watcher_window"]["status"] == "inactive" - assert source_by_id["watcher_window"]["status_label"] == "inactive" - assert source_by_id["file_operations"]["status"] == "inactive" - assert source_by_id["file_operations"]["status_label"] == "inactive" - assert not any(action["evidence"].get("source_id") in {"watcher_window", "file_operations"} for action in actions) - finally: - MODULE.latest_bucket_event = original - MODULE.now_utc = original_now_utc - - -def test_resolve_report_format_uses_html_for_browser_accept_without_breaking_api_default(): - assert MODULE.resolve_report_format({}) == "json" - assert MODULE.resolve_report_format({}, "text/html,application/xhtml+xml") == "html" - assert MODULE.resolve_report_format({"format": ["json"]}, "text/html") == "json" - assert MODULE.resolve_report_format({"format": ["csv"]}, "text/html") == "csv" - - -def test_send_bytes_returns_false_on_broken_pipe(): - handler = _FakeHandler(exc=BrokenPipeError()) - ok = MODULE.send_bytes(handler, b"{}", "application/json; charset=utf-8") - assert ok is False - assert handler.status == 200 - assert handler.headers["Content-Type"] == "application/json; charset=utf-8" - assert handler.headers["Content-Length"] == "2" - assert handler.ended is True - - -def test_send_bytes_writes_payload_when_client_is_connected(): - handler = _FakeHandler() - ok = MODULE.send_bytes(handler, b"payload", "text/plain; charset=utf-8", status=201) - assert ok is True - assert handler.status == 201 - assert handler.headers["Content-Length"] == "7" - assert handler.wfile.data == b"payload" - - -def test_report_response_cache_returns_fresh_and_stale_entries(): - original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS - original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS - original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR - original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS - original_cache = MODULE._REPORT_RESPONSE_CACHE - original_busy_wait = MODULE.REPORT_BUSY_WAIT_SECONDS - try: - with tempfile.TemporaryDirectory() as tmp: - MODULE.REPORT_CACHE_TTL_SECONDS = 1 - MODULE.REPORT_STALE_TTL_SECONDS = 60 - MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) - MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 - MODULE.REPORT_BUSY_WAIT_SECONDS = 0 - MODULE._REPORT_RESPONSE_CACHE = {} - key = MODULE.make_report_cache_key("/reports/worktime/today", "json", "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) - MODULE.save_report_response_cache(key, b"{}", "application/json; charset=utf-8") - fresh = MODULE.get_report_response_cache(key) - assert fresh is not None - assert fresh["stale"] is False - assert fresh["cache_source"] == "memory" - MODULE._REPORT_RESPONSE_CACHE[key]["stored_monotonic"] -= 10 - assert MODULE.get_report_response_cache(key) is None - stale = MODULE.get_report_response_cache(key, allow_stale=True) - assert stale is not None - assert stale["stale"] is True - finally: - MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl - MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl - MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir - MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl - MODULE._REPORT_RESPONSE_CACHE = original_cache - MODULE.REPORT_BUSY_WAIT_SECONDS = original_busy_wait - - -def test_report_response_cache_uses_disk_stale_after_memory_loss(): - original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS - original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS - original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR - original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS - original_cache = MODULE._REPORT_RESPONSE_CACHE - original_busy_wait = MODULE.REPORT_BUSY_WAIT_SECONDS - try: - with tempfile.TemporaryDirectory() as tmp: - MODULE.REPORT_CACHE_TTL_SECONDS = 1 - MODULE.REPORT_STALE_TTL_SECONDS = 60 - MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) - MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 - MODULE.REPORT_BUSY_WAIT_SECONDS = 0 - MODULE._REPORT_RESPONSE_CACHE = {} - key = MODULE.make_report_cache_key("/reports/worktime/today", "csv", "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) - MODULE.save_report_response_cache(key, b"user,active_seconds\nuser1,60\n", "text/csv; charset=utf-8") - MODULE._REPORT_RESPONSE_CACHE = {} - - cached = MODULE.get_report_response_cache(key, allow_stale=True) - - assert cached is not None - assert cached["data"].startswith(b"user,active_seconds") - assert cached["content_type"] == "text/csv; charset=utf-8" - assert cached["stale"] is True - assert cached["cache_source"] == "disk" - finally: - MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl - MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl - MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir - MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl - MODULE._REPORT_RESPONSE_CACHE = original_cache - MODULE.REPORT_BUSY_WAIT_SECONDS = original_busy_wait - - -def test_send_cached_report_marks_cache_reason_and_age(): - handler = _FakeHandler() - cached = { - "data": b"cached", - "content_type": "application/json; charset=utf-8", - "age_seconds": 42, - "stale": True, - } - ok = MODULE.send_cached_report(handler, cached, reason="busy") - assert ok is True - assert handler.status == 200 - assert handler.headers["X-AW-Worktime-Cache"] == "stale" - assert handler.headers["X-AW-Worktime-Cache-Reason"] == "busy" - assert handler.headers["X-AW-Worktime-Cache-Age"] == "42" - assert handler.headers["X-AW-Worktime-Cache-Source"] == "memory" - assert handler.wfile.data == b"cached" - - -def test_send_report_unavailable_returns_503_json(): - handler = _FakeHandler() - MODULE.send_report_unavailable(handler, "busy") - payload = json.loads(handler.wfile.data.decode("utf-8")) - assert handler.status == 503 - assert payload["ok"] is False - assert payload["error"] == "report_unavailable" - - -def test_send_worktime_report_rebuilds_stale_cache_when_builder_is_available(monkeypatch): - original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS - original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS - original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR - original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS - original_cache = MODULE._REPORT_RESPONSE_CACHE - try: - with tempfile.TemporaryDirectory() as tmp: - MODULE.REPORT_CACHE_TTL_SECONDS = 1 - MODULE.REPORT_STALE_TTL_SECONDS = 60 - MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) - MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 - MODULE._REPORT_RESPONSE_CACHE = {} - report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date() - key = MODULE.make_report_cache_key("/reports/worktime/today", "html", "SHARKON2025", report_date) - MODULE.save_report_response_cache(key, b"stale", "text/html; charset=utf-8") - MODULE._REPORT_RESPONSE_CACHE[key]["stored_monotonic"] -= 10 - - calls = [] - - def fake_build(path, params): - calls.append((path, params)) - return b"fresh", "text/html; charset=utf-8" - - monkeypatch.setattr(MODULE, "build_worktime_report_response", fake_build) - handler = _FakeHandler() - - MODULE.send_worktime_report( - handler, - "/reports/worktime/today", - {"format": ["html"]}, - key, - host="SHARKON2025", - report_date=report_date, - ) - - assert calls == [("/reports/worktime/today", {"format": ["html"]})] - assert handler.status == 200 - assert handler.headers["Content-Type"] == "text/html; charset=utf-8" - assert "X-AW-Worktime-Cache" not in handler.headers - assert handler.wfile.data == b"fresh" - finally: - MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl - MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl - MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir - MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl - MODULE._REPORT_RESPONSE_CACHE = original_cache - - -def test_send_worktime_report_serves_stale_cache_only_when_builder_is_busy(monkeypatch): - original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS - original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS - original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR - original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS - original_cache = MODULE._REPORT_RESPONSE_CACHE - original_busy_wait = MODULE.REPORT_BUSY_WAIT_SECONDS - try: - with tempfile.TemporaryDirectory() as tmp: - MODULE.REPORT_CACHE_TTL_SECONDS = 1 - MODULE.REPORT_STALE_TTL_SECONDS = 60 - MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) - MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 - MODULE.REPORT_BUSY_WAIT_SECONDS = 0 - MODULE._REPORT_RESPONSE_CACHE = {} - report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date() - key = MODULE.make_report_cache_key("/reports/worktime/today", "html", "SHARKON2025", report_date) - MODULE.save_report_response_cache(key, b"stale", "text/html; charset=utf-8") - MODULE._REPORT_RESPONSE_CACHE[key]["stored_monotonic"] -= 10 - - semaphore = MODULE.threading.BoundedSemaphore(1) - assert semaphore.acquire(blocking=False) is True - monkeypatch.setattr(MODULE, "_REPORT_BUILD_SEMAPHORE", semaphore) - monkeypatch.setattr( - MODULE, - "build_worktime_report_response", - lambda path, params: (_ for _ in ()).throw(AssertionError("builder must not run while busy")), - ) - handler = _FakeHandler() - - MODULE.send_worktime_report( - handler, - "/reports/worktime/today", - {"format": ["html"]}, - key, - host="SHARKON2025", - report_date=report_date, - ) - - assert handler.status == 200 - assert handler.headers["X-AW-Worktime-Cache"] == "stale" - assert handler.headers["X-AW-Worktime-Cache-Reason"] == "busy" - assert handler.wfile.data == b"stale" - semaphore.release() - finally: - MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl - MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl - MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir - MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl - MODULE._REPORT_RESPONSE_CACHE = original_cache - MODULE.REPORT_BUSY_WAIT_SECONDS = original_busy_wait - - -def test_send_worktime_report_allows_explicit_machine_stale_without_blocking(monkeypatch): - original_ttl = MODULE.REPORT_CACHE_TTL_SECONDS - original_stale_ttl = MODULE.REPORT_STALE_TTL_SECONDS - original_disk_dir = MODULE.REPORT_DISK_CACHE_DIR - original_disk_ttl = MODULE.REPORT_DISK_STALE_TTL_SECONDS - original_cache = MODULE._REPORT_RESPONSE_CACHE - try: - with tempfile.TemporaryDirectory() as tmp: - MODULE.REPORT_CACHE_TTL_SECONDS = 1 - MODULE.REPORT_STALE_TTL_SECONDS = 60 - MODULE.REPORT_DISK_CACHE_DIR = Path(tmp) - MODULE.REPORT_DISK_STALE_TTL_SECONDS = 3600 - MODULE._REPORT_RESPONSE_CACHE = {} - report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date() - key = MODULE.make_report_cache_key("/reports/worktime/today", "csv", "SHARKON2025", report_date) - MODULE.save_report_response_cache(key, b"stale", "text/csv; charset=utf-8") - MODULE._REPORT_RESPONSE_CACHE[key]["stored_monotonic"] -= 10 - - refreshes = [] - monkeypatch.setattr( - MODULE, - "trigger_report_background_refresh", - lambda key, path, params: refreshes.append((key, path, params)) or True, - ) - monkeypatch.setattr( - MODULE, - "build_worktime_report_response", - lambda path, params: (_ for _ in ()).throw(AssertionError("explicit stale request must not block on build")), - ) - handler = _FakeHandler() - - MODULE.send_worktime_report( - handler, - "/reports/worktime/today", - {"format": ["csv"], "allow_stale": ["1"]}, - key, - host="SHARKON2025", - report_date=report_date, - ) - - assert refreshes == [] - assert handler.status == 200 - assert handler.headers["X-AW-Worktime-Cache"] == "stale" - assert handler.headers["X-AW-Worktime-Cache-Reason"] == "stale-allowed" - assert handler.wfile.data == b"stale" - finally: - MODULE.REPORT_CACHE_TTL_SECONDS = original_ttl - MODULE.REPORT_STALE_TTL_SECONDS = original_stale_ttl - MODULE.REPORT_DISK_CACHE_DIR = original_disk_dir - MODULE.REPORT_DISK_STALE_TTL_SECONDS = original_disk_ttl - MODULE._REPORT_RESPONSE_CACHE = original_cache - - -def test_management_report_for_date_bypasses_today_payload_cache_for_human_requests(monkeypatch): - original_ttl = MODULE.MANAGER_CACHE_TTL_SECONDS - original_dir = MODULE.MANAGER_CACHE_DIR - try: - with tempfile.TemporaryDirectory() as tmp: - MODULE.MANAGER_CACHE_TTL_SECONDS = 300 - MODULE.MANAGER_CACHE_DIR = Path(tmp) - report_date = datetime.now(timezone.utc).date() - monkeypatch.setattr(MODULE, "REPORT_TZ", timezone.utc) - monkeypatch.setattr(MODULE, "fetch_events_for_date", lambda host, date: ({"start": datetime.combine(date, datetime.min.time(), tzinfo=timezone.utc), "end": datetime.combine(date, datetime.max.time(), tzinfo=timezone.utc)}, [])) - monkeypatch.setattr(MODULE, "aggregate_rows_with_intervals", lambda events, start, end, host: []) - - generated = iter(["old", "fresh"]) - monkeypatch.setattr( - MODULE, - "build_management_payload", - lambda rows, host, date: {"generated_at_utc": next(generated), "rows": rows}, - ) - - cached = MODULE.management_report_for_date("SHARKON2025", report_date, use_cache=True) - fresh = MODULE.management_report_for_date("SHARKON2025", report_date, use_cache=False) - - assert cached["generated_at_utc"] == "old" - assert fresh["generated_at_utc"] == "fresh" - assert MODULE.load_management_cache("SHARKON2025", report_date)["generated_at_utc"] == "fresh" - finally: - MODULE.MANAGER_CACHE_TTL_SECONDS = original_ttl - MODULE.MANAGER_CACHE_DIR = original_dir diff --git a/aw-server/test_aw_worktime_influx_exporter.py b/aw-server/test_aw_worktime_influx_exporter.py deleted file mode 100644 index c4ee32e..0000000 --- a/aw-server/test_aw_worktime_influx_exporter.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -from datetime import date -from pathlib import Path - - -MODULE_PATH = Path(__file__).with_name("aw-worktime-influx-exporter.py") -SPEC = importlib.util.spec_from_file_location("aw_worktime_influx_exporter", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -def test_build_lines_for_day_emits_daily_hourly_and_summary(monkeypatch): - bounds = MODULE.WORKTIME.get_report_bounds(date(2026, 5, 14)) - events = [ - { - "timestamp": "2026-05-14T06:00:00Z", - "duration": 0.0, - "data": { - "username": "user5", - "userId": "WORKGROUP\\user5", - "sessionId": 4, - "state": "Активно", - "active": True, - "sampleSeconds": 1800, - }, - } - ] - - monkeypatch.setattr(MODULE.WORKTIME, "fetch_events_for_date", lambda host, report_date: (bounds, events)) - monkeypatch.setattr(MODULE.WORKTIME, "build_true_active_apps", lambda host, report_date: []) - lines = MODULE.build_lines_for_day("SHARKON2025", date(2026, 5, 14)) - - assert any(line.startswith("aw_rdp_worktime_daily,") for line in lines) - assert any(line.startswith("aw_rdp_worktime_hourly,") for line in lines) - assert any(line.startswith("aw_rdp_worktime_summary_daily,") for line in lines) - - -def test_build_lines_for_day_emits_true_active_app_daily(monkeypatch): - bounds = MODULE.WORKTIME.get_report_bounds(date(2026, 5, 14)) - - monkeypatch.setattr(MODULE.WORKTIME, "fetch_events_for_date", lambda host, report_date: (bounds, [])) - monkeypatch.setattr( - MODULE.WORKTIME, - "build_true_active_apps", - lambda host, report_date: [ - { - "application": "1С", - "proved_work_seconds": 2040, - "proved_work_human": "34 мин", - "proved_work_hhmm": "00:34", - "last_action": "1С:Предприятие", - "last_action_local": "09:42", - "last_action_utc": "2026-05-14T06:42:00Z", - "evidence_events": 7, - } - ], - ) - - lines = MODULE.build_lines_for_day("SHARKON2025", date(2026, 5, 14)) - - true_active_line = next(line for line in lines if line.startswith("aw_true_active_app_daily,")) - assert "application=1С" in true_active_line - assert "report_date=2026-05-14" in true_active_line - assert "proved_work_seconds=2040i" in true_active_line - assert "evidence_events=7i" in true_active_line - assert 'last_action="1С:Предприятие"' in true_active_line diff --git a/aw-server/test_aw_worktime_ui_bridge.py b/aw-server/test_aw_worktime_ui_bridge.py deleted file mode 100644 index 3becf60..0000000 --- a/aw-server/test_aw_worktime_ui_bridge.py +++ /dev/null @@ -1,244 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -import unittest -from datetime import datetime, timedelta, timezone -from pathlib import Path -from unittest import mock - - -MODULE_PATH = Path(__file__).with_name("aw-worktime-ui-bridge.py") -SPEC = importlib.util.spec_from_file_location("aw_worktime_ui_bridge", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(MODULE) - - -class WorktimeUiBridgeTests(unittest.TestCase): - def test_bucket_needs_fallback_when_bucket_is_missing(self): - now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc) - with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=None): - self.assertTrue(MODULE.bucket_needs_fallback("aw-watcher-window_SHARKON2025", now, 600)) - - def test_bucket_needs_fallback_when_bucket_is_recent(self): - now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc) - recent = now - timedelta(seconds=120) - with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=recent): - self.assertFalse(MODULE.bucket_needs_fallback("aw-watcher-window_SHARKON2025", now, 600)) - - def test_bucket_needs_fallback_when_bucket_is_stale(self): - now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc) - stale = now - timedelta(seconds=601) - with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=stale): - self.assertTrue(MODULE.bucket_needs_fallback("aw-watcher-afk_SHARKON2025", now, 600)) - - def test_get_latest_foreground_context_uses_recent_collector_health(self): - now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc) - recent_events = [ - { - "timestamp": "2026-05-27T07:59:30Z", - "data": { - "signalType": "collector_health", - "foregroundProcess": "totalcmd", - "foregroundTitle": "Total Commander 6.01 - HARVEST", - }, - } - ] - with mock.patch.object(MODULE, "_req", return_value=recent_events): - ctx = MODULE.get_latest_foreground_context(now) - self.assertEqual(ctx["app"], "totalcmd.exe") - self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST") - - def test_get_latest_active_session_ids_uses_latest_timestamp_group(self): - events = [ - { - "timestamp": "2026-05-27T07:59:25Z", - "data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"}, - }, - { - "timestamp": "2026-05-27T07:59:30Z", - "data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"}, - }, - { - "timestamp": "2026-05-27T07:59:30Z", - "data": {"sessionId": 3, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"}, - }, - { - "timestamp": "2026-05-27T07:59:30Z", - "data": {"sessionId": 4, "state": "Диск", "username": "user1", "sessionName": ""}, - }, - ] - self.assertEqual(MODULE.get_latest_active_session_ids(events), {2, 3}) - - def test_get_latest_foreground_context_prefers_active_session(self): - now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc) - recent_events = [ - { - "timestamp": "2026-05-27T07:59:40Z", - "data": { - "signalType": "collector_health", - "foregroundProcess": "explorer", - "foregroundTitle": "Explorer", - "sessionId": 9, - }, - }, - { - "timestamp": "2026-05-27T07:59:45Z", - "data": { - "signalType": "collector_health", - "foregroundProcess": "totalcmd", - "foregroundTitle": "Total Commander 6.01 - HARVEST", - "sessionId": 2, - }, - }, - ] - with mock.patch.object(MODULE, "_req", return_value=recent_events): - ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2}) - self.assertEqual(ctx["app"], "totalcmd.exe") - self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST") - - def test_get_latest_foreground_context_uses_cached_context_when_recent_events_are_blank(self): - now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) - recent_events = [ - { - "timestamp": "2026-05-27T08:04:50Z", - "data": { - "signalType": "collector_health", - "foregroundProcess": "", - "foregroundTitle": "", - "sessionId": 3, - }, - } - ] - state = { - "last_foreground_context": { - "app": "totalcmd.exe", - "title": "Total Commander 6.01 - HARVEST", - "timestamp": "2026-05-27T08:03:30Z", - } - } - with mock.patch.object(MODULE, "_req", return_value=recent_events): - ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2, 3}, state=state) - self.assertEqual(ctx["app"], "totalcmd.exe") - self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST") - - def test_transform_uses_foreground_context_for_active_sessions(self): - events = [ - { - "timestamp": "2026-05-27T07:59:30Z", - "duration": 0, - "data": { - "username": "user5", - "state": "Активно", - "sessionId": 3, - "sessionName": "rdp-tcp#0", - }, - } - ] - afk_events, win_events, last_ts = MODULE.transform( - events, - foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"}, - ) - self.assertEqual(afk_events[0]["data"]["status"], "not-afk") - self.assertEqual(win_events[0]["data"]["app"], "totalcmd.exe") - self.assertEqual(win_events[0]["data"]["title"], "Total Commander 6.01 - HARVEST") - self.assertEqual(last_ts, "2026-05-27T07:59:30Z") - - def test_transform_caps_duration_at_next_timestamp_gap(self): - events = [ - { - "timestamp": "2026-05-27T07:59:30Z", - "duration": 5, - "data": { - "username": "user5", - "state": "Активно", - "sessionId": 3, - "sessionName": "rdp-tcp#0", - }, - }, - { - "timestamp": "2026-05-27T07:59:31Z", - "duration": 5, - "data": { - "username": "user5", - "state": "Активно", - "sessionId": 3, - "sessionName": "rdp-tcp#0", - }, - }, - ] - _, win_events, _ = MODULE.transform( - events, - foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"}, - ) - self.assertEqual(win_events[0]["duration"], 1.0) - self.assertEqual(win_events[1]["duration"], 5) - - def test_normalize_watcher_window_events_strips_rdp_suffix_for_real_apps(self): - events = [ - { - "timestamp": "2026-05-27T08:10:00Z", - "duration": 5, - "data": { - "app": "totalcmd.exe", - "title": "Total Commander 6.01 - HARVEST | RDP active (2): user5, администратор", - "source": "aw-worktime-ui-bridge", - }, - }, - { - "timestamp": "2026-05-27T08:10:05Z", - "duration": 5, - "data": { - "app": "RDP", - "title": "RDP active (2): user5, администратор", - "source": "aw-worktime-ui-bridge", - }, - }, - ] - normalized = MODULE.normalize_watcher_window_events(events) - self.assertEqual(normalized[0]["data"]["title"], "Total Commander 6.01 - HARVEST") - self.assertEqual(len(normalized), 1) - - def test_watcher_window_needs_bridge_sync_for_generic_bridge_rdp(self): - now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) - latest_event = { - "timestamp": "2026-05-27T08:04:40Z", - "data": { - "app": "RDP", - "title": "RDP active (2): user5, администратор", - "source": "aw-worktime-ui-bridge", - }, - } - with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \ - mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)): - self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now)) - - def test_watcher_window_needs_bridge_sync_for_non_generic_bridge_event(self): - now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) - latest_event = { - "timestamp": "2026-05-27T08:04:40Z", - "data": { - "app": "totalcmd.exe", - "title": "Total Commander 6.01 - HARVEST", - "source": "aw-worktime-ui-bridge", - }, - } - with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \ - mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)): - self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now)) - - def test_watcher_window_does_not_override_real_watcher_stream(self): - now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc) - latest_event = { - "timestamp": "2026-05-27T08:04:40Z", - "data": { - "app": "notepad.exe", - "title": "Безымянный — Блокнот", - "source": "aw-watcher-window", - }, - } - with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \ - mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)): - self.assertFalse(MODULE.watcher_window_needs_bridge_sync(now)) - - -if __name__ == "__main__": - unittest.main() diff --git a/docs/DETMIR_PYTHON_RETIREMENT_RU.md b/docs/DETMIR_PYTHON_RETIREMENT_RU.md new file mode 100644 index 0000000..1bb453a --- /dev/null +++ b/docs/DETMIR_PYTHON_RETIREMENT_RU.md @@ -0,0 +1,54 @@ +# DetMir: статус вывода Python из runtime + +Дата фиксации: 2026-06-03. + +## Решение + +Боевой контур DetMir/AWatch-rus переводится на Rust-first runtime. Python entrypoints удаляются из серверных DLP, health, SLO, worktime, Hayabusa/offline tooling, backup/merge и deploy путей, если для них уже есть Rust-замена. + +## Удалено из repo/runtime path + +- AW health/SLO/worktime Python services. +- DLP policy engine, case management, compliance, integrations Python services. +- DLP aggregator, health check, admin CLI, IOC extractor, AW DB merge Python scripts. +- Hayabusa case/offline helper Python scripts. +- Legacy `aw-health-check.sh` server-side fallback. +- Active `/usr/local/bin/*.py` entrypoints and old DLP service virtualenvs on AW server. +- Python-era DLP Ansible roles now fail fast and point to `ansible/deploy_aw_server.yml`. +- Python-era Proxmox CT bootstrap playbooks now fail fast until rebuilt as Rust bootstrap. + +## Оставленные исключения + +- Telegram bot runtime on Proxmox remains Python by product decision. +- pfSense tooling is frozen/no-touch in this migration track. +- OCR/content-analysis keeps Python dependencies for image/OCR path; Rust handles text/dictionary/regex path. +- `clickhouse-1c/ai` and `clickhouse-1c/etl` remain a separate 1C/business-data track. +- `detmir-mcp/main.py` remains a separate MCP/runtime track. + +## Проверка + +```bash +rg -n '\.py\b|python3' ansible aw-server scripts adk-rust \ + --glob '!adk-rust/target/**' + +ansible-playbook --syntax-check -i ansible/inventory.ini ansible/deploy_aw_server.yml + +cd adk-rust +CARGO_TARGET_DIR=/tmp/detmir-adk-rust-target cargo test --workspace +CARGO_TARGET_DIR=/tmp/detmir-adk-rust-target cargo clippy --workspace --all-targets -- -D warnings +``` + +Ожидаемые оставшиеся Python-ссылки после очистки: только перечисленные исключения, историческая документация или legacy cleanup patterns. + +Production AW server scan excludes the agreed OCR/content-analysis path, +temporary external Hayabusa rules under `dlp-ioc/tmp`, and rollback backups: + +```bash +find /usr/local/bin /opt/activitywatch \ + -path "/opt/activitywatch/dlp-content-analysis" -prune -o \ + -path "/opt/activitywatch/dlp-ioc/tmp" -prune -o \ + -path "*/switch-backups" -prune -o \ + \( -name "*.py" -o -name "health-check.sh" -o -path "*/.venv" \) -print +``` + +Ожидаемый результат для active server runtime: пустой вывод. diff --git a/install-kit-awindows-20260427-211240.tar.gz b/install-kit-awindows-20260427-211240.tar.gz index 72b91d6d287a5852dc64ee9e0154f1bab6f245ec..6a74debc182386d0ae58107ff02794a78da86044 100644 GIT binary patch delta 144246 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 delta 183873 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≀!kC^KJ1+fkg-$w76k;>!sp3E{N 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 z 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 OJpVuGvfM!c01p6RU<)Y# diff --git a/install-kit-awindows-20260427-211240.zip b/install-kit-awindows-20260427-211240.zip index 8dce547b1c22b023e0871569a2b343c0d67037eb..9a613cbd16b001901e4dc0dd4327a279d76c43dd 100644 GIT binary patch delta 30798 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 KHRH8^hx&ijdF5RI delta 74217 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 diff --git a/install-kit-awindows-20260427-211240/MANIFEST.txt b/install-kit-awindows-20260427-211240/MANIFEST.txt index 35550c9..e1c4ced 100644 --- a/install-kit-awindows-20260427-211240/MANIFEST.txt +++ b/install-kit-awindows-20260427-211240/MANIFEST.txt @@ -1,8 +1,8 @@ 1b776081c24ad8413c2f3e24aff3952949e7786a108a08ef45121536bcae9482 install-kit-awindows-20260427-211240/README-INSTALL-KIT.txt e973ad9aa3824a6588ce4ef1b80ce3caec0085434a2755f9b657f09fb7435632 install-kit-awindows-20260427-211240/ansible/README.md 412bb766bbf0791c3593f38daa771d5d0aa58cc1f2d3c9010fcd4588d0fe87df install-kit-awindows-20260427-211240/ansible/deploy_aw_pfsense_poller.yml -951b6e22c285a95a3f45ead81fe98069eb48405368617a600f86240ccad65b70 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml -7c1afd6088e7ec26842be4b4b8a0530780d1d7075f19f80d63eb08445857823f install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml +ac091618a6793a9be9b52eeb78267068df16d4f57e0051b9d868813c4a641f35 install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +cd09574fd0d55bf62d9d7df757bb206b5184ba9ffdcc200e755700e50cfa41f3 install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml d666eac9d94ad71842e21aaac5d23968a0c2213bb4edab899bf432d7ad417ddf install-kit-awindows-20260427-211240/ansible/group_vars/all.example.yml a896676303be9a47ed6b0c8cc07deb5a5432aebc97231e801fda985121c539eb install-kit-awindows-20260427-211240/ansible/group_vars/pfsense-poller.example.yml c5cab36645065815571c99f6d360f910dcccbb54b780c8bfd526a6cdc3684e19 install-kit-awindows-20260427-211240/ansible/group_vars/proxmox-matrix.example.yml @@ -10,8 +10,8 @@ c5cab36645065815571c99f6d360f910dcccbb54b780c8bfd526a6cdc3684e19 install-kit-aw 9e4939d2d951870cacab6e42c5bf0ccd03350089dc0d32b9a681948ff96754d2 install-kit-awindows-20260427-211240/ansible/group_vars/windows.example.yml 7189b5205bd25313db54e5be027b0d066199e6ae34ad74be2095a1691adaf5e3 install-kit-awindows-20260427-211240/ansible/install_full_stack.yml fea0574d7eb98ce24a1e7025afb9837c6241180095d21eaa74892225305d05a3 install-kit-awindows-20260427-211240/ansible/inventory.example.ini -8a5e4923c0f581dd4fbb32549ee7ab45ba506260056da923ba86d9f1b1081714 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml -18928adcaef5d01b4c621b48f5559383c8b749ef182fd3710f10b222a164f8b7 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +3adda7422ffbbb76be50858f2f1ed6073b6bf9ee0b57283c41d0fabd3e7c7f9e install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml +234932632092226526173b9ef906de2cce961bc95be0f37c878e48d9779e1de7 install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml a74a49371e889dc3ea404534a939f32f2dac940d8902d20770590951ab67d532 install-kit-awindows-20260427-211240/ansible/tasks/provision_ct_and_deploy_aw.yml ba16fe9e267194459a6082045a387acb828a1d39a98e66b401ece5069ea62e64 install-kit-awindows-20260427-211240/aw-server/activitywatch-server.service fea5734c99b516b01bfe8ebbef59972d0ef553c09d7691790b08472ecfb9602d install-kit-awindows-20260427-211240/aw-server/apply_webui_ru_patch.sh @@ -19,30 +19,25 @@ fabcdbb933b1d3b605c5ac1234af4e10495c09f18bd50931bf4a92081fcb5023 install-kit-aw e59d58d4cc6b2b0dceef5b1da14ca0a1b24a202ce576ee43028f44ffe43c5ef4 install-kit-awindows-20260427-211240/aw-server/aw-browser-smoke.timer ba88cc284d047f521427ac038c266624a6fe8493ce3e79bc27c172a2e70ac54a install-kit-awindows-20260427-211240/aw-server/aw-host-groups.json 509759461ce0918a2dc839832812cf5f4c77b1cea1e6ffdcab0146e02598df79 install-kit-awindows-20260427-211240/aw-server/aw-ru-patch.js -6072757b685430962f61f97f6de4b0ed6f6653454559974a6ee87936fc6c7571 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py -05b04b5f49e9c7783917e0861e3edd63f5d6db8638d6b33dbfd4dc0f1c16040f install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service +699f697143f6be588c698bad9a14a45c736fe74f5e4b0e0ea0f48b569c7df396 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service 8ae02b42f4bc33d95268624bae79c2101298b7ec6644553a1e81f7841dda74d8 install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.timer 28950acbfeed3f3e5729e0227fd96785dd840330ed9b94a3d6ab0a6a855ca5ec install-kit-awindows-20260427-211240/aw-server/aw-server.env.example -bc8f88fa615a74fc69f80c0cc614ddea323156e5e49d3de28a9958295737afab install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py -6db182e8e5237b1daaafdcd1003fccf7e5aaaabb7be8ab4226cb513f7b1c739b install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service +18d038e67b926eca04bec725ed5e6de34b93f8330c3b30a0b07d52eab414bd03 install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service 257cdc18f088e8848b1bee69e7f8b99aa860a0f600eebd74365a40ed94aa5ecb install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.timer 98c0bed353bbda0fa7a69df23f3b008cb0e8e70cdff6cc63330d4caf79fd3280 install-kit-awindows-20260427-211240/aw-server/aw-sw-cleanup.js -9597e8e12a3472b86b222f10c2e0a71c653efc41415771aeaab2e830c9424d3c install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py -f764e566d70952acc1b4f2baeccd6b7888905bc6b98b36333336c0dc77c66694 install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service +0e555853f1cb498e63b70d305ce5facfaa626a2ceee6f7edb854cf37c4371f65 install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service afbb4be301b4940ed9b7671be3441a48d53bee968d8870749442f81c6d066650 install-kit-awindows-20260427-211240/aw-server/aw-worktime-panel.js -c9169d01469b2f49eab0f0e9a483f883d4c367719fde51fb262d74c35e798c44 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service -1733c8c84b17ad89e0f3e5b27f649b968dedd52d9048f9bc2d92e7cc10daebd1 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh +7e37dd8bbfdbbe661defb9ce69284db8ad339a49b34f8c507060d0dbc75b7aa8 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service 0cd38bae7ad1b4718d7decc8ef3ac932939a6e5f8c25e9e27d1d0ecf8cc328b9 install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.timer -2e3a0667f8a9eddcb4803c0ba274615092cdd3eec23c2dba3f494715dac91423 install-kit-awindows-20260427-211240/aw-server/health-check.sh -ca7d1ed7665e225824d4ed7ef8456c81a6d97ef2c90cb3aa799d72e07b6bcbb8 install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh +6de9b272ad732ad174c32398ab8baf5cc8c83be47efe0d6df46c3190b4010680 install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh aedffecfa24834968742cb2477faef80bf794345275a9679ac12c5a1f609acc2 install-kit-awindows-20260427-211240/aw-server/settings/classes-worktime.json 47c50054515506b72af2d6bca0ae959ca57861803e528991c230611e6a8893d6 install-kit-awindows-20260427-211240/aw-server/settings/views-default.json 4e5b23300ba5c9878b2c8ce6eea65043b7a4c5cb2d14619243cfca6d202aec9f install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.mjs -a851fba13a14a529f95eeb054c2f64dc108cf7a379c4ea35402e84c0b4a2733d install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh -da178cb5dfff3ae8465a6139048adc6757cb1dc23edb30d747d09a05e6b07aeb install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh +62980eeebe01d7da243be8535a48f5d41d4eead83cf94015df807111c3d920ab install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh +6770275fac17607770653a522a64cf5ab31a7a11993b68e7e80a2858c3b8930e install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh fe8d2b846a64f1357dda48b4c817c7919a3c7398921ddca2adf9a99711ac2613 install-kit-awindows-20260427-211240/scripts/quality-gate.sh -f634ea60040153fafcab6363bea8c442d05c1734e6a9d2e16844cf63a741df24 install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh -b58c572edd12e4c3620c045f3382e7b9f74664ef5550302772a828a023f8f3fe install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh +db1a9b0ccd21aec78d8e2aa08dfa28368e5ba6209c57361e6824e85c1af9f6ca install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh +38ad2e112796c2fa9bdc1cc2403c679bb896f90ae2b51d6ccc9a9ec103b4dea6 install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh 0b49e6db51d5abcecaeee0b85f186efd4f10d3360cf04e8aea2e4c7d4465dbda install-kit-awindows-20260427-211240/scripts/verify_innosetup_installer.sh dd2389e9cb199ed86d219120294fdaa64415cfbc683004d5f5db5d52aa758a92 install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-admin.deployment-config.json 5279f9d677faed76a5f0248f9217ecc29eac977ab752552cab852f5b4b6715df install-kit-awindows-20260427-211240/server-configs-192.168.100.18/awatch-rus-u2u5.deployment-config.json diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml index 7b68704..6a81578 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_server.yml @@ -27,6 +27,7 @@ - tesseract-ocr - tesseract-ocr-rus - unzip + - jq state: present update_cache: true @@ -109,6 +110,64 @@ - "{{ aw_rus_health_validation_dir }}" - "{{ aw_server_log_dir }}" + - name: Проверить существующие health state files + ansible.builtin.stat: + path: "{{ item }}" + loop: + - "{{ aw_rus_health_state_dir }}/aw-rus-health.json" + - "{{ aw_rus_health_state_dir }}/aw-rus-health.txt" + - "{{ aw_rus_health_state_dir }}/dlp-health-check-counters.json" + register: aw_rus_health_state_files + + - name: Нормализовать владельца существующих health state files + ansible.builtin.file: + path: "{{ item.stat.path }}" + owner: "{{ aw_server_user }}" + group: "{{ aw_server_group }}" + mode: "0644" + loop: "{{ aw_rus_health_state_files.results }}" + when: item.stat.exists | default(false) + + - name: Удалить устаревшие Python entrypoints после Rust migration + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - /opt/activitywatch/aw-rus-ops/health-check.sh + - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py + - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py + - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py + - /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py + - /opt/activitywatch/dlp-case-management/.venv + - /opt/activitywatch/dlp-case-management/case_rules.py + - /opt/activitywatch/dlp-case-management/case_schema.py + - /opt/activitywatch/dlp-case-management/case_service.py + - /opt/activitywatch/dlp-case-management/case_storage.py + - /opt/activitywatch/dlp-case-management/evidence_chain.py + - /opt/activitywatch/dlp-compliance/compliance_scheduler.py + - /opt/activitywatch/dlp-compliance/report_generator.py + - /opt/activitywatch/dlp-compliance/.venv + - /opt/activitywatch/dlp-integrations/cef_exporter.py + - /opt/activitywatch/dlp-integrations/syslog_forwarder.py + - /opt/activitywatch/dlp-integrations/webhook_sender.py + - /opt/activitywatch/dlp-integrations/.venv + - /opt/activitywatch/dlp-policy-engine/.venv + - /opt/activitywatch/dlp-policy-engine/policy_distributor.py + - /opt/activitywatch/dlp-policy-engine/policy_schema.py + - /opt/activitywatch/dlp-policy-engine/policy_service.py + - /opt/activitywatch/dlp-policy-engine/policy_storage.py + - /opt/activitywatch/scripts/aggregate_dlp_events.py + - /usr/local/bin/aw-dlp-influx-exporter.py + - /usr/local/bin/aw-extract-ioc-from-sigma.py + - /usr/local/bin/aw-rus-healthd.py + - /usr/local/bin/aw-slo-monitor.py + - /usr/local/bin/aw-worktime-afk-bridge.py + - /usr/local/bin/aw-worktime-api.py + - /usr/local/bin/aw-worktime-influx-exporter.py + - /usr/local/bin/aw-worktime-ui-bridge.py + - /usr/local/bin/dlp-admin-cli.py + - /usr/local/bin/merge_aw_server_dbs.py + - name: Установить prune script для локального state ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-prune-local-state.sh" @@ -431,14 +490,6 @@ group: root mode: "0755" - - name: Положить legacy aw-health-check до Influx проверок - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/health-check.sh" - dest: /opt/activitywatch/aw-rus-ops/health-check.sh - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust aw-health-check до Influx проверок ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-health-check" @@ -455,6 +506,12 @@ mode: "0755" when: aw_health_check_rust_binary_early.stat.exists | default(false) + - name: Требовать Rust aw-health-check до Influx проверок + ansible.builtin.assert: + that: + - aw_health_check_rust_binary_early.stat.exists | default(false) + fail_msg: "Rust aw-health-check artifact is required at {{ aw_rust_release_dir }}/aw-health-check" + - name: Проверить локальный Rust aw-db-health до Influx проверок ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-db-health" @@ -471,6 +528,64 @@ mode: "0755" when: aw_db_health_rust_binary_early.stat.exists | default(false) + - name: Проверить локальный Rust aw-ensure-reliability до Influx проверок + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-ensure-reliability" + delegate_to: localhost + register: aw_ensure_reliability_rust_binary_early + become: false + + - name: Установить Rust aw-ensure-reliability до Influx проверок + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-ensure-reliability" + dest: /usr/local/bin/aw-ensure-reliability + owner: root + group: root + mode: "0755" + when: aw_ensure_reliability_rust_binary_early.stat.exists | default(false) + + - name: Проверить локальный Rust aw-db-maintenance до Influx проверок + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-db-maintenance" + delegate_to: localhost + register: aw_db_maintenance_rust_binary_early + become: false + + - name: Установить Rust aw-db-maintenance до Influx проверок + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-db-maintenance" + dest: /usr/local/bin/aw-db-maintenance + owner: root + group: root + mode: "0755" + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + + - name: Установить aw-db-maintenance service до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-db-maintenance.service" + dest: /etc/systemd/system/aw-db-maintenance.service + owner: root + group: root + mode: "0644" + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + + - name: Установить aw-db-maintenance timer до Influx проверок + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/aw-db-maintenance.timer" + dest: /etc/systemd/system/aw-db-maintenance.timer + owner: root + group: root + mode: "0644" + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + + - name: Включить weekly aw-db-maintenance timer до Influx проверок + ansible.builtin.systemd: + name: aw-db-maintenance.timer + enabled: true + state: started + daemon_reload: true + when: aw_db_maintenance_rust_binary_early.stat.exists | default(false) + - name: Прочитать текущий aw-server.env для сохранения Influx token ansible.builtin.slurp: path: /etc/activitywatch/aw-server.env @@ -592,34 +707,6 @@ mode: "0755" when: aw_dlp_policy_engine_enabled | default(false) | bool - - name: Скопировать файлы DLP policy engine - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/dlp-policy-engine/{{ item }}" - dest: "/opt/activitywatch/dlp-policy-engine/{{ item }}" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0644" - loop: - - policy_service.py - - policy_schema.py - - policy_storage.py - - policy_distributor.py - - requirements.txt - when: aw_dlp_policy_engine_enabled | default(false) | bool - - - name: Создать virtualenv DLP policy engine - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-policy-engine/.venv - args: - creates: /opt/activitywatch/dlp-policy-engine/.venv/bin/python - when: aw_dlp_policy_engine_enabled | default(false) | bool - - - name: Установить зависимости DLP policy engine - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-policy-engine/requirements.txt - virtualenv: /opt/activitywatch/dlp-policy-engine/.venv - when: aw_dlp_policy_engine_enabled | default(false) | bool - - name: Установить systemd unit DLP policy engine ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-policy-engine/dlp-policy-engine.service" @@ -637,6 +724,13 @@ become: false when: aw_dlp_policy_engine_enabled | default(false) | bool + - name: Требовать Rust DLP policy engine artifact + ansible.builtin.assert: + that: + - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-policy-engine" + when: aw_dlp_policy_engine_enabled | default(false) | bool + - name: Установить Rust DLP policy engine ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-policy-engine" @@ -648,31 +742,6 @@ - aw_dlp_policy_engine_enabled | default(false) | bool - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог DLP policy engine - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-policy-engine.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_policy_engine_enabled | default(false) | bool - - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) - - - name: Переключить DLP policy engine на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-policy-engine.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-dlp-policy-engine-rust - when: - - aw_dlp_policy_engine_enabled | default(false) | bool - - aw_dlp_policy_engine_rust_binary.stat.exists | default(false) - - name: Создать каталог DLP content analysis ansible.builtin.file: path: /opt/activitywatch/dlp-content-analysis @@ -749,38 +818,9 @@ group: "{{ aw_server_group }}" mode: "0644" loop: - - cef_exporter.py - - syslog_forwarder.py - - webhook_sender.py - cef-config.yaml - syslog-forwarder-config.yaml - webhook-config.yaml - - requirements.txt - when: aw_dlp_integrations_enabled | default(true) | bool - - - name: Выставить executable для python scripts DLP integrations - ansible.builtin.file: - path: "/opt/activitywatch/dlp-integrations/{{ item }}" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0755" - loop: - - cef_exporter.py - - syslog_forwarder.py - - webhook_sender.py - when: aw_dlp_integrations_enabled | default(true) | bool - - - name: Создать virtualenv DLP integrations - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-integrations/.venv - args: - creates: /opt/activitywatch/dlp-integrations/.venv/bin/python - when: aw_dlp_integrations_enabled | default(true) | bool - - - name: Установить зависимости DLP integrations - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-integrations/requirements.txt - virtualenv: /opt/activitywatch/dlp-integrations/.venv when: aw_dlp_integrations_enabled | default(true) | bool - name: Создать state каталог DLP integrations @@ -818,6 +858,13 @@ become: false when: aw_dlp_integrations_enabled | default(true) | bool + - name: Требовать Rust CEF exporter artifact + ansible.builtin.assert: + that: + - dlp_cef_exporter_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-cef-exporter" + when: aw_dlp_integrations_enabled | default(true) | bool + - name: Установить Rust CEF exporter ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-cef-exporter" @@ -829,31 +876,6 @@ - aw_dlp_integrations_enabled | default(true) | bool - dlp_cef_exporter_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог CEF exporter - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-cef-exporter.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_cef_exporter_rust_binary.stat.exists | default(false) - - - name: Переключить CEF exporter на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-cef-exporter.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/dlp-cef-exporter-rust - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_cef_exporter_rust_binary.stat.exists | default(false) - - name: Установить systemd unit syslog forwarder ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-integrations/syslog-forwarder.service" @@ -880,6 +902,13 @@ become: false when: aw_dlp_integrations_enabled | default(true) | bool + - name: Требовать Rust syslog forwarder artifact + ansible.builtin.assert: + that: + - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-syslog-forwarder" + when: aw_dlp_integrations_enabled | default(true) | bool + - name: Установить Rust syslog forwarder ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-syslog-forwarder" @@ -891,31 +920,6 @@ - aw_dlp_integrations_enabled | default(true) | bool - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог syslog forwarder - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - - - name: Переключить syslog forwarder на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-syslog-forwarder.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/dlp-syslog-forwarder-rust - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_syslog_forwarder_rust_binary.stat.exists | default(false) - - name: Установить systemd unit webhook sender ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-integrations/webhook-sender.service" @@ -942,6 +946,13 @@ become: false when: aw_dlp_integrations_enabled | default(true) | bool + - name: Требовать Rust webhook sender artifact + ansible.builtin.assert: + that: + - dlp_webhook_sender_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-webhook-sender" + when: aw_dlp_integrations_enabled | default(true) | bool + - name: Установить Rust webhook sender ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-webhook-sender" @@ -953,31 +964,6 @@ - aw_dlp_integrations_enabled | default(true) | bool - dlp_webhook_sender_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог webhook sender - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-webhook-sender.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_webhook_sender_rust_binary.stat.exists | default(false) - - - name: Переключить webhook sender на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-webhook-sender.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/dlp-webhook-sender-rust - when: - - aw_dlp_integrations_enabled | default(true) | bool - - dlp_webhook_sender_rust_binary.stat.exists | default(false) - - name: Создать каталог DLP case management ansible.builtin.file: path: /opt/activitywatch/dlp-case-management @@ -987,43 +973,6 @@ mode: "0755" when: aw_dlp_case_management_enabled | default(true) | bool - - name: Скопировать файлы DLP case management - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/dlp-case-management/{{ item }}" - dest: "/opt/activitywatch/dlp-case-management/{{ item }}" - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0644" - loop: - - case_service.py - - case_rules.py - - case_schema.py - - case_storage.py - - evidence_chain.py - - requirements.txt - when: aw_dlp_case_management_enabled | default(true) | bool - - - name: Выставить executable для case_service - ansible.builtin.file: - path: /opt/activitywatch/dlp-case-management/case_service.py - owner: "{{ aw_server_user }}" - group: "{{ aw_server_group }}" - mode: "0755" - when: aw_dlp_case_management_enabled | default(true) | bool - - - name: Создать virtualenv DLP case management - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-case-management/.venv - args: - creates: /opt/activitywatch/dlp-case-management/.venv/bin/python - when: aw_dlp_case_management_enabled | default(true) | bool - - - name: Установить зависимости DLP case management - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-case-management/requirements.txt - virtualenv: /opt/activitywatch/dlp-case-management/.venv - when: aw_dlp_case_management_enabled | default(true) | bool - - name: Установить systemd unit DLP case management ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/dlp-case-management/case-service.service" @@ -1041,6 +990,13 @@ become: false when: aw_dlp_case_management_enabled | default(true) | bool + - name: Требовать Rust DLP case management artifact + ansible.builtin.assert: + that: + - aw_dlp_case_management_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-case-management" + when: aw_dlp_case_management_enabled | default(true) | bool + - name: Установить Rust DLP case management ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-case-management" @@ -1052,31 +1008,6 @@ - aw_dlp_case_management_enabled | default(true) | bool - aw_dlp_case_management_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог DLP case management - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-case-management.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_case_management_enabled | default(true) | bool - - aw_dlp_case_management_rust_binary.stat.exists | default(false) - - - name: Переключить DLP case management на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-case-management.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-dlp-case-management-rust - when: - - aw_dlp_case_management_enabled | default(true) | bool - - aw_dlp_case_management_rust_binary.stat.exists | default(false) - - name: Создать каталоги DLP compliance ansible.builtin.file: path: "{{ item }}" @@ -1098,28 +1029,12 @@ group: "{{ aw_server_group }}" mode: "{{ item.mode }}" loop: - - { src: "report_generator.py", dest: "/opt/activitywatch/dlp-compliance/report_generator.py", mode: "0755" } - - { src: "compliance_scheduler.py", dest: "/opt/activitywatch/dlp-compliance/compliance_scheduler.py", mode: "0755" } - - { src: "requirements.txt", dest: "/opt/activitywatch/dlp-compliance/requirements.txt", mode: "0644" } - { src: "templates/152-fz-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/152-fz-report.html", mode: "0644" } - { src: "templates/pci-dss-report.html", dest: "/opt/activitywatch/dlp-compliance/templates/pci-dss-report.html", mode: "0644" } - { src: "report-scheduler.service", dest: "/etc/systemd/system/aw-dlp-report-scheduler.service", mode: "0644" } - { src: "report-scheduler.timer", dest: "/etc/systemd/system/aw-dlp-report-scheduler.timer", mode: "0644" } when: aw_dlp_compliance_enabled | default(true) | bool - - name: Создать virtualenv DLP compliance - ansible.builtin.command: - cmd: python3 -m venv /opt/activitywatch/dlp-compliance/.venv - args: - creates: /opt/activitywatch/dlp-compliance/.venv/bin/python - when: aw_dlp_compliance_enabled | default(true) | bool - - - name: Установить зависимости DLP compliance - ansible.builtin.pip: - requirements: /opt/activitywatch/dlp-compliance/requirements.txt - virtualenv: /opt/activitywatch/dlp-compliance/.venv - when: aw_dlp_compliance_enabled | default(true) | bool - - name: Проверить локальный Rust DLP compliance ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/dlp-compliance" @@ -1128,6 +1043,13 @@ become: false when: aw_dlp_compliance_enabled | default(true) | bool + - name: Требовать Rust DLP compliance artifact + ansible.builtin.assert: + that: + - aw_dlp_compliance_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-compliance" + when: aw_dlp_compliance_enabled | default(true) | bool + - name: Установить Rust DLP compliance ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-compliance" @@ -1139,45 +1061,18 @@ - aw_dlp_compliance_enabled | default(true) | bool - aw_dlp_compliance_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог DLP compliance report scheduler - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-report-scheduler.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_compliance_enabled | default(true) | bool - - aw_dlp_compliance_rust_binary.stat.exists | default(false) - - - name: Переключить DLP compliance report scheduler на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-report-scheduler.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-dlp-compliance-rust - when: - - aw_dlp_compliance_enabled | default(true) | bool - - aw_dlp_compliance_rust_binary.stat.exists | default(false) - - - name: Установить dlp-admin-cli - ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/dlp-admin-cli.py" - dest: /usr/local/bin/dlp-admin-cli.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust dlp-admin-cli ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/dlp-admin-cli" delegate_to: localhost register: aw_dlp_admin_cli_rust_binary + - name: Требовать Rust dlp-admin-cli artifact + ansible.builtin.assert: + that: + - aw_dlp_admin_cli_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-admin-cli" + - name: Установить Rust dlp-admin-cli ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-admin-cli" @@ -1187,14 +1082,6 @@ mode: "0755" when: aw_dlp_admin_cli_rust_binary.stat.exists - - name: Установить скрипт AW worktime API - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-api.py" - dest: /usr/local/bin/aw-worktime-api.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust AW worktime API ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/worktime-api" @@ -1202,6 +1089,12 @@ register: aw_worktime_api_rust_binary become: false + - name: Требовать Rust AW worktime API artifact + ansible.builtin.assert: + that: + - aw_worktime_api_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-api" + - name: Установить Rust AW worktime API ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-api" @@ -1219,35 +1112,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW worktime API - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-api.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_worktime_api_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime API на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-api.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-api-rust - when: aw_worktime_api_rust_binary.stat.exists | default(false) - - - name: Установить скрипт AW worktime UI bridge - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.py" - dest: /usr/local/bin/aw-worktime-ui-bridge.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust AW worktime UI bridge ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/worktime-ui-bridge" @@ -1255,6 +1119,12 @@ register: aw_worktime_ui_bridge_rust_binary become: false + - name: Требовать Rust AW worktime UI bridge artifact + ansible.builtin.assert: + that: + - aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-ui-bridge" + - name: Установить Rust AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-ui-bridge" @@ -1264,14 +1134,6 @@ mode: "0755" when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) - - name: Установить скрипт AW worktime autoheal - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.sh" - dest: /usr/local/bin/aw-worktime-autoheal.sh - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust AW worktime autoheal ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/worktime-autoheal" @@ -1279,6 +1141,12 @@ register: aw_worktime_autoheal_rust_binary become: false + - name: Требовать Rust AW worktime autoheal artifact + ansible.builtin.assert: + that: + - aw_worktime_autoheal_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-autoheal" + - name: Установить Rust AW worktime autoheal ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-autoheal" @@ -1288,14 +1156,6 @@ mode: "0755" when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) - - name: Установить скрипт AW worktime prewarm - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.sh" - dest: /usr/local/bin/aw-worktime-prewarm.sh - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust AW worktime prewarm ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/worktime-prewarm" @@ -1303,6 +1163,12 @@ register: aw_worktime_prewarm_rust_binary become: false + - name: Требовать Rust AW worktime prewarm artifact + ansible.builtin.assert: + that: + - aw_worktime_prewarm_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-prewarm" + - name: Установить Rust AW worktime prewarm ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-prewarm" @@ -1312,25 +1178,7 @@ mode: "0755" when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) - - name: Установить скрипт AW worktime Influx exporter - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.py" - dest: /usr/local/bin/aw-worktime-influx-exporter.py - owner: root - group: root - mode: "0755" - when: aw_worktime_influx_enabled | default(false) | bool - - - name: Установить скрипт AW DLP Influx exporter - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.py" - dest: /usr/local/bin/aw-dlp-influx-exporter.py - owner: root - group: root - mode: "0755" - when: aw_dlp_influx_enabled | default(false) | bool - - - name: Создать server-side ops bundle для legacy aw-health-check + - name: Создать server-side ops bundle для AW ops helpers ansible.builtin.file: path: /opt/activitywatch/aw-rus-ops state: directory @@ -1346,14 +1194,6 @@ group: root mode: "0755" - - name: Положить legacy aw-health-check в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/health-check.sh" - dest: /opt/activitywatch/aw-rus-ops/health-check.sh - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust aw-health-check ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-health-check" @@ -1370,6 +1210,12 @@ mode: "0755" when: aw_health_check_rust_binary.stat.exists | default(false) + - name: Требовать Rust aw-health-check artifact + ansible.builtin.assert: + that: + - aw_health_check_rust_binary.stat.exists | default(false) + fail_msg: "Rust aw-health-check artifact is required at {{ aw_rust_release_dir }}/aw-health-check" + - name: Проверить локальный Rust aw-db-health ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-db-health" @@ -1434,30 +1280,12 @@ mode: "0755" when: dlp_health_check_rust_binary.stat.exists | default(false) - - name: Установить legacy Python dlp-health-check - ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/dlp-health-check.py" - dest: /usr/local/bin/dlp-health-check - owner: root - group: root - mode: "0755" - when: not (dlp_health_check_rust_binary.stat.exists | default(false)) - - - name: Установить скрипт aw-rus-healthd - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-rus-healthd.py" - dest: /usr/local/bin/aw-rus-healthd.py - owner: root - group: root - mode: "0755" - - - name: Установить скрипт aw-slo-monitor - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.py" - dest: /usr/local/bin/aw-slo-monitor.py - owner: root - group: root - mode: "0755" + - name: Проверить локальный Rust AW-RUS healthd + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/aw-rus-healthd" + delegate_to: localhost + register: aw_rus_healthd_rust_binary + become: false - name: Проверить локальный Rust AW-RUS SLO monitor ansible.builtin.stat: @@ -1466,6 +1294,32 @@ register: aw_slo_monitor_rust_binary become: false + - name: Требовать Rust dlp-health-check artifact + ansible.builtin.assert: + that: + - dlp_health_check_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-health-check" + + - name: Требовать Rust AW-RUS healthd artifact + ansible.builtin.assert: + that: + - aw_rus_healthd_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-rus-healthd" + + - name: Требовать Rust AW-RUS SLO monitor artifact + ansible.builtin.assert: + that: + - aw_slo_monitor_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-slo-monitor" + + - name: Установить Rust AW-RUS healthd + ansible.builtin.copy: + src: "{{ aw_rust_release_dir }}/aw-rus-healthd" + dest: /usr/local/bin/aw-rus-healthd-rust + owner: root + group: root + mode: "0755" + - name: Установить Rust AW-RUS SLO monitor ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/aw-slo-monitor" @@ -1492,27 +1346,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW worktime UI bridge - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-ui-bridge.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime UI bridge на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-ui-bridge.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-ui-bridge-rust - when: aw_worktime_ui_bridge_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW worktime UI bridge ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-ui-bridge.timer" @@ -1529,27 +1362,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW worktime autoheal - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-autoheal.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime autoheal на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-autoheal.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-autoheal-rust - when: aw_worktime_autoheal_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW worktime autoheal ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-autoheal.timer" @@ -1566,27 +1378,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW worktime prewarm - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-prewarm.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime prewarm на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-prewarm.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-prewarm-rust - when: aw_worktime_prewarm_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW worktime prewarm ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-prewarm.timer" @@ -1619,24 +1410,6 @@ group: root mode: "0644" - - name: Создать drop-in каталог AW-RUS SLO monitor - ansible.builtin.file: - path: /etc/systemd/system/aw-slo-monitor.service.d - state: directory - owner: root - group: root - mode: "0755" - when: aw_slo_monitor_rust_binary.stat.exists | default(false) - - - name: Переключить AW-RUS SLO monitor на Rust - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor-rust-switch.conf" - dest: /etc/systemd/system/aw-slo-monitor.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - when: aw_slo_monitor_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW-RUS SLO monitor ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-slo-monitor.timer" @@ -1680,6 +1453,13 @@ become: false when: aw_worktime_influx_enabled | default(false) | bool + - name: Требовать Rust AW worktime Influx exporter artifact + ansible.builtin.assert: + that: + - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/worktime-influx-exporter" + when: aw_worktime_influx_enabled | default(false) | bool + - name: Установить Rust AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/worktime-influx-exporter" @@ -1691,31 +1471,6 @@ - aw_worktime_influx_enabled | default(false) | bool - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог AW worktime Influx exporter - ansible.builtin.file: - path: /etc/systemd/system/aw-worktime-influx-exporter.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_worktime_influx_enabled | default(false) | bool - - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) - - - name: Переключить AW worktime Influx exporter на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-worktime-influx-exporter.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-worktime-influx-exporter-rust - when: - - aw_worktime_influx_enabled | default(false) | bool - - aw_worktime_influx_exporter_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW worktime Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-worktime-influx-exporter.timer" @@ -1742,6 +1497,13 @@ become: false when: aw_dlp_influx_enabled | default(false) | bool + - name: Требовать Rust AW DLP Influx exporter artifact + ansible.builtin.assert: + that: + - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-influx-exporter" + when: aw_dlp_influx_enabled | default(false) | bool + - name: Установить Rust AW DLP Influx exporter ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-influx-exporter" @@ -1753,31 +1515,6 @@ - aw_dlp_influx_enabled | default(false) | bool - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог AW DLP Influx exporter - ansible.builtin.file: - path: /etc/systemd/system/aw-dlp-influx-exporter.service.d - state: directory - owner: root - group: root - mode: "0755" - when: - - aw_dlp_influx_enabled | default(false) | bool - - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) - - - name: Переключить AW DLP Influx exporter на Rust - ansible.builtin.copy: - dest: /etc/systemd/system/aw-dlp-influx-exporter.service.d/20-rust-switch.conf - owner: root - group: root - mode: "0644" - content: | - [Service] - ExecStart= - ExecStart=/usr/local/bin/aw-dlp-influx-exporter-rust - when: - - aw_dlp_influx_enabled | default(false) | bool - - aw_dlp_influx_exporter_rust_binary.stat.exists | default(false) - - name: Установить systemd timer AW DLP Influx exporter ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-dlp-influx-exporter.timer" @@ -1986,13 +1723,28 @@ regexp: '' replace: '' - - name: Скопировать merge script AW DB на сервер + - name: Check Rust merge AW DB binary artifact + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/merge-aw-server-dbs" + register: aw_merge_db_rust_binary + delegate_to: localhost + become: false + + - name: Требовать Rust merge AW DB artifact + ansible.builtin.assert: + that: + - aw_merge_db_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/merge-aw-server-dbs" + when: aw_legacy_db_merge_enabled | default(false) | bool + + - name: Install Rust merge AW DB binary ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/merge_aw_server_dbs.py" - dest: /usr/local/bin/merge_aw_server_dbs.py + src: "{{ aw_rust_release_dir }}/merge-aw-server-dbs" + dest: /usr/local/bin/merge-aw-server-dbs owner: root group: root mode: "0755" + when: aw_merge_db_rust_binary.stat.exists | default(false) - name: Проверить наличие legacy root DB ansible.builtin.stat: @@ -2051,8 +1803,7 @@ - name: Merge legacy root DB в target DB ansible.builtin.command: argv: - - python3 - - /usr/local/bin/merge_aw_server_dbs.py + - /usr/local/bin/merge-aw-server-dbs - --base - /root/.local/share/activitywatch/aw-server-rust/sqlite.db - --overlay @@ -2182,34 +1933,6 @@ group: root mode: "0755" - - name: Скопировать агрегатор событий DLP - ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/aggregate_dlp_events.py" - dest: "/opt/activitywatch/scripts/aggregate_dlp_events.py" - owner: root - group: root - mode: "0755" - - - name: Установить systemd unit для агрегатора - ansible.builtin.copy: - dest: /etc/systemd/system/activitywatch-dlp-aggregator.service - content: | - [Unit] - Description=ActivityWatch DLP Event Aggregator - After=activitywatch-server.service - - [Service] - Type=oneshot - User={{ aw_server_user }} - WorkingDirectory={{ aw_server_data_dir }} - ExecStart=/usr/bin/python3 /opt/activitywatch/scripts/aggregate_dlp_events.py \ - --aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \ - --sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \ - --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json - - [Install] - WantedBy=multi-user.target - - name: Проверить локальный Rust DLP aggregator ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/dlp-aggregator" @@ -2217,6 +1940,12 @@ register: dlp_aggregator_rust_binary become: false + - name: Требовать Rust DLP aggregator artifact + ansible.builtin.assert: + that: + - dlp_aggregator_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/dlp-aggregator" + - name: Установить Rust DLP aggregator ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/dlp-aggregator" @@ -2226,29 +1955,28 @@ mode: "0755" when: dlp_aggregator_rust_binary.stat.exists | default(false) - - name: Создать drop-in каталог DLP aggregator - ansible.builtin.file: - path: /etc/systemd/system/activitywatch-dlp-aggregator.service.d - state: directory - owner: root - group: root - mode: "0755" - when: dlp_aggregator_rust_binary.stat.exists | default(false) - - - name: Переключить DLP aggregator на Rust + - name: Установить systemd unit для агрегатора ansible.builtin.copy: - dest: /etc/systemd/system/activitywatch-dlp-aggregator.service.d/20-rust-switch.conf + dest: /etc/systemd/system/activitywatch-dlp-aggregator.service owner: root group: root mode: "0644" content: | + [Unit] + Description=ActivityWatch DLP Event Aggregator + After=activitywatch-server.service + [Service] - ExecStart= + Type=oneshot + User={{ aw_server_user }} + WorkingDirectory={{ aw_server_data_dir }} ExecStart=/usr/local/bin/dlp-aggregator-rust \ --aw-url http://127.0.0.1:{{ aw_server_port }}/api/0 \ --sqlite-path {{ aw_server_data_dir }}/dlp_warehouse.sqlite \ --state-path {{ aw_server_data_dir }}/dlp-aggregator-state.json - when: dlp_aggregator_rust_binary.stat.exists | default(false) + + [Install] + WantedBy=multi-user.target - name: Установить systemd timer для агрегатора ansible.builtin.copy: @@ -2276,13 +2004,6 @@ - name: Настроить IOC enrichment из Hayabusa Sigma when: aw_dlp_ioc_enabled | default(false) | bool block: - - name: Установить зависимости IOC refresh - ansible.builtin.apt: - name: - - python3-yaml - state: present - update_cache: true - - name: Создать каталог IOC enrichment ansible.builtin.file: path: "{{ aw_dlp_ioc_workdir }}/{{ item }}" @@ -2295,10 +2016,23 @@ - output - tmp - - name: Установить экстрактор IOC из Sigma + - name: Проверить локальный Rust extractor IOC из Sigma + ansible.builtin.stat: + path: "{{ aw_rust_release_dir }}/extract-ioc-from-sigma" + delegate_to: localhost + register: aw_extract_ioc_from_sigma_rust_binary + become: false + + - name: Требовать Rust extractor IOC из Sigma + ansible.builtin.assert: + that: + - aw_extract_ioc_from_sigma_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/extract-ioc-from-sigma" + + - name: Установить Rust extractor IOC из Sigma ansible.builtin.copy: - src: "{{ aw_repo_root }}/scripts/extract_ioc_from_sigma.py" - dest: /usr/local/bin/aw-extract-ioc-from-sigma.py + src: "{{ aw_rust_release_dir }}/extract-ioc-from-sigma" + dest: /usr/local/bin/aw-extract-ioc-from-sigma owner: root group: root mode: "0755" @@ -2330,7 +2064,7 @@ fi mv "${EXTRACTED_DIR}" "${RULES_DIR}" - /usr/bin/python3 /usr/local/bin/aw-extract-ioc-from-sigma.py \ + /usr/local/bin/aw-extract-ioc-from-sigma \ --rules-root "${RULES_DIR}" \ --out-dir "${OUTDIR}" @@ -2642,28 +2376,23 @@ mode: "0755" when: aw_hayabusa_link_case_rust_binary.stat.exists | default(false) + - name: Требовать Rust helper link-case для Hayabusa + ansible.builtin.assert: + that: + - aw_hayabusa_link_case_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-hayabusa-link-case-rust" + - name: Установить helper link-case для Hayabusa ansible.builtin.copy: content: | #!/usr/bin/env bash set -euo pipefail - if [[ -x /usr/local/bin/aw-hayabusa-link-case-rust ]]; then - exec /usr/local/bin/aw-hayabusa-link-case-rust "$@" - fi - exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py "$@" + exec /usr/local/bin/aw-hayabusa-link-case-rust "$@" dest: /usr/local/bin/aw-hayabusa-link-case owner: root group: root mode: "0755" - - name: Положить helper link-case в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-link-case.py" - dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-link-case.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust helper from-windows для Hayabusa ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-hayabusa-from-windows-rust" @@ -2680,28 +2409,23 @@ mode: "0755" when: aw_hayabusa_from_windows_rust_binary.stat.exists | default(false) + - name: Требовать Rust helper from-windows для Hayabusa + ansible.builtin.assert: + that: + - aw_hayabusa_from_windows_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-hayabusa-from-windows-rust" + - name: Установить helper from-windows для Hayabusa ansible.builtin.copy: content: | #!/usr/bin/env bash set -euo pipefail - if [[ -x /usr/local/bin/aw-hayabusa-from-windows-rust ]]; then - exec /usr/local/bin/aw-hayabusa-from-windows-rust "$@" - fi - exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py "$@" + exec /usr/local/bin/aw-hayabusa-from-windows-rust "$@" dest: /usr/local/bin/aw-hayabusa-from-windows owner: root group: root mode: "0755" - - name: Положить helper from-windows в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-from-windows.py" - dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-from-windows.py - owner: root - group: root - mode: "0755" - - name: Положить README Hayabusa в server-side ops bundle ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/hayabusa/README.md" @@ -2726,28 +2450,23 @@ mode: "0755" when: aw_hayabusa_autoprocess_rust_binary.stat.exists | default(false) + - name: Требовать Rust helper autoprocess для Hayabusa + ansible.builtin.assert: + that: + - aw_hayabusa_autoprocess_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-hayabusa-autoprocess-rust" + - name: Установить helper autoprocess для Hayabusa ansible.builtin.copy: content: | #!/usr/bin/env bash set -euo pipefail - if [[ -x /usr/local/bin/aw-hayabusa-autoprocess-rust ]]; then - exec /usr/local/bin/aw-hayabusa-autoprocess-rust "$@" - fi - exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py "$@" + exec /usr/local/bin/aw-hayabusa-autoprocess-rust "$@" dest: /usr/local/bin/aw-hayabusa-autoprocess owner: root group: root mode: "0755" - - name: Положить helper autoprocess в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-autoprocess.py" - dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-autoprocess.py - owner: root - group: root - mode: "0755" - - name: Проверить локальный Rust helper case-alert для Hayabusa ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/aw-hayabusa-case-alert-rust" @@ -2764,28 +2483,23 @@ mode: "0755" when: aw_hayabusa_case_alert_rust_binary.stat.exists | default(false) + - name: Требовать Rust helper case-alert для Hayabusa + ansible.builtin.assert: + that: + - aw_hayabusa_case_alert_rust_binary.stat.exists | default(false) + fail_msg: "Missing Rust artifact: {{ aw_rust_release_dir }}/aw-hayabusa-case-alert-rust" + - name: Установить helper case-alert для Hayabusa ansible.builtin.copy: content: | #!/usr/bin/env bash set -euo pipefail - if [[ -x /usr/local/bin/aw-hayabusa-case-alert-rust ]]; then - exec /usr/local/bin/aw-hayabusa-case-alert-rust "$@" - fi - exec /usr/bin/python3 /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py "$@" + exec /usr/local/bin/aw-hayabusa-case-alert-rust "$@" dest: /usr/local/bin/aw-hayabusa-case-alert owner: root group: root mode: "0755" - - name: Положить helper case-alert в server-side ops bundle - ansible.builtin.copy: - src: "{{ aw_repo_root }}/aw-server/hayabusa/aw-hayabusa-case-alert.py" - dest: /opt/activitywatch/aw-rus-ops/hayabusa/aw-hayabusa-case-alert.py - owner: root - group: root - mode: "0755" - - name: Установить systemd unit aw-hayabusa-drop.service ansible.builtin.copy: src: "{{ aw_repo_root }}/aw-server/aw-hayabusa-drop.service" @@ -2833,9 +2547,9 @@ - not ansible_check_mode - aw_server_post_deploy_health_check_enabled | default(true) | bool block: - - name: Запустить /usr/local/bin/aw-rus-healthd.py --json + - name: Запустить /usr/local/bin/aw-rus-healthd-rust --json ansible.builtin.command: - cmd: /usr/local/bin/aw-rus-healthd.py --json + cmd: /usr/local/bin/aw-rus-healthd-rust --json register: aw_post_deploy_health changed_when: false failed_when: false diff --git a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml index e71a3a0..4a1734c 100644 --- a/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml +++ b/install-kit-awindows-20260427-211240/ansible/deploy_aw_windows.yml @@ -181,6 +181,7 @@ - export-evtx-for-hayabusa.ps1 - export-upload-hayabusa-to-aw-server.ps1 - export-upload-file-1c-telemetry.ps1 + - sync-dlp-evidence-artifacts.ps1 - migrate-awatch-rus-paths.ps1 - deploy-domain-users.ps1 - deploy-ensemble.ps1 diff --git a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml index 1ac786e..c6318db 100644 --- a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_and_deploy_aw.yml @@ -1,46 +1,14 @@ --- -- name: Создать один Proxmox CT и развернуть AWatch-rus +- name: Deprecated Proxmox CT bootstrap path hosts: proxmox gather_facts: false - vars: - proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap" - aw_bootstrap_files: - - install_aw_server.sh - - apply_webui_ru_patch.sh - - activitywatch-server.service - - aw-worktime-api.py - - aw-worktime-api.service - - aw-worktime-ui-bridge.py - - aw-worktime-ui-bridge.service - - aw-worktime-ui-bridge.timer - - aw-worktime-panel.js - - aw-server.env.example - - aw-ru-patch.js - - aw-sw-cleanup.js - - aw-host-groups.json - - settings/classes-worktime.json - - settings/views-default.json - tasks: - - name: Выполнить workflow создания одного CT - ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml - vars: - ct_id: "{{ proxmox_ct_id }}" - ct_hostname: "{{ proxmox_ct_hostname }}" - ct_storage: "{{ proxmox_ct_storage }}" - ct_template: "{{ proxmox_ct_template }}" - ct_rootfs_size: "{{ proxmox_ct_rootfs_size }}" - ct_cores: "{{ proxmox_ct_cores }}" - ct_memory: "{{ proxmox_ct_memory }}" - ct_swap: "{{ proxmox_ct_swap }}" - ct_bridge: "{{ proxmox_ct_bridge }}" - ct_ip: "{{ proxmox_ct_ip }}" - ct_gw: "{{ proxmox_ct_gw }}" - ct_vlan: "{{ proxmox_ct_vlan | default('') }}" - ct_nameserver: "{{ proxmox_ct_nameserver | default('') }}" - ct_searchdomain: "{{ proxmox_ct_searchdomain | default('') }}" - ct_password: "{{ proxmox_ct_password }}" - ct_unprivileged: "{{ proxmox_ct_unprivileged }}" - ct_onboot: "{{ proxmox_ct_onboot }}" - ct_features: "{{ proxmox_ct_features }}" + - name: Stop deprecated Python-era CT bootstrap + ansible.builtin.fail: + msg: >- + ansible/provision_proxmox_ct_and_deploy_aw.yml is deprecated because + its bootstrap path copied removed Python service entrypoints. Use the + existing DetMir AW server and ansible/deploy_aw_server.yml for the + Rust application deploy. Rebuild this CT bootstrap path separately + before using it for new infrastructure. diff --git a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml index e0ff9bc..3819d24 100644 --- a/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml +++ b/install-kit-awindows-20260427-211240/ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml @@ -1,56 +1,14 @@ --- -- name: Создать матрицу Proxmox CT и развернуть AWatch-rus с RU patch +- name: Deprecated Proxmox CT matrix bootstrap path hosts: proxmox gather_facts: false - vars: - proxmox_bootstrap_dir: "/tmp/aw-rus-bootstrap" - aw_bootstrap_files: - - install_aw_server.sh - - apply_webui_ru_patch.sh - - activitywatch-server.service - - aw-worktime-api.py - - aw-worktime-api.service - - aw-worktime-ui-bridge.py - - aw-worktime-ui-bridge.service - - aw-worktime-ui-bridge.timer - - aw-worktime-panel.js - - aw-server.env.example - - aw-ru-patch.js - - aw-sw-cleanup.js - - aw-host-groups.json - - settings/classes-worktime.json - - settings/views-default.json - tasks: - - name: Проверить, что матрица CT задана - ansible.builtin.assert: - that: - - proxmox_ct_matrix is defined - - proxmox_ct_matrix | length > 0 - fail_msg: "Задайте proxmox_ct_matrix в group_vars/proxmox-matrix.yml" - - - name: Выполнить workflow создания для каждого CT - ansible.builtin.include_tasks: tasks/provision_ct_and_deploy_aw.yml - vars: - ct_id: "{{ item.id }}" - ct_hostname: "{{ item.hostname }}" - ct_storage: "{{ item.storage }}" - ct_template: "{{ item.template }}" - ct_rootfs_size: "{{ item.rootfs_size }}" - ct_cores: "{{ item.cores }}" - ct_memory: "{{ item.memory }}" - ct_swap: "{{ item.swap }}" - ct_bridge: "{{ item.bridge }}" - ct_ip: "{{ item.ip }}" - ct_gw: "{{ item.gw }}" - ct_vlan: "{{ item.vlan | default('') }}" - ct_nameserver: "{{ item.nameserver | default('') }}" - ct_searchdomain: "{{ item.searchdomain | default('') }}" - ct_password: "{{ item.password }}" - ct_unprivileged: "{{ item.unprivileged }}" - ct_onboot: "{{ item.onboot }}" - ct_features: "{{ item.features }}" - loop: "{{ proxmox_ct_matrix }}" - loop_control: - label: "ct={{ item.id }} host={{ item.hostname }} ip={{ item.ip }}" + - name: Stop deprecated Python-era CT matrix bootstrap + ansible.builtin.fail: + msg: >- + ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml is deprecated + because its bootstrap path copied removed Python service entrypoints. + Use the existing DetMir AW server and ansible/deploy_aw_server.yml for + the Rust application deploy. Rebuild this CT bootstrap path separately + before using it for new infrastructure. diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py deleted file mode 100644 index 07d0538..0000000 --- a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.py +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import os -import socket -import subprocess -import sys -import tempfile -import time -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import Any -from urllib import request - - -ENV_FILE = Path("/etc/activitywatch/aw-server.env") - - -def load_env_file(path: Path) -> None: - if not path.exists(): - return - try: - lines = path.read_text(encoding="utf-8").splitlines() - except PermissionError: - # systemd EnvironmentFile has already injected the variables for service runs. - return - for raw_line in lines: - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip("'").strip('"') - os.environ.setdefault(key, value) - - -def env(name: str, default: str) -> str: - value = os.environ.get(name) - return value if value not in (None, "") else default - - -def now_utc() -> datetime: - return datetime.now(UTC) - - -def parse_ts(value: str | None) -> datetime | None: - if not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) - except ValueError: - return None - - -def age_seconds(ts: datetime | None, now: datetime) -> int | None: - if ts is None: - return None - return max(0, int((now - ts).total_seconds())) - - -def http_json(url: str, timeout: int = 20, attempts: int = 2, backoff_seconds: float = 0.5) -> Any: - last_exc: Exception | None = None - for attempt in range(max(1, attempts)): - try: - with request.urlopen(url, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")) - except Exception as exc: - last_exc = exc - if attempt + 1 >= max(1, attempts): - break - time.sleep(backoff_seconds * (2**attempt)) - assert last_exc is not None - raise last_exc - - -def run_command(cmd: list[str]) -> tuple[int, str]: - proc = subprocess.run( - cmd, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - return proc.returncode, proc.stdout.strip() - - -def tcp_connect(host: str, port: int, timeout: float) -> tuple[bool, str]: - try: - with socket.create_connection((host, port), timeout=timeout): - return True, "connected" - except OSError as exc: - return False, str(exc) - - -@dataclass -class CheckResult: - name: str - status: str - summary: str - details: dict[str, Any] - - -class Report: - def __init__(self) -> None: - self.results: list[CheckResult] = [] - - def add(self, name: str, status: str, summary: str, **details: Any) -> None: - self.results.append(CheckResult(name=name, status=status, summary=summary, details=details)) - - @property - def ok(self) -> bool: - return not any(item.status == "fail" for item in self.results) - - def as_dict(self) -> dict[str, Any]: - counts = {"ok": 0, "warn": 0, "fail": 0} - for item in self.results: - counts[item.status] = counts.get(item.status, 0) + 1 - return { - "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), - "ok": self.ok, - "counts": counts, - "results": [ - { - "name": item.name, - "status": item.status, - "summary": item.summary, - "details": item.details, - } - for item in self.results - ], - } - - def render_text(self) -> str: - icon = {"ok": "OK", "warn": "WARN", "fail": "FAIL"} - lines = ["=== AW-RUS Health ===", f"Timestamp: {now_utc().isoformat().replace('+00:00', 'Z')}", ""] - for item in self.results: - lines.append(f"[{icon.get(item.status, item.status.upper())}] {item.name}: {item.summary}") - lines.append("") - payload = self.as_dict() - lines.append( - "Counts: ok={ok} warn={warn} fail={fail}".format( - ok=payload["counts"]["ok"], - warn=payload["counts"]["warn"], - fail=payload["counts"]["fail"], - ) - ) - lines.append(f"Overall: {'OK' if payload['ok'] else 'FAIL'}") - return "\n".join(lines) - - -def latest_bucket_event(api_base: str, bucket_id: str) -> dict[str, Any] | None: - events = http_json(f"{api_base}/buckets/{bucket_id}/events?limit=1", timeout=25) - if isinstance(events, list) and events: - events = [item for item in events if isinstance(item, dict)] - if not events: - return None - events.sort(key=lambda item: item.get("timestamp") or "", reverse=True) - return events[0] - return None - - -def bucket_metadata_ts(buckets: dict[str, Any], bucket_id: str) -> datetime | None: - bucket = buckets.get(bucket_id) if isinstance(buckets, dict) else None - if not isinstance(bucket, dict): - return None - metadata = bucket.get("metadata") - if not isinstance(metadata, dict): - return None - return parse_ts(metadata.get("end")) - - -def host_activity_from_worktime(event: dict[str, Any] | None, max_age_seconds: int) -> dict[str, Any]: - now = now_utc() - if not event: - return {"fresh": False, "active": False, "age_seconds": None, "timestamp": None} - ts = parse_ts(event.get("timestamp")) - age = age_seconds(ts, now) - data = event.get("data") or {} - is_fresh = age is not None and age <= max_age_seconds - is_active = bool(is_fresh and data.get("active")) - return { - "fresh": bool(is_fresh), - "active": bool(is_active), - "age_seconds": age, - "timestamp": event.get("timestamp"), - "data": data, - } - - -def bucket_health( - api_base: str, - bucket_id: str, - max_age_seconds: int, - missing_status: str, - stale_status: str, -) -> tuple[str, str, dict[str, Any]]: - try: - event = latest_bucket_event(api_base, bucket_id) - except Exception as exc: - return "fail", f"bucket query failed: {exc}", {"bucket": bucket_id} - - if not event: - return missing_status, "no events", {"bucket": bucket_id} - - ts = parse_ts(event.get("timestamp")) - age = age_seconds(ts, now_utc()) - details = {"bucket": bucket_id, "timestamp": event.get("timestamp"), "age_seconds": age} - if age is None: - return "warn", "timestamp parse failed", details - if age > max_age_seconds: - return stale_status, f"stale ({age}s)", details - return "ok", f"fresh ({age}s)", details - - -def bucket_timestamp_health( - api_base: str, - buckets: dict[str, Any], - bucket_id: str, - max_age_seconds: int, - missing_status: str, - stale_status: str, -) -> tuple[str, str, dict[str, Any]]: - metadata_ts = bucket_metadata_ts(buckets, bucket_id) - if metadata_ts is not None: - age = age_seconds(metadata_ts, now_utc()) - timestamp = metadata_ts.isoformat().replace("+00:00", "Z") - details = { - "bucket": bucket_id, - "timestamp": timestamp, - "age_seconds": age, - "timestamp_source": "bucket_metadata.end", - } - if age is None: - return "warn", "timestamp parse failed", details - if age > max_age_seconds: - return stale_status, f"stale ({age}s)", details - return "ok", f"fresh ({age}s)", details - - return bucket_health(api_base, bucket_id, max_age_seconds, missing_status, stale_status) - - -def guard_bucket_health( - api_base: str, - host: str, - max_age_seconds: int, - required: bool, -) -> tuple[str, str, dict[str, Any]]: - bucket_id = f"aw-rus-collector-guard_{host}" - try: - event = latest_bucket_event(api_base, bucket_id) - except Exception as exc: - status = "fail" if required else "warn" - return status, f"guard bucket query failed: {exc}", {"bucket": bucket_id} - - if not event: - status = "fail" if required else "warn" - return status, "no guard heartbeat", {"bucket": bucket_id, "required": required} - - ts = parse_ts(event.get("timestamp")) - age = age_seconds(ts, now_utc()) - data = event.get("data") if isinstance(event.get("data"), dict) else {} - guard_status = str(data.get("status") or "unknown").lower() - details = { - "bucket": bucket_id, - "timestamp": event.get("timestamp"), - "age_seconds": age, - "required": required, - "guard_status": guard_status, - "mode": data.get("mode"), - "live_session_count": data.get("liveSessionCount"), - "problems": data.get("problems", []), - "actions": data.get("actions", []), - } - - stale_status = "fail" if required else "warn" - if age is None: - return "warn", "guard timestamp parse failed", details - if age > max_age_seconds: - return stale_status, f"guard stale ({age}s)", details - if guard_status in ("fail", "error"): - return "fail" if required else "warn", f"guard reports {guard_status}", details - if guard_status == "warn": - return "warn", "guard reports warn", details - return "ok", f"guard fresh ({age}s)", details - - -def latest_validation_report(validation_dir: Path) -> Path | None: - candidates = sorted( - (path for path in validation_dir.glob("*-aw_validate_ansible.json") if path.is_file()), - key=lambda item: item.stat().st_mtime, - reverse=True, - ) - return candidates[0] if candidates else None - - -def write_atomic(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: - handle.write(content) - tmp_name = handle.name - os.replace(tmp_name, path) - - -def chmod_if_possible(path: Path, mode: int) -> None: - try: - path.chmod(mode) - except OSError: - pass - - -def check_wrapper( - report: Report, - name: str, - cmd: list[str], - json_mode: bool = False, - failure_status: str = "fail", -) -> None: - if not Path(cmd[0]).exists(): - report.add(name, "warn", "binary missing", command=cmd) - return - rc, output = run_command(cmd) - details: dict[str, Any] = {"command": cmd, "returncode": rc} - if json_mode: - try: - details["payload"] = json.loads(output) if output else {} - except json.JSONDecodeError: - details["raw_output"] = output - report.add(name, "fail", "invalid JSON output", **details) - return - else: - details["output"] = output - report.add(name, "ok" if rc == 0 else failure_status, "passed" if rc == 0 else "failed", **details) - - -def main() -> int: - load_env_file(ENV_FILE) - - parser = argparse.ArgumentParser(description="Unified AW-RUS health orchestrator") - parser.add_argument("--aw-server", default=env("AW_SERVER_URL", "http://127.0.0.1:5600")) - parser.add_argument( - "--worktime-api", - default=env("AW_RUS_HEALTH_WORKTIME_API", env("AW_WORKTIME_REPORT_BASE", "http://127.0.0.1:5610")), - ) - parser.add_argument("--rdp-host", default=env("AW_MONITORED_WINDOWS_HOST", "192.168.100.18")) - parser.add_argument("--rdp-hostname", default=env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025")) - parser.add_argument("--state-dir", default=env("AW_RUS_HEALTH_STATE_DIR", "/var/lib/activitywatch/health")) - parser.add_argument("--validation-dir", default=env("AW_RUS_HEALTH_VALIDATION_DIR", "/var/lib/activitywatch/health/windows-validation")) - parser.add_argument("--session-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_MAX_AGE_SECONDS", "900"))) - parser.add_argument("--interactive-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_INTERACTIVE_MAX_AGE_SECONDS", "900"))) - parser.add_argument("--session-events-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_SESSION_EVENTS_MAX_AGE_SECONDS", "86400"))) - parser.add_argument("--guard-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_GUARD_MAX_AGE_SECONDS", "300"))) - parser.add_argument("--guard-required", action="store_true", default=env("AW_RUS_HEALTH_GUARD_REQUIRED", "0").lower() in ("1", "true", "yes")) - parser.add_argument("--validation-max-age-seconds", type=int, default=int(env("AW_RUS_HEALTH_VALIDATION_MAX_AGE_SECONDS", "259200"))) - parser.add_argument("--tcp-timeout-seconds", type=float, default=float(env("AW_RUS_HEALTH_TCP_TIMEOUT_SECONDS", "3"))) - parser.add_argument("--json", action="store_true") - args = parser.parse_args() - - report = Report() - aw_api_base = args.aw_server.rstrip("/") - if not aw_api_base.endswith("/api/0"): - aw_api_base = aw_api_base.rstrip("/") + "/api/0" - - check_wrapper(report, "wrapper:aw-health-check", ["/usr/local/bin/aw-health-check"], failure_status="warn") - check_wrapper( - report, - "wrapper:dlp-health-check", - ["/usr/local/bin/dlp-health-check", "--json"], - json_mode=True, - failure_status="warn", - ) - - try: - info = http_json(f"{aw_api_base}/info") - report.add("http:aw-server", "ok", "activitywatch API responded", version=info.get("version")) - except Exception as exc: - report.add("http:aw-server", "fail", f"activitywatch API failed: {exc}", url=f"{aw_api_base}/info") - - try: - payload = http_json(args.worktime_api.rstrip("/") + "/health") - report.add("http:worktime-api", "ok", "worktime API responded", payload=payload if isinstance(payload, dict) else {}) - except Exception as exc: - report.add("http:worktime-api", "fail", f"worktime API failed: {exc}", url=args.worktime_api) - - for port, label in ((5985, "winrm"), (3389, "rdp")): - ok, message = tcp_connect(args.rdp_host, port, args.tcp_timeout_seconds) - report.add(f"tcp:{label}", "ok" if ok else "fail", message if ok else f"unreachable: {message}", host=args.rdp_host, port=port) - - try: - buckets = http_json(f"{aw_api_base}/buckets") - if not isinstance(buckets, dict): - raise RuntimeError("bucket index is not a dict") - report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets)) - except Exception as exc: - report.add("aw:buckets-index", "fail", f"failed to load bucket index: {exc}") - buckets = {} - - host = args.rdp_hostname - guard_status, guard_summary, guard_details = guard_bucket_health( - aw_api_base, - host, - args.guard_max_age_seconds, - args.guard_required, - ) - report.add("bucket:collector-guard", guard_status, guard_summary, **guard_details) - - worktime_bucket = f"aw-worktime-sessions_{host}" - worktime_event = None - if buckets: - try: - worktime_event = latest_bucket_event(aw_api_base, worktime_bucket) - except Exception: - worktime_event = None - activity = host_activity_from_worktime(worktime_event, args.session_max_age_seconds) - if worktime_event: - status, summary, details = bucket_health( - aw_api_base, - worktime_bucket, - args.session_max_age_seconds, - missing_status="fail", - stale_status="fail", - ) - details["host_activity"] = activity - report.add("bucket:worktime-sessions", status, summary, **details) - else: - report.add("bucket:worktime-sessions", "fail", "no events", bucket=worktime_bucket, host_activity=activity) - - interactive_required = bool(activity["active"]) - for bucket_name, label in ( - ("aw-watcher-afk", "bucket:afk"), - ("aw-watcher-window", "bucket:window"), - ("aw-dlp-endpoint-signals", "bucket:endpoint-signals"), - ): - status, summary, details = bucket_health( - aw_api_base, - f"{bucket_name}_{host}", - args.interactive_max_age_seconds, - missing_status="fail" if interactive_required else "warn", - stale_status="fail" if interactive_required else "warn", - ) - details["interactive_required"] = interactive_required - details["host_activity"] = activity - if not interactive_required and status != "ok": - details["inactive_summary"] = summary - status = "ok" - summary = "inactive: no active interactive users" - report.add(label, status, summary, **details) - - session_status, session_summary, session_details = bucket_timestamp_health( - aw_api_base, - buckets, - f"aw-session-events_{host}", - args.session_events_max_age_seconds, - missing_status="fail", - stale_status="warn", - ) - if session_status == "warn" and session_details.get("age_seconds") is not None: - session_status = "ok" - session_summary = f"event-driven ({session_details['age_seconds']}s since last logon marker)" - report.add("bucket:session-events", session_status, session_summary, **session_details) - - validation_dir = Path(args.validation_dir) - validation_report = latest_validation_report(validation_dir) - if validation_report is None: - report.add("validation:windows", "warn", "no validation report snapshot", directory=str(validation_dir)) - else: - try: - payload = json.loads(validation_report.read_text(encoding="utf-8-sig")) - age = age_seconds(datetime.fromtimestamp(validation_report.stat().st_mtime, tz=UTC), now_utc()) - if age is not None and age > args.validation_max_age_seconds: - report.add( - "validation:windows", - "warn", - f"validation snapshot is stale ({age}s)", - path=str(validation_report), - overall_ok=payload.get("overallOk"), - failed_sections=payload.get("summary", {}).get("failedSections", []), - ) - elif payload.get("overallOk") is True: - report.add("validation:windows", "ok", "validation snapshot OK", path=str(validation_report), age_seconds=age) - else: - report.add( - "validation:windows", - "fail", - "validation snapshot reports failure", - path=str(validation_report), - age_seconds=age, - failed_sections=payload.get("summary", {}).get("failedSections", []), - ) - except Exception as exc: - report.add("validation:windows", "fail", f"invalid validation snapshot: {exc}", path=str(validation_report)) - - payload = report.as_dict() - state_dir = Path(args.state_dir) - health_json_path = state_dir / "aw-rus-health.json" - health_txt_path = state_dir / "aw-rus-health.txt" - write_atomic(health_json_path, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") - write_atomic(health_txt_path, report.render_text() + "\n") - chmod_if_possible(health_json_path, 0o644) - chmod_if_possible(health_txt_path, 0o644) - - if args.json: - print(json.dumps(payload, ensure_ascii=False, indent=2)) - else: - print(report.render_text()) - return 0 if payload["ok"] else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service index 015a906..b5ba629 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service +++ b/install-kit-awindows-20260427-211240/aw-server/aw-rus-healthd.service @@ -6,7 +6,7 @@ Wants=network-online.target activitywatch-server.service aw-worktime-api.service [Service] Type=oneshot EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/bin/python3 /usr/local/bin/aw-rus-healthd.py +ExecStart=/usr/local/bin/aw-rus-healthd-rust User=root Group=root StandardOutput=journal diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py b/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py deleted file mode 100644 index 854d6ff..0000000 --- a/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.py +++ /dev/null @@ -1,465 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import tempfile -import time -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Any -from urllib import request - - -ENV_FILE = Path("/etc/activitywatch/aw-server.env") -DEFAULT_STATE_DIR = Path("/var/lib/activitywatch/slo") -DEFAULT_HEALTHD_CMD = "/usr/local/bin/aw-rus-healthd.py --json" -DEFAULT_HEALTHD_STATE_FILE = Path("/var/lib/activitywatch/health/aw-rus-health.json") - - -def load_env_file(path: Path) -> None: - if not path.exists(): - return - try: - lines = path.read_text(encoding="utf-8").splitlines() - except PermissionError: - # systemd EnvironmentFile has already loaded the variables for the service. - return - for raw_line in lines: - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - os.environ.setdefault(key.strip(), value.strip().strip("'").strip('"')) - - -def env(name: str, default: str) -> str: - value = os.environ.get(name) - return value if value not in (None, "") else default - - -def now_utc() -> datetime: - return datetime.now(UTC) - - -def parse_ts(value: str) -> datetime: - return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) - - -def iso(dt: datetime) -> str: - return dt.astimezone(UTC).isoformat().replace("+00:00", "Z") - - -def write_atomic(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: - handle.write(content) - tmp_name = handle.name - os.replace(tmp_name, path) - - -def chmod_if_possible(path: Path, mode: int) -> None: - try: - path.chmod(mode) - except OSError: - pass - - -def chown_like_parent_if_possible(path: Path) -> None: - try: - parent_stat = path.parent.stat() - os.chown(path, parent_stat.st_uid, parent_stat.st_gid) - except OSError: - pass - - -def fetch_url(url: str, timeout_seconds: float, *, accept: str | None = None, attempts: int = 1) -> dict[str, Any]: - started = now_utc() - last_error = "" - for attempt in range(1, max(1, attempts) + 1): - try: - req = request.Request(url, headers={"Accept": accept}) if accept else url - with request.urlopen(req, timeout=timeout_seconds) as resp: - body = resp.read() - status = int(resp.status) - content_type = resp.headers.get("Content-Type", "") - ok = 200 <= status < 300 - return { - "ok": ok, - "status": status, - "body_bytes": len(body), - "content_type": content_type, - "body": body, - "attempts": attempt, - "latency_ms": int((now_utc() - started).total_seconds() * 1000), - "url": url, - } - except Exception as exc: - last_error = str(exc) - if attempt < max(1, attempts): - time.sleep(1) - return { - "ok": False, - "error": last_error, - "body_bytes": 0, - "attempts": max(1, attempts), - "latency_ms": int((now_utc() - started).total_seconds() * 1000), - "url": url, - } - - -def public_probe_result(result: dict[str, Any]) -> dict[str, Any]: - return {key: value for key, value in result.items() if key != "body"} - - -def http_probe(url: str, timeout_seconds: float) -> dict[str, Any]: - return public_probe_result(fetch_url(url, timeout_seconds, attempts=2)) - - -def html_probe( - url: str, - timeout_seconds: float, - *, - min_bytes: int, - required_markers: tuple[str, ...], -) -> dict[str, Any]: - result = fetch_url(url, timeout_seconds, accept="text/html", attempts=2) - if not result.get("ok"): - return public_probe_result(result) - - body = result.get("body", b"") - text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else str(body) - missing = [marker for marker in required_markers if marker not in text] - content_type = str(result.get("content_type") or "") - if len(body) < min_bytes: - result["ok"] = False - result["error"] = f"body too small: {len(body)} < {min_bytes}" - elif "text/html" not in content_type.lower(): - result["ok"] = False - result["error"] = f"unexpected content-type: {content_type or 'unknown'}" - elif missing: - result["ok"] = False - result["error"] = "missing markers: " + ", ".join(missing) - result["missing_markers"] = missing - return public_probe_result(result) - - -def json_probe( - url: str, - timeout_seconds: float, - *, - expected_values: dict[str, Any], - required_keys: tuple[str, ...], -) -> dict[str, Any]: - result = fetch_url(url, timeout_seconds, accept="application/json", attempts=2) - if not result.get("ok"): - return public_probe_result(result) - - body = result.get("body", b"") - try: - payload = json.loads(body.decode("utf-8") if isinstance(body, bytes) else str(body)) - except Exception as exc: - result["ok"] = False - result["error"] = f"invalid json: {exc}" - return public_probe_result(result) - - if not isinstance(payload, dict): - result["ok"] = False - result["error"] = "json root is not object" - return public_probe_result(result) - - missing_keys = [key for key in required_keys if key not in payload] - mismatched = { - key: {"expected": expected, "actual": payload.get(key)} - for key, expected in expected_values.items() - if payload.get(key) != expected - } - if missing_keys: - result["ok"] = False - result["error"] = "missing json keys: " + ", ".join(missing_keys) - result["missing_keys"] = missing_keys - elif mismatched: - result["ok"] = False - result["error"] = "unexpected json values" - result["mismatched_values"] = mismatched - else: - result["json_keys"] = sorted(payload.keys()) - return public_probe_result(result) - - -def run_healthd(command: str, timeout_seconds: int) -> dict[str, Any]: - try: - proc = subprocess.run( - command, - shell=True, - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - timeout=timeout_seconds, - ) - except subprocess.TimeoutExpired as exc: - return { - "ok": False, - "returncode": None, - "error": f"timeout after {timeout_seconds}s", - "output_tail": (exc.stdout or "")[-1000:] if isinstance(exc.stdout, str) else "", - } - - payload: dict[str, Any] = {} - try: - payload = json.loads(proc.stdout or "{}") - except json.JSONDecodeError: - pass - return { - "ok": proc.returncode == 0 and bool(payload.get("ok")), - "returncode": proc.returncode, - "counts": payload.get("counts", {}), - "payload": payload if isinstance(payload, dict) else {}, - "output_tail": (proc.stdout or "")[-1000:], - } - - -def read_healthd_state(path: Path, max_age_seconds: int) -> dict[str, Any]: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - generated = parse_ts(str(payload["generated_at_utc"])) - age = max(0, int((now_utc() - generated).total_seconds())) - except Exception as exc: - return {"ok": False, "error": str(exc), "path": str(path)} - return { - "ok": bool(payload.get("ok")) and age <= max_age_seconds, - "counts": payload.get("counts", {}), - "age_seconds": age, - "path": str(path), - "payload": payload if isinstance(payload, dict) else {}, - } - - -def load_samples(path: Path, cutoff: datetime) -> list[dict[str, Any]]: - samples: list[dict[str, Any]] = [] - if not path.exists(): - return samples - try: - lines = path.read_text(encoding="utf-8").splitlines() - except PermissionError: - return samples - for line in lines: - if not line.strip(): - continue - try: - item = json.loads(line) - ts = parse_ts(str(item["ts"])) - except Exception: - continue - if ts >= cutoff: - samples.append(item) - return samples - - -def append_and_trim_sample(path: Path, sample: dict[str, Any], retention_seconds: int) -> list[dict[str, Any]]: - cutoff = parse_ts(sample["ts"]) - timedelta(seconds=retention_seconds) - samples = load_samples(path, cutoff) - samples.append(sample) - content = "".join(json.dumps(item, ensure_ascii=False, separators=(",", ":")) + "\n" for item in samples) - write_atomic(path, content) - chmod_if_possible(path, 0o644) - chown_like_parent_if_possible(path) - return samples - - -def summarize_window( - samples: list[dict[str, Any]], - *, - now: datetime, - window_seconds: int, - sample_interval_seconds: int, - target_percent: float, -) -> dict[str, Any]: - cutoff = now - timedelta(seconds=window_seconds) - window = [] - for item in samples: - try: - if parse_ts(str(item["ts"])) >= cutoff: - window.append(item) - except Exception: - continue - - total = len(window) - good = sum(1 for item in window if item.get("ok") is True) - bad = total - good - availability = round((good / total) * 100, 5) if total else None - observed_bad_seconds = bad * sample_interval_seconds - budget_seconds = int(window_seconds * ((100.0 - target_percent) / 100.0)) - budget_remaining_seconds = budget_seconds - observed_bad_seconds - status = "unknown" - if total: - status = "ok" if budget_remaining_seconds >= 0 else "burning" - return { - "window_seconds": window_seconds, - "samples": total, - "good_samples": good, - "bad_samples": bad, - "availability_percent": availability, - "target_percent": target_percent, - "observed_bad_seconds": observed_bad_seconds, - "budget_seconds": budget_seconds, - "budget_remaining_seconds": budget_remaining_seconds, - "status": status, - } - - -def render_summary_text(summary: dict[str, Any]) -> str: - lines = [ - "=== AW-RUS SLO ===", - f"Timestamp: {summary['generated_at_utc']}", - f"Target: {summary['target_percent']}%", - "", - ] - for name, data in summary["windows"].items(): - availability = data["availability_percent"] - availability_text = "n/a" if availability is None else f"{availability:.5f}%" - remaining = data["budget_remaining_seconds"] - lines.append( - f"{name}: {data['status']} availability={availability_text} " - f"samples={data['samples']} bad={data['bad_samples']} " - f"bad_seconds={data['observed_bad_seconds']} budget_remaining_seconds={remaining}" - ) - lines.append("") - lines.append(f"Current sample: {'OK' if summary['current_sample']['ok'] else 'FAIL'}") - for name, probe in summary["current_sample"].get("probes", {}).items(): - marker = "OK" if probe.get("ok") else "FAIL" - detail = probe.get("status", probe.get("error", "")) - lines.append(f"- {name}: {marker} {detail}") - return "\n".join(lines) - - -def build_sample(args: argparse.Namespace) -> dict[str, Any]: - ts = iso(now_utc()) - if args.healthd_mode == "run": - health = run_healthd(args.healthd_cmd, args.health_timeout_seconds) - else: - health = read_healthd_state(Path(args.healthd_state_file), args.healthd_state_max_age_seconds) - probes = { - "aw_webui_index": html_probe( - args.aw_webui_url, - args.http_timeout_seconds, - min_bytes=1000, - required_markers=("ActivityWatch", 'id="app"', "ru-patch-v5.js"), - ), - "worktime_today_html": html_probe( - args.worktime_today_html_url, - args.http_timeout_seconds, - min_bytes=5000, - required_markers=("AW-rus", ""), - ), - "worktime_management_html": html_probe( - args.worktime_management_html_url, - args.http_timeout_seconds, - min_bytes=5000, - required_markers=("AW-rus", ""), - ), - "worktime_today_csv": http_probe(args.worktime_today_csv_url, args.http_timeout_seconds), - "worktime_management_json": json_probe( - args.worktime_management_json_url, - args.http_timeout_seconds, - expected_values={"host": args.host}, - required_keys=("generated_at_utc", "host", "summary", "rows", "workday"), - ), - } - ok = bool(health.get("ok")) and all(probe.get("ok") for probe in probes.values()) - return { - "ts": ts, - "ok": ok, - "healthd_ok": bool(health.get("ok")), - "healthd_counts": health.get("counts", {}), - "probes": probes, - } - - -def main() -> int: - load_env_file(ENV_FILE) - parser = argparse.ArgumentParser(description="AW-RUS rolling SLO sampler") - parser.add_argument("--state-dir", default=env("AW_RUS_SLO_STATE_DIR", str(DEFAULT_STATE_DIR))) - parser.add_argument("--healthd-cmd", default=env("AW_RUS_SLO_HEALTHD_CMD", DEFAULT_HEALTHD_CMD)) - parser.add_argument("--healthd-mode", choices=["state", "run"], default=env("AW_RUS_SLO_HEALTHD_MODE", "state")) - parser.add_argument("--healthd-state-file", default=env("AW_RUS_SLO_HEALTHD_STATE_FILE", str(DEFAULT_HEALTHD_STATE_FILE))) - parser.add_argument("--healthd-state-max-age-seconds", type=int, default=int(env("AW_RUS_SLO_HEALTHD_STATE_MAX_AGE_SECONDS", "180"))) - parser.add_argument("--aw-base", default=env("AW_RUS_SLO_AW_BASE", env("AW_SERVER_URL", "http://127.0.0.1:5600"))) - parser.add_argument("--worktime-base", default=env("AW_RUS_SLO_WORKTIME_BASE", env("AW_RUS_HEALTH_WORKTIME_API", "http://127.0.0.1:5610"))) - parser.add_argument("--host", default=env("AW_RUS_SLO_HOST", env("AW_MONITORED_WINDOWS_HOSTNAME", "SHARKON2025"))) - parser.add_argument("--target-percent", type=float, default=float(env("AW_RUS_SLO_TARGET_PERCENT", "99.97"))) - parser.add_argument("--sample-interval-seconds", type=int, default=int(env("AW_RUS_SLO_SAMPLE_INTERVAL_SECONDS", "15"))) - parser.add_argument("--retention-days", type=int, default=int(env("AW_RUS_SLO_RETENTION_DAYS", "35"))) - parser.add_argument("--http-timeout-seconds", type=float, default=float(env("AW_RUS_SLO_HTTP_TIMEOUT_SECONDS", "15"))) - parser.add_argument("--health-timeout-seconds", type=int, default=int(env("AW_RUS_SLO_HEALTH_TIMEOUT_SECONDS", "90"))) - parser.add_argument("--json", action="store_true") - args = parser.parse_args() - - aw_base = args.aw_base.rstrip("/") - worktime_base = args.worktime_base.rstrip("/") - args.aw_webui_url = f"{aw_base}/" - args.worktime_today_html_url = f"{worktime_base}/reports/worktime/today?format=html&day=today&host={args.host}&allow_stale=1" - args.worktime_management_html_url = f"{worktime_base}/reports/worktime/management?format=html&day=today&host={args.host}&allow_stale=1" - args.worktime_today_csv_url = f"{worktime_base}/reports/worktime/today?format=csv&day=today&host={args.host}&allow_stale=1" - args.worktime_management_json_url = f"{worktime_base}/reports/worktime/management?format=json&day=today&host={args.host}&allow_stale=1" - - state_dir = Path(args.state_dir) - sample_path = state_dir / "aw-slo-samples.jsonl" - sample = build_sample(args) - samples = append_and_trim_sample( - sample_path, - sample, - retention_seconds=max(1, args.retention_days) * 86400, - ) - - generated_at = now_utc() - summary = { - "generated_at_utc": iso(generated_at), - "target_percent": args.target_percent, - "sample_interval_seconds": args.sample_interval_seconds, - "current_sample": sample, - "windows": { - "24h": summarize_window( - samples, - now=generated_at, - window_seconds=86400, - sample_interval_seconds=args.sample_interval_seconds, - target_percent=args.target_percent, - ), - "7d": summarize_window( - samples, - now=generated_at, - window_seconds=7 * 86400, - sample_interval_seconds=args.sample_interval_seconds, - target_percent=args.target_percent, - ), - "30d": summarize_window( - samples, - now=generated_at, - window_seconds=30 * 86400, - sample_interval_seconds=args.sample_interval_seconds, - target_percent=args.target_percent, - ), - }, - } - summary_json_path = state_dir / "aw-slo-summary.json" - summary_txt_path = state_dir / "aw-slo-summary.txt" - write_atomic(summary_json_path, json.dumps(summary, ensure_ascii=False, indent=2) + "\n") - text = render_summary_text(summary) - write_atomic(summary_txt_path, text + "\n") - chmod_if_possible(summary_json_path, 0o644) - chmod_if_possible(summary_txt_path, 0o644) - chown_like_parent_if_possible(summary_json_path) - chown_like_parent_if_possible(summary_txt_path) - if args.json: - print(json.dumps(summary, ensure_ascii=False, indent=2)) - else: - print(text) - return 0 if sample["ok"] else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service b/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service index a1bd724..0fbba5e 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service +++ b/install-kit-awindows-20260427-211240/aw-server/aw-slo-monitor.service @@ -6,7 +6,7 @@ Wants=network-online.target activitywatch-server.service aw-worktime-api.service [Service] Type=oneshot EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/bin/python3 /usr/local/bin/aw-slo-monitor.py +ExecStart=/usr/local/bin/aw-slo-monitor-rust SuccessExitStatus=1 User=activitywatch Group=activitywatch diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py deleted file mode 100644 index caba965..0000000 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.py +++ /dev/null @@ -1,3039 +0,0 @@ -#!/usr/bin/env python3 -import base64 -import csv -import hashlib -import html -import io -import importlib.util -import json -import os -import sys -import tempfile -import threading -import time -import urllib.request -from datetime import datetime, timezone, timedelta -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from urllib.parse import parse_qs, urlencode, urlparse -from zoneinfo import ZoneInfo - - -def build_aw_api_base(raw_url): - url = (raw_url or "http://127.0.0.1:5600").strip().rstrip("/") - if url.endswith("/api/0"): - return url - return url + "/api/0" - - -AW_SERVER_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600") -AW = build_aw_api_base(AW_SERVER_URL) -REPORT_TZ = ZoneInfo(os.environ.get("AW_WORKTIME_TZ", "Europe/Moscow")) -IOC_DIR = os.environ.get("AW_DLP_IOC_DIR", "/opt/activitywatch/dlp-ioc/output") -DEFAULT_HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025").strip() or "SHARKON2025" -DEFAULT_SAMPLE_SECONDS = max(1.0, float(os.environ.get("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", "30"))) -MAX_SAMPLE_SECONDS = max(DEFAULT_SAMPLE_SECONDS, float(os.environ.get("AW_WORKTIME_MAX_SAMPLE_SECONDS", "300"))) -LISTEN_HOST = os.environ.get("AW_WORKTIME_LISTEN_HOST", "0.0.0.0") -LISTEN_PORT = int(os.environ.get("AW_WORKTIME_PORT", "5610")) -WORKDAY_START_HOUR = int(os.environ.get("AW_WORKTIME_MANAGER_START_HOUR", "9")) -WORKDAY_END_HOUR = int(os.environ.get("AW_WORKTIME_MANAGER_END_HOUR", "18")) -MANAGER_TARGET_COVERAGE_PCT = max(1, min(100, int(os.environ.get("AW_WORKTIME_MANAGER_TARGET_COVERAGE_PCT", "75")))) -MANAGER_LOW_COVERAGE_PCT = max(1, min(100, int(os.environ.get("AW_WORKTIME_MANAGER_LOW_COVERAGE_PCT", "35")))) -MANAGER_LATE_START_GRACE_MINUTES = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_LATE_START_GRACE_MINUTES", "60"))) -MANAGER_EARLY_FINISH_GRACE_MINUTES = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_EARLY_FINISH_GRACE_MINUTES", "90"))) -MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS = max(60, int(os.environ.get("AW_WORKTIME_MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS", "900"))) -MANAGER_WEB_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_WEB_SOURCE_MAX_AGE_SECONDS", "259200"))) -MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS", "604800"))) -MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS", "172800"))) -MANAGER_TREND_DAYS = max(3, min(31, int(os.environ.get("AW_WORKTIME_MANAGER_TREND_DAYS", "7")))) -MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS", "300"))) -MANAGER_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_MANAGER_CACHE_DIR", "/var/lib/activitywatch/worktime-cache")) -MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", "/etc/activitywatch/worktime-manager-aliases.json")) -MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()} -EVENTS_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", "30"))) -WORKTIME_EVENTS_LIMIT = max(1000, int(os.environ.get("AW_WORKTIME_EVENTS_LIMIT", "50000"))) -AW_HTTP_TIMEOUT_SECONDS = max(0.5, float(os.environ.get("AW_WORKTIME_AW_HTTP_TIMEOUT_SECONDS", "5"))) -REPORT_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_REPORT_CACHE_TTL_SECONDS", "60"))) -REPORT_STALE_TTL_SECONDS = max(REPORT_CACHE_TTL_SECONDS, int(os.environ.get("AW_WORKTIME_REPORT_STALE_TTL_SECONDS", "900"))) -REPORT_DISK_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_REPORT_DISK_CACHE_DIR", "/var/lib/activitywatch/worktime-report-cache")) -REPORT_DISK_STALE_TTL_SECONDS = max(REPORT_STALE_TTL_SECONDS, int(os.environ.get("AW_WORKTIME_REPORT_DISK_STALE_TTL_SECONDS", "86400"))) -REPORT_BUSY_WAIT_SECONDS = max(0.0, float(os.environ.get("AW_WORKTIME_REPORT_BUSY_WAIT_SECONDS", "8"))) -REPORT_BUSY_WAIT_INTERVAL_SECONDS = max(0.05, float(os.environ.get("AW_WORKTIME_REPORT_BUSY_WAIT_INTERVAL_SECONDS", "0.2"))) -REPORT_MAX_CONCURRENT = max(1, int(os.environ.get("AW_WORKTIME_REPORT_MAX_CONCURRENT", "1"))) -TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS", "180"))) -TRUE_ACTIVE_MAX_EVENT_SECONDS = max(30, int(os.environ.get("AW_WORKTIME_TRUE_ACTIVE_MAX_EVENT_SECONDS", "600"))) -MODULE_PATH = Path(__file__).resolve() -_ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}} -_EVENTS_CACHE_LOCK = threading.Lock() -_EVENTS_CACHE = {} -_REPORT_CACHE_LOCK = threading.Lock() -_REPORT_RESPONSE_CACHE = {} -_REPORT_REFRESHING = set() -_REPORT_BUILD_SEMAPHORE = threading.BoundedSemaphore(REPORT_MAX_CONCURRENT) -_MANAGEMENT_BUILD_LOCKS_LOCK = threading.Lock() -_MANAGEMENT_BUILD_LOCKS = {} - - -def get(u): - with urllib.request.urlopen(u, timeout=AW_HTTP_TIMEOUT_SECONDS) as r: - return json.loads(r.read().decode()) - - -def log_warning(message): - print(f"[aw-worktime-api] {message}", file=sys.stderr, flush=True) - - -def pts(s): - return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc) - - -def to_iso_utc(dt): - return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -def parse_iso_utc(value): - if not value: - return None - return pts(value) - - -def hhmm(total_seconds): - total_seconds = max(0, int(total_seconds)) - return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60) - - -def human_duration_ru(total_seconds): - total_seconds = max(0, int(total_seconds)) - hours = total_seconds // 3600 - minutes = (total_seconds % 3600) // 60 - if hours and minutes: - return f"{hours} ч {minutes} мин" - if hours: - return f"{hours} ч" - if minutes: - return f"{minutes} мин" - return f"{total_seconds} сек" - - -def now_utc(): - return datetime.now(timezone.utc) - - -def worktime_health_payload(): - return { - "ok": True, - "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), - "report_timezone": str(REPORT_TZ), - "default_host": DEFAULT_HOST, - "aw_api_base": AW, - } - - -def age_seconds(ts, now=None): - if ts is None: - return None - ref = now or now_utc() - return max(0, int((ref - ts).total_seconds())) - - -def write_atomic_json(path, payload): - path.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: - json.dump(payload, handle, ensure_ascii=False, indent=2) - handle.write("\n") - tmp_name = handle.name - os.replace(tmp_name, path) - - -def safe_slug(value): - text = str(value or "").strip().lower() - slug = [] - for char in text: - if char.isalnum(): - slug.append(char) - else: - slug.append("-") - normalized = "".join(slug).strip("-") - while "--" in normalized: - normalized = normalized.replace("--", "-") - return normalized or "user" - - -def _normalize_identity_key(value): - return str(value or "").strip().lower() - - -def _default_display_name(user, user_id): - base = str(user_id or user or "").strip() - if "\\" in base: - base = base.split("\\", 1)[1] - if not base: - base = str(user or "").strip() - if base and base.isascii() and base.lower() == base and any(ch.isalpha() for ch in base): - return base.upper() - return base or str(user or "").strip() - - -def _load_manager_directory(): - try: - stat = MANAGER_ALIASES_JSON.stat() - except FileNotFoundError: - _ALIASES_CACHE["mtime"] = None - _ALIASES_CACHE["users"] = {} - _ALIASES_CACHE["owners"] = {} - _ALIASES_CACHE["raw"] = {} - return {"users": {}, "owners": {}, "raw": {}} - mtime = stat.st_mtime - if _ALIASES_CACHE["mtime"] == mtime: - return { - "users": _ALIASES_CACHE["users"], - "owners": _ALIASES_CACHE["owners"], - "raw": _ALIASES_CACHE["raw"], - } - try: - raw = json.loads(MANAGER_ALIASES_JSON.read_text(encoding="utf-8")) - except Exception as exc: - log_warning(f"failed to load manager aliases from {MANAGER_ALIASES_JSON}: {exc}") - raw = {} - users_payload = {} - owners_payload = {} - if isinstance(raw, dict): - users = raw.get("users", raw) - if isinstance(users, dict): - for key, value in users.items(): - norm_key = _normalize_identity_key(key) - if not norm_key: - continue - if isinstance(value, str): - users_payload[norm_key] = {"display_name": value} - elif isinstance(value, dict): - users_payload[norm_key] = dict(value) - owners = raw.get("owners", {}) - if isinstance(owners, dict): - for key, value in owners.items(): - norm_key = _normalize_identity_key(key) - if not norm_key: - continue - if isinstance(value, str): - owners_payload[norm_key] = {"display_name": value} - elif isinstance(value, dict): - owners_payload[norm_key] = dict(value) - _ALIASES_CACHE["mtime"] = mtime - _ALIASES_CACHE["users"] = users_payload - _ALIASES_CACHE["owners"] = owners_payload - _ALIASES_CACHE["raw"] = raw if isinstance(raw, dict) else {} - return { - "users": users_payload, - "owners": owners_payload, - "raw": _ALIASES_CACHE["raw"], - } - - -def load_manager_aliases(): - return _load_manager_directory()["users"] - - -def load_manager_owners(): - return _load_manager_directory()["owners"] - - -def resolve_user_alias(user, user_id, host): - aliases = load_manager_aliases() - candidates = [ - _normalize_identity_key(user_id), - _normalize_identity_key(f"{resolve_host(host)}\\{user}"), - _normalize_identity_key(user), - ] - alias = {} - for candidate in candidates: - if candidate and candidate in aliases: - alias = dict(aliases[candidate]) - break - display_name = str(alias.get("display_name") or alias.get("name") or _default_display_name(user, user_id)).strip() - manager_owner = str(alias.get("manager") or alias.get("owner") or display_name).strip() or display_name - department = str(alias.get("department") or "").strip() - role = str(alias.get("role") or "").strip() - notes = str(alias.get("notes") or "").strip() - canonical_user_id = str(alias.get("canonical_user_id") or user_id or "").strip() - exclude = bool(alias.get("exclude")) or _normalize_identity_key(user) in MANAGER_EXCLUDE_USERS or _normalize_identity_key(display_name) in MANAGER_EXCLUDE_USERS - return { - "display_name": display_name, - "manager_owner": manager_owner, - "department": department, - "role": role, - "notes": notes, - "canonical_user_id": canonical_user_id, - "exclude": exclude, - } - - -def resolve_owner_profile(owner_name): - owner_name = normalize_management_filter(owner_name) - if not owner_name: - owner_name = "unassigned" - owners = load_manager_owners() - profile = dict(owners.get(_normalize_identity_key(owner_name), {})) - display_name = str(profile.get("display_name") or profile.get("name") or owner_name).strip() or owner_name - title = str(profile.get("title") or profile.get("role") or "").strip() - department = str(profile.get("department") or "").strip() - escalation_to = str(profile.get("escalation_to") or profile.get("escalate_to") or "").strip() - contact = str(profile.get("contact") or profile.get("telegram") or profile.get("email") or "").strip() - notes = str(profile.get("notes") or "").strip() - return { - "owner_name": owner_name, - "display_name": display_name, - "title": title, - "department": department, - "escalation_to": escalation_to, - "contact": contact, - "notes": notes, - } - - -def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS): - try: - seconds = float(value) - except Exception: - seconds = float(fallback) - if seconds <= 0: - seconds = float(fallback) - return min(seconds, MAX_SAMPLE_SECONDS) - - -def resolve_host(request_host=None): - host = (request_host or DEFAULT_HOST).strip() - if not host: - host = DEFAULT_HOST - return host - - -def get_sessions_bucket_id(host): - return f"aw-worktime-sessions_{resolve_host(host)}" - - -def get_management_build_lock(host, report_date): - key = (resolve_host(host), report_date.isoformat()) - with _MANAGEMENT_BUILD_LOCKS_LOCK: - lock = _MANAGEMENT_BUILD_LOCKS.get(key) - if lock is None: - lock = threading.Lock() - _MANAGEMENT_BUILD_LOCKS[key] = lock - return lock - - -def resolve_report_date(day=None, date_text=None): - now_local = datetime.now(REPORT_TZ) - if date_text: - return datetime.strptime(date_text, "%Y-%m-%d").date() - if day == "yesterday": - return (now_local - timedelta(days=1)).date() - return now_local.date() - - -def get_report_bounds(report_date): - start_local = datetime(report_date.year, report_date.month, report_date.day, tzinfo=REPORT_TZ) - end_local = start_local + timedelta(days=1) - timedelta(seconds=1) - start = start_local.astimezone(timezone.utc) - end = end_local.astimezone(timezone.utc) - end_exclusive = end + timedelta(seconds=1) - return { - "start_local": start_local, - "end_local": end_local, - "start": start, - "end": end, - "end_exclusive": end_exclusive, - } - - -def get_workday_bounds(report_date): - start_local = datetime(report_date.year, report_date.month, report_date.day, WORKDAY_START_HOUR, 0, 0, tzinfo=REPORT_TZ) - end_local = datetime(report_date.year, report_date.month, report_date.day, WORKDAY_END_HOUR, 0, 0, tzinfo=REPORT_TZ) - if end_local <= start_local: - end_local = start_local + timedelta(hours=8) - return { - "start_local": start_local, - "end_local": end_local, - "duration_seconds": int((end_local - start_local).total_seconds()), - } - - -def _is_machine_user(user: str): - u = (user or "").strip().lower() - return u.endswith("$") or u in {"system", "localservice", "networkservice"} - - -def _is_active_sample(data: dict): - state = str(data.get("state") or "").strip().lower() - if isinstance(data.get("active"), bool) and data.get("active"): - return True - if ("актив" in state) or (state == "active"): - return True - if state == "unknown": - try: - sid = int(data.get("sessionId")) - except Exception: - sid = -1 - user = str(data.get("username") or "").strip() - session_name = str(data.get("sessionName") or "").strip().lower() - if sid > 0 and user and (not _is_machine_user(user)) and (session_name.startswith("rdp-") or session_name == "console"): - return True - return False - - -def _normalize_user_id(data, host, username): - user_id = str(data.get("userId") or "").strip() - if user_id: - left, sep, right = user_id.partition("\\") - if sep and right: - return f"{resolve_host(host)}\\{right}" - return user_id - return f"{resolve_host(host)}\\{username}" - - -def _event_sample_seconds(event, next_same_session_ts=None): - data = event.get("data") or {} - for key in ("sampleSeconds", "pollSeconds"): - value = data.get(key) - try: - if float(value) > 0: - return clamp_seconds(value) - except Exception: - pass - try: - duration = float(event.get("duration") or 0.0) - except Exception: - duration = 0.0 - if duration > 0: - return clamp_seconds(duration) - if next_same_session_ts is not None: - delta = (next_same_session_ts - event["_ts"]).total_seconds() - if delta > 0: - return clamp_seconds(delta) - return clamp_seconds(DEFAULT_SAMPLE_SECONDS) - - -def _merge_intervals(intervals): - if not intervals: - return [] - ordered = sorted(intervals, key=lambda item: item[0]) - merged = [ordered[0]] - for start, end in ordered[1:]: - last_start, last_end = merged[-1] - if start <= last_end: - if end > last_end: - merged[-1] = (last_start, end) - continue - merged.append((start, end)) - return merged - - -def _overlap_interval(left, right): - start = max(left[0], right[0]) - end = min(left[1], right[1]) - if end <= start: - return None - return start, end - - -def _interval_contains(interval, ts): - return interval[0] <= ts < interval[1] - - -def _event_timestamp(event): - try: - return pts(event.get("timestamp")) - except Exception: - return None - - -def _event_duration_seconds(event, default_seconds=DEFAULT_SAMPLE_SECONDS): - try: - duration = float(event.get("duration") or 0.0) - except Exception: - duration = 0.0 - if duration <= 0: - duration = default_seconds - return max(1.0, min(float(TRUE_ACTIVE_MAX_EVENT_SECONDS), duration)) - - -def _normalize_app_name(app, title=""): - app_raw = str(app or "").strip() - app_l = app_raw.lower() - title_raw = str(title or "").strip() - if app_l.startswith(("1cv8", "1cestart")): - return "1С" - if app_l in {"chrome.exe", "google chrome"} or "google chrome" in title_raw.lower(): - return "Chrome" - if app_l in {"msedge.exe", "microsoft edge"} or "microsoft edge" in title_raw.lower(): - return "Edge" - if app_l in {"browser.exe", "browser"} or "яндекс" in title_raw.lower(): - return "Яндекс Браузер" - if app_l in {"excel.exe"}: - return "Excel" - if app_l in {"winword.exe"}: - return "Word" - if app_l in {"powerpnt.exe"}: - return "PowerPoint" - if app_l in {"outlook.exe"}: - return "Outlook" - if app_l in {"explorer.exe"}: - return "Проводник" - if app_l in {"totalcmd.exe", "totalcmd64.exe"}: - return "Total Commander" - if app_l in {"cmd.exe"}: - return "Command Prompt" - if app_l in {"powershell.exe", "pwsh.exe"}: - return "PowerShell" - if app_l in {"acrord32.exe", "acrobat.exe"}: - return "Adobe Acrobat Reader" - if app_l in {"windowsterminal.exe", "windowsterminal"}: - return "Windows Terminal" - if app_raw: - return app_raw[:-4] if app_l.endswith(".exe") else app_raw - if title_raw: - return title_raw - return "Неизвестное приложение" - - -def _event_context(event): - data = event.get("data") or {} - for key in ("title", "url", "path", "filePath", "targetPath", "windowTitle", "foregroundTitle", "signalType"): - value = str(data.get(key) or "").strip() - if value: - return value - return "активность" - - -def _is_not_afk_event(event): - data = event.get("data") or {} - status = str(data.get("status") or data.get("state") or "").strip().lower() - return status in {"not-afk", "not_afk", "active", "активно"} - - -def _is_real_evidence_event(event): - data = event.get("data") or {} - signal_type = str(data.get("signalType") or data.get("type") or "").strip().lower() - if signal_type in {"collector_health", "self_test", "heartbeat", "health"}: - return False - if data.get("url") or data.get("title") or data.get("path") or data.get("filePath") or data.get("targetPath"): - return True - if signal_type: - return True - return False - - -def _events_for_bounds(events, start, end): - result = [] - for event in events: - ts = _event_timestamp(event) - if ts is None or ts < start or ts > end: - continue - result.append((ts, event)) - result.sort(key=lambda item: item[0]) - return result - - -def _build_window_intervals(window_events, start, end): - intervals = [] - previous_key = None - for ts, event in _events_for_bounds(window_events, start, end): - data = event.get("data") or {} - app = str(data.get("app") or data.get("process") or data.get("processName") or "").strip() - title = str(data.get("title") or data.get("windowTitle") or "").strip() - if not app and not title: - continue - duration = _event_duration_seconds(event, default_seconds=DEFAULT_SAMPLE_SECONDS) - interval = (max(ts, start), min(ts + timedelta(seconds=duration), end + timedelta(seconds=1))) - if interval[1] <= interval[0]: - continue - app_name = _normalize_app_name(app, title) - current_key = (app_name, title) - title_changed = previous_key is not None and current_key != previous_key - previous_key = current_key - intervals.append( - { - "app": app_name, - "raw_app": app, - "title": title, - "start": interval[0], - "end": interval[1], - "title_changed": title_changed, - "timestamp": ts, - } - ) - return intervals - - -def _build_not_afk_intervals(afk_events, start, end): - intervals = [] - for ts, event in _events_for_bounds(afk_events, start, end): - if not _is_not_afk_event(event): - continue - duration = _event_duration_seconds(event, default_seconds=5) - interval = (max(ts, start), min(ts + timedelta(seconds=duration), end + timedelta(seconds=1))) - if interval[1] > interval[0]: - intervals.append(interval) - return _merge_intervals(intervals) - - -def _find_window_at(window_intervals, ts): - for item in window_intervals: - if item["start"] <= ts < item["end"]: - return item - return None - - -def _add_app_evidence(evidence_by_app, app, ts, context): - evidence_by_app.setdefault(app, []).append((ts, str(context or "").strip() or "активность")) - - -def build_true_active_apps_from_events(window_events, afk_events, evidence_events_by_bucket, start, end): - window_intervals = _build_window_intervals(window_events, start, end) - not_afk_intervals = _build_not_afk_intervals(afk_events, start, end) - evidence_by_app = {} - - for item in window_intervals: - if item["title_changed"]: - _add_app_evidence(evidence_by_app, item["app"], item["timestamp"], item["title"] or item["raw_app"]) - - for events in evidence_events_by_bucket.values(): - for ts, event in _events_for_bounds(events, start, end): - if not _is_real_evidence_event(event): - continue - window = _find_window_at(window_intervals, ts) - if window is None: - continue - _add_app_evidence(evidence_by_app, window["app"], ts, _event_context(event)) - - rows = [] - evidence_delta = timedelta(seconds=TRUE_ACTIVE_EVIDENCE_WINDOW_SECONDS) - for app in sorted({item["app"] for item in window_intervals} | set(evidence_by_app)): - app_evidence = sorted(evidence_by_app.get(app, []), key=lambda item: item[0]) - if not app_evidence: - continue - evidence_windows = _merge_intervals([(ts - evidence_delta, ts + evidence_delta) for ts, _context in app_evidence]) - proved_intervals = [] - for window in [item for item in window_intervals if item["app"] == app]: - base = (window["start"], window["end"]) - for afk_interval in not_afk_intervals: - active_overlap = _overlap_interval(base, afk_interval) - if active_overlap is None: - continue - for evidence_interval in evidence_windows: - proved = _overlap_interval(active_overlap, evidence_interval) - if proved is not None: - proved_intervals.append(proved) - proved_intervals = _merge_intervals(proved_intervals) - proved_seconds = int(sum((right - left).total_seconds() for left, right in proved_intervals)) - if proved_seconds <= 0: - continue - last_ts, last_context = app_evidence[-1] - rows.append( - { - "application": app, - "proved_work_seconds": proved_seconds, - "proved_work_hhmm": hhmm(proved_seconds), - "proved_work_human": human_duration_ru(proved_seconds), - "last_action_utc": to_iso_utc(last_ts), - "last_action_local": last_ts.astimezone(REPORT_TZ).strftime("%H:%M"), - "last_action": last_context, - "evidence_events": len(app_evidence), - } - ) - rows.sort(key=lambda item: (-item["proved_work_seconds"], item["application"].lower())) - return rows - - -def build_true_active_apps(host, report_date): - bounds = get_report_bounds(report_date) - host = resolve_host(host) - window_events = fetch_bucket_events(f"aw-watcher-window_{host}", host) or fetch_bucket_events(f"aw-rdp-window_{host}", host) - afk_events = fetch_bucket_events(f"aw-watcher-afk_{host}", host) or fetch_bucket_events(f"aw-rdp-afk_{host}", host) - evidence_events_by_bucket = {} - for bucket_id in ( - f"aw-file-operations_{host}", - f"aw-dlp-endpoint-signals_{host}", - f"aw-watcher-web-chrome_{host}", - f"aw-watcher-web-edge_{host}", - f"aw-detmir-web-category_{host}", - ): - evidence_events_by_bucket[bucket_id] = fetch_bucket_events(bucket_id, host) - return build_true_active_apps_from_events( - window_events, - afk_events, - evidence_events_by_bucket, - bounds["start"], - bounds["end"], - ) - - -def _collect_user_rows(events, start, end, host): - end_exclusive = end + timedelta(seconds=1) - by_user = {} - by_identity = {} - - for event in events: - ts = pts(event.get("timestamp")) - if ts < start or ts > end: - continue - data = event.get("data") or {} - username = str(data.get("username") or "").strip() - if not username: - continue - session_id = str(data.get("sessionId") or "").strip() or "unknown" - event_copy = { - "_ts": ts, - "data": data, - "duration": event.get("duration"), - } - by_identity.setdefault((username, session_id), []).append(event_copy) - - for (username, session_id), samples in by_identity.items(): - ordered = sorted(samples, key=lambda item: item["_ts"]) - for idx, sample in enumerate(ordered): - data = sample["data"] - active = _is_active_sample(data) - next_ts = ordered[idx + 1]["_ts"] if idx + 1 < len(ordered) else None - sample_seconds = _event_sample_seconds(sample, next_ts) - row = by_user.setdefault( - username, - { - "user": username, - "user_id": _normalize_user_id(data, host, username), - "samples_count": 0, - "active_samples": 0, - "session_ids": set(), - "intervals": [], - }, - ) - row["samples_count"] += 1 - row["session_ids"].add(session_id) - if active: - row["active_samples"] += 1 - interval_start = sample["_ts"] - interval_end = min(sample["_ts"] + timedelta(seconds=sample_seconds), end_exclusive) - if interval_end > interval_start: - row["intervals"].append((interval_start, interval_end)) - return by_user - - -def aggregate_rows(events, start, end, host): - by_user = _collect_user_rows(events, start, end, host) - return _build_rows_from_user_map(by_user, start, end, include_intervals=False) - - -def aggregate_rows_with_intervals(events, start, end, host): - by_user = _collect_user_rows(events, start, end, host) - return _build_rows_from_user_map(by_user, start, end, include_intervals=True) - - -def _build_rows_from_user_map(by_user, start, end, include_intervals): - rows = [] - full_range = int((end - start).total_seconds()) + 1 - for username in sorted(by_user): - row = by_user[username] - merged = _merge_intervals(row["intervals"]) - active_seconds = int(sum((end_dt - start_dt).total_seconds() for start_dt, end_dt in merged)) - active_seconds = min(active_seconds, full_range) - first_activity = to_iso_utc(merged[0][0]) if merged else "" - last_activity = to_iso_utc(merged[-1][1]) if merged else "" - rows.append( - { - "user": row["user"], - "user_id": row["user_id"], - "active_seconds": active_seconds, - "active_hhmm": hhmm(active_seconds), - "first_activity": first_activity, - "last_activity": last_activity, - "idle_seconds": max(0, full_range - active_seconds), - "sessions_count": len(row["session_ids"]), - "samples_count": row["samples_count"], - "active_samples": row["active_samples"], - } - ) - if include_intervals: - rows[-1]["_intervals"] = merged - return rows - - -def aggregate_hourly_rows(events, start, end, host): - by_user = _collect_user_rows(events, start, end, host) - rows = [] - for username in sorted(by_user): - row = by_user[username] - merged = _merge_intervals(row["intervals"]) - per_bucket = {} - for interval_start, interval_end in merged: - cursor = interval_start - while cursor < interval_end: - bucket_local = cursor.astimezone(REPORT_TZ).replace(minute=0, second=0, microsecond=0) - bucket_start = bucket_local.astimezone(timezone.utc) - bucket_end = (bucket_local + timedelta(hours=1)).astimezone(timezone.utc) - overlap_start = max(interval_start, bucket_start) - overlap_end = min(interval_end, bucket_end) - if overlap_end > overlap_start: - key = bucket_start - per_bucket[key] = per_bucket.get(key, 0) + int((overlap_end - overlap_start).total_seconds()) - cursor = bucket_end - - for bucket_start in sorted(per_bucket): - active_seconds = per_bucket[bucket_start] - if active_seconds <= 0: - continue - bucket_local = bucket_start.astimezone(REPORT_TZ) - rows.append( - { - "user": row["user"], - "user_id": row["user_id"], - "bucket_start_utc": to_iso_utc(bucket_start), - "bucket_start_local": bucket_local.isoformat(), - "report_date": bucket_local.date().isoformat(), - "hour_local": bucket_local.strftime("%H:00"), - "active_seconds": active_seconds, - "active_hhmm": hhmm(active_seconds), - } - ) - return rows - - -def fetch_events_for_date(host, report_date): - bounds = get_report_bounds(report_date) - bucket_id = get_sessions_bucket_id(host) - events = fetch_bucket_events(bucket_id, host, start=bounds["start"], end=bounds["end"]) - return bounds, events - - -def fetch_bucket_events(bucket_id, host, start=None, end=None): - now = now_utc() - cache_key = bucket_id - params = {"limit": WORKTIME_EVENTS_LIMIT} - if start is not None and end is not None: - params["start"] = to_iso_utc(start) - params["end"] = to_iso_utc(end) - cache_key = f"{bucket_id}|{params['start']}|{params['end']}" - if EVENTS_CACHE_TTL_SECONDS > 0: - with _EVENTS_CACHE_LOCK: - cached = _EVENTS_CACHE.get(cache_key) - if cached is not None and age_seconds(cached["stored_at"], now=now) <= EVENTS_CACHE_TTL_SECONDS: - return cached["events"] - try: - get(f"{AW}/buckets/{bucket_id}") - except Exception: - log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}") - return [] - try: - events = get(f"{AW}/buckets/{bucket_id}/events?{urlencode(params)}") - except Exception: - log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}") - return [] - if EVENTS_CACHE_TTL_SECONDS > 0: - with _EVENTS_CACHE_LOCK: - _EVENTS_CACHE[cache_key] = {"stored_at": now, "events": events} - return events - - -def build_report_summary(rows): - if not rows: - return { - "users_count": 0, - "total_active_seconds": 0, - "total_active_hhmm": "00:00", - "first_activity": "", - "last_activity": "", - "top_user": "", - "top_user_active_hhmm": "00:00", - } - - total_active_seconds = sum(int(row.get("active_seconds", 0) or 0) for row in rows) - first_values = [row.get("first_activity") for row in rows if row.get("first_activity")] - last_values = [row.get("last_activity") for row in rows if row.get("last_activity")] - top_row = max(rows, key=lambda row: int(row.get("active_seconds", 0) or 0)) - return { - "users_count": len(rows), - "total_active_seconds": total_active_seconds, - "total_active_hhmm": hhmm(total_active_seconds), - "first_activity": min(first_values) if first_values else "", - "last_activity": max(last_values) if last_values else "", - "top_user": top_row.get("user", ""), - "top_user_active_hhmm": top_row.get("active_hhmm", "00:00"), - } - - -def latest_bucket_event(bucket_id): - try: - events = get(f"{AW}/buckets/{bucket_id}/events?limit=1") - except Exception: - events = None - if isinstance(events, list) and events: - valid = [item for item in events if isinstance(item, dict)] - if valid: - valid.sort(key=lambda item: item.get("timestamp") or "", reverse=True) - return valid[0] - try: - bucket = get(f"{AW}/buckets/{bucket_id}") - except Exception: - return None - end = ((bucket or {}).get("metadata") or {}).get("end") - if end: - return {"timestamp": end, "duration": 0, "data": {"source": "bucket_metadata"}} - return None - - -def _priority_rank(priority): - return {"critical": 0, "high": 1, "medium": 2, "low": 3}.get(priority, 9) - - -def _clamp_pct(value): - return round(min(100.0, max(0.0, float(value))), 2) - - -def _action(action_id, priority, owner, deadline_hint, reason, recommended_action, *, user_id="", evidence=None): - return { - "action_id": action_id, - "priority": priority, - "owner": owner, - "user_id": user_id, - "deadline_hint": deadline_hint, - "reason": reason, - "recommended_action": recommended_action, - "evidence": evidence or {}, - } - - -def build_executive_summary(summary, actions, sources): - critical = [action for action in actions if action.get("priority") == "critical"] - high = [action for action in actions if action.get("priority") == "high"] - stale_sources = [source for source in sources if source.get("status") != "ok"] - if critical: - portfolio_state = "critical" - headline = f"Есть {len(critical)} критичных вопроса, требующих решения сегодня." - elif high: - portfolio_state = "attention" - headline = f"Критичных провалов нет, но есть {len(high)} вопроса повышенного внимания." - elif summary.get("portfolio_coverage_pct", 0.0) < MANAGER_TARGET_COVERAGE_PCT: - portfolio_state = "attention" - headline = "Покрытие ниже целевого порога, но явных критичных кейсов не найдено." - else: - portfolio_state = "stable" - headline = "Критичных отклонений не найдено, рабочий день идёт в пределах нормы." - - message_parts = [ - f"Активны {summary.get('active_users', 0)} из {summary.get('users_count', 0)} сотрудников.", - f"Покрытие рабочего окна {summary.get('portfolio_coverage_pct', 0.0)}%.", - ] - if stale_sources: - message_parts.append(f"Есть {len(stale_sources)} проблем(ы) со свежестью источников.") - message = " ".join(message_parts) - - focus_items = [] - for action in actions[:5]: - focus_items.append( - { - "priority": action["priority"], - "owner": action["owner"], - "title": action["action_id"], - "reason": action["reason"], - "recommended_action": action["recommended_action"], - } - ) - stale_items = [] - for source in stale_sources[:3]: - stale_items.append( - { - "source_id": source["source_id"], - "label": source["label"], - "status": source["status_label"], - "summary": source["summary"], - } - ) - return { - "portfolio_state": portfolio_state, - "headline": headline, - "message": message, - "focus_items": focus_items, - "stale_sources": stale_items, - } - - -def _empty_rollup(name): - return { - "name": name or "unassigned", - "users_count": 0, - "active_users": 0, - "inactive_users": 0, - "below_target_users": 0, - "workday_total_active_seconds": 0, - "workday_total_active_hhmm": "00:00", - "portfolio_coverage_pct": 0.0, - "actions_count": 0, - "critical_actions_count": 0, - "high_actions_count": 0, - "medium_actions_count": 0, - "low_actions_count": 0, - "users": [], - } - - -def normalize_management_filter(value): - if value is None: - return "" - return " ".join(str(value).split()).strip() - - -def _filter_key(value): - return normalize_management_filter(value).casefold() - - -def _matches_management_filters(alias, owner_filter="", department_filter=""): - if owner_filter and _filter_key(alias.get("manager_owner")) != _filter_key(owner_filter): - return False - if department_filter and _filter_key(alias.get("department")) != _filter_key(department_filter): - return False - return True - - -def _action_department(action): - evidence = action.get("evidence") or {} - department = normalize_management_filter(evidence.get("department")) - if department: - return department - if normalize_management_filter(action.get("owner")) == "ops": - return "Инфраструктура" - return "" - - -def filter_management_actions(actions, rows, owner_filter="", department_filter=""): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - if not owner_filter and not department_filter: - return list(actions) - row_user_ids = { - normalize_management_filter(row.get("canonical_user_id") or row.get("user_id")) - for row in rows - if normalize_management_filter(row.get("canonical_user_id") or row.get("user_id")) - } - filtered = [] - for action in actions: - if owner_filter and _filter_key(action.get("owner")) != _filter_key(owner_filter): - continue - if department_filter: - action_department = _action_department(action) - if action_department: - if _filter_key(action_department) != _filter_key(department_filter): - continue - else: - user_id = normalize_management_filter(action.get("user_id")) - if not user_id or user_id not in row_user_ids: - continue - filtered.append(action) - return filtered - - -def _row_matches_payload_filters(row, owner_filter="", department_filter=""): - if owner_filter and _filter_key(row.get("manager_owner")) != _filter_key(owner_filter): - return False - if department_filter and _filter_key(row.get("department")) != _filter_key(department_filter): - return False - return True - - -def summarize_management_rows(rows, actions, workday): - expected_seconds_per_user = int(workday.get("expected_seconds_per_user", 0) or 0) - workday_total_active_seconds = sum(int(row.get("workday_active_seconds", 0) or 0) for row in rows) - calendar_total_active_seconds = sum(int(row.get("calendar_active_seconds", 0) or 0) for row in rows) - active_users = sum(1 for row in rows if row.get("status") != "inactive") - inactive_users = sum(1 for row in rows if row.get("status") == "inactive") - below_target_users = sum(1 for row in rows if row.get("status") == "below_target") - on_target_users = sum(1 for row in rows if row.get("status") == "ok") - workday_first_values = sorted(str(row.get("workday_first_activity_local") or "") for row in rows if row.get("workday_first_activity_local")) - workday_last_values = sorted(str(row.get("workday_last_activity_local") or "") for row in rows if row.get("workday_last_activity_local")) - calendar_first_values = sorted(str(row.get("first_activity_local") or "") for row in rows if row.get("first_activity_local")) - calendar_last_values = sorted(str(row.get("last_activity_local") or "") for row in rows if row.get("last_activity_local")) - top_row = max(rows, key=lambda row: int(row.get("workday_active_seconds", 0) or 0), default=None) - portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(rows))) * 100.0) if rows and expected_seconds_per_user > 0 else 0.0 - return { - "users_count": len(rows), - "active_users": active_users, - "inactive_users": inactive_users, - "on_target_users": on_target_users, - "below_target_users": below_target_users, - "portfolio_coverage_pct": portfolio_coverage_pct, - "actions_count": len(actions), - "critical_actions_count": sum(1 for action in actions if action.get("priority") == "critical"), - "high_actions_count": sum(1 for action in actions if action.get("priority") == "high"), - "calendar_total_active_seconds": calendar_total_active_seconds, - "calendar_total_active_hhmm": hhmm(calendar_total_active_seconds), - "calendar_first_activity": calendar_first_values[0] if calendar_first_values else "", - "calendar_last_activity": calendar_last_values[-1] if calendar_last_values else "", - "workday_total_active_seconds": workday_total_active_seconds, - "workday_total_active_hhmm": hhmm(workday_total_active_seconds), - "workday_first_activity": workday_first_values[0] if workday_first_values else "", - "workday_last_activity": workday_last_values[-1] if workday_last_values else "", - "total_active_seconds": workday_total_active_seconds, - "total_active_hhmm": hhmm(workday_total_active_seconds), - "first_activity": workday_first_values[0] if workday_first_values else "", - "last_activity": workday_last_values[-1] if workday_last_values else "", - "top_user": str((top_row or {}).get("user") or ""), - "top_user_active_hhmm": hhmm(int((top_row or {}).get("workday_active_seconds", 0) or 0)), - } - - -def apply_management_filters_to_payload(payload, owner_filter="", department_filter="", include_sources=True, include_source_actions=True): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - filtered_rows = [ - dict(row) - for row in payload.get("rows", []) - if _row_matches_payload_filters(row, owner_filter=owner_filter, department_filter=department_filter) - ] - base_actions = list(payload.get("actions", [])) - if not include_source_actions: - base_actions = [action for action in base_actions if action.get("action_id") != "source_freshness_review"] - filtered_actions = filter_management_actions(base_actions, filtered_rows, owner_filter=owner_filter, department_filter=department_filter) - filtered_payload = dict(payload) - filtered_payload["filters"] = { - "owner": owner_filter, - "department": department_filter, - } - filtered_payload["rows"] = filtered_rows - filtered_payload["actions"] = filtered_actions - filtered_payload["summary"] = summarize_management_rows(filtered_rows, filtered_actions, payload.get("workday") or {}) - filtered_payload["owner_rollups"] = build_owner_rollups(filtered_rows, filtered_actions) - filtered_payload["department_rollups"] = build_department_rollups(filtered_rows, filtered_actions) - filtered_payload["owner_roster"] = build_owner_roster(filtered_rows, filtered_actions) - if include_sources: - filtered_payload["sources"] = list(payload.get("sources", [])) - else: - filtered_payload["sources"] = [] - filtered_payload["executive"] = build_executive_summary(filtered_payload["summary"], filtered_actions, filtered_payload["sources"]) - return filtered_payload - - -def build_owner_rollups(rows, actions): - groups = {} - for row in rows: - owner = str(row.get("manager_owner") or row.get("user") or "unassigned").strip() or "unassigned" - group = groups.setdefault(owner, _empty_rollup(owner)) - group["users_count"] += 1 - if row.get("status") == "inactive": - group["inactive_users"] += 1 - else: - group["active_users"] += 1 - if row.get("status") == "below_target": - group["below_target_users"] += 1 - group["workday_total_active_seconds"] += int(row.get("workday_active_seconds", 0) or 0) - group["users"].append(row.get("user", "unknown")) - - for action in actions: - owner = str(action.get("owner") or "unassigned").strip() or "unassigned" - group = groups.setdefault(owner, _empty_rollup(owner)) - group["actions_count"] += 1 - prio = str(action.get("priority") or "").lower() - if prio == "critical": - group["critical_actions_count"] += 1 - elif prio == "high": - group["high_actions_count"] += 1 - elif prio == "medium": - group["medium_actions_count"] += 1 - elif prio == "low": - group["low_actions_count"] += 1 - - for group in groups.values(): - expected = group["users_count"] * max(1, WORKDAY_END_HOUR - WORKDAY_START_HOUR) * 3600 - group["workday_total_active_hhmm"] = hhmm(group["workday_total_active_seconds"]) - group["portfolio_coverage_pct"] = _clamp_pct((group["workday_total_active_seconds"] / expected) * 100.0) if expected > 0 else 0.0 - group["users"] = sorted(set(str(user or "").strip() for user in group["users"] if str(user or "").strip())) - - return sorted( - groups.values(), - key=lambda item: ( - -item["critical_actions_count"], - -item["high_actions_count"], - -item["inactive_users"], - item["name"].lower(), - ), - ) - - -def build_department_rollups(rows, actions): - groups = {} - for row in rows: - department = str(row.get("department") or "Без подразделения").strip() or "Без подразделения" - group = groups.setdefault(department, _empty_rollup(department)) - group["users_count"] += 1 - if row.get("status") == "inactive": - group["inactive_users"] += 1 - else: - group["active_users"] += 1 - if row.get("status") == "below_target": - group["below_target_users"] += 1 - group["workday_total_active_seconds"] += int(row.get("workday_active_seconds", 0) or 0) - group["users"].append(row.get("user", "unknown")) - - for action in actions: - evidence = action.get("evidence") or {} - department = str(evidence.get("department") or ("Инфраструктура" if str(action.get("owner") or "").strip().lower() == "ops" else "Без подразделения")).strip() - group = groups.setdefault(department, _empty_rollup(department)) - group["actions_count"] += 1 - prio = str(action.get("priority") or "").lower() - if prio == "critical": - group["critical_actions_count"] += 1 - elif prio == "high": - group["high_actions_count"] += 1 - elif prio == "medium": - group["medium_actions_count"] += 1 - elif prio == "low": - group["low_actions_count"] += 1 - - for group in groups.values(): - expected = group["users_count"] * max(1, WORKDAY_END_HOUR - WORKDAY_START_HOUR) * 3600 - group["workday_total_active_hhmm"] = hhmm(group["workday_total_active_seconds"]) - group["portfolio_coverage_pct"] = _clamp_pct((group["workday_total_active_seconds"] / expected) * 100.0) if expected > 0 else 0.0 - group["users"] = sorted(set(str(user or "").strip() for user in group["users"] if str(user or "").strip())) - - return sorted( - groups.values(), - key=lambda item: ( - -item["critical_actions_count"], - -item["high_actions_count"], - -item["inactive_users"], - item["name"].lower(), - ), - ) - - -def build_owner_roster(rows, actions): - owner_rollups = build_owner_rollups(rows, actions) - roster = [] - for item in owner_rollups: - profile = resolve_owner_profile(item["name"]) - roster.append( - { - **item, - "display_name": profile["display_name"], - "title": profile["title"], - "department": profile["department"], - "contact": profile["contact"], - "escalation_to": profile["escalation_to"], - "notes": profile["notes"], - } - ) - return roster - - -def _interval_overlap_seconds(intervals, start, end): - total = 0 - first = None - last = None - for interval_start, interval_end in intervals or []: - overlap_start = max(interval_start, start) - overlap_end = min(interval_end, end) - if overlap_end <= overlap_start: - continue - seconds = int((overlap_end - overlap_start).total_seconds()) - if seconds <= 0: - continue - total += seconds - if first is None or overlap_start < first: - first = overlap_start - if last is None or overlap_end > last: - last = overlap_end - return total, first, last - - -def _source_status_label(status): - return { - "ok": "fresh", - "warn": "stale", - "fail": "missing", - "inactive": "inactive", - }.get(status, status) - - -def _source_summary(event): - data = (event or {}).get("data") or {} - signal_type = str(data.get("signalType") or "").strip() - if signal_type == "collector_health": - return ( - f"queue={data.get('queueDepth', 0)} " - f"failures={data.get('sendFailures', 0)} " - f"flushed={data.get('eventsFlushed', 0)}" - ) - if data.get("domain"): - return f"{data.get('domain')} ({data.get('category', 'uncategorized')})" - if data.get("eventType"): - return f"{data.get('eventType')} {data.get('username', '')}".strip() - if data.get("action"): - return f"{data.get('action')} {data.get('result', '')}".strip() - if data.get("title"): - return str(data.get("title"))[:120] - if data.get("status"): - return str(data.get("status")) - if data.get("app"): - return str(data.get("app")) - return "" - - -def management_cache_path(host, report_date): - host_slug = safe_slug(host) - return MANAGER_CACHE_DIR / f"{host_slug}-{report_date.isoformat()}.json" - - -def load_management_cache(host, report_date): - if MANAGER_CACHE_TTL_SECONDS <= 0: - return None - path = management_cache_path(host, report_date) - if not path.exists(): - return None - if report_date >= datetime.now(REPORT_TZ).date(): - age = age_seconds(datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)) - if age is None or age > MANAGER_CACHE_TTL_SECONDS: - return None - try: - return json.loads(path.read_text(encoding="utf-8")) - except Exception: - return None - - -def save_management_cache(host, report_date, payload): - if MANAGER_CACHE_TTL_SECONDS <= 0: - return - write_atomic_json(management_cache_path(host, report_date), payload) - - -def make_report_cache_key(path, fmt, host, report_date, selected_day="", owner_filter="", department_filter=""): - return "|".join( - [ - str(path), - str(fmt), - resolve_host(host), - report_date.isoformat(), - str(selected_day or ""), - normalize_management_filter(owner_filter), - normalize_management_filter(department_filter), - ] - ) - - -def resolve_report_format(params, accept_header=""): - requested = str(params.get("format", [""])[0] or "").lower() - if requested in {"csv", "html", "json"}: - return requested - accept = str(accept_header or "").lower() - if "text/html" in accept and "application/json" not in accept: - return "html" - return "json" - - -def report_disk_cache_path(key): - digest = hashlib.sha256(key.encode("utf-8")).hexdigest() - return REPORT_DISK_CACHE_DIR / f"{digest}.json" - - -def load_report_response_disk_cache(key): - if REPORT_DISK_STALE_TTL_SECONDS <= 0: - return None - path = report_disk_cache_path(key) - if not path.exists(): - return None - try: - payload = json.loads(path.read_text(encoding="utf-8")) - stored_epoch = float(payload["stored_epoch"]) - age = max(0, int(time.time() - stored_epoch)) - if age > REPORT_DISK_STALE_TTL_SECONDS: - return None - data = base64.b64decode(payload["data_b64"].encode("ascii")) - content_type = str(payload["content_type"]) - except Exception as exc: - log_warning(f"failed to load report disk cache key={key}: {exc}") - return None - return { - "stored_monotonic": time.monotonic() - age, - "generated_at_utc": payload.get("generated_at_utc", ""), - "data": data, - "content_type": content_type, - "age_seconds": age, - "stale": True, - "cache_source": "disk", - } - - -def save_report_response_disk_cache(key, data, content_type, generated_at_utc): - if REPORT_DISK_STALE_TTL_SECONDS <= 0: - return - payload = { - "key": key, - "stored_epoch": time.time(), - "generated_at_utc": generated_at_utc, - "content_type": content_type, - "data_b64": base64.b64encode(data).decode("ascii"), - } - try: - write_atomic_json(report_disk_cache_path(key), payload) - except Exception as exc: - log_warning(f"failed to save report disk cache key={key}: {exc}") - - -def get_report_response_cache(key, *, allow_stale=False): - if REPORT_CACHE_TTL_SECONDS <= 0 and not allow_stale: - return None - now = time.monotonic() - with _REPORT_CACHE_LOCK: - cached = _REPORT_RESPONSE_CACHE.get(key) - if cached is not None: - age = now - cached["stored_monotonic"] - max_age = REPORT_STALE_TTL_SECONDS if allow_stale else REPORT_CACHE_TTL_SECONDS - if max_age > 0 and age <= max_age: - result = dict(cached) - result["age_seconds"] = int(age) - result["stale"] = age > REPORT_CACHE_TTL_SECONDS - result["cache_source"] = "memory" - return result - if allow_stale: - return load_report_response_disk_cache(key) - return None - - -def save_report_response_cache(key, data, content_type): - if REPORT_STALE_TTL_SECONDS <= 0: - return - generated_at_utc = now_utc().isoformat().replace("+00:00", "Z") - with _REPORT_CACHE_LOCK: - _REPORT_RESPONSE_CACHE[key] = { - "stored_monotonic": time.monotonic(), - "generated_at_utc": generated_at_utc, - "data": data, - "content_type": content_type, - } - save_report_response_disk_cache(key, data, content_type, generated_at_utc) - - -def trigger_report_background_refresh(key, path, params): - refresh_params = {name: list(values) for name, values in params.items()} - with _REPORT_CACHE_LOCK: - if key in _REPORT_REFRESHING: - return False - _REPORT_REFRESHING.add(key) - - def refresh(): - try: - if not _REPORT_BUILD_SEMAPHORE.acquire(blocking=False): - return - try: - data, content_type = build_worktime_report_response(path, refresh_params) - save_report_response_cache(key, data, content_type) - except Exception as exc: - log_warning(f"background report refresh failed path={path}: {exc}") - finally: - _REPORT_BUILD_SEMAPHORE.release() - finally: - with _REPORT_CACHE_LOCK: - _REPORT_REFRESHING.discard(key) - - thread = threading.Thread(target=refresh, name="worktime-report-refresh", daemon=True) - thread.start() - return True - - -def send_cached_report(handler, cached, *, reason): - handler.send_response(200) - handler.send_header("Content-Type", cached["content_type"]) - handler.send_header("Content-Length", str(len(cached["data"]))) - handler.send_header("X-AW-Worktime-Cache", "stale" if cached.get("stale") else "fresh") - handler.send_header("X-AW-Worktime-Cache-Reason", reason) - handler.send_header("X-AW-Worktime-Cache-Age", str(cached.get("age_seconds", 0))) - handler.send_header("X-AW-Worktime-Cache-Source", cached.get("cache_source", "memory")) - handler.end_headers() - try: - handler.wfile.write(cached["data"]) - except (BrokenPipeError, ConnectionResetError): - return False - return True - - -def send_report_unavailable(handler, message): - payload = { - "ok": False, - "error": "report_unavailable", - "message": message, - "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), - } - data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") - send_bytes(handler, data, "application/json; charset=utf-8", status=503) - - -def send_worktime_report(handler, path, params, cache_key, *, host, report_date): - cached = get_report_response_cache(cache_key) - if cached is not None: - send_cached_report(handler, cached, reason="ttl") - return - - allow_immediate_stale = str(params.get("allow_stale", [""])[0]).lower() in {"1", "true", "yes"} - if allow_immediate_stale: - cached = get_report_response_cache(cache_key, allow_stale=True) - if cached is not None: - refresh_stale = str(params.get("refresh_stale", [""])[0]).lower() in {"1", "true", "yes"} - if refresh_stale: - trigger_report_background_refresh(cache_key, path, params) - send_cached_report(handler, cached, reason="stale-allowed") - return - - if not _REPORT_BUILD_SEMAPHORE.acquire(blocking=False): - if not allow_immediate_stale and REPORT_BUSY_WAIT_SECONDS > 0: - deadline = time.monotonic() + REPORT_BUSY_WAIT_SECONDS - while True: - remaining = deadline - time.monotonic() - if remaining <= 0: - break - time.sleep(min(REPORT_BUSY_WAIT_INTERVAL_SECONDS, remaining)) - cached = get_report_response_cache(cache_key) - if cached is not None: - send_cached_report(handler, cached, reason="ttl-after-wait") - return - cached = get_report_response_cache(cache_key, allow_stale=True) - if cached is not None: - send_cached_report(handler, cached, reason="busy") - return - send_report_unavailable(handler, "report builder is busy and no cached response is available") - return - - try: - cached = get_report_response_cache(cache_key) - if cached is not None: - send_cached_report(handler, cached, reason="ttl-after-lock") - return - try: - data, content_type = build_worktime_report_response(path, params) - except Exception as exc: - log_warning(f"report build failed path={path} host={host} date={report_date}: {exc}") - cached = get_report_response_cache(cache_key, allow_stale=True) - if cached is not None: - send_cached_report(handler, cached, reason="build-error") - return - send_report_unavailable(handler, "report build failed and no cached response is available") - return - save_report_response_cache(cache_key, data, content_type) - send_bytes(handler, data, content_type) - finally: - _REPORT_BUILD_SEMAPHORE.release() - - -def build_source_freshness(host, interactive_required=True): - source_specs = [ - { - "source_id": "worktime_sessions", - "label": "RDP worktime sessions", - "bucket_candidates": [f"aw-worktime-sessions_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "owner": "ops", - }, - { - "source_id": "rdp_window", - "label": "RDP current window", - "bucket_candidates": [f"aw-rdp-window_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "owner": "ops", - }, - { - "source_id": "rdp_afk", - "label": "RDP AFK", - "bucket_candidates": [f"aw-rdp-afk_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "owner": "ops", - }, - { - "source_id": "watcher_window", - "label": "Local watcher window", - "bucket_candidates": [f"aw-watcher-window_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "interactive_only": True, - "owner": "ops", - }, - { - "source_id": "watcher_afk", - "label": "Local watcher AFK", - "bucket_candidates": [f"aw-watcher-afk_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "owner": "ops", - }, - { - "source_id": "file_operations", - "label": "File operations collector", - "bucket_candidates": [f"aw-file-operations_{host}"], - "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, - "required": True, - "interactive_only": True, - "owner": "ops", - }, - { - "source_id": "web_categories", - "label": "Browser/web categories", - "bucket_candidates": [f"aw-detmir-web-category_{host}"], - "max_age_seconds": MANAGER_WEB_SOURCE_MAX_AGE_SECONDS, - "required": False, - "owner": "ops", - }, - { - "source_id": "session_events", - "label": "Windows session events", - "bucket_candidates": [f"aw-session-events_{host}"], - "max_age_seconds": MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS, - "required": False, - "owner": "ops", - }, - { - "source_id": "pve_tasks", - "label": "PVE task feed", - "bucket_candidates": ["aw-pve-task-events_pve-detmir"], - "max_age_seconds": MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS, - "required": False, - "owner": "ops", - }, - ] - now = now_utc() - sources = [] - actions = [] - for spec in source_specs: - matched_bucket = "" - matched_event = None - matched_age = None - for candidate in spec["bucket_candidates"]: - event = latest_bucket_event(candidate) - if not event: - continue - ts = parse_iso_utc(event.get("timestamp")) - candidate_age = age_seconds(ts, now=now) - if matched_event is None or (candidate_age is not None and (matched_age is None or candidate_age < matched_age)): - matched_bucket = candidate - matched_event = event - matched_age = candidate_age - ts = parse_iso_utc((matched_event or {}).get("timestamp")) - age = matched_age if matched_event is not None else age_seconds(ts, now=now) - if matched_event is None: - status = "fail" if spec["required"] else "warn" - summary = "bucket missing or empty" - elif age is None: - status = "warn" - summary = "timestamp parse failed" - elif age > spec["max_age_seconds"]: - status = "fail" if spec["required"] else "warn" - summary = f"stale ({age}s)" - else: - status = "ok" - summary = f"fresh ({age}s)" - if spec.get("interactive_only") and not interactive_required and status != "ok": - status = "inactive" - summary = "inactive: no active interactive users" - detail = { - "source_id": spec["source_id"], - "label": spec["label"], - "status": status, - "status_label": _source_status_label(status), - "bucket_id": matched_bucket or spec["bucket_candidates"][0], - "timestamp": (matched_event or {}).get("timestamp", ""), - "age_seconds": age, - "required": spec["required"], - "interactive_only": bool(spec.get("interactive_only")), - "interactive_required": bool(interactive_required), - "max_age_seconds": spec["max_age_seconds"], - "summary": summary, - "event_summary": _source_summary(matched_event), - } - sources.append(detail) - if status in {"ok", "inactive"}: - continue - priority = "critical" if spec["required"] else "medium" - actions.append( - _action( - "source_freshness_review", - priority, - spec["owner"], - "today" if spec["required"] else "3d", - f"Источник '{spec['label']}' в состоянии {detail['status_label']}: {summary}.", - "Проверить collector/service, причину отставания и подтвердить, что управленческие выводы по данным ещё надёжны.", - evidence={ - "source_id": spec["source_id"], - "bucket_id": detail["bucket_id"], - "age_seconds": age, - "required": spec["required"], - }, - ) - ) - return sources, actions - - -def _build_management_core(rows, host, report_date, owner_filter="", department_filter=""): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - report_bounds = get_report_bounds(report_date) - workday = get_workday_bounds(report_date) - now_local = datetime.now(REPORT_TZ) - is_today = report_date == now_local.date() - effective_end_local = min(now_local, workday["end_local"]) if is_today else workday["end_local"] - elapsed_seconds = max(0, int((effective_end_local - workday["start_local"]).total_seconds())) - if not is_today: - elapsed_seconds = workday["duration_seconds"] - expected_seconds_per_user = min(workday["duration_seconds"], max(0, elapsed_seconds)) - target_seconds = int(expected_seconds_per_user * (MANAGER_TARGET_COVERAGE_PCT / 100.0)) - low_seconds = int(expected_seconds_per_user * (MANAGER_LOW_COVERAGE_PCT / 100.0)) - late_start_local = workday["start_local"] + timedelta(minutes=MANAGER_LATE_START_GRACE_MINUTES) - early_finish_local = workday["end_local"] - timedelta(minutes=MANAGER_EARLY_FINISH_GRACE_MINUTES) - - roster = [] - actions = [] - active_users = 0 - on_target_users = 0 - below_target_users = 0 - workday_total_active_seconds = 0 - workday_first_values = [] - workday_last_values = [] - top_workday_user = "" - top_workday_seconds = -1 - filtered_rows = [] - - for row in rows: - alias = resolve_user_alias(row.get("user", ""), row.get("user_id", ""), host) - if alias["exclude"]: - continue - if not _matches_management_filters(alias, owner_filter=owner_filter, department_filter=department_filter): - continue - filtered_rows.append(row) - public_row = {key: value for key, value in row.items() if key != "_intervals"} - calendar_active_seconds = int(row.get("active_seconds", 0) or 0) - intervals = row.get("_intervals") or [] - workday_active_seconds, workday_first, workday_last = _interval_overlap_seconds( - intervals, - workday["start_local"].astimezone(timezone.utc), - effective_end_local.astimezone(timezone.utc), - ) - first_activity = parse_iso_utc(row.get("first_activity")) - last_activity = parse_iso_utc(row.get("last_activity")) - first_local = first_activity.astimezone(REPORT_TZ) if first_activity else None - last_local = last_activity.astimezone(REPORT_TZ) if last_activity else None - workday_first_local = workday_first.astimezone(REPORT_TZ) if workday_first else None - workday_last_local = workday_last.astimezone(REPORT_TZ) if workday_last else None - coverage_pct = _clamp_pct((workday_active_seconds / expected_seconds_per_user) * 100.0) if expected_seconds_per_user > 0 else 0.0 - status = "ok" - if workday_active_seconds <= 0: - status = "inactive" - elif workday_active_seconds < target_seconds: - status = "below_target" - if workday_active_seconds > 0: - active_users += 1 - workday_total_active_seconds += workday_active_seconds - if workday_first_local: - workday_first_values.append(workday_first_local.isoformat()) - if workday_last_local: - workday_last_values.append(workday_last_local.isoformat()) - if workday_active_seconds >= target_seconds and workday_active_seconds > 0: - on_target_users += 1 - elif workday_active_seconds > 0: - below_target_users += 1 - if workday_active_seconds > top_workday_seconds: - top_workday_seconds = workday_active_seconds - top_workday_user = alias["display_name"] - - roster.append( - { - **public_row, - "user": alias["display_name"], - "user_original": row.get("user", ""), - "manager_owner": alias["manager_owner"], - "department": alias["department"], - "role": alias["role"], - "notes": alias["notes"], - "canonical_user_id": alias["canonical_user_id"] or row.get("user_id", ""), - "calendar_active_seconds": calendar_active_seconds, - "calendar_active_hhmm": row.get("active_hhmm", "00:00"), - "workday_active_seconds": workday_active_seconds, - "workday_active_hhmm": hhmm(workday_active_seconds), - "coverage_pct": coverage_pct, - "status": status, - "first_activity_local": first_local.isoformat() if first_local else "", - "last_activity_local": last_local.isoformat() if last_local else "", - "workday_first_activity_local": workday_first_local.isoformat() if workday_first_local else "", - "workday_last_activity_local": workday_last_local.isoformat() if workday_last_local else "", - } - ) - - owner = alias["display_name"] - user_id = alias["canonical_user_id"] or row.get("user_id", "") - evidence = { - "calendar_active_hhmm": row.get("active_hhmm", "00:00"), - "workday_active_hhmm": hhmm(workday_active_seconds), - "coverage_pct": coverage_pct, - "first_activity": row.get("first_activity", ""), - "last_activity": row.get("last_activity", ""), - "sessions_count": row.get("sessions_count", 0), - "manager_owner": alias["manager_owner"], - "department": alias["department"], - "role": alias["role"], - } - if workday_active_seconds <= 0: - actions.append( - _action( - "missing_activity", - "critical", - alias["manager_owner"], - "today", - f"За {report_date.isoformat()} у сотрудника {owner} нет подтверждённой активности в рабочем окне RDP.", - f"Проверить сотрудника {owner}: работал ли он в рабочее время, была ли потеря сбора данных или отсутствие входа в систему.", - user_id=user_id, - evidence=evidence, - ) - ) - continue - if expected_seconds_per_user > 0 and workday_active_seconds < low_seconds: - actions.append( - _action( - "low_activity_review", - "high", - alias["manager_owner"], - "24h", - f"У сотрудника {owner} активное время в рабочем окне {hhmm(workday_active_seconds)} ниже {MANAGER_LOW_COVERAGE_PCT}% от ожидаемого окна.", - f"Проверить загрузку сотрудника {owner}, задачи и фактическое присутствие в рабочем процессе.", - user_id=user_id, - evidence=evidence, - ) - ) - elif expected_seconds_per_user > 0 and workday_active_seconds < target_seconds: - actions.append( - _action( - "target_gap_review", - "medium", - alias["manager_owner"], - "24h", - f"У сотрудника {owner} активное время в рабочем окне {hhmm(workday_active_seconds)} ниже управленческого целевого порога {MANAGER_TARGET_COVERAGE_PCT}%.", - f"Уточнить причину отклонения по сотруднику {owner} и подтвердить план работ.", - user_id=user_id, - evidence=evidence, - ) - ) - if workday_first_local and workday_first_local > late_start_local: - actions.append( - _action( - "late_start_review", - "medium", - alias["manager_owner"], - "24h", - f"У сотрудника {owner} первая активность в рабочем окне зафиксирована поздно: {workday_first_local.strftime('%H:%M')}.", - f"Проверить причину позднего старта сотрудника {owner} и подтвердить, что это не проблема доступа или дисциплины.", - user_id=user_id, - evidence=evidence, - ) - ) - if (not is_today) and workday_last_local and workday_last_local < early_finish_local: - actions.append( - _action( - "early_finish_review", - "medium", - alias["manager_owner"], - "24h", - f"У сотрудника {owner} последняя активность в рабочем окне завершилась рано: {workday_last_local.strftime('%H:%M')}.", - f"Проверить, было ли досрочное завершение рабочего дня сотрудника {owner} согласовано и чем оно объясняется.", - user_id=user_id, - evidence=evidence, - ) - ) - - actions.sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) - calendar_summary = build_report_summary(filtered_rows) - inactive_users = sum(1 for row in roster if row["status"] == "inactive") - portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(roster))) * 100.0) if roster and expected_seconds_per_user > 0 else 0.0 - calendar_first = calendar_summary.get("first_activity", "") - calendar_last = calendar_summary.get("last_activity", "") - return { - "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), - "host": resolve_host(host), - "report_date": report_date.isoformat(), - "report_timezone": str(REPORT_TZ), - "filters": { - "owner": owner_filter, - "department": department_filter, - }, - "workday": { - "start_local": workday["start_local"].isoformat(), - "end_local": workday["end_local"].isoformat(), - "expected_seconds_per_user": expected_seconds_per_user, - "expected_hhmm_per_user": hhmm(expected_seconds_per_user), - "target_coverage_pct": MANAGER_TARGET_COVERAGE_PCT, - "low_coverage_pct": MANAGER_LOW_COVERAGE_PCT, - }, - "summary": { - **calendar_summary, - "calendar_total_active_seconds": calendar_summary["total_active_seconds"], - "calendar_total_active_hhmm": calendar_summary["total_active_hhmm"], - "calendar_first_activity": calendar_first, - "calendar_last_activity": calendar_last, - "workday_total_active_seconds": workday_total_active_seconds, - "workday_total_active_hhmm": hhmm(workday_total_active_seconds), - "workday_first_activity": min(workday_first_values) if workday_first_values else "", - "workday_last_activity": max(workday_last_values) if workday_last_values else "", - "total_active_seconds": workday_total_active_seconds, - "total_active_hhmm": hhmm(workday_total_active_seconds), - "first_activity": min(workday_first_values) if workday_first_values else "", - "last_activity": max(workday_last_values) if workday_last_values else "", - "top_user": top_workday_user, - "top_user_active_hhmm": hhmm(top_workday_seconds if top_workday_seconds > 0 else 0), - "active_users": active_users, - "inactive_users": inactive_users, - "on_target_users": on_target_users, - "below_target_users": below_target_users, - "portfolio_coverage_pct": portfolio_coverage_pct, - "actions_count": len(actions), - "critical_actions_count": sum(1 for action in actions if action["priority"] == "critical"), - "high_actions_count": sum(1 for action in actions if action["priority"] == "high"), - }, - "actions": actions, - "rows": roster, - "bucket_id": get_sessions_bucket_id(host), - "report_bounds": { - "start_utc": to_iso_utc(report_bounds["start"]), - "end_utc": to_iso_utc(report_bounds["end"]), - }, - } - - -def _management_trend_item(payload, report_date): - summary = payload["summary"] - return { - "report_date": report_date.isoformat(), - "users_count": summary["users_count"], - "active_users": summary["active_users"], - "inactive_users": summary["inactive_users"], - "workday_total_active_seconds": summary["workday_total_active_seconds"], - "workday_total_active_hhmm": summary["workday_total_active_hhmm"], - "portfolio_coverage_pct": summary["portfolio_coverage_pct"], - "actions_count": summary["actions_count"], - "critical_actions_count": summary["critical_actions_count"], - } - - -def build_management_trend(host, anchor_date, owner_filter="", department_filter="", precomputed_payloads=None): - trend = [] - precomputed_payloads = precomputed_payloads or {} - for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): - current_date = anchor_date - timedelta(days=offset) - payload = precomputed_payloads.get(current_date) - if payload is None: - payload = load_management_cache(host, current_date) - if payload is None: - bounds, events = fetch_events_for_date(host, current_date) - rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) - payload = _build_management_core(rows, host, current_date, owner_filter=owner_filter, department_filter=department_filter) - elif owner_filter or department_filter: - payload = apply_management_filters_to_payload( - payload, - owner_filter=owner_filter, - department_filter=department_filter, - include_sources=False, - include_source_actions=False, - ) - trend.append(_management_trend_item(payload, current_date)) - return trend - - -def build_filtered_management_trend(host, anchor_date, owner_filter="", department_filter=""): - trend = [] - for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): - current_date = anchor_date - timedelta(days=offset) - base_payload = load_management_cache(host, current_date) - if base_payload is None: - bounds, events = fetch_events_for_date(host, current_date) - rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) - base_payload = _build_management_core(rows, host, current_date) - filtered_payload = apply_management_filters_to_payload( - base_payload, - owner_filter=owner_filter, - department_filter=department_filter, - include_sources=False, - include_source_actions=False, - ) - summary = filtered_payload["summary"] - trend.append( - { - "report_date": current_date.isoformat(), - "users_count": summary["users_count"], - "active_users": summary["active_users"], - "inactive_users": summary["inactive_users"], - "workday_total_active_seconds": summary["workday_total_active_seconds"], - "workday_total_active_hhmm": summary["workday_total_active_hhmm"], - "portfolio_coverage_pct": summary["portfolio_coverage_pct"], - "actions_count": summary["actions_count"], - "critical_actions_count": summary["critical_actions_count"], - } - ) - return trend - - -def build_management_payload(rows, host, report_date, owner_filter="", department_filter=""): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter) - interactive_required = int(payload.get("summary", {}).get("active_users", 0) or 0) > 0 - source_freshness, source_actions = build_source_freshness(resolve_host(host), interactive_required=interactive_required) - payload["sources"] = source_freshness - payload["trend"] = build_management_trend( - resolve_host(host), - report_date, - owner_filter=owner_filter, - department_filter=department_filter, - precomputed_payloads={report_date: payload}, - ) - payload["trend_scope"] = "portfolio" - if source_actions: - payload["actions"].extend(filter_management_actions(source_actions, payload["rows"], owner_filter=owner_filter, department_filter=department_filter)) - payload["actions"].sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) - payload["summary"]["actions_count"] = len(payload["actions"]) - payload["summary"]["critical_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "critical") - payload["summary"]["high_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "high") - payload["executive"] = build_executive_summary(payload["summary"], payload["actions"], payload["sources"]) - payload["owner_rollups"] = build_owner_rollups(payload["rows"], payload["actions"]) - payload["department_rollups"] = build_department_rollups(payload["rows"], payload["actions"]) - payload["owner_roster"] = build_owner_roster(payload["rows"], payload["actions"]) - return payload - - -def report_for_date(host, report_date): - bounds, events = fetch_events_for_date(host, report_date) - return aggregate_rows(events, bounds["start"], bounds["end"], host) - - -def management_report_for_date(host, report_date, owner_filter="", department_filter="", use_cache=True): - owner_filter = normalize_management_filter(owner_filter) - department_filter = normalize_management_filter(department_filter) - if owner_filter or department_filter: - base_payload = management_report_for_date(host, report_date, use_cache=use_cache) - filtered_payload = apply_management_filters_to_payload( - base_payload, - owner_filter=owner_filter, - department_filter=department_filter, - include_sources=True, - include_source_actions=True, - ) - filtered_payload["trend"] = [] - filtered_payload["trend_scope"] = "filtered_current_only" - return filtered_payload - cache_allowed = use_cache or report_date < datetime.now(REPORT_TZ).date() - if cache_allowed: - cached = load_management_cache(host, report_date) - if cached is not None: - return cached - lock = get_management_build_lock(host, report_date) - with lock: - if cache_allowed: - cached = load_management_cache(host, report_date) - if cached is not None: - return cached - bounds, events = fetch_events_for_date(host, report_date) - rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) - payload = build_management_payload(rows, host, report_date) - save_management_cache(host, report_date, payload) - return payload - - -def report_today(host): - return report_for_date(host, resolve_report_date()) - - -def report_for_date_fresh(host, report_date): - spec = importlib.util.spec_from_file_location("aw_worktime_runtime", MODULE_PATH) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.report_for_date(host, report_date) - - -def render_html(rows, host, report_date, selected_day=None, true_active_apps=None): - generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") - date_local = report_date.strftime("%Y-%m-%d") - day_query = f"&day={selected_day}" if selected_day in {"today", "yesterday"} else "" - date_query = f"&date={date_local}" if not day_query else "" - summary = build_report_summary(rows) - today_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "today"}) - yesterday_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "yesterday"}) - csv_url = "/reports/worktime/today?" + urlencode({"format": "csv", "host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) - json_url = "/reports/worktime/today?" + urlencode({"host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) - form_action = "/reports/worktime/today" - cards = [ - ("Пользователи", str(summary["users_count"])), - ("Активное время", summary["total_active_hhmm"]), - ("Лидер дня", f"{summary['top_user']} · {summary['top_user_active_hhmm']}" if summary["top_user"] else "н/д"), - ("Диапазон", f"{summary['first_activity']} -> {summary['last_activity']}" if summary["first_activity"] else "нет активности"), - ] - trs = [] - detail_cards = [] - true_active_apps = true_active_apps or [] - true_active_rows = [] - for app_row in true_active_apps: - last_action = app_row.get("last_action") or "-" - last_time = app_row.get("last_action_local") or "-" - true_active_rows.append( - "" - f"{html.escape(app_row.get('application') or '-')}" - f"{html.escape(app_row.get('proved_work_human') or app_row.get('proved_work_hhmm') or '0 сек')}" - f"{html.escape(last_time)} · {html.escape(last_action)}" - "" - ) - if not true_active_rows: - true_active_rows.append("Пока нет доказанной активной работы по приложениям за выбранную дату.") - for row in rows: - user_slug = safe_slug(row["user"]) - active_seconds = int(row.get("active_seconds", 0) or 0) - utilization = 0.0 - day_total = 24 * 3600 - if day_total > 0: - utilization = round((active_seconds / day_total) * 100.0, 2) - trs.append( - "" - f"{html.escape(row['user'])}" - f"{html.escape(row['user_id'])}" - f"{row['active_hhmm']}" - f"{row['active_seconds']}" - f"{html.escape(row['first_activity'])}" - f"{html.escape(row['last_activity'])}" - f"{row['idle_seconds']}" - f"{row['sessions_count']}" - f"{row['samples_count']}" - "" - ) - detail_cards.append( - "
    " - "
    " - "

    {user}

    " - "{active}" - "
    " - "
    " - "
    Пользователь{user_id}
    " - "
    Загрузка{utilization}%
    " - "
    Начало активности{first_activity}
    " - "
    Конец активности{last_activity}
    " - "
    Сессии{sessions}
    " - "
    Активные сэмплы{active_samples} / {samples}
    " - "
    " - "
    " - .format( - slug=user_slug, - user=html.escape(row["user"]), - active=html.escape(row["active_hhmm"]), - user_id=html.escape(row["user_id"]), - utilization=utilization, - first_activity=html.escape(row["first_activity"] or "н/д"), - last_activity=html.escape(row["last_activity"] or "н/д"), - sessions=row["sessions_count"], - active_samples=row["active_samples"], - samples=row["samples_count"], - )) - if not trs: - trs.append('За выбранную дату данных пока нет.') - detail_cards.append("

    За выбранную дату нет активности пользователей.

    ") - return f""" - - - - - AW-rus Отчёт по работе в RDP - - - -
    -
    -

    Отчёт по работе в RDP

    -
    Хост: {resolve_host(host)} · Дата: {date_local} · Часовой пояс: {REPORT_TZ} · Сформировано UTC: {generated}
    - -
    -
    - - - - -
    -
    -
    - {''.join(f"
    {html.escape(label)}{html.escape(value)}
    " for label, value in cards)} -
    -
    -
    -

    Доказанная работа по приложениям

    - - - - - - - - - - {''.join(true_active_rows)} - -
    ПриложениеДоказанная работаПоследнее действие
    -
    -
    -

    Таблица по пользователям

    - - - - - - - - - - - - - - - - {''.join(trs)} - -
    ПользовательУчётная записьАктивноАктивно, секНачало активностиКонец активностиПростой, секСессииСэмплы
    -
    -
    -

    Детали по пользователям

    -
    - {''.join(detail_cards)} -
    -
    -
    - -""" - - -def build_management_report_url(host, report_date, selected_day=None, fmt=None, owner_filter="", department_filter=""): - params = {"host": resolve_host(host)} - if fmt: - params["format"] = fmt - if selected_day in {"today", "yesterday"}: - params["day"] = selected_day - else: - params["date"] = report_date - if normalize_management_filter(owner_filter): - params["owner"] = normalize_management_filter(owner_filter) - if normalize_management_filter(department_filter): - params["department"] = normalize_management_filter(department_filter) - return "/reports/worktime/management?" + urlencode(params) - - -def render_management_html(payload, selected_day=None): - summary = payload["summary"] - workday = payload["workday"] - report_date = payload["report_date"] - host = payload["host"] - executive = payload.get("executive") or {} - active_filters = payload.get("filters") or {} - owner_filter = normalize_management_filter(active_filters.get("owner")) - department_filter = normalize_management_filter(active_filters.get("department")) - today_url = build_management_report_url(host, report_date, selected_day="today", fmt="html", owner_filter=owner_filter, department_filter=department_filter) - yesterday_url = build_management_report_url(host, report_date, selected_day="yesterday", fmt="html", owner_filter=owner_filter, department_filter=department_filter) - json_url = build_management_report_url(host, report_date, selected_day=selected_day, owner_filter=owner_filter, department_filter=department_filter) - classic_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": host, **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": report_date})}) - reset_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html") - actions_html = [] - for action in payload["actions"]: - actions_html.append( - "" - f"{html.escape(action['priority'])}" - f"{html.escape(action['owner'])}" - f"{html.escape(action['action_id'])}" - f"{html.escape(action['deadline_hint'])}" - f"{html.escape(action['reason'])}" - f"{html.escape(action['recommended_action'])}" - "" - ) - if not actions_html: - actions_html.append("Отклонений по текущим правилам не найдено.") - - roster_html = [] - for row in payload["rows"]: - roster_html.append( - "" - f"{html.escape(row['user'])}" - f"{html.escape(row.get('canonical_user_id') or row['user_id'])}" - f"{html.escape(row.get('manager_owner') or row['user'])}" - f"{html.escape(row.get('department') or '-')}" - f"{html.escape(row['workday_active_hhmm'])}" - f"{html.escape(row['calendar_active_hhmm'])}" - f"{row['coverage_pct']}" - f"{html.escape(row['status'])}" - f"{html.escape(row.get('workday_first_activity_local') or '-')}" - f"{html.escape(row.get('workday_last_activity_local') or '-')}" - f"{row['sessions_count']}" - "" - ) - if not roster_html: - roster_html.append("За выбранную дату данных нет.") - - trend_html = [] - for row in payload.get("trend", []): - trend_html.append( - "" - f"{html.escape(row['report_date'])}" - f"{row['users_count']}" - f"{row['active_users']}" - f"{row['inactive_users']}" - f"{html.escape(row['workday_total_active_hhmm'])}" - f"{row['portfolio_coverage_pct']}" - f"{row['actions_count']}" - f"{row['critical_actions_count']}" - "" - ) - if not trend_html: - trend_message = "Тренд по выбранному фильтру пока отключён, чтобы current-scope отчёт отвечал быстро." if (owner_filter or department_filter) else "Тренд пока недоступен." - trend_html.append(f"{html.escape(trend_message)}") - - owner_rollup_html = [] - for item in payload.get("owner_rollups", []): - owner_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=item["name"], department_filter=department_filter) - owner_rollup_html.append( - "" - f"{html.escape(item['name'])}" - f"{item['users_count']}" - f"{item['inactive_users']}" - f"{item['below_target_users']}" - f"{item['critical_actions_count']}" - f"{item['high_actions_count']}" - f"{html.escape(item['workday_total_active_hhmm'])}" - f"{item['portfolio_coverage_pct']}" - f"{html.escape(', '.join(item['users']) if item['users'] else '-')}" - "" - ) - if not owner_rollup_html: - owner_rollup_html.append("Нет данных по ответственным.") - - department_rollup_html = [] - for item in payload.get("department_rollups", []): - department_url = build_management_report_url(host, report_date, selected_day=selected_day, fmt="html", owner_filter=owner_filter, department_filter=item["name"]) - department_rollup_html.append( - "" - f"{html.escape(item['name'])}" - f"{item['users_count']}" - f"{item['inactive_users']}" - f"{item['below_target_users']}" - f"{item['critical_actions_count']}" - f"{item['high_actions_count']}" - f"{html.escape(item['workday_total_active_hhmm'])}" - f"{item['portfolio_coverage_pct']}" - f"{html.escape(', '.join(item['users']) if item['users'] else '-')}" - "" - ) - if not department_rollup_html: - department_rollup_html.append("Нет данных по подразделениям.") - - owner_profile_html = [] - for item in payload.get("owner_roster", []): - owner_profile_html.append( - "
    " - f"
    0 else ('high' if item['high_actions_count'] > 0 else 'low')}'>{html.escape(item['display_name'])}
    " - f"

    {html.escape(item['title'] or item['name'])}

    " - f"
    Подразделение: {html.escape(item['department'] or '-')}
    " - f"

    Пользователи: {item['users_count']} · inactive: {item['inactive_users']} · actions: {item['actions_count']}

    " - f"

    Контакт: {html.escape(item['contact'] or '-')}

    " - f"

    Эскалация: {html.escape(item['escalation_to'] or '-')}

    " - f"

    {html.escape(item['notes'] or 'Без дополнительных заметок.')}

    " - "
    " - ) - if not owner_profile_html: - owner_profile_html.append("

    Каталог ответственных пуст

    Добавьте блок owners в worktime-manager-aliases.json, чтобы отчёт показывал роли, контакты и эскалацию.

    ") - - sources_html = [] - for source in payload.get("sources", []): - source_priority_class = "low" if source["status"] in {"ok", "inactive"} else ("critical" if source["required"] else "medium") - sources_html.append( - "" - f"{html.escape(source['label'])}" - f"{html.escape(source['status_label'])}" - f"{html.escape(source['bucket_id'])}" - f"{html.escape(source.get('timestamp') or '-')}" - f"{html.escape(str(source.get('age_seconds')) if source.get('age_seconds') is not None else '-')}" - f"{html.escape(source.get('event_summary') or source.get('summary') or '-')}" - "" - ) - if not sources_html: - sources_html.append("Статусы источников недоступны.") - - focus_html = [] - for item in executive.get("focus_items", []): - focus_html.append( - "
    " - f"
    {html.escape(item['priority'])}
    " - f"

    {html.escape(item['title'])}

    " - f"
    Ответственный: {html.escape(item['owner'])}
    " - f"

    {html.escape(item['reason'])}

    " - f"{html.escape(item['recommended_action'])}" - "
    " - ) - if not focus_html: - focus_html.append("

    Критичных действий нет

    На текущий момент менеджерских отклонений по активным правилам не найдено.

    ") - - stale_html = [] - for item in executive.get("stale_sources", []): - stale_html.append( - "
  • " - f"{html.escape(item['label'])}: {html.escape(item['status'])} · {html.escape(item['summary'])}" - "
  • " - ) - stale_block = ( - "
    Проблемы со свежестью источников:
      " - + "".join(stale_html) - + "
    " - ) if stale_html else "" - filter_parts = [] - if owner_filter: - filter_parts.append(f"ответственный: {html.escape(owner_filter)}") - if department_filter: - filter_parts.append(f"подразделение: {html.escape(department_filter)}") - filter_block = ( - "
    Фильтр: " - + " · ".join(filter_parts) - + f" Сбросить
    " - ) if filter_parts else "" - - cards = [ - ("Пользователи", str(summary["users_count"])), - ("Активны", str(summary["active_users"])), - ("Без активности", str(summary["inactive_users"])), - ("Ниже цели", str(summary["below_target_users"])), - ("Покрытие", f"{summary['portfolio_coverage_pct']}%"), - ("Действия", str(summary["actions_count"])), - ("Рабочее окно", summary["workday_total_active_hhmm"]), - ("Календарный день", summary["calendar_total_active_hhmm"]), - ] - - return f""" - - - - - AW-rus Управленческий отчёт по работе в RDP - - - -
    -
    -

    Управленческий отчёт по работе в RDP

    -
    Хост: {html.escape(host)} · Дата: {html.escape(report_date)} · Рабочее окно: {html.escape(workday['start_local'])} -> {html.escape(workday['end_local'])} · Сформировано UTC: {html.escape(payload['generated_at_utc'])}
    - -
    - {''.join(f"
    {html.escape(label)}{html.escape(value)}
    " for label, value in cards)} -
    -
    - Целевое покрытие: {MANAGER_TARGET_COVERAGE_PCT}% от ожидаемого рабочего окна на пользователя. - Критический провал: ниже {MANAGER_LOW_COVERAGE_PCT}% или полное отсутствие активности. - Рабочее окно считается отдельно от календарной активности, чтобы ночная работа не маскировала дневной провал. -
    -
    -
    -

    Что делать сегодня

    -
    - {html.escape(executive.get('headline') or 'Сводка недоступна')} -

    {html.escape(executive.get('message') or '')}

    - {filter_block} - {stale_block} -
    -
    - {''.join(focus_html)} -
    -
    -
    -

    Тренд за {MANAGER_TREND_DAYS} дней

    - - - - - - - - - - - - - - - {''.join(trend_html)} - -
    ДатаПользователиАктивныБез активностиРабочее окноПокрытие, %ДействияCritical
    -
    -
    -

    По ответственным

    - - - - - - - - - - - - - - - - {''.join(owner_rollup_html)} - -
    ОтветственныйСотрудникиБез активностиНиже целиCriticalHighРабочее окноПокрытие, %Кого затрагивает
    -
    -
    -

    Ответственные и эскалация

    -
    - {''.join(owner_profile_html)} -
    -
    -
    -

    По подразделениям

    - - - - - - - - - - - - - - - - {''.join(department_rollup_html)} - -
    ПодразделениеСотрудникиБез активностиНиже целиCriticalHighРабочее окноПокрытие, %Кого затрагивает
    -
    -
    -

    Очередь действий руководителя

    - - - - - - - - - - - - - {''.join(actions_html)} - -
    ПриоритетСотрудникТипСрокПочему это важноЧто сделать
    -
    -
    -

    Покрытие по сотрудникам

    - - - - - - - - - - - - - - - - - - {''.join(roster_html)} - -
    СотрудникУчётная записьОтветственныйПодразделениеАктивно в окнеАктивно за деньПокрытие, %СтатусПервая активность в окнеПоследняя активность в окнеСессии
    -
    -
    -

    Свежесть источников данных

    - - - - - - - - - - - - - {''.join(sources_html)} - -
    ИсточникСтатусБакетПоследнее событие UTCВозраст, секКонтекст
    -
    -
    - -""" - - -def build_worktime_report_response(path, params): - fmt = resolve_report_format(params) - host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) - day = params.get("day", ["today"])[0] - date_text = params.get("date", [None])[0] - owner_filter = normalize_management_filter(params.get("owner", [""])[0]) - department_filter = normalize_management_filter(params.get("department", [""])[0]) - report_date = resolve_report_date(day=day, date_text=date_text) - is_management = path == "/reports/worktime/management" - allow_immediate_stale = str(params.get("allow_stale", [""])[0]).lower() in {"1", "true", "yes"} - management_payload = ( - management_report_for_date( - host, - report_date, - owner_filter=owner_filter, - department_filter=department_filter, - use_cache=allow_immediate_stale, - ) - if is_management - else None - ) - rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] - true_active_apps = [] if is_management else build_true_active_apps(host, report_date) - - if fmt == "csv": - if is_management: - out = io.StringIO() - writer = csv.DictWriter( - out, - fieldnames=["priority", "owner", "user_id", "action_id", "deadline_hint", "reason", "recommended_action"], - extrasaction="ignore", - ) - writer.writeheader() - writer.writerows(management_payload["actions"]) - return out.getvalue().encode(), "text/csv; charset=utf-8" - out = io.StringIO() - writer = csv.DictWriter( - out, - fieldnames=[ - "user", - "user_id", - "active_seconds", - "active_hhmm", - "first_activity", - "last_activity", - "idle_seconds", - "sessions_count", - "samples_count", - "active_samples", - ], - ) - writer.writeheader() - writer.writerows(rows) - return out.getvalue().encode(), "text/csv; charset=utf-8" - - if fmt == "html": - if is_management: - data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") - else: - data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None, true_active_apps=true_active_apps).encode("utf-8") - return data, "text/html; charset=utf-8" - - if is_management: - data = json.dumps(management_payload, ensure_ascii=False, indent=2).encode("utf-8") - return data, "application/json; charset=utf-8" - - obj = { - "generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), - "report_timezone": str(REPORT_TZ), - "host": host, - "report_date": report_date.isoformat(), - "bucket_id": get_sessions_bucket_id(host), - "rows": rows, - "true_active_apps": true_active_apps, - } - data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") - return data, "application/json; charset=utf-8" - - -def send_bytes(handler, data, content_type, status=200): - handler.send_response(status) - handler.send_header("Content-Type", content_type) - handler.send_header("Content-Length", str(len(data))) - handler.end_headers() - try: - handler.wfile.write(data) - except (BrokenPipeError, ConnectionResetError): - return False - return True - - -class H(BaseHTTPRequestHandler): - def do_GET(self): - parsed = urlparse(self.path) - if parsed.path in {"/health", "/api/health"}: - data = json.dumps(worktime_health_payload(), ensure_ascii=False, indent=2).encode("utf-8") - send_bytes(self, data, "application/json; charset=utf-8") - return - - if parsed.path.startswith("/dlp-ioc/"): - name = parsed.path.rsplit("/", 1)[-1] - if name not in {"ioc_blacklist.json", "ioc_blacklist.csv", "ioc_blacklist.sql"}: - self.send_response(404) - self.end_headers() - return - path = os.path.join(IOC_DIR, name) - if not os.path.isfile(path): - self.send_response(404) - self.end_headers() - return - with open(path, "rb") as f: - data = f.read() - if name.endswith(".json"): - ctype = "application/json; charset=utf-8" - elif name.endswith(".csv"): - ctype = "text/csv; charset=utf-8" - else: - ctype = "text/plain; charset=utf-8" - send_bytes(self, data, ctype) - return - - if parsed.path not in {"/reports/worktime/today", "/reports/worktime/management"}: - self.send_response(404) - self.end_headers() - return - - params = parse_qs(parsed.query, keep_blank_values=False) - fmt = resolve_report_format(params, self.headers.get("Accept", "")) - if not params.get("format") and fmt != "json": - params = {name: list(values) for name, values in params.items()} - params["format"] = [fmt] - host = resolve_host(params.get("host", [DEFAULT_HOST])[0]) - day = params.get("day", ["today"])[0] - date_text = params.get("date", [None])[0] - owner_filter = normalize_management_filter(params.get("owner", [""])[0]) - department_filter = normalize_management_filter(params.get("department", [""])[0]) - report_date = resolve_report_date(day=day, date_text=date_text) - cache_key = make_report_cache_key( - parsed.path, - fmt, - host, - report_date, - selected_day=day if day in {"today", "yesterday"} else "", - owner_filter=owner_filter, - department_filter=department_filter, - ) - - send_worktime_report(self, parsed.path, params, cache_key, host=host, report_date=report_date) - - -class WorktimeHTTPServer(ThreadingHTTPServer): - daemon_threads = True - request_queue_size = 64 - - -def main(): - WorktimeHTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever(poll_interval=0.5) - - -if __name__ == "__main__": - main() diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service index e85d3bf..07e32d8 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-api.service @@ -8,7 +8,7 @@ StartLimitIntervalSec=60 [Service] Type=simple EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/bin/python3 /usr/local/bin/aw-worktime-api.py +ExecStart=/usr/local/bin/aw-worktime-api-rust Restart=on-failure RestartSec=5 User=activitywatch diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service index a013770..6f3cb29 100644 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service +++ b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.service @@ -6,10 +6,9 @@ Wants=network-online.target activitywatch-server.service aw-worktime-api.service [Service] Type=oneshot EnvironmentFile=/etc/activitywatch/aw-server.env -ExecStart=/usr/local/bin/aw-worktime-prewarm.sh +ExecStart=/usr/local/bin/aw-worktime-prewarm-rust User=activitywatch Group=activitywatch StandardOutput=journal StandardError=journal SyslogIdentifier=aw-worktime-prewarm - diff --git a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh b/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh deleted file mode 100644 index 7ae33e9..0000000 --- a/install-kit-awindows-20260427-211240/aw-server/aw-worktime-prewarm.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -WORKTIME_BASE_URL="${WORKTIME_BASE_URL:-http://127.0.0.1:5610}" -WORKTIME_PREWARM_TIMEOUT_SECONDS="${WORKTIME_PREWARM_TIMEOUT_SECONDS:-45}" -WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS="${WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS:-10}" -WORKTIME_PREWARM_READY_TIMEOUT_SECONDS="${WORKTIME_PREWARM_READY_TIMEOUT_SECONDS:-60}" -WORKTIME_PREWARM_READY_INTERVAL_SECONDS="${WORKTIME_PREWARM_READY_INTERVAL_SECONDS:-2}" -WORKTIME_PREWARM_HOST="${WORKTIME_PREWARM_HOST:-${AW_WORKTIME_HOST:-SHARKON2025}}" -WORKTIME_PREWARM_PROFILE="${WORKTIME_PREWARM_PROFILE:-full}" - -log() { - printf '%s %s\n' "$(date '+%F %T')" "$*" -} - -probe() { - local url="$1" - local timeout="$2" - local tmp - tmp="$(mktemp)" - local code - code="$(curl -sS --max-time "$timeout" -o /dev/null -D "$tmp" -w '%{http_code}' "$url" 2>/dev/null || true)" - if [[ "$code" =~ ^2 ]]; then - local cache - cache="$(tr '\r' '\n' < "$tmp" | awk 'tolower($0) ~ /^x-aw-worktime-cache:/ {print $2; exit}' || true)" - local reason - reason="$(tr '\r' '\n' < "$tmp" | awk 'tolower($0) ~ /^x-aw-worktime-cache-reason:/ {print $2; exit}' || true)" - rm -f "$tmp" - log "ok code=$code cache=${cache:-none} reason=${reason:-none} url=$url" - return 0 - fi - rm -f "$tmp" - log "warn code=${code:-000} url=$url" - return 1 -} - -wait_until_ready() { - local deadline - deadline=$(( $(date +%s) + WORKTIME_PREWARM_READY_TIMEOUT_SECONDS )) - while true; do - if probe "$WORKTIME_BASE_URL/health" "$WORKTIME_PREWARM_HEALTH_TIMEOUT_SECONDS"; then - return 0 - fi - if [[ "$(date +%s)" -ge "$deadline" ]]; then - return 1 - fi - sleep "$WORKTIME_PREWARM_READY_INTERVAL_SECONDS" - done -} - -if ! wait_until_ready; then - log "health readiness timed out; skip prewarm" - exit 0 -fi - -full_urls=( - "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" - "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=json&host=$WORKTIME_PREWARM_HOST" - "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=html&host=$WORKTIME_PREWARM_HOST" - "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" - "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=json&host=$WORKTIME_PREWARM_HOST" - "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=html&host=$WORKTIME_PREWARM_HOST" -) - -startup_urls=( - "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" - "$WORKTIME_BASE_URL/reports/worktime/today?day=today&format=json&host=$WORKTIME_PREWARM_HOST" - "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=json&host=$WORKTIME_PREWARM_HOST" - "$WORKTIME_BASE_URL/reports/worktime/management?day=today&format=csv&host=$WORKTIME_PREWARM_HOST" -) - -case "$WORKTIME_PREWARM_PROFILE" in - full) - urls=("${full_urls[@]}") - ;; - startup) - urls=("${startup_urls[@]}") - ;; - *) - log "unknown profile=$WORKTIME_PREWARM_PROFILE" - exit 0 - ;; -esac - -failures=0 -for url in "${urls[@]}"; do - if ! probe "$url" "$WORKTIME_PREWARM_TIMEOUT_SECONDS"; then - failures=$((failures + 1)) - fi -done - -if [[ "$failures" -gt 0 ]]; then - log "completed profile=$WORKTIME_PREWARM_PROFILE with failures=$failures" -else - log "completed profile=$WORKTIME_PREWARM_PROFILE successfully" -fi - -exit 0 diff --git a/install-kit-awindows-20260427-211240/aw-server/health-check.sh b/install-kit-awindows-20260427-211240/aw-server/health-check.sh deleted file mode 100644 index 7df5693..0000000 --- a/install-kit-awindows-20260427-211240/aw-server/health-check.sh +++ /dev/null @@ -1,173 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Health check script for AW services -# Returns 0 if all services are healthy, 1 otherwise - -SERVICES=("activitywatch-server" "aw-worktime-api" "aw-worktime-ui-bridge") -UNHEALTHY_SERVICES=() -WARNINGS=() - -if [[ -f /etc/activitywatch/aw-server.env ]]; then - # shellcheck disable=SC1091 - source /etc/activitywatch/aw-server.env -fi - -WORKTIME_HEALTH_BASE="${AW_RUS_HEALTH_WORKTIME_API:-${AW_WORKTIME_REPORT_BASE:-http://127.0.0.1:5610}}" -WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_BASE%/}/health" -WORKTIME_HEALTH_TIMEOUT_SECONDS="${AW_RUS_HEALTH_WORKTIME_TIMEOUT_SECONDS:-15}" -AW_HEALTH_API_TIMEOUT_SECONDS="${AW_RUS_HEALTH_AW_TIMEOUT_SECONDS:-15}" -AW_HEALTH_API_ATTEMPTS="${AW_RUS_HEALTH_AW_ATTEMPTS:-3}" -SETTINGS_TIMEOUT_SECONDS="${AW_RUS_HEALTH_SETTINGS_TIMEOUT_SECONDS:-15}" -SETTINGS_ATTEMPTS="${AW_RUS_HEALTH_SETTINGS_ATTEMPTS:-3}" - -check_service() { - local service=$1 - if [[ "$service" == "aw-worktime-ui-bridge" ]]; then - if systemctl is-active --quiet aw-worktime-ui-bridge.timer && systemctl is-enabled --quiet aw-worktime-ui-bridge.timer; then - echo "✓ aw-worktime-ui-bridge.timer is running and enabled" - else - echo "✗ aw-worktime-ui-bridge.timer is not active/enabled" - UNHEALTHY_SERVICES+=("aw-worktime-ui-bridge.timer") - fi - return - fi - if systemctl is-active --quiet "$service"; then - echo "✓ $service is running" - else - echo "✗ $service is not running" - UNHEALTHY_SERVICES+=("$service") - fi -} - -check_api_endpoint() { - local url=$1 - local service_name=$2 - local timeout_seconds=${3:-5} - local attempts=${4:-1} - local attempt - - for ((attempt=1; attempt<=attempts; attempt++)); do - if curl -fsS --connect-timeout 3 --max-time "$timeout_seconds" "$url" >/dev/null 2>&1; then - echo "✓ $service_name API endpoint is responding" - return - fi - if (( attempt < attempts )); then - sleep 1 - fi - done - - echo "✗ $service_name API endpoint is not responding" - UNHEALTHY_SERVICES+=("$service_name-api") -} - -read_setting_value() { - local key=$1 - local attempt - for ((attempt=1; attempt<=SETTINGS_ATTEMPTS; attempt++)); do - if curl -fsS --connect-timeout 3 --max-time "$SETTINGS_TIMEOUT_SECONDS" "http://127.0.0.1:5600/api/0/settings/${key}" 2>/dev/null | \ - python3 -c 'import json,sys; print(json.load(sys.stdin))'; then - return 0 - fi - if (( attempt < SETTINGS_ATTEMPTS )); then - sleep 1 - fi - done - return 1 -} - -check_expected_setting() { - local key=$1 - local expected=$2 - local label=${3:-$1} - - if [[ -z "$expected" ]]; then - echo "⚠ expected value for ${label} is not configured, skipping drift check" - WARNINGS+=("${key}-expected-missing") - return - fi - - local actual - if ! actual="$(read_setting_value "$key" 2>/dev/null)"; then - echo "✗ failed to read setting ${label}" - UNHEALTHY_SERVICES+=("setting-${key}") - return - fi - - if [[ "$actual" == "$expected" ]]; then - echo "✓ ${label} matches expected value (${expected})" - else - echo "✗ ${label} drift detected: actual='${actual}' expected='${expected}'" - UNHEALTHY_SERVICES+=("setting-${key}") - fi -} - -check_dlp_transport_freshness() { - local dlp_health="${DLP_HEALTH_BIN:-/usr/local/bin/dlp-health-check}" - local result - - if [[ ! -x "$dlp_health" ]]; then - echo "⚠ dlp-health-check is not available, skipping DLP transport freshness checks" - WARNINGS+=("dlp-health-check-missing") - return - fi - - result="$("$dlp_health" --json 2>/dev/null || true)" - if [[ -z "$result" ]]; then - echo "⚠ dlp-health-check did not return JSON, skipping DLP transport freshness checks" - WARNINGS+=("dlp-health-check-empty") - return - fi - - local ok - ok="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); names={r["name"]:r for r in data.get("results", [])}; checks=["buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics"]; bad=[n for n in checks if names.get(n,{}).get("status")=="fail"]; print("1" if not bad else "0")' 2>/dev/null || echo "0")" - if [[ "$ok" == "1" ]]; then - echo "✓ DLP transport freshness check passed" - else - echo "✗ DLP transport freshness check failed" - UNHEALTHY_SERVICES+=("dlp-transport") - fi - - local errors warnings - errors="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="fail" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)" - warnings="$(printf '%s' "$result" | python3 -c 'import json,sys; data=json.load(sys.stdin); out=[]; [out.append(f"{r.get(\"name\")}:{r.get(\"summary\")}") for r in data.get("results", []) if r.get("status")=="warn" and r.get("name") in ("buckets:endpoint-signals","buckets:file-operations","endpoint-self-test-metrics")]; print(", ".join(out))' 2>/dev/null || true)" - if [[ -n "$errors" ]]; then - echo " errors: $errors" - fi - if [[ -n "$warnings" ]]; then - echo " warnings: $warnings" - WARNINGS+=("$warnings") - fi -} - -echo "=== AW Services Health Check ===" -echo "Timestamp: $(date)" -echo - -# Check systemd services -for service in "${SERVICES[@]}"; do - check_service "$service" -done - -echo - -# Check API endpoints -check_api_endpoint "http://127.0.0.1:5600/api/0/info" "activitywatch-server" "$AW_HEALTH_API_TIMEOUT_SECONDS" "$AW_HEALTH_API_ATTEMPTS" -check_api_endpoint "$WORKTIME_HEALTH_URL" "aw-worktime-api" "$WORKTIME_HEALTH_TIMEOUT_SECONDS" 2 -check_dlp_transport_freshness "http://127.0.0.1:5600/api/0" "900" "${AW_HEALTH_STRICT_FILEOPS:-0}" -check_expected_setting "startOfDay" "${AW_EXPECT_START_OF_DAY:-}" "startOfDay" -check_expected_setting "always_active_pattern" "${AW_EXPECT_ALWAYS_ACTIVE_PATTERN:-}" "always_active_pattern" -check_expected_setting "landingpage" "${AW_EXPECT_LANDINGPAGE:-}" "landingpage" - -echo - -if [ ${#UNHEALTHY_SERVICES[@]} -eq 0 ]; then - echo "✓ All services are healthy" - if [ ${#WARNINGS[@]} -gt 0 ]; then - echo "⚠ Warnings: ${WARNINGS[*]}" - fi - exit 0 -else - echo "✗ Unhealthy services: ${UNHEALTHY_SERVICES[*]}" - exit 1 -fi diff --git a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh index 34930d6..cdc1241 100755 --- a/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh +++ b/install-kit-awindows-20260427-211240/aw-server/install_aw_server.sh @@ -24,13 +24,13 @@ required_vars=( BOOTSTRAP_DIR="/root/bootstrap" VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json" CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json" -WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py" +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_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py" +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" -HEALTHD_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.py" +HEALTHD_RUST_SRC="$BOOTSTRAP_DIR/aw-rus-healthd" HEALTHD_SERVICE_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.service" HEALTHD_TIMER_SRC="$BOOTSTRAP_DIR/aw-rus-healthd.timer" @@ -99,11 +99,11 @@ systemctl enable activitywatch-server.service systemctl restart activitywatch-server.service systemctl --no-pager --full status activitywatch-server.service || true -if [[ -f "$WORKTIME_API_SRC" ]]; then - install -m 0755 "$WORKTIME_API_SRC" /usr/local/bin/aw-worktime-api.py +if [[ -f "$WORKTIME_API_RUST_SRC" ]]; then + install -m 0755 "$WORKTIME_API_RUST_SRC" /usr/local/bin/aw-worktime-api-rust fi -if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then +if [[ -f "$WORKTIME_API_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-api-rust ]]; then install -m 0644 "$WORKTIME_API_SERVICE_SRC" /etc/systemd/system/aw-worktime-api.service systemctl daemon-reload systemctl enable aw-worktime-api.service @@ -119,15 +119,15 @@ if [[ -f "$WORKTIME_ALIASES_SRC" ]]; then fi fi -if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then - install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py +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 -if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" ]]; then +if [[ -f "$WORKTIME_UI_BRIDGE_SERVICE_SRC" && -f /usr/local/bin/aw-worktime-ui-bridge-rust ]]; then install -m 0644 "$WORKTIME_UI_BRIDGE_SERVICE_SRC" /etc/systemd/system/aw-worktime-ui-bridge.service fi -if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then +if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" && -f /usr/local/bin/aw-worktime-ui-bridge-rust ]]; then install -m 0644 "$WORKTIME_UI_BRIDGE_TIMER_SRC" /etc/systemd/system/aw-worktime-ui-bridge.timer systemctl daemon-reload systemctl disable --now aw-worktime-afk-bridge.timer >/dev/null 2>&1 || true @@ -137,15 +137,15 @@ if [[ -f "$WORKTIME_UI_BRIDGE_TIMER_SRC" ]]; then systemctl --no-pager --full status aw-worktime-ui-bridge.timer || true fi -if [[ -f "$HEALTHD_SRC" ]]; then - install -m 0755 "$HEALTHD_SRC" /usr/local/bin/aw-rus-healthd.py +if [[ -f "$HEALTHD_RUST_SRC" ]]; then + install -m 0755 "$HEALTHD_RUST_SRC" /usr/local/bin/aw-rus-healthd-rust fi -if [[ -f "$HEALTHD_SERVICE_SRC" ]]; then +if [[ -f "$HEALTHD_SERVICE_SRC" && -f /usr/local/bin/aw-rus-healthd-rust ]]; then install -m 0644 "$HEALTHD_SERVICE_SRC" /etc/systemd/system/aw-rus-healthd.service fi -if [[ -f "$HEALTHD_TIMER_SRC" ]]; then +if [[ -f "$HEALTHD_TIMER_SRC" && -f /usr/local/bin/aw-rus-healthd-rust ]]; then install -m 0644 "$HEALTHD_TIMER_SRC" /etc/systemd/system/aw-rus-healthd.timer systemctl daemon-reload systemctl enable aw-rus-healthd.timer diff --git a/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh b/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh index ebc8624..66d2670 100644 --- a/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh +++ b/install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.sh @@ -3,6 +3,19 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +rust_candidates=( + "${AW_BROWSER_SMOKE_RUST:-}" + "${CARGO_TARGET_DIR:-}/release/aw-browser-smoke" + "$ROOT_DIR/adk-rust/target/release/aw-browser-smoke" + "/usr/local/bin/aw-browser-smoke" +) + +for rust_bin in "${rust_candidates[@]}"; do + if [[ -n "$rust_bin" && -x "$rust_bin" ]]; then + exec "$rust_bin" --root "$ROOT_DIR" -- "$@" + fi +done + if [[ -z "${NODE_PATH:-}" && -d /home/igor/.agents/skills/playwright/node_modules ]]; then export NODE_PATH=/home/igor/.agents/skills/playwright/node_modules fi diff --git a/install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh b/install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh index ff2600b..a6db8d7 100644 --- a/install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh +++ b/install-kit-awindows-20260427-211240/scripts/check_install_kit_vs_repo.sh @@ -24,59 +24,5 @@ for candidate in "${rust_candidates[@]}"; do fi done -python3 - <<'PY' -from pathlib import Path -import hashlib -import sys - -root=Path('.') -kit=Path('install-kit-awindows-20260427-211240') -if not kit.exists(): - raise SystemExit('Install kit directory not found') - -def sha(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - -all_compared=[] -mismatches=[] -missing_in_repo=[] -allowed_kit_only_prefixes=('server-configs-' ,) -allowed_kit_only_files={'README-INSTALL-KIT.txt'} - -for kp in sorted(p for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'): - rel=kp.relative_to(kit) - rp=root/rel - if not rp.exists(): - rel_str=str(rel) - if rel_str in allowed_kit_only_files or rel_str.startswith(allowed_kit_only_prefixes): - continue - missing_in_repo.append(str(rel)) - continue - all_compared.append(str(rel)) - if sha(kp)!=sha(rp): - mismatches.append(str(rel)) - -ps_mismatches=[ - p for p in mismatches - if p.startswith('windows/') and p.endswith(('.ps1', '.psm1', '.psd1')) -] - -print(f'Compared files: {len(all_compared)}') -print(f'Missing in repo: {len(missing_in_repo)}') -print(f'Mismatched content: {len(mismatches)}') -if missing_in_repo: - print('--- Missing in repo ---') - for p in missing_in_repo: - print(p) -if mismatches: - print('--- Mismatches ---') - for p in mismatches: - print(p) -print(f'PowerShell mismatches: {len(ps_mismatches)}') -if ps_mismatches: - print('--- PowerShell mismatches ---') - for p in ps_mismatches: - print(p) -if missing_in_repo or mismatches: - sys.exit(1) -PY +echo "ERROR: Rust checker not found. Build it with: cd '$ROOT_DIR/adk-rust' && cargo build --release -p check-install-kit-vs-repo" >&2 +exit 127 diff --git a/install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh b/install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh index 13936f0..001c39e 100644 --- a/install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh +++ b/install-kit-awindows-20260427-211240/scripts/rebuild_install_kit.sh @@ -23,150 +23,5 @@ for candidate in "${rust_candidates[@]}"; do fi done -KIT_DIR="install-kit-awindows-20260427-211240" -ZIP_ARCHIVE="${KIT_DIR}.zip" -TAR_ARCHIVE="${KIT_DIR}.tar.gz" -SERVER_CONFIG_DIR="${KIT_DIR}/server-configs-192.168.100.18" -OLD_SERVER_CONFIG_DIR="${KIT_DIR}/server-configs-192.168.100.21" -TMP_SERVER_CONFIG_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_SERVER_CONFIG_DIR"' EXIT - -copy_file() { - local src="$1" - local dest="$2" - mkdir -p "$(dirname "$dest")" - cp "$src" "$dest" -} - -sync_tree() { - local base="$1" - shift - for rel in "$@"; do - copy_file "$rel" "${KIT_DIR}/${rel}" - done -} - -if [[ -d "$OLD_SERVER_CONFIG_DIR" ]]; then - cp "$OLD_SERVER_CONFIG_DIR"/*.deployment-config.json "$TMP_SERVER_CONFIG_DIR"/ -fi -if [[ -d "$SERVER_CONFIG_DIR" ]]; then - cp "$SERVER_CONFIG_DIR"/*.deployment-config.json "$TMP_SERVER_CONFIG_DIR"/ -fi - -rm -rf "${KIT_DIR}/ansible" "${KIT_DIR}/aw-server" "${KIT_DIR}/windows" "${KIT_DIR}/scripts" "${KIT_DIR}/server-configs-"* - -ansible_files=( - ansible/README.md - ansible/deploy_aw_pfsense_poller.yml - ansible/deploy_aw_server.yml - ansible/deploy_aw_windows.yml - ansible/group_vars/all.example.yml - ansible/group_vars/pfsense-poller.example.yml - ansible/group_vars/proxmox-matrix.example.yml - ansible/group_vars/proxmox.example.yml - ansible/group_vars/windows.example.yml - ansible/install_full_stack.yml - ansible/inventory.example.ini - ansible/provision_proxmox_ct_and_deploy_aw.yml - ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml - ansible/tasks/provision_ct_and_deploy_aw.yml -) - -aw_server_files=( - aw-server/activitywatch-server.service - aw-server/apply_webui_ru_patch.sh - aw-server/aw-host-groups.json - aw-server/aw-ru-patch.js - aw-server/aw-rus-healthd.py - aw-server/aw-rus-healthd.service - aw-server/aw-rus-healthd.timer - aw-server/aw-browser-smoke.service - aw-server/aw-browser-smoke.timer - aw-server/aw-slo-monitor.py - aw-server/aw-slo-monitor.service - aw-server/aw-slo-monitor.timer - aw-server/aw-server.env.example - aw-server/aw-sw-cleanup.js - aw-server/aw-worktime-api.py - aw-server/aw-worktime-api.service - aw-server/aw-worktime-prewarm.sh - aw-server/aw-worktime-prewarm.service - aw-server/aw-worktime-prewarm.timer - aw-server/aw-worktime-panel.js - aw-server/health-check.sh - aw-server/install_aw_server.sh - aw-server/settings/classes-worktime.json - aw-server/settings/views-default.json -) - -windows_files=( - windows/ActivityWatch.Windows.Common.psd1 - windows/ActivityWatch.Windows.Common.psm1 - windows/browser-domains-native-collector.ps1 - windows/deploy-domain-users.ps1 - windows/deploy-ensemble.ps1 - windows/deploy-single-user.ps1 - windows/AWatchRusCollectorGuardService.cs - windows/aw-collector-guard.ps1 - windows/install-collector-guard-service.ps1 - windows/dlp-endpoint-signals-collector.ps1 - windows/dlp-policy.example.json - windows/dlp-policy.native-cross-os.example.json - windows/email-outbound-collector.ps1 - windows/hardening-recovery.ps1 - windows/migrate-awatch-rus-paths.ps1 - windows/validate-deployment.ps1 - windows/web-category-rules.example.json - windows/worktime-session-collector.ps1 -) - -scripts_files=( - scripts/aw-webui-browser-smoke.mjs - scripts/aw-webui-browser-smoke.sh - scripts/check_install_kit_vs_repo.sh - scripts/quality-gate.sh - scripts/rebuild_install_kit.sh - scripts/validate_install_kit.sh - scripts/verify_innosetup_installer.sh -) - -sync_tree ansible "${ansible_files[@]}" -sync_tree aw-server "${aw_server_files[@]}" -sync_tree windows "${windows_files[@]}" -sync_tree scripts "${scripts_files[@]}" - -mkdir -p "$SERVER_CONFIG_DIR" -if compgen -G "$TMP_SERVER_CONFIG_DIR/*.deployment-config.json" >/dev/null; then - cp "$TMP_SERVER_CONFIG_DIR"/*.deployment-config.json "$SERVER_CONFIG_DIR"/ -fi - -cat > "${KIT_DIR}/README-INSTALL-KIT.txt" <<'EOF' -ActivityWatch DetMir Windows Install Kit - -Includes: -- windows/* (deploy scripts, collectors, common module, configs/examples) -- ansible/* (Windows and AW server playbooks, examples, inventory, tasks) -- aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings) -- scripts/* (install-kit rebuild/validation, quality gates and browser/web smoke checks) -- server-configs-192.168.100.18/* (working Windows/RDP config snapshots) - -Source: -- Local project snapshot at build time. -EOF - -python3 - <<'PY' -from pathlib import Path -import hashlib - -root = Path('install-kit-awindows-20260427-211240') -manifest = root / 'MANIFEST.txt' -files = sorted(p for p in root.rglob('*') if p.is_file() and p.name != 'MANIFEST.txt') -with manifest.open('w', encoding='utf-8') as handle: - for path in files: - digest = hashlib.sha256(path.read_bytes()).hexdigest() - handle.write(f"{digest} {path.as_posix()}\n") -PY - -rm -f "$ZIP_ARCHIVE" "$TAR_ARCHIVE" -zip -rq "$ZIP_ARCHIVE" "$KIT_DIR" -tar -czf "$TAR_ARCHIVE" "$KIT_DIR" +echo "ERROR: Rust install-kit builder not found. Build it with: cd '$ROOT_DIR/adk-rust' && cargo build --release -p rebuild-install-kit" >&2 +exit 127 diff --git a/install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh b/install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh index 94b924a..c209127 100644 --- a/install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh +++ b/install-kit-awindows-20260427-211240/scripts/validate_install_kit.sh @@ -32,75 +32,5 @@ for candidate in "${rust_candidates[@]}"; do fi done -required_files=( - "$MANIFEST" - "$KIT_DIR/README-INSTALL-KIT.txt" - "$KIT_DIR/windows/deploy-ensemble.ps1" - "$KIT_DIR/windows/validate-deployment.ps1" - "$KIT_DIR/ansible/deploy_aw_windows.yml" - "$KIT_DIR/aw-server/install_aw_server.sh" - "$KIT_DIR/scripts/rebuild_install_kit.sh" - "$KIT_DIR/scripts/validate_install_kit.sh" - "$KIT_DIR/scripts/check_install_kit_vs_repo.sh" - "$KIT_DIR/scripts/quality-gate.sh" -) - -echo "[1/4] Required files presence" -for file in "${required_files[@]}"; do - [[ -f "$file" ]] || { echo "Missing required file: $file"; exit 1; } -done - -echo "[2/4] Manifest checksum verification" -sha256sum -c "$MANIFEST" >/dev/null - -echo "[3/4] Manifest completeness" -python3 - <<'PY' -from pathlib import Path -import sys -kit=Path('install-kit-awindows-20260427-211240') -manifest=kit/'MANIFEST.txt' -listed=[] -for line in manifest.read_text().splitlines(): - line=line.strip() - if not line: - continue - parts=line.split(' ',1) - if len(parts)!=2: - print(f'Invalid MANIFEST line: {line}') - sys.exit(1) - listed.append(parts[1]) -listed_set=set(listed) -actual_set={str(p) for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'} -missing=sorted(listed_set-actual_set) -extra=sorted(actual_set-listed_set) -if missing or extra: - print('Missing files listed in MANIFEST:', missing) - print('Files not listed in MANIFEST:', extra) - sys.exit(1) -print(f'MANIFEST complete: {len(actual_set)} files tracked') -PY - -echo "[4/4] Archive composition check" -python3 - <<'PY' -from pathlib import Path -import tarfile, zipfile, sys -kit_prefix='install-kit-awindows-20260427-211240/' -zip_path=Path('install-kit-awindows-20260427-211240.zip') -tar_path=Path('install-kit-awindows-20260427-211240.tar.gz') -if not zip_path.exists() or not tar_path.exists(): - print('Archives not found') - sys.exit(1) -with zipfile.ZipFile(zip_path) as z: - zip_files=sorted(i for i in z.namelist() if not i.endswith('/')) -with tarfile.open(tar_path, 'r:gz') as t: - tar_files=sorted(m.name for m in t.getmembers() if m.isfile()) -if zip_files != tar_files: - print('ZIP and TAR contents differ') - sys.exit(1) -if not all(f.startswith(kit_prefix) for f in zip_files): - print('Unexpected archive prefix layout') - sys.exit(1) -print(f'Archives match: {len(zip_files)} files') -PY - -echo "validate_install_kit: OK" +echo "ERROR: Rust validator not found. Build it with: cd '$ROOT_DIR/adk-rust' && cargo build --release -p validate-install-kit" >&2 +exit 127 diff --git a/scripts/aggregate_dlp_events.py b/scripts/aggregate_dlp_events.py deleted file mode 100755 index 7be7760..0000000 --- a/scripts/aggregate_dlp_events.py +++ /dev/null @@ -1,517 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import os -import sqlite3 -import sys -import urllib.error -import urllib.parse -import urllib.request -from collections.abc import Iterable -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import Protocol, TypeAlias - - -JsonScalar: TypeAlias = str | int | float | bool | None -JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] - - -DEFAULT_BUCKET_PREFIXES = ("aw-file-operations_", "aw-dlp-incidents_") -DEFAULT_SQLITE_PATH = "data/dlp-events.sqlite3" -EVENT_COLUMNS = ( - "bucket_id", - "event_id", - "stream_type", - "hostname", - "username", - "event_ts", - "duration", - "operation", - "file_path", - "old_file_path", - "extension", - "archive_hint", - "rule_id", - "action", - "severity", - "signal_type", - "message", - "source", - "screenshot_path", - "raw_json", - "ingested_at", -) - - -@dataclass(frozen=True) -class Bucket: - id: str - type: str - client: str - hostname: str - - -@dataclass(frozen=True) -class AwEvent: - bucket_id: str - hostname: str - stream_type: str - event_id: str - timestamp: str - duration: float - data: dict[str, JsonValue] - - -class PsycopgConnection(Protocol): - def cursor(self): - ... - - def commit(self) -> None: - ... - - -def utc_now() -> datetime: - return datetime.now(tz=UTC) - - -def parse_timestamp(value: str) -> datetime: - normalized = value.replace("Z", "+00:00") - parsed = datetime.fromisoformat(normalized) - if parsed.tzinfo is None: - return parsed.replace(tzinfo=UTC) - return parsed.astimezone(UTC) - - -def format_aw_timestamp(value: datetime) -> str: - return value.astimezone(UTC).isoformat().replace("+00:00", "Z") - - -def load_state(path: Path) -> dict[str, str]: - if not path.exists(): - return {} - return json.loads(path.read_text(encoding="utf-8")) - - -def save_state(path: Path, state: dict[str, str]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def normalize_base_url(base_url: str) -> str: - return base_url.rstrip("/") - - -def aw_get_json(base_url: str, path: str, timeout: int) -> JsonValue: - url = normalize_base_url(base_url) + path - request = urllib.request.Request(url, headers={"Accept": "application/json"}) - with urllib.request.urlopen(request, timeout=timeout) as response: - return json.loads(response.read().decode("utf-8")) - - -def list_buckets(base_url: str, timeout: int) -> list[Bucket]: - payload = aw_get_json(base_url, "/buckets", timeout) - if not isinstance(payload, dict): - raise ValueError("ActivityWatch /buckets response must be a JSON object") - buckets: list[Bucket] = [] - for bucket_id, bucket_data in payload.items(): - if not isinstance(bucket_data, dict): - continue - buckets.append( - Bucket( - id=str(bucket_id), - type=str(bucket_data.get("type", "")), - client=str(bucket_data.get("client", "")), - hostname=str(bucket_data.get("hostname", "")), - ) - ) - return buckets - - -def bucket_stream_type(bucket: Bucket) -> str | None: - if bucket.id.startswith("aw-file-operations_") or bucket.type == "aw.file.operation": - return "file_operation" - if bucket.id.startswith("aw-dlp-incidents_") or bucket.type == "aw.dlp.incident": - return "dlp_incident" - return None - - -def select_buckets(buckets: Iterable[Bucket], prefixes: tuple[str, ...]) -> list[tuple[Bucket, str]]: - selected: list[tuple[Bucket, str]] = [] - for bucket in buckets: - stream_type = bucket_stream_type(bucket) - if stream_type and any(bucket.id.startswith(prefix) for prefix in prefixes): - selected.append((bucket, stream_type)) - return selected - - -def build_events_path(bucket_id: str, start: datetime, end: datetime, limit: int) -> str: - query = urllib.parse.urlencode( - { - "start": format_aw_timestamp(start), - "end": format_aw_timestamp(end), - "limit": str(limit), - } - ) - return f"/buckets/{urllib.parse.quote(bucket_id, safe='')}/events?{query}" - - -def event_key(bucket_id: str, timestamp: str, duration: float, data: dict[str, JsonValue]) -> str: - payload = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":")) - return f"{bucket_id}|{timestamp}|{duration}|{payload}" - - -def fetch_bucket_events( - base_url: str, - bucket: Bucket, - stream_type: str, - start: datetime, - end: datetime, - limit: int, - timeout: int, -) -> list[AwEvent]: - payload = aw_get_json(base_url, build_events_path(bucket.id, start, end, limit), timeout) - if not isinstance(payload, list): - raise ValueError(f"ActivityWatch events response for {bucket.id} must be a JSON array") - events: list[AwEvent] = [] - for item in payload: - if not isinstance(item, dict): - continue - timestamp = str(item["timestamp"]) - duration = float(item.get("duration", 0) or 0) - data = item.get("data") or {} - if not isinstance(data, dict): - data = {"raw": data} - item_id = str(item.get("id") or event_key(bucket.id, timestamp, duration, data)) - events.append( - AwEvent( - bucket_id=bucket.id, - hostname=bucket.hostname or str(data.get("hostname") or ""), - stream_type=stream_type, - event_id=item_id, - timestamp=timestamp, - duration=duration, - data=data, - ) - ) - return events - - -def connect_sqlite(path: Path) -> sqlite3.Connection: - path.parent.mkdir(parents=True, exist_ok=True) - connection = sqlite3.connect(str(path)) - connection.execute("PRAGMA journal_mode=WAL") - connection.execute("PRAGMA synchronous=NORMAL") - connection.execute("PRAGMA foreign_keys=ON") - return connection - - -def ensure_schema(connection: sqlite3.Connection) -> None: - connection.executescript( - """ - create table if not exists dlp_events ( - id integer primary key autoincrement, - bucket_id text not null, - event_id text not null, - stream_type text not null, - hostname text not null, - username text, - event_ts text not null, - duration real not null default 0, - operation text, - file_path text, - old_file_path text, - extension text, - archive_hint integer not null default 0, - rule_id text, - action text, - severity text, - signal_type text, - message text, - source text, - screenshot_path text, - raw_json text not null, - ingested_at text not null, - unique (bucket_id, event_id) - ); - create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts); - create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts); - create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts); - create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts); - create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts); - - create view if not exists dlp_file_operations as - select * - from dlp_events - where stream_type = 'file_operation'; - - create view if not exists dlp_incidents as - select * - from dlp_events - where stream_type = 'dlp_incident'; - """ - ) - connection.commit() - - -def ensure_postgres_schema(connection: PsycopgConnection) -> None: - with connection.cursor() as cursor: - cursor.execute( - """ - create table if not exists dlp_events ( - id bigserial primary key, - bucket_id text not null, - event_id text not null, - stream_type text not null, - hostname text not null, - username text, - event_ts timestamptz not null, - duration double precision not null default 0, - operation text, - file_path text, - old_file_path text, - extension text, - archive_hint boolean not null default false, - rule_id text, - action text, - severity text, - signal_type text, - message text, - source text, - screenshot_path text, - raw_json jsonb not null, - ingested_at timestamptz not null, - unique (bucket_id, event_id) - ); - create index if not exists idx_dlp_events_event_ts on dlp_events(event_ts); - create index if not exists idx_dlp_events_host_ts on dlp_events(hostname, event_ts); - create index if not exists idx_dlp_events_stream_ts on dlp_events(stream_type, event_ts); - create index if not exists idx_dlp_events_archive on dlp_events(archive_hint, event_ts); - create index if not exists idx_dlp_events_rule on dlp_events(rule_id, event_ts); - - create or replace view dlp_file_operations as - select * - from dlp_events - where stream_type = 'file_operation'; - - create or replace view dlp_incidents as - select * - from dlp_events - where stream_type = 'dlp_incident'; - """ - ) - connection.commit() - - -def first_string(data: dict[str, JsonValue], keys: tuple[str, ...]) -> str | None: - for key in keys: - value = data.get(key) - if value is not None and str(value) != "": - return str(value) - return None - - -def bool_as_int(value: JsonValue) -> int: - if isinstance(value, bool): - return int(value) - if isinstance(value, str): - return int(value.lower() in {"1", "true", "yes", "y"}) - return int(bool(value)) - - -def event_row(event: AwEvent, ingested_at: str) -> tuple[JsonValue, ...]: - data = event.data - event_id = event.event_id or event_key(event.bucket_id, event.timestamp, event.duration, data) - return ( - event.bucket_id, - event_id, - event.stream_type, - event.hostname, - first_string(data, ("username", "user")), - event.timestamp, - event.duration, - first_string(data, ("operation",)), - first_string(data, ("path", "filePath")), - first_string(data, ("oldPath", "oldFilePath")), - first_string(data, ("extension",)), - bool_as_int(data.get("archiveHint")), - first_string(data, ("ruleId", "rule")), - first_string(data, ("action",)), - first_string(data, ("severity",)), - first_string(data, ("signalType",)), - first_string(data, ("message",)), - first_string(data, ("source",)), - first_string(data, ("screenshotPath", "capturePath", "artifactPath")), - json.dumps(data, ensure_ascii=False, sort_keys=True), - ingested_at, - ) - - -def insert_events(connection: sqlite3.Connection, events: Iterable[AwEvent]) -> int: - inserted = 0 - now = format_aw_timestamp(utc_now()) - for event in events: - cursor = connection.execute( - """ - insert or ignore into dlp_events ( - bucket_id, - event_id, - stream_type, - hostname, - username, - event_ts, - duration, - operation, - file_path, - old_file_path, - extension, - archive_hint, - rule_id, - action, - severity, - signal_type, - message, - source, - screenshot_path, - raw_json, - ingested_at - ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - event_row(event, now), - ) - inserted += int(cursor.rowcount > 0) - connection.commit() - return inserted - - -def insert_postgres_events(dsn: str, events: Iterable[AwEvent]) -> int: - try: - import psycopg - except ImportError as exc: - raise SystemExit("PostgreSQL mode requires psycopg: python3 -m pip install 'psycopg[binary]'") from exc - - inserted = 0 - now = format_aw_timestamp(utc_now()) - columns = ", ".join(EVENT_COLUMNS) - placeholders = ", ".join(["%s"] * len(EVENT_COLUMNS)) - sql = f""" - insert into dlp_events ({columns}) - values ({placeholders}) - on conflict (bucket_id, event_id) do nothing - """ - with psycopg.connect(dsn) as connection: - ensure_postgres_schema(connection) - with connection.cursor() as cursor: - for event in events: - row = list(event_row(event, now)) - row[EVENT_COLUMNS.index("archive_hint")] = bool(row[EVENT_COLUMNS.index("archive_hint")]) - cursor.execute(sql, row) - inserted += int(cursor.rowcount > 0) - connection.commit() - return inserted - - -def get_start_time(args: argparse.Namespace, state: dict[str, str]) -> datetime: - if args.since: - return parse_timestamp(args.since) - if state.get("last_end"): - return parse_timestamp(state["last_end"]) - timedelta(seconds=args.overlap_seconds) - return utc_now() - timedelta(hours=args.lookback_hours) - - -def parse_prefixes(value: str) -> tuple[str, ...]: - prefixes = tuple(item.strip() for item in value.split(",") if item.strip()) - if not prefixes: - raise argparse.ArgumentTypeError("at least one bucket prefix is required") - return prefixes - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Aggregate AWatch-rus DLP buckets into a local warehouse database.") - parser.add_argument("--aw-url", default=os.environ.get("AW_URL", "http://127.0.0.1:5600/api/0")) - parser.add_argument("--postgres-dsn", default=os.environ.get("DLP_AGGREGATOR_POSTGRES_DSN")) - parser.add_argument("--sqlite-path", default=os.environ.get("DLP_AGGREGATOR_SQLITE_PATH", DEFAULT_SQLITE_PATH)) - parser.add_argument("--state-path", default=os.environ.get("DLP_AGGREGATOR_STATE_PATH", "data/dlp-aggregator-state.json")) - parser.add_argument("--bucket-prefixes", type=parse_prefixes, default=DEFAULT_BUCKET_PREFIXES) - parser.add_argument("--since", help="UTC ISO timestamp. Overrides saved state, for example 2026-05-02T00:00:00Z.") - parser.add_argument("--lookback-hours", type=int, default=24) - parser.add_argument("--overlap-seconds", type=int, default=60) - parser.add_argument("--limit", type=int, default=10000) - parser.add_argument("--timeout", type=int, default=15) - parser.add_argument("--dry-run", action="store_true") - return parser - - -def main() -> int: - args = build_parser().parse_args() - state_path = Path(args.state_path) - state = load_state(state_path) - start = get_start_time(args, state) - end = utc_now() - - buckets = select_buckets(list_buckets(args.aw_url, args.timeout), args.bucket_prefixes) - all_events: list[AwEvent] = [] - for bucket, stream_type in buckets: - all_events.extend(fetch_bucket_events(args.aw_url, bucket, stream_type, start, end, args.limit, args.timeout)) - - if args.dry_run: - print( - json.dumps( - { - "aw_url": args.aw_url, - "start": format_aw_timestamp(start), - "end": format_aw_timestamp(end), - "selected_buckets": [bucket.id for bucket, _stream_type in buckets], - "fetched_events": len(all_events), - }, - ensure_ascii=False, - indent=2, - ) - ) - return 0 - - if args.postgres_dsn: - target = "postgres" - target_path = args.postgres_dsn.split("@")[-1] - inserted = insert_postgres_events(args.postgres_dsn, all_events) - else: - target = "sqlite" - sqlite_path = Path(args.sqlite_path) - target_path = str(sqlite_path) - connection = connect_sqlite(sqlite_path) - try: - ensure_schema(connection) - inserted = insert_events(connection, all_events) - finally: - connection.close() - - state["last_end"] = format_aw_timestamp(end) - save_state(state_path, state) - print( - json.dumps( - { - "aw_url": args.aw_url, - "target": target, - "target_path": target_path, - "state_path": str(state_path), - "start": format_aw_timestamp(start), - "end": format_aw_timestamp(end), - "selected_buckets": len(buckets), - "fetched_events": len(all_events), - "inserted_events": inserted, - }, - ensure_ascii=False, - indent=2, - ) - ) - return 0 - - -if __name__ == "__main__": - try: - raise SystemExit(main()) - except urllib.error.URLError as exc: - print(f"ActivityWatch API request failed: {exc}", file=sys.stderr) - raise SystemExit(2) diff --git a/scripts/build_dlp_ioc_from_hayabusa.sh b/scripts/build_dlp_ioc_from_hayabusa.sh index 21e120e..f73f729 100644 --- a/scripts/build_dlp_ioc_from_hayabusa.sh +++ b/scripts/build_dlp_ioc_from_hayabusa.sh @@ -43,8 +43,5 @@ for candidate in "${rust_candidates[@]}"; do fi done -python3 "$REPO_ROOT/scripts/extract_ioc_from_sigma.py" \ - --rules-root "$RULES_ROOT" \ - --out-dir "$OUT_DIR" - -echo "IOC artifacts generated in: $OUT_DIR" +echo "ERROR: Rust extractor not found. Build it with: cd '$REPO_ROOT/adk-rust' && cargo build --release -p extract-ioc-from-sigma" >&2 +exit 127 diff --git a/scripts/check_install_kit_vs_repo.sh b/scripts/check_install_kit_vs_repo.sh index ff2600b..a6db8d7 100755 --- a/scripts/check_install_kit_vs_repo.sh +++ b/scripts/check_install_kit_vs_repo.sh @@ -24,59 +24,5 @@ for candidate in "${rust_candidates[@]}"; do fi done -python3 - <<'PY' -from pathlib import Path -import hashlib -import sys - -root=Path('.') -kit=Path('install-kit-awindows-20260427-211240') -if not kit.exists(): - raise SystemExit('Install kit directory not found') - -def sha(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - -all_compared=[] -mismatches=[] -missing_in_repo=[] -allowed_kit_only_prefixes=('server-configs-' ,) -allowed_kit_only_files={'README-INSTALL-KIT.txt'} - -for kp in sorted(p for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'): - rel=kp.relative_to(kit) - rp=root/rel - if not rp.exists(): - rel_str=str(rel) - if rel_str in allowed_kit_only_files or rel_str.startswith(allowed_kit_only_prefixes): - continue - missing_in_repo.append(str(rel)) - continue - all_compared.append(str(rel)) - if sha(kp)!=sha(rp): - mismatches.append(str(rel)) - -ps_mismatches=[ - p for p in mismatches - if p.startswith('windows/') and p.endswith(('.ps1', '.psm1', '.psd1')) -] - -print(f'Compared files: {len(all_compared)}') -print(f'Missing in repo: {len(missing_in_repo)}') -print(f'Mismatched content: {len(mismatches)}') -if missing_in_repo: - print('--- Missing in repo ---') - for p in missing_in_repo: - print(p) -if mismatches: - print('--- Mismatches ---') - for p in mismatches: - print(p) -print(f'PowerShell mismatches: {len(ps_mismatches)}') -if ps_mismatches: - print('--- PowerShell mismatches ---') - for p in ps_mismatches: - print(p) -if missing_in_repo or mismatches: - sys.exit(1) -PY +echo "ERROR: Rust checker not found. Build it with: cd '$ROOT_DIR/adk-rust' && cargo build --release -p check-install-kit-vs-repo" >&2 +exit 127 diff --git a/scripts/dlp-admin-cli.py b/scripts/dlp-admin-cli.py deleted file mode 100644 index 46900f6..0000000 --- a/scripts/dlp-admin-cli.py +++ /dev/null @@ -1,158 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -from datetime import UTC, datetime, timedelta -from urllib import request -from urllib.parse import quote - - -def get_json(url: str): - with request.urlopen(url, timeout=30) as r: - return json.loads(r.read().decode("utf-8")) - - -def send_json(url: str, method: str, payload: dict | None = None): - body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8") - req = request.Request(url, data=body, method=method, headers={"Content-Type": "application/json"}) - with request.urlopen(req, timeout=30) as r: - raw = r.read().decode("utf-8") - return json.loads(raw) if raw else {} - - -def parse_iso(ts: str | None) -> datetime | None: - if not ts: - return None - try: - return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone(UTC) - except ValueError: - return None - - -def main() -> None: - p = argparse.ArgumentParser(description="AWatch DLP admin CLI") - p.add_argument("--policy-server", default="http://127.0.0.1:5601") - p.add_argument("--case-server", default="http://127.0.0.1:5602") - p.add_argument("--aw-server", default="http://127.0.0.1:5600") - sub = p.add_subparsers(dest="cmd", required=True) - - policies = sub.add_parser("policies") - policies_sub = policies.add_subparsers(dest="policies_cmd", required=True) - policies_sub.add_parser("list") - policies_sub.add_parser("active") - - incidents = sub.add_parser("incidents") - incidents_sub = incidents.add_subparsers(dest="incidents_cmd", required=True) - incidents_list = incidents_sub.add_parser("list") - incidents_list.add_argument("--host") - incidents_list.add_argument("--severity") - incidents_list.add_argument("--limit", type=int, default=100) - incidents_list.add_argument("--since-hours", type=int, default=24) - - cases = sub.add_parser("cases") - cases_sub = cases.add_subparsers(dest="cases_cmd", required=True) - cases_list = cases_sub.add_parser("list") - cases_list.add_argument("--host") - cases_list.add_argument("--status") - cases_list.add_argument("--limit", type=int, default=100) - cases_create = cases_sub.add_parser("create") - cases_create.add_argument("--incident-id", required=True) - cases_create.add_argument("--title", required=True) - cases_create.add_argument("--host") - cases_create.add_argument("--severity", default="medium") - - health = sub.add_parser("health") - health_sub = health.add_subparsers(dest="health_cmd", required=True) - health_sub.add_parser("check") - - args = p.parse_args() - - if args.cmd == "policies" and args.policies_cmd == "list": - data = get_json(f"{args.policy_server}/api/0/dlp/policies") - print(json.dumps(data, ensure_ascii=False, indent=2)) - return - - if args.cmd == "policies" and args.policies_cmd == "active": - data = get_json(f"{args.policy_server}/api/0/dlp/policies/active") - print(json.dumps(data, ensure_ascii=False, indent=2)) - return - - if args.cmd == "incidents" and args.incidents_cmd == "list": - bucket_map = get_json(f"{args.aw_server}/api/0/buckets") - if not isinstance(bucket_map, dict): - print("[]") - return - - bucket_ids = [x for x in bucket_map.keys() if str(x).startswith("aw-dlp-incidents_")] - if args.host: - bucket_ids = [x for x in bucket_ids if str(x).endswith("_" + args.host)] - - after = datetime.now(UTC) - timedelta(hours=max(1, args.since_hours)) - rows = [] - for bucket_id in sorted(bucket_ids): - encoded = quote(str(bucket_id), safe="") - events = get_json(f"{args.aw_server}/api/0/buckets/{encoded}/events?limit={max(1, args.limit)}") - if not isinstance(events, list): - continue - for ev in events: - if not isinstance(ev, dict): - continue - ts = parse_iso(ev.get("timestamp")) - if ts is None or ts < after: - continue - data = ev.get("data") or {} - if args.severity and str((data or {}).get("severity", "")).lower() != args.severity.lower(): - continue - rows.append(ev) - print(json.dumps(rows, ensure_ascii=False, indent=2)) - return - - if args.cmd == "cases" and args.cases_cmd == "list": - query = [] - if args.host: - query.append(f"host={quote(args.host, safe='')}") - if args.status: - query.append(f"status={quote(args.status, safe='')}") - query.append(f"limit={max(1, args.limit)}") - data = get_json(f"{args.case_server}/api/0/dlp/cases?{'&'.join(query)}") - print(json.dumps(data, ensure_ascii=False, indent=2)) - return - - if args.cmd == "cases" and args.cases_cmd == "create": - payload = { - "incident_id": args.incident_id, - "title": args.title, - "host": args.host, - "severity": args.severity, - "evidence": {"source": "dlp-admin-cli"}, - } - data = send_json(f"{args.case_server}/api/0/dlp/cases", "POST", payload) - print(json.dumps(data, ensure_ascii=False, indent=2)) - return - - if args.cmd == "health" and args.health_cmd == "check": - out = {} - try: - out["policy"] = get_json(f"{args.policy_server}/healthz") - except Exception as exc: - out["policy"] = {"status": "error", "error": str(exc)} - - try: - out["cases"] = get_json(f"{args.case_server}/health") - except Exception as exc: - out["cases"] = {"status": "error", "error": str(exc)} - - try: - out["aw"] = get_json(f"{args.aw_server}/api/0/info") - except Exception as exc: - out["aw"] = {"status": "error", "error": str(exc)} - - print(json.dumps(out, ensure_ascii=False, indent=2)) - return - - raise SystemExit("unsupported command") - - -if __name__ == "__main__": - main() diff --git a/scripts/dlp-health-check.py b/scripts/dlp-health-check.py deleted file mode 100644 index 260b364..0000000 --- a/scripts/dlp-health-check.py +++ /dev/null @@ -1,1023 +0,0 @@ -#!/usr/bin/env python3 -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import time -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import Any -from urllib import request - - -def _env(name: str, default: str) -> str: - value = os.environ.get(name) - return value if value not in (None, "") else default - - -def _http_json(url: str, timeout: int = 15, attempts: int = 2, backoff_seconds: float = 0.5) -> Any: - last_exc: Exception | None = None - for attempt in range(max(1, attempts)): - try: - with request.urlopen(url, timeout=timeout) as resp: - return json.loads(resp.read().decode("utf-8")) - except Exception as exc: - last_exc = exc - if attempt + 1 >= max(1, attempts): - break - time.sleep(backoff_seconds * (2**attempt)) - assert last_exc is not None - raise last_exc - - -def _parse_ts(value: str | None) -> datetime | None: - if not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC) - except ValueError: - return None - - -def _now_utc() -> datetime: - return datetime.now(UTC) - - -def _age_seconds(ts: datetime | None, now: datetime) -> int | None: - if ts is None: - return None - return max(0, int((now - ts).total_seconds())) - - -def _int_or_zero(value: Any) -> int: - try: - return int(value) - except (TypeError, ValueError): - return 0 - - -def _path_tail(value: Any, parts: int = 2) -> str: - text = str(value or "").replace("\\", "/").strip() - if not text: - return "" - tokens = [item for item in text.split("/") if item] - return "/".join(tokens[-parts:]) if tokens else text - - -def _text_excerpt(value: Any, limit: int = 120) -> str: - text = " ".join(str(value or "").split()) - if len(text) <= limit: - return text - return text[: max(0, limit - 1)].rstrip() + "…" - - -def _load_counter_state(path: Path) -> dict[str, Any]: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except FileNotFoundError: - return {"counters": {}} - except Exception: - return {"counters": {}} - if not isinstance(payload, dict): - return {"counters": {}} - counters = payload.get("counters") - if not isinstance(counters, dict): - payload["counters"] = {} - return payload - - -def _save_counter_state(path: Path, state: dict[str, Any]) -> str | None: - try: - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.parent / f".{path.name}.{os.getpid()}.tmp" - tmp_path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") - os.replace(tmp_path, path) - return None - except Exception as exc: - try: - path.write_text(json.dumps(state, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") - return None - except Exception: - return str(exc) - - -def _counter_delta(counter_state: dict[str, Any] | None, key: str, current_value: int) -> tuple[int | None, int]: - if counter_state is None: - return None, current_value - counters = counter_state.setdefault("counters", {}) - if not isinstance(counters, dict): - counters = {} - counter_state["counters"] = counters - previous: int | None = None - try: - previous = int(counters[key]) - except (KeyError, TypeError, ValueError): - previous = None - counters[key] = current_value - if previous is None or current_value < previous: - return previous, 0 - return previous, current_value - previous - - -def _run_systemctl(*args: str) -> tuple[int, str]: - proc = subprocess.run( - ["systemctl", *args], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - return proc.returncode, proc.stdout.strip() - - -@dataclass -class CheckResult: - name: str - status: str - summary: str - details: dict[str, Any] - - -class HealthReport: - def __init__(self) -> None: - self.results: list[CheckResult] = [] - - def add(self, name: str, status: str, summary: str, **details: Any) -> None: - self.results.append(CheckResult(name=name, status=status, summary=summary, details=details)) - - @property - def ok(self) -> bool: - return not any(item.status == "fail" for item in self.results) - - def as_dict(self) -> dict[str, Any]: - counts = {"ok": 0, "warn": 0, "fail": 0} - for item in self.results: - counts[item.status] = counts.get(item.status, 0) + 1 - return { - "ok": self.ok, - "counts": counts, - "results": [ - { - "name": item.name, - "status": item.status, - "summary": item.summary, - "details": item.details, - } - for item in self.results - ], - } - - def render_text(self) -> str: - icon = {"ok": "OK", "warn": "WARN", "fail": "FAIL"} - lines = ["=== DLP Health Check ===", f"Timestamp: {_now_utc().isoformat().replace('+00:00', 'Z')}", ""] - for item in self.results: - lines.append(f"[{icon.get(item.status, item.status.upper())}] {item.name}: {item.summary}") - if item.details: - lines.append(f" details: {json.dumps(item.details, ensure_ascii=False, sort_keys=True)}") - lines.append("") - lines.append(f"Overall: {'OK' if self.ok else 'FAIL'}") - return "\n".join(lines) - - -def check_http_endpoint(report: HealthReport, name: str, url: str) -> None: - try: - payload = _http_json(url) - report.add(name, "ok", f"HTTP endpoint responded", url=url, payload=payload) - except Exception as exc: - report.add(name, "fail", f"HTTP endpoint failed: {exc}", url=url) - - -def check_systemd_unit(report: HealthReport, unit: str, kind: str) -> None: - active_rc, active_out = _run_systemctl("is-active", unit) - enabled_rc, enabled_out = _run_systemctl("is-enabled", unit) - exists_rc, _ = _run_systemctl("status", unit) - if exists_rc != 0 and active_rc != 0 and enabled_rc != 0: - report.add(f"systemd:{unit}", "warn", "unit not installed", kind=kind) - return - - if active_rc == 0 and enabled_rc == 0: - report.add(f"systemd:{unit}", "ok", "active and enabled", kind=kind) - return - - report.add( - f"systemd:{unit}", - "fail", - "unit is not active/enabled", - kind=kind, - active=active_out or str(active_rc), - enabled=enabled_out or str(enabled_rc), - ) - - -def _latest_bucket_ts(api_base: str, bucket_id: str, bucket_meta: dict[str, Any]) -> datetime | None: - meta = bucket_meta.get("metadata") or {} - ts = _parse_ts(meta.get("end")) - if ts is not None: - return ts - try: - events = _http_json(f"{api_base}/buckets/{bucket_id}/events?limit=1") - except Exception: - return None - if isinstance(events, list) and events: - return _parse_ts(events[0].get("timestamp")) - return None - - -def _bucket_suffix(bucket_id: str, prefix: str) -> str: - return bucket_id[len(prefix):] if bucket_id.startswith(prefix) else bucket_id - - -def check_bucket_group( - report: HealthReport, - api_base: str, - buckets: dict[str, Any], - name: str, - prefix: str, - max_age_seconds: int, - severity_if_missing: str = "fail", - severity_if_stale: str = "fail", -) -> None: - now = _now_utc() - matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) - if not matched: - report.add( - f"buckets:{name}", - severity_if_missing, - f"no buckets matched prefix {prefix}", - prefix=prefix, - ) - return - - stale: list[dict[str, Any]] = [] - unknown: list[str] = [] - ages: dict[str, int] = {} - for bucket_id in matched: - ts = _latest_bucket_ts(api_base, bucket_id, buckets.get(bucket_id, {})) - age = _age_seconds(ts, now) - if age is None: - unknown.append(bucket_id) - continue - ages[bucket_id] = age - if age > max_age_seconds: - stale.append({"bucket": bucket_id, "age_seconds": age}) - - status = "ok" - summary = f"{len(matched)} buckets, freshest ok" - if stale: - status = severity_if_stale - summary = f"{len(stale)} stale buckets" - elif unknown: - status = "warn" - summary = f"{len(unknown)} buckets without timestamp" - - report.add( - f"buckets:{name}", - status, - summary, - prefix=prefix, - max_age_seconds=max_age_seconds, - bucket_count=len(matched), - max_observed_age_seconds=max(ages.values()) if ages else None, - stale=stale, - unknown=unknown, - ) - - -def check_incident_buckets( - report: HealthReport, - api_base: str, - buckets: dict[str, Any], - max_age_seconds: int, -) -> None: - now = _now_utc() - prefix = "aw-dlp-incidents_" - matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) - - if not matched: - report.add( - "buckets:incidents", - "ok", - "no incident buckets yet", - prefix=prefix, - bucket_count=0, - ) - return - - ages: dict[str, int] = {} - unknown: list[str] = [] - stale: list[dict[str, Any]] = [] - for bucket_id in matched: - ts = _latest_bucket_ts(api_base, bucket_id, buckets.get(bucket_id, {})) - age = _age_seconds(ts, now) - if age is None: - unknown.append(bucket_id) - continue - ages[bucket_id] = age - if age > max_age_seconds: - stale.append({"bucket": bucket_id, "age_seconds": age}) - - if stale and not unknown: - report.add( - "buckets:incidents", - "ok", - "no recent incidents", - prefix=prefix, - bucket_count=len(matched), - max_age_seconds=max_age_seconds, - max_observed_age_seconds=max(ages.values()) if ages else None, - stale=stale, - unknown=[], - ) - return - - status = "ok" if not unknown else "warn" - summary = "incident buckets healthy" if not unknown else f"{len(unknown)} incident buckets without timestamp" - report.add( - "buckets:incidents", - status, - summary, - prefix=prefix, - bucket_count=len(matched), - max_age_seconds=max_age_seconds, - max_observed_age_seconds=max(ages.values()) if ages else None, - stale=stale, - unknown=unknown, - ) - - -def check_incident_runtime( - report: HealthReport, - api_base: str, - buckets: dict[str, Any], - sample_limit: int = 20, -) -> None: - now = _now_utc() - prefix = "aw-dlp-incidents_" - matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) - if not matched: - report.add("incident-runtime", "ok", "no incident buckets to sample", bucket_count=0) - return - - if sample_limit <= 0: - metadata = [] - for bucket_id in matched: - ts = _latest_bucket_ts(api_base, bucket_id, buckets.get(bucket_id, {})) - metadata.append( - { - "bucket": bucket_id, - "end": ts.isoformat().replace("+00:00", "Z") if ts else None, - "age_seconds": _age_seconds(ts, now), - } - ) - report.add( - "incident-runtime", - "ok", - "incident event sampling disabled", - bucket_count=len(matched), - sample_limit=sample_limit, - metadata=metadata, - ) - return - - sampled: list[dict[str, Any]] = [] - latest_incidents: list[dict[str, Any]] = [] - read_failed: list[dict[str, str]] = [] - totals = { - "sampled_events": 0, - "real_incidents": 0, - "self_tests": 0, - } - severity_counts: dict[str, int] = {} - action_counts: dict[str, int] = {} - rule_counts: dict[str, int] = {} - - for bucket_id in matched: - try: - events = _http_json(f"{api_base}/buckets/{bucket_id}/events?limit={sample_limit}", timeout=5, attempts=1) - except Exception as exc: - read_failed.append({"bucket": bucket_id, "error": str(exc)}) - continue - if not isinstance(events, list): - read_failed.append({"bucket": bucket_id, "error": "events response is not a list"}) - continue - - bucket_summary = { - "bucket": bucket_id, - "sampled_events": len(events), - "real_incidents": 0, - "self_tests": 0, - } - sampled.append(bucket_summary) - totals["sampled_events"] += len(events) - - for event in events: - data = event.get("data") or {} - if not isinstance(data, dict): - continue - - signal_type = str(data.get("signalType") or "").strip() - source = str(data.get("source") or "").strip() - rule_id = str(data.get("ruleId") or data.get("rule_id") or "").strip() - is_self_test = signal_type == "self_test" or source == "self-test" or rule_id.startswith("selftest-") - if is_self_test: - bucket_summary["self_tests"] += 1 - totals["self_tests"] += 1 - continue - - ts = _parse_ts(event.get("timestamp")) - severity = str(data.get("severity") or "unknown").strip().lower() or "unknown" - action = str(data.get("action") or "unknown").strip().lower() or "unknown" - rule_key = rule_id or "unknown" - severity_counts[severity] = severity_counts.get(severity, 0) + 1 - action_counts[action] = action_counts.get(action, 0) + 1 - rule_counts[rule_key] = rule_counts.get(rule_key, 0) + 1 - bucket_summary["real_incidents"] += 1 - totals["real_incidents"] += 1 - latest_incidents.append( - { - "bucket": bucket_id, - "timestamp": event.get("timestamp"), - "age_seconds": _age_seconds(ts, now), - "ruleId": rule_id, - "severity": severity, - "action": action, - "username": str(data.get("username") or ""), - "hostname": str(data.get("hostname") or ""), - "source": source, - "message_excerpt": _text_excerpt(data.get("message")), - } - ) - - latest_incidents.sort(key=lambda item: item.get("timestamp") or "", reverse=True) - status = "ok" - summary = f"{totals['real_incidents']} real incidents in sampled events" - if read_failed: - status = "warn" - summary = f"{len(read_failed)} incident buckets failed to sample" - elif totals["real_incidents"] == 0: - summary = "no real incidents in sampled events" - - report.add( - "incident-runtime", - status, - summary, - bucket_count=len(matched), - sample_limit=sample_limit, - totals=totals, - sampled=sampled, - severity_counts=severity_counts, - action_counts=action_counts, - rule_counts=dict(sorted(rule_counts.items(), key=lambda item: (-item[1], item[0]))[:10]), - latest_incidents=latest_incidents[:5], - read_failed=read_failed, - ) - - -def _worktime_activity_map(api_base: str, buckets: dict[str, Any], max_age_seconds: int) -> dict[str, dict[str, Any]]: - now = _now_utc() - activity: dict[str, dict[str, Any]] = {} - prefix = "aw-worktime-sessions_" - for bucket_id in sorted(key for key in buckets if key.startswith(prefix)): - host = _bucket_suffix(bucket_id, prefix) - latest_ts: datetime | None = None - latest_active = False - try: - events = _http_json(f"{api_base}/buckets/{bucket_id}/events?limit=20") - except Exception: - activity[host] = {"active": False, "age_seconds": None, "bucket": bucket_id} - continue - if isinstance(events, list): - for event in events: - ts = _parse_ts(event.get("timestamp")) - if ts is None: - continue - if latest_ts is None or ts > latest_ts: - latest_ts = ts - latest_active = bool((event.get("data") or {}).get("active")) - activity[host] = { - "active": bool(latest_ts and latest_active and (_age_seconds(latest_ts, now) or 0) <= max_age_seconds), - "age_seconds": _age_seconds(latest_ts, now), - "bucket": bucket_id, - } - return activity - - -def check_file_operations_buckets( - report: HealthReport, - api_base: str, - buckets: dict[str, Any], - max_age_seconds: int, - strict: bool, -) -> None: - now = _now_utc() - prefix = "aw-file-operations_" - matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) - worktime = _worktime_activity_map(api_base, buckets, max_age_seconds) - active_hosts = sorted(host for host, meta in worktime.items() if meta.get("active")) - matched_by_host = {_bucket_suffix(bucket_id, prefix): bucket_id for bucket_id in matched} - - ignored_unmanaged: list[str] = [] - ignored_inactive: list[str] = [] - missing_active: list[str] = [] - stale: list[dict[str, Any]] = [] - unknown: list[str] = [] - fresh: list[str] = [] - - for host, bucket_id in matched_by_host.items(): - if host not in worktime: - ignored_unmanaged.append(bucket_id) - continue - if host not in active_hosts: - ignored_inactive.append(bucket_id) - continue - ts = _latest_bucket_ts(api_base, bucket_id, buckets.get(bucket_id, {})) - age = _age_seconds(ts, now) - if age is None: - unknown.append(bucket_id) - continue - if age > max_age_seconds: - stale.append({"bucket": bucket_id, "age_seconds": age}) - else: - fresh.append(bucket_id) - - for host in active_hosts: - if host not in matched_by_host: - missing_active.append(host) - - if not active_hosts: - report.add( - "buckets:file-operations", - "ok", - "no active managed hosts require file-operations freshness", - active_hosts=[], - ignored_unmanaged=ignored_unmanaged, - ignored_inactive=ignored_inactive, - worktime_hosts=sorted(worktime), - ) - return - - status = "ok" - summary = f"{len(fresh)} active host buckets fresh" - if missing_active: - status = "fail" if strict else "warn" - summary = f"{len(missing_active)} active hosts missing file-operations buckets" - elif stale: - status = "fail" if strict else "warn" - summary = f"{len(stale)} active host buckets stale" - elif unknown: - status = "warn" - summary = f"{len(unknown)} active host buckets without timestamp" - - report.add( - "buckets:file-operations", - status, - summary, - active_hosts=active_hosts, - fresh=fresh, - stale=stale, - missing_active=missing_active, - unknown=unknown, - ignored_unmanaged=ignored_unmanaged, - ignored_inactive=ignored_inactive, - ) - - -def check_file_operations_runtime( - report: HealthReport, - api_base: str, - buckets: dict[str, Any], - sample_limit: int = 20, - queue_warn_depth: int = 100, - send_failure_warn_count: int = 1, - counter_state: dict[str, Any] | None = None, -) -> None: - now = _now_utc() - prefix = "aw-file-operations_" - matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) - if not matched: - report.add("file-operations-runtime", "warn", "no file-operations buckets to sample", bucket_count=0) - return - - sampled: list[dict[str, Any]] = [] - latest_operations: list[dict[str, Any]] = [] - latest_health: list[dict[str, Any]] = [] - warnings: list[dict[str, Any]] = [] - read_failed: list[dict[str, str]] = [] - - for bucket_id in matched: - try: - events = _http_json(f"{api_base}/buckets/{bucket_id}/events?limit={sample_limit}") - except Exception as exc: - read_failed.append({"bucket": bucket_id, "error": str(exc)}) - continue - if not isinstance(events, list): - read_failed.append({"bucket": bucket_id, "error": "events response is not a list"}) - continue - - operation_counts: dict[str, int] = {} - latest_health_event: dict[str, Any] | None = None - latest_health_ts: datetime | None = None - - for event in events: - data = event.get("data") or {} - if not isinstance(data, dict): - continue - ts = _parse_ts(event.get("timestamp")) - signal_type = str(data.get("signalType") or "") - operation = str(data.get("operation") or "") - - if signal_type == "collector_health": - if latest_health_event is None or (ts is not None and (latest_health_ts is None or ts > latest_health_ts)): - latest_health_event = event - latest_health_ts = ts - continue - - if operation: - operation_counts[operation] = operation_counts.get(operation, 0) + 1 - latest_operations.append( - { - "bucket": bucket_id, - "timestamp": event.get("timestamp"), - "age_seconds": _age_seconds(ts, now), - "operation": operation, - "username": str(data.get("username") or ""), - "hostname": str(data.get("hostname") or ""), - "extension": str(data.get("extension") or ""), - "archiveHint": bool(data.get("archiveHint")), - "path_tail": _path_tail(data.get("path")), - "size": _int_or_zero(data.get("size")), - } - ) - - bucket_sample = { - "bucket": bucket_id, - "sampled_events": len(events), - "operation_counts": operation_counts, - } - sampled.append(bucket_sample) - - if latest_health_event is None: - warnings.append({"bucket": bucket_id, "metric": "collector_health", "value": "missing_in_sample"}) - continue - - health_data = latest_health_event.get("data") or {} - send_failures = _int_or_zero(health_data.get("sendFailures")) - previous_send_failures, send_failures_delta = _counter_delta( - counter_state, - f"file-operations:{bucket_id}:sendFailures", - send_failures, - ) - health_item = { - "bucket": bucket_id, - "timestamp": latest_health_event.get("timestamp"), - "age_seconds": _age_seconds(latest_health_ts, now), - "queueDepth": _int_or_zero(health_data.get("queueDepth")), - "eventsEnqueued": _int_or_zero(health_data.get("eventsEnqueued")), - "eventsFlushed": _int_or_zero(health_data.get("eventsFlushed")), - "sendFailures": send_failures, - "sendFailuresPrevious": previous_send_failures, - "sendFailuresDelta": send_failures_delta, - "username": str(health_data.get("username") or ""), - "hostname": str(health_data.get("hostname") or ""), - "sessionId": _int_or_zero(health_data.get("sessionId")), - } - latest_health.append(health_item) - if health_item["queueDepth"] > queue_warn_depth: - warnings.append({"bucket": bucket_id, "metric": "queueDepth", "value": health_item["queueDepth"], "threshold": queue_warn_depth}) - if send_failure_warn_count > 0 and health_item["sendFailuresDelta"] >= send_failure_warn_count: - warnings.append( - { - "bucket": bucket_id, - "metric": "sendFailuresDelta", - "value": health_item["sendFailuresDelta"], - "current": health_item["sendFailures"], - "previous": health_item["sendFailuresPrevious"], - "threshold": send_failure_warn_count, - } - ) - - latest_operations.sort(key=lambda item: item.get("timestamp") or "", reverse=True) - status = "ok" - summary = f"{len(matched)} file-operations buckets sampled" - if read_failed: - status = "warn" - summary = f"{len(read_failed)} file-operations buckets failed to sample" - elif warnings: - status = "warn" - summary = "file-operations runtime counters outside expectations" - - report.add( - "file-operations-runtime", - status, - summary, - bucket_count=len(matched), - sample_limit=sample_limit, - sampled=sampled, - latest_health=latest_health, - latest_operations=latest_operations[:5], - warnings=warnings, - read_failed=read_failed, - thresholds={ - "queueDepth": queue_warn_depth, - "sendFailures": send_failure_warn_count, - }, - ) - - -def check_endpoint_signal_buckets( - report: HealthReport, - api_base: str, - buckets: dict[str, Any], - max_age_seconds: int, -) -> None: - now = _now_utc() - prefix = "aw-dlp-endpoint-signals_" - matched = sorted(bucket_id for bucket_id in buckets if bucket_id.startswith(prefix)) - worktime = _worktime_activity_map(api_base, buckets, max_age_seconds) - active_hosts = sorted(host for host, meta in worktime.items() if meta.get("active")) - matched_by_host = {_bucket_suffix(bucket_id, prefix): bucket_id for bucket_id in matched} - - ignored_unmanaged: list[str] = [] - ignored_inactive: list[str] = [] - missing_active: list[str] = [] - stale: list[dict[str, Any]] = [] - unknown: list[str] = [] - fresh: list[str] = [] - - for host, bucket_id in matched_by_host.items(): - if host not in worktime: - ignored_unmanaged.append(bucket_id) - continue - if host not in active_hosts: - ignored_inactive.append(bucket_id) - continue - ts = _latest_bucket_ts(api_base, bucket_id, buckets.get(bucket_id, {})) - age = _age_seconds(ts, now) - if age is None: - unknown.append(bucket_id) - continue - if age > max_age_seconds: - stale.append({"bucket": bucket_id, "age_seconds": age}) - else: - fresh.append(bucket_id) - - for host in active_hosts: - if host not in matched_by_host: - missing_active.append(host) - - if not active_hosts: - report.add( - "buckets:endpoint-signals", - "ok", - "no active managed hosts require endpoint-signals freshness", - active_hosts=[], - ignored_unmanaged=ignored_unmanaged, - ignored_inactive=ignored_inactive, - worktime_hosts=sorted(worktime), - ) - return - - status = "ok" - summary = f"{len(fresh)} active host buckets fresh" - if missing_active: - status = "fail" - summary = f"{len(missing_active)} active hosts missing endpoint-signals buckets" - elif stale: - status = "fail" - summary = f"{len(stale)} active host buckets stale" - elif unknown: - status = "warn" - summary = f"{len(unknown)} active host buckets without timestamp" - - report.add( - "buckets:endpoint-signals", - status, - summary, - active_hosts=active_hosts, - fresh=fresh, - stale=stale, - missing_active=missing_active, - unknown=unknown, - ignored_unmanaged=ignored_unmanaged, - ignored_inactive=ignored_inactive, - ) - - -def check_endpoint_self_test_metrics( - report: HealthReport, - api_base: str, - buckets: dict[str, Any], - queue_warn_depth: int = 100, - send_failure_warn_count: int = 1, - counter_state: dict[str, Any] | None = None, -) -> None: - now = _now_utc() - missing: list[str] = [] - latest_self_tests: list[dict[str, Any]] = [] - warnings: list[dict[str, Any]] = [] - expected = ("queueDepth", "eventsEnqueued", "eventsFlushed", "sendFailures") - for bucket_id in sorted(k for k in buckets if k.startswith("aw-dlp-endpoint-signals_")): - try: - events = _http_json(f"{api_base}/buckets/{bucket_id}/events?limit=20") - except Exception as exc: - report.add(f"endpoint-self-test:{bucket_id}", "warn", f"failed to read events: {exc}", bucket=bucket_id) - continue - latest_event: dict[str, Any] | None = None - latest_ts: datetime | None = None - if isinstance(events, list): - for event in events: - data = event.get("data") or {} - if data.get("signalType") != "self_test" or not all(key in data for key in expected): - continue - ts = _parse_ts(event.get("timestamp")) - if latest_event is None or (ts is not None and (latest_ts is None or ts > latest_ts)): - latest_event = event - latest_ts = ts - if latest_event is None: - missing.append(bucket_id) - continue - - data = latest_event.get("data") or {} - send_failures = _int_or_zero(data.get("sendFailures")) - previous_send_failures, send_failures_delta = _counter_delta( - counter_state, - f"endpoint-self-test:{bucket_id}:sendFailures", - send_failures, - ) - item = { - "bucket": bucket_id, - "timestamp": latest_event.get("timestamp"), - "age_seconds": _age_seconds(latest_ts, now), - "queueDepth": _int_or_zero(data.get("queueDepth")), - "eventsEnqueued": _int_or_zero(data.get("eventsEnqueued")), - "eventsFlushed": _int_or_zero(data.get("eventsFlushed")), - "sendFailures": send_failures, - "sendFailuresPrevious": previous_send_failures, - "sendFailuresDelta": send_failures_delta, - } - latest_self_tests.append(item) - if item["queueDepth"] > queue_warn_depth: - warnings.append({"bucket": bucket_id, "metric": "queueDepth", "value": item["queueDepth"], "threshold": queue_warn_depth}) - if send_failure_warn_count > 0 and item["sendFailuresDelta"] >= send_failure_warn_count: - warnings.append( - { - "bucket": bucket_id, - "metric": "sendFailuresDelta", - "value": item["sendFailuresDelta"], - "current": item["sendFailures"], - "previous": item["sendFailuresPrevious"], - "threshold": send_failure_warn_count, - } - ) - - if missing: - report.add( - "endpoint-self-test-metrics", - "warn", - "missing transport metrics in sampled self_test events", - buckets=missing, - latest_self_tests=latest_self_tests, - thresholds={ - "queueDepth": queue_warn_depth, - "sendFailures": send_failure_warn_count, - }, - ) - elif warnings: - report.add( - "endpoint-self-test-metrics", - "warn", - "endpoint transport counters outside thresholds", - latest_self_tests=latest_self_tests, - warnings=warnings, - thresholds={ - "queueDepth": queue_warn_depth, - "sendFailures": send_failure_warn_count, - }, - ) - else: - report.add( - "endpoint-self-test-metrics", - "ok", - "self_test transport metrics present", - latest_self_tests=latest_self_tests, - thresholds={ - "queueDepth": queue_warn_depth, - "sendFailures": send_failure_warn_count, - }, - ) - - -def check_compliance_reports(report: HealthReport, report_dir: Path, profiles: list[str], month: str) -> None: - missing: list[str] = [] - present: list[str] = [] - for profile in profiles: - for suffix in ("html", "json"): - path = report_dir / f"{profile}-{month}.{suffix}" - if path.exists(): - present.append(str(path)) - else: - missing.append(str(path)) - if missing: - report.add("compliance-reports", "fail", "missing expected compliance report artifacts", present=present, missing=missing) - else: - report.add("compliance-reports", "ok", "all expected compliance artifacts exist", present=present) - - -def main() -> int: - parser = argparse.ArgumentParser(description="AWatch DLP health check") - parser.add_argument("--aw-server", default=_env("AW_HEALTH_AW_SERVER", "http://127.0.0.1:5600")) - parser.add_argument("--policy-server", default=_env("AW_HEALTH_POLICY_SERVER", "http://127.0.0.1:5601")) - parser.add_argument("--case-server", default=_env("AW_HEALTH_CASE_SERVER", "http://127.0.0.1:5602")) - parser.add_argument("--max-age-seconds", type=int, default=int(_env("AW_HEALTH_MAX_AGE_SECONDS", "900"))) - parser.add_argument("--strict-fileops", action="store_true", default=_env("AW_HEALTH_STRICT_FILEOPS", "0").lower() in {"1", "true", "yes", "on"}) - parser.add_argument("--endpoint-queue-warn-depth", type=int, default=int(_env("AW_DLP_HEALTH_ENDPOINT_QUEUE_WARN_DEPTH", "100"))) - parser.add_argument("--endpoint-send-failure-warn-count", type=int, default=int(_env("AW_DLP_HEALTH_ENDPOINT_SEND_FAILURE_WARN_COUNT", "1"))) - parser.add_argument("--fileops-sample-limit", type=int, default=int(_env("AW_DLP_HEALTH_FILEOPS_SAMPLE_LIMIT", "20"))) - parser.add_argument("--fileops-queue-warn-depth", type=int, default=int(_env("AW_DLP_HEALTH_FILEOPS_QUEUE_WARN_DEPTH", "100"))) - parser.add_argument("--fileops-send-failure-warn-count", type=int, default=int(_env("AW_DLP_HEALTH_FILEOPS_SEND_FAILURE_WARN_COUNT", "1"))) - parser.add_argument("--incident-sample-limit", type=int, default=int(_env("AW_DLP_HEALTH_INCIDENT_SAMPLE_LIMIT", "0"))) - parser.add_argument("--state-dir", default=_env("AW_DLP_HEALTH_STATE_DIR", "/var/lib/activitywatch/health")) - parser.add_argument("--report-dir", default=_env("AW_DLP_COMPLIANCE_REPORT_DIR", "/opt/activitywatch/dlp-compliance/reports")) - parser.add_argument("--profiles", default=_env("AW_DLP_COMPLIANCE_PROFILES", "152-fz,pci-dss")) - parser.add_argument("--json", action="store_true") - args = parser.parse_args() - - report = HealthReport() - aw_api_base = args.aw_server.rstrip("/") + "/api/0" - counter_state_path = Path(args.state_dir) / "dlp-health-check-counters.json" - counter_state = _load_counter_state(counter_state_path) - - check_http_endpoint(report, "http:aw", f"{aw_api_base}/info") - check_http_endpoint(report, "http:policy", args.policy_server.rstrip("/") + "/healthz") - check_http_endpoint(report, "http:cases", args.case_server.rstrip("/") + "/health") - - for unit in ( - "activitywatch-server", - "aw-dlp-policy-engine.service", - "aw-dlp-case-management.service", - "aw-worktime-api.service", - ): - check_systemd_unit(report, unit, "service") - - for unit in ( - "aw-dlp-report-scheduler.timer", - "aw-dlp-syslog-forwarder.timer", - "aw-dlp-webhook-sender.timer", - "aw-dlp-cef-exporter.timer", - "activitywatch-dlp-aggregator.timer", - "aw-dlp-ioc-refresh.timer", - "aw-worktime-ui-bridge.timer", - ): - check_systemd_unit(report, unit, "timer") - - try: - buckets = _http_json(f"{aw_api_base}/buckets") - if not isinstance(buckets, dict): - raise RuntimeError("bucket list is not a dict") - report.add("aw:buckets-index", "ok", "bucket index loaded", total=len(buckets)) - check_endpoint_signal_buckets(report, aw_api_base, buckets, args.max_age_seconds) - check_file_operations_buckets(report, aw_api_base, buckets, args.max_age_seconds, args.strict_fileops) - check_file_operations_runtime( - report, - aw_api_base, - buckets, - args.fileops_sample_limit, - args.fileops_queue_warn_depth, - args.fileops_send_failure_warn_count, - counter_state, - ) - check_incident_buckets(report, aw_api_base, buckets, args.max_age_seconds * 24) - check_incident_runtime(report, aw_api_base, buckets, args.incident_sample_limit) - check_endpoint_self_test_metrics( - report, - aw_api_base, - buckets, - args.endpoint_queue_warn_depth, - args.endpoint_send_failure_warn_count, - counter_state, - ) - except Exception as exc: - report.add("aw:buckets-index", "fail", f"failed to inspect bucket index: {exc}") - - state_error = _save_counter_state(counter_state_path, counter_state) - if state_error: - report.add("state:counters", "warn", f"failed to save counter baseline: {state_error}", path=str(counter_state_path)) - - month = _now_utc().strftime("%Y-%m") - profiles = [x.strip() for x in args.profiles.split(",") if x.strip()] - check_compliance_reports(report, Path(args.report_dir), profiles, month) - - payload = report.as_dict() - if args.json: - print(json.dumps(payload, ensure_ascii=False, indent=2)) - else: - print(report.render_text()) - return 0 if payload["ok"] else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/extract_ioc_from_sigma.py b/scripts/extract_ioc_from_sigma.py deleted file mode 100644 index a9c27c2..0000000 --- a/scripts/extract_ioc_from_sigma.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env python3 -""" -Extract IOC-like indicators from Sigma YAML rules for DLP preload. - -Extracted fields: - - Image|endswith - - CommandLine|contains - - OriginalFileName - - Hashes|SHA256 (plus SHA256 values embedded in Hashes strings) -""" - -from __future__ import annotations - -import argparse -import csv -import json -import re -from pathlib import Path -from typing import Any - -import yaml - - -BASE_FIELDS = {"image", "commandline", "originalfilename", "hashes"} -SHA256_RE = re.compile(r"\b[a-fA-F0-9]{64}\b") - - -def split_key(key: str) -> tuple[str, list[str]]: - parts = [p.strip() for p in str(key).split("|") if p.strip()] - if not parts: - return "", [] - return parts[0].lower(), [p.lower() for p in parts[1:]] - - -def flatten_values(value: Any) -> list[str]: - if value is None: - return [] - if isinstance(value, str): - v = value.strip() - return [v] if v else [] - if isinstance(value, (int, float, bool)): - return [str(value)] - if isinstance(value, list): - out: list[str] = [] - for item in value: - out.extend(flatten_values(item)) - return out - if isinstance(value, dict): - out: list[str] = [] - for k, v in value.items(): - vals = flatten_values(v) - for vv in vals: - out.append(f"{k}:{vv}") - return out - return [] - - -def detect_ioc_type(base: str, ops: list[str], raw: str) -> str | None: - if base == "image" and "endswith" in ops: - return "process_image_endswith" - if base == "commandline" and "contains" in ops: - return "commandline_contains" - if base == "originalfilename": - return "original_filename" - if base == "hashes" and ("sha256" in ops or SHA256_RE.search(raw)): - return "sha256" - return None - - -def parse_sha256(raw: str) -> list[str]: - vals = SHA256_RE.findall(raw) - seen = set() - out = [] - for v in vals: - lv = v.lower() - if lv in seen: - continue - seen.add(lv) - out.append(lv) - return out - - -def walk(node: Any, *, rule_id: str, rule_title: str, source_file: str, out: list[dict[str, str]]) -> None: - if isinstance(node, dict): - for k, v in node.items(): - base, ops = split_key(str(k)) - if base in BASE_FIELDS: - for raw in flatten_values(v): - ioc_type = detect_ioc_type(base, ops, raw) - if not ioc_type: - continue - if ioc_type == "sha256": - for h in parse_sha256(raw): - out.append( - { - "ioc_type": "sha256", - "ioc_value": h, - "field": str(k), - "rule_id": rule_id, - "rule_title": rule_title, - "source_file": source_file, - } - ) - else: - out.append( - { - "ioc_type": ioc_type, - "ioc_value": raw, - "field": str(k), - "rule_id": rule_id, - "rule_title": rule_title, - "source_file": source_file, - } - ) - walk(v, rule_id=rule_id, rule_title=rule_title, source_file=source_file, out=out) - elif isinstance(node, list): - for item in node: - walk(item, rule_id=rule_id, rule_title=rule_title, source_file=source_file, out=out) - - -def extract_from_yaml(path: Path) -> list[dict[str, str]]: - try: - doc = yaml.safe_load(path.read_text(encoding="utf-8", errors="ignore")) - except Exception: - return [] - if not isinstance(doc, dict): - return [] - detection = doc.get("detection") - if detection is None: - return [] - rid = str(doc.get("id") or "") - title = str(doc.get("title") or "") - rows: list[dict[str, str]] = [] - walk(detection, rule_id=rid, rule_title=title, source_file=str(path), out=rows) - return rows - - -def dedupe(rows: list[dict[str, str]]) -> list[dict[str, str]]: - seen = set() - out = [] - for r in rows: - key = (r["ioc_type"], r["ioc_value"].lower(), r["field"]) - if key in seen: - continue - seen.add(key) - out.append(r) - return out - - -def write_json(path: Path, rows: list[dict[str, str]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8") - - -def write_csv(path: Path, rows: list[dict[str, str]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - fields = ["ioc_type", "ioc_value", "field", "rule_id", "rule_title", "source_file"] - with path.open("w", encoding="utf-8", newline="") as f: - w = csv.DictWriter(f, fieldnames=fields) - w.writeheader() - for row in rows: - w.writerow(row) - - -def sql_escape(s: str) -> str: - return s.replace("'", "''") - - -def write_sql(path: Path, rows: list[dict[str, str]], table_name: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8") as f: - f.write( - f"CREATE TABLE IF NOT EXISTS {table_name} (\n" - " id INTEGER PRIMARY KEY AUTOINCREMENT,\n" - " ioc_type TEXT NOT NULL,\n" - " ioc_value TEXT NOT NULL,\n" - " field TEXT,\n" - " rule_id TEXT,\n" - " rule_title TEXT,\n" - " source_file TEXT\n" - ");\n\n" - ) - for r in rows: - f.write( - f"INSERT INTO {table_name} (ioc_type, ioc_value, field, rule_id, rule_title, source_file) VALUES " - f"('{sql_escape(r['ioc_type'])}'," - f"'{sql_escape(r['ioc_value'])}'," - f"'{sql_escape(r['field'])}'," - f"'{sql_escape(r['rule_id'])}'," - f"'{sql_escape(r['rule_title'])}'," - f"'{sql_escape(r['source_file'])}');\n" - ) - - -def main() -> int: - ap = argparse.ArgumentParser(description="Extract IOC-like Sigma values for DLP preload.") - ap.add_argument("--rules-root", default="rules", help="Path to hayabusa-rules root") - ap.add_argument("--out-dir", default="ioc_export", help="Output directory") - ap.add_argument("--table-name", default="dlp_blacklist_ioc", help="SQL table name") - args = ap.parse_args() - - rules_root = Path(args.rules_root) - if not rules_root.exists(): - raise SystemExit(f"rules root not found: {rules_root}") - - yaml_files = [p for p in rules_root.rglob("*") if p.is_file() and p.suffix.lower() in {".yml", ".yaml"}] - all_rows: list[dict[str, str]] = [] - for yp in yaml_files: - all_rows.extend(extract_from_yaml(yp)) - - rows = dedupe(all_rows) - rows.sort(key=lambda r: (r["ioc_type"], r["ioc_value"].lower())) - - out_dir = Path(args.out_dir) - write_json(out_dir / "ioc_blacklist.json", rows) - write_csv(out_dir / "ioc_blacklist.csv", rows) - write_sql(out_dir / "ioc_blacklist.sql", rows, args.table_name) - - counts: dict[str, int] = {} - for r in rows: - counts[r["ioc_type"]] = counts.get(r["ioc_type"], 0) + 1 - - print(f"rules_scanned={len(yaml_files)}") - print(f"iocs_extracted={len(rows)}") - for k in sorted(counts): - print(f"{k}={counts[k]}") - print(f"json={out_dir / 'ioc_blacklist.json'}") - print(f"csv={out_dir / 'ioc_blacklist.csv'}") - print(f"sql={out_dir / 'ioc_blacklist.sql'}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) - diff --git a/scripts/merge_aw_server_dbs.py b/scripts/merge_aw_server_dbs.py deleted file mode 100644 index 9f7408f..0000000 --- a/scripts/merge_aw_server_dbs.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import os -import sqlite3 -import sys -from pathlib import Path - - -def maybe_exec_rust() -> None: - if os.environ.get("MERGE_AW_SERVER_DBS_FORCE_LEGACY") == "1": - return - script_path = Path(__file__).resolve() - repo_root = script_path.parent.parent if script_path.parent.name == "scripts" else None - candidates = [ - os.environ.get("MERGE_AW_SERVER_DBS_RUST"), - str(Path(os.environ.get("CARGO_TARGET_DIR", "")) / "release" / "merge-aw-server-dbs") - if os.environ.get("CARGO_TARGET_DIR") - else None, - str(repo_root / "adk-rust" / "target" / "release" / "merge-aw-server-dbs") - if repo_root - else None, - "/usr/local/bin/merge-aw-server-dbs", - ] - for candidate in candidates: - if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK): - os.execv(candidate, [candidate, *sys.argv[1:]]) - - -maybe_exec_rust() - - -def connect(path: Path) -> sqlite3.Connection: - connection = sqlite3.connect(str(path)) - connection.execute("PRAGMA journal_mode=WAL") - connection.execute("PRAGMA synchronous=NORMAL") - return connection - - -def bucket_key(row: sqlite3.Row) -> tuple[str, str, str, str]: - return ( - str(row["name"]), - str(row["type"]), - str(row["client"]), - str(row["hostname"]), - ) - -def find_bucket_by_name(connection: sqlite3.Connection, name: str) -> int | None: - row = connection.execute( - "select rowid as bucketrow from buckets where name = ? order by rowid limit 1", - (name,), - ).fetchone() - return int(row["bucketrow"]) if row else None - - -def find_bucket_by_key(connection: sqlite3.Connection, name: str, type_: str, client: str, hostname: str) -> int | None: - row = connection.execute( - "select rowid as bucketrow from buckets where name = ? and type = ? and client = ? and hostname = ? order by rowid limit 1", - (name, type_, client, hostname), - ).fetchone() - return int(row["bucketrow"]) if row else None - - -def copy_sqlite_via_backup(src: Path, dst: Path) -> None: - """Create a consistent copy of an sqlite DB using the sqlite backup API. - - This avoids corrupt/inconsistent files if the source DB is live. - """ - # ensure parent exists - dst.parent.mkdir(parents=True, exist_ok=True) - # remove any existing tmp file - if dst.exists(): - dst.unlink() - src_conn = sqlite3.connect(str(src)) - dst_conn = sqlite3.connect(str(dst)) - try: - # perform online backup - src_conn.backup(dst_conn) - dst_conn.commit() - finally: - try: - src_conn.close() - finally: - dst_conn.close() - - -def ensure_parent(path: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - - -def load_existing_events(connection: sqlite3.Connection, bucketrow: int) -> set[tuple[int, int, str]]: - cursor = connection.execute( - "select starttime, endtime, data from events where bucketrow = ?", - (bucketrow,), - ) - return {(int(start), int(end), str(data)) for start, end, data in cursor.fetchall()} - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--base", required=True) - parser.add_argument("--output", required=True) - parser.add_argument("--overlay") - args = parser.parse_args() - - base = Path(args.base) - output = Path(args.output) - overlay = Path(args.overlay) if args.overlay else None - - if not base.exists(): - raise SystemExit(f"Base DB not found: {base}") - - ensure_parent(output) - tmp_output = output.with_suffix(output.suffix + ".tmp") - # make a consistent copy of the base DB into tmp_output - copy_sqlite_via_backup(base, tmp_output) - - dest = connect(tmp_output) - dest.row_factory = sqlite3.Row - - inserted_buckets = 0 - inserted_events = 0 - - if overlay and overlay.exists(): - source = connect(overlay) - source.row_factory = sqlite3.Row - try: - source_buckets = source.execute( - "select rowid as bucketrow, id, name, type, client, hostname, created, data_deprecated, data from buckets order by rowid" - ).fetchall() - - dest_bucket_map = { - bucket_key(row): row["bucketrow"] - for row in dest.execute( - "select rowid as bucketrow, id, name, type, client, hostname, created, data_deprecated, data from buckets order by rowid" - ).fetchall() - } - dest_id_map = { - row["id"]: row["bucketrow"] - for row in dest.execute( - "select rowid as bucketrow, id, name, type, client, hostname, created, data_deprecated, data from buckets order by rowid" - ).fetchall() - if row["id"] - } - - for src_bucket in source_buckets: - key = bucket_key(src_bucket) - src_id = src_bucket["id"] if "id" in src_bucket.keys() else None - dest_rowid = None - # Prefer exact id match if available - if src_id: - dest_rowid = dest_id_map.get(src_id) - if dest_rowid is None: - dest_rowid = dest_bucket_map.get(key) - if dest_rowid is None: - dest_rowid = find_bucket_by_name(dest, str(src_bucket["name"])) - if dest_rowid is not None: - dest.execute( - """ - UPDATE buckets - SET type = ?, client = ?, hostname = ?, created = ?, - data_deprecated = ?, data = ? - WHERE rowid = ? - """, - ( - src_bucket["type"], - src_bucket["client"], - src_bucket["hostname"], - src_bucket["created"], - src_bucket["data_deprecated"], - src_bucket["data"], - dest_rowid, - ), - ) - else: - cursor = dest.execute( - """ - INSERT INTO buckets (id, name, type, client, hostname, created, data_deprecated, data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - src_bucket["id"], - src_bucket["name"], - src_bucket["type"], - src_bucket["client"], - src_bucket["hostname"], - src_bucket["created"], - src_bucket["data_deprecated"], - src_bucket["data"], - ), - ) - dest_rowid = int(cursor.lastrowid) - inserted_buckets += 1 - - dest_bucket_map[key] = dest_rowid - if src_id: - dest_id_map[str(src_id)] = dest_rowid - - existing_events = load_existing_events(dest, dest_rowid) - for starttime, endtime, data in source.execute( - "select starttime, endtime, data from events where bucketrow = ? order by id", - (src_bucket["bucketrow"],), - ).fetchall(): - event_key = (int(starttime), int(endtime), str(data)) - if event_key in existing_events: - continue - dest.execute( - "insert into events (bucketrow, starttime, endtime, data) values (?, ?, ?, ?)", - (dest_rowid, int(starttime), int(endtime), str(data)), - ) - existing_events.add(event_key) - inserted_events += 1 - - dest.commit() - finally: - source.close() - - dest.close() - os.replace(tmp_output, output) - print( - json.dumps( - { - "base": str(base), - "overlay": str(overlay) if overlay else None, - "output": str(output), - "inserted_buckets": inserted_buckets, - "inserted_events": inserted_events, - }, - ensure_ascii=False, - ) - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/prod_backup_restore.sh b/scripts/prod_backup_restore.sh index 9780df2..bc3f50e 100644 --- a/scripts/prod_backup_restore.sh +++ b/scripts/prod_backup_restore.sh @@ -11,84 +11,19 @@ if [[ -f "${ROOT_DIR}/secrets/runtime.env" ]]; then set +a fi -if [[ "${1:-}" == "--apply-legacy" ]]; then - shift -else - for candidate in \ - "${PROD_BACKUP_RESTORE_RUST:-}" \ - "${CARGO_TARGET_DIR:-}/release/prod-backup-restore" \ - "$ROOT_DIR/adk-rust/target/release/prod-backup-restore" \ - "/usr/local/bin/prod-backup-restore"; do - if [[ -n "$candidate" && -x "$candidate" ]]; then - exec "$candidate" --root "$ROOT_DIR" "$@" - fi - done - cat >&2 <<'EOF' -prod_backup_restore.sh is destructive and now requires the Rust planner. +for candidate in \ + "${PROD_BACKUP_RESTORE_RUST:-}" \ + "${CARGO_TARGET_DIR:-}/release/prod-backup-restore" \ + "$ROOT_DIR/adk-rust/target/release/prod-backup-restore" \ + "/usr/local/bin/prod-backup-restore"; do + if [[ -n "$candidate" && -x "$candidate" ]]; then + exec "$candidate" --root "$ROOT_DIR" "$@" + fi +done + +cat >&2 </dev/null 2>&1 || { echo "missing sshpass" >&2; exit 127; } -command -v ansible-playbook >/dev/null 2>&1 || { echo "missing ansible-playbook" >&2; exit 127; } - -SERVER_HOST="${AW_SERVER_HOST:-10.10.10.13}" -SERVER_USER="${AW_SERVER_USER:-igor}" -TIMESTAMP="$(date +%Y%m%d-%H%M%S)" -REMOTE_BACKUP_DIR="/var/lib/activitywatch/backups/prod-restore-${TIMESTAMP}" -LEGACY_DB="/root/.local/share/activitywatch/aw-server-rust/sqlite.db" -TARGET_DB="/var/lib/activitywatch/.local/share/activitywatch/aw-server-rust/sqlite.db" -REMOTE_MERGE_SCRIPT="/tmp/merge_aw_server_dbs.py" - -ssh_remote() { - sshpass -p "$AW_SSH_PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "${SERVER_USER}@${SERVER_HOST}" "$@" -} - -scp_remote() { - sshpass -p "$AW_SSH_PASSWORD" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$@" -} - -scp_remote "${ROOT_DIR}/scripts/merge_aw_server_dbs.py" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_MERGE_SCRIPT}" - -ssh_remote "sudo mkdir -p '${REMOTE_BACKUP_DIR}' && sudo chown root:root '${REMOTE_BACKUP_DIR}'" -ssh_remote "sudo test -f '${LEGACY_DB}'" -ssh_remote "sudo test -f '${TARGET_DB}'" -ssh_remote "sudo cp -a '${LEGACY_DB}' '${REMOTE_BACKUP_DIR}/legacy-root-sqlite.db' && sudo cp -a '${TARGET_DB}' '${REMOTE_BACKUP_DIR}/target-before-merge-sqlite.db'" -ssh_remote "sudo systemctl stop activitywatch-server.service || true" -ssh_remote "sudo python3 '${REMOTE_MERGE_SCRIPT}' --base '${LEGACY_DB}' --overlay '${TARGET_DB}' --output '${REMOTE_BACKUP_DIR}/sqlite.merged.db'" -ssh_remote "sudo install -o activitywatch -g activitywatch -m 0644 '${REMOTE_BACKUP_DIR}/sqlite.merged.db' '${TARGET_DB}'" - -ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_server.yml -ansible-playbook -i ansible/inventory.ini ansible/deploy_aw_windows.yml -ansible-playbook -i ansible/inventory.ini ansible/post_validate_aw_windows.yml - -python3 - <<'PY' -import json, urllib.request -base = 'http://10.10.10.13:5600' -window_payload = { - 'timeperiods': ['2026-04-29T00:00:00+03:00/2026-04-29T23:59:59+03:00'], - 'query': [ - 'window_events = query_bucket(find_bucket("aw-watcher-window_SHARKON2025"));', - 'RETURN = window_events;' - ] -} -req = urllib.request.Request(base + '/api/0/query/', data=json.dumps(window_payload).encode(), method='POST', headers={'Content-Type': 'application/json', 'Origin': 'http://10.10.10.13:5600'}) -with urllib.request.urlopen(req) as response: - data = json.loads(response.read().decode()) -window_count = len(data[0]) if isinstance(data, list) and data else 0 -if window_count <= 0: - raise SystemExit('no historical window data restored for 2026-04-29') -with urllib.request.urlopen(base + '/api/0/settings/') as response: - settings = json.loads(response.read().decode()) -if settings.get('always_active_pattern') != 'aw-watcher-window': - raise SystemExit('always_active_pattern is not configured') -print(json.dumps({'restored_window_events_2026_04_29': window_count, 'always_active_pattern': settings.get('always_active_pattern')}, ensure_ascii=False)) -PY +exit 127 diff --git a/scripts/rebuild_install_kit.sh b/scripts/rebuild_install_kit.sh index 13936f0..001c39e 100644 --- a/scripts/rebuild_install_kit.sh +++ b/scripts/rebuild_install_kit.sh @@ -23,150 +23,5 @@ for candidate in "${rust_candidates[@]}"; do fi done -KIT_DIR="install-kit-awindows-20260427-211240" -ZIP_ARCHIVE="${KIT_DIR}.zip" -TAR_ARCHIVE="${KIT_DIR}.tar.gz" -SERVER_CONFIG_DIR="${KIT_DIR}/server-configs-192.168.100.18" -OLD_SERVER_CONFIG_DIR="${KIT_DIR}/server-configs-192.168.100.21" -TMP_SERVER_CONFIG_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_SERVER_CONFIG_DIR"' EXIT - -copy_file() { - local src="$1" - local dest="$2" - mkdir -p "$(dirname "$dest")" - cp "$src" "$dest" -} - -sync_tree() { - local base="$1" - shift - for rel in "$@"; do - copy_file "$rel" "${KIT_DIR}/${rel}" - done -} - -if [[ -d "$OLD_SERVER_CONFIG_DIR" ]]; then - cp "$OLD_SERVER_CONFIG_DIR"/*.deployment-config.json "$TMP_SERVER_CONFIG_DIR"/ -fi -if [[ -d "$SERVER_CONFIG_DIR" ]]; then - cp "$SERVER_CONFIG_DIR"/*.deployment-config.json "$TMP_SERVER_CONFIG_DIR"/ -fi - -rm -rf "${KIT_DIR}/ansible" "${KIT_DIR}/aw-server" "${KIT_DIR}/windows" "${KIT_DIR}/scripts" "${KIT_DIR}/server-configs-"* - -ansible_files=( - ansible/README.md - ansible/deploy_aw_pfsense_poller.yml - ansible/deploy_aw_server.yml - ansible/deploy_aw_windows.yml - ansible/group_vars/all.example.yml - ansible/group_vars/pfsense-poller.example.yml - ansible/group_vars/proxmox-matrix.example.yml - ansible/group_vars/proxmox.example.yml - ansible/group_vars/windows.example.yml - ansible/install_full_stack.yml - ansible/inventory.example.ini - ansible/provision_proxmox_ct_and_deploy_aw.yml - ansible/provision_proxmox_ct_matrix_and_deploy_aw.yml - ansible/tasks/provision_ct_and_deploy_aw.yml -) - -aw_server_files=( - aw-server/activitywatch-server.service - aw-server/apply_webui_ru_patch.sh - aw-server/aw-host-groups.json - aw-server/aw-ru-patch.js - aw-server/aw-rus-healthd.py - aw-server/aw-rus-healthd.service - aw-server/aw-rus-healthd.timer - aw-server/aw-browser-smoke.service - aw-server/aw-browser-smoke.timer - aw-server/aw-slo-monitor.py - aw-server/aw-slo-monitor.service - aw-server/aw-slo-monitor.timer - aw-server/aw-server.env.example - aw-server/aw-sw-cleanup.js - aw-server/aw-worktime-api.py - aw-server/aw-worktime-api.service - aw-server/aw-worktime-prewarm.sh - aw-server/aw-worktime-prewarm.service - aw-server/aw-worktime-prewarm.timer - aw-server/aw-worktime-panel.js - aw-server/health-check.sh - aw-server/install_aw_server.sh - aw-server/settings/classes-worktime.json - aw-server/settings/views-default.json -) - -windows_files=( - windows/ActivityWatch.Windows.Common.psd1 - windows/ActivityWatch.Windows.Common.psm1 - windows/browser-domains-native-collector.ps1 - windows/deploy-domain-users.ps1 - windows/deploy-ensemble.ps1 - windows/deploy-single-user.ps1 - windows/AWatchRusCollectorGuardService.cs - windows/aw-collector-guard.ps1 - windows/install-collector-guard-service.ps1 - windows/dlp-endpoint-signals-collector.ps1 - windows/dlp-policy.example.json - windows/dlp-policy.native-cross-os.example.json - windows/email-outbound-collector.ps1 - windows/hardening-recovery.ps1 - windows/migrate-awatch-rus-paths.ps1 - windows/validate-deployment.ps1 - windows/web-category-rules.example.json - windows/worktime-session-collector.ps1 -) - -scripts_files=( - scripts/aw-webui-browser-smoke.mjs - scripts/aw-webui-browser-smoke.sh - scripts/check_install_kit_vs_repo.sh - scripts/quality-gate.sh - scripts/rebuild_install_kit.sh - scripts/validate_install_kit.sh - scripts/verify_innosetup_installer.sh -) - -sync_tree ansible "${ansible_files[@]}" -sync_tree aw-server "${aw_server_files[@]}" -sync_tree windows "${windows_files[@]}" -sync_tree scripts "${scripts_files[@]}" - -mkdir -p "$SERVER_CONFIG_DIR" -if compgen -G "$TMP_SERVER_CONFIG_DIR/*.deployment-config.json" >/dev/null; then - cp "$TMP_SERVER_CONFIG_DIR"/*.deployment-config.json "$SERVER_CONFIG_DIR"/ -fi - -cat > "${KIT_DIR}/README-INSTALL-KIT.txt" <<'EOF' -ActivityWatch DetMir Windows Install Kit - -Includes: -- windows/* (deploy scripts, collectors, common module, configs/examples) -- ansible/* (Windows and AW server playbooks, examples, inventory, tasks) -- aw-server/* (server installer, health orchestrator, RU patch loader, host groups, default settings) -- scripts/* (install-kit rebuild/validation, quality gates and browser/web smoke checks) -- server-configs-192.168.100.18/* (working Windows/RDP config snapshots) - -Source: -- Local project snapshot at build time. -EOF - -python3 - <<'PY' -from pathlib import Path -import hashlib - -root = Path('install-kit-awindows-20260427-211240') -manifest = root / 'MANIFEST.txt' -files = sorted(p for p in root.rglob('*') if p.is_file() and p.name != 'MANIFEST.txt') -with manifest.open('w', encoding='utf-8') as handle: - for path in files: - digest = hashlib.sha256(path.read_bytes()).hexdigest() - handle.write(f"{digest} {path.as_posix()}\n") -PY - -rm -f "$ZIP_ARCHIVE" "$TAR_ARCHIVE" -zip -rq "$ZIP_ARCHIVE" "$KIT_DIR" -tar -czf "$TAR_ARCHIVE" "$KIT_DIR" +echo "ERROR: Rust install-kit builder not found. Build it with: cd '$ROOT_DIR/adk-rust' && cargo build --release -p rebuild-install-kit" >&2 +exit 127 diff --git a/scripts/test_dlp_health_check.py b/scripts/test_dlp_health_check.py deleted file mode 100644 index 13ad931..0000000 --- a/scripts/test_dlp_health_check.py +++ /dev/null @@ -1,421 +0,0 @@ -#!/usr/bin/env python3 -import importlib.util -import sys -from pathlib import Path - - -MODULE_PATH = Path(__file__).with_name("dlp-health-check.py") -SPEC = importlib.util.spec_from_file_location("dlp_health_check", MODULE_PATH) -MODULE = importlib.util.module_from_spec(SPEC) -sys.modules[SPEC.name] = MODULE -SPEC.loader.exec_module(MODULE) - - -def test_endpoint_signals_ok_when_no_active_managed_hosts(monkeypatch): - buckets = { - "aw-worktime-sessions_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}, - "aw-dlp-endpoint-signals_SHARKON2025": {"metadata": {"end": "2026-05-30T07:00:00Z"}}, - } - monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:00:10Z")) - monkeypatch.setattr( - MODULE, - "_http_json", - lambda url: [{"timestamp": "2026-05-30T12:00:00Z", "data": {"active": False}}] - if "aw-worktime-sessions_SHARKON2025/events" in url - else [], - ) - report = MODULE.HealthReport() - - MODULE.check_endpoint_signal_buckets(report, "http://127.0.0.1:5600/api/0", buckets, 900) - - result = report.results[0] - assert result.name == "buckets:endpoint-signals" - assert result.status == "ok" - assert result.summary == "no active managed hosts require endpoint-signals freshness" - assert result.details["ignored_inactive"] == ["aw-dlp-endpoint-signals_SHARKON2025"] - - -def test_endpoint_self_test_metrics_reports_transport_counters(monkeypatch): - buckets = {"aw-dlp-endpoint-signals_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} - monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) - monkeypatch.setattr( - MODULE, - "_http_json", - lambda url, **kwargs: [ - { - "timestamp": "2026-05-30T11:59:00Z", - "data": { - "signalType": "self_test", - "queueDepth": 8, - "eventsEnqueued": 100, - "eventsFlushed": 92, - "sendFailures": 0, - }, - }, - { - "timestamp": "2026-05-30T12:00:30Z", - "data": { - "signalType": "self_test", - "queueDepth": "3", - "eventsEnqueued": "120", - "eventsFlushed": "117", - "sendFailures": "0", - }, - }, - ], - ) - report = MODULE.HealthReport() - - MODULE.check_endpoint_self_test_metrics(report, "http://127.0.0.1:5600/api/0", buckets) - - result = report.results[0] - assert result.name == "endpoint-self-test-metrics" - assert result.status == "ok" - latest = result.details["latest_self_tests"][0] - assert latest["bucket"] == "aw-dlp-endpoint-signals_SHARKON2025" - assert latest["timestamp"] == "2026-05-30T12:00:30Z" - assert latest["age_seconds"] == 30 - assert latest["queueDepth"] == 3 - assert latest["eventsEnqueued"] == 120 - assert latest["eventsFlushed"] == 117 - assert latest["sendFailures"] == 0 - assert latest["sendFailuresDelta"] == 0 - - -def test_endpoint_self_test_metrics_warns_on_transport_counters(monkeypatch): - buckets = {"aw-dlp-endpoint-signals_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} - monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) - monkeypatch.setattr( - MODULE, - "_http_json", - lambda url, **kwargs: [ - { - "timestamp": "2026-05-30T12:00:30Z", - "data": { - "signalType": "self_test", - "queueDepth": 101, - "eventsEnqueued": 120, - "eventsFlushed": 19, - "sendFailures": 1, - }, - } - ], - ) - report = MODULE.HealthReport() - - MODULE.check_endpoint_self_test_metrics( - report, - "http://127.0.0.1:5600/api/0", - buckets, - queue_warn_depth=100, - send_failure_warn_count=1, - ) - - result = report.results[0] - assert result.status == "warn" - assert result.summary == "endpoint transport counters outside thresholds" - assert result.details["warnings"] == [ - {"bucket": "aw-dlp-endpoint-signals_SHARKON2025", "metric": "queueDepth", "value": 101, "threshold": 100}, - { - "bucket": "aw-dlp-endpoint-signals_SHARKON2025", - "metric": "sendFailuresDelta", - "value": 1, - "current": 1, - "previous": None, - "threshold": 1, - }, - ] - - -def test_file_operations_runtime_reports_health_and_latest_operations(monkeypatch): - buckets = {"aw-file-operations_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} - monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) - monkeypatch.setattr( - MODULE, - "_http_json", - lambda url: [ - { - "timestamp": "2026-05-30T12:00:30Z", - "data": { - "signalType": "collector_health", - "queueDepth": "0", - "eventsEnqueued": "5", - "eventsFlushed": "5", - "sendFailures": "0", - "username": "USER1", - "hostname": "SHARKON2025", - "sessionId": "3", - }, - }, - { - "timestamp": "2026-05-30T12:00:20Z", - "data": { - "operation": "Created", - "path": "C:\\Users\\USER1\\Downloads\\report.zip", - "extension": ".zip", - "archiveHint": True, - "username": "USER1", - "hostname": "SHARKON2025", - "size": "42", - }, - }, - ], - ) - report = MODULE.HealthReport() - - MODULE.check_file_operations_runtime(report, "http://127.0.0.1:5600/api/0", buckets) - - result = report.results[0] - assert result.name == "file-operations-runtime" - assert result.status == "ok" - assert result.details["latest_health"][0]["queueDepth"] == 0 - assert result.details["latest_health"][0]["eventsEnqueued"] == 5 - assert result.details["latest_health"][0]["sendFailuresDelta"] == 0 - latest_op = result.details["latest_operations"][0] - assert latest_op["operation"] == "Created" - assert latest_op["extension"] == ".zip" - assert latest_op["archiveHint"] is True - assert latest_op["path_tail"] == "Downloads/report.zip" - assert latest_op["size"] == 42 - assert result.details["sampled"][0]["operation_counts"] == {"Created": 1} - - -def test_file_operations_runtime_warns_on_transport_counters(monkeypatch): - buckets = {"aw-file-operations_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} - monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) - monkeypatch.setattr( - MODULE, - "_http_json", - lambda url: [ - { - "timestamp": "2026-05-30T12:00:30Z", - "data": { - "signalType": "collector_health", - "queueDepth": 101, - "eventsEnqueued": 5, - "eventsFlushed": 2, - "sendFailures": 1, - "username": "USER1", - "hostname": "SHARKON2025", - "sessionId": 3, - }, - } - ], - ) - report = MODULE.HealthReport() - - MODULE.check_file_operations_runtime( - report, - "http://127.0.0.1:5600/api/0", - buckets, - queue_warn_depth=100, - send_failure_warn_count=1, - ) - - result = report.results[0] - assert result.status == "warn" - assert result.summary == "file-operations runtime counters outside expectations" - assert result.details["warnings"] == [ - {"bucket": "aw-file-operations_SHARKON2025", "metric": "queueDepth", "value": 101, "threshold": 100}, - { - "bucket": "aw-file-operations_SHARKON2025", - "metric": "sendFailuresDelta", - "value": 1, - "current": 1, - "previous": None, - "threshold": 1, - }, - ] - - -def test_endpoint_send_failures_uses_delta_baseline(monkeypatch): - buckets = {"aw-dlp-endpoint-signals_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} - state = {"counters": {}} - monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) - values = [12, 12, 13] - - def fake_http(url, **kwargs): - value = values.pop(0) - return [ - { - "timestamp": "2026-05-30T12:00:30Z", - "data": { - "signalType": "self_test", - "queueDepth": 0, - "eventsEnqueued": 120, - "eventsFlushed": 117, - "sendFailures": value, - }, - } - ] - - monkeypatch.setattr(MODULE, "_http_json", fake_http) - - first = MODULE.HealthReport() - MODULE.check_endpoint_self_test_metrics(first, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) - assert first.results[0].status == "ok" - assert first.results[0].details["latest_self_tests"][0]["sendFailuresPrevious"] is None - assert first.results[0].details["latest_self_tests"][0]["sendFailuresDelta"] == 0 - - second = MODULE.HealthReport() - MODULE.check_endpoint_self_test_metrics(second, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) - assert second.results[0].status == "ok" - assert second.results[0].details["latest_self_tests"][0]["sendFailuresPrevious"] == 12 - assert second.results[0].details["latest_self_tests"][0]["sendFailuresDelta"] == 0 - - third = MODULE.HealthReport() - MODULE.check_endpoint_self_test_metrics(third, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) - assert third.results[0].status == "warn" - assert third.results[0].details["warnings"] == [ - { - "bucket": "aw-dlp-endpoint-signals_SHARKON2025", - "metric": "sendFailuresDelta", - "value": 1, - "current": 13, - "previous": 12, - "threshold": 1, - } - ] - - -def test_file_operations_send_failures_uses_delta_baseline(monkeypatch): - buckets = {"aw-file-operations_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} - state = {"counters": {}} - monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) - values = [28, 28, 29] - - def fake_http(url, **kwargs): - value = values.pop(0) - return [ - { - "timestamp": "2026-05-30T12:00:30Z", - "data": { - "signalType": "collector_health", - "queueDepth": 0, - "eventsEnqueued": 120, - "eventsFlushed": 117, - "sendFailures": value, - }, - } - ] - - monkeypatch.setattr(MODULE, "_http_json", fake_http) - - first = MODULE.HealthReport() - MODULE.check_file_operations_runtime(first, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) - assert first.results[0].status == "ok" - assert first.results[0].details["latest_health"][0]["sendFailuresPrevious"] is None - assert first.results[0].details["latest_health"][0]["sendFailuresDelta"] == 0 - - second = MODULE.HealthReport() - MODULE.check_file_operations_runtime(second, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) - assert second.results[0].status == "ok" - assert second.results[0].details["latest_health"][0]["sendFailuresPrevious"] == 28 - assert second.results[0].details["latest_health"][0]["sendFailuresDelta"] == 0 - - third = MODULE.HealthReport() - MODULE.check_file_operations_runtime(third, "http://127.0.0.1:5600/api/0", buckets, counter_state=state) - assert third.results[0].status == "warn" - assert third.results[0].details["warnings"] == [ - { - "bucket": "aw-file-operations_SHARKON2025", - "metric": "sendFailuresDelta", - "value": 1, - "current": 29, - "previous": 28, - "threshold": 1, - } - ] - - -def test_incident_runtime_reports_counts_and_latest_real_incidents(monkeypatch): - buckets = {"aw-dlp-incidents_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} - monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) - monkeypatch.setattr( - MODULE, - "_http_json", - lambda url, **kwargs: [ - { - "timestamp": "2026-05-30T12:00:30Z", - "data": { - "ruleId": "usb-archive-copy", - "severity": "high", - "action": "alert", - "message": "Archive copied to removable device with a long message that should not leak full raw content.", - "username": "USER1", - "hostname": "SHARKON2025", - "source": "endpoint", - }, - }, - { - "timestamp": "2026-05-30T12:00:00Z", - "data": { - "ruleId": "selftest-dlp-incident", - "severity": "low", - "action": "alert", - "message": "Self-test DLP incident from validation", - "signalType": "self_test", - "source": "self-test", - }, - }, - ], - ) - report = MODULE.HealthReport() - - MODULE.check_incident_runtime(report, "http://127.0.0.1:5600/api/0", buckets, sample_limit=20) - - result = report.results[0] - assert result.name == "incident-runtime" - assert result.status == "ok" - assert result.summary == "1 real incidents in sampled events" - assert result.details["totals"] == {"sampled_events": 2, "real_incidents": 1, "self_tests": 1} - assert result.details["severity_counts"] == {"high": 1} - assert result.details["action_counts"] == {"alert": 1} - assert result.details["rule_counts"] == {"usb-archive-copy": 1} - latest = result.details["latest_incidents"][0] - assert latest["age_seconds"] == 30 - assert latest["ruleId"] == "usb-archive-copy" - assert latest["message_excerpt"].endswith(".") - - -def test_incident_runtime_defaults_to_metadata_without_event_sampling(monkeypatch): - buckets = {"aw-dlp-incidents_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} - monkeypatch.setattr(MODULE, "_now_utc", lambda: MODULE._parse_ts("2026-05-30T12:01:00Z")) - - def fail_if_called(url, **kwargs): - raise AssertionError("event sampling should stay disabled") - - monkeypatch.setattr(MODULE, "_http_json", fail_if_called) - report = MODULE.HealthReport() - - MODULE.check_incident_runtime(report, "http://127.0.0.1:5600/api/0", buckets, sample_limit=0) - - result = report.results[0] - assert result.name == "incident-runtime" - assert result.status == "ok" - assert result.summary == "incident event sampling disabled" - assert result.details["metadata"] == [ - { - "bucket": "aw-dlp-incidents_SHARKON2025", - "end": "2026-05-30T12:00:00Z", - "age_seconds": 60, - } - ] - - -def test_incident_runtime_warns_on_read_failure(monkeypatch): - buckets = {"aw-dlp-incidents_SHARKON2025": {"metadata": {"end": "2026-05-30T12:00:00Z"}}} - - def fail_http(url, **kwargs): - raise RuntimeError("timeout") - - monkeypatch.setattr(MODULE, "_http_json", fail_http) - report = MODULE.HealthReport() - - MODULE.check_incident_runtime(report, "http://127.0.0.1:5600/api/0", buckets, sample_limit=20) - - result = report.results[0] - assert result.name == "incident-runtime" - assert result.status == "warn" - assert result.summary == "1 incident buckets failed to sample" - assert result.details["read_failed"] == [{"bucket": "aw-dlp-incidents_SHARKON2025", "error": "timeout"}] diff --git a/scripts/validate_install_kit.sh b/scripts/validate_install_kit.sh index 94b924a..c209127 100755 --- a/scripts/validate_install_kit.sh +++ b/scripts/validate_install_kit.sh @@ -32,75 +32,5 @@ for candidate in "${rust_candidates[@]}"; do fi done -required_files=( - "$MANIFEST" - "$KIT_DIR/README-INSTALL-KIT.txt" - "$KIT_DIR/windows/deploy-ensemble.ps1" - "$KIT_DIR/windows/validate-deployment.ps1" - "$KIT_DIR/ansible/deploy_aw_windows.yml" - "$KIT_DIR/aw-server/install_aw_server.sh" - "$KIT_DIR/scripts/rebuild_install_kit.sh" - "$KIT_DIR/scripts/validate_install_kit.sh" - "$KIT_DIR/scripts/check_install_kit_vs_repo.sh" - "$KIT_DIR/scripts/quality-gate.sh" -) - -echo "[1/4] Required files presence" -for file in "${required_files[@]}"; do - [[ -f "$file" ]] || { echo "Missing required file: $file"; exit 1; } -done - -echo "[2/4] Manifest checksum verification" -sha256sum -c "$MANIFEST" >/dev/null - -echo "[3/4] Manifest completeness" -python3 - <<'PY' -from pathlib import Path -import sys -kit=Path('install-kit-awindows-20260427-211240') -manifest=kit/'MANIFEST.txt' -listed=[] -for line in manifest.read_text().splitlines(): - line=line.strip() - if not line: - continue - parts=line.split(' ',1) - if len(parts)!=2: - print(f'Invalid MANIFEST line: {line}') - sys.exit(1) - listed.append(parts[1]) -listed_set=set(listed) -actual_set={str(p) for p in kit.rglob('*') if p.is_file() and p.name!='MANIFEST.txt'} -missing=sorted(listed_set-actual_set) -extra=sorted(actual_set-listed_set) -if missing or extra: - print('Missing files listed in MANIFEST:', missing) - print('Files not listed in MANIFEST:', extra) - sys.exit(1) -print(f'MANIFEST complete: {len(actual_set)} files tracked') -PY - -echo "[4/4] Archive composition check" -python3 - <<'PY' -from pathlib import Path -import tarfile, zipfile, sys -kit_prefix='install-kit-awindows-20260427-211240/' -zip_path=Path('install-kit-awindows-20260427-211240.zip') -tar_path=Path('install-kit-awindows-20260427-211240.tar.gz') -if not zip_path.exists() or not tar_path.exists(): - print('Archives not found') - sys.exit(1) -with zipfile.ZipFile(zip_path) as z: - zip_files=sorted(i for i in z.namelist() if not i.endswith('/')) -with tarfile.open(tar_path, 'r:gz') as t: - tar_files=sorted(m.name for m in t.getmembers() if m.isfile()) -if zip_files != tar_files: - print('ZIP and TAR contents differ') - sys.exit(1) -if not all(f.startswith(kit_prefix) for f in zip_files): - print('Unexpected archive prefix layout') - sys.exit(1) -print(f'Archives match: {len(zip_files)} files') -PY - -echo "validate_install_kit: OK" +echo "ERROR: Rust validator not found. Build it with: cd '$ROOT_DIR/adk-rust' && cargo build --release -p validate-install-kit" >&2 +exit 127