diff --git a/adk-rust/crates/detmir-grafana-check/src/main.rs b/adk-rust/crates/detmir-grafana-check/src/main.rs index b7dad83..09e1334 100644 --- a/adk-rust/crates/detmir-grafana-check/src/main.rs +++ b/adk-rust/crates/detmir-grafana-check/src/main.rs @@ -19,6 +19,7 @@ const REQUIRED_MEASUREMENTS: &[&str] = &[ "aw_rdp_worktime_daily", "aw_rdp_worktime_summary_daily", "aw_true_active_app_daily", + "aw_worktime_exporter_heartbeat", ]; #[derive(Debug, Parser)] diff --git a/adk-rust/crates/quality-gate/src/main.rs b/adk-rust/crates/quality-gate/src/main.rs index f7d0fc1..f55a4a6 100644 --- a/adk-rust/crates/quality-gate/src/main.rs +++ b/adk-rust/crates/quality-gate/src/main.rs @@ -34,12 +34,12 @@ fn run() -> Result<()> { } fn quality_gate(root: &Path) -> Result<()> { - println!("[1/5] Bash syntax check"); + println!("[1/6] Bash syntax check"); for file in collect_by_extension(root, &["aw-server", "proxmox", "scripts"], "sh")? { run_status(Command::new("bash").arg("-n").arg(&file), false)?; } - println!("[2/5] Shellcheck (if available)"); + println!("[2/6] Shellcheck (if available)"); if command_exists("shellcheck") { let mut files = collect_by_extension(root, &["aw-server", "proxmox"], "sh")?; files.push(root.join("scripts/aw-webui-browser-smoke.sh")); @@ -53,7 +53,7 @@ fn quality_gate(root: &Path) -> Result<()> { println!("shellcheck not found, skipping."); } - println!("[3/5] Node syntax check (if node available)"); + println!("[3/6] Node syntax check (if node available)"); if command_exists("node") { run_status( Command::new("node") @@ -66,7 +66,7 @@ fn quality_gate(root: &Path) -> Result<()> { println!("node not found, skipping."); } - println!("[4/5] PowerShell parse check (if pwsh available)"); + println!("[4/6] PowerShell parse check (if pwsh available)"); if command_exists("pwsh") { let ps = r#" $ErrorActionPreference = "Stop" @@ -107,7 +107,7 @@ foreach ($path in @("windows/ActivityWatch.Windows.Common.psm1", "windows/Activi println!("pwsh not found, skipping."); } - println!("[5/5] Ansible syntax check (if ansible-playbook available)"); + println!("[5/6] Ansible syntax check (if ansible-playbook available)"); if command_exists("ansible-playbook") { for playbook in collect_top_level_yml(&root.join("ansible"))? { run_status( @@ -124,6 +124,9 @@ foreach ($path in @("windows/ActivityWatch.Windows.Common.psm1", "windows/Activi println!("ansible-playbook not found, skipping."); } + println!("[6/6] DetMir Python runtime retirement guard"); + python_runtime_guard(root)?; + println!("quality-gate: OK"); Ok(()) } @@ -200,13 +203,107 @@ fn collect_top_level_yml(dir: &Path) -> Result> { Ok(out) } +fn python_runtime_guard(root: &Path) -> Result<()> { + let mut violations = Vec::new(); + for rel in tracked_files(root)? { + if is_allowed_python_runtime_path(&rel) { + continue; + } + if rel.ends_with(".py") && is_detmir_retired_runtime_path(&rel) { + violations.push(rel); + } + } + + if !violations.is_empty() { + violations.sort(); + bail!( + "Python runtime regression in Rust-retired DetMir paths:\n{}", + violations.join("\n") + ); + } + Ok(()) +} + +fn tracked_files(root: &Path) -> Result> { + if command_exists("git") { + let output = Command::new("git") + .arg("ls-files") + .current_dir(root) + .output() + .context("run git ls-files")?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect()); + } + } + + let mut out = Vec::new(); + collect_tracked_fallback(root, root, &mut out)?; + out.sort(); + Ok(out) +} + +fn collect_tracked_fallback(root: &Path, path: &Path, out: &mut Vec) -> Result<()> { + for entry in fs::read_dir(path).with_context(|| format!("read dir {}", path.display()))? { + let entry = entry.with_context(|| format!("read dir entry {}", path.display()))?; + let entry_path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("read file type {}", entry_path.display()))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if file_type.is_dir() { + if matches!( + name.as_ref(), + ".git" | "target" | "node_modules" | ".venv" | ".ops" | ".playwright-cli" + ) { + continue; + } + collect_tracked_fallback(root, &entry_path, out)?; + } else if file_type.is_file() { + let rel = entry_path + .strip_prefix(root) + .with_context(|| format!("strip root from {}", entry_path.display()))? + .to_string_lossy() + .replace('\\', "/"); + out.push(rel); + } + } + Ok(()) +} + +fn is_allowed_python_runtime_path(rel: &str) -> bool { + rel.starts_with("aw-server/dlp-content-analysis/") + || rel.starts_with("clickhouse-1c/ai/") + || rel.starts_with("clickhouse-1c/etl/") + || rel == "detmir-mcp/main.py" + || rel.starts_with("grafana-1c/") + || rel.starts_with("pfsense/") + || rel == "proxmox/tsj_guardian_bot.py" + || rel == "proxmox/test_tsj_guardian_bot.py" +} + +fn is_detmir_retired_runtime_path(rel: &str) -> bool { + rel.starts_with("aw-server/") + || rel.starts_with("proxmox/") + || rel.starts_with("scripts/") + || rel.starts_with("ansible/") +} + #[cfg(test)] mod tests { use std::fs; use tempfile::tempdir; - use super::{collect_by_extension, collect_top_level_yml}; + use super::{ + collect_by_extension, collect_top_level_yml, is_allowed_python_runtime_path, + is_detmir_retired_runtime_path, + }; #[test] fn collects_recursive_shell_files_sorted() { @@ -233,4 +330,31 @@ mod tests { assert_eq!(files.len(), 1); assert_eq!(files[0].file_name().unwrap(), "a.yml"); } + + #[test] + fn allows_agreed_python_runtime_exceptions() { + assert!(is_allowed_python_runtime_path( + "aw-server/dlp-content-analysis/content_analyzer.py" + )); + assert!(is_allowed_python_runtime_path( + "proxmox/tsj_guardian_bot.py" + )); + assert!(is_allowed_python_runtime_path("clickhouse-1c/etl/load.py")); + assert!(is_allowed_python_runtime_path("detmir-mcp/main.py")); + assert!(is_allowed_python_runtime_path( + "pfsense/pfsense-aw-poller.py" + )); + } + + #[test] + fn flags_retired_detmir_python_runtime_paths() { + assert!(is_detmir_retired_runtime_path( + "aw-server/dlp-policy-engine/server.py" + )); + assert!(is_detmir_retired_runtime_path("scripts/dlp-admin-cli.py")); + assert!(!is_allowed_python_runtime_path( + "aw-server/dlp-policy-engine/server.py" + )); + assert!(!is_allowed_python_runtime_path("scripts/dlp-admin-cli.py")); + } } diff --git a/adk-rust/crates/worktime-influx-exporter/src/main.rs b/adk-rust/crates/worktime-influx-exporter/src/main.rs index bb2ac7a..c6cb560 100644 --- a/adk-rust/crates/worktime-influx-exporter/src/main.rs +++ b/adk-rust/crates/worktime-influx-exporter/src/main.rs @@ -889,6 +889,14 @@ fn run(cli: &Cli) -> Result { for day in &config.days { lines.extend(build_lines_for_day(&client, &config, host, day)?); } + if let Some(line) = line( + "aw_worktime_exporter_heartbeat", + vec![("host", host.to_string())], + vec![("run", FieldValue::Int(1))], + timestamp_ns(utc_now()), + ) { + lines.push(line); + } } let written = if cli.dry_run { 0 @@ -981,6 +989,21 @@ mod tests { assert_eq!(out, "m,host=A\\ B\\,C\\=D text=\"a\\\"b\" 10"); } + #[test] + fn heartbeat_line_uses_current_exporter_timestamp() { + let out = line( + "aw_worktime_exporter_heartbeat", + vec![("host", "SHARKON2025".to_string())], + vec![("run", FieldValue::Int(1))], + 42, + ) + .unwrap(); + assert_eq!( + out, + "aw_worktime_exporter_heartbeat,host=SHARKON2025 run=1i 42" + ); + } + #[test] fn aggregates_daily_and_hourly_active_samples() { let config = test_config(); diff --git a/ansible/deploy_grafana_check.yml b/ansible/deploy_grafana_check.yml index 1d6d286..221ee45 100644 --- a/ansible/deploy_grafana_check.yml +++ b/ansible/deploy_grafana_check.yml @@ -12,6 +12,8 @@ grafana_check_user: "{{ detmir_grafana_check_user | default(lookup('env', 'DETMIR_GRAFANA_USER') | default(lookup('env', 'GRAFANA_USER'), true), true) }}" grafana_check_password: "{{ detmir_grafana_check_password | default(lookup('env', 'DETMIR_GRAFANA_PASSWORD') | default(lookup('env', 'GRAFANA_PASSWORD'), true), true) }}" grafana_check_dashboard_uid: "{{ detmir_grafana_check_dashboard_uid | default('detmir-aw-main') }}" + grafana_check_dashboard_file_src: "{{ aw_repo_root }}/grafana/detmir-aw-main-dashboard.json" + grafana_check_dashboard_file_dest: /etc/grafana/provisioning/dashboards/aw/detmir-aw-main.json grafana_check_host: "{{ detmir_grafana_check_host | default('SHARKON2025') }}" grafana_check_max_freshness_minutes: "{{ detmir_grafana_check_max_freshness_minutes | default(360) }}" grafana_check_on_calendar: "{{ detmir_grafana_check_on_calendar | default('*:0/15') }}" @@ -37,6 +39,18 @@ msg: "Missing {{ aw_rust_release_dir }}/detmir-grafana-check. Build with cargo build --release -p detmir-grafana-check." when: not (grafana_check_binary.stat.exists | default(false)) + - name: Check local DetMir Grafana dashboard JSON + ansible.builtin.stat: + path: "{{ grafana_check_dashboard_file_src }}" + delegate_to: localhost + become: false + register: grafana_check_dashboard + + - name: Fail when DetMir Grafana dashboard JSON is absent + ansible.builtin.fail: + msg: "Missing {{ grafana_check_dashboard_file_src }}." + when: not (grafana_check_dashboard.stat.exists | default(false)) + - name: Copy detmir-grafana-check binary to Proxmox staging ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/detmir-grafana-check" @@ -67,6 +81,38 @@ - /usr/local/bin/detmir-grafana-check changed_when: true + - name: Copy DetMir Grafana dashboard JSON to Proxmox staging + ansible.builtin.copy: + src: "{{ grafana_check_dashboard_file_src }}" + dest: /tmp/detmir-aw-main-dashboard.json + owner: root + group: root + mode: "0644" + + - name: Ensure Grafana provisioning dashboard directory exists in CT + ansible.builtin.command: + argv: + - pct + - exec + - "{{ grafana_check_ct_id }}" + - -- + - install + - "-d" + - "-m" + - "0755" + - "{{ grafana_check_dashboard_file_dest | dirname }}" + changed_when: true + + - name: Install DetMir Grafana dashboard JSON into CT provisioning + ansible.builtin.command: + argv: + - pct + - push + - "{{ grafana_check_ct_id }}" + - /tmp/detmir-aw-main-dashboard.json + - "{{ grafana_check_dashboard_file_dest }}" + changed_when: true + - name: Create Grafana check runtime in CT ansible.builtin.command: argv: @@ -89,7 +135,7 @@ DETMIR_GRAFANA_USER={{ grafana_check_user }} DETMIR_GRAFANA_PASSWORD={{ grafana_check_password }} DETMIR_GRAFANA_DASHBOARD_UID={{ grafana_check_dashboard_uid }} - DETMIR_GRAFANA_DASHBOARD_FILE=/etc/grafana/provisioning/dashboards/aw/detmir-aw-main.json + DETMIR_GRAFANA_DASHBOARD_FILE={{ grafana_check_dashboard_file_dest }} DETMIR_GRAFANA_HOST={{ grafana_check_host }} DETMIR_GRAFANA_MAX_FRESHNESS_MINUTES={{ grafana_check_max_freshness_minutes }} DETMIR_GRAFANA_OUTPUT_JSON=/var/lib/detmir-grafana-check/latest.json diff --git a/aw-server/hayabusa/aw-hayabusa.sh b/aw-server/hayabusa/aw-hayabusa.sh index 974f5d2..0f03548 100644 --- a/aw-server/hayabusa/aw-hayabusa.sh +++ b/aw-server/hayabusa/aw-hayabusa.sh @@ -120,7 +120,7 @@ extract_zip_normalized() { while IFS= read -r entry; do normalized="${entry//\\//}" case "${normalized}" in - ""|.|/*|*"/../"*|../*|*".."|*"/..") + ""|.|/*|*"/../"*|../*|*"..") fail "unsafe zip entry: ${entry}" ;; esac diff --git a/docs/DETMIR_PYTHON_RETIREMENT_RU.md b/docs/DETMIR_PYTHON_RETIREMENT_RU.md index 1bb453a..5e8f9ea 100644 --- a/docs/DETMIR_PYTHON_RETIREMENT_RU.md +++ b/docs/DETMIR_PYTHON_RETIREMENT_RU.md @@ -27,6 +27,17 @@ ## Проверка +Основной guard встроен в `quality-gate`: + +```bash +scripts/quality-gate.sh +``` + +Он проверяет tracked-файлы и блокирует возврат `.py` entrypoints в Rust-retired +runtime paths (`aw-server`, `proxmox`, `scripts`, `ansible`) за исключением +согласованных зон: Telegram bot, OCR/content-analysis, 1C/AI/ETL, MCP, +pfSense/no-touch и Grafana-1C. + ```bash rg -n '\.py\b|python3' ansible aw-server scripts adk-rust \ --glob '!adk-rust/target/**' diff --git a/grafana/detmir-aw-main-dashboard.json b/grafana/detmir-aw-main-dashboard.json index 96cfc21..6090c6e 100644 --- a/grafana/detmir-aw-main-dashboard.json +++ b/grafana/detmir-aw-main-dashboard.json @@ -45,7 +45,7 @@ "id": 5, "targets": [ { - "query": "from(bucket: \"aw_metrics\")\n |> range(start: -24h)\n |> filter(fn: (r) => (r._measurement == \"aw_rdp_worktime_hourly\" or r._measurement == \"aw_rdp_worktime_daily\" or r._measurement == \"aw_true_active_app_daily\") and (r._field == \"active_seconds\" or r._field == \"proved_work_seconds\"))\n |> last()\n |> group()\n |> sort(columns: [\"_time\"], desc: true)\n |> limit(n: 1)\n |> map(fn: (r) => ({ r with _value: (float(v: uint(v: now()) - uint(v: r._time)) / 1000000000.0) / 60.0 }))\n |> set(key: \"_field\", value: \"Свежесть\")\n |> keep(columns: [\"_value\", \"_field\"])\n |> yield(name: \"freshness_minutes\")\n", + "query": "from(bucket: \"aw_metrics\")\n |> range(start: -24h)\n |> filter(fn: (r) => r._measurement == \"aw_worktime_exporter_heartbeat\" and r._field == \"run\")\n |> last()\n |> group()\n |> sort(columns: [\"_time\"], desc: true)\n |> limit(n: 1)\n |> map(fn: (r) => ({ r with _value: (float(v: uint(v: now()) - uint(v: r._time)) / 1000000000.0) / 60.0 }))\n |> set(key: \"_field\", value: \"Свежесть\")\n |> keep(columns: [\"_value\", \"_field\"])\n |> yield(name: \"freshness_minutes\")\n", "refId": "A" } ], diff --git a/install-kit-awindows-20260427-211240.tar.gz b/install-kit-awindows-20260427-211240.tar.gz index 6a74deb..598887b 100644 Binary files a/install-kit-awindows-20260427-211240.tar.gz and b/install-kit-awindows-20260427-211240.tar.gz differ diff --git a/install-kit-awindows-20260427-211240.zip b/install-kit-awindows-20260427-211240.zip index 9a613cb..9d09ffc 100644 Binary files a/install-kit-awindows-20260427-211240.zip and b/install-kit-awindows-20260427-211240.zip differ diff --git a/install-kit-awindows-20260427-211240/MANIFEST.txt b/install-kit-awindows-20260427-211240/MANIFEST.txt index e1c4ced..c76f6dd 100644 --- a/install-kit-awindows-20260427-211240/MANIFEST.txt +++ b/install-kit-awindows-20260427-211240/MANIFEST.txt @@ -35,7 +35,7 @@ aedffecfa24834968742cb2477faef80bf794345275a9679ac12c5a1f609acc2 install-kit-aw 4e5b23300ba5c9878b2c8ce6eea65043b7a4c5cb2d14619243cfca6d202aec9f install-kit-awindows-20260427-211240/scripts/aw-webui-browser-smoke.mjs 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 +4e5bc6ce977e9f5b9e87c393061d5ecac0f90d5e1314e3325093692e5d19965e install-kit-awindows-20260427-211240/scripts/quality-gate.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 diff --git a/install-kit-awindows-20260427-211240/scripts/quality-gate.sh b/install-kit-awindows-20260427-211240/scripts/quality-gate.sh index 7a4bcf4..6a434a6 100644 --- a/install-kit-awindows-20260427-211240/scripts/quality-gate.sh +++ b/install-kit-awindows-20260427-211240/scripts/quality-gate.sh @@ -23,10 +23,10 @@ for candidate in "${rust_candidates[@]}"; do fi done -echo "[1/5] Bash syntax check" +echo "[1/6] Bash syntax check" find aw-server proxmox scripts -type f -name "*.sh" -print0 | xargs -0 -r -n1 bash -n -echo "[2/5] Shellcheck (if available)" +echo "[2/6] Shellcheck (if available)" if command -v shellcheck >/dev/null 2>&1; then { find aw-server proxmox -type f -name "*.sh" @@ -36,14 +36,14 @@ else echo "shellcheck not found, skipping." fi -echo "[3/5] Node syntax check (if node available)" +echo "[3/6] Node syntax check (if node available)" if command -v node >/dev/null 2>&1; then node --check scripts/aw-webui-browser-smoke.mjs >/dev/null else echo "node not found, skipping." fi -echo "[4/5] PowerShell parse check (if pwsh available)" +echo "[4/6] PowerShell parse check (if pwsh available)" if command -v pwsh >/dev/null 2>&1; then pwsh -NoLogo -NoProfile -Command ' $ErrorActionPreference = "Stop" @@ -60,7 +60,7 @@ fi -echo "[5/5] Ansible syntax check (if ansible-playbook available)" +echo "[5/6] Ansible syntax check (if ansible-playbook available)" if command -v ansible-playbook >/dev/null 2>&1; then for playbook in ansible/*.yml; do ansible-playbook --syntax-check "$playbook" -i ansible/inventory.example.ini >/dev/null @@ -69,4 +69,29 @@ else echo "ansible-playbook not found, skipping." fi +echo "[6/6] DetMir Python runtime retirement guard" +if command -v git >/dev/null 2>&1; then + mapfile -t tracked_py < <(git ls-files '*.py') +else + mapfile -t tracked_py < <(find aw-server proxmox scripts ansible -type f -name '*.py' 2>/dev/null) +fi +violations=() +for path in "${tracked_py[@]}"; do + case "$path" in + aw-server/dlp-content-analysis/*|clickhouse-1c/ai/*|clickhouse-1c/etl/*|detmir-mcp/main.py|grafana-1c/*|pfsense/*|proxmox/tsj_guardian_bot.py|proxmox/test_tsj_guardian_bot.py) + continue + ;; + esac + case "$path" in + aw-server/*|proxmox/*|scripts/*|ansible/*) + violations+=("$path") + ;; + esac +done +if (( ${#violations[@]} > 0 )); then + printf 'Python runtime regression in Rust-retired DetMir paths:\\n' >&2 + printf '%s\\n' "${violations[@]}" >&2 + exit 1 +fi + echo "quality-gate: OK" diff --git a/scripts/quality-gate.sh b/scripts/quality-gate.sh index 7a4bcf4..6a434a6 100755 --- a/scripts/quality-gate.sh +++ b/scripts/quality-gate.sh @@ -23,10 +23,10 @@ for candidate in "${rust_candidates[@]}"; do fi done -echo "[1/5] Bash syntax check" +echo "[1/6] Bash syntax check" find aw-server proxmox scripts -type f -name "*.sh" -print0 | xargs -0 -r -n1 bash -n -echo "[2/5] Shellcheck (if available)" +echo "[2/6] Shellcheck (if available)" if command -v shellcheck >/dev/null 2>&1; then { find aw-server proxmox -type f -name "*.sh" @@ -36,14 +36,14 @@ else echo "shellcheck not found, skipping." fi -echo "[3/5] Node syntax check (if node available)" +echo "[3/6] Node syntax check (if node available)" if command -v node >/dev/null 2>&1; then node --check scripts/aw-webui-browser-smoke.mjs >/dev/null else echo "node not found, skipping." fi -echo "[4/5] PowerShell parse check (if pwsh available)" +echo "[4/6] PowerShell parse check (if pwsh available)" if command -v pwsh >/dev/null 2>&1; then pwsh -NoLogo -NoProfile -Command ' $ErrorActionPreference = "Stop" @@ -60,7 +60,7 @@ fi -echo "[5/5] Ansible syntax check (if ansible-playbook available)" +echo "[5/6] Ansible syntax check (if ansible-playbook available)" if command -v ansible-playbook >/dev/null 2>&1; then for playbook in ansible/*.yml; do ansible-playbook --syntax-check "$playbook" -i ansible/inventory.example.ini >/dev/null @@ -69,4 +69,29 @@ else echo "ansible-playbook not found, skipping." fi +echo "[6/6] DetMir Python runtime retirement guard" +if command -v git >/dev/null 2>&1; then + mapfile -t tracked_py < <(git ls-files '*.py') +else + mapfile -t tracked_py < <(find aw-server proxmox scripts ansible -type f -name '*.py' 2>/dev/null) +fi +violations=() +for path in "${tracked_py[@]}"; do + case "$path" in + aw-server/dlp-content-analysis/*|clickhouse-1c/ai/*|clickhouse-1c/etl/*|detmir-mcp/main.py|grafana-1c/*|pfsense/*|proxmox/tsj_guardian_bot.py|proxmox/test_tsj_guardian_bot.py) + continue + ;; + esac + case "$path" in + aw-server/*|proxmox/*|scripts/*|ansible/*) + violations+=("$path") + ;; + esac +done +if (( ${#violations[@]} > 0 )); then + printf 'Python runtime regression in Rust-retired DetMir paths:\\n' >&2 + printf '%s\\n' "${violations[@]}" >&2 + exit 1 +fi + echo "quality-gate: OK"