--- - name: Deploy DetMir read-only portal hosts: proxmox become: true gather_facts: false vars: aw_repo_root: "{{ playbook_dir | dirname }}" aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" detmir_portal_bind: "{{ detmir_portal_bind_override | default('127.0.0.1:8720') }}" detmir_portal_env_path: "/etc/detmir-portal.env" detmir_portal_worktime_url: "{{ detmir_portal_worktime_url_override | default(aw_worktime_report_base | default('http://192.0.2.13:5610', true), true) }}" detmir_portal_one_c_host: "{{ hostvars[(groups['proxmox'] | default([]) | first) | default('192.0.2.2', true)].ansible_host | default((groups['proxmox'] | default([]) | first) | default('192.0.2.2', true), true) }}" detmir_portal_one_c_url: "{{ detmir_portal_one_c_url_override | default('http://' + detmir_portal_one_c_host + ':8710', true) }}" detmir_portal_workforce_policy_path: "/etc/detmir-portal-workforce-policy.json" detmir_portal_ueba_policy_path: "/etc/detmir-portal-ueba-policy.yaml" detmir_portal_readiness_bundle_dir: "{{ detmir_portal_readiness_bundle_dir_override | default('/var/lib/activitywatch/health/readiness-bundle', true) }}" detmir_portal_dlp_module_enabled: "{{ detmir_portal_dlp_module_enabled_override | default(false) }}" tasks: - name: Refuse inconsistent DetMir portal DLP profile ansible.builtin.assert: that: - detmir_portal_dlp_profile | default('core_only') in ['core_only', 'light', 'on_demand', 'full'] - (detmir_portal_dlp_profile | default('core_only') != 'core_only') or not (detmir_portal_dlp_module_enabled | bool) fail_msg: "Inconsistent DetMir portal DLP profile: core_only must keep DETMIR_PORTAL_DLP_MODULE_ENABLED=false." - name: Check local detmir-portal binary ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/detmir-portal" delegate_to: localhost become: false register: detmir_portal_binary - name: Fail when detmir-portal binary is absent ansible.builtin.fail: msg: "Missing {{ aw_rust_release_dir }}/detmir-portal. Build with cargo build --release -p detmir-portal." when: not (detmir_portal_binary.stat.exists | default(false)) - name: Install detmir-portal binary ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/detmir-portal" dest: /usr/local/bin/detmir-portal owner: root group: root mode: "0755" - name: Install detmir-portal environment ansible.builtin.copy: dest: "{{ detmir_portal_env_path }}" owner: root group: root mode: "0644" content: | DETMIR_PORTAL_BIND={{ detmir_portal_bind }} DETMIR_PORTAL_STATUS_CMD=detmir-status --json DETMIR_PORTAL_CHECK_CMD='cat /var/lib/detmir-ai/latest-run/detmir-check.json' DETMIR_PORTAL_FAILED_UNITS_CMD=systemctl --failed --no-pager DETMIR_PORTAL_WORKTIME_URL={{ detmir_portal_worktime_url }} DETMIR_PORTAL_ONE_C_URL={{ detmir_portal_one_c_url }} DETMIR_PORTAL_WORKFORCE_POLICY_PATH={{ detmir_portal_workforce_policy_path }} DETMIR_PORTAL_UEBA_POLICY_PATH={{ detmir_portal_ueba_policy_path }} DETMIR_PORTAL_TIMEOUT_SECONDS=25 DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal DETMIR_PORTAL_DLP_MODULE_ENABLED={{ detmir_portal_dlp_module_enabled | bool | ternary('true', 'false') }} DETMIR_PORTAL_DLP_PROFILE={{ detmir_portal_dlp_profile | default('core_only') }} DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/detmir-portal/evidence DETMIR_PORTAL_READINESS_BUNDLE_DIR={{ detmir_portal_readiness_bundle_dir }} DETMIR_PORTAL_EVIDENCE_LIMIT=30 DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608 SECURITY_EVENTS_BACKEND={{ detmir_security_events_backend | default('disabled') }} CLICKHOUSE_URL={{ detmir_clickhouse_url | default('http://127.0.0.1:8123') }} CLICKHOUSE_DATABASE={{ detmir_clickhouse_database | default('analytics_1c') }} CLICKHOUSE_USER={{ detmir_clickhouse_user | default('default') }} CLICKHOUSE_PASSWORD={{ detmir_clickhouse_password | default('') }} - name: Install lightweight DLP warehouse sync helper ansible.builtin.copy: src: "{{ aw_repo_root }}/scripts/detmir_dlp_warehouse_sync.sh" dest: /usr/local/bin/detmir-dlp-warehouse-sync owner: root group: root mode: "0755" - name: Install lightweight DLP warehouse sync service ansible.builtin.copy: dest: /etc/systemd/system/detmir-dlp-warehouse-sync.service owner: root group: root mode: "0644" content: | [Unit] Description=Sync lightweight DetMir DLP SQLite warehouse for portal After=network-online.target Wants=network-online.target [Service] Type=oneshot Environment=AW_DLP_WAREHOUSE_SOURCE_HOST={{ detmir_portal_dlp_warehouse_source_host | default('igor@10.10.10.13') }} Environment=AW_DLP_WAREHOUSE_SOURCE_PATH={{ detmir_portal_dlp_warehouse_source_path | default('/var/lib/activitywatch/dlp_warehouse.sqlite') }} Environment=AW_DLP_WAREHOUSE_DEST_PATH={{ detmir_portal_dlp_warehouse_dest_path | default('/var/lib/activitywatch/dlp_warehouse.sqlite') }} Environment=AW_DLP_WAREHOUSE_SYNC_STATE_DIR={{ detmir_portal_dlp_warehouse_sync_state_dir | default('/var/lib/activitywatch/health') }} ExecStart=/usr/local/bin/detmir-dlp-warehouse-sync Nice=10 IOSchedulingClass=best-effort IOSchedulingPriority=7 TimeoutStartSec=60 - name: Install lightweight DLP warehouse sync timer ansible.builtin.copy: dest: /etc/systemd/system/detmir-dlp-warehouse-sync.timer owner: root group: root mode: "0644" content: | [Unit] Description=Run lightweight DetMir DLP SQLite warehouse sync [Timer] OnBootSec=4min OnUnitActiveSec={{ detmir_portal_dlp_warehouse_sync_interval | default('2min') }} AccuracySec=30s Persistent=false [Install] WantedBy=timers.target - name: Enable lightweight DLP warehouse sync timer ansible.builtin.systemd: name: detmir-dlp-warehouse-sync.timer enabled: true state: started daemon_reload: true when: detmir_portal_dlp_module_enabled | bool - name: Preserve local ClickHouse security-events settings when available ansible.builtin.shell: | set -euo pipefail python3 - <<'PY' from pathlib import Path source = Path("/opt/activitywatch/clickhouse-1c/.env") target = Path("{{ detmir_portal_env_path }}") if not source.exists() or not target.exists(): raise SystemExit(0) kv = {} for line in source.read_text().splitlines(): if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) kv[key.strip()] = value.strip().strip('"').strip("'") updates = { "SECURITY_EVENTS_BACKEND": "clickhouse", "CLICKHOUSE_URL": "http://127.0.0.1:8123", "CLICKHOUSE_DATABASE": kv.get("CLICKHOUSE_DB", "analytics_1c"), "CLICKHOUSE_USER": kv.get("CLICKHOUSE_USER", "default"), "CLICKHOUSE_PASSWORD": kv.get("CLICKHOUSE_PASSWORD", ""), } lines = [] seen = set() for line in target.read_text().splitlines(): if "=" in line and not line.startswith("#"): key = line.split("=", 1)[0].strip() if key in updates: lines.append(f"{key}={updates[key]}") seen.add(key) continue lines.append(line) for key, value in updates.items(): if key not in seen: lines.append(f"{key}={value}") target.write_text("\n".join(lines) + "\n") PY args: executable: /bin/bash no_log: true when: detmir_security_events_backend | default('clickhouse') == 'clickhouse' - name: Remove stale detmir-portal systemd overrides ansible.builtin.file: path: "{{ item }}" state: absent loop: - /etc/systemd/system/detmir-portal.service.d/20-timeouts.conf - /etc/systemd/system/detmir-portal.service.d/20-prod-timeout.conf - /etc/systemd/system/detmir-portal.service.d/30-warm-cache.conf - /etc/systemd/system/detmir-portal.service.d/30-prewarm-after-start.conf register: detmir_portal_stale_overrides - name: Install initial workforce policy when absent ansible.builtin.copy: dest: "{{ detmir_portal_workforce_policy_path }}" owner: root group: root mode: "0644" force: false content: "{{ lookup('file', aw_repo_root + '/configs/detmir-workforce-policy.example.json') }}" - name: Install initial UEBA risk policy when absent ansible.builtin.copy: dest: "{{ detmir_portal_ueba_policy_path }}" owner: root group: root mode: "0644" force: false content: "{{ lookup('file', aw_repo_root + '/configs/detmir-ueba-risk-policy.example.yaml') }}" - name: Ensure detmir-portal state and evidence directories ansible.builtin.file: path: "{{ item }}" state: directory owner: root group: root mode: "0750" loop: - /var/lib/detmir-portal - /var/lib/detmir-portal/evidence - /var/lib/detmir-portal/evidence/screenshots - name: Install detmir-portal systemd service ansible.builtin.copy: dest: /etc/systemd/system/detmir-portal.service owner: root group: root mode: "0644" content: | [Unit] Description=DetMir Operator Portal After=network-online.target Wants=network-online.target [Service] Type=simple EnvironmentFile=-{{ detmir_portal_env_path }} EnvironmentFile=-/etc/detmir/detmir-check.env ExecStart=/usr/local/bin/detmir-portal Restart=on-failure RestartSec=5s [Install] WantedBy=multi-user.target register: detmir_portal_service_unit - name: Remove stale detmir-portal timeout override ansible.builtin.file: path: /etc/systemd/system/detmir-portal.service.d/10-detmir-check-env.conf state: absent - name: Reload systemd ansible.builtin.systemd: daemon_reload: true - name: Enable and restart detmir-portal ansible.builtin.systemd: name: detmir-portal.service enabled: true state: restarted - name: Verify detmir-portal local health ansible.builtin.uri: url: "http://{{ detmir_portal_bind }}/api/health" method: GET status_code: 200 return_content: true register: detmir_portal_health failed_when: detmir_portal_health.status != 200 or detmir_portal_health.json.sources is not defined changed_when: false - name: Verify detmir-portal reports API ansible.builtin.uri: url: "http://{{ detmir_portal_bind }}/api/reports" method: GET status_code: 200 return_content: true timeout: 90 register: detmir_portal_reports failed_when: > detmir_portal_reports.status != 200 or detmir_portal_reports.json.kpis is not defined or ( 'derived detections/cases' not in (detmir_portal_reports.json.markdown | default('')) and 'расчетными выводами' not in (detmir_portal_reports.json.markdown | default('')) ) changed_when: false - name: Deploy DetMir DLP evidence API on AW server hosts: aw_server become: true gather_facts: false vars: aw_repo_root: "{{ playbook_dir | dirname }}" aw_rust_release_dir: "{{ (lookup('env', 'CARGO_TARGET_DIR') | default(aw_repo_root + '/adk-rust/target', true)) + '/release' }}" detmir_evidence_bind_host: "{{ ansible_host | default(inventory_hostname, true) }}" detmir_evidence_bind: "{{ detmir_evidence_bind_override | default(detmir_evidence_bind_host + ':8721', true) }}" detmir_evidence_env_path: "/etc/detmir-portal-evidence.env" detmir_evidence_upload_token_path: "/var/lib/activitywatch/dlp-evidence/upload-token" tasks: - name: Check local detmir-portal binary for evidence service ansible.builtin.stat: path: "{{ aw_rust_release_dir }}/detmir-portal" delegate_to: localhost become: false register: detmir_evidence_binary - name: Fail when detmir-portal evidence binary is absent ansible.builtin.fail: msg: "Missing {{ aw_rust_release_dir }}/detmir-portal. Build with cargo build --release -p detmir-portal." when: not (detmir_evidence_binary.stat.exists | default(false)) - name: Install detmir-portal evidence binary ansible.builtin.copy: src: "{{ aw_rust_release_dir }}/detmir-portal" dest: /usr/local/bin/detmir-portal-evidence owner: root group: root mode: "0755" - name: Ensure detmir evidence directories on AW server ansible.builtin.file: path: "{{ item }}" state: directory owner: root group: root mode: "0750" loop: - /var/lib/activitywatch/dlp-evidence - /var/lib/activitywatch/dlp-evidence/screenshots - name: Check detmir evidence upload token ansible.builtin.stat: path: "{{ detmir_evidence_upload_token_path }}" register: detmir_evidence_upload_token_stat no_log: true - name: Generate detmir evidence upload token ansible.builtin.shell: | set -euo pipefail umask 077 python3 - <<'PY' > '{{ detmir_evidence_upload_token_path }}' import secrets print(secrets.token_urlsafe(48)) PY args: executable: /bin/bash when: not (detmir_evidence_upload_token_stat.stat.exists | default(false)) no_log: true - name: Fix detmir evidence upload token permissions ansible.builtin.file: path: "{{ detmir_evidence_upload_token_path }}" owner: root group: root mode: "0600" no_log: true - name: Read detmir evidence upload token ansible.builtin.slurp: src: "{{ detmir_evidence_upload_token_path }}" register: detmir_evidence_upload_token_slurp no_log: true - name: Install detmir evidence API environment ansible.builtin.copy: dest: "{{ detmir_evidence_env_path }}" owner: root group: root mode: "0644" content: | DETMIR_PORTAL_BIND={{ detmir_evidence_bind }} DETMIR_PORTAL_EVIDENCE_ONLY=true DETMIR_PORTAL_STATE_DIR=/var/lib/activitywatch/dlp-evidence DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/activitywatch/dlp-evidence DETMIR_PORTAL_READINESS_BUNDLE_DIR=/var/lib/activitywatch/health/readiness-bundle DETMIR_PORTAL_EVIDENCE_LIMIT=30 DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608 DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN={{ detmir_evidence_upload_token_slurp.content | b64decode | trim }} no_log: true - name: Install detmir evidence API systemd service ansible.builtin.copy: dest: /etc/systemd/system/detmir-portal-evidence.service owner: root group: root mode: "0644" content: | [Unit] Description=DetMir DLP Evidence API After=network-online.target Wants=network-online.target [Service] Type=simple EnvironmentFile=-{{ detmir_evidence_env_path }} ExecStart=/usr/local/bin/detmir-portal-evidence Restart=on-failure RestartSec=5s [Install] WantedBy=multi-user.target register: detmir_evidence_service_unit - name: Reload systemd for evidence API ansible.builtin.systemd: daemon_reload: true when: detmir_evidence_service_unit.changed - name: Enable and restart detmir evidence API ansible.builtin.systemd: name: detmir-portal-evidence.service enabled: true state: restarted - name: Verify detmir evidence API health ansible.builtin.uri: url: "http://{{ detmir_evidence_bind }}/api/health" method: GET status_code: 200 return_content: true register: detmir_evidence_health failed_when: detmir_evidence_health.status != 200 or detmir_evidence_health.json.mode | default('') != 'evidence-only' changed_when: false