Update Ceph cluster management workflows

parent 7bcb9076
......@@ -38,6 +38,7 @@ cephdeploy_*/
*.swp
*.swo
*~
.nfs*
# OS
.DS_Store
......
......@@ -63,6 +63,24 @@ class PrometheusClient:
raise PrometheusError(f"Нет связи с Prometheus: {exc}") from exc
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]:
if resp.status_code >= 400:
raise PrometheusError(
......@@ -103,14 +121,14 @@ class PrometheusClient:
return int(self.scalar("sum(ceph_mon_quorum_status)"))
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:
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:
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(
self,
......@@ -121,10 +139,7 @@ class PrometheusClient:
Возвращает {osd_name: [(ts, value), ...]}."""
end = time.time()
start = end - duration_seconds
promql = (
"rate(ceph_osd_op_latency_sum[1m]) "
"/ rate(ceph_osd_op_latency_count[1m])"
)
promql = "max(ceph_osd_apply_latency_ms) by (ceph_daemon)"
raw = self.query_range(promql, start, end, step=step)
out: dict[str, list[tuple[float, float]]] = {}
for row in raw:
......
......@@ -27,12 +27,13 @@ _DEFAULTS: dict = {
"grafana_password": "admin",
# ── Параметры создания LXC на PVE-хосте ─────────────────────────────
"monitoring_pve_host": "gefest.office.etersoft.ru",
"monitoring_vmid": 200,
"monitoring_ct_ip": "192.168.0.20/24",
"monitoring_ct_gw": "192.168.0.1",
"monitoring_ct_bridge": "vmbr0",
"monitoring_ct_storage": "local-zfs",
"monitoring_ct_template": "debian-12-standard_12.12-1_amd64.tar.zst",
"monitoring_vmid": 100,
"monitoring_ct_ip": "",
"monitoring_ct_gw": "",
"monitoring_ct_bridge": "",
"monitoring_ct_storage": "",
"monitoring_template_storage": "",
"monitoring_ct_template": "",
}
......
......@@ -2,6 +2,69 @@
# Ansible-плейбук для развёртывания Ceph {{ cluster.version }} (cephadm)
# Кластер: {{ cluster.name }}
# Сгенерировано 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: Подготовка узлов
hosts: all
......@@ -15,7 +78,10 @@
name:
- cephadm
- python3-pip
- python3-yaml
- python3-jinja2
- lvm2
- podman
state: present
update_cache: true
when: ansible_facts["os_family"] == "Debian"
......@@ -25,7 +91,10 @@
package:
- cephadm
- python3-module-pip
- python3-module-yaml
- python3-module-jinja2
- lvm2
- podman
state: present
update_cache: true
when: ansible_facts["os_family"] == "Altlinux"
......@@ -35,10 +104,34 @@
name:
- cephadm
- python3-pip
- python3-pyyaml
- python3-jinja2
- lvm2
- podman
state: present
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
- name: Проверить наличие chronyc
ansible.builtin.command: which chronyc
......@@ -75,11 +168,13 @@
tasks:
- name: Запустить cephadm bootstrap
ansible.builtin.command: >
cephadm bootstrap
/usr/sbin/cephadm
--image {{ resolved_ceph_image }}
bootstrap
--mon-ip {{ bootstrap_host.ip_address }}
--initial-dashboard-user {{ dashboard.user }}
--initial-dashboard-password {{ dashboard.password }}
--skip-dashboard
--skip-monitoring-stack
--allow-mismatched-release
--allow-overwrite
args:
creates: /etc/ceph/ceph.conf
......@@ -90,28 +185,15 @@
var: bootstrap_result.stdout_lines
when: bootstrap_result.stdout_lines is defined
# ── mgr-модули для внешнего мониторинга ─────────────────────────────
# ── mgr-модуль для внешнего мониторинга ─────────────────────────────
# prometheus: экспортёр метрик на :9283 — его опрашивает Prometheus-сервер,
# развёрнутый отдельным playbook setup_monitoring.yml в LXC на PVE-узле.
# dashboard: REST API на :{{ dashboard.port }} — резервный канал status_widget.
- 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
failed_when: false
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-оркестратора
ansible.builtin.slurp:
src: /etc/ceph/ceph.pub
......@@ -156,7 +238,7 @@
# поэтому все ceph-команды идут через `cephadm shell -- ceph ...`,
# которое запускает их внутри podman-контейнера с Ceph.
- name: Подождать готовности оркестратора
ansible.builtin.command: cephadm shell -- ceph orch status
ansible.builtin.command: /usr/sbin/cephadm shell -- ceph orch status
register: orch_status
retries: 10
delay: 10
......@@ -174,7 +256,7 @@
{% set hv = "{{ hostvars['" ~ s.hostname ~ "'].ansible_hostname | default('" ~ s.hostname ~ "') }}" %}
- name: Добавить хост {{ s.hostname }}
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
failed_when: false
changed_when: add_host_result.rc == 0
......@@ -196,7 +278,7 @@
{% for osd in s.osds %}
- name: Добавить OSD {{ osd.path }} на {{ s.hostname }} ({{ osd.type }}/{{ osd.role }})
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
failed_when: false
changed_when: "'Created osd' in (osd_result.stdout | default(''))"
......@@ -204,7 +286,7 @@
{% endfor %}
{% endfor %}
- name: Статус кластера
ansible.builtin.command: cephadm shell -- ceph -s
ansible.builtin.command: /usr/sbin/cephadm shell -- ceph -s
register: ceph_status
failed_when: false
changed_when: false
......
......@@ -38,13 +38,31 @@
failed_when: false
changed_when: true
- name: Остановить любые оставшиеся ceph-*@ юниты
- name: Остановить и отключить любые оставшиеся ceph systemd-юниты
ansible.builtin.shell: |
set -o pipefail
for u in $(systemctl list-units --all --no-legend 'ceph-*@*' 2>/dev/null | awk '{print $1}'); do
systemctl stop "$u" 2>/dev/null || true
systemctl disable "$u" 2>/dev/null || true
done
if command -v systemctl >/dev/null 2>&1; then
for u in $(timeout 5 systemctl list-units --all --no-legend 'ceph-*' 2>/dev/null | awk '{print $1}'); do
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
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:
executable: /bin/bash
changed_when: true
......@@ -63,17 +81,61 @@
- name: Убрать оставшиеся podman/docker-контейнеры ceph-*
ansible.builtin.shell: |
if command -v podman >/dev/null 2>&1; then
for c in $(podman ps -aq --filter name=ceph- 2>/dev/null); do
podman rm -f "$c" >/dev/null 2>&1 || true
for c in $(timeout 5 podman ps -aq --filter name=ceph- 2>/dev/null); do
timeout 20 podman rm -f "$c" >/dev/null 2>&1 || true
done
fi
if command -v docker >/dev/null 2>&1; then
for c in $(docker ps -aq --filter name=ceph- 2>/dev/null); do
docker rm -f "$c" >/dev/null 2>&1 || true
for c in $(timeout 5 docker ps -aq --filter name=ceph- 2>/dev/null); do
timeout 20 docker rm -f "$c" >/dev/null 2>&1 || true
done
fi
args:
executable: /bin/bash
changed_when: true
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 %}
......@@ -68,6 +68,34 @@
<p style="color:#9e9e9e">Серверов нет.</p>
{% 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 -->
<h2>OSD-устройства</h2>
{% set total_osds = namespace(n=0) %}
......
......@@ -18,6 +18,7 @@
ct_gw: "{{ monitoring.ct_gw }}"
ct_bridge: "{{ monitoring.ct_bridge }}"
ct_storage: "{{ monitoring.ct_storage }}"
template_storage: "{{ monitoring.template_storage }}"
ct_template: "{{ monitoring.ct_template }}"
grafana_admin_password: "{{ monitoring.grafana_password }}"
......@@ -25,16 +26,17 @@
# На свежем PVE-хосте хранилище 'local' может не иметь в content типа
# 'vztmpl' — без него pveam download падает "storage 'local' does not
# support templates". Добавляем vztmpl к текущему списку content.
- name: Разрешить хранить LXC-шаблоны в local
- name: Разрешить хранить LXC-шаблоны в выбранном template storage
ansible.builtin.shell: |
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 && /content/{sub(/^[[:space:]]+content[[:space:]]+/, ""); print; exit}
' /etc/pve/storage.cfg)
if ! echo "$cur" | tr ',' '\n' | grep -qx 'vztmpl'; then
new="${cur:+$cur,}vztmpl"
pvesm set local --content "$new"
pvesm set "$storage" --content "$new"
echo "VZTMPL_ADDED"
fi
args:
......@@ -44,10 +46,25 @@
# Шаблон сравниваем по имени файла (работает и для debian-12-..., и для
# вручную положенного 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-шаблона в хранилище
ansible.builtin.shell: |
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:
executable: /bin/bash
register: tpl_check
......@@ -61,15 +78,15 @@
- name: Скачать указанный LXC-шаблон, если его ещё нет
ansible.builtin.command: >
pveam download local {% raw %}{{ ct_template }}{% endraw %}
pveam download {{ '{{ template_storage }}' }} {{ '{{ ct_template }}' }}
when: tpl_check.rc != 0
register: tpl_download
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
failed_when: false
......@@ -77,9 +94,9 @@
- name: Создать LXC-контейнер
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 %}
......@@ -280,7 +297,7 @@
loop:
- prometheus
- alertmanager
- prometheus-alertmanager
- grafana-server
register: svc_enable
failed_when: false
......@@ -292,7 +309,7 @@
loop:
- prometheus
- alertmanager
- prometheus-alertmanager
failed_when: false
changed_when: true
......
......@@ -10,6 +10,7 @@ latency — кандидаты на профилактику).
from __future__ import annotations
import time
from urllib.parse import urlparse
import matplotlib
matplotlib.use("QtAgg")
......@@ -23,6 +24,7 @@ from PyQt6.QtWidgets import (
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QPushButton,
QTableWidget,
QTableWidgetItem,
......@@ -32,6 +34,8 @@ from PyQt6.QtWidgets import (
from api.prometheus_client import PrometheusClient, PrometheusError
from core.config import AppConfig
from db import SessionLocal
from db.repository import list_clusters, list_servers
from ui.base_page import BasePage
_BOX_STYLE = (
......@@ -87,11 +91,11 @@ class PromFetchWorker(QThread):
start = end - self.duration_s
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,
)
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,
)
latency_per_osd = cli.osd_latency_series(self.duration_s, step=self.step)
......@@ -105,12 +109,27 @@ class PromFetchWorker(QThread):
"read_bps": cli.cluster_read_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({
"kpi": kpi,
"throughput_w": throughput_write,
"throughput_r": throughput_read,
"latency_per_osd": latency_per_osd,
"fsids": fsids,
"ceph_targets": ceph_targets,
})
except PrometheusError as exc:
self.error.emit(str(exc))
......@@ -162,25 +181,51 @@ class AnalysisWidget(BasePage):
super().__init__("📈 Анализ", "Анализ функционирования кластера Ceph")
self._worker: PromFetchWorker | None = None
self._timer = QTimer(self)
self._timer.setInterval(2000)
self._timer.timeout.connect(self._fetch)
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:
# ── Управление ────────────────────────────────────────────────
ctrl_box = QGroupBox("Источник данных")
ctrl_box.setStyleSheet(_BOX_STYLE)
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(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(" Период:"))
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("6 часов", ("21600", "2m"))
self._period.addItem("24 часа", ("86400", "10m"))
self._period.setCurrentIndex(1)
self._period.currentIndexChanged.connect(self._fetch)
ctrl_layout.addWidget(self._period)
......@@ -197,9 +242,21 @@ class AnalysisWidget(BasePage):
self._lbl_status = QLabel("")
self._lbl_status.setStyleSheet("color: #5a6478; font-size: 12px;")
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()
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_row = QHBoxLayout()
self._kpi_health = KPICard("Здоровье")
......@@ -231,7 +288,7 @@ class AnalysisWidget(BasePage):
osd_layout = QVBoxLayout(osd_box)
self._osd_table = QTableWidget(0, 4)
self._osd_table.setHorizontalHeaderLabels(
["OSD", "avg latency, мс", "тренд 24ч, %", "примечание"]
["OSD", "avg latency, мс", "тренд периода, %", "примечание"]
)
self._osd_table.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
......@@ -256,12 +313,55 @@ class AnalysisWidget(BasePage):
self._fetch()
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:
url = AppConfig.get("prometheus_url") or ""
url = self._source_url() or AppConfig.get("prometheus_url") or ""
if not url:
self._lbl_status.setText(
"URL Prometheus не задан — зайди в Настройки → Внешний мониторинг."
......@@ -295,7 +395,7 @@ class AnalysisWidget(BasePage):
sub="up / in",
)
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_write.set_value(_human_bps(kpi.get("write_bps", 0)))
......@@ -303,6 +403,7 @@ class AnalysisWidget(BasePage):
self._draw_throughput(data["throughput_w"], data["throughput_r"])
# Таблица
self._fill_osd_table(data["latency_per_osd"])
self._update_cluster_identity(data)
self._lbl_status.setText(f"Обновлено: {datetime.now():%H:%M:%S}")
self._btn_refresh.setEnabled(True)
......@@ -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:
ax = self._ax_throughput
ax.clear()
......@@ -354,9 +506,7 @@ class AnalysisWidget(BasePage):
if not values:
continue
avg = sum(values) / len(values)
first = values[0] if values[0] else avg or 1e-9
last = values[-1]
trend_pct = ((last - first) / first) * 100.0 if first else 0.0
trend_pct = self._trend_pct(values)
rows.append((osd, avg, trend_pct))
rows.sort(key=lambda r: r[1], reverse=True)
......@@ -364,11 +514,9 @@ class AnalysisWidget(BasePage):
self._osd_table.setRowCount(len(rows))
for i, (osd, avg, trend) in enumerate(rows):
# avg: секунды → миллисекунды
ms = avg * 1000.0
note = ""
color = None
if trend >= 50 and avg > 0.005:
if trend >= 50 and avg > 5.0:
note = "кандидат на обслуживание"
color = "#f44336"
elif trend >= 20:
......@@ -377,7 +525,7 @@ class AnalysisWidget(BasePage):
items = [
QTableWidgetItem(str(osd)),
QTableWidgetItem(f"{ms:.2f}"),
QTableWidgetItem(f"{avg:.2f}"),
QTableWidgetItem(f"{trend:+.1f}"),
QTableWidgetItem(note),
]
......@@ -387,3 +535,19 @@ class AnalysisWidget(BasePage):
it.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
for col, it in enumerate(items):
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 @@
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 QProcess, QProcessEnvironment
from PyQt6.QtWidgets import (
QAbstractItemView,
QComboBox,
......@@ -24,16 +30,20 @@ from PyQt6.QtWidgets import (
QWidget,
)
from core.resources import get_templates_dir
from db import SessionLocal
from db.repository import (
create_cluster,
delete_cluster,
delete_server,
get_cluster,
list_clusters,
list_servers,
)
from ui.base_page import BasePage
_TEMPLATES_DIR = get_templates_dir()
def _plain_item(text: str) -> QTableWidgetItem:
item = QTableWidgetItem(text)
......@@ -131,6 +141,10 @@ class ClustersWidget(BasePage):
super().__init__("🖥️ Кластеры", "Список профилей кластеров")
self._clusters: list = []
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.refresh()
......@@ -308,16 +322,190 @@ class ClustersWidget(BasePage):
def _on_delete_cluster(self) -> None:
if self._selected_cluster_id is None:
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(
self,
"Удалить кластер?",
"В профиле нет серверов, поэтому на узлах нечего очищать.\n\n"
"Удалить только локальную запись кластера из CephDeploy?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
self._delete_local_cluster(self._selected_cluster_id)
return
reply = QMessageBox.question(
self, "Удалить кластер?",
"Все серверы и данные этого кластера будут удалены. Продолжить?",
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:
delete_cluster(session, cluster_id)
session.commit()
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 "✕ Удалить кластер"
)
if reply == QMessageBox.StandardButton.Yes:
with SessionLocal() as session:
delete_cluster(session, self._selected_cluster_id)
session.commit()
self.refresh()
def _on_delete_server(self, server_id: int) -> None:
reply = QMessageBox.question(
......
......@@ -5,12 +5,13 @@
from __future__ import annotations
import os
import shlex
import subprocess
import tempfile
from pathlib import Path
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.QtWidgets import (
QAbstractItemView,
......@@ -65,6 +66,50 @@ _TABLE_STYLE = """
_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:
item = QTableWidgetItem(text)
......@@ -268,8 +313,9 @@ class DeployWidget(BasePage):
osd_count = len(list_osd_devices(session, srv.id))
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")
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:
all_ready = False
......@@ -285,6 +331,16 @@ class DeployWidget(BasePage):
self._check_table.setItem(row, 4, _status_item("✔ Готов", "#4caf50"))
elif not ssh_ok:
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:
self._check_table.setItem(row, 4, _status_item("⚠ Нет OSD", "#ffb74d"))
......@@ -315,6 +371,47 @@ class DeployWidget(BasePage):
except OSError:
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
# ------------------------------------------------------------------
......@@ -361,6 +458,7 @@ class DeployWidget(BasePage):
Path(play_path).write_text(
env.get_template("ceph_bootstrap.yml.j2").render(
cluster={"name": cluster.name, "version": cluster.ceph_version},
ceph_image=_ceph_image_for_version(cluster.ceph_version),
servers=servers_data,
bootstrap_host=next(
(s for s in servers_data if s["role"] in ("mon", "all")),
......@@ -412,6 +510,11 @@ class DeployWidget(BasePage):
def _spawn_ansible(self, inv: str, play: str) -> None:
"""Запускает ansible-playbook через QProcess, используется и deploy, и cleanup."""
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.readyReadStandardOutput.connect(self._on_output)
self._process.finished.connect(self._on_finished)
......
......@@ -24,8 +24,9 @@ from PyQt6.QtWidgets import (
_CHAPTERS: list[tuple[str, str]] = [
("Обзор программы", """
<h2>CephDeploy — обзор программы</h2>
<p><b>CephDeploy</b> — десктопное приложение для автоматизированной установки и
мониторинга кластера <b>Ceph Reef</b> на узлах под управлением ALT Linux.</p>
<p><b>CephDeploy</b> — десктопное приложение для подготовки серверов,
автоматизированного развёртывания Ceph через cephadm, проверки работы
хранилища и анализа метрик внешнего мониторинга.</p>
<h3>Основные возможности</h3>
<ul>
......@@ -34,6 +35,9 @@ _CHAPTERS: list[tuple[str, str]] = [
<li>Назначение OSD-дисков серверам</li>
<li>Автоматическое развёртывание Ceph через Ansible (cephadm)</li>
<li>Live-мониторинг состояния кластера</li>
<li>Развёртывание внешнего стека Prometheus / Grafana / Alertmanager в LXC на PVE</li>
<li>Проверка записи, чтения и репликации через RADOS smoke-test</li>
<li>Анализ нагрузки, задержек и трендов по данным Prometheus</li>
<li>История запусков и экспорт отчёта в HTML</li>
</ul>
......@@ -52,9 +56,17 @@ _CHAPTERS: list[tuple[str, str]] = [
<li>Найдите серверы через <b>Сканер сети</b> и добавьте их в кластер</li>
<li>Назначьте OSD-диски на странице <b>OSD</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>
</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]] = [
<tr><td><b>DB</b></td><td>BlueStore DB — индексы и метаданные (SSD/NVMe)</td></tr>
</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>
<ul>
<li>Минимальная конфигурация: 1–2 диска DATA на узел</li>
......@@ -215,6 +247,17 @@ _CHAPTERS: list[tuple[str, str]] = [
<li>На серверах: root/sudo доступ, Python 3, доступ к репозиторию Ceph</li>
<li>Сеть: все узлы должны быть доступны по SSH с управляющей машины</li>
</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]] = [
<h3>Авто-обновление</h3>
<p>Установите интервал в поле <b>Авто (с)</b> для автоматического обновления.
Значение <b>0</b> отключает авто-обновление. Рекомендуется 30–60 секунд.</p>
<h3>Когда использовать этот раздел</h3>
<p><b>Состояние</b> подходит для оперативной проверки текущего кластера:
health, ёмкость, OSD tree, PG и вывод команд Ceph. Исторические графики,
нагрузка и тренды находятся на странице <b>Анализ</b> и требуют Prometheus.</p>
"""),
("Анализ функционирования", """
<h2>Анализ функционирования кластера</h2>
<p>Страница <b>Анализ</b> опрашивает внешний <b>Prometheus</b>, в котором
хранятся метрики ceph-mgr exporter, и показывает исторические показатели
кластера. URL Prometheus задаётся в <b>Настройки → Внешний мониторинг</b>,
сам Prometheus разворачивается через меню <b>Мониторинг → Развернуть…</b>.</p>
кластера. URL Prometheus можно выбрать или изменить прямо на странице
анализа, а также сохранить в <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>
<ul>
......@@ -258,19 +323,22 @@ _CHAPTERS: list[tuple[str, str]] = [
OK / WARN / ERR.</li>
<li><b>OSD</b> — число up / in OSD-демонов.</li>
<li><b>MON в кворуме</b> — сколько мониторов в кворуме.</li>
<li><b>IOPS</b> — <code>rate(ceph_osd_op[1m])</code>, суммарно по кластеру.</li>
<li><b>Чтение / Запись</b> — <code>rate(ceph_osd_op_r_out_bytes[1m])</code>
и <code>...w_in_bytes[1m]</code>.</li>
<li><b>IOPS</b> — скорость операций по метрикам Ceph за короткое окно.</li>
<li><b>Чтение / Запись</b> — скорость чтения и записи по pool-метрикам Ceph.</li>
</ul>
<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>
<h3>Таблица OSD и «предиктивное обслуживание»</h3>
<p>Для каждого OSD считается средняя задержка <code>ceph_osd_op_latency_sum /
ceph_osd_op_latency_count</code> и тренд за выбранный период (относительный
прирост в %). Строки подсвечиваются:</p>
<p>Для каждого OSD показываются состояние, средняя задержка и
<b>тренд периода, %</b>. Тренд считается по выбранному интервалу как сравнение
первой и второй половины периода; пустые или нулевые точки задержки не
используются, чтобы не получать ложные значения <code>-100%</code>.</p>
<p>Строки подсвечиваются:</p>
<ul>
<li><span style="color:#ff9800"><b>оранжевым</b></span> — задержка растёт
более чем на 20%;</li>
......@@ -291,10 +359,30 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
<ul>
<li>Кластер (из БД) — определяет список scrape-targets.</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>
</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>
<ul>
<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 кажд
во временном каталоге (путь указан в столбце <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>
<p>Формирование и экспорт конфигурации кластера в HTML-файл.</p>
......@@ -355,9 +483,13 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
<ul>
<li>Общая информация о кластере (название, версия Ceph, дата создания)</li>
<li>Список серверов с ролями и количеством OSD-дисков</li>
<li>Фактически обнаруженные диски на серверах на момент формирования отчёта</li>
<li>Детальная таблица OSD-устройств по всем серверам</li>
<li>История последних 20 запусков развёртывания</li>
</ul>
<p>При формировании отчёта CephDeploy подключается к серверам по SSH и
перечитывает <code>lsblk</code>, поэтому новые диски VM появляются в отчёте
даже до назначения их как OSD.</p>
<p>Отчёт удобно прикладывать к технической документации или ВКР.</p>
"""),
......@@ -401,8 +533,12 @@ Grafana и настраивает scrape-targets на ceph-mgr exporter кажд
<ul>
<li><b>URL Prometheus / Grafana / Alertmanager</b> — проставляются автоматически
после успешного развёртывания через меню Мониторинг.</li>
<li><b>PVE-хост, VMID, IP, шлюз, bridge, хранилище, шаблон</b> — параметры
создаваемого LXC-контейнера. Шаблон по умолчанию — ALT p11.</li>
<li><b>PVE-хост, VMID, IP, шлюз, bridge, хранилище, хранилище шаблонов,
шаблон</b> — последние использованные параметры создаваемого
LXC-контейнера.</li>
<li>Актуальные значения VMID, storage, template storage, LXC templates и
bridge лучше получать кнопкой <b>Проверить PVE</b> в мастере
развёртывания мониторинга.</li>
</ul>
<p>Настройки хранятся в <code>~/.config/cephdeploy/settings.json</code>.</p>
......
......@@ -31,6 +31,7 @@ from ui.osd_widget import OSDWidget
from ui.report_widget import ReportWidget
from ui.help_window import HelpWindow
from ui.settings_widget import SettingsWidget
from ui.storage_test_widget import StorageTestWidget
from ui.status_widget import StatusWidget
......@@ -41,25 +42,27 @@ from ui.status_widget import StatusWidget
_NAV_ITEMS: list[tuple[str, str]] = [
("🖥️ Кластеры", "Список профилей кластеров"),
("🔍 Сканер сети", "Поиск серверов в подсети"),
("💾 OSD", "Управление дисками OSD"),
("🚀 Развёртывание", "Мастер установки Ceph"),
("📜 Журнал", "История запусков"),
("📊 Состояние", "Дашборд кластера"),
("📈 Анализ", "Анализ функционирования кластера"),
("💾 OSD", "Управление дисками OSD"),
("📜 Журнал", "История запусков"),
("📄 Отчёт", "Экспорт в HTML"),
("🧪 Проверка", "Проверка записи и чтения из хранилища"),
("⚙️ Настройки", "Параметры приложения"),
]
# Индексы страниц в _NAV_ITEMS
_CLUSTERS_PAGE_IDX = 0
_SCAN_PAGE_IDX = 1
_DEPLOY_PAGE_IDX = 2
_STATUS_PAGE_IDX = 3
_ANALYSIS_PAGE_IDX = 4
_OSD_PAGE_IDX = 5
_LOG_PAGE_IDX = 6
_OSD_PAGE_IDX = 2
_DEPLOY_PAGE_IDX = 3
_LOG_PAGE_IDX = 4
_STATUS_PAGE_IDX = 5
_ANALYSIS_PAGE_IDX = 6
_REPORT_PAGE_IDX = 7
_SETTINGS_PAGE_IDX = 8
_STORAGE_TEST_PAGE_IDX = 8
_SETTINGS_PAGE_IDX = 9
# ---------------------------------------------------------------------------
......@@ -162,6 +165,8 @@ class MainWindow(QMainWindow):
page = DeployWidget()
elif i == _STATUS_PAGE_IDX:
page = StatusWidget()
elif i == _STORAGE_TEST_PAGE_IDX:
page = StorageTestWidget()
elif i == _ANALYSIS_PAGE_IDX:
page = AnalysisWidget()
elif i == _OSD_PAGE_IDX:
......
......@@ -9,6 +9,8 @@ PVE-узле и по успеху записывает URL'ы в AppConfig —
from __future__ import annotations
import os
import re
import subprocess
import tempfile
from pathlib import Path
......@@ -76,11 +78,21 @@ class MonitoringDialog(QDialog):
form.addRow("Кластер:", self._cluster_combo)
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.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()
form.addRow("IP контейнера:", self._ct_ip)
......@@ -88,13 +100,21 @@ class MonitoringDialog(QDialog):
self._ct_gw = QLineEdit()
form.addRow("Шлюз:", self._ct_gw)
self._ct_bridge = QLineEdit()
self._ct_bridge = QComboBox()
self._ct_bridge.setEditable(True)
form.addRow("Bridge:", self._ct_bridge)
self._ct_storage = QLineEdit()
self._ct_storage = QComboBox()
self._ct_storage.setEditable(True)
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)
self._grafana_pw = QLineEdit()
......@@ -153,16 +173,244 @@ class MonitoringDialog(QDialog):
f"{c.name} [{c.ceph_version}]", userData=c.id
)
self._pve_host.setText(AppConfig.get("monitoring_pve_host"))
self._vmid.setValue(int(AppConfig.get("monitoring_vmid")))
self._ct_ip.setText(AppConfig.get("monitoring_ct_ip"))
self._ct_gw.setText(AppConfig.get("monitoring_ct_gw"))
self._ct_bridge.setText(AppConfig.get("monitoring_ct_bridge"))
self._ct_storage.setText(AppConfig.get("monitoring_ct_storage"))
self._ct_template.setText(AppConfig.get("monitoring_ct_template"))
self._vmid.setValue(self._vmid.minimum())
self._ct_ip.clear()
self._ct_gw.clear()
self._set_combo_items(self._ct_bridge, [], "")
self._set_combo_items(self._ct_storage, [], "")
self._set_combo_items(self._template_storage, [], "")
self._set_combo_items(self._ct_template, [], "")
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:
self._log.moveCursor(self._log.textCursor().MoveOperation.End)
self._log.insertPlainText(text)
......@@ -194,11 +442,32 @@ class MonitoringDialog(QDialog):
"vmid": self._vmid.value(),
"ct_ip": self._ct_ip.text().strip(),
"ct_gw": self._ct_gw.text().strip(),
"ct_bridge": self._ct_bridge.text().strip(),
"ct_storage": self._ct_storage.text().strip(),
"ct_template": self._ct_template.text().strip(),
"ct_bridge": self._combo_text(self._ct_bridge),
"ct_storage": self._combo_text(self._ct_storage),
"template_storage": self._combo_text(self._template_storage),
"ct_template": self._combo_text(self._ct_template),
"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_")
env = Environment(
......@@ -304,9 +573,10 @@ class MonitoringDialog(QDialog):
AppConfig.set_value("monitoring_vmid", self._vmid.value())
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_bridge", self._ct_bridge.text().strip())
AppConfig.set_value("monitoring_ct_storage", self._ct_storage.text().strip())
AppConfig.set_value("monitoring_ct_template", self._ct_template.text().strip())
AppConfig.set_value("monitoring_ct_bridge", self._combo_text(self._ct_bridge))
AppConfig.set_value("monitoring_ct_storage", self._combo_text(self._ct_storage))
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:
AppConfig.save()
except Exception as exc:
......
......@@ -5,6 +5,7 @@
from __future__ import annotations
import json
import shlex
from pathlib import Path
import paramiko
......@@ -31,7 +32,7 @@ from PyQt6.QtWidgets import (
from core.config import AppConfig
from db import SessionLocal
from db.models import DeviceType, OSDRole
from db.models import DeviceType, OSDDevice, OSDRole, Server, ServerRole
from db.repository import (
add_osd_device,
delete_osd_device,
......@@ -69,7 +70,7 @@ _BOX_STYLE = (
"QGroupBox::title { subcontrol-origin: margin; padding: 0 6px; }"
)
_OSD_COLS = ["Устройство", "Тип", "Роль OSD", ""]
_OSD_COLS = ["Устройство", "Тип", "Роль OSD", "Действия"]
def _server_label(hostname: str, ip: str) -> str:
......@@ -184,6 +185,206 @@ class DiskFetchWorker(QThread):
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-устройства — с выбором диска из списка
# ---------------------------------------------------------------------------
......@@ -453,6 +654,8 @@ class OSDWidget(BasePage):
super().__init__("💾 OSD", "Управление дисками OSD")
self._server_id: int | None = None
self._server_name: str = ""
self._apply_worker: OSDApplyWorker | None = None
self._remove_worker: OSDRemoveWorker | None = None
self._build_content()
def _build_content(self) -> None:
......@@ -508,7 +711,7 @@ class OSDWidget(BasePage):
)
self._osd_table.setVisible(False)
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)
osd_layout.addWidget(self._osd_table)
......@@ -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, 2, _plain_item(dev.osd_role.value.upper()))
btn = QPushButton("✕")
btn.setFixedSize(28, 24)
btn.setToolTip("Удалить устройство")
btn.setStyleSheet(
btn_apply = QPushButton("Прицепить")
btn_apply.setFixedHeight(24)
btn_apply.setToolTip("Добавить этот диск в работающий Ceph-кластер")
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; "
"border-radius: 3px; font-size: 12px; }"
"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()
cl = QHBoxLayout(cell)
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)
# ------------------------------------------------------------------
......@@ -661,10 +890,196 @@ class OSDWidget(BasePage):
return
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:
reply = QMessageBox.question(
self, "Удалить устройство?",
"Удалить это OSD-устройство из конфигурации?",
self, "Удалить запись из конфигурации?",
"Удалить это OSD-устройство только из конфигурации CephDeploy?\n\n"
"Живой Ceph-кластер при этом не изменится. Если диск уже был "
"прицеплен к кластеру, сначала используйте кнопку «Отцепить».",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
......
......@@ -4,9 +4,11 @@
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
import paramiko
from jinja2 import Environment, FileSystemLoader
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
......@@ -21,6 +23,7 @@ from PyQt6.QtWidgets import (
QVBoxLayout,
)
from core.config import AppConfig
from core.resources import get_templates_dir
from db import SessionLocal
from db.repository import (
......@@ -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):
def __init__(self, parent=None) -> None:
super().__init__("📄 Отчёт", "Экспорт конфигурации кластера в HTML")
......@@ -143,6 +219,11 @@ class ReportWidget(BasePage):
"ip_address": srv.ip_address,
"role": srv.role.value,
"ssh_user": srv.ssh_user,
"disks": _fetch_server_disks(
srv.ip_address,
srv.ssh_user,
srv.ssh_key_path,
),
"osd_count": len(osds),
"osds": [
{"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