Update Ceph cluster management workflows

parent 7bcb9076
...@@ -38,6 +38,7 @@ cephdeploy_*/ ...@@ -38,6 +38,7 @@ cephdeploy_*/
*.swp *.swp
*.swo *.swo
*~ *~
.nfs*
# OS # OS
.DS_Store .DS_Store
......
...@@ -63,6 +63,24 @@ class PrometheusClient: ...@@ -63,6 +63,24 @@ class PrometheusClient:
raise PrometheusError(f"Нет связи с Prometheus: {exc}") from exc raise PrometheusError(f"Нет связи с Prometheus: {exc}") from exc
return self._parse(resp) return self._parse(resp)
def targets(self) -> list[dict]:
"""Возвращает activeTargets из /api/v1/targets."""
try:
resp = self._session.get(
f"{self._base}/api/v1/targets",
timeout=self._timeout,
)
except requests.RequestException as exc:
raise PrometheusError(f"Нет связи с Prometheus: {exc}") from exc
if resp.status_code >= 400:
raise PrometheusError(f"HTTP {resp.status_code}: {resp.text[:200]}")
data = resp.json()
if data.get("status") != "success":
raise PrometheusError(
f"Prometheus status={data.get('status')}: {data.get('error')}"
)
return data.get("data", {}).get("activeTargets", [])
def _parse(self, resp: requests.Response) -> list[dict]: def _parse(self, resp: requests.Response) -> list[dict]:
if resp.status_code >= 400: if resp.status_code >= 400:
raise PrometheusError( raise PrometheusError(
...@@ -103,14 +121,14 @@ class PrometheusClient: ...@@ -103,14 +121,14 @@ class PrometheusClient:
return int(self.scalar("sum(ceph_mon_quorum_status)")) return int(self.scalar("sum(ceph_mon_quorum_status)"))
def cluster_read_throughput(self) -> float: def cluster_read_throughput(self) -> float:
"""Суммарная скорость чтения по всем OSD (байт/с).""" """Суммарная скорость чтения по всем пулам (байт/с)."""
return self.scalar("sum(rate(ceph_osd_op_r_out_bytes[1m]))") return self.scalar("sum(rate(ceph_pool_rd_bytes[1m]))")
def cluster_write_throughput(self) -> float: def cluster_write_throughput(self) -> float:
return self.scalar("sum(rate(ceph_osd_op_w_in_bytes[1m]))") return self.scalar("sum(rate(ceph_pool_wr_bytes[1m]))")
def cluster_iops(self) -> float: def cluster_iops(self) -> float:
return self.scalar("sum(rate(ceph_osd_op[1m]))") return self.scalar("sum(rate(ceph_pool_rd[1m])) + sum(rate(ceph_pool_wr[1m]))")
def osd_latency_series( def osd_latency_series(
self, self,
...@@ -121,10 +139,7 @@ class PrometheusClient: ...@@ -121,10 +139,7 @@ class PrometheusClient:
Возвращает {osd_name: [(ts, value), ...]}.""" Возвращает {osd_name: [(ts, value), ...]}."""
end = time.time() end = time.time()
start = end - duration_seconds start = end - duration_seconds
promql = ( promql = "max(ceph_osd_apply_latency_ms) by (ceph_daemon)"
"rate(ceph_osd_op_latency_sum[1m]) "
"/ rate(ceph_osd_op_latency_count[1m])"
)
raw = self.query_range(promql, start, end, step=step) raw = self.query_range(promql, start, end, step=step)
out: dict[str, list[tuple[float, float]]] = {} out: dict[str, list[tuple[float, float]]] = {}
for row in raw: for row in raw:
......
...@@ -27,12 +27,13 @@ _DEFAULTS: dict = { ...@@ -27,12 +27,13 @@ _DEFAULTS: dict = {
"grafana_password": "admin", "grafana_password": "admin",
# ── Параметры создания LXC на PVE-хосте ───────────────────────────── # ── Параметры создания LXC на PVE-хосте ─────────────────────────────
"monitoring_pve_host": "gefest.office.etersoft.ru", "monitoring_pve_host": "gefest.office.etersoft.ru",
"monitoring_vmid": 200, "monitoring_vmid": 100,
"monitoring_ct_ip": "192.168.0.20/24", "monitoring_ct_ip": "",
"monitoring_ct_gw": "192.168.0.1", "monitoring_ct_gw": "",
"monitoring_ct_bridge": "vmbr0", "monitoring_ct_bridge": "",
"monitoring_ct_storage": "local-zfs", "monitoring_ct_storage": "",
"monitoring_ct_template": "debian-12-standard_12.12-1_amd64.tar.zst", "monitoring_template_storage": "",
"monitoring_ct_template": "",
} }
......
...@@ -2,6 +2,69 @@ ...@@ -2,6 +2,69 @@
# Ansible-плейбук для развёртывания Ceph {{ cluster.version }} (cephadm) # Ansible-плейбук для развёртывания Ceph {{ cluster.version }} (cephadm)
# Кластер: {{ cluster.name }} # Кластер: {{ cluster.name }}
# Сгенерировано CephDeploy # Сгенерировано CephDeploy
{% set resolved_ceph_image = ceph_image | default('', true) %}
{% if not resolved_ceph_image %}
{% set resolved_ceph_image = {
'quincy': 'quay.io/ceph/ceph:v17',
'reef': 'quay.io/ceph/ceph:v18',
'squid': 'quay.io/ceph/ceph:v19',
}.get(cluster.version | default('', true) | lower, 'quay.io/ceph/ceph:v19') %}
{% endif %}
- name: Защита от установки поверх существующего Ceph
hosts: all
become: true
gather_facts: false
tasks:
- name: Проверить признаки существующего Ceph
ansible.builtin.shell: |
evidence=0
if [ -s /etc/ceph/ceph.conf ]; then
echo "/etc/ceph/ceph.conf"
evidence=1
fi
if [ -d /var/lib/ceph ] && timeout 3 find /var/lib/ceph -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null | grep -q .; then
echo "/var/lib/ceph contains data"
evidence=1
fi
if command -v cephadm >/dev/null 2>&1 && timeout 3 cephadm ls 2>/dev/null | grep -q '"fsid"'; then
echo "cephadm cluster metadata exists"
evidence=1
fi
if command -v systemctl >/dev/null 2>&1 && timeout 3 systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | grep -q .; then
echo "ceph systemd units exist"
evidence=1
fi
if command -v lvs >/dev/null 2>&1 && timeout 3 lvs --noheadings -o lv_tags 2>/dev/null | grep -q 'ceph'; then
echo "Ceph LVM tags exist"
evidence=1
fi
if command -v podman >/dev/null 2>&1 && timeout 3 podman ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph podman containers exist"
evidence=1
fi
if command -v docker >/dev/null 2>&1 && timeout 3 docker ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph docker containers exist"
evidence=1
fi
exit "$evidence"
args:
executable: /bin/bash
register: existing_ceph
changed_when: false
failed_when: false
- name: Остановить развёртывание, если Ceph уже найден
ansible.builtin.fail:
{% raw %}
msg: >-
На узле {{ inventory_hostname }} уже есть признаки Ceph:
{{ existing_ceph.stdout_lines | join('; ') }}.
Развёртывание остановлено, чтобы не установить кластер поверх существующего.
Если это тестовый стенд, сначала используйте явную очистку.
{% endraw %}
when: existing_ceph.rc != 0
- name: Подготовка узлов - name: Подготовка узлов
hosts: all hosts: all
...@@ -15,7 +78,10 @@ ...@@ -15,7 +78,10 @@
name: name:
- cephadm - cephadm
- python3-pip - python3-pip
- python3-yaml
- python3-jinja2
- lvm2 - lvm2
- podman
state: present state: present
update_cache: true update_cache: true
when: ansible_facts["os_family"] == "Debian" when: ansible_facts["os_family"] == "Debian"
...@@ -25,7 +91,10 @@ ...@@ -25,7 +91,10 @@
package: package:
- cephadm - cephadm
- python3-module-pip - python3-module-pip
- python3-module-yaml
- python3-module-jinja2
- lvm2 - lvm2
- podman
state: present state: present
update_cache: true update_cache: true
when: ansible_facts["os_family"] == "Altlinux" when: ansible_facts["os_family"] == "Altlinux"
...@@ -35,10 +104,34 @@ ...@@ -35,10 +104,34 @@
name: name:
- cephadm - cephadm
- python3-pip - python3-pip
- python3-pyyaml
- python3-jinja2
- lvm2 - lvm2
- podman
state: present state: present
when: ansible_facts["os_family"] == "RedHat" when: ansible_facts["os_family"] == "RedHat"
- name: Проверить Python-зависимости cephadm
ansible.builtin.command: python3 -c "import yaml, jinja2"
changed_when: false
- name: Проверить запуск cephadm
ansible.builtin.command: /usr/sbin/cephadm version
changed_when: false
- name: Проверить container runtime
ansible.builtin.command: podman --version
changed_when: false
- name: Заранее загрузить Ceph container image
ansible.builtin.shell: |
set -o pipefail
podman image exists {{ resolved_ceph_image }} || podman pull {{ resolved_ceph_image }}
args:
executable: /bin/bash
register: ceph_image_pull
changed_when: "'Trying to pull' in (ceph_image_pull.stdout | default('')) or 'Copying blob' in (ceph_image_pull.stdout | default(''))"
# ── Проверка наличия chronyc и firewalld без зависимости от pkg-facts # ── Проверка наличия chronyc и firewalld без зависимости от pkg-facts
- name: Проверить наличие chronyc - name: Проверить наличие chronyc
ansible.builtin.command: which chronyc ansible.builtin.command: which chronyc
...@@ -75,11 +168,13 @@ ...@@ -75,11 +168,13 @@
tasks: tasks:
- name: Запустить cephadm bootstrap - name: Запустить cephadm bootstrap
ansible.builtin.command: > ansible.builtin.command: >
cephadm bootstrap /usr/sbin/cephadm
--image {{ resolved_ceph_image }}
bootstrap
--mon-ip {{ bootstrap_host.ip_address }} --mon-ip {{ bootstrap_host.ip_address }}
--initial-dashboard-user {{ dashboard.user }} --skip-dashboard
--initial-dashboard-password {{ dashboard.password }}
--skip-monitoring-stack --skip-monitoring-stack
--allow-mismatched-release
--allow-overwrite --allow-overwrite
args: args:
creates: /etc/ceph/ceph.conf creates: /etc/ceph/ceph.conf
...@@ -90,28 +185,15 @@ ...@@ -90,28 +185,15 @@
var: bootstrap_result.stdout_lines var: bootstrap_result.stdout_lines
when: bootstrap_result.stdout_lines is defined when: bootstrap_result.stdout_lines is defined
# ── mgr-модули для внешнего мониторинга ───────────────────────────── # ── mgr-модуль для внешнего мониторинга ─────────────────────────────
# prometheus: экспортёр метрик на :9283 — его опрашивает Prometheus-сервер, # prometheus: экспортёр метрик на :9283 — его опрашивает Prometheus-сервер,
# развёрнутый отдельным playbook setup_monitoring.yml в LXC на PVE-узле. # развёрнутый отдельным playbook setup_monitoring.yml в LXC на PVE-узле.
# dashboard: REST API на :{{ dashboard.port }} — резервный канал status_widget.
- name: Включить mgr-модуль prometheus - name: Включить mgr-модуль prometheus
ansible.builtin.command: cephadm shell -- ceph mgr module enable prometheus ansible.builtin.command: /usr/sbin/cephadm shell -- ceph mgr module enable prometheus
register: mod_prom register: mod_prom
failed_when: false failed_when: false
changed_when: mod_prom.rc == 0 changed_when: mod_prom.rc == 0
- name: Включить mgr-модуль dashboard
ansible.builtin.command: cephadm shell -- ceph mgr module enable dashboard
register: mod_dash
failed_when: false
changed_when: mod_dash.rc == 0
- name: Настроить порт dashboard
ansible.builtin.command: >
cephadm shell -- ceph config set mgr mgr/dashboard/ssl_server_port {{ dashboard.port }}
failed_when: false
changed_when: false
- name: Прочитать публичный ключ cephadm-оркестратора - name: Прочитать публичный ключ cephadm-оркестратора
ansible.builtin.slurp: ansible.builtin.slurp:
src: /etc/ceph/ceph.pub src: /etc/ceph/ceph.pub
...@@ -156,7 +238,7 @@ ...@@ -156,7 +238,7 @@
# поэтому все ceph-команды идут через `cephadm shell -- ceph ...`, # поэтому все ceph-команды идут через `cephadm shell -- ceph ...`,
# которое запускает их внутри podman-контейнера с Ceph. # которое запускает их внутри podman-контейнера с Ceph.
- name: Подождать готовности оркестратора - name: Подождать готовности оркестратора
ansible.builtin.command: cephadm shell -- ceph orch status ansible.builtin.command: /usr/sbin/cephadm shell -- ceph orch status
register: orch_status register: orch_status
retries: 10 retries: 10
delay: 10 delay: 10
...@@ -174,7 +256,7 @@ ...@@ -174,7 +256,7 @@
{% set hv = "{{ hostvars['" ~ s.hostname ~ "'].ansible_hostname | default('" ~ s.hostname ~ "') }}" %} {% set hv = "{{ hostvars['" ~ s.hostname ~ "'].ansible_hostname | default('" ~ s.hostname ~ "') }}" %}
- name: Добавить хост {{ s.hostname }} - name: Добавить хост {{ s.hostname }}
ansible.builtin.command: > ansible.builtin.command: >
cephadm shell -- ceph orch host add {{ hv }} {{ s.ip_address }} /usr/sbin/cephadm shell -- ceph orch host add {{ hv }} {{ s.ip_address }}
register: add_host_result register: add_host_result
failed_when: false failed_when: false
changed_when: add_host_result.rc == 0 changed_when: add_host_result.rc == 0
...@@ -196,7 +278,7 @@ ...@@ -196,7 +278,7 @@
{% for osd in s.osds %} {% for osd in s.osds %}
- name: Добавить OSD {{ osd.path }} на {{ s.hostname }} ({{ osd.type }}/{{ osd.role }}) - name: Добавить OSD {{ osd.path }} на {{ s.hostname }} ({{ osd.type }}/{{ osd.role }})
ansible.builtin.command: > ansible.builtin.command: >
cephadm shell -- ceph orch daemon add osd {{ osd_hv }}:{{ osd.path }} /usr/sbin/cephadm shell -- ceph orch daemon add osd {{ osd_hv }}:{{ osd.path }}
register: osd_result register: osd_result
failed_when: false failed_when: false
changed_when: "'Created osd' in (osd_result.stdout | default(''))" changed_when: "'Created osd' in (osd_result.stdout | default(''))"
...@@ -204,7 +286,7 @@ ...@@ -204,7 +286,7 @@
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
- name: Статус кластера - name: Статус кластера
ansible.builtin.command: cephadm shell -- ceph -s ansible.builtin.command: /usr/sbin/cephadm shell -- ceph -s
register: ceph_status register: ceph_status
failed_when: false failed_when: false
changed_when: false changed_when: false
......
...@@ -38,13 +38,31 @@ ...@@ -38,13 +38,31 @@
failed_when: false failed_when: false
changed_when: true changed_when: true
- name: Остановить любые оставшиеся ceph-*@ юниты - name: Остановить и отключить любые оставшиеся ceph systemd-юниты
ansible.builtin.shell: | ansible.builtin.shell: |
set -o pipefail set -o pipefail
for u in $(systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | awk '{print $1}'); do if command -v systemctl >/dev/null 2>&1; then
systemctl stop "$u" 2>/dev/null || true for u in $(timeout 5 systemctl list-units --all --no-legend 'ceph-*' 2>/dev/null | awk '{print $1}'); do
systemctl disable "$u" 2>/dev/null || true timeout 10 systemctl stop "$u" 2>/dev/null || true
timeout 10 systemctl disable "$u" 2>/dev/null || true
timeout 5 systemctl reset-failed "$u" 2>/dev/null || true
done done
fi
args:
executable: /bin/bash
changed_when: true
failed_when: false
- name: Удалить оставшиеся ceph systemd unit-файлы
ansible.builtin.shell: |
set -o pipefail
find /etc/systemd/system /run/systemd/system -maxdepth 2 \
\( -name 'ceph-*.target' -o -name 'ceph-*@.service' -o -name 'ceph-*@*.service' \) \
-exec rm -f {} + 2>/dev/null || true
if command -v systemctl >/dev/null 2>&1; then
timeout 10 systemctl daemon-reload 2>/dev/null || true
timeout 10 systemctl reset-failed 2>/dev/null || true
fi
args: args:
executable: /bin/bash executable: /bin/bash
changed_when: true changed_when: true
...@@ -63,17 +81,61 @@ ...@@ -63,17 +81,61 @@
- name: Убрать оставшиеся podman/docker-контейнеры ceph-* - name: Убрать оставшиеся podman/docker-контейнеры ceph-*
ansible.builtin.shell: | ansible.builtin.shell: |
if command -v podman >/dev/null 2>&1; then if command -v podman >/dev/null 2>&1; then
for c in $(podman ps -aq --filter name=ceph- 2>/dev/null); do for c in $(timeout 5 podman ps -aq --filter name=ceph- 2>/dev/null); do
podman rm -f "$c" >/dev/null 2>&1 || true timeout 20 podman rm -f "$c" >/dev/null 2>&1 || true
done done
fi fi
if command -v docker >/dev/null 2>&1; then if command -v docker >/dev/null 2>&1; then
for c in $(docker ps -aq --filter name=ceph- 2>/dev/null); do for c in $(timeout 5 docker ps -aq --filter name=ceph- 2>/dev/null); do
docker rm -f "$c" >/dev/null 2>&1 || true timeout 20 docker rm -f "$c" >/dev/null 2>&1 || true
done done
fi fi
args: args:
executable: /bin/bash executable: /bin/bash
changed_when: true changed_when: true
failed_when: false failed_when: false
- name: Проверить, что признаки Ceph удалены
ansible.builtin.shell: |
evidence=0
if [ -s /etc/ceph/ceph.conf ]; then
echo "/etc/ceph/ceph.conf"
evidence=1
fi
if [ -d /var/lib/ceph ] && timeout 3 find /var/lib/ceph -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null | grep -q .; then
echo "/var/lib/ceph contains data"
evidence=1
fi
if command -v cephadm >/dev/null 2>&1 && timeout 3 cephadm ls 2>/dev/null | grep -q '"fsid"'; then
echo "cephadm cluster metadata exists"
evidence=1
fi
if command -v systemctl >/dev/null 2>&1 && timeout 3 systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | grep -q .; then
echo "ceph systemd units exist"
evidence=1
fi
if find /etc/systemd/system /run/systemd/system -maxdepth 2 \
\( -name 'ceph-*.target' -o -name 'ceph-*@.service' -o -name 'ceph-*@*.service' \) \
-print -quit 2>/dev/null | grep -q .; then
echo "ceph systemd unit files exist"
evidence=1
fi
if command -v lvs >/dev/null 2>&1 && timeout 3 lvs --noheadings -o lv_tags 2>/dev/null | grep -q 'ceph'; then
echo "Ceph LVM tags exist"
evidence=1
fi
if command -v podman >/dev/null 2>&1 && timeout 3 podman ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph podman containers exist"
evidence=1
fi
if command -v docker >/dev/null 2>&1 && timeout 3 docker ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph docker containers exist"
evidence=1
fi
exit "$evidence"
args:
executable: /bin/bash
register: cleanup_verify
changed_when: false
failed_when: cleanup_verify.rc != 0
{% endraw %} {% endraw %}
...@@ -68,6 +68,34 @@ ...@@ -68,6 +68,34 @@
<p style="color:#9e9e9e">Серверов нет.</p> <p style="color:#9e9e9e">Серверов нет.</p>
{% endif %} {% endif %}
<!-- Фактические диски -->
<h2>Обнаруженные диски на серверах</h2>
{% set total_disks = namespace(n=0) %}
{% for s in servers %}{% set total_disks.n = total_disks.n + (s.disks | length) %}{% endfor %}
{% if total_disks.n > 0 %}
<table>
<thead>
<tr><th>Сервер</th><th>Диск</th><th>Размер</th><th>Тип</th><th>Состояние</th><th>Детали</th></tr>
</thead>
<tbody>
{% for s in servers %}
{% for disk in s.disks %}
<tr>
<td>{{ s.hostname }}</td>
<td><code>{{ disk.path }}</code></td>
<td>{{ disk.size }}</td>
<td>{{ disk.type }}</td>
<td>{{ disk.status }}</td>
<td>{{ disk.detail }}</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color:#9e9e9e">Диски не обнаружены или серверы недоступны по SSH.</p>
{% endif %}
<!-- OSD --> <!-- OSD -->
<h2>OSD-устройства</h2> <h2>OSD-устройства</h2>
{% set total_osds = namespace(n=0) %} {% set total_osds = namespace(n=0) %}
......
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
ct_gw: "{{ monitoring.ct_gw }}" ct_gw: "{{ monitoring.ct_gw }}"
ct_bridge: "{{ monitoring.ct_bridge }}" ct_bridge: "{{ monitoring.ct_bridge }}"
ct_storage: "{{ monitoring.ct_storage }}" ct_storage: "{{ monitoring.ct_storage }}"
template_storage: "{{ monitoring.template_storage }}"
ct_template: "{{ monitoring.ct_template }}" ct_template: "{{ monitoring.ct_template }}"
grafana_admin_password: "{{ monitoring.grafana_password }}" grafana_admin_password: "{{ monitoring.grafana_password }}"
...@@ -25,16 +26,17 @@ ...@@ -25,16 +26,17 @@
# На свежем PVE-хосте хранилище 'local' может не иметь в content типа # На свежем PVE-хосте хранилище 'local' может не иметь в content типа
# 'vztmpl' — без него pveam download падает "storage 'local' does not # 'vztmpl' — без него pveam download падает "storage 'local' does not
# support templates". Добавляем vztmpl к текущему списку content. # support templates". Добавляем vztmpl к текущему списку content.
- name: Разрешить хранить LXC-шаблоны в local - name: Разрешить хранить LXC-шаблоны в выбранном template storage
ansible.builtin.shell: | ansible.builtin.shell: |
set -eo pipefail set -eo pipefail
cur=$(awk '/^dir: local$/{flag=1; next} storage="{{ '{{ template_storage }}' }}"
cur=$(awk -v storage="$storage" '$0 ~ "^[^[:space:]]+: " storage "$"{flag=1; next}
flag && /^[^[:space:]]/{flag=0} flag && /^[^[:space:]]/{flag=0}
flag && /content/{sub(/^[[:space:]]+content[[:space:]]+/, ""); print; exit} flag && /content/{sub(/^[[:space:]]+content[[:space:]]+/, ""); print; exit}
' /etc/pve/storage.cfg) ' /etc/pve/storage.cfg)
if ! echo "$cur" | tr ',' '\n' | grep -qx 'vztmpl'; then if ! echo "$cur" | tr ',' '\n' | grep -qx 'vztmpl'; then
new="${cur:+$cur,}vztmpl" new="${cur:+$cur,}vztmpl"
pvesm set local --content "$new" pvesm set "$storage" --content "$new"
echo "VZTMPL_ADDED" echo "VZTMPL_ADDED"
fi fi
args: args:
...@@ -44,10 +46,25 @@ ...@@ -44,10 +46,25 @@
# Шаблон сравниваем по имени файла (работает и для debian-12-..., и для # Шаблон сравниваем по имени файла (работает и для debian-12-..., и для
# вручную положенного ALT-tarball). # вручную положенного ALT-tarball).
- name: Проверить, не занят ли VMID
ansible.builtin.shell: |
set -o pipefail
if pct status {{ '{{ vmid }}' }} >/dev/null 2>&1; then
echo "VMID {{ '{{ vmid }}' }} already exists as LXC"
exit 1
fi
if qm status {{ '{{ vmid }}' }} >/dev/null 2>&1; then
echo "VMID {{ '{{ vmid }}' }} already exists as VM"
exit 1
fi
args:
executable: /bin/bash
changed_when: false
- name: Проверить наличие LXC-шаблона в хранилище - name: Проверить наличие LXC-шаблона в хранилище
ansible.builtin.shell: | ansible.builtin.shell: |
set -o pipefail set -o pipefail
pveam list local | awk '{print $2}' | grep -q "{% raw %}{{ ct_template }}{% endraw %}" pveam list {{ '{{ template_storage }}' }} | awk 'NR>1{n=$1; sub(/^.*vztmpl\//, "", n); print n}' | grep -q "{{ '{{ ct_template }}' }}"
args: args:
executable: /bin/bash executable: /bin/bash
register: tpl_check register: tpl_check
...@@ -61,15 +78,15 @@ ...@@ -61,15 +78,15 @@
- name: Скачать указанный LXC-шаблон, если его ещё нет - name: Скачать указанный LXC-шаблон, если его ещё нет
ansible.builtin.command: > ansible.builtin.command: >
pveam download local {% raw %}{{ ct_template }}{% endraw %} pveam download {{ '{{ template_storage }}' }} {{ '{{ ct_template }}' }}
when: tpl_check.rc != 0 when: tpl_check.rc != 0
register: tpl_download register: tpl_download
changed_when: tpl_download.rc == 0 changed_when: tpl_download.rc == 0
- name: Проверить, существует ли контейнер VMID={% raw %}{{ vmid }}{% endraw %} - name: Проверить, существует ли контейнер VMID
ansible.builtin.command: pct status {% raw %}{{ vmid }}{% endraw %} ansible.builtin.command: pct status {{ '{{ vmid }}' }}
register: pct_status register: pct_status
failed_when: false failed_when: false
...@@ -77,9 +94,9 @@ ...@@ -77,9 +94,9 @@
- name: Создать LXC-контейнер - name: Создать LXC-контейнер
ansible.builtin.command: > ansible.builtin.command: >
pct create {% raw %}{{ vmid }}{% endraw %} pct create {{ '{{ vmid }}' }}
local:vztmpl/{% raw %}{{ ct_template }}{% endraw %} {{ '{{ template_storage }}' }}:vztmpl/{{ '{{ ct_template }}' }}
--hostname {% raw %}{{ ct_hostname }}{% endraw %} --hostname {% raw %}{{ ct_hostname }}{% endraw %}
...@@ -280,7 +297,7 @@ ...@@ -280,7 +297,7 @@
loop: loop:
- prometheus - prometheus
- alertmanager - prometheus-alertmanager
- grafana-server - grafana-server
register: svc_enable register: svc_enable
failed_when: false failed_when: false
...@@ -292,7 +309,7 @@ ...@@ -292,7 +309,7 @@
loop: loop:
- prometheus - prometheus
- alertmanager - prometheus-alertmanager
failed_when: false failed_when: false
changed_when: true changed_when: true
......
...@@ -10,6 +10,7 @@ latency — кандидаты на профилактику). ...@@ -10,6 +10,7 @@ latency — кандидаты на профилактику).
from __future__ import annotations from __future__ import annotations
import time import time
from urllib.parse import urlparse
import matplotlib import matplotlib
matplotlib.use("QtAgg") matplotlib.use("QtAgg")
...@@ -23,6 +24,7 @@ from PyQt6.QtWidgets import ( ...@@ -23,6 +24,7 @@ from PyQt6.QtWidgets import (
QHBoxLayout, QHBoxLayout,
QHeaderView, QHeaderView,
QLabel, QLabel,
QLineEdit,
QPushButton, QPushButton,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
...@@ -32,6 +34,8 @@ from PyQt6.QtWidgets import ( ...@@ -32,6 +34,8 @@ from PyQt6.QtWidgets import (
from api.prometheus_client import PrometheusClient, PrometheusError from api.prometheus_client import PrometheusClient, PrometheusError
from core.config import AppConfig from core.config import AppConfig
from db import SessionLocal
from db.repository import list_clusters, list_servers
from ui.base_page import BasePage from ui.base_page import BasePage
_BOX_STYLE = ( _BOX_STYLE = (
...@@ -87,11 +91,11 @@ class PromFetchWorker(QThread): ...@@ -87,11 +91,11 @@ class PromFetchWorker(QThread):
start = end - self.duration_s start = end - self.duration_s
throughput_write = cli.query_range( throughput_write = cli.query_range(
"sum(rate(ceph_osd_op_w_in_bytes[1m]))", "sum(rate(ceph_pool_wr_bytes[1m]))",
start, end, step=self.step, start, end, step=self.step,
) )
throughput_read = cli.query_range( throughput_read = cli.query_range(
"sum(rate(ceph_osd_op_r_out_bytes[1m]))", "sum(rate(ceph_pool_rd_bytes[1m]))",
start, end, step=self.step, start, end, step=self.step,
) )
latency_per_osd = cli.osd_latency_series(self.duration_s, step=self.step) latency_per_osd = cli.osd_latency_series(self.duration_s, step=self.step)
...@@ -105,12 +109,27 @@ class PromFetchWorker(QThread): ...@@ -105,12 +109,27 @@ class PromFetchWorker(QThread):
"read_bps": cli.cluster_read_throughput(), "read_bps": cli.cluster_read_throughput(),
"write_bps": cli.cluster_write_throughput(), "write_bps": cli.cluster_write_throughput(),
} }
health_rows = cli.query("ceph_health_status")
fsids = sorted(
{
row.get("metric", {}).get("ceph_cluster")
for row in health_rows
if row.get("metric", {}).get("ceph_cluster")
}
)
ceph_targets = [
target
for target in cli.targets()
if target.get("labels", {}).get("job") == "ceph"
]
self.result.emit({ self.result.emit({
"kpi": kpi, "kpi": kpi,
"throughput_w": throughput_write, "throughput_w": throughput_write,
"throughput_r": throughput_read, "throughput_r": throughput_read,
"latency_per_osd": latency_per_osd, "latency_per_osd": latency_per_osd,
"fsids": fsids,
"ceph_targets": ceph_targets,
}) })
except PrometheusError as exc: except PrometheusError as exc:
self.error.emit(str(exc)) self.error.emit(str(exc))
...@@ -162,25 +181,51 @@ class AnalysisWidget(BasePage): ...@@ -162,25 +181,51 @@ class AnalysisWidget(BasePage):
super().__init__("📈 Анализ", "Анализ функционирования кластера Ceph") super().__init__("📈 Анализ", "Анализ функционирования кластера Ceph")
self._worker: PromFetchWorker | None = None self._worker: PromFetchWorker | None = None
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.setInterval(2000)
self._timer.timeout.connect(self._fetch) self._timer.timeout.connect(self._fetch)
self._build_content() self._build_content()
def showEvent(self, event) -> None:
super().showEvent(event)
self._refresh_source_label()
self._fetch()
self._timer.start()
def hideEvent(self, event) -> None:
self._timer.stop()
super().hideEvent(event)
def _build_content(self) -> None: def _build_content(self) -> None:
# ── Управление ──────────────────────────────────────────────── # ── Управление ────────────────────────────────────────────────
ctrl_box = QGroupBox("Источник данных") ctrl_box = QGroupBox("Источник данных")
ctrl_box.setStyleSheet(_BOX_STYLE) ctrl_box.setStyleSheet(_BOX_STYLE)
ctrl_layout = QHBoxLayout(ctrl_box) ctrl_layout = QHBoxLayout(ctrl_box)
self._lbl_source = QLabel(AppConfig.get("prometheus_url") or "(не настроен)")
self._lbl_source.setStyleSheet("color: #c0c8d8;")
ctrl_layout.addWidget(QLabel("Prometheus:")) ctrl_layout.addWidget(QLabel("Prometheus:"))
ctrl_layout.addWidget(self._lbl_source) self._source_combo = QComboBox()
self._source_combo.setEditable(True)
self._source_combo.setMinimumWidth(260)
self._source_combo.lineEdit().setPlaceholderText("http://host:9090")
self._source_combo.setStyleSheet(
"QComboBox, QLineEdit { background: #111722; color: #e0e8f8; "
"border: 1px solid #3a4050; border-radius: 4px; padding: 3px 6px; }"
)
ctrl_layout.addWidget(self._source_combo)
self._btn_apply_source = QPushButton("Применить")
self._btn_apply_source.setFixedHeight(28)
self._btn_apply_source.clicked.connect(self._apply_source)
ctrl_layout.addWidget(self._btn_apply_source)
ctrl_layout.addWidget(QLabel(" Период:")) ctrl_layout.addWidget(QLabel(" Период:"))
self._period = QComboBox() self._period = QComboBox()
self._period.addItem("1 мин", ("60", "5s"))
self._period.addItem("5 мин", ("300", "10s"))
self._period.addItem("30 мин", ("1800", "15s"))
self._period.addItem("1 час", ("3600", "30s")) self._period.addItem("1 час", ("3600", "30s"))
self._period.addItem("6 часов", ("21600", "2m")) self._period.addItem("6 часов", ("21600", "2m"))
self._period.addItem("24 часа", ("86400", "10m")) self._period.addItem("24 часа", ("86400", "10m"))
self._period.setCurrentIndex(1)
self._period.currentIndexChanged.connect(self._fetch) self._period.currentIndexChanged.connect(self._fetch)
ctrl_layout.addWidget(self._period) ctrl_layout.addWidget(self._period)
...@@ -197,9 +242,21 @@ class AnalysisWidget(BasePage): ...@@ -197,9 +242,21 @@ class AnalysisWidget(BasePage):
self._lbl_status = QLabel("") self._lbl_status = QLabel("")
self._lbl_status.setStyleSheet("color: #5a6478; font-size: 12px;") self._lbl_status.setStyleSheet("color: #5a6478; font-size: 12px;")
ctrl_layout.addWidget(self._lbl_status) ctrl_layout.addWidget(self._lbl_status)
self._lbl_auto = QLabel("авто: 2с")
self._lbl_auto.setStyleSheet("color: #5a6478; font-size: 12px;")
ctrl_layout.addWidget(self._lbl_auto)
ctrl_layout.addStretch() ctrl_layout.addStretch()
self.content_layout.addWidget(ctrl_box) self.content_layout.addWidget(ctrl_box)
identity_box = QGroupBox("Определение кластера")
identity_box.setStyleSheet(_BOX_STYLE)
identity_layout = QVBoxLayout(identity_box)
self._lbl_cluster_identity = QLabel("Кластер: —")
self._lbl_cluster_identity.setStyleSheet("color: #c0c8d8;")
self._lbl_cluster_identity.setWordWrap(True)
identity_layout.addWidget(self._lbl_cluster_identity)
self.content_layout.addWidget(identity_box)
# ── KPI-карточки ────────────────────────────────────────────── # ── KPI-карточки ──────────────────────────────────────────────
kpi_row = QHBoxLayout() kpi_row = QHBoxLayout()
self._kpi_health = KPICard("Здоровье") self._kpi_health = KPICard("Здоровье")
...@@ -231,7 +288,7 @@ class AnalysisWidget(BasePage): ...@@ -231,7 +288,7 @@ class AnalysisWidget(BasePage):
osd_layout = QVBoxLayout(osd_box) osd_layout = QVBoxLayout(osd_box)
self._osd_table = QTableWidget(0, 4) self._osd_table = QTableWidget(0, 4)
self._osd_table.setHorizontalHeaderLabels( self._osd_table.setHorizontalHeaderLabels(
["OSD", "avg latency, мс", "тренд 24ч, %", "примечание"] ["OSD", "avg latency, мс", "тренд периода, %", "примечание"]
) )
self._osd_table.horizontalHeader().setSectionResizeMode( self._osd_table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch QHeaderView.ResizeMode.Stretch
...@@ -256,12 +313,55 @@ class AnalysisWidget(BasePage): ...@@ -256,12 +313,55 @@ class AnalysisWidget(BasePage):
self._fetch() self._fetch()
def _refresh_source_label(self) -> None: def _refresh_source_label(self) -> None:
self._lbl_source.setText(AppConfig.get("prometheus_url") or "(не настроен)") current = AppConfig.get("prometheus_url") or ""
candidates = [
current,
AppConfig.get("grafana_url") or "",
]
urls: list[str] = []
for value in candidates:
value = (value or "").strip()
if not value:
continue
if value.endswith(":3000"):
value = value[:-5] + ":9090"
if value not in urls:
urls.append(value)
self._source_combo.blockSignals(True)
typed = self._source_combo.currentText().strip()
self._source_combo.clear()
self._source_combo.addItems(urls)
if typed and typed not in urls:
self._source_combo.insertItem(0, typed)
if current:
idx = self._source_combo.findText(current)
if idx >= 0:
self._source_combo.setCurrentIndex(idx)
elif typed:
self._source_combo.setCurrentText(typed)
self._source_combo.blockSignals(False)
def _source_url(self) -> str:
return self._source_combo.currentText().strip().rstrip("/")
def _apply_source(self) -> None:
url = self._source_url()
if not url:
self._lbl_status.setText("Укажите URL Prometheus.")
return
AppConfig.set_value("prometheus_url", url)
try:
AppConfig.save()
except Exception as exc:
self._lbl_status.setText(f"Не удалось сохранить URL: {exc}")
return
self._refresh_source_label()
self._fetch()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _fetch(self) -> None: def _fetch(self) -> None:
url = AppConfig.get("prometheus_url") or "" url = self._source_url() or AppConfig.get("prometheus_url") or ""
if not url: if not url:
self._lbl_status.setText( self._lbl_status.setText(
"URL Prometheus не задан — зайди в Настройки → Внешний мониторинг." "URL Prometheus не задан — зайди в Настройки → Внешний мониторинг."
...@@ -295,7 +395,7 @@ class AnalysisWidget(BasePage): ...@@ -295,7 +395,7 @@ class AnalysisWidget(BasePage):
sub="up / in", sub="up / in",
) )
self._kpi_mon.set_value(str(kpi.get("mon_quorum", 0))) self._kpi_mon.set_value(str(kpi.get("mon_quorum", 0)))
self._kpi_iops.set_value(f"{kpi.get('iops', 0):.0f}", sub="ops/s (rate 1m)") self._kpi_iops.set_value(f"{kpi.get('iops', 0):.0f}", sub="ops/s по pool")
self._kpi_read.set_value(_human_bps(kpi.get("read_bps", 0))) self._kpi_read.set_value(_human_bps(kpi.get("read_bps", 0)))
self._kpi_write.set_value(_human_bps(kpi.get("write_bps", 0))) self._kpi_write.set_value(_human_bps(kpi.get("write_bps", 0)))
...@@ -303,6 +403,7 @@ class AnalysisWidget(BasePage): ...@@ -303,6 +403,7 @@ class AnalysisWidget(BasePage):
self._draw_throughput(data["throughput_w"], data["throughput_r"]) self._draw_throughput(data["throughput_w"], data["throughput_r"])
# Таблица # Таблица
self._fill_osd_table(data["latency_per_osd"]) self._fill_osd_table(data["latency_per_osd"])
self._update_cluster_identity(data)
self._lbl_status.setText(f"Обновлено: {datetime.now():%H:%M:%S}") self._lbl_status.setText(f"Обновлено: {datetime.now():%H:%M:%S}")
self._btn_refresh.setEnabled(True) self._btn_refresh.setEnabled(True)
...@@ -315,6 +416,57 @@ class AnalysisWidget(BasePage): ...@@ -315,6 +416,57 @@ class AnalysisWidget(BasePage):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _update_cluster_identity(self, data: dict) -> None:
targets = data.get("ceph_targets") or []
fsids = data.get("fsids") or []
target_hosts = []
for target in targets:
labels = target.get("labels", {})
address = labels.get("instance") or target.get("scrapeUrl", "")
host = self._host_from_address(address)
if host and host not in target_hosts:
target_hosts.append(host)
matches = self._match_clusters_by_ips(target_hosts)
if matches:
cluster_text = ", ".join(matches)
else:
cluster_text = "не найден в БД"
fsid_text = ", ".join(fsids) if fsids else "—"
target_text = ", ".join(target_hosts) if target_hosts else "—"
self._lbl_cluster_identity.setText(
f"Кластер: {cluster_text} FSID: {fsid_text} Ceph targets: {target_text}"
)
@staticmethod
def _host_from_address(address: str) -> str:
address = (address or "").strip()
if not address:
return ""
if "://" in address:
parsed = urlparse(address)
return parsed.hostname or ""
return address.rsplit(":", 1)[0]
@staticmethod
def _match_clusters_by_ips(ips: list[str]) -> list[str]:
if not ips:
return []
wanted = set(ips)
matches: list[str] = []
with SessionLocal() as session:
for cluster in list_clusters(session):
servers = list_servers(session, cluster.id)
matched = [
f"{srv.hostname} ({srv.ip_address})"
for srv in servers
if srv.ip_address in wanted
]
if matched:
matches.append(f"{cluster.name}: " + ", ".join(matched))
return matches
def _draw_throughput(self, w_series: list, r_series: list) -> None: def _draw_throughput(self, w_series: list, r_series: list) -> None:
ax = self._ax_throughput ax = self._ax_throughput
ax.clear() ax.clear()
...@@ -354,9 +506,7 @@ class AnalysisWidget(BasePage): ...@@ -354,9 +506,7 @@ class AnalysisWidget(BasePage):
if not values: if not values:
continue continue
avg = sum(values) / len(values) avg = sum(values) / len(values)
first = values[0] if values[0] else avg or 1e-9 trend_pct = self._trend_pct(values)
last = values[-1]
trend_pct = ((last - first) / first) * 100.0 if first else 0.0
rows.append((osd, avg, trend_pct)) rows.append((osd, avg, trend_pct))
rows.sort(key=lambda r: r[1], reverse=True) rows.sort(key=lambda r: r[1], reverse=True)
...@@ -364,11 +514,9 @@ class AnalysisWidget(BasePage): ...@@ -364,11 +514,9 @@ class AnalysisWidget(BasePage):
self._osd_table.setRowCount(len(rows)) self._osd_table.setRowCount(len(rows))
for i, (osd, avg, trend) in enumerate(rows): for i, (osd, avg, trend) in enumerate(rows):
# avg: секунды → миллисекунды
ms = avg * 1000.0
note = "" note = ""
color = None color = None
if trend >= 50 and avg > 0.005: if trend >= 50 and avg > 5.0:
note = "кандидат на обслуживание" note = "кандидат на обслуживание"
color = "#f44336" color = "#f44336"
elif trend >= 20: elif trend >= 20:
...@@ -377,7 +525,7 @@ class AnalysisWidget(BasePage): ...@@ -377,7 +525,7 @@ class AnalysisWidget(BasePage):
items = [ items = [
QTableWidgetItem(str(osd)), QTableWidgetItem(str(osd)),
QTableWidgetItem(f"{ms:.2f}"), QTableWidgetItem(f"{avg:.2f}"),
QTableWidgetItem(f"{trend:+.1f}"), QTableWidgetItem(f"{trend:+.1f}"),
QTableWidgetItem(note), QTableWidgetItem(note),
] ]
...@@ -387,3 +535,19 @@ class AnalysisWidget(BasePage): ...@@ -387,3 +535,19 @@ class AnalysisWidget(BasePage):
it.setTextAlignment(Qt.AlignmentFlag.AlignCenter) it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
for col, it in enumerate(items): for col, it in enumerate(items):
self._osd_table.setItem(i, col, it) self._osd_table.setItem(i, col, it)
@staticmethod
def _trend_pct(values: list[float]) -> float:
usable = [v for v in values if v is not None and abs(v) >= 1e-9]
if len(usable) < 4:
return 0.0
mid = len(usable) // 2
first_half = usable[:mid]
second_half = usable[mid:]
first_avg = sum(first_half) / len(first_half)
second_avg = sum(second_half) / len(second_half)
if abs(first_avg) < 1e-9 and abs(second_avg) < 1e-9:
return 0.0
if abs(first_avg) < 1e-9:
return 100.0
return ((second_avg - first_avg) / first_avg) * 100.0
...@@ -4,7 +4,13 @@ ...@@ -4,7 +4,13 @@
from __future__ import annotations from __future__ import annotations
import os
import tempfile
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtCore import QProcess, QProcessEnvironment
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QComboBox, QComboBox,
...@@ -24,16 +30,20 @@ from PyQt6.QtWidgets import ( ...@@ -24,16 +30,20 @@ from PyQt6.QtWidgets import (
QWidget, QWidget,
) )
from core.resources import get_templates_dir
from db import SessionLocal from db import SessionLocal
from db.repository import ( from db.repository import (
create_cluster, create_cluster,
delete_cluster, delete_cluster,
delete_server, delete_server,
get_cluster,
list_clusters, list_clusters,
list_servers, list_servers,
) )
from ui.base_page import BasePage from ui.base_page import BasePage
_TEMPLATES_DIR = get_templates_dir()
def _plain_item(text: str) -> QTableWidgetItem: def _plain_item(text: str) -> QTableWidgetItem:
item = QTableWidgetItem(text) item = QTableWidgetItem(text)
...@@ -131,6 +141,10 @@ class ClustersWidget(BasePage): ...@@ -131,6 +141,10 @@ class ClustersWidget(BasePage):
super().__init__("🖥️ Кластеры", "Список профилей кластеров") super().__init__("🖥️ Кластеры", "Список профилей кластеров")
self._clusters: list = [] self._clusters: list = []
self._selected_cluster_id: int | None = None self._selected_cluster_id: int | None = None
self._delete_process: QProcess | None = None
self._delete_cluster_id: int | None = None
self._delete_deploy_dir: str | None = None
self._delete_log: list[str] = []
self._build_content() self._build_content()
self.refresh() self.refresh()
...@@ -308,17 +322,191 @@ class ClustersWidget(BasePage): ...@@ -308,17 +322,191 @@ class ClustersWidget(BasePage):
def _on_delete_cluster(self) -> None: def _on_delete_cluster(self) -> None:
if self._selected_cluster_id is None: if self._selected_cluster_id is None:
return return
if self._delete_process is not None:
QMessageBox.information(
self,
"Удаление уже выполняется",
"Дождитесь завершения текущей очистки кластера.",
)
return
with SessionLocal() as session:
cluster = get_cluster(session, self._selected_cluster_id)
servers = list_servers(session, self._selected_cluster_id)
if cluster is None:
self.refresh()
return
cluster_name = cluster.name
if not servers:
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Удалить кластер?", self,
"Все серверы и данные этого кластера будут удалены. Продолжить?", "Удалить кластер?",
"В профиле нет серверов, поэтому на узлах нечего очищать.\n\n"
"Удалить только локальную запись кластера из CephDeploy?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
self._delete_local_cluster(self._selected_cluster_id)
return
reply = QMessageBox.question(
self,
"Удалить кластер?",
"CephDeploy запустит очистку на всех серверах кластера: "
"cephadm rm-cluster --zap-osds, удаление /etc/ceph, /var/lib/ceph, "
"/var/log/ceph, ceph systemd-юнитов и контейнеров ceph-*.\n\n"
"Локальная запись кластера будет удалена только после успешной очистки.\n\n"
f"Удалить кластер «{cluster_name}»?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self._start_delete_cleanup(self._selected_cluster_id)
def _delete_local_cluster(self, cluster_id: int) -> None:
with SessionLocal() as session: with SessionLocal() as session:
delete_cluster(session, self._selected_cluster_id) delete_cluster(session, cluster_id)
session.commit() session.commit()
self.refresh() self.refresh()
def _start_delete_cleanup(self, cluster_id: int) -> None:
try:
deploy_dir = self._generate_cleanup_configs(cluster_id)
except Exception as exc:
QMessageBox.critical(self, "Ошибка генерации очистки", str(exc))
return
inv = os.path.join(deploy_dir, "inventory.ini")
play = os.path.join(deploy_dir, "cleanup.yml")
process = QProcess(self)
env = QProcessEnvironment.systemEnvironment()
env.insert("ANSIBLE_PIPELINING", "True")
env.insert("ANSIBLE_FORKS", "10")
env.insert("ANSIBLE_HOST_KEY_CHECKING", "False")
process.setProcessEnvironment(env)
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
process.readyReadStandardOutput.connect(self._on_delete_cleanup_output)
process.finished.connect(self._on_delete_cleanup_finished)
self._delete_process = process
self._delete_cluster_id = cluster_id
self._delete_deploy_dir = deploy_dir
self._delete_log = [
f"Рабочий каталог очистки: {deploy_dir}",
f"ansible-playbook -i {inv} {play}",
]
self._set_delete_busy(True)
process.start("ansible-playbook", ["-i", inv, play])
if not process.waitForStarted(3000):
self._set_delete_busy(False)
self._delete_process = None
self._delete_cluster_id = None
self._delete_deploy_dir = None
QMessageBox.critical(
self,
"Ошибка запуска очистки",
"Не удалось запустить ansible-playbook. "
"Убедитесь, что ansible-core установлен и доступен в PATH.",
)
def _generate_cleanup_configs(self, cluster_id: int) -> str:
with SessionLocal() as session:
cluster = get_cluster(session, cluster_id)
if cluster is None:
raise RuntimeError("Кластер не найден.")
servers = list_servers(session, cluster_id)
if not servers:
raise RuntimeError("В кластере нет серверов для очистки.")
servers_data = [
{
"hostname": s.hostname,
"ip_address": s.ip_address,
"role": s.role.value,
"ssh_user": s.ssh_user,
"ssh_key_path": str(Path(s.ssh_key_path).expanduser()),
}
for s in servers
]
cluster_data = {"name": cluster.name, "version": cluster.ceph_version}
deploy_dir = tempfile.mkdtemp(prefix="cephdeploy_delete_cleanup_")
env = Environment(
loader=FileSystemLoader(str(_TEMPLATES_DIR)),
trim_blocks=True,
lstrip_blocks=True,
)
Path(deploy_dir, "inventory.ini").write_text(
env.get_template("inventory.ini.j2").render(servers=servers_data),
encoding="utf-8",
)
Path(deploy_dir, "cleanup.yml").write_text(
env.get_template("ceph_cleanup.yml.j2").render(
cluster=cluster_data,
servers=servers_data,
),
encoding="utf-8",
)
return deploy_dir
def _on_delete_cleanup_output(self) -> None:
if self._delete_process is None:
return
data = (
self._delete_process.readAllStandardOutput()
.data()
.decode(errors="replace")
)
self._delete_log.append(data)
if self._delete_deploy_dir:
log_f = os.path.join(self._delete_deploy_dir, "delete_cleanup.log")
with open(log_f, "a", encoding="utf-8") as f:
f.write(data)
def _on_delete_cleanup_finished(self, exit_code: int, _exit_status) -> None:
cluster_id = self._delete_cluster_id
deploy_dir = self._delete_deploy_dir
log_tail = "".join(self._delete_log)[-3000:]
self._set_delete_busy(False)
self._delete_process = None
self._delete_cluster_id = None
self._delete_deploy_dir = None
if exit_code == 0 and cluster_id is not None:
self._delete_local_cluster(cluster_id)
QMessageBox.information(
self,
"Кластер удалён",
"Очистка на серверах завершена успешно, локальная запись удалена.",
)
return
QMessageBox.critical(
self,
"Очистка не завершена",
"Кластер не удалён из CephDeploy, потому что очистка на серверах "
f"завершилась с кодом {exit_code}.\n\n"
f"Рабочий каталог: {deploy_dir}\n\n"
f"Последние строки вывода:\n{log_tail}",
)
def _set_delete_busy(self, busy: bool) -> None:
self._btn_create.setEnabled(not busy)
self._btn_delete_cluster.setEnabled(
not busy and self._selected_cluster_id is not None
)
self._server_table.setEnabled(not busy)
self._cluster_table.setEnabled(not busy)
self._btn_delete_cluster.setText(
"Очистка..." if busy else "✕ Удалить кластер"
)
def _on_delete_server(self, server_id: int) -> None: def _on_delete_server(self, server_id: int) -> None:
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Удалить сервер?", self, "Удалить сервер?",
......
...@@ -5,12 +5,13 @@ ...@@ -5,12 +5,13 @@
from __future__ import annotations from __future__ import annotations
import os import os
import shlex
import subprocess import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from PyQt6.QtCore import Qt, QProcess from PyQt6.QtCore import Qt, QProcess, QProcessEnvironment
from PyQt6.QtGui import QColor, QFont, QTextCursor from PyQt6.QtGui import QColor, QFont, QTextCursor
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
...@@ -65,6 +66,50 @@ _TABLE_STYLE = """ ...@@ -65,6 +66,50 @@ _TABLE_STYLE = """
_CHECK_COLS = ["Сервер", "IP-адрес", "Роль", "OSD-дисков", "Готовность"] _CHECK_COLS = ["Сервер", "IP-адрес", "Роль", "OSD-дисков", "Готовность"]
_CEPH_GUARD_SCRIPT = r"""
evidence=0
if [ -s /etc/ceph/ceph.conf ]; then
echo "/etc/ceph/ceph.conf"
evidence=1
fi
if [ -d /var/lib/ceph ] && timeout 3 find /var/lib/ceph -mindepth 1 -maxdepth 2 -print -quit 2>/dev/null | grep -q .; then
echo "/var/lib/ceph contains data"
evidence=1
fi
if command -v cephadm >/dev/null 2>&1 && timeout 3 cephadm ls 2>/dev/null | grep -q '"fsid"'; then
echo "cephadm cluster metadata exists"
evidence=1
fi
if command -v systemctl >/dev/null 2>&1 && timeout 3 systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | grep -q .; then
echo "ceph systemd units exist"
evidence=1
fi
if command -v lvs >/dev/null 2>&1 && timeout 3 lvs --noheadings -o lv_tags 2>/dev/null | grep -q 'ceph'; then
echo "Ceph LVM tags exist"
evidence=1
fi
if command -v podman >/dev/null 2>&1 && timeout 3 podman ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph podman containers exist"
evidence=1
fi
if command -v docker >/dev/null 2>&1 && timeout 3 docker ps -a --filter name=ceph- -q 2>/dev/null | grep -q .; then
echo "ceph docker containers exist"
evidence=1
fi
exit "$evidence"
""".strip()
_CEPH_IMAGES = {
"quincy": "quay.io/ceph/ceph:v17",
"reef": "quay.io/ceph/ceph:v18",
"squid": "quay.io/ceph/ceph:v19",
}
def _ceph_image_for_version(version: str) -> str:
normalized = (version or "").strip().lower()
return _CEPH_IMAGES.get(normalized, "quay.io/ceph/ceph:v19")
def _plain_item(text: str) -> QTableWidgetItem: def _plain_item(text: str) -> QTableWidgetItem:
item = QTableWidgetItem(text) item = QTableWidgetItem(text)
...@@ -268,8 +313,9 @@ class DeployWidget(BasePage): ...@@ -268,8 +313,9 @@ class DeployWidget(BasePage):
osd_count = len(list_osd_devices(session, srv.id)) osd_count = len(list_osd_devices(session, srv.id))
ssh_ok = self._tcp_check(srv.ip_address) ssh_ok = self._tcp_check(srv.ip_address)
ceph_check = self._detect_existing_ceph(srv) if ssh_ok else ("unknown", "")
needs_osd = srv.role.value in ("osd", "all") needs_osd = srv.role.value in ("osd", "all")
ready = ssh_ok and (not needs_osd or osd_count > 0) ready = ssh_ok and ceph_check[0] == "clean" and (not needs_osd or osd_count > 0)
if not ready: if not ready:
all_ready = False all_ready = False
...@@ -285,6 +331,16 @@ class DeployWidget(BasePage): ...@@ -285,6 +331,16 @@ class DeployWidget(BasePage):
self._check_table.setItem(row, 4, _status_item("✔ Готов", "#4caf50")) self._check_table.setItem(row, 4, _status_item("✔ Готов", "#4caf50"))
elif not ssh_ok: elif not ssh_ok:
self._check_table.setItem(row, 4, _status_item("✘ SSH недоступен", "#ef5350")) self._check_table.setItem(row, 4, _status_item("✘ SSH недоступен", "#ef5350"))
elif ceph_check[0] == "exists":
self._check_table.setItem(row, 4, _status_item("✘ Ceph уже есть", "#ef5350"))
self._log_line(
f"✘ На {srv.hostname} ({srv.ip_address}) уже обнаружен Ceph: {ceph_check[1]}"
)
elif ceph_check[0] == "unknown":
self._check_table.setItem(row, 4, _status_item("✘ Ceph не проверен", "#ef5350"))
self._log_line(
f"✘ Не удалось проверить Ceph на {srv.hostname} ({srv.ip_address}): {ceph_check[1]}"
)
else: else:
self._check_table.setItem(row, 4, _status_item("⚠ Нет OSD", "#ffb74d")) self._check_table.setItem(row, 4, _status_item("⚠ Нет OSD", "#ffb74d"))
...@@ -315,6 +371,47 @@ class DeployWidget(BasePage): ...@@ -315,6 +371,47 @@ class DeployWidget(BasePage):
except OSError: except OSError:
return False return False
@staticmethod
def _detect_existing_ceph(srv) -> tuple[str, str]:
"""Возвращает ('clean'|'exists'|'unknown', детали) для защиты от установки поверх Ceph."""
key_path = str(Path(srv.ssh_key_path).expanduser())
remote = f"{srv.ssh_user}@{srv.ip_address}"
quoted_script = shlex.quote(_CEPH_GUARD_SCRIPT)
cmd = [
"ssh",
"-x",
"-o", "BatchMode=yes",
"-o", "ForwardX11=no",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=no",
"-i", key_path,
remote,
f"timeout 20 sudo -n bash -c {quoted_script}",
]
try:
result = subprocess.run(
cmd,
text=True,
capture_output=True,
timeout=25,
check=False,
)
except subprocess.TimeoutExpired:
return "unknown", "проверка Ceph превысила таймаут 25 секунд"
except OSError as exc:
return "unknown", str(exc)
details = "\n".join(
line.strip()
for line in (result.stdout + "\n" + result.stderr).splitlines()
if line.strip()
)
if result.returncode == 0:
return "clean", ""
if result.returncode == 1 and result.stdout.strip():
return "exists", "; ".join(result.stdout.splitlines())
return "unknown", details or f"ssh/sudo завершился с кодом {result.returncode}"
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Генерация конфигурации Ansible # Генерация конфигурации Ansible
# ------------------------------------------------------------------ # ------------------------------------------------------------------
...@@ -361,6 +458,7 @@ class DeployWidget(BasePage): ...@@ -361,6 +458,7 @@ class DeployWidget(BasePage):
Path(play_path).write_text( Path(play_path).write_text(
env.get_template("ceph_bootstrap.yml.j2").render( env.get_template("ceph_bootstrap.yml.j2").render(
cluster={"name": cluster.name, "version": cluster.ceph_version}, cluster={"name": cluster.name, "version": cluster.ceph_version},
ceph_image=_ceph_image_for_version(cluster.ceph_version),
servers=servers_data, servers=servers_data,
bootstrap_host=next( bootstrap_host=next(
(s for s in servers_data if s["role"] in ("mon", "all")), (s for s in servers_data if s["role"] in ("mon", "all")),
...@@ -412,6 +510,11 @@ class DeployWidget(BasePage): ...@@ -412,6 +510,11 @@ class DeployWidget(BasePage):
def _spawn_ansible(self, inv: str, play: str) -> None: def _spawn_ansible(self, inv: str, play: str) -> None:
"""Запускает ansible-playbook через QProcess, используется и deploy, и cleanup.""" """Запускает ansible-playbook через QProcess, используется и deploy, и cleanup."""
self._process = QProcess() self._process = QProcess()
env = QProcessEnvironment.systemEnvironment()
env.insert("ANSIBLE_PIPELINING", "True")
env.insert("ANSIBLE_FORKS", "10")
env.insert("ANSIBLE_HOST_KEY_CHECKING", "False")
self._process.setProcessEnvironment(env)
self._process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) self._process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
self._process.readyReadStandardOutput.connect(self._on_output) self._process.readyReadStandardOutput.connect(self._on_output)
self._process.finished.connect(self._on_finished) self._process.finished.connect(self._on_finished)
......
...@@ -24,8 +24,9 @@ from PyQt6.QtWidgets import ( ...@@ -24,8 +24,9 @@ from PyQt6.QtWidgets import (
_CHAPTERS: list[tuple[str, str]] = [ _CHAPTERS: list[tuple[str, str]] = [
("Обзор программы", """ ("Обзор программы", """
<h2>CephDeploy — обзор программы</h2> <h2>CephDeploy — обзор программы</h2>
<p><b>CephDeploy</b> — десктопное приложение для автоматизированной установки и <p><b>CephDeploy</b> — десктопное приложение для подготовки серверов,
мониторинга кластера <b>Ceph Reef</b> на узлах под управлением ALT Linux.</p> автоматизированного развёртывания Ceph через cephadm, проверки работы
хранилища и анализа метрик внешнего мониторинга.</p>
<h3>Основные возможности</h3> <h3>Основные возможности</h3>
<ul> <ul>
...@@ -34,6 +35,9 @@ _CHAPTERS: list[tuple[str, str]] = [ ...@@ -34,6 +35,9 @@ _CHAPTERS: list[tuple[str, str]] = [
<li>Назначение OSD-дисков серверам</li> <li>Назначение OSD-дисков серверам</li>
<li>Автоматическое развёртывание Ceph через Ansible (cephadm)</li> <li>Автоматическое развёртывание Ceph через Ansible (cephadm)</li>
<li>Live-мониторинг состояния кластера</li> <li>Live-мониторинг состояния кластера</li>
<li>Развёртывание внешнего стека Prometheus / Grafana / Alertmanager в LXC на PVE</li>
<li>Проверка записи, чтения и репликации через RADOS smoke-test</li>
<li>Анализ нагрузки, задержек и трендов по данным Prometheus</li>
<li>История запусков и экспорт отчёта в HTML</li> <li>История запусков и экспорт отчёта в HTML</li>
</ul> </ul>
...@@ -52,9 +56,17 @@ _CHAPTERS: list[tuple[str, str]] = [ ...@@ -52,9 +56,17 @@ _CHAPTERS: list[tuple[str, str]] = [
<li>Найдите серверы через <b>Сканер сети</b> и добавьте их в кластер</li> <li>Найдите серверы через <b>Сканер сети</b> и добавьте их в кластер</li>
<li>Назначьте OSD-диски на странице <b>OSD</b></li> <li>Назначьте OSD-диски на странице <b>OSD</b></li>
<li>Запустите установку на странице <b>Развёртывание</b></li> <li>Запустите установку на странице <b>Развёртывание</b></li>
<li>Проверяйте ход установки в <b>Журнале</b></li>
<li>Следите за состоянием на странице <b>Состояние</b></li> <li>Следите за состоянием на странице <b>Состояние</b></li>
<li>Разверните внешний мониторинг через меню <b>Мониторинг</b> и смотрите метрики в <b>Анализе</b></li>
<li>Запустите smoke-test на странице <b>Проверка</b></li>
<li>Экспортируйте отчёт через страницу <b>Отчёт</b></li> <li>Экспортируйте отчёт через страницу <b>Отчёт</b></li>
</ol> </ol>
<h3>Порядок вкладок</h3>
<p>Основная навигация расположена слева: <b>Кластеры</b>, <b>Сканер сети</b>,
<b>OSD</b>, <b>Развёртывание</b>, <b>Журнал</b>, <b>Состояние</b>,
<b>Анализ</b>, <b>Отчёт</b>, <b>Проверка</b>, <b>Настройки</b>.</p>
"""), """),
("Кластеры", """ ("Кластеры", """
...@@ -144,6 +156,26 @@ _CHAPTERS: list[tuple[str, str]] = [ ...@@ -144,6 +156,26 @@ _CHAPTERS: list[tuple[str, str]] = [
<tr><td><b>DB</b></td><td>BlueStore DB — индексы и метаданные (SSD/NVMe)</td></tr> <tr><td><b>DB</b></td><td>BlueStore DB — индексы и метаданные (SSD/NVMe)</td></tr>
</table> </table>
<h3>Применение к уже работающему кластеру</h3>
<p>Добавление устройства во вкладке <b>OSD</b> сначала сохраняет диск в
локальную конфигурацию CephDeploy. Чтобы прицепить этот диск к уже
развёрнутому кластеру без полного повторного развёртывания, нажмите
<b>Прицепить</b> в строке устройства.</p>
<p>CephDeploy подключится к MON/ALL-узлу выбранного кластера и выполнит
команду <code>ceph orch daemon add osd &lt;hostname&gt;:&lt;device&gt;</code>.
Hostname целевого узла определяется по SSH с самого сервера, чтобы команда
совпадала с именем хоста в cephadm-оркестраторе.</p>
<p><b>Важно:</b> операция применяется к живому кластеру и может уничтожить
данные на выбранном диске. Используйте только свободные несистемные диски.</p>
<h3>Удаление OSD</h3>
<p>Кнопка <b>Отцепить</b> ищет OSD по hostname и имени диска в метаданных
Ceph и запускает штатную команду <code>ceph orch osd rm &lt;id&gt; --zap</code>.
Ceph может выполнять перенос данных не мгновенно; если удаление небезопасно,
команда будет отклонена самим кластером.</p>
<p>Красная кнопка <b>✕</b> удаляет только запись из локальной конфигурации
CephDeploy. Она не удаляет OSD из живого кластера.</p>
<h3>Рекомендации по конфигурации</h3> <h3>Рекомендации по конфигурации</h3>
<ul> <ul>
<li>Минимальная конфигурация: 1–2 диска DATA на узел</li> <li>Минимальная конфигурация: 1–2 диска DATA на узел</li>
...@@ -215,6 +247,17 @@ _CHAPTERS: list[tuple[str, str]] = [ ...@@ -215,6 +247,17 @@ _CHAPTERS: list[tuple[str, str]] = [
<li>На серверах: root/sudo доступ, Python 3, доступ к репозиторию Ceph</li> <li>На серверах: root/sudo доступ, Python 3, доступ к репозиторию Ceph</li>
<li>Сеть: все узлы должны быть доступны по SSH с управляющей машины</li> <li>Сеть: все узлы должны быть доступны по SSH с управляющей машины</li>
</ul> </ul>
<h3>Защита от повторного развёртывания</h3>
<p>Перед bootstrap плейбук проверяет, не установлен ли уже Ceph на выбранном
узле. Если на машине остались контейнеры, юниты или каталог
<code>/var/lib/ceph</code> от прошлой попытки, сначала выполните очистку.</p>
<h3>Версия и образ Ceph</h3>
<p>Версия берётся из профиля кластера. Если в шаблоне указан отдельный
container image, он передаётся cephadm до команды <code>bootstrap</code>.
Для тестовых стендов допускается несовпадение релиза cephadm и образа,
когда это явно разрешено в шаблоне развёртывания.</p>
"""), """),
("Состояние кластера", """ ("Состояние кластера", """
...@@ -243,14 +286,36 @@ _CHAPTERS: list[tuple[str, str]] = [ ...@@ -243,14 +286,36 @@ _CHAPTERS: list[tuple[str, str]] = [
<h3>Авто-обновление</h3> <h3>Авто-обновление</h3>
<p>Установите интервал в поле <b>Авто (с)</b> для автоматического обновления. <p>Установите интервал в поле <b>Авто (с)</b> для автоматического обновления.
Значение <b>0</b> отключает авто-обновление. Рекомендуется 30–60 секунд.</p> Значение <b>0</b> отключает авто-обновление. Рекомендуется 30–60 секунд.</p>
<h3>Когда использовать этот раздел</h3>
<p><b>Состояние</b> подходит для оперативной проверки текущего кластера:
health, ёмкость, OSD tree, PG и вывод команд Ceph. Исторические графики,
нагрузка и тренды находятся на странице <b>Анализ</b> и требуют Prometheus.</p>
"""), """),
("Анализ функционирования", """ ("Анализ функционирования", """
<h2>Анализ функционирования кластера</h2> <h2>Анализ функционирования кластера</h2>
<p>Страница <b>Анализ</b> опрашивает внешний <b>Prometheus</b>, в котором <p>Страница <b>Анализ</b> опрашивает внешний <b>Prometheus</b>, в котором
хранятся метрики ceph-mgr exporter, и показывает исторические показатели хранятся метрики ceph-mgr exporter, и показывает исторические показатели
кластера. URL Prometheus задаётся в <b>Настройки → Внешний мониторинг</b>, кластера. URL Prometheus можно выбрать или изменить прямо на странице
сам Prometheus разворачивается через меню <b>Мониторинг → Развернуть…</b>.</p> анализа, а также сохранить в <b>Настройки → Внешний мониторинг</b>.
Сам Prometheus разворачивается через меню <b>Мониторинг → Развернуть…</b>.</p>
<h3>Источник данных и автообновление</h3>
<ul>
<li><b>Источник Prometheus</b> — выпадающий список с сохранённым URL и
возможностью ввести другой адрес вручную.</li>
<li><b>Применить</b> — сохраняет выбранный источник и сразу обновляет данные.</li>
<li>Если вкладка <b>Анализ</b> открыта, данные обновляются автоматически
примерно каждые 2 секунды. Если предыдущий запрос ещё выполняется,
следующий пропускается.</li>
</ul>
<h3>Определение кластера</h3>
<p>Блок <b>Определение кластера</b> показывает, какие targets Prometheus
собирает для job <code>ceph</code>, и сопоставляет IP-адреса targets с
серверами из локальной базы CephDeploy. Так можно понять, метрики какого
кластера сейчас отображаются в анализе.</p>
<h3>Карточки KPI</h3> <h3>Карточки KPI</h3>
<ul> <ul>
...@@ -258,19 +323,22 @@ _CHAPTERS: list[tuple[str, str]] = [ ...@@ -258,19 +323,22 @@ _CHAPTERS: list[tuple[str, str]] = [
OK / WARN / ERR.</li> OK / WARN / ERR.</li>
<li><b>OSD</b> — число up / in OSD-демонов.</li> <li><b>OSD</b> — число up / in OSD-демонов.</li>
<li><b>MON в кворуме</b> — сколько мониторов в кворуме.</li> <li><b>MON в кворуме</b> — сколько мониторов в кворуме.</li>
<li><b>IOPS</b> — <code>rate(ceph_osd_op[1m])</code>, суммарно по кластеру.</li> <li><b>IOPS</b> — скорость операций по метрикам Ceph за короткое окно.</li>
<li><b>Чтение / Запись</b> — <code>rate(ceph_osd_op_r_out_bytes[1m])</code> <li><b>Чтение / Запись</b> — скорость чтения и записи по pool-метрикам Ceph.</li>
и <code>...w_in_bytes[1m]</code>.</li>
</ul> </ul>
<h3>График пропускной способности</h3> <h3>График пропускной способности</h3>
<p>Линия записи (красная) и чтения (синяя) за выбранный период (1ч / 6ч / 24ч). <p>Линия записи и чтения строится за выбранный период. Доступны короткие
интервалы для тестов и диагностики: <b>1 мин</b>, <b>5 мин</b>,
<b>30 мин</b>, <b>1 час</b>, <b>6 часов</b>, <b>24 часа</b>.
Используется <code>query_range</code> Prometheus-API с подходящим шагом.</p> Используется <code>query_range</code> Prometheus-API с подходящим шагом.</p>
<h3>Таблица OSD и «предиктивное обслуживание»</h3> <h3>Таблица OSD и «предиктивное обслуживание»</h3>
<p>Для каждого OSD считается средняя задержка <code>ceph_osd_op_latency_sum / <p>Для каждого OSD показываются состояние, средняя задержка и
ceph_osd_op_latency_count</code> и тренд за выбранный период (относительный <b>тренд периода, %</b>. Тренд считается по выбранному интервалу как сравнение
прирост в %). Строки подсвечиваются:</p> первой и второй половины периода; пустые или нулевые точки задержки не
используются, чтобы не получать ложные значения <code>-100%</code>.</p>
<p>Строки подсвечиваются:</p>
<ul> <ul>
<li><span style="color:#ff9800"><b>оранжевым</b></span> — задержка растёт <li><span style="color:#ff9800"><b>оранжевым</b></span> — задержка растёт
более чем на 20%;</li> более чем на 20%;</li>
...@@ -291,10 +359,30 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд ...@@ -291,10 +359,30 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
<ul> <ul>
<li>Кластер (из БД) — определяет список scrape-targets.</li> <li>Кластер (из БД) — определяет список scrape-targets.</li>
<li><b>PVE-хост</b> — куда идёт SSH для <code>pct create</code>.</li> <li><b>PVE-хост</b> — куда идёт SSH для <code>pct create</code>.</li>
<li><b>VMID</b>, <b>IP / шлюз / bridge</b>, <b>хранилище</b>, <b>шаблон</b> LXC.</li> <li><b>VMID</b>, <b>IP / шлюз / bridge</b>, <b>хранилище</b>,
<b>хранилище шаблонов</b> и <b>шаблон</b> LXC.</li>
<li>Пароль <code>admin</code> для Grafana.</li> <li>Пароль <code>admin</code> для Grafana.</li>
</ul> </ul>
<h3>Получение параметров с PVE</h3>
<p>Мастер не опирается на жёстко заданные значения. Кнопка
<b>Проверить PVE</b> подключается к выбранному PVE-хосту по SSH и запрашивает:</p>
<ul>
<li>занятые VMID и ближайший свободный VMID;</li>
<li>доступные хранилища для rootfs контейнера;</li>
<li>хранилища, где лежат LXC-шаблоны;</li>
<li>список LXC-шаблонов через <code>pveam list &lt;storage&gt;</code>;</li>
<li>сетевые bridge-интерфейсы PVE.</li>
</ul>
<p>Если вручную указан занятый VMID, развёртывание остановится до создания
контейнера. При смене хранилища шаблонов список LXC-шаблонов перечитывается
именно с выбранного storage.</p>
<h3>Сетевые значения по умолчанию</h3>
<p>IP, шлюз и bridge подставляются так, чтобы контейнер был доступен из
обычной рабочей сети стенда. Перед запуском проверьте, что адрес свободен,
а gateway соответствует подсети контейнера.</p>
<h3>Что попадает внутрь контейнера</h3> <h3>Что попадает внутрь контейнера</h3>
<ul> <ul>
<li><b>/etc/prometheus/prometheus.yml</b> — job <code>ceph</code> со всеми mon/mgr-узлами (<code>:9283</code>).</li> <li><b>/etc/prometheus/prometheus.yml</b> — job <code>ceph</code> со всеми mon/mgr-узлами (<code>:9283</code>).</li>
...@@ -339,6 +427,46 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд ...@@ -339,6 +427,46 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
во временном каталоге (путь указан в столбце <b>Лог</b>).</p> во временном каталоге (путь указан в столбце <b>Лог</b>).</p>
"""), """),
("Проверка работы хранилища", """
<h2>Проверка работы хранилища</h2>
<p>Страница <b>Проверка</b> запускает короткий RADOS smoke-test: создаёт
или использует тестовый pool, записывает объект, читает его обратно,
сравнивает контрольные суммы и показывает, на какие OSD попал объект.</p>
<h3>Для чего нужна проверка</h3>
<ul>
<li>Убедиться, что кластер принимает запись после развёртывания.</li>
<li>Проверить чтение данных из pool.</li>
<li>Увидеть mapping объекта и acting set OSD.</li>
<li>Создать небольшую нагрузку, которую затем можно увидеть в Prometheus
на странице <b>Анализ</b>.</li>
</ul>
<h3>Параметры теста</h3>
<ul>
<li><b>Кластер</b> — профиль из локальной базы CephDeploy.</li>
<li><b>MON-узел</b> — сервер, через который будет выполнена команда
<code>cephadm shell</code> / <code>rados</code>.</li>
<li><b>Pool</b> — по умолчанию <code>cephdeploy-test</code>. Если pool
отсутствует, он создаётся автоматически.</li>
<li><b>Объект</b> — имя временного RADOS-объекта.</li>
<li><b>Размер</b> — объём тестовых данных для записи.</li>
<li><b>Удалить объект после проверки</b> — оставляет pool, но удаляет
тестовый объект после успешного сравнения.</li>
</ul>
<h3>Что считается успешным результатом</h3>
<p>Проверка успешна, если запись завершилась без ошибки, объект прочитан
обратно, а checksum исходного и прочитанного файла совпал. В журнале теста
также выводятся <code>ceph osd map</code>, PG pool и краткое состояние
кластера.</p>
<h3>Связь с анализом нагрузки</h3>
<p>После теста метрики записи и чтения появятся в <b>Анализе</b> только если
Prometheus уже собирает ceph-mgr exporter выбранного кластера. Для коротких
smoke-test удобно выбирать период <b>1 мин</b> или <b>5 мин</b>.</p>
"""),
("Отчёт", """ ("Отчёт", """
<h2>Отчёт</h2> <h2>Отчёт</h2>
<p>Формирование и экспорт конфигурации кластера в HTML-файл.</p> <p>Формирование и экспорт конфигурации кластера в HTML-файл.</p>
...@@ -355,9 +483,13 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд ...@@ -355,9 +483,13 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
<ul> <ul>
<li>Общая информация о кластере (название, версия Ceph, дата создания)</li> <li>Общая информация о кластере (название, версия Ceph, дата создания)</li>
<li>Список серверов с ролями и количеством OSD-дисков</li> <li>Список серверов с ролями и количеством OSD-дисков</li>
<li>Фактически обнаруженные диски на серверах на момент формирования отчёта</li>
<li>Детальная таблица OSD-устройств по всем серверам</li> <li>Детальная таблица OSD-устройств по всем серверам</li>
<li>История последних 20 запусков развёртывания</li> <li>История последних 20 запусков развёртывания</li>
</ul> </ul>
<p>При формировании отчёта CephDeploy подключается к серверам по SSH и
перечитывает <code>lsblk</code>, поэтому новые диски VM появляются в отчёте
даже до назначения их как OSD.</p>
<p>Отчёт удобно прикладывать к технической документации или ВКР.</p> <p>Отчёт удобно прикладывать к технической документации или ВКР.</p>
"""), """),
...@@ -401,8 +533,12 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд ...@@ -401,8 +533,12 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
<ul> <ul>
<li><b>URL Prometheus / Grafana / Alertmanager</b> — проставляются автоматически <li><b>URL Prometheus / Grafana / Alertmanager</b> — проставляются автоматически
после успешного развёртывания через меню Мониторинг.</li> после успешного развёртывания через меню Мониторинг.</li>
<li><b>PVE-хост, VMID, IP, шлюз, bridge, хранилище, шаблон</b> — параметры <li><b>PVE-хост, VMID, IP, шлюз, bridge, хранилище, хранилище шаблонов,
создаваемого LXC-контейнера. Шаблон по умолчанию — ALT p11.</li> шаблон</b> — последние использованные параметры создаваемого
LXC-контейнера.</li>
<li>Актуальные значения VMID, storage, template storage, LXC templates и
bridge лучше получать кнопкой <b>Проверить PVE</b> в мастере
развёртывания мониторинга.</li>
</ul> </ul>
<p>Настройки хранятся в <code>~/.config/cephdeploy/settings.json</code>.</p> <p>Настройки хранятся в <code>~/.config/cephdeploy/settings.json</code>.</p>
......
...@@ -31,6 +31,7 @@ from ui.osd_widget import OSDWidget ...@@ -31,6 +31,7 @@ from ui.osd_widget import OSDWidget
from ui.report_widget import ReportWidget from ui.report_widget import ReportWidget
from ui.help_window import HelpWindow from ui.help_window import HelpWindow
from ui.settings_widget import SettingsWidget from ui.settings_widget import SettingsWidget
from ui.storage_test_widget import StorageTestWidget
from ui.status_widget import StatusWidget from ui.status_widget import StatusWidget
...@@ -41,25 +42,27 @@ from ui.status_widget import StatusWidget ...@@ -41,25 +42,27 @@ from ui.status_widget import StatusWidget
_NAV_ITEMS: list[tuple[str, str]] = [ _NAV_ITEMS: list[tuple[str, str]] = [
("🖥️ Кластеры", "Список профилей кластеров"), ("🖥️ Кластеры", "Список профилей кластеров"),
("🔍 Сканер сети", "Поиск серверов в подсети"), ("🔍 Сканер сети", "Поиск серверов в подсети"),
("💾 OSD", "Управление дисками OSD"),
("🚀 Развёртывание", "Мастер установки Ceph"), ("🚀 Развёртывание", "Мастер установки Ceph"),
("📜 Журнал", "История запусков"),
("📊 Состояние", "Дашборд кластера"), ("📊 Состояние", "Дашборд кластера"),
("📈 Анализ", "Анализ функционирования кластера"), ("📈 Анализ", "Анализ функционирования кластера"),
("💾 OSD", "Управление дисками OSD"),
("📜 Журнал", "История запусков"),
("📄 Отчёт", "Экспорт в HTML"), ("📄 Отчёт", "Экспорт в HTML"),
("🧪 Проверка", "Проверка записи и чтения из хранилища"),
("⚙️ Настройки", "Параметры приложения"), ("⚙️ Настройки", "Параметры приложения"),
] ]
# Индексы страниц в _NAV_ITEMS # Индексы страниц в _NAV_ITEMS
_CLUSTERS_PAGE_IDX = 0 _CLUSTERS_PAGE_IDX = 0
_SCAN_PAGE_IDX = 1 _SCAN_PAGE_IDX = 1
_DEPLOY_PAGE_IDX = 2 _OSD_PAGE_IDX = 2
_STATUS_PAGE_IDX = 3 _DEPLOY_PAGE_IDX = 3
_ANALYSIS_PAGE_IDX = 4 _LOG_PAGE_IDX = 4
_OSD_PAGE_IDX = 5 _STATUS_PAGE_IDX = 5
_LOG_PAGE_IDX = 6 _ANALYSIS_PAGE_IDX = 6
_REPORT_PAGE_IDX = 7 _REPORT_PAGE_IDX = 7
_SETTINGS_PAGE_IDX = 8 _STORAGE_TEST_PAGE_IDX = 8
_SETTINGS_PAGE_IDX = 9
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
...@@ -162,6 +165,8 @@ class MainWindow(QMainWindow): ...@@ -162,6 +165,8 @@ class MainWindow(QMainWindow):
page = DeployWidget() page = DeployWidget()
elif i == _STATUS_PAGE_IDX: elif i == _STATUS_PAGE_IDX:
page = StatusWidget() page = StatusWidget()
elif i == _STORAGE_TEST_PAGE_IDX:
page = StorageTestWidget()
elif i == _ANALYSIS_PAGE_IDX: elif i == _ANALYSIS_PAGE_IDX:
page = AnalysisWidget() page = AnalysisWidget()
elif i == _OSD_PAGE_IDX: elif i == _OSD_PAGE_IDX:
......
...@@ -9,6 +9,8 @@ PVE-узле и по успеху записывает URL'ы в AppConfig — ...@@ -9,6 +9,8 @@ PVE-узле и по успеху записывает URL'ы в AppConfig —
from __future__ import annotations from __future__ import annotations
import os import os
import re
import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
...@@ -76,11 +78,21 @@ class MonitoringDialog(QDialog): ...@@ -76,11 +78,21 @@ class MonitoringDialog(QDialog):
form.addRow("Кластер:", self._cluster_combo) form.addRow("Кластер:", self._cluster_combo)
self._pve_host = QLineEdit() self._pve_host = QLineEdit()
form.addRow("PVE-хост:", self._pve_host) pve_row = QHBoxLayout()
pve_row.addWidget(self._pve_host, stretch=1)
self._btn_probe = QPushButton("Проверить PVE")
self._btn_probe.clicked.connect(self._refresh_pve_options)
pve_row.addWidget(self._btn_probe)
form.addRow("PVE-хост:", pve_row)
self._vmid = QSpinBox() self._vmid = QSpinBox()
self._vmid.setRange(100, 999999) self._vmid.setRange(100, 999999)
form.addRow("VMID контейнера:", self._vmid) vmid_row = QHBoxLayout()
vmid_row.addWidget(self._vmid)
self._btn_next_vmid = QPushButton("Свободный")
self._btn_next_vmid.clicked.connect(self._fill_next_vmid)
vmid_row.addWidget(self._btn_next_vmid)
form.addRow("VMID контейнера:", vmid_row)
self._ct_ip = QLineEdit() self._ct_ip = QLineEdit()
form.addRow("IP контейнера:", self._ct_ip) form.addRow("IP контейнера:", self._ct_ip)
...@@ -88,13 +100,21 @@ class MonitoringDialog(QDialog): ...@@ -88,13 +100,21 @@ class MonitoringDialog(QDialog):
self._ct_gw = QLineEdit() self._ct_gw = QLineEdit()
form.addRow("Шлюз:", self._ct_gw) form.addRow("Шлюз:", self._ct_gw)
self._ct_bridge = QLineEdit() self._ct_bridge = QComboBox()
self._ct_bridge.setEditable(True)
form.addRow("Bridge:", self._ct_bridge) form.addRow("Bridge:", self._ct_bridge)
self._ct_storage = QLineEdit() self._ct_storage = QComboBox()
self._ct_storage.setEditable(True)
form.addRow("Хранилище:", self._ct_storage) form.addRow("Хранилище:", self._ct_storage)
self._ct_template = QLineEdit() self._template_storage = QComboBox()
self._template_storage.setEditable(True)
self._template_storage.currentTextChanged.connect(self._on_template_storage_changed)
form.addRow("Хранилище шаблонов:", self._template_storage)
self._ct_template = QComboBox()
self._ct_template.setEditable(True)
form.addRow("Шаблон LXC:", self._ct_template) form.addRow("Шаблон LXC:", self._ct_template)
self._grafana_pw = QLineEdit() self._grafana_pw = QLineEdit()
...@@ -153,16 +173,244 @@ class MonitoringDialog(QDialog): ...@@ -153,16 +173,244 @@ class MonitoringDialog(QDialog):
f"{c.name} [{c.ceph_version}]", userData=c.id f"{c.name} [{c.ceph_version}]", userData=c.id
) )
self._pve_host.setText(AppConfig.get("monitoring_pve_host")) self._pve_host.setText(AppConfig.get("monitoring_pve_host"))
self._vmid.setValue(int(AppConfig.get("monitoring_vmid"))) self._vmid.setValue(self._vmid.minimum())
self._ct_ip.setText(AppConfig.get("monitoring_ct_ip")) self._ct_ip.clear()
self._ct_gw.setText(AppConfig.get("monitoring_ct_gw")) self._ct_gw.clear()
self._ct_bridge.setText(AppConfig.get("monitoring_ct_bridge")) self._set_combo_items(self._ct_bridge, [], "")
self._ct_storage.setText(AppConfig.get("monitoring_ct_storage")) self._set_combo_items(self._ct_storage, [], "")
self._ct_template.setText(AppConfig.get("monitoring_ct_template")) self._set_combo_items(self._template_storage, [], "")
self._set_combo_items(self._ct_template, [], "")
self._grafana_pw.setText(AppConfig.get("grafana_password")) self._grafana_pw.setText(AppConfig.get("grafana_password"))
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@staticmethod
def _combo_text(combo: QComboBox) -> str:
return combo.currentText().strip()
@staticmethod
def _set_combo_items(combo: QComboBox, items: list[str], current: str = "") -> None:
seen: set[str] = set()
cleaned: list[str] = []
for item in items:
item = (item or "").strip()
if item and item not in seen:
cleaned.append(item)
seen.add(item)
combo.blockSignals(True)
combo.clear()
combo.addItems(cleaned)
if current:
idx = combo.findText(current)
if idx < 0:
combo.insertItem(0, current)
idx = 0
combo.setCurrentIndex(idx)
combo.blockSignals(False)
def _run_pve_command(self, remote_cmd: str, timeout: int = 20) -> subprocess.CompletedProcess[str]:
pve_host = self._pve_host.text().strip()
if not pve_host:
raise RuntimeError("Укажите PVE-хост.")
ssh_user = AppConfig.get("ssh_user") or "root"
ssh_key = str(Path(AppConfig.get("ssh_key_path") or "~/.ssh/id_ed25519").expanduser())
return subprocess.run(
[
"ssh",
"-x",
"-T",
"-o", "BatchMode=yes",
"-o", "ForwardX11=no",
"-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=no",
"-i", ssh_key,
f"{ssh_user}@{pve_host}",
remote_cmd,
],
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
def _pve_stdout(self, remote_cmd: str, timeout: int = 20) -> str:
result = self._run_pve_command(remote_cmd, timeout=timeout)
if result.returncode != 0:
details = (result.stderr or result.stdout or "").strip()
raise RuntimeError(details or f"ssh завершился с кодом {result.returncode}")
return result.stdout.strip()
def _fill_next_vmid(self) -> None:
try:
raw = self._pve_stdout("sudo -n pvesh get /cluster/nextid", timeout=15)
match = re.search(r"\d+", raw)
if not match:
raise RuntimeError(f"Не удалось разобрать VMID из ответа: {raw}")
self._vmid.setValue(int(match.group(0)))
self._append(f"VMID: выбран свободный {self._vmid.value()}\n")
except Exception as exc:
QMessageBox.warning(self, "VMID", f"Не удалось получить свободный VMID:\n{exc}")
def _refresh_pve_options(self) -> None:
self._append("Проверяю PVE-хост и загружаю доступные параметры...\n")
try:
probe = self._pve_stdout(
r"""
set -e
echo __NEXTID__
sudo -n pvesh get /cluster/nextid
echo __ROOTDIR_STORAGES__
timeout 25 sudo -n pvesm status 2>/dev/null | awk 'NR>1 && $3=="active" && $2!="pbs"{print $1, $2}' || true
echo __TEMPLATE_STORAGES__
timeout 25 sudo -n pvesm status --content vztmpl 2>/dev/null | awk 'NR>1 && $3=="active"{print $1}' || true
echo __BRIDGES__
for b in /sys/class/net/*/bridge; do
name=${b%/bridge}; name=${name##*/}
case "$name" in fwbr*|fwln*|fwpr*|tap*|veth*) continue;; esac
addr=$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}' | head -1)
gw=$(ip -4 route show default dev "$name" 2>/dev/null | awk '{print $3; exit}')
echo "$name ${addr:-} ${gw:-}"
done
echo __TEMPLATES__
timeout 20 sudo -n pveam available --section system 2>/dev/null | awk 'NR>1{print $2}' | grep -E 'debian-12|ubuntu-24|ubuntu-22|alt|ALT' | head -80 || true
""",
timeout=70,
)
sections = self._parse_probe_sections(probe)
nextid = self._first_int(sections.get("NEXTID", ""))
if nextid:
self._vmid.setValue(nextid)
self._append(f"VMID: выбран свободный {nextid}\n")
root_storage_rows = [line.split() for line in sections.get("ROOTDIR_STORAGES", "").splitlines()]
root_storages = [row[0] for row in root_storage_rows if row]
template_storages = sections.get("TEMPLATE_STORAGES", "").splitlines()
bridge_rows = [line.split() for line in sections.get("BRIDGES", "").splitlines()]
bridges = [row[0] for row in bridge_rows if row]
templates = sections.get("TEMPLATES", "").splitlines()
chosen_bridge = self._select_bridge(bridge_rows)
self._set_combo_items(self._ct_storage, root_storages, self._select_storage(root_storage_rows))
self._set_combo_items(
self._template_storage,
template_storages or ["local"],
template_storages[0] if template_storages else "local",
)
self._set_combo_items(self._ct_bridge, bridges, chosen_bridge[0] if chosen_bridge else "")
self._set_combo_items(self._ct_template, templates, self._select_template(templates))
if chosen_bridge:
_bridge, cidr, gw = chosen_bridge
if gw:
self._ct_gw.setText(gw)
suggested_ip = self._suggest_ct_ip(cidr, gw)
if suggested_ip:
self._ct_ip.setText(suggested_ip)
self._append("PVE: параметры обновлены.\n")
except Exception as exc:
QMessageBox.warning(self, "Проверка PVE", f"Не удалось загрузить параметры PVE:\n{exc}")
def _on_template_storage_changed(self, storage: str) -> None:
storage = storage.strip()
if not storage or not self._pve_host.text().strip():
return
try:
templates = self._templates_for_storage(storage)
self._set_combo_items(self._ct_template, templates, self._select_template(templates))
self._append(f"Шаблоны LXC обновлены для storage {storage}.\n")
except Exception as exc:
self._append(f"Не удалось обновить шаблоны LXC для {storage}: {exc}\n")
def _templates_for_storage(self, storage: str) -> list[str]:
local_raw = self._pve_stdout(
"sudo -n pveam list "
+ self._shell_quote(storage)
+ " 2>/dev/null | awk 'NR>1{n=$1; sub(/^.*vztmpl\\//, \"\", n); print n}'",
timeout=30,
)
local_templates = local_raw.splitlines()
if local_templates:
return local_templates
available_raw = self._pve_stdout(
"timeout 20 sudo -n pveam available --section system 2>/dev/null "
"| awk 'NR>1{print $2}' "
"| grep -E 'debian-12|ubuntu-24|ubuntu-22|alt|ALT' "
"| head -80 || true",
timeout=30,
)
return available_raw.splitlines()
@staticmethod
def _shell_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
@staticmethod
def _parse_probe_sections(output: str) -> dict[str, str]:
sections: dict[str, list[str]] = {}
current = ""
for line in output.splitlines():
marker = re.fullmatch(r"__([A-Z0-9_]+)__", line.strip())
if marker:
current = marker.group(1)
sections[current] = []
elif current:
sections[current].append(line)
return {key: "\n".join(value).strip() for key, value in sections.items()}
@staticmethod
def _first_int(text: str) -> int | None:
match = re.search(r"\d+", text)
return int(match.group(0)) if match else None
@staticmethod
def _select_template(templates: list[str]) -> str:
for needle in ("debian-12-standard", "ubuntu-24", "ubuntu-22"):
for template in templates:
if needle in template:
return template
return templates[0] if templates else ""
@staticmethod
def _select_storage(rows: list[list[str]]) -> str:
for preferred_type in ("zfspool", "lvmthin", "lvm", "dir"):
for row in rows:
if len(row) > 1 and row[1] == preferred_type:
return row[0]
return rows[0][0] if rows and rows[0] else ""
@staticmethod
def _select_bridge(rows: list[list[str]]) -> tuple[str, str, str] | None:
parsed = [(row[0], row[1] if len(row) > 1 else "", row[2] if len(row) > 2 else "") for row in rows if row]
for item in parsed:
if item[0] == "vmbr0":
return item
return parsed[0] if parsed else None
@staticmethod
def _suggest_ct_ip(cidr: str, gateway: str) -> str:
if not cidr or "/" not in cidr:
return ""
ip, prefix = cidr.split("/", 1)
parts = ip.split(".")
if len(parts) != 4:
return ""
host = "250"
if gateway:
gw_parts = gateway.split(".")
if len(gw_parts) == 4:
parts = gw_parts
return ".".join(parts[:3] + [host]) + f"/{prefix}"
def _validate_vmid_available(self) -> None:
vmid = self._vmid.value()
result = self._run_pve_command(
f"sudo -n pct status {vmid} >/dev/null 2>&1 || sudo -n qm status {vmid} >/dev/null 2>&1",
timeout=15,
)
if result.returncode == 0:
raise RuntimeError(f"VMID {vmid} уже занят. Нажмите «Свободный» или укажите другой VMID.")
def _append(self, text: str) -> None: def _append(self, text: str) -> None:
self._log.moveCursor(self._log.textCursor().MoveOperation.End) self._log.moveCursor(self._log.textCursor().MoveOperation.End)
self._log.insertPlainText(text) self._log.insertPlainText(text)
...@@ -194,11 +442,32 @@ class MonitoringDialog(QDialog): ...@@ -194,11 +442,32 @@ class MonitoringDialog(QDialog):
"vmid": self._vmid.value(), "vmid": self._vmid.value(),
"ct_ip": self._ct_ip.text().strip(), "ct_ip": self._ct_ip.text().strip(),
"ct_gw": self._ct_gw.text().strip(), "ct_gw": self._ct_gw.text().strip(),
"ct_bridge": self._ct_bridge.text().strip(), "ct_bridge": self._combo_text(self._ct_bridge),
"ct_storage": self._ct_storage.text().strip(), "ct_storage": self._combo_text(self._ct_storage),
"ct_template": self._ct_template.text().strip(), "template_storage": self._combo_text(self._template_storage),
"ct_template": self._combo_text(self._ct_template),
"grafana_password": self._grafana_pw.text() or "admin", "grafana_password": self._grafana_pw.text() or "admin",
} }
missing = [
label
for label, value in (
("PVE-хост", monitoring["pve_host"]),
("IP контейнера", monitoring["ct_ip"]),
("шлюз", monitoring["ct_gw"]),
("bridge", monitoring["ct_bridge"]),
("хранилище", monitoring["ct_storage"]),
("хранилище шаблонов", monitoring["template_storage"]),
("шаблон LXC", monitoring["ct_template"]),
)
if not value
]
if missing:
raise RuntimeError(
"Не заполнены параметры: "
+ ", ".join(missing)
+ ". Нажмите «Проверить PVE» для автозаполнения."
)
self._validate_vmid_available()
deploy_dir = tempfile.mkdtemp(prefix="cephdeploy_monitoring_") deploy_dir = tempfile.mkdtemp(prefix="cephdeploy_monitoring_")
env = Environment( env = Environment(
...@@ -304,9 +573,10 @@ class MonitoringDialog(QDialog): ...@@ -304,9 +573,10 @@ class MonitoringDialog(QDialog):
AppConfig.set_value("monitoring_vmid", self._vmid.value()) AppConfig.set_value("monitoring_vmid", self._vmid.value())
AppConfig.set_value("monitoring_ct_ip", self._ct_ip.text().strip()) AppConfig.set_value("monitoring_ct_ip", self._ct_ip.text().strip())
AppConfig.set_value("monitoring_ct_gw", self._ct_gw.text().strip()) AppConfig.set_value("monitoring_ct_gw", self._ct_gw.text().strip())
AppConfig.set_value("monitoring_ct_bridge", self._ct_bridge.text().strip()) AppConfig.set_value("monitoring_ct_bridge", self._combo_text(self._ct_bridge))
AppConfig.set_value("monitoring_ct_storage", self._ct_storage.text().strip()) AppConfig.set_value("monitoring_ct_storage", self._combo_text(self._ct_storage))
AppConfig.set_value("monitoring_ct_template", self._ct_template.text().strip()) AppConfig.set_value("monitoring_template_storage", self._combo_text(self._template_storage))
AppConfig.set_value("monitoring_ct_template", self._combo_text(self._ct_template))
try: try:
AppConfig.save() AppConfig.save()
except Exception as exc: except Exception as exc:
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import shlex
from pathlib import Path from pathlib import Path
import paramiko import paramiko
...@@ -31,7 +32,7 @@ from PyQt6.QtWidgets import ( ...@@ -31,7 +32,7 @@ from PyQt6.QtWidgets import (
from core.config import AppConfig from core.config import AppConfig
from db import SessionLocal from db import SessionLocal
from db.models import DeviceType, OSDRole from db.models import DeviceType, OSDDevice, OSDRole, Server, ServerRole
from db.repository import ( from db.repository import (
add_osd_device, add_osd_device,
delete_osd_device, delete_osd_device,
...@@ -69,7 +70,7 @@ _BOX_STYLE = ( ...@@ -69,7 +70,7 @@ _BOX_STYLE = (
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }" "QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
) )
_OSD_COLS = ["Устройство", "Тип", "Роль OSD", ""] _OSD_COLS = ["Устройство", "Тип", "Роль OSD", "Действия"]
def _server_label(hostname: str, ip: str) -> str: def _server_label(hostname: str, ip: str) -> str:
...@@ -184,6 +185,206 @@ class DiskFetchWorker(QThread): ...@@ -184,6 +185,206 @@ class DiskFetchWorker(QThread):
return mounts, fstypes, labels return mounts, fstypes, labels
class OSDApplyWorker(QThread):
"""Добавляет OSD в уже работающий кластер через cephadm/ceph orch."""
result = pyqtSignal(str)
error = pyqtSignal(str)
def __init__(
self,
mon_ip: str,
mon_user: str,
mon_key: str,
target_ip: str,
target_user: str,
target_key: str,
device_path: str,
parent=None,
) -> None:
super().__init__(parent)
self.mon_ip = mon_ip
self.mon_user = mon_user
self.mon_key = str(Path(mon_key).expanduser())
self.target_ip = target_ip
self.target_user = target_user
self.target_key = str(Path(target_key).expanduser())
self.device_path = device_path
def run(self) -> None:
target_client = paramiko.SSHClient()
mon_client = paramiko.SSHClient()
target_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
mon_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
timeout = max(20, int(AppConfig.get("scan_ssh_timeout") or 8))
target_client.connect(
self.target_ip,
username=self.target_user,
key_filename=self.target_key,
timeout=timeout,
banner_timeout=timeout,
auth_timeout=timeout,
look_for_keys=False,
allow_agent=False,
)
_, stdout, stderr = target_client.exec_command(
"hostname -s 2>/dev/null || hostname",
timeout=timeout,
)
host_name = stdout.read().decode(errors="replace").strip()
host_err = stderr.read().decode(errors="replace").strip()
if not host_name:
raise RuntimeError(host_err or "Не удалось определить hostname целевого узла")
mon_client.connect(
self.mon_ip,
username=self.mon_user,
key_filename=self.mon_key,
timeout=timeout,
banner_timeout=timeout,
auth_timeout=timeout,
look_for_keys=False,
allow_agent=False,
)
sudo = "" if self.mon_user == "root" else "sudo -n "
spec = f"{host_name}:{self.device_path}"
cmd = (
f"{sudo}/usr/sbin/cephadm shell -- ceph orch daemon add osd "
f"{shlex.quote(spec)}"
)
_, stdout, stderr = mon_client.exec_command(cmd, timeout=300)
out = stdout.read().decode(errors="replace").strip()
err = stderr.read().decode(errors="replace").strip()
rc = stdout.channel.recv_exit_status()
combined = "\n".join(part for part in (out, err) if part)
if rc != 0:
raise RuntimeError(combined or f"Команда завершилась с кодом {rc}")
self.result.emit(combined or f"OSD {spec} добавлен в оркестратор Ceph.")
except Exception as exc:
self.error.emit(str(exc))
finally:
target_client.close()
mon_client.close()
class OSDRemoveWorker(QThread):
"""Запускает штатное удаление OSD из работающего кластера."""
result = pyqtSignal(str)
error = pyqtSignal(str)
def __init__(
self,
mon_ip: str,
mon_user: str,
mon_key: str,
target_ip: str,
target_user: str,
target_key: str,
device_path: str,
parent=None,
) -> None:
super().__init__(parent)
self.mon_ip = mon_ip
self.mon_user = mon_user
self.mon_key = str(Path(mon_key).expanduser())
self.target_ip = target_ip
self.target_user = target_user
self.target_key = str(Path(target_key).expanduser())
self.device_path = device_path
def run(self) -> None:
target_client = paramiko.SSHClient()
mon_client = paramiko.SSHClient()
target_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
mon_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
timeout = max(20, int(AppConfig.get("scan_ssh_timeout") or 8))
target_client.connect(
self.target_ip,
username=self.target_user,
key_filename=self.target_key,
timeout=timeout,
banner_timeout=timeout,
auth_timeout=timeout,
look_for_keys=False,
allow_agent=False,
)
_, stdout, stderr = target_client.exec_command(
"hostname -s 2>/dev/null || hostname",
timeout=timeout,
)
host_name = stdout.read().decode(errors="replace").strip()
host_err = stderr.read().decode(errors="replace").strip()
if not host_name:
raise RuntimeError(host_err or "Не удалось определить hostname целевого узла")
mon_client.connect(
self.mon_ip,
username=self.mon_user,
key_filename=self.mon_key,
timeout=timeout,
banner_timeout=timeout,
auth_timeout=timeout,
look_for_keys=False,
allow_agent=False,
)
sudo = "" if self.mon_user == "root" else "sudo -n "
disk_name = Path(self.device_path).name
lookup_script = (
"python3 - <<'PY'\n"
"import json, subprocess, sys\n"
f"host = {host_name!r}\n"
f"disk = {disk_name!r}\n"
"raw = subprocess.check_output(['ceph', 'osd', 'metadata', '-f', 'json'], text=True)\n"
"matches = []\n"
"for row in json.loads(raw):\n"
" devices = [d.strip() for d in str(row.get('devices') or '').split(',') if d.strip()]\n"
" if row.get('hostname') == host and disk in devices:\n"
" matches.append(str(row.get('id')))\n"
"if not matches:\n"
" print(f'OSD for {host}:{disk} not found', file=sys.stderr)\n"
" sys.exit(2)\n"
"if len(matches) > 1:\n"
" print('ambiguous OSD ids: ' + ', '.join(matches), file=sys.stderr)\n"
" sys.exit(3)\n"
"print(matches[0])\n"
"PY"
)
lookup_cmd = (
f"{sudo}/usr/sbin/cephadm shell -- bash -lc "
f"{shlex.quote(lookup_script)}"
)
_, stdout, stderr = mon_client.exec_command(lookup_cmd, timeout=120)
osd_id = stdout.read().decode(errors="replace").strip()
err = stderr.read().decode(errors="replace").strip()
rc = stdout.channel.recv_exit_status()
if rc != 0 or not osd_id:
raise RuntimeError(err or f"Не удалось найти OSD для {host_name}:{disk_name}")
rm_cmd = (
f"{sudo}/usr/sbin/cephadm shell -- ceph orch osd rm "
f"{shlex.quote(osd_id)} --zap"
)
_, stdout, stderr = mon_client.exec_command(rm_cmd, timeout=300)
out = stdout.read().decode(errors="replace").strip()
err = stderr.read().decode(errors="replace").strip()
rc = stdout.channel.recv_exit_status()
combined = "\n".join(part for part in (out, err) if part)
if rc != 0:
raise RuntimeError(combined or f"Команда завершилась с кодом {rc}")
self.result.emit(
combined
or f"Запущено удаление osd.{osd_id} для {host_name}:{self.device_path}."
)
except Exception as exc:
self.error.emit(str(exc))
finally:
target_client.close()
mon_client.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Диалог добавления OSD-устройства — с выбором диска из списка # Диалог добавления OSD-устройства — с выбором диска из списка
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
...@@ -453,6 +654,8 @@ class OSDWidget(BasePage): ...@@ -453,6 +654,8 @@ class OSDWidget(BasePage):
super().__init__("💾 OSD", "Управление дисками OSD") super().__init__("💾 OSD", "Управление дисками OSD")
self._server_id: int | None = None self._server_id: int | None = None
self._server_name: str = "" self._server_name: str = ""
self._apply_worker: OSDApplyWorker | None = None
self._remove_worker: OSDRemoveWorker | None = None
self._build_content() self._build_content()
def _build_content(self) -> None: def _build_content(self) -> None:
...@@ -508,7 +711,7 @@ class OSDWidget(BasePage): ...@@ -508,7 +711,7 @@ class OSDWidget(BasePage):
) )
self._osd_table.setVisible(False) self._osd_table.setVisible(False)
self._osd_table.setStyleSheet(_TABLE_STYLE) self._osd_table.setStyleSheet(_TABLE_STYLE)
for i, w in enumerate([260, 80, 80, 50]): for i, w in enumerate([240, 80, 80, 260]):
self._osd_table.setColumnWidth(i, w) self._osd_table.setColumnWidth(i, w)
osd_layout.addWidget(self._osd_table) osd_layout.addWidget(self._osd_table)
...@@ -605,19 +808,45 @@ class OSDWidget(BasePage): ...@@ -605,19 +808,45 @@ class OSDWidget(BasePage):
self._osd_table.setItem(row, 1, _plain_item(dev.device_type.value.upper())) self._osd_table.setItem(row, 1, _plain_item(dev.device_type.value.upper()))
self._osd_table.setItem(row, 2, _plain_item(dev.osd_role.value.upper())) self._osd_table.setItem(row, 2, _plain_item(dev.osd_role.value.upper()))
btn = QPushButton("✕") btn_apply = QPushButton("Прицепить")
btn.setFixedSize(28, 24) btn_apply.setFixedHeight(24)
btn.setToolTip("Удалить устройство") btn_apply.setToolTip("Добавить этот диск в работающий Ceph-кластер")
btn.setStyleSheet( btn_apply.setStyleSheet(
"QPushButton { background: #2e7d32; color: #fff; "
"border-radius: 3px; font-size: 11px; padding: 0 8px; }"
"QPushButton:hover { background: #388e3c; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
btn_apply.clicked.connect(lambda _, did=dev.id: self._on_apply(did))
btn_remove = QPushButton("Отцепить")
btn_remove.setFixedHeight(24)
btn_remove.setToolTip("Запустить удаление этого OSD из работающего Ceph-кластера")
btn_remove.setStyleSheet(
"QPushButton { background: #ef6c00; color: #fff; "
"border-radius: 3px; font-size: 11px; padding: 0 8px; }"
"QPushButton:hover { background: #fb8c00; }"
"QPushButton:disabled { background: #333; color: #555; }"
)
btn_remove.clicked.connect(lambda _, did=dev.id: self._on_remove_live(did))
btn_delete = QPushButton("✕")
btn_delete.setFixedSize(28, 24)
btn_delete.setToolTip("Удалить только запись из конфигурации CephDeploy")
btn_delete.setStyleSheet(
"QPushButton { background: #b71c1c; color: #fff; " "QPushButton { background: #b71c1c; color: #fff; "
"border-radius: 3px; font-size: 12px; }" "border-radius: 3px; font-size: 12px; }"
"QPushButton:hover { background: #c62828; }" "QPushButton:hover { background: #c62828; }"
"QPushButton:disabled { background: #333; color: #555; }"
) )
btn.clicked.connect(lambda _, did=dev.id: self._on_delete(did)) btn_delete.clicked.connect(lambda _, did=dev.id: self._on_delete(did))
cell = QWidget() cell = QWidget()
cl = QHBoxLayout(cell) cl = QHBoxLayout(cell)
cl.setContentsMargins(3, 2, 3, 2) cl.setContentsMargins(3, 2, 3, 2)
cl.addWidget(btn) cl.setSpacing(6)
cl.addWidget(btn_apply)
cl.addWidget(btn_remove)
cl.addWidget(btn_delete)
self._osd_table.setCellWidget(row, 3, cell) self._osd_table.setCellWidget(row, 3, cell)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
...@@ -661,10 +890,196 @@ class OSDWidget(BasePage): ...@@ -661,10 +890,196 @@ class OSDWidget(BasePage):
return return
self._load_osd_devices() self._load_osd_devices()
@staticmethod
def _role_value(server: Server) -> str:
role = server.role
return role.value if hasattr(role, "value") else str(role)
def _set_apply_busy(self, busy: bool) -> None:
self._btn_add.setEnabled((not busy) and self._server_id is not None)
self._cluster_combo.setEnabled(not busy)
self._server_combo.setEnabled(not busy)
self._osd_table.setEnabled(not busy)
def _find_live_context(self, device_id: int) -> dict | None:
cluster_id = self._cluster_combo.currentData()
if cluster_id is None:
QMessageBox.warning(self, "Ошибка", "Сначала выберите кластер.")
return None
with SessionLocal() as session:
device = session.get(OSDDevice, device_id)
if device is None:
QMessageBox.critical(self, "Ошибка", "OSD-устройство не найдено в БД.")
return None
target = get_server(session, device.server_id)
if target is None:
QMessageBox.critical(self, "Ошибка", "Сервер устройства не найден в БД.")
return None
if target.cluster_id != cluster_id:
QMessageBox.warning(
self,
"Ошибка",
"Устройство относится к другому кластеру. Обновите страницу.",
)
return None
servers = list_servers(session, cluster_id)
mon = next(
(
s for s in servers
if self._role_value(s) in (ServerRole.MON.value, ServerRole.ALL.value)
),
None,
)
if mon is None:
QMessageBox.warning(
self,
"Нет MON-узла",
"Для операции с живым кластером нужен сервер с ролью mon или all.",
)
return None
return {
"device_path": device.device_path,
"target_name": _server_label(target.hostname, target.ip_address),
"target_ip": target.ip_address,
"target_user": target.ssh_user,
"target_key": target.ssh_key_path,
"mon_name": _server_label(mon.hostname, mon.ip_address),
"mon_ip": mon.ip_address,
"mon_user": mon.ssh_user,
"mon_key": mon.ssh_key_path,
}
def _on_apply(self, device_id: int) -> None:
if self._apply_worker is not None:
QMessageBox.information(
self,
"OSD уже добавляется",
"Дождитесь завершения текущей операции.",
)
return
ctx = self._find_live_context(device_id)
if ctx is None:
return
reply = QMessageBox.warning(
self,
"Прицепить диск к кластеру?",
f"Диск {ctx['device_path']} на узле {ctx['target_name']} будет добавлен в работающий "
"Ceph-кластер как OSD.\n\n"
"Ceph подготовит устройство под OSD, данные на диске могут быть уничтожены.\n\n"
f"Команда будет выполнена через MON/ALL-узел: {ctx['mon_name']}.\n\n"
"Продолжить?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self._set_apply_busy(True)
self._apply_worker = OSDApplyWorker(
mon_ip=ctx["mon_ip"],
mon_user=ctx["mon_user"],
mon_key=ctx["mon_key"],
target_ip=ctx["target_ip"],
target_user=ctx["target_user"],
target_key=ctx["target_key"],
device_path=ctx["device_path"],
)
self._apply_worker.result.connect(self._on_apply_success)
self._apply_worker.error.connect(self._on_apply_error)
self._apply_worker.start()
def _on_apply_success(self, output: str) -> None:
self._set_apply_busy(False)
self._apply_worker = None
QMessageBox.information(
self,
"OSD добавлен",
"Команда добавления OSD выполнена.\n\n" + output,
)
def _on_apply_error(self, error: str) -> None:
self._set_apply_busy(False)
self._apply_worker = None
QMessageBox.critical(
self,
"Не удалось добавить OSD",
"Команда добавления OSD завершилась ошибкой:\n\n" + error,
)
def _on_remove_live(self, device_id: int) -> None:
if self._remove_worker is not None:
QMessageBox.information(
self,
"OSD уже удаляется",
"Дождитесь завершения текущей операции.",
)
return
ctx = self._find_live_context(device_id)
if ctx is None:
return
reply = QMessageBox.warning(
self,
"Отцепить OSD от кластера?",
f"CephDeploy найдёт OSD для диска {ctx['device_path']} на узле "
f"{ctx['target_name']} и запустит штатное удаление через "
"ceph orch osd rm --zap.\n\n"
"Ceph может начать перенос данных и завершить удаление не сразу. "
"Если удаление небезопасно для текущей репликации, команда будет "
"отклонена Ceph.\n\n"
"Запись в конфигурации CephDeploy после этого останется. Удалите её "
"красной кнопкой только когда OSD действительно удалён из кластера.\n\n"
f"Команда будет выполнена через MON/ALL-узел: {ctx['mon_name']}.\n\n"
"Продолжить?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self._set_apply_busy(True)
self._remove_worker = OSDRemoveWorker(
mon_ip=ctx["mon_ip"],
mon_user=ctx["mon_user"],
mon_key=ctx["mon_key"],
target_ip=ctx["target_ip"],
target_user=ctx["target_user"],
target_key=ctx["target_key"],
device_path=ctx["device_path"],
)
self._remove_worker.result.connect(self._on_remove_success)
self._remove_worker.error.connect(self._on_remove_error)
self._remove_worker.start()
def _on_remove_success(self, output: str) -> None:
self._set_apply_busy(False)
self._remove_worker = None
QMessageBox.information(
self,
"Удаление OSD запущено",
"Команда удаления OSD выполнена.\n\n" + output,
)
def _on_remove_error(self, error: str) -> None:
self._set_apply_busy(False)
self._remove_worker = None
QMessageBox.critical(
self,
"Не удалось удалить OSD",
"Команда удаления OSD завершилась ошибкой:\n\n" + error,
)
def _on_delete(self, device_id: int) -> None: def _on_delete(self, device_id: int) -> None:
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Удалить устройство?", self, "Удалить запись из конфигурации?",
"Удалить это OSD-устройство из конфигурации?", "Удалить это OSD-устройство только из конфигурации CephDeploy?\n\n"
"Живой Ceph-кластер при этом не изменится. Если диск уже был "
"прицеплен к кластеру, сначала используйте кнопку «Отцепить».",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
......
...@@ -4,9 +4,11 @@ ...@@ -4,9 +4,11 @@
from __future__ import annotations from __future__ import annotations
import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import paramiko
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
...@@ -21,6 +23,7 @@ from PyQt6.QtWidgets import ( ...@@ -21,6 +23,7 @@ from PyQt6.QtWidgets import (
QVBoxLayout, QVBoxLayout,
) )
from core.config import AppConfig
from core.resources import get_templates_dir from core.resources import get_templates_dir
from db import SessionLocal from db import SessionLocal
from db.repository import ( from db.repository import (
...@@ -40,6 +43,79 @@ _BOX_STYLE = ( ...@@ -40,6 +43,79 @@ _BOX_STYLE = (
) )
def _collect_disk_signs(node: dict) -> tuple[list[str], list[str]]:
mounts: list[str] = []
fstypes: list[str] = []
mp = node.get("mountpoint")
fs = node.get("fstype")
if mp:
mounts.append(mp)
if fs:
fstypes.append(fs)
for child in node.get("children") or []:
child_mounts, child_fstypes = _collect_disk_signs(child)
mounts.extend(child_mounts)
fstypes.extend(child_fstypes)
return mounts, fstypes
def _fetch_server_disks(ip: str, user: str, key_path: str) -> list[dict]:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
timeout = max(10, int(AppConfig.get("scan_ssh_timeout") or 8))
client.connect(
ip,
username=user,
key_filename=str(Path(key_path).expanduser()),
timeout=timeout,
banner_timeout=timeout,
auth_timeout=timeout,
look_for_keys=False,
allow_agent=False,
)
_, stdout, _ = client.exec_command(
"lsblk -J -o NAME,SIZE,ROTA,TYPE,FSTYPE,MOUNTPOINT,LABEL 2>/dev/null",
timeout=timeout + 10,
)
raw = stdout.read().decode(errors="replace").strip()
data = json.loads(raw or "{}")
disks: list[dict] = []
for dev in data.get("blockdevices", []):
if dev.get("type") not in ("disk", "loop"):
continue
mounts, fstypes = _collect_disk_signs(dev)
if mounts:
status = "используется"
detail = ", ".join(mounts)
elif fstypes:
status = "есть ФС"
detail = ", ".join(sorted(set(fstypes)))
else:
status = "свободен"
detail = "разделы и ФС не найдены"
rota = str(dev.get("rota")) == "1" if dev.get("rota") is not None else True
kind = "LOOP" if dev.get("type") == "loop" else ("HDD" if rota else "SSD")
disks.append({
"path": f"/dev/{dev.get('name')}",
"size": dev.get("size") or "?",
"type": kind,
"status": status,
"detail": detail,
})
return disks
except Exception as exc:
return [{
"path": "—",
"size": "—",
"type": "—",
"status": "ошибка",
"detail": str(exc),
}]
finally:
client.close()
class ReportWidget(BasePage): class ReportWidget(BasePage):
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__("📄 Отчёт", "Экспорт конфигурации кластера в HTML") super().__init__("📄 Отчёт", "Экспорт конфигурации кластера в HTML")
...@@ -143,6 +219,11 @@ class ReportWidget(BasePage): ...@@ -143,6 +219,11 @@ class ReportWidget(BasePage):
"ip_address": srv.ip_address, "ip_address": srv.ip_address,
"role": srv.role.value, "role": srv.role.value,
"ssh_user": srv.ssh_user, "ssh_user": srv.ssh_user,
"disks": _fetch_server_disks(
srv.ip_address,
srv.ssh_user,
srv.ssh_key_path,
),
"osd_count": len(osds), "osd_count": len(osds),
"osds": [ "osds": [
{"path": d.device_path, {"path": d.device_path,
......
"""
Страница проверки работы Ceph-хранилища.
Запускает небольшой rados smoke-test: создаёт pool, записывает объект,
читает его обратно, сравнивает checksum и показывает состояние репликации.
"""
from __future__ import annotations
import shlex
from pathlib import Path
from PyQt6.QtCore import QProcess
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
QTextEdit,
QWidget,
)
from db import SessionLocal
from db.repository import list_clusters, list_servers
from ui.base_page import BasePage
_LOG_STYLE = (
"QTextEdit { background: #0d1117; color: #c0c8d8; "
"border: 1px solid #2e3340; border-radius: 4px; font-family: monospace; }"
)
class StorageTestWidget(BasePage):
def __init__(self) -> None:
super().__init__(
"Проверка работы хранилища",
"Запись, чтение и проверка репликации тестового объекта через rados",
)
self._process: QProcess | None = None
self._build_ui()
self.refresh()
# ------------------------------------------------------------------
def _build_ui(self) -> None:
panel = QWidget()
panel.setStyleSheet(
"QWidget { background: #202631; border: 1px solid #2e3340; "
"border-radius: 6px; }"
"QLabel { color: #c0c8d8; background: transparent; border: none; }"
"QLineEdit, QComboBox, QSpinBox { background: #111722; color: #e0e8f8; "
"border: 1px solid #3a4050; border-radius: 4px; padding: 4px 6px; }"
)
form = QFormLayout(panel)
form.setContentsMargins(14, 12, 14, 12)
form.setSpacing(8)
self._cluster_combo = QComboBox()
form.addRow("Кластер:", self._cluster_combo)
self._pool_edit = QLineEdit("cephdeploy-test")
form.addRow("Pool:", self._pool_edit)
self._object_edit = QLineEdit("cephdeploy-test-object")
form.addRow("Объект:", self._object_edit)
self._size_mb = QSpinBox()
self._size_mb.setRange(1, 1024)
self._size_mb.setValue(100)
self._size_mb.setSuffix(" MB")
form.addRow("Размер записи:", self._size_mb)
self._replicas = QSpinBox()
self._replicas.setRange(1, 10)
self._replicas.setValue(3)
form.addRow("Реплик:", self._replicas)
self._cleanup = QCheckBox("Удалить тестовый объект после проверки")
self._cleanup.setStyleSheet("background: transparent; border: none; color: #c0c8d8;")
form.addRow("", self._cleanup)
btn_row = QHBoxLayout()
self._btn_run = QPushButton("Запустить проверку")
self._btn_run.clicked.connect(self._start_test)
self._btn_stop = QPushButton("Остановить")
self._btn_stop.setEnabled(False)
self._btn_stop.clicked.connect(self._stop_test)
btn_row.addWidget(self._btn_run)
btn_row.addWidget(self._btn_stop)
btn_row.addStretch()
form.addRow("", btn_row)
hint = QLabel(
"Тест создаёт pool при необходимости, включает application=rados, "
"записывает объект, читает его обратно, сравнивает sha256 и выводит ceph -s."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: #8fbcbb; background: transparent; border: none;")
self._log = QTextEdit()
self._log.setReadOnly(True)
self._log.setStyleSheet(_LOG_STYLE)
font = QFont("Monospace")
font.setStyleHint(QFont.StyleHint.TypeWriter)
font.setPointSize(9)
self._log.setFont(font)
self.content_layout.addWidget(panel)
self.content_layout.addWidget(hint)
self.content_layout.addWidget(self._log, stretch=1)
# ------------------------------------------------------------------
def refresh(self) -> None:
current = self._cluster_combo.currentData()
self._cluster_combo.clear()
with SessionLocal() as session:
for cluster in list_clusters(session):
self._cluster_combo.addItem(
f"{cluster.name} [{cluster.ceph_version}]",
userData=cluster.id,
)
if current is not None:
idx = self._cluster_combo.findData(current)
if idx >= 0:
self._cluster_combo.setCurrentIndex(idx)
def _append(self, text: str) -> None:
self._log.moveCursor(self._log.textCursor().MoveOperation.End)
self._log.insertPlainText(text)
self._log.moveCursor(self._log.textCursor().MoveOperation.End)
def _start_test(self) -> None:
cluster_id = self._cluster_combo.currentData()
if cluster_id is None:
QMessageBox.warning(self, "Нет кластера", "В БД нет ни одного кластера.")
return
pool = self._pool_edit.text().strip()
obj = self._object_edit.text().strip()
if not pool or not obj:
QMessageBox.warning(self, "Параметры", "Укажите pool и имя объекта.")
return
with SessionLocal() as session:
servers = list_servers(session, cluster_id)
if not servers:
QMessageBox.warning(self, "Нет серверов", "В кластере нет серверов.")
return
host = next((s for s in servers if s.role.value in ("mon", "all")), servers[0])
ssh_key = str(Path(host.ssh_key_path).expanduser())
remote = f"{host.ssh_user}@{host.ip_address}"
script = self._build_remote_script(
pool=pool,
obj=obj,
size_mb=self._size_mb.value(),
replicas=self._replicas.value(),
cleanup=self._cleanup.isChecked(),
)
remote_cmd = (
"sudo -n /usr/sbin/cephadm shell -- bash -lc "
+ shlex.quote(script)
)
self._log.clear()
self._append(f"Узел запуска: {host.hostname} ({host.ip_address})\n")
self._append(f"Pool: {pool}, object: {obj}, size: {self._size_mb.value()} MB\n\n")
self._process = QProcess(self)
self._process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
self._process.readyReadStandardOutput.connect(self._on_output)
self._process.finished.connect(self._on_finished)
self._process.start(
"ssh",
[
"-x",
"-T",
"-o", "BatchMode=yes",
"-o", "ForwardX11=no",
"-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=no",
"-i", ssh_key,
remote,
remote_cmd,
],
)
if not self._process.waitForStarted(3000):
self._append("Не удалось запустить SSH-команду.\n")
return
self._btn_run.setEnabled(False)
self._btn_stop.setEnabled(True)
@staticmethod
def _build_remote_script(
*, pool: str, obj: str, size_mb: int, replicas: int, cleanup: bool
) -> str:
pool_q = shlex.quote(pool)
obj_q = shlex.quote(obj)
cleanup_flag = "1" if cleanup else "0"
return f"""
set -euo pipefail
pool={pool_q}
obj={obj_q}
size_mb={int(size_mb)}
replicas={int(replicas)}
cleanup={cleanup_flag}
src=/tmp/cephdeploy-rados-src.bin
dst=/tmp/cephdeploy-rados-dst.bin
echo "== Ceph status before =="
ceph -s
echo
echo "== Ensure pool =="
if ! ceph osd pool ls | grep -Fxq "$pool"; then
ceph osd pool create "$pool" 1
fi
ceph osd pool application enable "$pool" rados --yes-i-really-mean-it || true
ceph osd pool set "$pool" size "$replicas"
if [ "$replicas" -gt 1 ]; then
ceph osd pool set "$pool" min_size 2
fi
ceph osd pool get "$pool" size
ceph osd pool get "$pool" min_size
echo
echo "== Write test object =="
dd if=/dev/urandom of="$src" bs=1M count="$size_mb" status=progress
src_sum=$(sha256sum "$src" | awk '{{print $1}}')
rados -p "$pool" put "$obj" "$src"
rados -p "$pool" stat "$obj"
echo
echo "== Read and verify =="
rados -p "$pool" get "$obj" "$dst"
dst_sum=$(sha256sum "$dst" | awk '{{print $1}}')
echo "source sha256: $src_sum"
echo "read sha256: $dst_sum"
test "$src_sum" = "$dst_sum"
echo "checksum: OK"
echo
echo "== Placement and replication =="
ceph osd map "$pool" "$obj"
ceph pg ls-by-pool "$pool"
echo
echo "== Ceph status after =="
ceph -s
if [ "$cleanup" = "1" ]; then
echo
echo "== Cleanup =="
rados -p "$pool" rm "$obj" || true
fi
rm -f "$src" "$dst"
"""
def _stop_test(self) -> None:
if self._process and self._process.state() != QProcess.ProcessState.NotRunning:
self._process.kill()
self._append("\nОстановлено пользователем.\n")
def _on_output(self) -> None:
if not self._process:
return
data = bytes(self._process.readAllStandardOutput()).decode(errors="replace")
self._append(data)
def _on_finished(self, exit_code: int, _status) -> None:
self._btn_run.setEnabled(True)
self._btn_stop.setEnabled(False)
self._append(f"\nКод возврата: {exit_code}\n")
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment