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
......
......@@ -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)
......
......@@ -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:
......
......@@ -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