diff --git a/REDIS_AUDIT.md b/REDIS_AUDIT.md new file mode 100644 index 0000000..a7d465d --- /dev/null +++ b/REDIS_AUDIT.md @@ -0,0 +1,229 @@ +# Аудит Redis — полный отчёт + +**Дата:** 2026-02-28 +**Версия:** v1.8.65 +**Тестовый сервер:** Reddis main ovh (Redis 8.2.1, 27M+ ключей, 2.82GB RAM) + +--- + +## 1. Живое тестирование через CLI + +### Работает корректно + +| Команда | Результат | +|---------|-----------| +| `--redis-info ALIAS` | Версия, память, клиенты, keyspace | +| `--redis-keys ALIAS "*"` | SCAN до 1000 ключей, сортировка | +| `--redis ALIAS "PING"` | `True` | +| `--redis ALIAS "DBSIZE"` | `27199166` | +| `--redis ALIAS "GET key"` | Возвращает значение | +| `--redis ALIAS "SET key value"` | `True` | +| `--redis ALIAS "DEL key1 key2"` | Число удалённых | +| `--redis ALIAS "KEYS pattern"` | Список с нумерацией | +| `--redis ALIAS "INFO memory"` | Полный вывод секции | +| `--redis ALIAS "TYPE key"` | Тип данных | +| `--redis ALIAS "TTL key"` | Секунды / -1 | +| `--redis ALIAS "SELECT 1"` | `True` | +| `--redis ALIAS "MSET k1 v1 k2 v2"` | `True` | +| `--status` (redis) | `ONLINE` | + +### Сломано + +| Команда | Ошибка | Баг | +|---------|--------|-----| +| `SET key "hello world"` | `syntax error` | #1 | +| `SET key '{"a":1, "b":2}'` | `syntax error` | #1 | +| Любое значение с пробелами | `syntax error` | #1 | + +--- + +## 2. Найденные баги + +### БАГ #1 — КРИТИЧНЫЙ: Парсер команд ломает значения с пробелами + +**Файлы:** `tools/ssh.py:958`, `core/redis_client.py:78` + +**Проблема:** Используется `command.split()` вместо `shlex.split()`. + +```python +# Текущий код: +parts = command.split() +# "SET key 'hello world'" → ['SET', 'key', "'hello", "world'"] +# Redis получает 4 аргумента вместо 3 → syntax error +``` + +**Воздействие:** +- Невозможно SET/GET значения с пробелами +- Невозможно работать с JSON-данными содержащими пробелы +- Невозможно выполнить EVAL с Lua-скриптами +- Сломаны команды с multi-word аргументами (SORT, CONFIG SET и др.) + +**Затронуты:** CLI (`tools/ssh.py`) И GUI (`core/redis_client.py` → `redis_tab.py`) + +--- + +### БАГ #2 — СРЕДНИЙ: GUI stats показывает отформатированную строку + +**Файл:** `gui/tabs/redis_tab.py:192-208` + +**Проблема:** `_refresh_stats()` вызывает `client.execute("DBSIZE")`, который возвращает строку `"(integer) 27199166"` (уже отформатированную через `_format()`). Эта строка отображается как есть. + +```python +# redis_tab.py:198 +keys_count = client.execute("DBSIZE") # Returns "(integer) 27199166" +keys_text = str(keys_count) # → "(integer) 27199166" in label +``` + +**Для INFO memory проблема другая:** +```python +# redis_tab.py:201 +info = client.execute("INFO memory") +# _format() превращает dict в строку, но без \r\n +# Поиск по \r\n ничего не найдёт → memory = "—" всегда +for line in info.split("\r\n"): # \r\n не найдётся! + if line.startswith("used_memory_human:"): + memory = line.split(":")[1].strip() +``` + +**Воздействие:** Статистика в GUI: +- Keys показывает `(integer) 27199166` вместо `27199166` +- Memory всегда показывает `—` (парсинг не находит `\r\n`) + +--- + +### БАГ #3 — СРЕДНИЙ: GUI RedisClient не поддерживает SSL + +**Файл:** `core/redis_client.py:35-43` + +**Проблема:** В конструкторе `redis.Redis()` отсутствует параметр `ssl`. + +```python +# redis_client.py:35-43 +self._conn = r.Redis( + host=self._host, + port=self._port, + password=self._password, + db=self._db, + decode_responses=True, + socket_timeout=5, + socket_connect_timeout=5, + # ❌ ОТСУТСТВУЕТ: ssl=... +) +``` + +При этом CLI (`tools/ssh.py:955-956`) SSL поддерживает: +```python +ssl_enabled = server.get("ssl", False) +r = redis_lib.Redis(..., ssl=ssl_enabled) +``` + +**Воздействие:** GUI не может подключиться к Redis с TLS/SSL. + +**Связанная проблема:** В `server_dialog.py` поле `use_ssl`/`ssl` НЕ включено в `FIELD_MAP["redis"]`: +```python +"redis": ["password", "db_index"] # нет ssl! +``` + +Даже если добавить SSL в RedisClient, пользователь не сможет включить его в UI. + +--- + +### БАГ #4 — НИЗКИЙ: CLI Redis ошибки выдают сырой traceback + +**Файл:** `tools/ssh.py:957-962` + +**Проблема:** `r.execute_command()` не обёрнут в try-except. + +```python +# Текущий код: +result = r.execute_command(*parts) # При ошибке → необработанное исключение +``` + +**Пример:** `HGETALL` на string-ключе: +``` +ERROR: ResponseError: WRONGTYPE Operation against a key holding the wrong kind of value +``` +Выводится как traceback с двойным повтором вместо читаемого сообщения. + +--- + +### БАГ #5 — НИЗКИЙ: Двойной PING при status check + +**Файл:** `core/status_checker.py:98-110` + +```python +def _check_redis(self, server: dict) -> bool: + client = RedisClient(server) + result = client.connect() # ← PING #1 внутри connect() + if result: + ok = client.check_connection() # ← PING #2 + client.disconnect() + return ok + return False +``` + +`connect()` уже делает `self._conn.ping()` → `True`. Потом `check_connection()` вызывает `self._conn.ping()` ещё раз. + +--- + +### БАГ #6 — НИЗКИЙ: CLI status для Redis без try-except + +**Файл:** `tools/ssh.py:698-703` + +```python +if stype == "redis": + r = redis_lib.Redis(...) + r.ping() # ← Нет try-except! Если offline → traceback + r.close() + return "ONLINE" +``` + +Сравни с SQL, где `conn.close()` в finally. Здесь если `ping()` упадёт, `r.close()` не вызовется. + +--- + +### ПРОБЛЕМА #7 — Дублирование кода подключения + +**Файлы:** `tools/ssh.py:946-956`, `tools/ssh.py:978-986`, `tools/ssh.py:1009-1017` + +Три функции (`run_redis_cmd`, `redis_info`, `redis_keys`) создают одинаковое подключение к Redis копипастом: + +```python +import redis as redis_lib +host = server["ip"] +port = server.get("port", 6379) +password = server.get("password", "") or None +db_index = server.get("db_index", 0) +ssl_enabled = server.get("ssl", False) +r = redis_lib.Redis(host=host, port=port, ...) +``` + +Этот блок повторяется 3 раза. Вынести в хелпер. + +--- + +## 3. Карта проблем по файлам + +| Файл | Строки | Баги | +|------|--------|------| +| `tools/ssh.py` | 958 | #1 (split), #4 (error handling), #6 (status), #7 (copypaste) | +| `core/redis_client.py` | 78, 35-43 | #1 (split), #3 (no SSL) | +| `gui/tabs/redis_tab.py` | 192-208 | #2 (stats parsing) | +| `core/status_checker.py` | 98-110 | #5 (double ping) | +| `gui/server_dialog.py` | 22 | #3 (no ssl field for redis) | + +--- + +## 4. Влияние на пользователей + +| Сценарий | Статус | Причина | +|----------|--------|---------| +| Простые команды (GET/SET/DEL) | ✅ Работает | Нет пробелов | +| Команды со значениями с пробелами | ❌ Сломано | Баг #1 | +| JSON-данные с пробелами | ❌ Сломано | Баг #1 | +| GUI статистика Keys | ⚠️ Некорректно | Баг #2 | +| GUI статистика Memory | ❌ Всегда "—" | Баг #2 | +| SSL-подключение через GUI | ❌ Невозможно | Баг #3 | +| SSL-подключение через CLI | ✅ Работает | — | +| Ошибки в CLI | ⚠️ Нечитаемые | Баг #4 | +| Status check | ✅ Работает (с оверхедом) | Баг #5 | diff --git a/core/redis_client.py b/core/redis_client.py index db323b4..1e1cf77 100644 --- a/core/redis_client.py +++ b/core/redis_client.py @@ -23,6 +23,7 @@ class RedisClient: self._port = int(server.get("port", 6379)) self._password = server.get("password") or None self._db = int(server.get("db_index", 0)) + self._ssl = server.get("ssl", False) or server.get("use_ssl", False) self._conn = None # -- lifecycle -------------------------------------------------------- @@ -38,6 +39,7 @@ class RedisClient: decode_responses=True, socket_timeout=5, socket_connect_timeout=5, + ssl=self._ssl, ) self._conn.ping() log.info("Redis connected %s:%s db=%s", self._host, self._port, self._db) @@ -75,7 +77,11 @@ class RedisClient: """Parse a raw command string, execute via redis-py, return formatted.""" if not self._conn: return "[not connected]" - parts = command.split() + import shlex + try: + parts = shlex.split(command) + except ValueError: + parts = command.split() # fallback для незакрытых кавычек if not parts: return "" try: diff --git a/core/status_checker.py b/core/status_checker.py index d13f19b..160762d 100644 --- a/core/status_checker.py +++ b/core/status_checker.py @@ -100,12 +100,9 @@ class StatusChecker: try: from core.redis_client import RedisClient client = RedisClient(server) - result = client.connect() - if result: - ok = client.check_connection() - client.disconnect() - return ok - return False + result = client.connect() # connect() уже делает ping() + client.disconnect() + return result except Exception: return False diff --git a/gui/server_dialog.py b/gui/server_dialog.py index 314dbd6..5116ae3 100644 --- a/gui/server_dialog.py +++ b/gui/server_dialog.py @@ -19,7 +19,7 @@ FIELD_MAP = { "mariadb": ["user", "password", "database"], "mssql": ["user", "password", "database"], "postgresql": ["user", "password", "database"], - "redis": ["password", "db_index"], + "redis": ["password", "db_index", "use_ssl"], "grafana": ["api_token", "use_ssl"], "prometheus": ["use_ssl"], "rdp": ["user", "password", "rdp_resolution", "rdp_quality", "rdp_clipboard", "rdp_drives", "rdp_printers"], diff --git a/gui/tabs/redis_tab.py b/gui/tabs/redis_tab.py index 8859211..28e7154 100644 --- a/gui/tabs/redis_tab.py +++ b/gui/tabs/redis_tab.py @@ -188,18 +188,14 @@ class RedisTab(ctk.CTkFrame): client = self._get_client() db = int(self._db_var.get()) client.select_db(db) - keys_count = client.execute("DBSIZE") - info = client.execute("INFO memory") - # Parse memory from INFO output - memory = "—" - if isinstance(info, str): - for line in info.split("\r\n"): - if line.startswith("used_memory_human:"): - memory = line.split(":")[1].strip() - break + # Прямые методы возвращают int и dict, не форматированные строки + keys_count = client.dbsize() + info = client.info("memory") + + memory = info.get("used_memory_human", "—") if info else "—" + keys_text = f"{keys_count:,}" if keys_count is not None else "—" - keys_text = str(keys_count) if keys_count is not None else "—" self.after(0, lambda: self._keys_label.configure( text=t("redis_keys") + f": {keys_text}")) self.after(0, lambda: self._memory_label.configure( diff --git a/plans/redis-fixes.md b/plans/redis-fixes.md new file mode 100644 index 0000000..a77d1b7 --- /dev/null +++ b/plans/redis-fixes.md @@ -0,0 +1,561 @@ +# План исправления Redis — диффы с объяснениями + +**Дата:** 2026-02-28 +**Аудит:** [`REDIS_AUDIT.md`](../REDIS_AUDIT.md) +**Статус:** Верифицирован, готов к реализации +**Верификация:** 2026-02-28 — все 7 диффов проверены против кодовой базы + +--- + +## DIFF 1: Парсер команд — shlex вместо split (БАГ #1, КРИТИЧНЫЙ) + +### Почему + +`command.split()` разбивает строку по ВСЕМ пробелам, игнорируя кавычки. +`shlex.split()` понимает одинарные, двойные кавычки и экранирование — стандарт +для shell-подобного парсинга. + +``` +split(): "SET key 'hello world'" → ['SET', 'key', "'hello", "world'"] ← СЛОМАНО +shlex.split(): "SET key 'hello world'" → ['SET', 'key', 'hello world'] ← ПРАВИЛЬНО +``` + +### Файл: `core/redis_client.py` строка 78 + +**ДО:** +```python + def execute(self, command: str) -> str: + """Parse a raw command string, execute via redis-py, return formatted.""" + if not self._conn: + return "[not connected]" + parts = command.split() + if not parts: + return "" +``` + +**ПОСЛЕ:** +```python + def execute(self, command: str) -> str: + """Parse a raw command string, execute via redis-py, return formatted.""" + if not self._conn: + return "[not connected]" + import shlex + try: + parts = shlex.split(command) + except ValueError: + parts = command.split() # fallback для незакрытых кавычек + if not parts: + return "" +``` + +### Файл: `tools/ssh.py` строка 958 + +**ДО:** +```python + try: + parts = command.split() + if not parts: + print("ERROR: Empty Redis command") + sys.exit(1) + result = r.execute_command(*parts) +``` + +**ПОСЛЕ:** +```python + try: + import shlex + try: + parts = shlex.split(command) + except ValueError: + parts = command.split() + if not parts: + print("ERROR: Empty Redis command") + sys.exit(1) + result = r.execute_command(*parts) +``` + +### Результат + +```bash +# Было: +--redis ALIAS "SET key 'hello world'" → syntax error +# Стало: +--redis ALIAS "SET key 'hello world'" → True +--redis ALIAS "GET key" → hello world +``` + +--- + +## DIFF 2: GUI stats — прямые методы вместо execute (БАГ #2, СРЕДНИЙ) + +### Почему + +`client.execute("DBSIZE")` возвращает `"(integer) 27199166"` — уже отформатированную строку. +Этот текст отображается в label как есть, включая `(integer)`. + +`client.execute("INFO memory")` возвращает строку где `_format()` заменяет `\r\n` на `\n`, +но парсер ищет `\r\n` → ничего не находит → memory всегда `"—"`. + +**Решение:** Использовать `client.dbsize()` (int) и `client.info("memory")` (dict) напрямую. + +### Файл: `gui/tabs/redis_tab.py` метод `_refresh_stats()` строки 188-210 + +**ДО:** +```python + def _refresh_stats(self): + if not self._current_alias: + return + + def _do(): + try: + client = self._get_client() + db = int(self._db_var.get()) + client.select_db(db) + keys_count = client.execute("DBSIZE") + info = client.execute("INFO memory") + + # Parse memory from INFO output + memory = "—" + if isinstance(info, str): + for line in info.split("\r\n"): + if line.startswith("used_memory_human:"): + memory = line.split(":")[1].strip() + break + + keys_text = str(keys_count) if keys_count is not None else "—" + self.after(0, lambda: self._keys_label.configure( + text=t("redis_keys") + f": {keys_text}")) + self.after(0, lambda: self._memory_label.configure( + text=t("redis_memory") + f": {memory}")) + except Exception: + pass + + threading.Thread(target=_do, daemon=True).start() +``` + +**ПОСЛЕ:** +```python + def _refresh_stats(self): + if not self._current_alias: + return + + def _do(): + try: + client = self._get_client() + db = int(self._db_var.get()) + client.select_db(db) + + # Прямые методы возвращают int и dict, не форматированные строки + keys_count = client.dbsize() + info = client.info("memory") + + memory = info.get("used_memory_human", "—") if info else "—" + keys_text = f"{keys_count:,}" if keys_count is not None else "—" + + self.after(0, lambda: self._keys_label.configure( + text=t("redis_keys") + f": {keys_text}")) + self.after(0, lambda: self._memory_label.configure( + text=t("redis_memory") + f": {memory}")) + except Exception: + pass + + threading.Thread(target=_do, daemon=True).start() +``` + +### Результат + +``` +# Было: +Keys: (integer) 27199166 Memory: — + +# Стало: +Keys: 27,199,166 Memory: 2.82G +``` + +--- + +## DIFF 3: SSL поддержка в GUI RedisClient (БАГ #3, СРЕДНИЙ) + +### Почему + +CLI (`tools/ssh.py`) передаёт `ssl=server.get("ssl", False)` при создании подключения. +GUI (`redis_client.py`) этот параметр НЕ передаёт → невозможно подключиться к Redis с TLS. + +Дополнительно: `server_dialog.py` не показывает поле `ssl` для Redis в UI. + +### Файл: `core/redis_client.py` конструктор и connect() + +**ДО:** +```python +class RedisClient: + def __init__(self, server: dict): + self._host = server["ip"] + self._port = int(server.get("port", 6379)) + self._password = server.get("password") or None + self._db = int(server.get("db_index", 0)) + self._conn = None + + def connect(self) -> bool: + try: + r = _get_redis() + self._conn = r.Redis( + host=self._host, + port=self._port, + password=self._password, + db=self._db, + decode_responses=True, + socket_timeout=5, + socket_connect_timeout=5, + ) +``` + +**ПОСЛЕ:** +```python +class RedisClient: + def __init__(self, server: dict): + self._host = server["ip"] + self._port = int(server.get("port", 6379)) + self._password = server.get("password") or None + self._db = int(server.get("db_index", 0)) + self._ssl = server.get("ssl", False) or server.get("use_ssl", False) + self._conn = None + + def connect(self) -> bool: + try: + r = _get_redis() + self._conn = r.Redis( + host=self._host, + port=self._port, + password=self._password, + db=self._db, + decode_responses=True, + socket_timeout=5, + socket_connect_timeout=5, + ssl=self._ssl, + ) +``` + +### Файл: `gui/server_dialog.py` строка 22 — FIELD_MAP + +**ДО:** +```python + "redis": ["password", "db_index"], +``` + +**ПОСЛЕ:** +```python + "redis": ["password", "db_index", "use_ssl"], +``` + +### Почему `ssl` и `use_ssl` + +В проекте используются оба ключа в разных местах: +- `tools/ssh.py` читает `server.get("ssl", False)` +- `gui/server_dialog.py` сохраняет `use_ssl` (для WinRM) + +Поэтому в `RedisClient` проверяем оба: `server.get("ssl", False) or server.get("use_ssl", False)`. + +--- + +## DIFF 4: CLI error handling (БАГ #4, НИЗКИЙ) + +### Почему + +При ошибке Redis (WRONGTYPE, NOSCRIPT, синтаксис) выводится полный Python traceback +с двойным повтором. Пользователь видит нечитаемое сообщение. + +### Файл: `tools/ssh.py` функция `run_redis_cmd()` строки 957-973 + +**ДО:** +```python + try: + parts = command.split() + if not parts: + print("ERROR: Empty Redis command") + sys.exit(1) + result = r.execute_command(*parts) + if isinstance(result, list): + for i, item in enumerate(result): + print(f"{i + 1}) {item}") + print(f"\n({len(result)} items)") + elif isinstance(result, dict): + for k, v in result.items(): + print(f"{k}: {v}") + elif isinstance(result, bytes): + print(result.decode("utf-8", errors="replace")) + else: + print(result) + finally: + r.close() +``` + +**ПОСЛЕ:** +```python + try: + import shlex + try: + parts = shlex.split(command) + except ValueError: + parts = command.split() + if not parts: + print("ERROR: Empty Redis command") + sys.exit(1) + try: + result = r.execute_command(*parts) + except Exception as e: + print(f"(error) {e}", file=sys.stderr) + sys.exit(1) + if isinstance(result, list): + for i, item in enumerate(result): + print(f"{i + 1}) {item}") + print(f"\n({len(result)} items)") + elif isinstance(result, dict): + for k, v in result.items(): + print(f"{k}: {v}") + elif isinstance(result, bytes): + print(result.decode("utf-8", errors="replace")) + else: + print(result) + finally: + r.close() +``` + +### Результат + +```bash +# Было: +ERROR: ResponseError: WRONGTYPE Operation against a key holding the wrong kind of value +ERROR: ResponseError: WRONGTYPE Operation against a key holding the wrong kind of value + +# Стало: +(error) WRONGTYPE Operation against a key holding the wrong kind of value +``` + +--- + +## DIFF 5: Двойной PING → одинарный (БАГ #5, НИЗКИЙ) + +### Почему + +`connect()` делает `self._conn.ping()`. Затем `check_connection()` делает ещё один +`self._conn.ping()`. Два network round-trip вместо одного при каждом status check. +При 10 серверах × каждые 30 секунд = 20 лишних пингов. + +### Файл: `core/status_checker.py` строки 98-110 + +**ДО:** +```python + def _check_redis(self, server: dict) -> bool: + """Check Redis via PING.""" + try: + from core.redis_client import RedisClient + client = RedisClient(server) + result = client.connect() + if result: + ok = client.check_connection() + client.disconnect() + return ok + return False + except Exception: + return False +``` + +**ПОСЛЕ:** +```python + def _check_redis(self, server: dict) -> bool: + """Check Redis via PING.""" + try: + from core.redis_client import RedisClient + client = RedisClient(server) + result = client.connect() # connect() уже делает ping() + client.disconnect() + return result + except Exception: + return False +``` + +--- + +## DIFF 6: CLI status — добавить try-except и close (БАГ #6, НИЗКИЙ) + +### Почему + +Если Redis offline, `r.ping()` бросает исключение. `r.close()` не вызывается (утечка). +Ошибка не перехвачена → traceback вместо `OFFLINE`. + +### Файл: `tools/ssh.py` строки 698-703 + +**ДО:** +```python + if stype == "redis": + import redis as redis_lib + r = redis_lib.Redis(host=server["ip"], port=server.get("port", 6379), + password=server.get("password", "") or None, + db=server.get("db_index", 0), socket_timeout=10, + ssl=server.get("ssl", False)) + r.ping() + r.close() + return "ONLINE" +``` + +**ПОСЛЕ:** +```python + if stype == "redis": + import redis as redis_lib + r = redis_lib.Redis(host=server["ip"], port=server.get("port", 6379), + password=server.get("password", "") or None, + db=server.get("db_index", 0), socket_timeout=10, + ssl=server.get("ssl", False)) + try: + r.ping() + return "ONLINE" + finally: + r.close() +``` + +--- + +## DIFF 7: Дедупликация подключения в CLI (БАГ #7, УЛУЧШЕНИЕ) + +### Почему + +Три функции (`run_redis_cmd`, `redis_info`, `redis_keys`) содержат одинаковый блок +из 6 строк для создания Redis-подключения. При изменении параметров (например, добавление +`ssl_cert_reqs`) нужно менять в 3 местах. + +### Файл: `tools/ssh.py` — добавить хелпер перед строкой 946 + +**ДОБАВИТЬ:** +```python +def _get_redis_client(server: dict): + """Create a Redis client from server config. Single source of truth.""" + import redis as redis_lib + return redis_lib.Redis( + host=server["ip"], + port=server.get("port", 6379), + password=server.get("password", "") or None, + db=server.get("db_index", 0), + decode_responses=True, + socket_timeout=10, + ssl=server.get("ssl", False), + ) +``` + +**Затем заменить во всех трёх функциях:** +```python +# Было (в каждой из 3 функций): +import redis as redis_lib +host = server["ip"] +port = server.get("port", 6379) +password = server.get("password", "") or None +db_index = server.get("db_index", 0) +ssl_enabled = server.get("ssl", False) +r = redis_lib.Redis(host=host, port=port, password=password, db=db_index, + decode_responses=True, socket_timeout=10, ssl=ssl_enabled) + +# Стало: +r = _get_redis_client(server) +``` + +--- + +## Порядок применения + +| # | Дифф | Файлы | Строк | Приоритет | +|---|------|-------|-------|-----------| +| 1 | Парсер shlex | `redis_client.py`, `ssh.py` | 8 | КРИТИЧНЫЙ | +| 2 | GUI stats | `redis_tab.py` | 10 | СРЕДНИЙ | +| 3 | SSL в GUI | `redis_client.py`, `server_dialog.py` | 4 | СРЕДНИЙ | +| 4 | CLI error handling | `ssh.py` | 6 | НИЗКИЙ | +| 5 | Double PING | `status_checker.py` | 2 | НИЗКИЙ | +| 6 | CLI status fix | `ssh.py` | 4 | НИЗКИЙ | +| 7 | Дедупликация | `ssh.py` | -12 (убрано дублей) | УЛУЧШЕНИЕ | + +**Всего:** ~20 строк нового кода, -12 строк дублей = +8 строк нетто. + +--- + +## Схема: что сломано и что фиксит каждый дифф + +``` +Пользователь вводит: SET mykey "hello world" + │ + ├─ CLI (ssh.py) ├─ GUI (redis_tab.py) + │ │ │ │ + │ command.split() │ client.execute() + │ ❌ DIFF 1 фиксит │ │ + │ │ command.split() ← redis_client.py + │ │ ❌ DIFF 1 фиксит + │ │ + │ ├─ _refresh_stats() + │ │ execute("DBSIZE") → "(integer) N" + │ │ ❌ DIFF 2 фиксит → dbsize() → int + │ │ + │ │ execute("INFO memory") → строка без \r\n + │ │ ❌ DIFF 2 фиксит → info("memory") → dict + │ │ + ├─ SSL подключение ├─ SSL подключение + │ ✅ Работает │ ❌ DIFF 3 фиксит +``` + +--- + +## Результаты верификации (аудит плана) + +Каждый дифф проверен тремя независимыми аудиторами против текущей кодовой базы. + +### Сводная таблица + +| DIFF | Точное совпадение ДО↔код | Риски | Статус | +|------|--------------------------|-------|--------| +| 1 — shlex | ✅ redis_client.py:78 + ssh.py:958 | Нулевой. 11 команд протестированы | ОДОБРЕН | +| 2 — stats | ✅ redis_tab.py:191-208 | Нулевой. dbsize()→int, info()→dict подтверждено | ОДОБРЕН | +| 3 — SSL | ✅ redis_client.py:21-43 + dialog:22 | Нулевой. use_ssl чекбокс уже существует в UI | ОДОБРЕН | +| 4 — errors | ✅ ssh.py:957-975 | Нулевой. finally вызывается даже при sys.exit | ОДОБРЕН | +| 5 — ping | ✅ status_checker.py:98-110 | Нулевой. connect() содержит ping() | ОДОБРЕН | +| 6 — status | ✅ ssh.py:698-703 | Нулевой. try-finally корректно | ОДОБРЕН | +| 7 — dedup | ✅ 3 копии по 9 строк | **НИЗКИЙ** — см. замечание ниже | ОДОБРЕН с замечанием | + +### Замечания аудиторов + +**DIFF 1:** +- shlex.split() протестирован на: PING, GET, SET, HGETALL, INFO, SCAN, SELECT, MSET, CONFIG SET, значения с кавычками, Windows-пути +- Все 11 типов команд парсятся корректно +- Fallback на split() при ValueError (незакрытые кавычки) — корректный +- Inline import shlex соответствует паттерну проекта (lazy imports) + +**DIFF 2:** +- `dbsize()` всегда возвращает int (0 при ошибке, никогда None) +- `info("memory")` возвращает dict с ключом `"used_memory_human"` +- `f"{keys_count:,}"` безопасен для 0 и любого int +- Thread race condition существует но **pre-existing** — DIFF 2 не вводит новых + +**DIFF 3:** +- `use_ssl` — согласовано с паттерном проекта (winrm, grafana, prometheus используют то же) +- UI чекбокс `use_ssl` уже существует в server_dialog.py, нужно только добавить в FIELD_MAP +- Dual-key fallback `server.get("ssl") or server.get("use_ssl")` покрывает CLI и GUI пути +- `ssl=False` по умолчанию — не ломает существующие подключения + +**DIFF 5:** +- `connect()` вызывает `self._conn.ping()` на строке 42 redis_client.py — подтверждено +- Второй ping через `check_connection()` — гарантированно дубликат + +**DIFF 7 — ЗАМЕЧАНИЕ:** +- Status check в ssh.py:698-703 содержит **4-й дубликат** подключения Redis +- План НЕ включает его в дедупликацию +- **Рекомендация:** Обновить status check тоже при реализации DIFF 7 +- **Примечание:** decode_responses=True в хелпере безопасен для status check (ping() не зависит от декодирования) + +### Итог верификации + +**7 из 7 диффов ОДОБРЕНЫ.** Ни один не сломает существующую функциональность. +Единственное замечание: DIFF 7 стоит расширить на status check (4-й дубликат). + +--- + +## Связанные документы + +- [`REDIS_AUDIT.md`](../REDIS_AUDIT.md) — полный отчёт аудита +- [`plans/reliable-sftp-upload.md`](reliable-sftp-upload.md) — план по SFTP +- [`SFTP_UPLOAD_AUDIT.md`](../SFTP_UPLOAD_AUDIT.md) — аудит загрузки +- [`SFTP_UPLOAD_DIFFS.md`](../SFTP_UPLOAD_DIFFS.md) — диффы загрузки diff --git a/releases/ServerManager-v1.8.66-win-x64.exe b/releases/ServerManager-v1.8.66-win-x64.exe new file mode 100644 index 0000000..058975d Binary files /dev/null and b/releases/ServerManager-v1.8.66-win-x64.exe differ diff --git a/tools/ssh.py b/tools/ssh.py index ab08476..508b18f 100644 --- a/tools/ssh.py +++ b/tools/ssh.py @@ -694,14 +694,12 @@ def _check_status_one(server: dict) -> str: return "ONLINE" if stype == "redis": - import redis as redis_lib - r = redis_lib.Redis(host=server["ip"], port=server.get("port", 6379), - password=server.get("password", "") or None, - db=server.get("db_index", 0), socket_timeout=10, - ssl=server.get("ssl", False)) - r.ping() - r.close() - return "ONLINE" + r = _get_redis_client(server) + try: + r.ping() + return "ONLINE" + finally: + r.close() if stype == "grafana": import requests @@ -991,23 +989,37 @@ def sql_tables(server: dict, database: str = None): # ── Redis commands ──────────────────────────────────── +def _get_redis_client(server: dict): + """Create a Redis client from server config. Single source of truth.""" + import redis as redis_lib + return redis_lib.Redis( + host=server["ip"], + port=server.get("port", 6379), + password=server.get("password", "") or None, + db=server.get("db_index", 0), + decode_responses=True, + socket_timeout=10, + ssl=server.get("ssl", False), + ) + + def run_redis_cmd(server: dict, command: str): """Execute a Redis command.""" - import redis as redis_lib - host = server["ip"] - port = server.get("port", 6379) - password = server.get("password", "") or None - db_index = server.get("db_index", 0) - ssl_enabled = server.get("ssl", False) - - r = redis_lib.Redis(host=host, port=port, password=password, db=db_index, - decode_responses=True, socket_timeout=10, ssl=ssl_enabled) + r = _get_redis_client(server) try: - parts = command.split() + import shlex + try: + parts = shlex.split(command) + except ValueError: + parts = command.split() if not parts: print("ERROR: Empty Redis command") sys.exit(1) - result = r.execute_command(*parts) + try: + result = r.execute_command(*parts) + except Exception as e: + print(f"(error) {e}", file=sys.stderr) + sys.exit(1) if isinstance(result, list): for i, item in enumerate(result): print(f"{i + 1}) {item}") @@ -1025,15 +1037,7 @@ def run_redis_cmd(server: dict, command: str): def redis_info(server: dict): """Show Redis INFO.""" - import redis as redis_lib - host = server["ip"] - port = server.get("port", 6379) - password = server.get("password", "") or None - db_index = server.get("db_index", 0) - ssl_enabled = server.get("ssl", False) - - r = redis_lib.Redis(host=host, port=port, password=password, db=db_index, - decode_responses=True, socket_timeout=10, ssl=ssl_enabled) + r = _get_redis_client(server) try: info = r.info() # Print key sections @@ -1056,15 +1060,7 @@ def redis_info(server: dict): def redis_keys(server: dict, pattern: str): """SCAN keys matching a pattern.""" - import redis as redis_lib - host = server["ip"] - port = server.get("port", 6379) - password = server.get("password", "") or None - db_index = server.get("db_index", 0) - ssl_enabled = server.get("ssl", False) - - r = redis_lib.Redis(host=host, port=port, password=password, db=db_index, - decode_responses=True, socket_timeout=10, ssl=ssl_enabled) + r = _get_redis_client(server) try: keys = [] cursor = 0 diff --git a/version.py b/version.py index 5c27356..5c963ea 100644 --- a/version.py +++ b/version.py @@ -1,6 +1,6 @@ """Version info for ServerManager.""" -__version__ = "1.8.65" +__version__ = "1.8.66" __app_name__ = "ServerManager" __author__ = "aibot777" __description__ = "Desktop GUI for managing remote servers"