Author SHA1 Message Date
“Naeel” 5fa34c2ca4 doc: HANDOFF-NEW-CHAT.md — резюме сессии для перехода в новый чат 2026-08-16 07:55:43 +04:00
“Naeel” 30bc5a135d HISTORY: тест после починки — период 31-33с не ушёл, таймер на внешнем шлюзе; тикет остаётся главным инструментом 2026-08-15 21:55:34 +04:00
“Naeel” 96ddb4adcc HISTORY: баги платформы починены — kube-vip рестарт (leader election ок), пул в kube-system/kubevip (provider Ensured) 2026-08-15 21:44:54 +04:00
“Naeel” 73e38bd426 HISTORY: план починки багов платформы (ConfigMap kubevip + restart DS) 2026-08-15 21:38:43 +04:00
“Naeel” 97c2c67b3b doc: тикет — дополнение: таймера 31с внутри кластера нет, он на внешнем шлюзе 2026-08-15 21:28:55 +04:00
“Naeel” 63a8319364 HISTORY: полный обзор кластера — 2 дефекта платформы (kube-vip), таймера 31с внутри кластера нет (он на внешнем шлюзе) 2026-08-15 21:28:37 +04:00
“Naeel” 22cad485c5 doc: черновик тикета в Nubes (явления A/B, 6 доказательств); все фазы плана выполнены 2026-08-15 21:17:17 +04:00
“Naeel” 4fdc5dce9c HISTORY: серверный tcpdump — 8 дыр по 29.5с, запросы до пода не доходят; потеря на участке клиент→ingress 2026-08-15 21:16:44 +04:00
“Naeel” ccab093621 HISTORY: Фаза 2 — port-forward 24977 раундов 0 сбоев, приложение невиновно 2026-08-15 20:50:28 +04:00
“Naeel” f33134d0c1 HISTORY: период таймаутов 32.9с (18 интервалов 32.7-33.0с) — таймер доказан; полная проверка скриптов и кредов OK; +tcp_mtu_probe.py 2026-08-15 20:38:01 +04:00
“Naeel” 65f151d093 HISTORY: опровержение явления C — 403 был из-за env-кредов Яндекса в терминале, не платформа 2026-08-15 16:06:28 +04:00
“Naeel” b31c5eb7e9 HISTORY: Фаза 1 — с локали 403 InvalidClientTokenId (с ВМ ок); /health 5мин чист; 32391 закрыт снаружи 2026-08-15 16:01:22 +04:00
“Naeel” ecf66e4b27 HISTORY: Фаза 0 — vipHost на control-plane; kube-vip: leaderelection сломан у всех, LB без пулов (backoff 5мин) 2026-08-15 15:34:47 +04:00
“Naeel” 095bfce8c8 HISTORY: Фаза 0 — nginx max 68мс/499=0 (ответы теряются nginx→клиент); kube-vip ошибки leaderelection каждые 1с 2026-08-15 15:33:52 +04:00
“Naeel” e448b20916 HISTORY: Фаза 0 — kubectl недоступен, токен kubeconfig истёк (~26ч), нужен новый от пользователя 2026-08-15 15:14:47 +04:00
“Naeel” 951626c457 HISTORY: финал плана и сравнение четырёх агентов 2026-08-15 15:05:23 +04:00
“Naeel” a23e397907 doc: финал плана (решения зафиксированы) + сравнение четырёх агентов 2026-08-15 15:05:12 +04:00
“Naeel” 69bc33eab8 doc: план v4 от Опуса 4.8 + ответы на уточнения (нет второго ISP, NET_RAW вряд ли, echo отложить) 2026-08-15 15:03:33 +04:00
“Naeel” db90a6392e doc: план v3 от Соннета — разделение явлений A/B, port-forward первым; принят за основу 2026-08-15 14:56:13 +04:00
“Naeel” 71e774da77 doc: план v2 от DeepSeek V4 Pro (новый чат) — стратегия изоляции слоёв; сравнение с Flash 2026-08-15 14:50:02 +04:00
“Naeel” adfc9a3610 doc: план сетевых экспериментов от DeepSeek V4 Flash (не Соннет) с поправками — nubes-network-bug-plan 2026-08-15 14:41:53 +04:00
“Naeel” 28669a79a7 tests: 30-мин сравнение с локали — p50 90мс vs 140мс, но 49 таймаутов у shared (5.5%), YMQ 1 ошибка 2026-08-15 13:01:11 +04:00
“Naeel” 0bf20565ff tests: сравнение shared-SQS vs YMQ — p50 6-8мс vs 60-62мс; у shared выброс send 12-25с на прогон (платформа?) 2026-08-15 12:10:11 +04:00
“Naeel” 5bc7eb7fbb HISTORY: P3.7 RSS 2ч PASS — 16588→15648 kB, Threads 13, утечки нет 2026-08-15 11:29:42 +04:00
“Naeel” 6a292769a3 tests: fifo_dlq_probe — опрос DLQ до 10с вместо мгновенной проверки (гонка с тиком); финальная регрессия зелёная 2026-08-15 09:31:11 +04:00
“Naeel” 22d3ea3d40 HISTORY: полная регрессия на ВМ — api_test 22/22, sdk_test 15/15; флап fifo_dlq_probe разобран (гонка теста, сервис 5/5) 2026-08-15 09:25:32 +04:00
“Naeel” a502d7890a HISTORY: P1.4/P2.5/P3.8 PASS, фикс api_test; +cli_load_test.py 2026-08-15 09:17:04 +04:00
“Naeel” bdc7336a80 tests: change-visibility 5с вместо 1с — гонка с таймером AWS-семантики 2026-08-15 09:07:24 +04:00
“Naeel” 01b08fb6ca HISTORY: исправлены дефекты — синхронные удаления Redis + RedrivePolicy; верификация PASS 2026-08-15 09:00:34 +04:00
“Naeel” 7467eccd66 v0.1.35: фиксы — синхронные удаления в Redis (воскрешение очередей) + RedrivePolicy tenant-scoped DLQ 2026-08-15 08:57:25 +04:00
“Naeel” 26c77ff9e1 HISTORY: FIFO/DLQ e2e — FIFO PASS, найден баг RedrivePolicy (tenant-scoped ключ) 2026-08-15 08:43:10 +04:00
“Naeel” 63261d1238 HISTORY: P0.1 таймауты + P0.2 рестарт PASS + найден дефект воскрешения удалённых очередей 2026-08-15 08:41:58 +04:00
“Naeel” af0325ebbe v0.1.35: серверные таймауты по Соннету — ReadHeaderTimeout вместо ReadTimeout, MaxHeaderBytes 2026-08-15 08:34:02 +04:00
“Naeel” 0f10b43ece HISTORY: локализация 51с — MSS 1448 на VIP, дроп на платформенном участке 2026-08-15 08:10:51 +04:00
“Naeel” d528770f1d chore: убрать __pycache__ из репо, добавить в .gitignore 2026-08-14 23:04:27 +04:00
“Naeel” 59318aa9c6 HISTORY: суровый тест 3 тенантов одновременно — итоги 2026-08-14 23:03:20 +04:00
“Naeel” 77b8586a47 tests: авточистка очередей до и после прогона (мусор не копится) 2026-08-14 22:19:27 +04:00
“Naeel” 31cd327946 HISTORY: итоги финального 30-мин прогона v0.1.35 2026-08-14 21:27:34 +04:00
“Naeel” e5c53664d2 HISTORY: тесты без больших тел, запуск 30-мин прогона 2026-08-14 20:56:59 +04:00
“Naeel” 96f55bee49 tests: без больших тел (платформа MTU 51с); превышение лимита через MaximumMessageSize=1024 2026-08-14 20:53:32 +04:00
“Naeel” a62f9e9a55 HISTORY: уточнение причины 51с — MTU (drhider), решение пользователя по тестам 2026-08-14 20:52:34 +04:00
“Naeel” 2090f309ae HISTORY: результаты диагностики платформы (зависание больших POST ~51с, WAF) + platform_probe 2026-08-14 20:32:30 +04:00
“Naeel” 64728e4ccc HISTORY: Q&A Соннета по тестам + план диагностики платформы 2026-08-14 20:11:03 +04:00
“Naeel” 23695585e9 HISTORY: сетевые сбои ВМ, фикс DNS (1.1.1.1 + /etc/hosts) 2026-08-14 19:31:20 +04:00
“Naeel” 8c00c05d0c HISTORY: фиксы гонок map, инцидент с рестартами пода 2026-08-14 18:52:48 +04:00
“Naeel” c4da02c814 v0.1.35: устранены гонки map (ChangeVisibility/DeleteBatch) и nil-deref под Lock + OverLimit 2026-08-14 18:48:35 +04:00
“Naeel” 9cde807dda tests: фикс invalid_cases (очереди удаляются после сценариев) + полные AWS-коды в EXPECTED_CODES + invalid_probe 2026-08-14 17:45:37 +04:00
“Naeel” 7ac750bd72 HISTORY: лимит 50, очистка очередей, итоги 1-го нагрузочного прогона 2026-08-14 16:53:41 +04:00
“Naeel” 0b56677363 v0.1.35: лимит очередей JWT-тенантов 50, обновление лимита при логине 2026-08-14 16:51:12 +04:00
“Naeel” fa237204f0 v0.1.35: FifoQueue/ContentBasedDeduplication в whitelist (совместимость CLI v2) 2026-08-14 16:02:51 +04:00
“Naeel” 7f6e9308d5 v0.1.35: whitelist имён атрибутов и для JSON-протокола (UnmarshalJSON) 2026-08-14 16:00:48 +04:00
“Naeel” dbb1aea936 v0.1.35: resolveProtocol — query-mode клиенты парсят JSON-ответы (эмпирика), ошибки AWS-JSON __type 2026-08-14 15:56:40 +04:00
“Naeel” ec10acc99e v0.1.35: query-mode XML-ответы, TOCTOU receive, валидации AWS, purge 60s, сужение локов 2026-08-14 15:53:08 +04:00
“Naeel” 8c40ca592f HISTORY: Q&A Sonnet по дефектам + план v0.1.35 2026-08-14 15:39:57 +04:00
“Naeel” 3a4a5929e9 HISTORY: нагрузочный тест 30 мин — находки и анализ 2026-08-14 15:24:34 +04:00
“Naeel” 167d5bef41 HISTORY: программные проверки API CLI+SDK локально и с ВМ 2026-08-14 14:43:37 +04:00
“Naeel” 698921c88a tests: api_test.sh (CLI 22 проверки) + sdk_test.py (boto3 15 проверок) 2026-08-14 14:43:36 +04:00
“Naeel” abe8355389 v0.1.34: кэш валидации JWT + smoke-тесты 2026-08-14 14:16:38 +04:00
“Naeel” 0f24658fc7 v0.1.34: кэш валидации JWT (TTL 5 мин + grace 2 мин) — консоль устойчива к флаку шлюза 2026-08-14 14:09:49 +04:00
“Naeel” 0d5dbaba4f HISTORY: Q&A Sonnet по кэшу валидации JWT 2026-08-14 14:06:45 +04:00
“Naeel” 5679348f65 HISTORY: email-креды работают на проде (smoke list-queues OK) 2026-08-14 13:54:31 +04:00
“Naeel” c6784e6bc6 HISTORY: v0.1.33 строгий стиль 2026-08-14 13:50:38 +04:00
“Naeel” 12d07f1589 v0.1.33: строгий нейтральный стиль текстов на главной и в примерах 2026-08-14 13:49:26 +04:00
“Naeel” cf17a1f729 HISTORY: v0.1.32 пошаговый старт и /examples 2026-08-14 13:46:22 +04:00
“Naeel” 94108eec48 v0.1.32: главная — пошаговый старт; примеры команд — на /examples крупным шрифтом; консоль сразу в аккаунт 2026-08-14 13:45:06 +04:00
“Naeel” 709127caf7 HISTORY: v0.1.31 админский UI 2026-08-14 13:30:45 +04:00
“Naeel” afa9a1a7e4 v0.1.31: админский UI /admin, юзерский UI без слова «тенант» 2026-08-14 10:19:35 +04:00
“Naeel” e83ae1e376 HISTORY: v0.1.30 локальные ассеты 2026-08-14 10:11:25 +04:00
“Naeel” bbbd17ad45 v0.1.30: локальные логотип и favicon /static в консоли (внешние terra.k8c.ru битые) 2026-08-14 10:10:18 +04:00
“Naeel” 8e2b69642e HISTORY: v0.1.29 светлый дизайн 2026-08-14 09:57:56 +04:00
“Naeel” cfea73cdea v0.1.29: светлый Nubes-дизайн консоли, читаемая модалка Credentials (env + файл кредов) 2026-08-14 09:56:18 +04:00
“Naeel” a85e086c94 HISTORY: v0.1.28 + проблема gateway 401 с датацентровых IP 2026-08-14 09:46:59 +04:00
“Naeel” e19f63abe4 v0.1.28: email-идентичность — детерминированные креды из email, credentials-эндпоинт, UI модалка 2026-08-14 09:43:36 +04:00
“Naeel” 87ecf3b5ca HISTORY: сохранить Q&A с Sonnet по email-идентичности 2026-08-14 09:36:14 +04:00
“Naeel” dc1aaa6652 HISTORY: зачистка мусора на ВМ 2026-08-14 09:13:25 +04:00
“Naeel” 8afc181030 HISTORY: v0.1.27 страница на корне + инцидент rsync 2026-08-14 09:10:33 +04:00
“Naeel” af656fe8f5 v0.1.27: страница-описание сервиса на корне / (дизайн Nubes) + /static 2026-08-14 09:05:16 +04:00
“Naeel” 5437b9a918 HISTORY: развязка — set image v0.1.26, легаси ns удалён, вход работает 2026-08-14 08:56:08 +04:00
“Naeel” a5f8218a37 HISTORY: v0.1.26 журнал 2026-08-14 08:20:19 +04:00
“Naeel” 3a105de05b v0.1.26: версия в /health, admin health и UI; ldflags VERSION; Makefile build фикс 2026-08-14 08:19:14 +04:00
“Naeel” 166baa9a98 fix: валидация JWT перебором всех стендов gateway (токен любого стенда принимается) 2026-08-14 08:05:16 +04:00
“Naeel” de72fe5c8f fix: JWT-валидация через lk-api-gateway PROD + User-Agent в PingNubesAPI 2026-08-14 07:51:05 +04:00
“Naeel” 7db1b1ef69 docs: HISTORY 2026-08-14 — найден gateway endpoint, нужен User-Agent в PingNubesAPI 2026-08-14 07:47:56 +04:00
“Naeel” 032ca66cc6 feat: UI — инфо-блок о сервисе на странице входа 2026-08-13 23:20:51 +04:00
“Naeel” 6f575c833d docs: обновить HISTORY — деплой работает, образ Docker Hub, репа Gitea 2026-08-13 23:19:42 +04:00
“Naeel” 29ac406d71 fix: Makefile — вернуть registry Docker Hub (образ пушится на naeel/shared-sqs) 2026-08-13 23:11:12 +04:00
“Naeel” a149692a6e feat: Dockerfile — ENV для Redis и seed-demo (под деплой на Nubes) 2026-08-13 22:07:42 +04:00
“Naeel” 15ec1a9ee4 chore: удалить устаревшие тестовые файлы (SNS-легаси, несуществующие импорты) 2026-08-13 22:06:26 +04:00
“Naeel” 66d1e4e586 chore: переместить исторические doc в doc/legacy, удалить tests и пустые легаси-папки 2026-08-13 22:03:08 +04:00
“Naeel” 55a65c7130 chore: удалить легаси — k8s/helm манифесты (уходим на Nubes Managed) 2026-08-13 22:00:52 +04:00
“Naeel” 8c5c779a0c chore: убрать легаси — pearlharbor креды удалены, doc/api и yandex YMQ помечены ЛЕГАСИ 2026-08-13 21:58:08 +04:00
“Naeel” e1165b42c3 docs: пометка ЛЕГАСИ в doc/legacy/PLAN.md 2026-08-13 21:55:09 +04:00
“Naeel” 35a2bfa719 chore: git локально, после пуша синк на ВМ 2026-08-13 21:51:36 +04:00
Naeel 5c1c021bb4 chore: миграция на Nubes Managed — правила, README, registry Gitea, легаси помечено 2026-08-13 20:42:18 +03:00
109 changed files with 6260 additions and 7719 deletions
+13 -56
View File
@@ -1,63 +1,20 @@
# Правила
# Правила (кратко)
## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО
- Вопрос → короткий ответ → СТОП.
- Ничего лишнего.
- Код — только по запросу.
## ⛔⛔⛔ НИКАКИХ ПРЕДПОЛОЖЕНИЙ — ТОЛЬКО ФАКТЫ
- Только проверенные факты. Не проверено — писать «не проверено».
## ⛔ ОТВЕЧАТЬ КРАТКО
- Вопрос → короткий ответ → СТОП. Код — только по запросу.
## ⛔⛔⛔ ВОПРОС = СТОП
- Любой вопрос → только ответить, ждать «делай». Не начинать работу без команды.
**Если в сообщении есть вопрос в ЛЮБОЙ форме** ("так ?", "верно ?", "почему ?", "как ?", "так же ?" и т.д.):
1. ТОЛЬКО ответить на вопрос
2. ОСТАНОВИТЬСЯ
3. ЖДАТЬ следующей команды
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД
- Никаких изменений кода/инфраструктуры без явного «делай».
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД — АБСОЛЮТНЫЙ ЗАПРЕТ НАВСЕГДА
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРИКАЗАНО
- Без додумывания, без «логичных следующих шагов».
**НИКАКИХ самодеятельных изменений рабочего кода:**
- Никаких "оптимизаций", "улучшений", "рефакторинга" без команды
- Никаких новых фич без явного разрешения
- Никаких docker/kubectl/helm команд и прочих инфраструктурных изменений без команды
- Перед ЛЮБЫМ изменением рабочего кода — объяснить ЗАЧЕМ и ждать "делай"
## ЕСЛИ ПРАВИШЬ КОД
- Сначала прочитай /home/naeel/nubes/SQS-service/.github/pravila.md (там: git локально + после пуша синк на ВМ, docker/ssh на ВМ, деплой).
1. Не трогать рабочий код без явного указания.
2. Файлы редактируются локально:
~/SQS-service
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/SQS-service/ \
naeel@5.172.178.213:~/terra/SQS-service/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/SQS-service
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
- не выполнять инфраструктурные команды локально
- не открывать интерактивные сессии
- не делать цепочки без необходимости
4. Перед запуском команд ОБЯЗАТЕЛЬНО убедиться, что синхронизация выполнена.
5. ЗАПРЕЩЕНО:
- откатывать код
- менять версии
- ломать рабочее состояние
6. После каждого исправления:
- git add/commit ЛОКАЛЬНО
- затем синхронизация (rsync) на ВМ
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРЯМО ПРИКАЗАНО
**АБСОЛЮТНЫЙ ЗАПРЕТ на додумывание:**
- Не расширять масштаб работы
- Не выполнять "логичные следующие шаги"
- Не инициировать дополнительные операции
- Не делать ничего кроме того что сказано
**Исключение:** только если приказ явно включает цепочку ("собери И залей И тесты")
+29 -10
View File
@@ -1,7 +1,25 @@
# Правила работы агента
## ⛔⛔⛔ НИКАКИХ ПРЕДПОЛОЖЕНИЙ — ТОЛЬКО ФАКТЫ
**АБСОЛЮТНОЕ ПРАВИЛО:**
- Не выдавать предположения/допущения за факты.
- Говорить только то, что ПРОВЕРЕНО (прочитано, выполнено, подтверждено).
- Если не проверено — прямо писать «не проверено», не додумывать.
- Не строить выводы о реальном состоянии системы на основе манифестов/доков.
## ⛔⛔⛔ ВОПРОС = СТОП
**Если в сообщении есть вопрос в ЛЮБОЙ форме:**
1. ТОЛЬКО ответить на вопрос
2. ОСТАНОВИТЬСЯ
3. ЖДАТЬ следующей команды
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
> ⚠️ ЛЕГАСИ — старый k8s-деплой (kubectl apply). Уходим на Nubes Managed. ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.
1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/deployment.yaml` (vX.Y.Z → vX.Y.Z+1)
2. rsync на ВМ
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
@@ -13,14 +31,14 @@
## Файловая система (актуально)
1. Все файлы редактируются локально: `~/SQS-service`
1. Все файлы редактируются локально: `/home/naeel/nubes/SQS-service`
2. После любых изменений — обязательно rsync на ВМ:
rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/SQS-service/ \
/home/naeel/nubes/SQS-service/ \
naeel@5.172.178.213:~/terra/SQS-service/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/SQS-service
3. Git (add/commit/push) выполнять ЛОКАЛЬНО. После пуша — ОБЯЗАТЕЛЬНО синк на ВМ (rsync).
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
@@ -28,8 +46,9 @@
Пример:
1. Редактируешь локально
2. rsync на ВМ
3. Выполняешь команды через SSH на ВМ
2. git add/commit/push ЛОКАЛЬНО
3. rsync на ВМ (после пуша)
4. Выполняешь команды через SSH на ВМ
## SSH
@@ -39,7 +58,7 @@
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
```
Запрещено локально: `go`, `docker`, `kubectl`, `helm`, `terraform`, `curl/wget`, `git push/pull`, любые скрипты проекта.
Запрещено локально: `go`, `docker`, `kubectl`, `helm`, `terraform`, `curl/wget`, любые скрипты проекта. (git — разрешён локально)
## Документация
@@ -50,9 +69,9 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
## Git
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/SQS-service`. НИКОГДА через SSH на VM.
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
- Git — ЛОКАЛЬНО в /home/naeel/nubes/SQS-service. После push — синк на ВМ (rsync).
- Разрешены операции: `git add`, `git commit`, `git push` (локально).
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме add/commit/push.
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
Версионирование тагами: `vMAJOR.MINOR.PATCH`
@@ -86,7 +105,7 @@ LOG="tests/results/$(date +%Y-%m-%d_%H-%M).log"
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
"bash ~/terra/SQS-service/tests/run_tests.sh 2>&1 | tee ~/terra/SQS-service/${LOG}"
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
naeel@5.172.178.213:~/terra/SQS-service/tests/results/ ~/SQS-service/tests/results/
naeel@5.172.178.213:~/terra/SQS-service/tests/results/ /home/naeel/nubes/SQS-service/tests/results/
```
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
+4
View File
@@ -31,3 +31,7 @@ build/
secrets/
goaws
cmd
# Python cache
__pycache__/
*.pyc
+6 -1
View File
@@ -6,13 +6,18 @@ WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o shared-sqs ./app/cmd/
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags "-X shared-sqs/app/models.Version=${VERSION}" -o shared-sqs ./app/cmd/
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs
COPY --from=builder /build/app/conf/goaws.yaml /conf/goaws.yaml
EXPOSE 4100
# Нечувствительные значения зашиты в образ. Секреты (SHARED_SQS_ADMIN_TOKEN, REDIS_PASSWORD) — env'ом контейнера в deck.
ENV REDIS_ADDR=redisk8s.4ff5678b-b683-4aef-91da-22d89dec8a25.svc.cluster.local:6379 \
REDIS_USER=default \
SHARED_SQS_SEED_DEMO=false
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD wget -q -O - http://localhost:4100/health || exit 1
ENTRYPOINT ["shared-sqs", "--config", "/conf/goaws.yaml"]
+91
View File
@@ -0,0 +1,91 @@
# HANDOFF для нового чата — shared-sqs (состояние на 2026-08-16)
> Этот файл — резюме длинной сессии 13–16.08.2026. Новый чат: прочитать
> этот файл + HISTORY/2026-08-14-session-log.md + doc/thinking/.
## 1. Что за проект
`shared-sqs` — SQS-совместимая очередь (AWS API), Go 1.23, multi-tenant,
форк GoAWS. Живёт на платформе Nubes (managed Kubernetes, realm iot-naeel).
Локально: `/home/naeel/nubes/SQS-service`, зеркало на ВМ:
`naeel@5.172.178.213:~/terra/SQS-service` (ssh-ключ `~/.ssh/naeel_vm_id_ed25519`).
**⛔ ГРАНИЦЫ ПРОЕКТА (записано в память):** работать ТОЛЬКО с локальной папкой
и её зеркалом на ВМ. Никуда больше без прямого указания пути.
## 2. Статус сервиса
- Прод-версия **v0.1.35** задеплоена по digest (Docker Hub `naeel/shared-sqs`).
- Все тесты зелёные: api_test 22/22, sdk_test 15/15, fifo_dlq_probe PASS.
- План Соннета выполнен: P0.1, P0.2, P1.4, P2.5, P2.6, P3.7, P3.8 — все PASS.
- Нагрузка 168 591 операция — 0 сервисных ошибок.
- Память стабильна 2ч (VmRSS 16588→15648 kB, Threads 13).
## 3. Главная открытая проблема — ПЛАТФОРМА, не код
1. **Таймауты каждые 31–33с** на внешнем пути (интернет→шлюз Nubes): ~5.5%
запросов не получают ответ 30с. Доказано серверным tcpdump (8 «дыр» по
29.5с: запросы не доходят до пода), nginx чист (max 68мс, 499=0), cilium
0 дропов, port-forward 24977 раундов 0 сбоев. Таймер — на ВНЕШНЕМ шлюзе,
вне кластера.
2. **MSS=1448** анонсируется шлюзом при underlay MTU 1450 → большие тела
(>~1400 байт) виснут ~51с (PMTUD сломан).
3. Готов тикет: `doc/thinking/nubes-ticket.md` — ОТПРАВИТЬ в поддержку Nubes.
## 4. Что мы починили в кластере (16.08, по команде пользователя)
- kube-vip: `rollout restart ds shturval-vip` — ушли ошибки leaderelection в
удалённый namespace `0a42bef3-...` (хвост июльского сервиса пользователя).
- provider LB: добавлен `cidr-global: 185.247.187.151/32` в ConfigMap
`kubevip` в **kube-system** (provider читает именно оттуда,
env KUBEVIP_NAMESPACE=kube-system). «Ensured load balancer» — OK.
- После починки таймауты НЕ ушли (тест: 19 сбоев, период 31.3–33.5с) —
подтверждение, что таймер на внешнем шлюзе.
## 5. Сравнение с Yandex YMQ (очередь `newsqs`)
- Наш p50: 68мс (с ВМ) / 89–90мс (с локали). YMQ: 6062мс / 138150мс.
- YMQ стабилен (1 ошибка на 959), у нас — таймауты платформы (см. п.3).
- YMQ-доступ восстановлен: SA `newsqs` (роль ymq.admin), креды в
`secrets/yandex_ymq_newsqs.txt` (имена переменных AWS_ACCESS_KEY_ID/
AWS_SECRET_ACCESS_KEY). Очередь: URL в HISTORY.
- Повторный бенчмарк — ПОСЛЕ фикса шлюза Nubes.
## 6. Ключевые доступы
- Креды shared-SQS (тенант t-fec713a719ad0b33):
AK `SSAK-fec713a719ad0b33c91fa54a`,
SK `e898ed410a20ff166f51a52ba39a2954fb87e2da200b1648c3975772bfe9e2b4`.
- Внешний endpoint: `https://sqs.containerk8s.dev.nubes.ru` (регион us-east-1).
- Внутренний (из подов кластера): сервис `containerk8s` в ns
`f1ffb134-7d16-45bd-8bef-69f6ec8ab33c`, порт 4100.
- Gitea: `gitea.services.ngcloud.ru/Nail/SQS-service` (токен — в secrets/gitea.txt).
- kubeconfig на ВМ протухает ~раз в сутки (~26ч) — обновлять через консоль
Nubes (пользователь копирует в буфер, на ВМ: `xclip -selection clipboard -o
> ~/.kube/config`).
- ⚠️ boto3/aws CLI: ВСЕГДА передавать креды явно (env терминала копит чужие
AWS_ACCESS_KEY_ID — был ложный 403 с кредами Яндекса).
## 7. Хвосты / TODO
1. Отправить тикет Nubes (файл готов).
2. Удалить дубликат `secrets/yandex_ymq_fork8s.txt` (одинаков с newsqs.txt) —
ждёт команды пользователя.
3. После фикса шлюза: повторный 30-мин бенчмарк vs YMQ, обновить
BENCHMARK-COMPARISON.
4. Эхо-сервис (Фаза 3 планов) не понадобился — доказательств хватило.
## 8. Тестовая инфраструктура (tests/)
- `run_full_regression.sh` — полная регрессия (api_test + sdk_test + fifo_dlq).
- `long_compare_local.py` — 30-мин сравнение с YMQ с таймстемпами.
- `compare_ymq_vs_shared.py` — короткое сравнение.
- `tcp_mtu_probe.py` — бинарный поиск MTU-порога (порт 443! 32391 снаружи закрыт).
- Планы 4 агентов: doc/thinking/nubes-network-bug-plan*.md.
## 9. Правила пользователя (обязательные)
- Без «делай» — ничего не менять, только отвечать/смотреть.
- Каждый шаг и результат — в HISTORY сразу; коммит после правок.
- Команды только с таймаутами. Никаких оценок — считать/мерить.
- Часовой пояс GMT+03. sed запрещён (только редакторы файлов).
+77
View File
@@ -0,0 +1,77 @@
# Журнал сессии — 2026-08-13 (миграция shared-sqs → Nubes Managed)
**Рабочая папка:** /home/naeel/nubes/SQS-service
---
## ИТОГОВОЕ СОСТОЯНИЕ (актуально)
### Репозиторий и образ
- **Репа (код):** Gitea — `gitea.services.ngcloud.ru/Nail/SQS-service` (origin)
- **Образ:** Docker Hub — `naeel/shared-sqs:latest` (+ `v0.1.25`)
- **Makefile:** `IMAGE_REPO=naeel/shared-sqs` (Docker Hub)
### Деплой (РАБОТАЕТ)
- Платформа: deck-UI → «Простой HTTP контейнер» (nubes_http), realm `iot-naeel`
- **Домен:** `https://sqs.containerk8s.dev.nubes.ru`
- `/health``OK` (200)
- Корень `/` без AWS-auth → `MissingAuthenticationToken` (норма для SQS API)
### Managed Redis (рабочий)
- instanceUid: `4ff5678b-b683-4aef-91da-22d89dec8a25`, realm `iot-naeel`
- internal master: `redisk8s.4ff5678b-b683-4aef-91da-22d89dec8a25.svc.cluster.local:6379`
- user `default`, пароль в `redis.md`
### Env контейнера (заданы в deck-UI)
- `SHARED_SQS_ADMIN_TOKEN` = `b8c812eb8fdc9594285a210faae40b239965646c30daeb98`
- `REDIS_PASSWORD` = `ZJOke5b2bIr6YPOKrnJG`
- В образе (Dockerfile ENV): `REDIS_ADDR`, `REDIS_USER=default`, `SHARED_SQS_SEED_DEMO=false`
### Секреты (в `secrets/`, не в git)
- `secrets/gitea.txt`: git-токен `6804dfb...`, gitea-docker-токен `b096775e...` (устарел)
- GitHub PAT (для GHCR, не понадобился): `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`
---
## Хронология действий
1. Сохранён план миграции IoT (`doc/2026-08-13-migration-to-managed.md`).
2. Изучен репозиторий SQS-service: Go 1.23, порт 4100, Redis write-through (без TLS), billing опционально.
3. Выяснено: Managed Redis на Nubes есть; «Простой HTTP контейнер» запускает docker-образы.
4. Сохранён план миграции (`doc/2026-08-13-migration-to-nubes.md`).
5. Коммит + push в gitea (обновлены git-креды для `gitea.services.ngcloud.ru`, логин `ntazetdinov`).
6. Поиск «кто создал poc-redis» — локальных следов нет.
7. Проверка утечки кредов в терраформ-репах — прекращено (не лезть в чужие репы).
8. `redis.md` приведён в структурированный вид.
9. Промпт Claude Sonnet v1 — лазил по всем файлам, начитался легаси.
10. Промпт v2 (16 файлов, без поиска) — отработал чисто.
11. Архивная ветка `archive/2026-08-13-migration-planning`.
12. Прочитаны правила проекта (`.github/copilot-instructions.md`, `.github/pravila.md`).
13. Правила: путь → `/home/naeel/nubes/SQS-service`; git — ЛОКАЛЬНО, после пуша синк на ВМ.
14. Удалены бинарники `goaws`, `cmd`.
15. Легаси доки → `doc/legacy/` с пометкой ЛЕГАСИ.
16. Зачистка легаси: удалены `deployments/`, `shared-SQS/`, `tests/`, `secrets/pearlharbor_registry.txt`; `doc/thinking|decisions|errors|api|progress``doc/legacy/`; помечен `yandex_ymq_fork8s.txt`.
17. Удалены 7 устаревших тестовых файлов (SNS-легаси, несуществующие импорты).
18. Dockerfile: добавлен ENV (Redis + seed-demo).
19. Образ собран, запушен в Gitea registry (`nail/shared-sqs`).
20. **Проблема:** Gitea пакет приватный (анонимный pull 401) → платформа Nubes не может тянуть.
21. Перепуш в org `sqs` (`sqs/shared-sqs`) — тоже приватный.
22. Попытка GHCR (`fornubes/shared-sqs`, `repinoid/sqs`) — пакеты приватные, API visibility не меняет (404).
23. **Решение:** образ на Docker Hub `naeel/shared-sqs:latest` (публичный).
24. Репа возвращена на Gitea (origin), Makefile → Docker Hub.
25. Контейнер создан в deck-UI, работает: `sqs.containerk8s.dev.nubes.ru`, `/health` OK.
26. Пользователь просит вставить страницу с полной инфой (на корень `/` вместо XML-ошибки).
---
## Ошибки сессии (не повторять)
1. Сначала сохранил в память AI вместо файла, когда просили «в файл».
2. Промпт агенту должен ЖЁСТКО ограничивать файлы и запрещать поиск.
3. Не лезть в чужие репозитории без явной команды.
4. Не упоминать терраформ — деплой только через deck-UI.
5. Ответы агентов проверять на легаси (pearlharbor, kafka, старые планы).
6. Не хардкодить секреты в Dockerfile — только env контейнера.
7. НЕ лезть в документацию терраформа (tf-registry) — запрещено пользователем.
8. НЕ менять git remote без явной команды (переключил на GitHub — пришлось возвращать на Gitea).
9. Сначала ушёл с Docker Hub на Gitea по команде — но приватные пакеты сломали pull; вернулись на Docker Hub. Для Nubes нужен публичный образ.
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -1,21 +1,23 @@
# Makefile — shared-sqs
# Created: 2026-04-09
# Registry: Docker Hub (naeel/shared-sqs) — pearlharbor не используется (нестабилен)
# Registry: Docker Hub (naeel/shared-sqs)
IMAGE_REPO=naeel/shared-sqs
VERSION=v0.1.0
VERSION=v0.1.35
LDFLAGS=-X shared-sqs/app/models.Version=$(VERSION)
BINARY=shared-sqs
.PHONY: build docker-build docker-push test run clean
build:
CGO_ENABLED=0 go build -o $(BINARY) app/cmd/goaws.go
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./app/cmd/
docker-build:
docker build -t $(IMAGE_REPO):$(VERSION) .
docker build --build-arg VERSION=$(VERSION) -t $(IMAGE_REPO):$(VERSION) -t $(IMAGE_REPO):latest .
docker-push:
docker push $(IMAGE_REPO):$(VERSION)
docker push $(IMAGE_REPO):latest
test:
go test ./...
+29 -22
View File
@@ -8,29 +8,34 @@
- **Многотенантность** — каждый тенант имеет отдельный Access Key и Secret Key
- **Redis persistence** — очереди и сообщения сохраняются при перезапуске сервиса
- **Web UI Console** — управление тенантами и просмотр сообщений
- **Kubernetes-ready** — Helm/манифесты, TLS Ingress, здоровье-checks
- **Managed-ready** — деплой Docker-образом через Nubes («Простой HTTP контейнер»), health-check /health
- **Admin API** — управление тенантами программно
## Быстрый старт
## Деплой (Nubes Managed)
### Локально
Сервис деплоится как Docker-образ через «Простой HTTP контейнер» (deck-UI) + Managed Redis.
Никакого Kubernetes/VPS.
### 1. Собрать и запушить образ в Gitea registry
```bash
docker run -d -p 9090:9090 naeel/shared-sqs:latest
# Откройте http://localhost:9090
docker build -t gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25 .
docker login gitea.services.ngcloud.ru -u ntazetdinov -p <токен>
docker push gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25
```
### На Kubernetes
### 2. Создать контейнер в deck-UI
```bash
kubectl apply -k deployments/k8s/
```
- Сервис: «Простой HTTP контейнер»
- Образ: `gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25`
- Realm: `iot-naeel`
- Env (СРАЗУ, потом не меняются): `SHARED_SQS_ADMIN_TOKEN`, `REDIS_PASSWORD`
### Сборка локально
### Локальный запуск
```bash
go build -o shared-sqs ./app/cmd
./shared-sqs --port 9090
./shared-sqs --port 4100
```
## Архитектура
@@ -47,26 +52,28 @@ app/
├── router/ — HTTP маршруты
└── ui/ — Web Console (HTML/JS)
deployments/k8s/
├── deployment.yaml — Deployment с Redis sidecar
├── ingress.yaml — TLS, qu.kube5s.ru
└── redis.yaml — Redis Persistent Volume
deployments/k8s/ — ЛЕГАСИ (старый k8s-деплой, не использовать)
deployments/helm/ — ЛЕГАСИ (helm chart, не использовать)
```
## Окружение
| Переменная | Значение | Описание |
|-----------|---------|---------|
| `PORT` | 9090 | Порт HTTP сервера |
| `REDIS_URL` | localhost:6379 | Redis для persistence |
| `PORT` (--port) | 4100 | Порт HTTP сервера |
| `REDIS_ADDR` | host:6379 | Redis для persistence |
| `REDIS_USER` | default | Redis username |
| `REDIS_PASSWORD` | — | Redis password (секрет) |
| `SHARED_SQS_ADMIN_TOKEN` | — | Admin API bearer token (обязательный) |
| `SHARED_SQS_SEED_DEMO` | true/false | Загрузить demo данные на старте |
| `NUBES_ENDPOINT` | https://deck-api.ngcloud.ru/api/v1 | JWT-валидация UI |
## API примеры
### Создать очередь (как тенант)
```bash
curl -X POST "http://localhost:9090/" \
curl -X POST "http://localhost:4100/" \
-H "Authorization: AWS4-HMAC-SHA256 Credential=SSAK-demo-shared-sqs/20260410/us-east-1/sqs/aws4_request" \
-d "Action=CreateQueue&QueueName=myqueue&Version=2012-11-05"
```
@@ -75,8 +82,8 @@ curl -X POST "http://localhost:9090/" \
```bash
aws sqs send-message \
--endpoint-url http://localhost:9090 \
--queue-url http://localhost:9090/demo-shared-sqs/myqueue \
--endpoint-url http://localhost:4100 \
--queue-url http://localhost:4100/demo-shared-sqs/myqueue \
--message-body "Hello World"
```
@@ -88,12 +95,12 @@ aws sqs send-message \
## Документация
- [PLAN.md](PLAN.md) — детальный план разработки
- [PLAN.md](doc/legacy/PLAN.md) — ЛЕГАСИ, исторический план разработки
- [doc/billing-and-metrics.md](doc/billing-and-metrics.md) — биллинг и Prometheus-метрики
- [doc/byoc-credentials.md](doc/byoc-credentials.md) — BYOC интеграция
## Ссылки
- Репо: https://gitea.services.ngcloud.ru/Nail/SQS-service
- Demo (dev): https://qu.kube5s.ru
- Registry: https://gitea.services.ngcloud.ru/nail/shared-sqs
- Основана на: [GoAws](https://github.com/Admiral-Piett/goaws)
+42 -12
View File
@@ -64,9 +64,8 @@ type Handler struct {
// NewHandler — создаёт admin handler
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
nubesEndpoint := os.Getenv("NUBES_ENDPOINT")
if nubesEndpoint == "" {
nubesEndpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
// Если не задан — пустой: PingNubesAPI переберёт все стенды сам.
auth.StartTokenCacheCleanup() // фоновая очистка кэша валидации (идемпотентно)
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
}
@@ -157,6 +156,7 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
// jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint
ui.Use(h.jwtMiddleware)
ui.HandleFunc("/auth", h.jwtAuth).Methods("POST")
ui.HandleFunc("/credentials", h.uiCredentials).Methods("GET")
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
ui.HandleFunc("/tenants", h.listTenants).Methods("GET")
ui.HandleFunc("/tenants", h.createTenant).Methods("POST")
@@ -223,6 +223,14 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
return
}
// Email — единственный ключ идентичности. Без него не создаём тенанта
// (иначе hash("") даст один тенант на всех пользователей без email).
if claims.Email == "" {
log.Warnf("jwt auth: token for sub=%s has no email claim", claims.Sub)
jsonErr(w, http.StatusBadRequest, "token has no email claim")
return
}
// Валидируем через nubes API
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -231,10 +239,13 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
return
}
// Логин — всегда свежая проверка; после успеха прогреваем кэш,
// чтобы дальнейшие запросы консоли не дёргали нестабильный шлюз.
auth.WarmTokenCache(req.Token)
// Auto-provisioning: создаём тенанта если не существует
tenantID := auth.TenantIDFromSub(claims.Sub)
t, err := h.store.CreateFromJWT(tenantID, claims.Sub, claims.Email, 10)
// Auto-provisioning: тенант и ключи детерминированы из email.
// Токен — только аутентификация; ключи в ответ не возвращаем (GET /ui/api/credentials).
t, err := h.store.CreateFromJWT(claims.Sub, claims.Email, tenant.DefaultTenantMaxQueues)
if err != nil {
log.Errorf("jwt auth: failed to create tenant: %v", err)
jsonErr(w, http.StatusInternalServerError, "failed to provision tenant")
@@ -247,13 +258,28 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"email": claims.Email,
"tenant_id": t.ID,
"access_key": t.AccessKey,
"secret_key": t.SecretKey,
"max_queues": t.MaxQueues,
"token": req.Token, // возвращаем для использования в последующих запросах
})
}
// uiCredentials — GET /ui/api/credentials: отдаёт AccessKey/SecretKey текущего тенанта.
// Ключи НЕ светятся в других ответах UI API — только по явному запросу под JWT-сессией.
func (h *Handler) uiCredentials(w http.ResponseWriter, r *http.Request) {
t, ok := currentUITenant(r)
if !ok {
jsonErr(w, http.StatusUnauthorized, "unauthorized")
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"tenant_id": t.ID,
"email": t.Email,
"access_key": t.AccessKey,
"secret_key": t.SecretKey,
})
}
// jwtMiddleware — middleware для /ui/api/* endpoints.
// Пропускает /ui/api/auth (публичный endpoint авторизации).
// Проверяет Authorization: Bearer <jwt> заголовок.
@@ -298,17 +324,18 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
return
}
// Повторно валидируем токен в nubes API на каждый UI API запрос,
// чтобы отозванные токены не оставались валидными до повторного логина.
// Повторная валидация через кэш: в течение TTL шлюз не вызывается
// (шлюз нестабилен с IP платформы), отозванный токен умрёт через TTL,
// явный 401/403 от шлюза сбрасывает кэш.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := auth.PingNubesAPI(ctx, h.nubesEndpoint, token); err != nil {
if err := auth.PingNubesAPICached(ctx, h.nubesEndpoint, token); err != nil {
jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
return
}
// Проверяем что тенант существует (был создан при /ui/api/auth)
jwtTenant, ok := h.store.GetBySub(claims.Sub)
jwtTenant, ok := h.store.GetByEmail(claims.Email)
if !ok {
jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth")
return
@@ -729,6 +756,7 @@ func jsonErr(w http.ResponseWriter, code int, msg string) {
// adminHealthDetail — ответ GET /admin/health
type adminHealthDetail struct {
Status string `json:"status"`
Version string `json:"version"`
TenantCount int `json:"tenant_count"`
QueueCount int `json:"queue_count"`
MessageCount int `json:"message_count"`
@@ -752,6 +780,7 @@ func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{
Status: "ok",
Version: models.Version,
TenantCount: 1,
QueueCount: queueCount,
MessageCount: msgCount,
@@ -770,6 +799,7 @@ func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{
Status: "ok",
Version: models.Version,
TenantCount: len(tenants),
QueueCount: queueCount,
MessageCount: msgCount,
+113
View File
@@ -0,0 +1,113 @@
// app/auth/cache.go
// Кэш успешной валидации JWT-токенов (в памяти процесса).
// Мотивация: Nubes-шлюз нестабильно отвечает на запросы с IP платформы
// (то 200, то 401/403), из-за чего повторная валидация на каждый запрос
// консоли роняла UI. Кэш: одна успешная проверка → TTL без обращений к шлюзу.
// Безопасность: отозванный токен перестаёт работать максимум через TTL;
// явный 401/403 сбрасывает запись немедленно.
// Created: 2026-08-14
package auth
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"sync"
"time"
)
// cacheTTL — срок жизни записи успешной валидации.
const cacheTTL = 5 * time.Minute
// cacheGraceTTL — продление при сетевой ошибке шлюза для ранее валидного токена.
const cacheGraceTTL = 2 * time.Minute
// cacheCleanupInterval — период фоновой очистки протухших записей.
const cacheCleanupInterval = 5 * time.Minute
type tokenCacheEntry struct {
validUntil time.Time
}
var (
tokenCacheMu sync.RWMutex
tokenCache = map[string]tokenCacheEntry{}
)
// tokenCacheKey — компактный ключ кэша: hex(SHA256(token)).
func tokenCacheKey(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
// PingNubesAPICached — валидация токена через шлюз с кэшем.
// HIT (запись валидна) → nil без обращения к шлюзу.
// MISS → свежая проверка: успех → запись с TTL; 401/403 → удаление записи и ошибка.
// Сетевая ошибка: если запись была (пусть и протухшая) → grace-продление и nil,
// иначе — ошибка (шлюз недоступен, кэша нет — справедливый отказ).
func PingNubesAPICached(ctx context.Context, endpoint, token string) error {
key := tokenCacheKey(token)
now := time.Now()
tokenCacheMu.RLock()
entry, ok := tokenCache[key]
tokenCacheMu.RUnlock()
if ok && now.Before(entry.validUntil) {
return nil // cache HIT
}
err := PingNubesAPI(ctx, endpoint, token)
if errors.Is(err, ErrTokenRejected) {
tokenCacheMu.Lock()
delete(tokenCache, key)
tokenCacheMu.Unlock()
return err
}
if err != nil {
// Сетевая ошибка шлюза: если токен ранее был валиден — даём grace.
if ok {
tokenCacheMu.Lock()
tokenCache[key] = tokenCacheEntry{validUntil: time.Now().Add(cacheGraceTTL)}
tokenCacheMu.Unlock()
return nil
}
return err
}
tokenCacheMu.Lock()
tokenCache[key] = tokenCacheEntry{validUntil: time.Now().Add(cacheTTL)}
tokenCacheMu.Unlock()
return nil
}
// WarmTokenCache — записывает токен в кэш как валидный без вызова шлюза.
// Используется после успешной свежей проверки при логине (jwtAuth).
func WarmTokenCache(token string) {
tokenCacheMu.Lock()
tokenCache[tokenCacheKey(token)] = tokenCacheEntry{validUntil: time.Now().Add(cacheTTL)}
tokenCacheMu.Unlock()
}
var tokenCacheCleanupOnce sync.Once
// StartTokenCacheCleanup — фоновая очистка протухших записей. Идемпотентна (sync.Once).
func StartTokenCacheCleanup() {
tokenCacheCleanupOnce.Do(func() {
go func() {
ticker := time.NewTicker(cacheCleanupInterval)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
tokenCacheMu.Lock()
for k, e := range tokenCache {
if now.After(e.validUntil) {
delete(tokenCache, k)
}
}
tokenCacheMu.Unlock()
}
}()
})
}
+49 -24
View File
@@ -7,9 +7,9 @@ package auth
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -65,35 +65,60 @@ func ParseJWTClaims(token string) (*JWTClaims, error) {
return &claims, nil
}
// TenantIDFromSub — вычисляет tenant ID из JWT subject (sub claim).
// Алгоритм совместим с sless: SHA256(sub) → первые 8 байт → hex → "sless-{16hex}".
// Длина: 6 + 16 = 22 символа. Детерминирован, необратим.
// Почему "sless-" а не "ssq-": единый namespace для всех сервисов (IoT, funcs, SQS).
func TenantIDFromSub(sub string) string {
hash := sha256.Sum256([]byte(sub))
return fmt.Sprintf("sless-%x", hash[:8])
// ErrTokenRejected — шлюз явно отклонил токен (401/403).
// Sentinel, чтобы отличать отказ от сетевых ошибок (см. cache.go).
var ErrTokenRejected = errors.New("token rejected by nubes API")
// nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен.
// Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял.
// Юзер может вводить токен от любого стенда — главное, что токен действительный.
var nubesAPIEndpoints = []string{
"https://lk-api-gateway.ngcloud.ru/api/v1/svc",
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
}
// PingNubesAPI — проверяет валидность токена запросом к nubes API.
// endpoint — базовый URL (например "https://deck-api-test.ngcloud.ru/api/v1").
// Логика: 401/403 → токен отклонён. Ошибка соединения → API недоступен.
// PingNubesAPI — проверяет валидность токена перебором стендов Nubes API.
// endpoint (если задан через NUBES_ENDPOINT) пробуется первым.
// Логика: 401/403 → токен отклонён ЭТИМ стендом, пробуем следующий.
// Любой другой HTTP статус → токен принят.
func PingNubesAPI(ctx context.Context, endpoint, token string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("build nubes ping request: %w", err)
candidates := make([]string, 0, len(nubesAPIEndpoints)+1)
if endpoint != "" {
candidates = append(candidates, endpoint)
}
for _, ep := range nubesAPIEndpoints {
if ep != endpoint {
candidates = append(candidates, ep)
}
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("nubes API unreachable at %s: %w", endpoint, err)
}
defer resp.Body.Close()
var lastErr error
for _, ep := range candidates {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
if err != nil {
lastErr = fmt.Errorf("build nubes ping request: %w", err)
continue
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "Mozilla/5.0")
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("nubes API rejected token (HTTP %d)", resp.StatusCode)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
lastErr = fmt.Errorf("nubes API unreachable at %s: %w", ep, err)
continue
}
resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
lastErr = fmt.Errorf("%w (HTTP %d)", ErrTokenRejected, resp.StatusCode)
continue
}
return nil
}
return nil
if lastErr == nil {
lastErr = fmt.Errorf("nubes API rejected token")
}
return lastErr
}
+14 -6
View File
@@ -140,13 +140,21 @@ func main() {
// Metrics: gauge updater — пересчёт очередей/сообщений per tenant каждые 15 секунд
metrics.StartGaugeUpdater(15*time.Second, quit)
// HTTP сервер с таймаутами
// HTTP сервер с таймаутами (рекомендации ревью Соннета, план v0.1.35):
// - ReadHeaderTimeout 10с — лимит ТОЛЬКО на чтение заголовков (slowloris).
// ReadTimeout НЕ ставим: он обрывал бы запросы с медленной/большой
// загрузкой тела (в т.ч. большие SendMessage) и ломал бы long-poll.
// - WriteTimeout 35с — ответ; чуть больше max WaitTimeSeconds (20с long-poll).
// - IdleTimeout 90с — keep-alive; больше интервала ретраев клиентских SDK.
// - MaxHeaderBytes 8192 — защита от гигантских заголовков
// (платформа режет >16KB, наш лимит строже).
srv := &http.Server{
Addr: "0.0.0.0:" + port,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s)
IdleTimeout: 60 * time.Second,
Addr: "0.0.0.0:" + port,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 35 * time.Second,
IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 8192,
}
// Запуск в горутине для graceful shutdown
-166
View File
@@ -1,166 +0,0 @@
package conf
import (
"testing"
"shared-sqs/app/models"
"github.com/stretchr/testify/assert"
)
func TestConfig_NoQueuesOrTopics(t *testing.T) {
env := "NoQueuesOrTopics"
port := LoadYamlConfig("./mock-data/mock-config.yaml", env)
if port[0] != "4100" {
t.Errorf("Expected port number 4200 but got %s\n", port)
}
numQueues := len(envs[env].Queues)
if numQueues != 0 {
t.Errorf("Expected zero queues to be in the environment but got %d\n", numQueues)
}
numQueues = len(models.SyncQueues.Queues)
if numQueues != 0 {
t.Errorf("Expected zero queues to be in the sqs topics but got %d\n", numQueues)
}
numTopics := len(envs[env].Topics)
if numTopics != 0 {
t.Errorf("Expected zero topics to be in the environment but got %d\n", numTopics)
}
numTopics = len(models.SyncTopics.Topics)
if numTopics != 0 {
t.Errorf("Expected zero topics to be in the sns topics but got %d\n", numTopics)
}
}
func TestConfig_CreateQueuesTopicsAndSubscriptions(t *testing.T) {
env := "Local"
port := LoadYamlConfig("./mock-data/mock-config.yaml", env)
if port[0] != "4100" {
t.Errorf("Expected port number 4100 but got %s\n", port)
}
numQueues := len(envs[env].Queues)
if numQueues != 4 {
t.Errorf("Expected three queues to be in the environment but got %d\n", numQueues)
}
numQueues = len(models.SyncQueues.Queues)
if numQueues != 6 {
t.Errorf("Expected five queues to be in the sqs topics but got %d\n", numQueues)
}
numTopics := len(envs[env].Topics)
if numTopics != 2 {
t.Errorf("Expected two topics to be in the environment but got %d\n", numTopics)
}
numTopics = len(models.SyncTopics.Topics)
if numTopics != 2 {
t.Errorf("Expected two topics to be in the sns topics but got %d\n", numTopics)
}
}
func TestConfig_QueueAttributes(t *testing.T) {
var emptyQueue *models.Queue
env := "Local"
port := LoadYamlConfig("./mock-data/mock-config.yaml", env)
if port[0] != "4100" {
t.Errorf("Expected port number 4100 but got %s\n", port)
}
assert.Equal(t, 10, models.SyncQueues.Queues["local-queue1"].ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 10, models.SyncQueues.Queues["local-queue1"].VisibilityTimeout)
assert.Equal(t, 1024, models.SyncQueues.Queues["local-queue1"].MaximumMessageSize)
assert.Equal(t, emptyQueue, models.SyncQueues.Queues["local-queue1"].DeadLetterQueue)
assert.Equal(t, 0, models.SyncQueues.Queues["local-queue1"].MaxReceiveCount)
assert.Equal(t, 345600, models.SyncQueues.Queues["local-queue1"].MessageRetentionPeriod)
assert.Equal(t, 100, models.SyncQueues.Queues["local-queue3"].MaxReceiveCount)
assert.Equal(t, "local-queue3-dlq", models.SyncQueues.Queues["local-queue3"].DeadLetterQueue.Name)
assert.Equal(t, 128, models.SyncQueues.Queues["local-queue2"].MaximumMessageSize)
assert.Equal(t, 150, models.SyncQueues.Queues["local-queue2"].VisibilityTimeout)
assert.Equal(t, 245600, models.SyncQueues.Queues["local-queue2"].MessageRetentionPeriod)
}
func TestConfig_NoQueueAttributeDefaults(t *testing.T) {
env := "NoQueueAttributeDefaults"
LoadYamlConfig("./mock-data/mock-config.yaml", env)
receiveWaitTime := models.SyncQueues.Queues["local-queue1"].ReceiveMessageWaitTimeSeconds
if receiveWaitTime != 0 {
t.Errorf("Expected local-queue1 Queue to be configured with ReceiveMessageWaitTimeSeconds: 0 but got %d\n", receiveWaitTime)
}
timeoutSecs := models.SyncQueues.Queues["local-queue1"].VisibilityTimeout
if timeoutSecs != 30 {
t.Errorf("Expected local-queue1 Queue to be configured with VisibilityTimeout: 30 but got %d\n", timeoutSecs)
}
receiveWaitTime = models.SyncQueues.Queues["local-queue2"].ReceiveMessageWaitTimeSeconds
if receiveWaitTime != 20 {
t.Errorf("Expected local-queue2 Queue to be configured with ReceiveMessageWaitTimeSeconds: 20 but got %d\n", receiveWaitTime)
}
messageRetentionPeriod := models.SyncQueues.Queues["local-queue1"].MessageRetentionPeriod
if messageRetentionPeriod != 345600 {
t.Errorf("Expected local-queue2 Queue to be configured with VisibilityTimeout: 150 but got %d\n", timeoutSecs)
}
}
func TestConfig_invalid_config_resorts_to_default_queue_attributes(t *testing.T) {
env := "missing"
port := LoadYamlConfig("./mock-data/mock-config.yaml", env)
if port[0] != "4100" {
t.Errorf("Expected port number 4100 but got %s\n", port)
}
assert.Equal(t, 262144, models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize)
assert.Equal(t, 345600, models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod)
assert.Equal(t, 0, models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, 30, models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout)
}
func TestConfig_LoadYamlConfig_finds_default_config(t *testing.T) {
expectedQueues := []string{
"local-queue1",
"local-queue2",
"local-queue3",
"local-queue3-dlq",
"local-queue4",
}
expectedTopics := []string{
"local-topic1",
"local-topic2",
"local-topic3",
"local-topic4",
}
env := "Local"
LoadYamlConfig("", env)
queues := models.SyncQueues.Queues
topics := models.SyncTopics.Topics
for _, expectedName := range expectedQueues {
_, ok := queues[expectedName]
assert.True(t, ok)
}
for _, expectedName := range expectedTopics {
_, ok := topics[expectedName]
assert.True(t, ok)
}
}
func TestConfig_LoadYamlConfig_missing_config_loads_nothing(t *testing.T) {
models.CurrentEnvironment = models.Environment{}
ports := LoadYamlConfig("/garbage", "Local")
assert.Equal(t, []string{"4100"}, ports)
assert.Equal(t, models.CurrentEnvironment, models.Environment{})
}
func TestConfig_LoadYamlConfig_invalid_config_loads_nothing(t *testing.T) {
models.CurrentEnvironment = models.Environment{}
ports := LoadYamlConfig("../common/common.go", "Local")
assert.Equal(t, []string{"4100"}, ports)
assert.Equal(t, models.CurrentEnvironment, models.Environment{})
}
+17 -11
View File
@@ -47,19 +47,26 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
if _, ok := models.SyncQueues.Queues[key]; !ok {
// ВСЯ работа с очередью — под одним Lock.
// Раньше проверка существования шла БЕЗ блокировки (чтение map) —
// concurrent map read and map write при параллельном DeleteQueue
// (fatal error, под падал под нагрузкой).
// defer Unlock: при любой панике в секции мьютекс не останется захваченным.
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
models.SyncQueues.Lock()
messageFound := false
var changedMsgUuid string
var msgRemoved bool
for i := 0; i < len(models.SyncQueues.Queues[key].Messages); i++ {
queue := models.SyncQueues.Queues[key]
for i := 0; i < len(queue.Messages); i++ {
msgs := queue.Messages
if msgs[i].ReceiptHandle == receiptHandle {
timeout := models.SyncQueues.Queues[key].VisibilityTimeout
timeout := queue.VisibilityTimeout
if visibilityTimeout == 0 {
msgs[i].ReceiptTime = time.Now().UTC()
msgs[i].ReceiptHandle = ""
@@ -83,23 +90,22 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
break
}
}
// Персистим изменения в Redis под Lock
// Персистим изменения в Redis под Lock (сетевые записи асинхронны).
if messageFound {
if msgRemoved {
// Сообщение удалено (перемещено в DLQ) — удаляем из Redis
persistence.DeleteMessagePersist(key, changedMsgUuid)
} else {
// Сообщение обновлено — перезаписываем в Redis
for i := range models.SyncQueues.Queues[key].Messages {
if models.SyncQueues.Queues[key].Messages[i].Uuid == changedMsgUuid {
persistence.SaveMessage(key, &models.SyncQueues.Queues[key].Messages[i])
for i := range queue.Messages {
if queue.Messages[i].Uuid == changedMsgUuid {
persistence.SaveMessage(key, &queue.Messages[i])
break
}
}
}
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
persistence.SaveQueue(key, queue)
}
models.SyncQueues.Unlock()
if !messageFound {
return utils.CreateErrorResponseV1("MessageNotInFlight", true)
}
+8 -6
View File
@@ -42,10 +42,6 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
key := tenantQueueKey(t.AccessKey, queueName)
if _, ok := models.SyncQueues.Queues[key]; !ok {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
if len(requestBody.Entries) == 0 {
return utils.CreateErrorResponseV1("EmptyBatchRequest", true)
}
@@ -66,6 +62,13 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
// Проверка существования очереди ВНУТРИ Lock: раньше была БЕЗ блокировки
// (чтение map) — concurrent map read/write при параллельном DeleteQueue.
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
successEntries := make([]models.ChangeMessageVisibilityBatchResultEntry, 0)
failedEntries := make([]models.BatchResultErrorEntry, 0)
@@ -81,7 +84,6 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
}
messageFound := false
queue := models.SyncQueues.Queues[key]
for i := 0; i < len(queue.Messages); i++ {
if queue.Messages[i].ReceiptHandle == entry.ReceiptHandle {
if entry.VisibilityTimeout == 0 {
@@ -114,7 +116,7 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
// Персистим изменения в Redis под Lock
if len(successEntries) > 0 {
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
persistence.SaveQueue(key, queue)
}
respStruct := models.ChangeMessageVisibilityBatchResponse{
@@ -1,75 +0,0 @@
package gosqs
import (
"net/http"
"testing"
"shared-sqs/app/test"
"shared-sqs/app/fixtures"
"shared-sqs/app/models"
"github.com/stretchr/testify/assert"
)
func TestChangeMessageVisibility_success(t *testing.T) {
// create a queue
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
defer func() {
models.ResetApp()
}()
q := &models.Queue{
Name: "testing",
Messages: []models.SqsMessage{{
MessageBody: "test1",
ReceiptHandle: "123",
}},
}
models.SyncQueues.Queues["testing"] = q
// The default value for the VisibilityTimeout is the zero value of time.Time
assert.Zero(t, q.Messages[0].VisibilityTimeout)
_, r := test.GenerateRequestInfo("POST", "/", models.ChangeMessageVisibilityRequest{
QueueUrl: "http://localhost:4100/queue/testing",
ReceiptHandle: "123",
VisibilityTimeout: 0,
}, true)
status, _ := ChangeMessageVisibilityV1(r)
assert.Equal(t, status, http.StatusOK)
// Changing the message visibility increments the time.Time by N seconds
// from the current time.
//
// Given that the current time is relative between calling the endpoint and
// the time being set, we can't reliably assert an exact value. So assert
// that the time.Time value is no longer the default zero value.
assert.NotZero(t, q.Messages[0].VisibilityTimeout)
assert.NotZero(t, q.Messages[0].ReceiptTime)
assert.Equal(t, "", q.Messages[0].ReceiptHandle)
assert.Equal(t, 1, q.Messages[0].Retry)
}
func TestChangeMessageVisibility_success_adds_to_existing_visibility_timeout(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_success_transfers_to_dead_letter_queue(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_request_transformer_error(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_visibility_timeout_too_large(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_missing_queue(t *testing.T) {
// TODO
}
func TestChangeMessageVisibility_missing_message(t *testing.T) {
// TODO - mismatch receipt handle
}
+22 -4
View File
@@ -1,6 +1,3 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// CreateQueueV1 — создаёт очередь для тенанта из request context.
// Ключ в SyncQueues: "{tenantAccessKey}:{queueName}" для изоляции между тенантами.
package gosqs
import (
@@ -15,6 +12,21 @@ import (
log "github.com/sirupsen/logrus"
)
// CreateQueueV1 — создаёт очередь для тенанта из request context.
//
// Логическая схема:
// 1. Разбор тела запроса (Query/JSON) → CreateQueueRequest.
// 2. Тенант из контекста (SigV4 → AccessKey).
// 3. Валидация имени очереди по AWS-правилам: до 80 символов, [a-zA-Z0-9_-],
// опциональный суффикс .fifo (ValidateQueueName).
// 4. Под Lock:
// - если очередь уже есть — просто возвращаем её URL (идемпотентность AWS);
// - проверка лимита очередей тенанта (MaxQueues) → LimitExceeded;
// - создание очереди с дефолтами и применение переданных атрибутов
// (setQueueAttributesV1 с валидацией диапазонов);
// - вставка в map с tenant-scoped ключом "{tenantAccessKey}:{queueName}".
// 5. Сохранение очереди в Redis (асинхронно, маршалинг под Lock).
// 6. Ответ: QueueUrl.
func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewCreateQueueRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -53,7 +65,13 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
Duplicates: make(map[string]time.Time),
}
if err := setQueueAttributesV1(queue, requestBody.Attributes); err != nil {
// Список имён переданных атрибутов — для whitelist-валидации
// (Query-протокол: Attribute.N.Name; JSON: nil — проверка пропускается).
var provided map[string]string
if req.Header.Get("Content-Type") != "application/x-amz-json-1.0" {
provided = utils.ExtractQueueAttributes(req.PostForm)
}
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1(err.Error(), true)
}
+13 -10
View File
@@ -40,10 +40,6 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
key := tenantQueueKey(t.AccessKey, queueName)
if _, ok := models.SyncQueues.Queues[key]; !ok {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
if len(requestBody.Entries) == 0 {
return utils.CreateErrorResponseV1("EmptyBatchRequest", true)
}
@@ -63,6 +59,13 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
// Проверка существования очереди ВНУТРИ Lock: раньше была БЕЗ блокировки
// (чтение map) — concurrent map read/write при параллельном DeleteQueue.
queue, exists := models.SyncQueues.Queues[key]
if !exists {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
deleteMessageMap := make(map[string]*deleteEntry)
for _, entry := range requestBody.Entries {
deleteMessageMap[entry.ReceiptHandle] = &deleteEntry{
@@ -74,13 +77,13 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0)
deletedUuids := make([]string, 0)
remainingMessages := make([]models.SqsMessage, 0, len(models.SyncQueues.Queues[key].Messages))
remainingMessages := make([]models.SqsMessage, 0, len(queue.Messages))
for _, message := range models.SyncQueues.Queues[key].Messages {
for _, message := range queue.Messages {
if de, found := deleteMessageMap[message.ReceiptHandle]; found {
log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID)
models.SyncQueues.Queues[key].UnlockGroup(message.GroupID)
delete(models.SyncQueues.Queues[key].Duplicates, message.DeduplicationID)
queue.UnlockGroup(message.GroupID)
delete(queue.Duplicates, message.DeduplicationID)
de.Deleted = true
deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id})
deletedUuids = append(deletedUuids, message.Uuid)
@@ -89,11 +92,11 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
}
}
models.SyncQueues.Queues[key].Messages = remainingMessages
queue.Messages = remainingMessages
// Удаляем сообщения из Redis отдельно — O(batch_size)
persistence.DeleteMessagesPersist(key, deletedUuids)
// Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
persistence.SaveQueue(key, queue)
notFoundEntries := make([]models.BatchResultErrorEntry, 0)
for _, de := range deleteMessageMap {
+18 -3
View File
@@ -1,5 +1,3 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// DeleteQueueV1 — удаляет очередь тенанта по tenant-scoped ключу.
package gosqs
import (
@@ -14,6 +12,17 @@ import (
log "github.com/sirupsen/logrus"
)
// DeleteQueueV1 — удаляет очередь тенанта по tenant-scoped ключу.
//
// Логическая схема:
// 1. Разбор тела запроса → DeleteQueueRequest.
// 2. Тенант из контекста (SigV4 → AccessKey).
// 3. Имя очереди из QueueUrl (последний сегмент) + tenant-scoped ключ.
// 4. Под Lock: проверка существования очереди → QueueNotFound (фикс: раньше
// удаление несуществующей очереди возвращало 200, как будто она удалена);
// удаление очереди из in-memory map.
// 5. Асинхронное удаление метаданных и всех сообщений очереди из Redis
// (persistence.DeleteQueue — защищено write-seq от гонки со SaveQueue).
func DeleteQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewDeleteQueueRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -34,8 +43,14 @@ func DeleteQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
log.Infof("Deleting Queue: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
// Проверка существования: AWS возвращает NonExistentQueue, а не 200
// (раньше удаление несуществующей очереди молча «успевало»).
if _, ok := models.SyncQueues.Queues[key]; !ok {
log.Warnf("Delete Queue: %s does not exist (tenant: %s)", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
delete(models.SyncQueues.Queues, key)
models.SyncQueues.Unlock()
// Удаляем из Redis асинхронно
persistence.DeleteQueue(key)
+79 -34
View File
@@ -13,46 +13,40 @@ func init() {
models.SyncQueues.Queues = make(map[string]*models.Queue)
}
// PeriodicTasks — фоновая обработка очередей, выполняется раз в `d` (1 секунда).
//
// Задачи на каждый такт (для КАЖДОЙ очереди, см. processQueueTick):
// 1. Очистка просроченных записей deduplication-окна (FIFO).
// 2. Возврат сообщений в видимое состояние по истечении VisibilityTimeout.
// 3. Перенос сообщений в DeadLetterQueue при достижении MaxReceiveCount.
//
// ВАЖНО (фикс производительности): раньше глобальный SyncQueues.Lock удерживался
// на время обхода ВСЕХ очередей — при большом числе очередей HTTP-обработчики
// блокировались на секунды (наблюдались ReadTimeout и latency до 54 секунд).
// Теперь блокировка захватывается ПО ОДНОЙ очереди: между обработкой двух
// очередей другие горутины могут вклиниться.
func PeriodicTasks(d time.Duration, quit chan bool) {
ticker := time.NewTicker(d)
for {
select {
case <-ticker.C:
models.SyncQueues.Lock()
for qName := range models.SyncQueues.Queues {
queue := models.SyncQueues.Queues[qName]
// Reset deduplication period
for dedupId, startTime := range queue.Duplicates {
if time.Now().After(startTime.Add(models.DeduplicationPeriod)) {
log.Debugf("deduplication period for message with deduplicationId [%s] expired", dedupId)
delete(queue.Duplicates, dedupId)
}
}
log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages))
for i := 0; i < len(queue.Messages); i++ {
msg := &queue.Messages[i]
if msg.ReceiptHandle != "" {
if msg.VisibilityTimeout.Before(time.Now()) {
log.Debugf("Making message visible again %s", msg.ReceiptHandle)
queue.UnlockGroup(msg.GroupID)
msg.ReceiptHandle = ""
msg.ReceiptTime = time.Now().UTC()
msg.Retry++
if queue.MaxReceiveCount > 0 &&
queue.DeadLetterQueue != nil &&
msg.Retry >= queue.MaxReceiveCount {
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg)
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
i--
}
}
}
}
// Снапшот указателей на очереди под коротким RLock.
// Указатели остаются валидными после снятия RLock: очереди из map
// не удаляются во время итерации, а объекты живут в куче.
models.SyncQueues.RLock()
queues := make([]*models.Queue, 0, len(models.SyncQueues.Queues))
for _, queue := range models.SyncQueues.Queues {
queues = append(queues, queue)
}
models.SyncQueues.RUnlock()
// Каждую очередь обрабатываем под ОТДЕЛЬНЫМ Lock — блокировка одной
// очереди не мешает обработке остальных и HTTP-запросам.
for _, queue := range queues {
models.SyncQueues.Lock()
processQueueTick(queue)
models.SyncQueues.Unlock()
}
models.SyncQueues.Unlock()
case <-quit:
ticker.Stop()
return
@@ -60,6 +54,57 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
}
}
// processQueueTick — обработка ОДНОЙ очереди за один такт.
//
// ВЫЗЫВАТЬ ТОЛЬКО под удержанием SyncQueues.Lock.
//
// Логическая схема:
// 1. Deduplication-окно (FIFO): удаляем записи старше DeduplicationPeriod.
// 2. Проходим по сообщениям очереди:
// - пустой ReceiptHandle (не in-flight) — пропускаем;
// - VisibilityTimeout ещё не истёк — пропускаем;
// - иначе: возвращаем видимость (сброс ReceiptHandle, разблокировка
// FIFO-группы, обновление ReceiptTime), инкрементируем Retry;
// - если Retry >= MaxReceiveCount и настроена DLQ — переносим сообщение
// в DLQ и удаляем из исходной очереди.
func processQueueTick(queue *models.Queue) {
// 1. Просроченные записи deduplication-окна.
for dedupId, startTime := range queue.Duplicates {
if time.Now().After(startTime.Add(models.DeduplicationPeriod)) {
log.Debugf("deduplication period for message with deduplicationId [%s] expired", dedupId)
delete(queue.Duplicates, dedupId)
}
}
// 2. Возврат видимости истёкших сообщений + перенос в DLQ.
log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages))
for i := 0; i < len(queue.Messages); i++ {
msg := &queue.Messages[i]
if msg.ReceiptHandle == "" {
continue // сообщение не в полёте
}
if msg.VisibilityTimeout.After(time.Now()) {
continue // таймаут видимости ещё действует
}
log.Debugf("Making message visible again %s", msg.ReceiptHandle)
queue.UnlockGroup(msg.GroupID)
msg.ReceiptHandle = ""
msg.ReceiptTime = time.Now().UTC()
msg.Retry++
// 3. Перенос в DLQ при превышении MaxReceiveCount.
if queue.MaxReceiveCount > 0 &&
queue.DeadLetterQueue != nil &&
msg.Retry >= queue.MaxReceiveCount {
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg)
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
i-- // элемент на позиции i удалён — повторно проверяем новый элемент на ней
}
}
}
func numberOfHiddenMessagesInQueue(queue models.Queue) int {
num := 0
for _, m := range queue.Messages {
+24 -2
View File
@@ -1,5 +1,3 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// PurgeQueueV1 — очищает все сообщения в очереди тенанта.
package gosqs
import (
@@ -15,6 +13,21 @@ import (
log "github.com/sirupsen/logrus"
)
// PurgeQueueV1 — очищает ВСЕ сообщения очереди тенанта.
//
// Логическая схема:
// 1. Разбор тела запроса → PurgeQueueRequest.
// 2. Тенант из контекста (SigV4 → AccessKey).
// 3. Имя очереди из QueueUrl (последний сегмент).
// 4. Под Lock:
// - проверка существования очереди → QueueNotFound;
// - защита от повторного purge (фикс): повторный вызов в течение 60 секунд
// → PurgeQueueInProgress (требование AWS). Раньше purge можно было
// вызывать без ограничений;
// - очистка in-memory сообщений и dedup-записей;
// - фиксация LastPurgeTime;
// - асинхронная очистка Redis (DEL хэша сообщений) + SaveQueue.
// 5. Ответ: пустой PurgeQueueResponse.
func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewPurgeQueueRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -39,9 +52,18 @@ func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
// AWS: PurgeQueue допускается не чаще одного раза в 60 секунд на очередь.
// Нулевой LastPurgeTime (purge ещё не выполнялся) → запрет не срабатывает.
if time.Since(models.SyncQueues.Queues[key].LastPurgeTime) < 60*time.Second {
log.Warnf("Purge Queue: %s rejected — purge in progress (last: %s)", queueName, models.SyncQueues.Queues[key].LastPurgeTime.Format(time.RFC3339))
return utils.CreateErrorResponseV1("PurgeQueueInProgress", true)
}
log.Infof("Purging Queue: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.Queues[key].Messages = nil
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time)
// Фиксируем время purge — защита от повторного вызова в течение 60 секунд.
models.SyncQueues.Queues[key].LastPurgeTime = time.Now()
// Удаляем все сообщения из Redis одной командой DEL
persistence.PurgeMessagesPersist(key)
// Сохраняем пустые метаданные очереди
+59 -17
View File
@@ -9,31 +9,73 @@ import (
"shared-sqs/app/models"
)
// TODO - Support:
// - attr.MessageRetentionPeriod
// - attr.Policy
// - attr.RedriveAllowPolicy
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes) error {
// AWS-совместимые лимиты: clamp значений к допустимым диапазонам
if attr.DelaySeconds >= 0 {
q.DelaySeconds = ClampInt(attr.DelaySeconds.Int(), 0, MaxDelaySeconds)
// setQueueAttributesV1 — применяет атрибуты очереди с валидацией по AWS-спецификации.
//
// Логическая схема:
// 1. Whitelist имён: неизвестное имя атрибута → InvalidAttributeName.
// Список имён (provided) доступен для Query-протокола (form-параметры
// Attribute.N.Name); для JSON-протокола provided == nil и проверка пропускается
// (структура уже разобрана из тела, неизвестные поля отброшены).
// 2. Применяются ТОЛЬКО явно переданные атрибуты (значение != 0).
// ВАЖНО (фикс): раньше условие `attr.X >= 0` срабатывало и для НЕ переданных
// полей (zero-value = 0) — вызов SetQueueAttributes с одним атрибутом
// обнулял все остальные атрибуты очереди.
// 3. Значения вне диапазонов AWS → ошибка InvalidParameterValue
// (раньше значения молча клэмпились в допустимый диапазон).
// 4. RedrivePolicy: ARN DLQ разбирается, DLQ ищется по TENANT-SCOPED ключу
// "{accessKey}:{queueName}" (раньше — по голому имени, а ключи в map
// tenant-scoped → DLQ никогда не находилась, RedrivePolicy не работал).
// DLQ должна принадлежать тому же тенанту.
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes, provided map[string]string, tenantAccessKey string) error {
// Шаг 1: whitelist имён атрибутов (единый список — models.AttrNameWhitelist).
for name := range provided {
if !models.AttrNameWhitelist[name] {
return fmt.Errorf("InvalidAttributeName")
}
}
if attr.MaximumMessageSize >= 0 {
q.MaximumMessageSize = ClampInt(attr.MaximumMessageSize.Int(), MinMessageSizeLimit, MaxMessageSizeLimit)
// Шаг 2: применяем только явно переданные атрибуты с проверкой диапазонов.
// Диапазоны AWS: DelaySeconds 0900, MaximumMessageSize 1024262144,
// MessageRetentionPeriod 601209600, ReceiveMessageWaitTimeSeconds 020,
// VisibilityTimeout 043200.
if attr.DelaySeconds != 0 {
if attr.DelaySeconds.Int() < 0 || attr.DelaySeconds.Int() > MaxDelaySeconds {
return fmt.Errorf("InvalidParameterValue")
}
q.DelaySeconds = attr.DelaySeconds.Int()
}
if attr.MessageRetentionPeriod > 0 {
q.MessageRetentionPeriod = ClampInt(attr.MessageRetentionPeriod.Int(), MinMessageRetentionPeriod, MaxMessageRetentionPeriod)
if attr.MaximumMessageSize != 0 {
if attr.MaximumMessageSize.Int() < MinMessageSizeLimit || attr.MaximumMessageSize.Int() > MaxMessageSizeLimit {
return fmt.Errorf("InvalidParameterValue")
}
q.MaximumMessageSize = attr.MaximumMessageSize.Int()
}
if attr.ReceiveMessageWaitTimeSeconds > 0 {
q.ReceiveMessageWaitTimeSeconds = ClampInt(attr.ReceiveMessageWaitTimeSeconds.Int(), 0, MaxReceiveMessageWaitTimeSeconds)
if attr.MessageRetentionPeriod != 0 {
if attr.MessageRetentionPeriod.Int() < MinMessageRetentionPeriod || attr.MessageRetentionPeriod.Int() > MaxMessageRetentionPeriod {
return fmt.Errorf("InvalidParameterValue")
}
q.MessageRetentionPeriod = attr.MessageRetentionPeriod.Int()
}
if attr.VisibilityTimeout >= 0 {
q.VisibilityTimeout = ClampInt(attr.VisibilityTimeout.Int(), 0, MaxVisibilityTimeout)
if attr.ReceiveMessageWaitTimeSeconds != 0 {
if attr.ReceiveMessageWaitTimeSeconds.Int() < 0 || attr.ReceiveMessageWaitTimeSeconds.Int() > MaxReceiveMessageWaitTimeSeconds {
return fmt.Errorf("InvalidParameterValue")
}
q.ReceiveMessageWaitTimeSeconds = attr.ReceiveMessageWaitTimeSeconds.Int()
}
if attr.VisibilityTimeout != 0 {
if attr.VisibilityTimeout.Int() < 0 || attr.VisibilityTimeout.Int() > MaxVisibilityTimeout {
return fmt.Errorf("InvalidParameterValue")
}
q.VisibilityTimeout = attr.VisibilityTimeout.Int()
}
// Шаг 3: RedrivePolicy (dead-letter queue).
if attr.RedrivePolicy != (models.RedrivePolicy{}) {
arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":")
queueName := arnArray[len(arnArray)-1]
deadLetterQueue, ok := models.SyncQueues.Queues[queueName]
// DLQ ищем по tenant-scoped ключу — как хранятся все очереди.
dlqKey := tenantAccessKey + ":" + queueName
deadLetterQueue, ok := models.SyncQueues.Queues[dlqKey]
if !ok {
log.Error("Invalid RedrivePolicy Attribute")
return fmt.Errorf("InvalidAttributeValue")
+152 -83
View File
@@ -19,7 +19,27 @@ import (
log "github.com/sirupsen/logrus"
)
// ReceiveMessageV1 — получает до MaxNumberOfMessages сообщений из очереди тенанта.
//
// Логическая схема:
// 1. Разбор тела запроса (Query-форма или JSON) → ReceiveMessageRequest.
// 2. Аутентифицированный тенант из контекста (SigV4 → AccessKey).
// 3. Валидация входных параметров (AWS-совместимая):
// - MaxNumberOfMessages: 110; 0 (не задан) = 1. Выход за диапазон — ошибка
// InvalidParameterValue (раньше значение молча клэмпилось).
// - VisibilityTimeout: 043200 секунд, иначе InvalidParameterValue.
// - WaitTimeSeconds: 020 секунд; 0 = брать атрибут очереди
// ReceiveMessageWaitTimeSeconds.
// 4. Имя очереди: из QueueUrl (последний сегмент) или из пути /{account}/{queueName}.
// 5. Выборка сообщений — АТОМАРНО под одним Lock (см. receiveMessagesUnderLock):
// устранена гонка TOCTOU, когда два параллельных receive видели одни и те же
// сообщения, но один из них получал пустой ответ («потери» сообщений).
// 6. Long polling: при WaitTimeSeconds > 0 ждём сообщений тиками по 100 мс до
// истечения deadline; прерываемся по отмене HTTP-запроса (клиент отвалился).
//
// Возвращает HTTP 200 и ReceiveMessageResponse (пустой Result, если сообщений нет).
func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
// --- Шаг 1: разбор тела запроса ---
requestBody := models.NewReceiveMessageRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
if !ok {
@@ -27,18 +47,31 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
// --- Шаг 2: тенант из контекста ---
t := getTenantFromContext(req)
if t == nil {
return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
}
// --- Шаг 3: валидация параметров ---
// MaxNumberOfMessages: AWS допускает 1–10; 0 (параметр не задан) означает 1.
// Выход за диапазон — ошибка, а не молчаливый clamp (так делает AWS).
maxNumberOfMessages := requestBody.MaxNumberOfMessages
if maxNumberOfMessages == 0 {
maxNumberOfMessages = 1
maxNumberOfMessages = MinNumberOfMessagesLimit
}
if maxNumberOfMessages < MinNumberOfMessagesLimit || maxNumberOfMessages > MaxNumberOfMessagesLimit {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
// Fix #8: clamp MaxNumberOfMessages к AWS лимиту 110
maxNumberOfMessages = ClampInt(maxNumberOfMessages, MinNumberOfMessagesLimit, MaxNumberOfMessagesLimit)
// VisibilityTimeout: AWS допускает 043200.
if requestBody.VisibilityTimeout < 0 || requestBody.VisibilityTimeout > MaxVisibilityTimeout {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
// --- Шаг 4: имя очереди ---
// QueueUrl не задан → берём из пути /{account}/{queueName} (старый стиль клиентов).
// QueueUrl задан → последний сегмент URL = имя очереди.
queueName := ""
if requestBody.QueueUrl == "" {
vars := mux.Vars(req)
@@ -50,118 +83,148 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
key := tenantQueueKey(t.AccessKey, queueName)
if _, ok := models.SyncQueues.Queues[key]; !ok {
// ОДИН RLock на проверку существования И чтение атрибута очереди:
// раньше было два отдельных RLock — между ними очередь могла быть удалена
// (nil-deref при чтении ReceiveMessageWaitTimeSeconds).
models.SyncQueues.RLock()
queue, queueExists := models.SyncQueues.Queues[key]
if !queueExists {
models.SyncQueues.RUnlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
var messages []*models.ResultMessage
respStruct := models.ReceiveMessageResponse{}
// Валидация VisibilityTimeout: AWS SQS допускает 043200
if requestBody.VisibilityTimeout < 0 || requestBody.VisibilityTimeout > MaxVisibilityTimeout {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
waitTimeSeconds := requestBody.WaitTimeSeconds
if waitTimeSeconds == 0 {
models.SyncQueues.RLock()
waitTimeSeconds = models.SyncQueues.Queues[key].ReceiveMessageWaitTimeSeconds
models.SyncQueues.RUnlock()
waitTimeSeconds = queue.ReceiveMessageWaitTimeSeconds
}
// Валидация WaitTimeSeconds: AWS SQS допускает 0–20, иначе ошибка
models.SyncQueues.RUnlock()
if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
// --- Шаги 6–7: выборка сообщений (атомарно) + long polling ---
if waitTimeSeconds > 0 {
deadline := time.Now().Add(time.Duration(waitTimeSeconds) * time.Second)
pollTicker := time.NewTicker(100 * time.Millisecond)
defer pollTicker.Stop()
for {
models.SyncQueues.RLock()
queue, queueFound := models.SyncQueues.Queues[key]
if !queueFound {
models.SyncQueues.RUnlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
// Атомарная проверка+выборка: между проверкой и выдачей другой
// получатель не может «съесть» сообщения — они помечаются
// ReceiptHandle в том же критическом участке (см. receiveMessagesUnderLock).
messages, found := receiveMessagesUnderLock(key, requestBody.VisibilityTimeout, maxNumberOfMessages)
if found {
return http.StatusOK, buildReceiveResponse(messages)
}
messageFound := queueHasReceivableMessages(queue)
models.SyncQueues.RUnlock()
if messageFound || time.Now().After(deadline) {
break
if time.Now().After(deadline) {
return http.StatusOK, buildReceiveResponse(nil)
}
select {
case <-req.Context().Done():
return http.StatusOK, models.ReceiveMessageResponse{
Xmlns: models.BaseXmlns,
Result: models.ReceiveMessageResult{},
Metadata: models.BaseResponseMetadata,
}
// Клиент разорвал соединение — завершаем без ошибки.
return http.StatusOK, buildReceiveResponse(nil)
case <-pollTicker.C:
}
}
}
log.Debugf("Getting Message from Queue:%s (tenant: %s)", queueName, t.ID)
messages, _ := receiveMessagesUnderLock(key, requestBody.VisibilityTimeout, maxNumberOfMessages)
return http.StatusOK, buildReceiveResponse(messages)
}
// receiveMessagesUnderLock — АТОМАРНАЯ проверка и выборка сообщений очереди.
//
// ВЫЗЫВАТЬ БЕЗ удержания SyncQueues-блокировок.
// Возвращает:
// - messages — готовые к ответу ResultMessage (уже помечены in-flight);
// - found=false — пригодных сообщений нет (очередь пуста, всё in-flight,
// всё на задержке доставки или все FIFO-группы заблокированы).
//
// Почему весь цикл под одним Lock (фикс гонки TOCTOU):
// раньше проверка queueHasReceivableMessages выполнялась под RLock, а выборка —
// под Lock отдельно. Два параллельных receive оба проходили проверку, затем
// первый забирал все сообщения, а второй получал пустой ответ — сообщения
// «терялись» для второго клиента. Теперь проверка и пометка ReceiptHandle
// выполняются в одном критическом участке.
//
// Для каждого выбранного сообщения:
// - генерируется ReceiptHandle = {Uuid}#{случайный id} — уникален для каждой
// доставки (два разных receive не имеют одинакового хендла);
// - проставляется VisibilityTimeout (параметр запроса или атрибут очереди);
// - инкрементируется NumberOfReceives — счётчик доставок
// (ApproximateReceiveCount в ответе);
// - для FIFO-очереди блокируется группа (LockGroup) до DeleteMessage/таймаута.
func receiveMessagesUnderLock(key string, visibilityTimeout, maxNumberOfMessages int) ([]*models.ResultMessage, bool) {
models.SyncQueues.Lock()
defer models.SyncQueues.Unlock()
if len(models.SyncQueues.Queues[key].Messages) > 0 {
numMsg := 0
messages = make([]*models.ResultMessage, 0)
for i := range models.SyncQueues.Queues[key].Messages {
if numMsg >= maxNumberOfMessages {
break
}
if models.SyncQueues.Queues[key].Messages[i].ReceiptHandle != "" {
continue
}
msg := &models.SyncQueues.Queues[key].Messages[i]
if !msg.IsReadyForReceipt() {
continue
}
if models.SyncQueues.Queues[key].IsFIFO {
if models.SyncQueues.Queues[key].IsLocked(msg.GroupID) {
continue
}
models.SyncQueues.Queues[key].LockGroup(msg.GroupID)
}
randomId := uuid.NewString()
msg.ReceiptHandle = msg.Uuid + "#" + randomId
msg.ReceiptTime = time.Now().UTC()
if requestBody.VisibilityTimeout != 0 {
msg.VisibilityTimeout = time.Now().Add(time.Duration(requestBody.VisibilityTimeout) * time.Second)
} else {
msg.VisibilityTimeout = time.Now().Add(time.Duration(models.SyncQueues.Queues[key].VisibilityTimeout) * time.Second)
}
messages = append(messages, buildResultMessage(msg))
numMsg++
}
respStruct = models.ReceiveMessageResponse{
"http://queue.amazonaws.com/doc/2012-11-05/",
models.ReceiveMessageResult{Messages: messages},
models.ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"},
}
} else {
log.Warning("No messages in Queue:", queueName)
respStruct = models.ReceiveMessageResponse{
Xmlns: "http://queue.amazonaws.com/doc/2012-11-05/",
Result: models.ReceiveMessageResult{},
Metadata: models.ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"},
}
queue, queueFound := models.SyncQueues.Queues[key]
if !queueFound || len(queue.Messages) == 0 {
return nil, false
}
// Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение.
if !queueHasReceivableMessages(queue) {
return nil, false
}
return http.StatusOK, respStruct
messages := make([]*models.ResultMessage, 0)
numMsg := 0
for i := range queue.Messages {
if numMsg >= maxNumberOfMessages {
break
}
msg := &queue.Messages[i]
if msg.ReceiptHandle != "" {
continue // сообщение уже в полёте у другого получателя
}
if !msg.IsReadyForReceipt() {
continue // задержка доставки (DelaySeconds / random latency) ещё не истекла
}
if queue.IsFIFO {
if queue.IsLocked(msg.GroupID) {
continue // FIFO-группа заблокирована предыдущим in-flight сообщением
}
queue.LockGroup(msg.GroupID)
}
// Пометка in-flight: уникальный ReceiptHandle + таймаут видимости.
msg.ReceiptHandle = msg.Uuid + "#" + uuid.NewString()
msg.ReceiptTime = time.Now().UTC()
if visibilityTimeout != 0 {
msg.VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
} else {
msg.VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second)
}
msg.NumberOfReceives++
messages = append(messages, buildResultMessage(msg))
numMsg++
}
if numMsg == 0 {
return nil, false
}
return messages, true
}
// buildReceiveResponse — формирует успешный ответ ReceiveMessage.
// При messages == nil Result содержит пустой список (как AWS: без <Message>).
func buildReceiveResponse(messages []*models.ResultMessage) models.ReceiveMessageResponse {
return models.ReceiveMessageResponse{
Xmlns: models.BaseXmlns,
Result: models.ReceiveMessageResult{Messages: messages},
Metadata: models.BaseResponseMetadata,
}
}
// buildResultMessage — конвертирует SqsMessage в ResultMessage (XML/JSON ответ).
//
// Атрибуты AWS-совместимы:
// - ApproximateFirstReceiveTimestamp — время первой выдачи (ReceiptTime);
// - ApproximateReceiveCount — число доставок (NumberOfReceives + 1);
// - SentTimestamp — реальное время отправки (SentTime), НЕ время выдачи.
//
// MD5 тела берётся из кэша (посчитан при SendMessage) — без пересчёта.
func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
return &models.ResultMessage{
MessageId: m.Uuid,
@@ -179,6 +242,12 @@ func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
}
}
// queueHasReceivableMessages — проверяет, есть ли в очереди хотя бы ОДНО готовое
// к выдаче сообщение: не in-flight, задержка доставки истекла, FIFO-группа не
// заблокирована. Используется как быстрая предпроверка перед выборкой.
//
// ВАЖНО: вызывается ТОЛЬКО под удержанием блокировки (Lock или RLock) —
// читает общее состояние очереди.
func queueHasReceivableMessages(queue *models.Queue) bool {
for i := range queue.Messages {
msg := &queue.Messages[i]
+49 -11
View File
@@ -1,7 +1,3 @@
// Изменено: 2026-04-11 — убрано логирование тела сообщения (perf + security)
// SendMessageV1 — добавляет сообщение в очередь тенанта.
// Ловушка #6: queueName извлекается как ПОСЛЕДНИЙ сегмент URL — при URL вида
// http://host/tenantID/queueName последний сегмент = queueName (правильно).
package gosqs
import (
@@ -21,6 +17,27 @@ import (
"github.com/gorilla/mux"
)
// SendMessageV1 — добавляет сообщение в очередь тенанта.
//
// Логическая схема:
// 1. Разбор тела запроса (Query/JSON) → SendMessageRequest.
// 2. Аутентифицированный тенант из контекста (SigV4 → AccessKey).
// 3. Валидации:
// - MessageBody обязателен (пустой → MissingParameter);
// - DeduplicationId и GroupId ≤ 128 символов;
// - MessageAttributes ≤ 10;
// - FIFO-очередь БЕЗ MessageGroupId → MissingParameter (AWS требует GroupId
// для каждой отправки в FIFO);
// - размер тела ≤ MaximumMessageSize очереди → MessageTooBig;
// - число сообщений < лимита очереди (OOM-защита) → OverLimit.
// 4. Построение SqsMessage: MD5 тела и атрибутов, UUID, метки времени, задержка.
// 5. Атомарно под Lock:
// - FIFO: порядковый номер группы (NextSequenceNumber);
// - проверка дубликата по deduplicationId (дубль НЕ добавляется);
// - добавление в очередь + SaveMessage/SaveQueue в Redis.
// Примечание: сетевые Redis-записи асинхронны (asyncWrite), под Lock
// выполняется только json.Marshal — блокировка не удерживается на сетевом I/O.
// 6. Ответ: MessageId, MD5OfMessageBody/MD5OfMessageAttributes, SequenceNumber (FIFO).
func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewSendMessageRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -78,6 +95,13 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
delaySecs := queue.DelaySeconds
models.SyncQueues.RUnlock()
// FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой
// отправки в FIFO (иначе невозможно гарантировать порядок внутри группы).
// Раньше сообщение просто попадало в группу с пустым именем.
if queueIsFIFO && messageGroupID == "" {
return utils.CreateErrorResponseV1("MissingParameter", true)
}
if maxMessageSize > 0 && len(messageBody) > maxMessageSize {
return utils.CreateErrorResponseV1("MessageTooBig", true)
}
@@ -105,22 +129,36 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
msg.DelaySecs = delaySecs
models.SyncQueues.Lock()
fifoSeqNumber := ""
if models.SyncQueues.Queues[key].IsFIFO {
fifoSeqNumber = models.SyncQueues.Queues[key].NextSequenceNumber(messageGroupID)
// Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой
// выше и захватом Lock (иначе nil-pointer deref).
queue = models.SyncQueues.Queues[key]
if queue == nil {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
// Повторная проверка лимита под Lock: между RLock-снимком и захватом Lock
// другие горутины могли добавить сообщения (мягкий OOM-лимит).
if len(queue.Messages) >= MaxMessagesForQueue(queue.IsFIFO) {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("OverLimit", true)
}
if !models.SyncQueues.Queues[key].IsDuplicate(messageDeduplicationID) {
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
fifoSeqNumber := ""
if queue.IsFIFO {
fifoSeqNumber = queue.NextSequenceNumber(messageGroupID)
}
if !queue.IsDuplicate(messageDeduplicationID) {
queue.Messages = append(queue.Messages, msg)
// Персистим одно сообщение отдельно — O(msg_size) вместо O(N*msg_size)
persistence.SaveMessage(key, &msg)
} else {
log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName)
}
models.SyncQueues.Queues[key].InitDuplicatation(messageDeduplicationID)
queue.InitDuplicatation(messageDeduplicationID)
// Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
persistence.SaveQueue(key, queue)
models.SyncQueues.Unlock()
// Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody))
+6
View File
@@ -105,7 +105,13 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
}
models.SyncQueues.Lock()
// Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой
// выше и захватом Lock (иначе nil-pointer deref).
queue = models.SyncQueues.Queues[key]
if queue == nil {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
newMsgs := make([]models.SqsMessage, 0, len(sendEntries))
for _, sendEntry := range sendEntries {
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
+21 -4
View File
@@ -1,6 +1,3 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// SetQueueAttributesV1 — устанавливает атрибуты очереди тенанта.
// Ловушка #9: при RedrivePolicy парсим ARN DLQ и DLQ тоже должна принадлежать тому же тенанту.
package gosqs
import (
@@ -15,6 +12,17 @@ import (
log "github.com/sirupsen/logrus"
)
// SetQueueAttributesV1 — устанавливает атрибуты очереди тенанта.
//
// Логическая схема:
// 1. Разбор тела запроса (Query/JSON) → SetQueueAttributesRequest.
// 2. QueueUrl обязателен → InvalidParameterValue.
// 3. Тенант из контекста (SigV4 → AccessKey).
// 4. Имя очереди из QueueUrl (последний сегмент) + tenant-scoped ключ.
// 5. Под Lock: проверка существования очереди → QueueNotFound;
// применение атрибутов с валидацией (setQueueAttributesV1);
// сохранение очереди в Redis (асинхронно, маршалинг под Lock).
// 6. Ответ: пустой SetQueueAttributesResponse (AWS так и отвечает).
func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewSetQueueAttributesRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -44,7 +52,16 @@ func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
log.Warningf("Set Queue Attributes: %s, queue does not exist for tenant %s", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
if err := setQueueAttributesV1(queue, requestBody.Attributes); err != nil {
// Список имён переданных атрибутов — для whitelist-валидации.
// Query-протокол: имена видны в form (Attribute.N.Name).
// JSON-протокол: структура уже разобрана из тела, список имён недоступен → nil
// (whitelist-проверка пропускается, неизвестные поля отброшены парсером).
var provided map[string]string
if req.Header.Get("Content-Type") != "application/x-amz-json-1.0" {
provided = utils.ExtractQueueAttributes(req.PostForm)
}
if err := setQueueAttributesV1(queue, requestBody.Attributes, provided, t.AccessKey); err != nil {
return utils.CreateErrorResponseV1(err.Error(), true)
}
// Сохраняем атрибуты в Redis пока держим Lock (через defer)
+4
View File
@@ -4,6 +4,10 @@ import (
"time"
)
// Version — версия сервиса. Переопределяется при сборке:
// -ldflags "-X shared-sqs/app/models.Version=<версия>"
var Version = "dev"
var BaseXmlns = "http://queue.amazonaws.com/doc/2012-11-05/"
var BaseResponseMetadata = ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"}
-75
View File
@@ -1,75 +0,0 @@
package models
import (
"encoding/json"
"testing"
"shared-sqs/app/test"
"github.com/stretchr/testify/assert"
)
type StringToIntStruct struct {
Field1 StringToInt `json:"Field1"`
Field2 StringToInt `json:"Field2"`
}
func TestStringToInt_unmarshalJSON_int(t *testing.T) {
body := struct {
Field1 int `json:"Field1"`
Field2 int `json:"Field2"`
}{
Field1: 1,
Field2: 2,
}
_, r := test.GenerateRequestInfo("POST", "/", body, true)
result := &StringToIntStruct{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(result)
assert.Nil(t, err)
assert.Equal(t, StringToInt(1), result.Field1)
assert.Equal(t, StringToInt(2), result.Field2)
}
func TestStringToInt_unmarshalJSON_string(t *testing.T) {
body := struct {
Field1 string `json:"Field1"`
Field2 string `json:"Field2"`
}{
Field1: "1",
Field2: "2",
}
_, r := test.GenerateRequestInfo("POST", "/", body, true)
result := &StringToIntStruct{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(result)
assert.Nil(t, err)
assert.Equal(t, StringToInt(1), result.Field1)
assert.Equal(t, StringToInt(2), result.Field2)
}
func TestStringToInt_unmarshalJSON_invalid_type_returns_error(t *testing.T) {
body := struct {
Field1 bool `json:"Field1"`
Field2 bool `json:"Field2"`
}{
Field1: true,
Field2: false,
}
_, r := test.GenerateRequestInfo("POST", "/", body, true)
result := &StringToIntStruct{}
decoder := json.NewDecoder(r.Body)
err := decoder.Decode(result)
assert.Error(t, err)
}
func TestStringToInt_int_returns_int_type(t *testing.T) {
s := StringToInt(1)
assert.Equal(t, int(1), s.Int())
}
+6
View File
@@ -24,6 +24,12 @@ func init() {
"LimitExceeded": {HttpError: http.StatusBadRequest, Type: "LimitExceeded", Code: "AWS.SimpleQueueService.LimitExceeded", Message: "You've reached the limit on the number of queues."},
// MissingParameter — обязательный параметр отсутствует (например, пустой MessageBody)
"MissingParameter": {HttpError: http.StatusBadRequest, Type: "MissingParameter", Code: "MissingParameter", Message: "The request must contain the parameter MessageBody."},
// InvalidAttributeName — передан атрибут с неизвестным именем (SetQueueAttributes)
"InvalidAttributeName": {HttpError: http.StatusBadRequest, Type: "InvalidAttributeName", Code: "AWS.SimpleQueueService.InvalidAttributeName", Message: "Unknown Attribute."},
// PurgeQueueInProgress — повторный PurgeQueue на очереди ранее чем через 60 секунд
"PurgeQueueInProgress": {HttpError: http.StatusForbidden, Type: "Sender", Code: "AWS.SimpleQueueService.PurgeQueueInProgress", Message: "Only one PurgeQueue operation on a queue is allowed every 60 seconds."},
// OverLimit — превышен лимит сообщений в очереди (OOM-защита)
"OverLimit": {HttpError: http.StatusBadRequest, Type: "OverLimit", Code: "OverLimit", Message: "The queue contains the maximum number of messages."},
}
}
+41 -9
View File
@@ -63,38 +63,66 @@ type Queue struct {
EnableDuplicates bool
Duplicates map[string]time.Time
Tags map[string]string
// LastPurgeTime — время последнего успешного PurgeQueue.
// Используется для запрета повторного purge ранее чем через 60 секунд
// (требование AWS: PurgeQueueInProgress). Нулевое значение = purge ещё не выполнялся.
LastPurgeTime time.Time
}
// NextSequenceNumber — возвращает следующий порядковый номер сообщения FIFO-группы.
//
// Логическая схема: каждый вызов инкрементирует счётчик группы и возвращает
// его строковое представление (порядковые номера в FIFO должны расти монотонно).
//
// ВАЖНО (фикс): раньше при ПЕРВОМ обращении к группе map пересоздавалась целиком
// (q.FIFOSequenceNumbers = map[string]int{groupId: 0}) — счётчики ВСЕХ остальных
// групп терялись, и порядковые номера начинали дублироваться. Теперь map только
// инициализируется один раз (если nil), а счётчик конкретной группы инкрементируется.
func (q *Queue) NextSequenceNumber(groupId string) string {
if _, ok := q.FIFOSequenceNumbers[groupId]; !ok {
q.FIFOSequenceNumbers = map[string]int{
groupId: 0,
}
if q.FIFOSequenceNumbers == nil {
q.FIFOSequenceNumbers = make(map[string]int)
}
q.FIFOSequenceNumbers[groupId]++
return strconv.Itoa(q.FIFOSequenceNumbers[groupId])
}
// IsLocked — проверяет, заблокирована ли FIFO-группа.
// Группа блокируется при выдаче её сообщения в ReceiveMessage и остаётся
// заблокированной, пока сообщение in-flight (до DeleteMessage или таймаута
// видимости) — это гарантирует порядок обработки внутри группы.
func (q *Queue) IsLocked(groupId string) bool {
_, ok := q.FIFOMessages[groupId]
return ok
}
// LockGroup — блокирует FIFO-группу (помечает как занятую).
//
// ВАЖНО (фикс): раньше при ПЕРВОМ обращении map пересоздавалась целиком
// (q.FIFOMessages = map[string]int{groupId: 0}) — блокировки ВСЕХ остальных
// групп терялись, и их сообщения могли выдаваться одновременно. Теперь map
// только инициализируется один раз, а для группы проставляется ключ.
func (q *Queue) LockGroup(groupId string) {
if _, ok := q.FIFOMessages[groupId]; !ok {
q.FIFOMessages = map[string]int{
groupId: 0,
}
if q.FIFOMessages == nil {
q.FIFOMessages = make(map[string]int)
}
q.FIFOMessages[groupId] = 0
}
// UnlockGroup — снимает блокировку FIFO-группы.
// Вызывается после удаления сообщения или при истечении таймаута видимости
// (см. PeriodicTasks → processQueueTick).
func (q *Queue) UnlockGroup(groupId string) {
if _, ok := q.FIFOMessages[groupId]; ok {
delete(q.FIFOMessages, groupId)
}
}
// IsDuplicate — проверяет, было ли сообщение с таким deduplicationId уже
// добавлено в очередь в пределах deduplication-окна.
//
// Логическая схема: дубликаты отслеживаются ТОЛЬКО для FIFO-очередей с
// включённой дупликацией (EnableDuplicates) и непустым deduplicationId.
// Для остальных случаев дубликаты не отслеживаются (AWS-семантика).
func (q *Queue) IsDuplicate(deduplicationId string) bool {
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
return false
@@ -105,6 +133,10 @@ func (q *Queue) IsDuplicate(deduplicationId string) bool {
return ok
}
// InitDuplicatation — регистрирует deduplicationId с текущим временем.
// Эта отметка — начало deduplication-окна; просроченные записи удаляются
// фоновой задачей PeriodicTasks (deduplication period expired).
// Для не-FIFO очередей или пустого deduplicationId — no-op.
func (q *Queue) InitDuplicatation(deduplicationId string) {
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
return
-73
View File
@@ -1,73 +0,0 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestFilterPolicy_IsSatisfiedBy(t *testing.T) {
var tests = []struct {
filterPolicy *FilterPolicy
messageAttributes map[string]MessageAttribute
expected bool
}{
{
&FilterPolicy{"foo": {"bar"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "bar"}},
true,
},
{
&FilterPolicy{"foo": {"bar", "xyz"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "xyz"}},
true,
},
{
&FilterPolicy{"foo": {"bar", "xyz"}, "abc": {"def"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "xyz"},
"abc": {DataType: "String", StringValue: "def"}},
true,
},
{
&FilterPolicy{"foo": {"bar"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "baz"}},
false,
},
{
&FilterPolicy{"foo": {"bar"}},
map[string]MessageAttribute{},
false,
},
{
&FilterPolicy{"foo": {"bar"}, "abc": {"def"}},
map[string]MessageAttribute{"foo": {DataType: "String", StringValue: "bar"}},
false,
},
{
&FilterPolicy{"foo": {"bar"}},
map[string]MessageAttribute{"foo": {DataType: "Binary", BinaryValue: "bar"}},
false,
},
}
for i, tt := range tests {
actual := tt.filterPolicy.IsSatisfiedBy(tt.messageAttributes)
if tt.filterPolicy.IsSatisfiedBy(tt.messageAttributes) != tt.expected {
t.Errorf("#%d FilterPolicy: expected %t, actual %t", i, tt.expected, actual)
}
}
}
func TestMessage_IsReadyForReceipt(t *testing.T) {
CurrentEnvironment.RandomLatency.Min = 100
CurrentEnvironment.RandomLatency.Max = 100
msg := SqsMessage{
SentTime: time.Now(),
}
assert.False(t, msg.IsReadyForReceipt())
duration, _ := time.ParseDuration("105ms")
time.Sleep(duration)
assert.True(t, msg.IsReadyForReceipt())
}
+47
View File
@@ -401,6 +401,53 @@ type QueueAttributes struct {
RedriveAllowPolicy map[string]interface{} `json:"RedriveAllowPolicy"` // NOTE: not implemented
}
// AttrNameWhitelist — имена атрибутов, допустимые в SetQueueAttributes/CreateQueue.
// Используется для валидации в ОБОИХ протоколах:
// - Query (form Attribute.N.Name) — в app/gosqs/queue_attributes.go;
// - JSON — в UnmarshalJSON ниже (стандартный парсер молча отбрасывает
// неизвестные поля, поэтому проверка выполняется вручную).
//
// Policy/RedriveAllowPolicy — допустимы для совместимости, но не реализованы (no-op).
var AttrNameWhitelist = map[string]bool{
"DelaySeconds": true,
"MaximumMessageSize": true,
"MessageRetentionPeriod": true,
"Policy": true,
"ReceiveMessageWaitTimeSeconds": true,
"VisibilityTimeout": true,
"RedrivePolicy": true,
"RedriveAllowPolicy": true,
// FIFO-атрибуты: FifoQueue у нас выводится из суффикса .fifo имени очереди,
// но клиенты присылают эти атрибуты в CreateQueue — они допустимы (no-op).
"FifoQueue": true,
"ContentBasedDeduplication": true,
}
// UnmarshalJSON — строгая разборка QueueAttributes для JSON-протокола.
//
// Логическая схема:
// 1. Разбираем тело в map[string]json.RawMessage, чтобы увидеть ВСЕ ключи.
// 2. Каждый ключ проверяем по AttrNameWhitelist: неизвестное имя → ошибка
// (раньше парсер молча отбрасывал чужие поля и SetQueueAttributes принимал
// любой атрибут — «неверное имя атрибута принято» из нагрузочного теста).
// 3. Разбираем штатно: все известные поля попадают в структуру, вложенный
// RedrivePolicy использует собственный UnmarshalJSON.
//
// Ошибка приводит к TransformRequest=false → InvalidParameterValue в handler.
func (q *QueueAttributes) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
for name := range raw {
if !AttrNameWhitelist[name] {
return fmt.Errorf("invalid attribute name: %s", name)
}
}
type plain QueueAttributes
return json.Unmarshal(data, (*plain)(q))
}
type RedrivePolicy struct {
MaxReceiveCount StringToInt `json:"maxReceiveCount"`
DeadLetterTargetArn string `json:"deadLetterTargetArn"`
-739
View File
@@ -1,739 +0,0 @@
package models
import (
"encoding/json"
"fmt"
"math/rand"
"net/url"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestNewCreateQueueRequest(t *testing.T) {
CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize = 262144
CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod = 345600
CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds = 10
CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30
defer func() {
ResetApp()
}()
expectedCreateQueueRequest := &CreateQueueRequest{
Attributes: QueueAttributes{
DelaySeconds: 0,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
result := NewCreateQueueRequest()
assert.Equal(t, expectedCreateQueueRequest, result)
}
func TestCreateQueueRequest_SetAttributesFromForm_success(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "new-queue")
form.Add("Version", "2012-11-05")
form.Add("Attribute.1.Name", "DelaySeconds")
form.Add("Attribute.1.Value", "1")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "2")
form.Add("Attribute.3.Name", "MessageRetentionPeriod")
form.Add("Attribute.3.Value", "3")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "{\"i-am\":\"the-policy\"}")
form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds")
form.Add("Attribute.5.Value", "4")
form.Add("Attribute.6.Name", "VisibilityTimeout")
form.Add("Attribute.6.Value", "5")
form.Add("Attribute.7.Name", "RedrivePolicy")
form.Add("Attribute.7.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
form.Add("Attribute.8.Name", "RedriveAllowPolicy")
form.Add("Attribute.8.Value", "{\"i-am\":\"the-redrive-allow-policy\"}")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds)
assert.Equal(t, StringToInt(2), cqr.Attributes.MaximumMessageSize)
assert.Equal(t, StringToInt(3), cqr.Attributes.MessageRetentionPeriod)
assert.Equal(t, map[string]interface{}{"i-am": "the-policy"}, cqr.Attributes.Policy)
assert.Equal(t, StringToInt(4), cqr.Attributes.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, StringToInt(5), cqr.Attributes.VisibilityTimeout)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
assert.Equal(t, map[string]interface{}{"i-am": "the-redrive-allow-policy"}, cqr.Attributes.RedriveAllowPolicy)
}
func TestCreateQueueRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_int(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestCreateQueueRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_string(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": \"100\", \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestCreateQueueRequest_SetAttributesFromForm_success_default_unparsable_redrive_recieve_count(t *testing.T) {
defaultRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 10,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": null, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, defaultRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestCreateQueueRequest_SetAttributesFromForm_success_skips_invalid_values(t *testing.T) {
form := url.Values{}
form.Add("Attribute.1.Name", "DelaySeconds")
form.Add("Attribute.1.Value", "garbage")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "garbage")
form.Add("Attribute.3.Name", "MessageRetentionPeriod")
form.Add("Attribute.3.Value", "garbage")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "garbage")
form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds")
form.Add("Attribute.5.Value", "garbage")
form.Add("Attribute.6.Name", "VisibilityTimeout")
form.Add("Attribute.6.Value", "garbage")
form.Add("Attribute.7.Name", "RedrivePolicy")
form.Add("Attribute.7.Value", "garbage")
form.Add("Attribute.8.Name", "RedriveAllowPolicy")
form.Add("Attribute.8.Value", "garbage")
cqr := &CreateQueueRequest{
Attributes: QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds)
assert.Equal(t, StringToInt(262144), cqr.Attributes.MaximumMessageSize)
assert.Equal(t, StringToInt(345600), cqr.Attributes.MessageRetentionPeriod)
assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.Policy)
assert.Equal(t, StringToInt(10), cqr.Attributes.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, StringToInt(30), cqr.Attributes.VisibilityTimeout)
assert.Equal(t, RedrivePolicy{}, cqr.Attributes.RedrivePolicy)
assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.RedriveAllowPolicy)
}
func TestRedrivePolicy_UnmarshalJSON_handles_nested_json(t *testing.T) {
request := struct {
MaxReceiveCount int `json:"maxReceiveCount"`
DeadLetterTargetArn string `json:"deadLetterTargetArn"`
}{
MaxReceiveCount: 100,
DeadLetterTargetArn: "arn:redrive-queue",
}
b, _ := json.Marshal(request)
var r = RedrivePolicy{}
err := r.UnmarshalJSON(b)
assert.Nil(t, err)
assert.Equal(t, StringToInt(100), r.MaxReceiveCount)
assert.Equal(t, fmt.Sprintf("%s:%s", "arn", "redrive-queue"), r.DeadLetterTargetArn)
}
func TestRedrivePolicy_UnmarshalJSON_handles_escaped_string(t *testing.T) {
request := `{"maxReceiveCount":"100","deadLetterTargetArn":"arn:redrive-queue"}`
b, _ := json.Marshal(request)
var r = RedrivePolicy{}
err := r.UnmarshalJSON(b)
assert.Nil(t, err)
assert.Equal(t, StringToInt(100), r.MaxReceiveCount)
assert.Equal(t, fmt.Sprintf("%s:%s", "arn", "redrive-queue"), r.DeadLetterTargetArn)
}
func TestRedrivePolicy_UnmarshalJSON_invalid_json_request_returns_error(t *testing.T) {
request := fmt.Sprintf(`{\"maxReceiveCount\":\"100\",\"deadLetterTargetArn\":\"arn:redrive-queue\"}`)
var r = RedrivePolicy{}
err := r.UnmarshalJSON([]byte(request))
assert.Error(t, err)
assert.Equal(t, StringToInt(0), r.MaxReceiveCount)
assert.Equal(t, "", r.DeadLetterTargetArn)
}
func TestRedrivePolicy_UnmarshalJSON_invalid_type_returns_error(t *testing.T) {
request := `{"maxReceiveCount":true,"deadLetterTargetArn":"arn:redrive-queue"}`
b, _ := json.Marshal(request)
var r = RedrivePolicy{}
err := r.UnmarshalJSON(b)
assert.Error(t, err)
assert.Equal(t, StringToInt(0), r.MaxReceiveCount)
assert.Equal(t, "", r.DeadLetterTargetArn)
}
func TestNewListQueuesRequest_SetAttributesFromForm(t *testing.T) {
form := url.Values{}
form.Add("MaxResults", "1")
form.Add("NextToken", "next-token")
form.Add("QueueNamePrefix", "queue-name-prefix")
lqr := &ListQueueRequest{}
lqr.SetAttributesFromForm(form)
assert.Equal(t, 1, lqr.MaxResults)
assert.Equal(t, "next-token", lqr.NextToken)
assert.Equal(t, "queue-name-prefix", lqr.QueueNamePrefix)
}
func TestListQueuesRequest_SetAttributesFromForm_invalid_max_results(t *testing.T) {
form := url.Values{}
form.Add("MaxResults", "1.0")
form.Add("NextToken", "next-token")
form.Add("QueueNamePrefix", "queue-name-prefix")
lqr := &ListQueueRequest{}
lqr.SetAttributesFromForm(form)
assert.Equal(t, 0, lqr.MaxResults)
assert.Equal(t, "next-token", lqr.NextToken)
assert.Equal(t, "queue-name-prefix", lqr.QueueNamePrefix)
}
func TestGetQueueAttributesRequest_SetAttributesFromForm(t *testing.T) {
form := url.Values{}
form.Add("QueueUrl", "queue-url")
form.Add("AttributeName.1", "attribute-1")
form.Add("AttributeName.2", "attribute-2")
lqr := &GetQueueAttributesRequest{}
lqr.SetAttributesFromForm(form)
assert.Equal(t, "queue-url", lqr.QueueUrl)
assert.Equal(t, 2, len(lqr.AttributeNames))
assert.Contains(t, lqr.AttributeNames, "attribute-1")
assert.Contains(t, lqr.AttributeNames, "attribute-2")
}
func TestGetQueueAttributesRequest_SetAttributesFromForm_skips_invalid_key_sequence(t *testing.T) {
form := url.Values{}
form.Add("QueueUrl", "queue-url")
form.Add("AttributeName.1", "attribute-1")
form.Add("AttributeName.3", "attribute-3")
lqr := &GetQueueAttributesRequest{}
lqr.SetAttributesFromForm(form)
assert.Equal(t, "queue-url", lqr.QueueUrl)
assert.Equal(t, 1, len(lqr.AttributeNames))
assert.Contains(t, lqr.AttributeNames, "attribute-1")
}
func TestSendMessageRequest_SetAttributesFromForm_success(t *testing.T) {
form := url.Values{}
form.Add("MessageAttribute.1.Name", "Attr1")
form.Add("MessageAttribute.1.Value.DataType", "String")
form.Add("MessageAttribute.1.Value.StringValue", "Value1")
form.Add("MessageAttribute.2.Name", "Attr2")
form.Add("MessageAttribute.2.Value.DataType", "Binary")
form.Add("MessageAttribute.2.Value.BinaryValue", "VmFsdWUy")
form.Add("MessageAttribute.3.Name", "")
form.Add("MessageAttribute.3.Value.DataType", "String")
form.Add("MessageAttribute.3.Value.StringValue", "Value")
form.Add("MessageAttribute.4.Name", "Attr4")
form.Add("MessageAttribute.4.Value.DataType", "")
form.Add("MessageAttribute.4.Value.StringValue", "Value4")
r := &SendMessageRequest{
MessageAttributes: make(map[string]MessageAttribute),
MessageSystemAttributes: make(map[string]MessageAttribute),
}
r.SetAttributesFromForm(form)
assert.Equal(t, 2, len(r.MessageAttributes))
assert.NotNil(t, r.MessageAttributes["Attr1"])
attr1 := r.MessageAttributes["Attr1"]
assert.Equal(t, "String", attr1.DataType)
assert.Equal(t, "Value1", attr1.StringValue)
assert.Empty(t, attr1.BinaryValue)
assert.NotNil(t, r.MessageAttributes["Attr2"])
attr2 := r.MessageAttributes["Attr2"]
assert.Equal(t, "Binary", attr2.DataType)
assert.Empty(t, attr2.StringValue)
assert.Equal(t, "VmFsdWUy", attr2.BinaryValue)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "new-queue")
form.Add("Version", "2012-11-05")
form.Add("Attribute.1.Name", "DelaySeconds")
form.Add("Attribute.1.Value", "1")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "2")
form.Add("Attribute.3.Name", "MessageRetentionPeriod")
form.Add("Attribute.3.Value", "3")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "{\"i-am\":\"the-policy\"}")
form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds")
form.Add("Attribute.5.Value", "4")
form.Add("Attribute.6.Name", "VisibilityTimeout")
form.Add("Attribute.6.Value", "5")
form.Add("Attribute.7.Name", "RedrivePolicy")
form.Add("Attribute.7.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
form.Add("Attribute.8.Name", "RedriveAllowPolicy")
form.Add("Attribute.8.Value", "{\"i-am\":\"the-redrive-allow-policy\"}")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds)
assert.Equal(t, StringToInt(2), cqr.Attributes.MaximumMessageSize)
assert.Equal(t, StringToInt(3), cqr.Attributes.MessageRetentionPeriod)
assert.Equal(t, map[string]interface{}{"i-am": "the-policy"}, cqr.Attributes.Policy)
assert.Equal(t, StringToInt(4), cqr.Attributes.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, StringToInt(5), cqr.Attributes.VisibilityTimeout)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
assert.Equal(t, map[string]interface{}{"i-am": "the-redrive-allow-policy"}, cqr.Attributes.RedriveAllowPolicy)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_int(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": 100, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success_handles_redrive_recieve_count_string(t *testing.T) {
expectedRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 100,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": \"100\", \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, expectedRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success_default_unparsable_redrive_recieve_count(t *testing.T) {
defaultRedrivePolicy := RedrivePolicy{
MaxReceiveCount: 10,
DeadLetterTargetArn: "dead-letter-queue-arn",
}
form := url.Values{}
form.Add("Attribute.1.Name", "RedrivePolicy")
form.Add("Attribute.1.Value", "{\"maxReceiveCount\": null, \"deadLetterTargetArn\":\"dead-letter-queue-arn\"}")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, defaultRedrivePolicy, cqr.Attributes.RedrivePolicy)
}
func TestSetQueueAttributesRequest_SetAttributesFromForm_success_skips_invalid_values(t *testing.T) {
form := url.Values{}
form.Add("Attribute.1.Name", "DelaySeconds")
form.Add("Attribute.1.Value", "garbage")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "garbage")
form.Add("Attribute.3.Name", "MessageRetentionPeriod")
form.Add("Attribute.3.Value", "garbage")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "garbage")
form.Add("Attribute.5.Name", "ReceiveMessageWaitTimeSeconds")
form.Add("Attribute.5.Value", "garbage")
form.Add("Attribute.6.Name", "VisibilityTimeout")
form.Add("Attribute.6.Value", "garbage")
form.Add("Attribute.7.Name", "RedrivePolicy")
form.Add("Attribute.7.Value", "garbage")
form.Add("Attribute.8.Name", "RedriveAllowPolicy")
form.Add("Attribute.8.Value", "garbage")
cqr := &SetQueueAttributesRequest{
Attributes: QueueAttributes{
DelaySeconds: 1,
MaximumMessageSize: 262144,
MessageRetentionPeriod: 345600,
ReceiveMessageWaitTimeSeconds: 10,
VisibilityTimeout: 30,
},
}
cqr.SetAttributesFromForm(form)
assert.Equal(t, StringToInt(1), cqr.Attributes.DelaySeconds)
assert.Equal(t, StringToInt(262144), cqr.Attributes.MaximumMessageSize)
assert.Equal(t, StringToInt(345600), cqr.Attributes.MessageRetentionPeriod)
assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.Policy)
assert.Equal(t, StringToInt(10), cqr.Attributes.ReceiveMessageWaitTimeSeconds)
assert.Equal(t, StringToInt(30), cqr.Attributes.VisibilityTimeout)
assert.Equal(t, RedrivePolicy{}, cqr.Attributes.RedrivePolicy)
assert.Equal(t, map[string]interface{}(nil), cqr.Attributes.RedriveAllowPolicy)
}
func TestNewCreateTopicRequest(t *testing.T) {
defer func() {
ResetApp()
}()
result := NewCreateTopicRequest()
assert.Equal(t, false, result.Attributes.FifoTopic)
assert.Equal(t, StringToInt(1), result.Attributes.SignatureVersion)
assert.Equal(t, "Active", result.Attributes.TracingConfig)
assert.Equal(t, false, result.Attributes.ContentBasedDeduplication)
}
func TestCreateTopicRequest_SetAttributesFromForm_success(t *testing.T) {
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "new-queue")
form.Add("Version", "2012-11-05")
form.Add("Attribute.1.Name", "DeliveryPolicy")
form.Add("Attribute.1.Value", "{\"i-am\":\"the-policy\", \"name\":\"delivery-policy\"}")
form.Add("Attribute.2.Name", "DisplayName")
form.Add("Attribute.2.Value", "Foo")
form.Add("Attribute.3.Name", "FifoTopic")
form.Add("Attribute.3.Value", "true")
form.Add("Attribute.4.Name", "Policy")
form.Add("Attribute.4.Value", "{\"i-am\":\"the-policy\", \"name\":\"policy\"}")
form.Add("Attribute.5.Name", "SignatureVersion")
form.Add("Attribute.5.Value", "99")
form.Add("Attribute.6.Name", "TracingConfig")
form.Add("Attribute.6.Value", "PassThrough")
form.Add("Attribute.7.Name", "KmsMasterKeyId")
form.Add("Attribute.7.Value", "1234abcd-12ab-34cd-56ef-1234567890ab")
form.Add("Attribute.8.Name", "ArchivePolicy")
form.Add("Attribute.8.Value", "{\"i-am\":\"the-policy\", \"name\":\"archive-policy\"}")
form.Add("Attribute.9.Name", "BeginningArchiveTime")
form.Add("Attribute.9.Value", "2024-07-01T23:59:59+09:00")
form.Add("Attribute.10.Name", "ContentBasedDeduplication")
form.Add("Attribute.10.Value", "true")
ctr := &CreateTopicRequest{}
ctr.SetAttributesFromForm(form)
assert.Equal(t, 2, len(ctr.Attributes.DeliveryPolicy))
assert.Equal(t, "the-policy", ctr.Attributes.DeliveryPolicy["i-am"])
assert.Equal(t, "delivery-policy", ctr.Attributes.DeliveryPolicy["name"])
assert.Equal(t, "Foo", ctr.Attributes.DisplayName)
assert.Equal(t, true, ctr.Attributes.FifoTopic)
assert.Equal(t, 2, len(ctr.Attributes.Policy))
assert.Equal(t, "the-policy", ctr.Attributes.Policy["i-am"])
assert.Equal(t, "policy", ctr.Attributes.Policy["name"])
assert.Equal(t, StringToInt(99), ctr.Attributes.SignatureVersion)
assert.Equal(t, "PassThrough", ctr.Attributes.TracingConfig)
assert.Equal(t, "1234abcd-12ab-34cd-56ef-1234567890ab", ctr.Attributes.KmsMasterKeyId)
assert.Equal(t, 2, len(ctr.Attributes.ArchivePolicy))
assert.Equal(t, "the-policy", ctr.Attributes.ArchivePolicy["i-am"])
assert.Equal(t, "archive-policy", ctr.Attributes.ArchivePolicy["name"])
assert.Equal(t, "2024-07-01T23:59:59+09:00", ctr.Attributes.BeginningArchiveTime)
assert.Equal(t, true, ctr.Attributes.ContentBasedDeduplication)
}
func TestSubscribeRequest_SetAttributesFromForm_success(t *testing.T) {
form := url.Values{}
form.Add("Attributes.entry.1.key", "RawMessageDelivery")
form.Add("Attributes.entry.1.value", "true")
form.Add("Attributes.entry.2.key", "FilterPolicy")
form.Add("Attributes.entry.2.value", "{\"filter\": [\"policy\"]}")
cqr := &SubscribeRequest{
Attributes: SubscriptionAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.True(t, cqr.Attributes.RawMessageDelivery)
assert.Equal(t, FilterPolicy{"filter": []string{"policy"}}, cqr.Attributes.FilterPolicy)
}
func TestSubscribeRequest_SetAttributesFromForm_skips_invalid_values(t *testing.T) {
form := url.Values{}
form.Add("Attributes.entry.1.key", "RawMessageDelivery")
form.Add("Attributes.entry.1.value", "garbage")
form.Add("Attributes.entry.2.key", "FilterPolicy")
form.Add("Attributes.entry.2.value", "also-garbage")
cqr := &SubscribeRequest{
Attributes: SubscriptionAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.False(t, cqr.Attributes.RawMessageDelivery)
assert.Equal(t, FilterPolicy(nil), cqr.Attributes.FilterPolicy)
}
func TestSubscribeRequest_SetAttributesFromForm_stops_if_attributes_not_numbered_sequentially(t *testing.T) {
form := url.Values{}
form.Add("Attributes.entry.2.key", "RawMessageDelivery")
form.Add("Attributes.entry.2.value", "garbage")
form.Add("Attributes.entry.3.key", "FilterPolicy")
form.Add("Attributes.entry.3.value", "also-garbage")
cqr := &SubscribeRequest{
Attributes: SubscriptionAttributes{},
}
cqr.SetAttributesFromForm(form)
assert.False(t, cqr.Attributes.RawMessageDelivery)
assert.Equal(t, FilterPolicy(nil), cqr.Attributes.FilterPolicy)
}
func Test_DeleteMessageBatchRequest_SetAttributesFromForm_success(t *testing.T) {
form := url.Values{}
form.Add("DeleteMessageBatchRequestEntry.1.Id", "message-id-1")
form.Add("DeleteMessageBatchRequestEntry.1.ReceiptHandle", "receipt-handle-1")
form.Add("DeleteMessageBatchRequestEntry.2.Id", "message-id-2")
form.Add("DeleteMessageBatchRequestEntry.2.ReceiptHandle", "receipt-handle-2")
form.Add("DeleteMessageBatchRequestEntry.3.Id", "message-id-3")
form.Add("DeleteMessageBatchRequestEntry.3.ReceiptHandle", "receipt-handle-3")
dmbr := &DeleteMessageBatchRequest{}
dmbr.SetAttributesFromForm(form)
assert.Len(t, dmbr.Entries, 3)
assert.Equal(t, "message-id-1", dmbr.Entries[0].Id)
assert.Equal(t, "receipt-handle-1", dmbr.Entries[0].ReceiptHandle)
assert.Equal(t, "message-id-2", dmbr.Entries[1].Id)
assert.Equal(t, "receipt-handle-2", dmbr.Entries[1].ReceiptHandle)
assert.Equal(t, "message-id-3", dmbr.Entries[2].Id)
assert.Equal(t, "receipt-handle-3", dmbr.Entries[2].ReceiptHandle)
}
func Test_DeleteMessageBatchRequest_SetAttributesFromForm_stops_at_non_sequential_keys(t *testing.T) {
form := url.Values{}
form.Add("DeleteMessageBatchRequestEntry.1.Id", "message-id-1")
form.Add("DeleteMessageBatchRequestEntry.1.ReceiptHandle", "receipt-handle-1")
form.Add("DeleteMessageBatchRequestEntry.4.Id", "message-id-2")
form.Add("DeleteMessageBatchRequestEntry.4.ReceiptHandle", "receipt-handle-2")
form.Add("DeleteMessageBatchRequestEntry.3.Id", "message-id-3")
form.Add("DeleteMessageBatchRequestEntry.3.ReceiptHandle", "receipt-handle-3")
dmbr := &DeleteMessageBatchRequest{}
dmbr.SetAttributesFromForm(form)
assert.Len(t, dmbr.Entries, 1)
assert.Equal(t, "message-id-1", dmbr.Entries[0].Id)
assert.Equal(t, "receipt-handle-1", dmbr.Entries[0].ReceiptHandle)
}
func Test_DeleteMessageBatchRequest_SetAttributesFromForm_stops_at_invalid_keys(t *testing.T) {
form := url.Values{}
form.Add("DeleteMessageBatchRequestEntry.1.Id", "message-id-1")
form.Add("DeleteMessageBatchRequestEntry.1.ReceiptHandle", "receipt-handle-1")
form.Add("INVALID_DeleteMessageBatchRequestEntry.2.Id", "message-id-2")
form.Add("DeleteMessageBatchRequestEntry.2.ReceiptHandle", "receipt-handle-2")
form.Add("DeleteMessageBatchRequestEntry.3.Id", "message-id-3")
form.Add("DeleteMessageBatchRequestEntry.3.ReceiptHandle", "receipt-handle-3")
dmbr := &DeleteMessageBatchRequest{}
dmbr.SetAttributesFromForm(form)
assert.Len(t, dmbr.Entries, 1)
assert.Equal(t, "message-id-1", dmbr.Entries[0].Id)
assert.Equal(t, "receipt-handle-1", dmbr.Entries[0].ReceiptHandle)
}
func TestPublishRequest_SetAttributesFromForm_success_concurrent(t *testing.T) {
form := url.Values{}
form.Add("MessageAttributes.entry.1.Name", "test1")
form.Add("MessageAttributes.entry.1.Value.DataType", "String")
form.Add("MessageAttributes.entry.1.Value.StringValue", "sample-string")
form.Add("MessageAttributes.entry.2.Name", "test2")
form.Add("MessageAttributes.entry.2.Value.DataType", "Binary")
form.Add("MessageAttributes.entry.2.Value.BinaryValue", "YmluYXJ5LXZhbHVl")
// if the code is not thread-safe, repeated runs increase the chance of detecting a race.
for r := 0; r < 10; r++ {
var wg sync.WaitGroup
goroutineCount := 40
// launch goroutines in parallel to simulate concurrent access.
for g := 0; g < goroutineCount; g++ {
wg.Add(1)
go func() {
defer wg.Done()
// introduce a random delay to encourage goroutine interleaving
time.Sleep(time.Duration(rand.Intn(5)) * time.Millisecond)
cqr := &PublishRequest{
MessageAttributes: make(map[string]MessageAttribute),
}
cqr.SetAttributesFromForm(form)
// validate the expected DataType values
assert.Equal(t, "String", cqr.MessageAttributes["test1"].DataType)
assert.Equal(t, "Binary", cqr.MessageAttributes["test2"].DataType)
}()
}
wg.Wait()
}
}
func TestParseMessageAttributes(t *testing.T) {
for _, tc := range []struct {
description string
values url.Values
keyPrefix string
want map[string]MessageAttribute
}{
{
description: "empty",
values: url.Values{},
keyPrefix: "foo",
want: nil,
},
{
description: "simple",
values: url.Values{
"MessageAttribute.1.Name": []string{"Attr1"},
"MessageAttribute.1.Value.DataType": []string{"String"},
"MessageAttribute.1.Value.StringValue": []string{"Value1"},
"MessageAttribute.2.Name": []string{"Attr2"},
"MessageAttribute.2.Value.DataType": []string{"Binary"},
"MessageAttribute.2.Value.BinaryValue": []string{"VmFsdWUy"},
},
keyPrefix: "MessageAttribute",
want: map[string]MessageAttribute{
"Attr1": {
DataType: "String",
StringValue: "Value1",
BinaryValue: "",
},
"Attr2": {
DataType: "Binary",
BinaryValue: "VmFsdWUy",
},
},
},
{
description: "attributes after empty name ignored",
values: url.Values{
"MessageAttribute.1.Name": []string{""},
"MessageAttribute.1.Value.DataType": []string{"String"},
"MessageAttribute.1.Value.StringValue": []string{"Value4"},
"MessageAttribute.2.Name": []string{"Attr2"},
"MessageAttribute.2.Value.DataType": []string{"Binary"},
"MessageAttribute.2.Value.BinaryValue": []string{"VmFsdWUy"},
},
keyPrefix: "MessageAttribute",
want: nil,
},
{
description: "attributes after missing number ignored",
values: url.Values{
// Note starting from 2
"MessageAttribute.2.Name": []string{"Attr2"},
"MessageAttribute.2.Value.DataType": []string{"Binary"},
"MessageAttribute.2.Value.BinaryValue": []string{"VmFsdWUy"},
},
keyPrefix: "MessageAttribute",
want: nil,
},
{
description: "empty DataType ignored",
values: url.Values{
"MessageAttribute.1.Name": []string{"Attr4"},
"MessageAttribute.1.Value.DataType": []string{""},
"MessageAttribute.1.Value.StringValue": []string{"Value4"},
},
keyPrefix: "MessageAttribute",
want: nil,
},
} {
t.Run(tc.description, func(t *testing.T) {
got := parseMessageAttributes(tc.values, tc.keyPrefix)
assert.Equal(t, tc.want, got)
})
}
}
+40 -45
View File
@@ -184,55 +184,49 @@ func SaveMessages(queueKey string, msgs []models.SqsMessage) {
})
}
// DeleteMessagePersist — удаляет одно сообщение из Redis асинхронно.
// Вызывать при DeleteMessage (после удаления из in-memory).
// DeleteMessagePersist — удаляет одно сообщение из Redis СИНХРОННО.
// Синхронность обязательна: если удаление потеряется при рестарте пода,
// сообщение «воскреснет» из Redis (факт: удалённые очереди возвращались
// после рестарта, когда удаления были асинхронными).
func DeleteMessagePersist(queueKey string, msgUuid string) {
if Client == nil || msgUuid == "" {
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HDel(ctx, hashKey, msgUuid).Err(); err != nil {
log.Errorf("persistence: HDel message %s/%s: %v", queueKey, msgUuid, err)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HDel(ctx, hashKey, msgUuid).Err(); err != nil {
log.Errorf("persistence: HDel message %s/%s: %v", queueKey, msgUuid, err)
}
}
// DeleteMessagesPersist — удаляет несколько сообщений из Redis.
// Вызывать при DeleteMessageBatch.
// DeleteMessagesPersist — удаляет несколько сообщений из Redis СИНХРОННО
// (иначе удалённые сообщения воскреснут после рестарта пода).
func DeleteMessagesPersist(queueKey string, uuids []string) {
if Client == nil || len(uuids) == 0 {
return
}
hashKey := redisMsgHashPrefix + queueKey
// Копируем uuids — вызывающий может переиспользовать slice
ids := make([]string, len(uuids))
copy(ids, uuids)
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.HDel(ctx, hashKey, ids...).Err(); err != nil {
log.Errorf("persistence: HDel messages %s (%d): %v", queueKey, len(ids), err)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.HDel(ctx, hashKey, uuids...).Err(); err != nil {
log.Errorf("persistence: HDel messages %s (%d): %v", queueKey, len(uuids), err)
}
}
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis (для PurgeQueue).
// Удаляет весь HASH ssq:msg:{queueKey}.
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis СИНХРОННО.
// Синхронность обязательна: при рестарте пода удалённые сообщения не должны
// воскреснуть (факт: асинхронное удаление очередей приводило к их возврату).
func PurgeMessagesPersist(queueKey string) {
if Client == nil {
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.Del(ctx, hashKey).Err(); err != nil {
log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.Del(ctx, hashKey).Err(); err != nil {
log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err)
}
}
// DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно.
@@ -242,21 +236,22 @@ func DeleteQueue(key string) {
}
writeSeq := nextQueueWriteSeq(key)
msgHashKey := redisMsgHashPrefix + key
asyncWrite(func() {
if !isLatestQueueWriteSeq(key, writeSeq) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Удаляем метаданные из общего HASH
if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil {
log.Errorf("persistence: HDel queue meta %q: %v", key, err)
}
// Удаляем весь HASH с сообщениями
if err := Client.Del(ctx, msgHashKey).Err(); err != nil {
log.Errorf("persistence: DEL messages hash %q: %v", key, err)
}
})
// СИНХРОННО: удаление очереди обязано попасть в Redis до ответа клиенту.
// Асинхронное удаление приводило к «воскрешению» удалённых очередей после
// рестарта пода (факт 2026-08-15: 50 мусорных очередей вернулись из Redis).
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !isLatestQueueWriteSeq(key, writeSeq) {
return
}
// Удаляем метаданные из общего HASH
if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil {
log.Errorf("persistence: HDel queue meta %q: %v", key, err)
}
// Удаляем весь HASH с сообщениями
if err := Client.Del(ctx, msgHashKey).Err(); err != nil {
log.Errorf("persistence: DEL messages hash %q: %v", key, err)
}
}
// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
+91 -17
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
@@ -19,6 +18,7 @@ import (
sqs "shared-sqs/app/gosqs"
"shared-sqs/app/interfaces"
"shared-sqs/app/metrics"
"shared-sqs/app/models"
"shared-sqs/app/tenant"
"shared-sqs/app/ui"
@@ -38,9 +38,26 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
// /health — публичный, без auth
r.HandleFunc("/health", health).Methods("GET")
// /static — логотип, favicon для публичных страниц
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
// /metrics — Prometheus endpoint для Victoria Metrics scrape
r.Handle("/metrics", promhttp.Handler()).Methods("GET")
// Страница примеров команд — публично
r.HandleFunc("/examples", func(w http.ResponseWriter, req *http.Request) {
ui.ExamplesHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
// Админ-консоль — статика. Регистрируем ДО admin subrouter,
// иначе PathPrefix("/admin") с bearer-auth перехватит GET /admin.
r.HandleFunc("/admin", func(w http.ResponseWriter, req *http.Request) {
ui.AdminHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
r.HandleFunc("/admin/", func(w http.ResponseWriter, req *http.Request) {
ui.AdminHandler(models.Version).ServeHTTP(w, req)
}).Methods("GET")
// Admin API — Bearer token auth, регистрируется через AdminHandler
adminHandler := admin.NewHandler(tenantStore, adminToken)
adminHandler.RegisterRoutes(r)
@@ -57,7 +74,16 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
// r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0
// при пустом prefix — ответы теряются. Поэтому используем явную обёртку.
sqsAuth := auth.AuthMiddleware(tenantStore)
r.Handle("/", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
// Корень: GET без Action — публичная страница-описание сервиса;
// всё остальное (POST, или GET с Action/X-Amz-Target) — SQS API за tenant auth.
rootHandler := func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet && extractAction(req) == "" {
ui.InfoHandler(models.Version).ServeHTTP(w, req)
return
}
sqsAuth(http.HandlerFunc(actionHandler)).ServeHTTP(w, req)
}
r.HandleFunc("/", rootHandler).Methods("GET", "POST")
r.Handle("/{account}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/queue/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/{account}/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
@@ -76,10 +102,35 @@ func requestBodyLimitMiddleware(next http.Handler) http.Handler {
})
}
// encodeResponse — сериализует ответ в формате протокола клиента.
//
// Логическая схема:
// - AwsJsonProtocol (чистый JSON-протокол): успешный ответ — JSON тела;
// ОШИБКА — спец-формат AWS-JSON {"__type": "com.amazonaws.sqs#<Code>",
// "message": "..."}. Раньше для ErrorResponse писался голый ErrorResult
// ({"Type":...,"Code":...,"Message":...}) — SDK не распознавал это как ошибку
// и возвращал Code=None.
// - AwsQueryProtocol: ответ всегда XML (в т.ч. <ErrorResponse> для ошибок).
func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) {
protocol := resolveProtocol(req)
switch protocol {
case AwsJsonProtocol:
// Ошибка в JSON-протоколе: __type + message — единственный формат,
// который botocore распознаёт как исключение с кодом.
if errResp, ok := body.(models.ErrorResponse); ok {
w.Header().Set("x-amzn-RequestId", errResp.RequestId)
w.Header().Set("Content-Type", "application/x-amz-json-1.0")
w.WriteHeader(statusCode)
err := json.NewEncoder(w).Encode(map[string]string{
"__type": "com.amazonaws.sqs#" + errResp.Result.Code,
"message": errResp.Result.Message,
})
if err != nil {
log.Errorf("Response Encoding Error: %v\nResponse: %+v", err, body)
}
return
}
// Успешный ответ: JSON тела (Result).
w.Header().Set("x-amzn-RequestId", body.GetRequestId())
w.Header().Set("Content-Type", "application/x-amz-json-1.0")
w.WriteHeader(statusCode)
@@ -125,8 +176,9 @@ var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractR
}
func health(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
fmt.Fprint(w, "OK")
fmt.Fprintf(w, `{"status":"ok","version":%q}`, models.Version)
}
func actionHandler(w http.ResponseWriter, req *http.Request) {
@@ -168,9 +220,16 @@ func actionHandler(w http.ResponseWriter, req *http.Request) {
}
return
}
// Неизвестный Action — отвечаем ошибкой в ФОРМАТЕ ПРОТОКОЛА клиента
// (XML ErrorResponse для Query, AWS-JSON для JSON-протокола).
// Раньше возвращался голый текст "Bad Request" (200/400 без AWS-XML) —
// SDK получал ClientError с Code=None и не мог понять причину.
log.Warnf("Bad Request - Action: %s", action)
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, "Bad Request")
errType := models.SqsErrors["GeneralError"]
encodeResponse(w, req, errType.StatusCode(), models.ErrorResponse{
Result: errType.Response(),
RequestId: "00000000-0000-0000-0000-000000000000",
})
}
type AwsProtocol int
@@ -180,24 +239,39 @@ const (
AwsQueryProtocol AwsProtocol = iota
)
// extractAction — извлекает Action из запроса (Query Protocol или JSON Protocol)
// extractAction — извлекает имя операции SQS из запроса.
//
// Логическая схема (не зависит от resolveProtocol):
// 1. Заголовок X-Amz-Target ("AmazonSQS.SendMessage") — используют клиенты
// JSON-протокола И AWS CLI v2 в query-mode. Извлекаем часть после точки.
// 2. Иначе — form-параметр Action (классический query-протокол).
//
// ВАЖНО: определение по X-Amz-Target в первую очередь обязательно — AWS CLI v2
// шлёт Content-Type: application/x-amz-json-1.0 + x-amzn-query-mode: true, но
// Action у него в заголовке, а НЕ в form-параметрах.
func extractAction(req *http.Request) string {
protocol := resolveProtocol(req)
switch protocol {
case AwsJsonProtocol:
action := req.Header.Get("X-Amz-Target")
if action := req.Header.Get("X-Amz-Target"); action != "" {
parts := strings.SplitN(action, ".", 2)
if len(parts) != 2 || parts[1] == "" {
return ""
if len(parts) == 2 && parts[1] != "" {
return parts[1]
}
return parts[1]
case AwsQueryProtocol:
return req.FormValue("Action")
}
return ""
return req.FormValue("Action")
}
// resolveProtocol — определяет протокол по Content-Type
// resolveProtocol — определяет протокол ОТВЕТА по заголовкам запроса.
//
// Логическая схема:
// 1. Content-Type: application/x-amz-json-1.0 → JSON-протокол (ответ JSON).
// Сюда входят: чистые JSON-клиенты (SDK) И AWS CLI v2/boto3 в режиме
// «query-compatible JSON» (x-amzn-query-mode: true). ВАЖНО (эмпирика):
// query-mode клиент парсит именно JSON-ответы — при XML-ответе список
// очередей распознаётся как пустой (None), при JSON — корректно.
// 2. Всё остальное (form-urlencoded от классических клиентов) — query/XML.
//
// Ошибки в JSON-протоколе оформляются как {"__type":"...","message":"..."}
// (см. encodeResponse) — только такой формат botocore распознаёт как ошибку
// с кодом (раньше отдавался голый ErrorResult → Code=None).
func resolveProtocol(req *http.Request) AwsProtocol {
if req.Header.Get("Content-Type") == "application/x-amz-json-1.0" {
return AwsJsonProtocol
-251
View File
@@ -1,251 +0,0 @@
package router
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
af "shared-sqs/app/fixtures"
"shared-sqs/app/mocks"
"shared-sqs/app/interfaces"
sqs "shared-sqs/app/gosqs"
"github.com/stretchr/testify/assert"
"shared-sqs/app/test"
)
func TestIndexServerhandler_POST_BadRequest(t *testing.T) {
// Create a request to pass to our handler. We don't have any query parameters for now, so we'll
// pass 'nil' as the third parameter.
req, err := http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "BadRequest")
req.PostForm = form
// We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
rr := httptest.NewRecorder()
// Our handlers satisfy http.Handler, so we can call their ServeHTTP method
// directly and pass in our Request and ResponseRecorder.
New().ServeHTTP(rr, req)
// Check the status code is what we expect.
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
}
func TestIndexServerhandler_POST_GoodRequest(t *testing.T) {
// Create a request to pass to our handler. We don't have any query parameters for now, so we'll
// pass 'nil' as the third parameter.
req, err := http.NewRequest("POST", "/", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "ListTopics")
req.PostForm = form
// We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
rr := httptest.NewRecorder()
// Our handlers satisfy http.Handler, so we can call their ServeHTTP method
// directly and pass in our Request and ResponseRecorder.
New().ServeHTTP(rr, req)
// Check the status code is what we expect.
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
}
func TestIndexServerhandler_POST_GoodRequest_With_URL(t *testing.T) {
req, err := http.NewRequest("POST", "/100010001000/local-queue1", nil)
if err != nil {
t.Fatal(err)
}
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "local-queue1")
req.PostForm = form
rr := httptest.NewRecorder()
New().ServeHTTP(rr, req)
form = url.Values{}
form.Add("Action", "GetQueueAttributes")
form.Add("QueueUrl", fmt.Sprintf("%s/local-queue1", af.BASE_URL))
req.PostForm = form
// We create a ResponseRecorder (which satisfies http.ResponseWriter) to record the response.
rr = httptest.NewRecorder()
// Our handlers satisfy http.Handler, so we can call their ServeHTTP method
// directly and pass in our Request and ResponseRecorder.
New().ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestIndexServerhandler_POST_GoodRequest_With_URL_And_Aws_Json_Protocol(t *testing.T) {
json, _ := json.Marshal(map[string]string{
"QueueName": "local-queue1",
})
req, err := http.NewRequest("POST", "/100010001000/local-queue1", bytes.NewBuffer(json))
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Amz-Target", "AmazonSQS.CreateQueue")
req.Header.Set("Content-Type", "application/x-amz-json-1.0")
rr := httptest.NewRecorder()
New().ServeHTTP(rr, req)
// Check the status code is what we expect.
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
}
func TestIndexServerhandler_GET_GoodRequest_Pem_cert(t *testing.T) {
req, err := http.NewRequest("GET", "/SimpleNotificationService/100010001000.pem", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
New().ServeHTTP(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
}
func TestEncodeResponse_success_xml(t *testing.T) {
w, r := test.GenerateRequestInfo("POST", "/url", nil, false)
encodeResponse(w, r, http.StatusOK, mocks.BaseResponse{Message: "test"})
assert.Equal(t, http.StatusOK, w.Code)
tmp := mocks.BaseResponse{}
xml.Unmarshal(w.Body.Bytes(), &tmp)
assert.Equal(t, mocks.BaseResponse{Message: "test"}, tmp)
}
func TestEncodeResponse_success_skips_nil_body_xml(t *testing.T) {
w, r := test.GenerateRequestInfo("POST", "/url", nil, false)
encodeResponse(w, r, http.StatusOK, nil)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, &bytes.Buffer{}, w.Body)
}
func TestEncodeResponse_success_json(t *testing.T) {
w, r := test.GenerateRequestInfo("POST", "/url", nil, true)
encodeResponse(w, r, http.StatusOK, mocks.BaseResponse{Message: "test"})
assert.Equal(t, http.StatusOK, w.Code)
tmp := mocks.BaseResponse{}
json.Unmarshal(w.Body.Bytes(), &tmp)
assert.Equal(t, mocks.BaseResponse{Message: "test"}, tmp)
}
func TestEncodeResponse_success_skips_malformed_body_json(t *testing.T) {
mock := mocks.BaseResponse{
Message: "test",
}
mock.MockGetResult = func() interface{} {
return make(chan int)
}
w, r := test.GenerateRequestInfo("POST", "/url", nil, true)
encodeResponse(w, r, http.StatusOK, mock)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "General Error", strings.TrimSpace(string(w.Body.Bytes())))
}
func TestActionHandler_v1_json(t *testing.T) {
defer func() {
routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
"CreateQueue": sqs.CreateQueueV1,
}
}()
mockCalled := false
mockFunction := func(req *http.Request) (int, interfaces.AbstractResponseBody) {
mockCalled = true
return http.StatusOK, mocks.BaseResponse{Message: "response-body"}
}
routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
"CreateQueue": mockFunction,
}
w, r := test.GenerateRequestInfo("POST", "/url", nil, true)
r.Header.Set("X-Amz-Target", "QueueService.CreateQueue")
actionHandler(w, r)
assert.True(t, mockCalled)
assert.Equal(t, http.StatusOK, w.Code)
tmp := mocks.BaseResponse{}
json.Unmarshal(w.Body.Bytes(), &tmp)
assert.Equal(t, mocks.BaseResponse{Message: "response-body"}, tmp)
}
func TestActionHandler_v1_xml(t *testing.T) {
defer func() {
routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
"CreateQueue": sqs.CreateQueueV1,
}
}()
mockCalled := false
mockFunction := func(req *http.Request) (int, interfaces.AbstractResponseBody) {
mockCalled = true
return http.StatusOK, mocks.BaseResponse{Message: "response-body"}
}
routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){
"CreateQueue": mockFunction,
}
w, r := test.GenerateRequestInfo("POST", "/url", nil, false)
form := url.Values{}
form.Add("Action", "CreateQueue")
r.PostForm = form
actionHandler(w, r)
assert.True(t, mockCalled)
assert.Equal(t, http.StatusOK, w.Code)
tmp := mocks.BaseResponse{}
xml.Unmarshal(w.Body.Bytes(), &tmp)
assert.Equal(t, mocks.BaseResponse{Message: "response-body"}, tmp)
}
+82 -44
View File
@@ -4,9 +4,11 @@ package tenant
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
@@ -16,10 +18,15 @@ import (
// MaxTenantsGlobal — глобальный лимит тенантов (защита от OOM, Fix #12)
const MaxTenantsGlobal = 1000
// DefaultTenantMaxQueues — лимит очередей для JWT-тенантов (auto-provisioning).
// Используется в app/admin при логине; при каждом логине лимит существующего
// тенанта обновляется до этого значения (см. CreateFromJWT).
const DefaultTenantMaxQueues = 50
// Tenant — модель тенанта shared-sqs.
// AccessKey используется как идентификатор в AWS Authorization header.
type Tenant struct {
ID string // уникальный идентификатор тенанта (sless-<hex> из JWT sub, или t-<hex> для legacy)
ID string // уникальный идентификатор тенанта (t-<hex>: детерминированный из email для JWT-тенантов, случайный для manual)
Name string // человекочитаемое имя
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
@@ -31,12 +38,12 @@ type Tenant struct {
}
// TenantStore — потокобезопасное in-memory хранилище тенантов.
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth).
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по Email (JWT auth).
type TenantStore struct {
mu sync.RWMutex
byID map[string]*Tenant
byAccessKey map[string]*Tenant
bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim)
byEmail map[string]*Tenant // индекс по Email (JWT claim). Тенанты без email сюда НЕ попадают.
}
// NewTenantStore — создаёт пустое хранилище тенантов.
@@ -44,7 +51,7 @@ func NewTenantStore() *TenantStore {
return &TenantStore{
byID: make(map[string]*Tenant),
byAccessKey: make(map[string]*Tenant),
bySub: make(map[string]*Tenant),
byEmail: make(map[string]*Tenant),
}
}
@@ -83,8 +90,8 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
s.mu.Lock()
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
if t.NubesSub != "" {
s.bySub[t.NubesSub] = t
if t.Email != "" {
s.byEmail[t.Email] = t
}
s.mu.Unlock()
@@ -156,8 +163,8 @@ func (s *TenantStore) Delete(id string) bool {
}
delete(s.byID, t.ID)
delete(s.byAccessKey, t.AccessKey)
if t.NubesSub != "" {
delete(s.bySub, t.NubesSub)
if t.Email != "" {
delete(s.byEmail, t.Email)
}
s.mu.Unlock()
@@ -173,60 +180,71 @@ func (s *TenantStore) LoadTenant(t *Tenant) {
s.mu.Lock()
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
if t.NubesSub != "" {
s.bySub[t.NubesSub] = t
if t.Email != "" {
s.byEmail[t.Email] = t
}
s.mu.Unlock()
}
// GetBySub — поиск тенанта по NubesSub (JWT sub claim).
// GetByEmail — поиск тенанта по Email (JWT claim).
// Используется при JWT-авторизации через UI.
func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) {
func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) {
s.mu.RLock()
t, ok := s.bySub[sub]
t, ok := s.byEmail[email]
s.mu.RUnlock()
return t, ok
}
// CreateFromJWT — auto-provisioning тенанта из JWT claims.
// ID = TenantIDFromSub(sub) — совместим с sless namespace.
// Если тенант с таким sub уже существует — возвращает его (идемпотентно).
func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) (*Tenant, error) {
// Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email.
// Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле.
// Легаси-тенант (старые случайные ключи / другой ID) пересчитывается:
// старые индексы и Redis-запись удаляются, создаётся детерминированная запись.
// ВНИМАНИЕ: очереди привязаны к старому AccessKey — при пересчёте они остаются
// в SyncQueues/Redis под старым префиксом (текущий тенант имеет 0 очередей).
func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) {
if email == "" {
return nil, fmt.Errorf("JWT email is required for tenant provisioning")
}
tenantID := tenantIDFromEmail(email)
accessKey := accessKeyFromEmail(email)
secretKey := secretKeyFromEmail(email)
s.mu.Lock()
// Идемпотентность: если тенант с таким sub уже есть — возвращаем
if existing, ok := s.bySub[sub]; ok {
// Обновляем email если изменился
if email != "" && existing.Email != email {
existing.Email = email
defer s.mu.Unlock()
// Идемпотентность: тенант с таким email уже есть
if existing, ok := s.byEmail[email]; ok {
// Полное совпадение — возвращаем как есть
if existing.ID == tenantID && existing.AccessKey == accessKey {
if sub != "" {
existing.NubesSub = sub
}
// Обновляем лимит очередей на актуальное значение из кода при каждом
// логине и персистим в Redis — иначе после рестарта пода из Redis
// восстановится старый лимит (было: тенант навсегда оставался с лимитом,
// заданным при первом логине).
if existing.MaxQueues != maxQueues {
existing.MaxQueues = maxQueues
if data, err := json.Marshal(existing); err == nil {
persistence.SaveTenantRaw(existing.ID, data)
}
}
return existing, nil
}
s.mu.Unlock()
return existing, nil
// Легаси: убираем старую запись из всех индексов и из Redis, ниже создаём новую
delete(s.byID, existing.ID)
delete(s.byAccessKey, existing.AccessKey)
delete(s.byEmail, existing.Email)
persistence.DeleteTenant(existing.ID)
}
if len(s.byID) >= MaxTenantsGlobal {
s.mu.Unlock()
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal)
}
accessKey, err := generateAccessKey()
if err != nil {
s.mu.Unlock()
return nil, fmt.Errorf("generate access key: %w", err)
}
secretKey, err := generateSecretKey()
if err != nil {
s.mu.Unlock()
return nil, fmt.Errorf("generate secret key: %w", err)
}
// Имя тенанта — email или sub (если email пустой)
name := email
if name == "" {
name = sub
}
t := &Tenant{
ID: tenantID,
Name: name,
Name: email,
AccessKey: accessKey,
SecretKey: secretKey,
MaxQueues: maxQueues,
@@ -238,8 +256,7 @@ func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int)
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
s.bySub[t.NubesSub] = t
s.mu.Unlock()
s.byEmail[t.Email] = t
// Сохраняем в Redis
if data, err := json.Marshal(t); err == nil {
@@ -249,6 +266,27 @@ func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int)
return t, nil
}
// tenantIDFromEmail — детерминированный ID тенанта из email: "t-{16 hex}".
func tenantIDFromEmail(email string) string {
hash := sha256.Sum256([]byte(strings.ToLower(email)))
return "t-" + hex.EncodeToString(hash[:8])
}
// accessKeyFromEmail — детерминированный AccessKey: "SSAK-{24 hex}".
func accessKeyFromEmail(email string) string {
hash := sha256.Sum256([]byte(strings.ToLower(email)))
return "SSAK-" + hex.EncodeToString(hash[:12])
}
// secretKeySalt — соль для детерминированного SecretKey email-тенантов.
const secretKeySalt = "shared-sqs:secret-key:v1"
// secretKeyFromEmail — детерминированный SecretKey: 64 hex = SHA256(email + соль).
func secretKeyFromEmail(email string) string {
h := sha256.Sum256([]byte(strings.ToLower(email) + ":" + secretKeySalt))
return hex.EncodeToString(h[:])
}
// List — список всех тенантов (для admin GET /tenants).
func (s *TenantStore) List() []*Tenant {
s.mu.RLock()
+409
View File
@@ -0,0 +1,409 @@
<!DOCTYPE html>
<!-- app/ui/admin.html — админский UI shared-sqs (тенанты, лимиты). Только по admin-токену. -->
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SQS Admin — Nubes</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--text-primary: #1a1a1a;
--text-muted: #6b7280;
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: #fafafa;
line-height: 1.5;
min-height: 100vh;
}
.topbar {
height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--brand-gray);
position: sticky;
top: 0;
}
.topbar .brand { font-weight: 600; }
.topbar .muted { color: var(--text-muted); }
.topbar .spacer { flex: 1; }
.chip {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--brand-primary);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 2px 10px;
}
.container { max-width: 1100px; margin: 0 auto; padding: 24px 16px 48px; }
.card {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.card-header { background: var(--brand-grey-light); padding: 12px; font-weight: 600; }
.card-body { padding: 16px; }
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar h2 { font-size: 20px; }
.tbl-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--brand-grey-light);
text-align: left;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.3px;
padding: 8px 10px;
border-right: 1px solid var(--brand-gray);
color: var(--text-muted);
font-weight: 600;
}
td { padding: 8px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:last-child td { border-bottom: none; }
td:last-child, th:last-child { border-right: none; }
tr:hover td { background: rgba(243,244,246,0.5); }
code, .mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.badge-active { background: rgba(22,163,74,0.12); color: var(--success); }
.badge-inactive { background: rgba(220,38,38,0.1); color: var(--danger); }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--brand-gray);
background: #fff;
color: var(--text-primary);
}
.btn:hover { background: var(--brand-grey-light); }
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
.btn-primary:hover { background: var(--brand-primary-dark); }
.btn-danger { background: #fff; border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: rgba(220,38,38,0.08); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.form-group { margin-bottom: 14px; }
.form-group label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 6px; }
.form-group input {
width: 100%;
padding: 9px 12px;
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
}
.form-group input:focus { outline: none; border-color: var(--brand-primary); }
.error { color: var(--danger); font-size: 13px; min-height: 20px; margin: 8px 0; }
.login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.login-box {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
padding: 32px;
width: 400px;
max-width: 92vw;
}
.login-box img { height: 28px; display: block; margin: 0 auto 12px; }
.login-box h1 { font-size: 20px; text-align: center; margin-bottom: 24px; }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
padding: 28px;
width: 480px;
max-width: 92vw;
max-height: 88vh;
overflow-y: auto;
}
.modal h2 { font-size: 20px; margin-bottom: 16px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px; }
.hidden { display: none !important; }
.hint {
font-size: 13px;
color: #374151;
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 14px;
line-height: 1.6;
}
.hint .warn { color: var(--warning); font-weight: 600; }
.empty { text-align: center; color: var(--text-muted); padding: 28px; }
</style>
</head>
<body>
<div id="login-page" class="login-page">
<div class="login-box">
<img src="/static/logo.svg" alt="Nubes">
<h1>SQS Admin</h1>
<div class="form-group">
<label>Admin-токен</label>
<input id="admin-token" type="password" placeholder="Bearer admin token">
</div>
<div id="login-error" class="error"></div>
<button class="btn btn-primary" style="width:100%;justify-content:center" onclick="doLogin()">Войти</button>
</div>
</div>
<div id="app" class="hidden">
<div class="topbar">
<img src="/static/logo.svg" height="18" alt="nubes">
<span class="brand">SQS Admin</span>
<span class="muted">· тенанты</span>
<span class="spacer"></span>
<span class="chip">{{VERSION}}</span>
<button class="btn btn-sm" onclick="doLogout()">Выйти</button>
</div>
<div class="container">
<div class="card">
<div class="toolbar" style="padding:14px 16px 0">
<h2>Тенанты</h2>
<div style="display:flex;gap:10px;align-items:center">
<button class="btn btn-sm" onclick="loadTenants()">⟳ Обновить</button>
<button class="btn btn-primary btn-sm" onclick="openCreateModal()">+ Создать</button>
</div>
</div>
<div class="card-body">
<div id="list-error" class="error"></div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Имя</th>
<th>ID</th>
<th>Access Key</th>
<th>Макс. очередей</th>
<th>Статус</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="tenants-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeCreateModal()">
<div class="modal">
<h2>Создать тенанта</h2>
<div class="form-group">
<label>Имя</label>
<input id="ct-name" placeholder="service-name">
</div>
<div class="form-group">
<label>Макс. очередей</label>
<input id="ct-queues" type="number" value="10" min="1" max="1000">
</div>
<div id="ct-error" class="error"></div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="createTenant()">Создать</button>
<button class="btn" onclick="closeCreateModal()">Отмена</button>
</div>
</div>
</div>
<div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()">
<div class="modal">
<h2>Тенант создан</h2>
<div class="hint">
<p class="warn">⚠ Передайте эти ключи владельцу тенанта.</p>
<p>Это креды для AWS CLI/SDK (подпись Signature V4).</p>
</div>
<div class="form-group">
<label>Access Key</label>
<input id="creds-ak" readonly onclick="copyField(this)">
</div>
<div class="form-group">
<label>Secret Key</label>
<input id="creds-sk" readonly onclick="copyField(this)">
</div>
<div class="modal-actions">
<button class="btn btn-primary" onclick="closeCredsModal()">Готово</button>
</div>
</div>
</div>
<script>
let adminToken = localStorage.getItem('sqs_admin_token') || '';
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
}
function api(path, opts = {}) {
return fetch(path, {
...opts,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + adminToken,
...(opts.headers || {})
}
}).then(r => {
if (r.status === 401) {
doLogout();
throw new Error('Неверный или истёкший admin-токен');
}
if (!r.ok) {
return r.json().catch(() => ({}))
.then(d => { throw new Error(d.error || r.status + ' ' + r.statusText); });
}
if (r.status === 204) return null;
return r.json();
});
}
function doLogin() {
const t = document.getElementById('admin-token').value.trim();
if (!t) return;
adminToken = t;
api('/admin/tenants')
.then(() => {
localStorage.setItem('sqs_admin_token', t);
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
loadTenants();
})
.catch(err => {
adminToken = '';
document.getElementById('login-error').textContent = 'Ошибка: ' + err.message;
});
}
function doLogout() {
adminToken = '';
localStorage.removeItem('sqs_admin_token');
document.getElementById('app').classList.add('hidden');
document.getElementById('login-page').classList.remove('hidden');
}
function loadTenants() {
document.getElementById('list-error').textContent = '';
api('/admin/tenants')
.then(list => renderTenants(list || []))
.catch(err => { document.getElementById('list-error').textContent = 'Ошибка: ' + err.message; });
}
function renderTenants(list) {
const tbody = document.getElementById('tenants-tbody');
tbody.innerHTML = (list || []).map(t => `
<tr>
<td><strong>${esc(t.name)}</strong>${t.id === (window._lastCreated || '') ? '' : ''}</td>
<td class="mono">${esc(t.id)}</td>
<td class="mono">${esc(t.access_key)}</td>
<td>${t.max_queues}</td>
<td>${t.active
? '<span class="badge badge-active">active</span>'
: '<span class="badge badge-inactive">inactive</span>'}</td>
<td>${fmtDate(t.created_at)}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteTenant('${esc(t.id)}','${esc(t.name)}')">Удалить</button></td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Тенантов нет</td></tr>';
}
function openCreateModal() {
document.getElementById('ct-name').value = '';
document.getElementById('ct-queues').value = '10';
document.getElementById('ct-error').textContent = '';
document.getElementById('modal-create').classList.remove('hidden');
}
function closeCreateModal() {
document.getElementById('modal-create').classList.add('hidden');
}
function createTenant() {
const name = document.getElementById('ct-name').value.trim();
const maxQ = parseInt(document.getElementById('ct-queues').value) || 10;
if (!name) {
document.getElementById('ct-error').textContent = 'Укажите имя';
return;
}
api('/admin/tenants', { method: 'POST', body: JSON.stringify({ name: name, max_queues: maxQ }) })
.then(data => {
closeCreateModal();
document.getElementById('creds-ak').value = data.access_key || '';
document.getElementById('creds-sk').value = data.secret_key || '';
document.getElementById('modal-creds').classList.remove('hidden');
loadTenants();
})
.catch(err => { document.getElementById('ct-error').textContent = 'Ошибка: ' + err.message; });
}
function closeCredsModal() {
document.getElementById('modal-creds').classList.add('hidden');
}
function deleteTenant(id, name) {
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди и сообщения будут удалены.')) return;
api('/admin/tenants/' + id, { method: 'DELETE' })
.then(() => loadTenants())
.catch(err => alert('Ошибка: ' + err.message));
}
function copyField(el) {
el.select();
navigator.clipboard && navigator.clipboard.writeText(el.value);
}
// init
if (adminToken) {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
loadTenants();
}
</script>
</body>
</html>
+54 -1
View File
@@ -5,13 +5,66 @@ package ui
import (
"embed"
"io/fs"
"net/http"
"strings"
)
//go:embed index.html
//go:embed index.html info.html admin.html examples.html static
var content embed.FS
// Handler — возвращает http.Handler, раздающий встроенный index.html
func Handler() http.Handler {
return http.FileServer(http.FS(content))
}
// InfoHandler — публичная страница-описание сервиса (GET /) с подстановкой версии
func InfoHandler(version string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := content.ReadFile("info.html")
if err != nil {
http.Error(w, "info page unavailable", http.StatusInternalServerError)
return
}
html := strings.ReplaceAll(string(body), "{{VERSION}}", version)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
})
}
// AdminHandler — админская консоль (GET /admin) с подстановкой версии
func AdminHandler(version string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := content.ReadFile("admin.html")
if err != nil {
http.Error(w, "admin page unavailable", http.StatusInternalServerError)
return
}
html := strings.ReplaceAll(string(body), "{{VERSION}}", version)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
})
}
// ExamplesHandler — страница примеров команд (GET /examples) с подстановкой версии
func ExamplesHandler(version string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := content.ReadFile("examples.html")
if err != nil {
http.Error(w, "examples page unavailable", http.StatusInternalServerError)
return
}
html := strings.ReplaceAll(string(body), "{{VERSION}}", version)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
})
}
// StaticHandler — раздаёт /static/* (логотип, favicon) для публичных страниц
func StaticHandler() http.Handler {
sub, err := fs.Sub(content, "static")
if err != nil {
panic(err)
}
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
}
+228
View File
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<!-- app/ui/examples.html — примеры команд SQS API (отдельная страница, крупный шрифт) -->
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Примеры команд — shared-sqs</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--text-primary: #1a1a1a;
--text-muted: #6b7280;
--success: #16a34a;
--warning: #d97706;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
color: var(--text-primary);
background: #fafafa;
line-height: 1.6;
}
.topbar {
height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--brand-gray);
position: sticky;
top: 0;
}
.topbar .brand { font-weight: 600; }
.topbar .muted { color: var(--text-muted); }
.topbar .spacer { flex: 1; }
.chip {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--brand-primary);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 2px 10px;
}
a { color: var(--brand-primary); text-decoration: none; }
a:hover { color: var(--brand-primary-dark); text-decoration: underline; }
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px 48px; }
.hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.hero h1 { font-size: 26px; margin: 0; }
.hero .sub { color: var(--text-muted); margin: 4px 0 0; }
.card {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.card-header { background: var(--brand-grey-light); padding: 12px 16px; font-weight: 600; font-size: 16px; }
.card-body { padding: 16px; }
.card-body p { margin: 8px 0; color: #374151; }
.step-num {
display: inline-flex;
width: 26px;
height: 26px;
border-radius: 999px;
background: var(--brand-primary);
color: #fff;
font-weight: 700;
font-size: 14px;
align-items: center;
justify-content: center;
margin-right: 8px;
}
pre {
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 14px;
line-height: 1.7;
margin: 10px 0 16px;
}
pre code { background: none; border: none; padding: 0; }
.note {
font-size: 13px;
color: var(--text-muted);
margin: -8px 0 14px;
}
.tbl-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th {
background: var(--brand-grey-light);
text-align: left;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.3px;
padding: 8px 12px;
border-right: 1px solid var(--brand-gray);
color: var(--text-muted);
font-weight: 600;
}
td { padding: 8px 12px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:last-child td { border-bottom: none; }
td:last-child, th:last-child { border-right: none; }
tr:hover td { background: rgba(243,244,246,0.5); }
td code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; }
.footer { color: var(--text-muted); font-size: 13px; text-align: center; margin-top: 32px; }
</style>
</head>
<body>
<div class="topbar">
<img src="/static/logo.svg" height="18" alt="nubes">
<span class="brand">Nubes</span>
<span class="muted">· очередь</span>
<span class="spacer"></span>
<a href="/">← На главную</a>
<span class="chip">{{VERSION}}</span>
</div>
<div class="container">
<div class="hero">
<div>
<h1>Примеры команд</h1>
<p class="sub">Пошаговое подключение к SQS API через AWS CLI. Ключи доступа: консоль <a href="/ui">/ui</a> → кнопка Credentials.</p>
</div>
</div>
<div class="card">
<div class="card-header">Шаг 1. Указание ключей доступа</div>
<div class="card-body">
<p><span class="step-num">A</span><strong>Переменные окружения</strong> (действуют в текущей сессии терминала):</p>
<pre><code>export AWS_ACCESS_KEY_ID=&lt;Access Key&gt;
export AWS_SECRET_ACCESS_KEY=&lt;Secret Key&gt;</code></pre>
<p><span class="step-num">B</span><strong>Файл ~/.aws/credentials</strong> (постоянно, для всех команд):</p>
<pre><code>[sqs]
aws_access_key_id = &lt;Access Key&gt;
aws_secret_access_key = &lt;Secret Key&gt;</code></pre>
<p class="note">Для способа B к каждой команде добавляется <code>--profile sqs</code>.</p>
</div>
</div>
<div class="card">
<div class="card-header">Шаг 2. Создание очереди</div>
<div class="card-body">
<pre><code>aws sqs create-queue --queue-name demo \
--endpoint-url https://sqs.containerk8s.dev.nubes.ru \
--region us-east-1 --profile sqs</code></pre>
<p>В ответе возвращается <code>QueueUrl</code> — он используется в следующих командах.</p>
</div>
</div>
<div class="card">
<div class="card-header">Шаг 3. Отправка сообщения</div>
<div class="card-body">
<pre><code>aws sqs send-message \
--queue-url &lt;QueueUrl&gt; \
--message-body "hello nubes" \
--endpoint-url https://sqs.containerk8s.dev.nubes.ru \
--region us-east-1 --profile sqs</code></pre>
</div>
</div>
<div class="card">
<div class="card-header">Шаг 4. Получение и удаление сообщения</div>
<div class="card-body">
<pre><code>aws sqs receive-message \
--queue-url &lt;QueueUrl&gt; \
--endpoint-url https://sqs.containerk8s.dev.nubes.ru \
--region us-east-1 --profile sqs
aws sqs delete-message \
--queue-url &lt;QueueUrl&gt; \
--receipt-handle &lt;ReceiptHandle из ответа receive-message&gt; \
--endpoint-url https://sqs.containerk8s.dev.nubes.ru \
--region us-east-1 --profile sqs</code></pre>
</div>
</div>
<div class="card">
<div class="card-header">Все команды SQS API</div>
<div class="card-body">
<p>Все команды отправляются на <code>POST https://sqs.containerk8s.dev.nubes.ru/</code> с параметром <code>Action</code> (AWS CLI делает это сам).</p>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>Action</th><th>Описание</th></tr>
</thead>
<tbody>
<tr><td><code>CreateQueue</code></td><td>Создать очередь</td></tr>
<tr><td><code>ListQueues</code></td><td>Список очередей</td></tr>
<tr><td><code>GetQueueUrl</code></td><td>URL очереди по имени</td></tr>
<tr><td><code>GetQueueAttributes</code></td><td>Атрибуты очереди</td></tr>
<tr><td><code>SetQueueAttributes</code></td><td>Изменить атрибуты очереди</td></tr>
<tr><td><code>SendMessage</code></td><td>Отправить сообщение</td></tr>
<tr><td><code>SendMessageBatch</code></td><td>Отправить до 10 сообщений разом</td></tr>
<tr><td><code>ReceiveMessage</code></td><td>Получить сообщения (long polling до 20с)</td></tr>
<tr><td><code>DeleteMessage</code></td><td>Удалить сообщение</td></tr>
<tr><td><code>DeleteMessageBatch</code></td><td>Удалить до 10 сообщений разом</td></tr>
<tr><td><code>ChangeMessageVisibility</code></td><td>Изменить таймаут видимости сообщения</td></tr>
<tr><td><code>ChangeMessageVisibilityBatch</code></td><td>Пакетно изменить таймаут видимости</td></tr>
<tr><td><code>PurgeQueue</code></td><td>Очистить очередь</td></tr>
<tr><td><code>DeleteQueue</code></td><td>Удалить очередь</td></tr>
<tr><td><code>TagQueue</code></td><td>Назначить теги очереди</td></tr>
<tr><td><code>UntagQueue</code></td><td>Снять теги с очереди</td></tr>
<tr><td><code>ListQueueTags</code></td><td>Список тегов очереди</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="footer">shared-sqs · версия {{VERSION}} · <a href="/">на главную</a> · <a href="/ui">консоль</a> · <a href="/health">health</a></div>
</div>
</body>
</html>
+144 -68
View File
@@ -12,25 +12,27 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SQS Console — Nubes</title>
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png">
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
/* Nubes palette — идентично iot.kube5s.ru/console */
/* Nubes design systemсветлая тема (deck) */
:root {
--bg-page: #001120;
--bg-surface: #001929;
--bg-navbar: #001C34;
--border: #0b2d50;
--accent: #1a7fd4;
--accent-hover: #2196f3;
--text-primary: #e2ecf6;
--text-secondary: #6b8eaa;
--danger: #e74c3c;
--success: #27ae60;
--warning: #f39c12;
--bg-page: #fafafa;
--bg-surface: #ffffff;
--bg-navbar: #ffffff;
--border: #d1d5db;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--text-primary: #1a1a1a;
--text-secondary: #6b7280;
--danger: #dc2626;
--success: #16a34a;
--warning: #d97706;
--brand-grey-light: #f3f4f6;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
background: var(--bg-page);
color: var(--text-primary);
min-height: 100vh;
@@ -55,7 +57,6 @@ a:hover { color: var(--accent-hover); }
}
.navbar-brand img {
height: 28px;
filter: brightness(0) invert(1);
}
.navbar-brand span {
font-size: 16px;
@@ -88,7 +89,8 @@ a:hover { color: var(--accent-hover); }
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
padding: 20px;
margin-bottom: 20px;
}
@@ -111,15 +113,17 @@ a:hover { color: var(--accent-hover); }
.stat-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
padding: 20px;
text-align: center;
}
.stat-value {
font-size: 36px;
font-size: 30px;
font-weight: 700;
color: var(--accent);
color: var(--text-primary);
}
.stat-value.accent { color: var(--accent); }
.stat-label {
font-size: 12px;
color: var(--text-secondary);
@@ -149,7 +153,7 @@ tbody td {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
tbody tr:hover { background: rgba(26, 127, 212, 0.05); }
tbody tr:hover { background: var(--brand-grey-light); }
tbody tr { cursor: pointer; }
/* Buttons */
@@ -168,8 +172,8 @@ tbody tr { cursor: pointer; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #c0392b; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
.btn-danger:hover { background: #b91c1c; }
.btn-sm { padding: 4px 12px; font-size: 13px; }
/* Forms */
.form-group { margin-bottom: 16px; }
@@ -182,9 +186,9 @@ tbody tr { cursor: pointer; }
.form-group input {
width: 100%;
padding: 10px 14px;
background: var(--bg-page);
background: #ffffff;
border: 1px solid var(--border);
border-radius: 4px;
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
}
@@ -208,10 +212,10 @@ tbody tr { cursor: pointer; }
width: 400px;
text-align: center;
}
.login-box img { height: 40px; filter: brightness(0) invert(1); margin-bottom: 12px; }
.login-box img { height: 40px; margin-bottom: 12px; }
.login-box h1 {
font-size: 18px;
letter-spacing: 2px;
font-size: 22px;
letter-spacing: 1px;
margin-bottom: 32px;
color: var(--text-primary);
}
@@ -225,15 +229,15 @@ tbody tr { cursor: pointer; }
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.badge-active { background: rgba(39,174,96,0.15); color: var(--success); }
.badge-inactive { background: rgba(231,76,60,0.15); color: var(--danger); }
.badge-active { background: rgba(22,163,74,0.12); color: var(--success); }
.badge-inactive { background: rgba(220,38,38,0.1); color: var(--danger); }
.badge-count {
background: rgba(26,127,212,0.15);
background: rgba(37,99,235,0.1);
color: var(--accent);
min-width: 24px;
text-align: center;
@@ -252,7 +256,7 @@ tbody tr { cursor: pointer; }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
background: rgba(0,0,0,0.45);
display: flex;
align-items: center;
justify-content: center;
@@ -262,12 +266,15 @@ tbody tr { cursor: pointer; }
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
padding: 32px;
width: 480px;
max-width: 90vw;
width: 560px;
max-width: 92vw;
max-height: 88vh;
overflow-y: auto;
}
.modal h2 {
font-size: 18px;
font-size: 20px;
margin-bottom: 20px;
}
.modal-actions {
@@ -304,7 +311,7 @@ tbody tr { cursor: pointer; }
margin-bottom: 16px;
}
.toolbar h2 {
font-size: 18px;
font-size: 20px;
font-weight: 600;
}
.auto-refresh {
@@ -315,6 +322,36 @@ tbody tr { cursor: pointer; }
color: var(--text-secondary);
}
/* Credentials modal — подсказка */
.creds-hint {
font-size: 13px;
color: #374151;
background: var(--brand-grey-light);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 16px;
line-height: 1.6;
}
.creds-hint .warn {
color: var(--warning);
font-weight: 600;
}
.creds-hint p { margin: 6px 0; }
.creds-hint .creds-sub { font-weight: 600; margin-top: 10px; color: var(--text-primary); }
.creds-hint pre {
background: #ffffff;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
overflow-x: auto;
white-space: pre;
margin: 6px 0 10px;
}
/* Expandable message rows */
td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
.msg-expand-inner { padding: 12px 20px; background: var(--bg-page); }
@@ -327,7 +364,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<!-- ===== LOGIN PAGE ===== -->
<div id="login-page" class="login-page">
<div class="login-box">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<img src="/static/logo.svg" alt="Nubes">
<h1>SQS CONSOLE</h1>
<div class="form-group">
<label for="login-token">API Token или Demo Token</label>
@@ -337,8 +374,19 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br>
Для личного tenant-а используйте API token из панели Nubes.
Для личного аккаунта используйте API token из панели Nubes.
</p>
<div style="margin-top:24px;text-align:left;font-size:12px;color:var(--text-secondary);border-top:1px solid var(--border);padding-top:16px;line-height:1.7">
<div style="font-weight:700;color:var(--text-primary);margin-bottom:6px;letter-spacing:0.5px">Информация о сервисе</div>
<div>Endpoint: <span style="color:var(--accent);font-family:monospace">https://sqs.containerk8s.dev.nubes.ru</span></div>
<div>Health: <span style="font-family:monospace">GET /health</span> · Метрики: <span style="font-family:monospace">GET /metrics</span></div>
<div>SQS API: <span style="font-family:monospace">POST /</span> (AWS Signature V4, регион <span style="font-family:monospace">us-east-1</span>)</div>
<div>Админ-консоль: <a href="/admin">/admin</a> (вход по admin-токену)</div>
<div>Realm: <span style="font-family:monospace">iot-naeel</span> · Persistence: Managed Redis</div>
<div>Версия: <span id="svc-version" style="font-family:monospace"></span></div>
<div>Образ: <span style="font-family:monospace">naeel/shared-sqs:v0.1.35</span></div>
<div style="margin-top:8px;color:var(--warning)">Статус: тестирование</div>
</div>
</div>
</div>
@@ -346,7 +394,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<div id="app" class="hidden">
<nav class="navbar">
<div class="navbar-brand">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<img src="/static/logo.svg" alt="Nubes">
<span>SQS CONSOLE</span>
</div>
<div class="navbar-user">
@@ -365,7 +413,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<!-- ===== CREATE TENANT MODAL ===== -->
<div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeModal()">
<div class="modal">
<h2>Создать тенанта</h2>
<h2>Создать аккаунт</h2>
<div class="form-group">
<label for="ct-name">Имя</label>
<input id="ct-name" placeholder="my-service">
@@ -385,10 +433,21 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<!-- ===== CREDENTIALS MODAL ===== -->
<div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()">
<div class="modal">
<h2>Тенант создан</h2>
<p style="color:var(--warning);font-size:13px;margin-bottom:16px">
⚠ Сохраните credentials — Secret Key показывается только один раз.
</p>
<h2>Credentials</h2>
<div class="creds-hint">
<p class="warn">⚠ Сохраните ключи — это креды вашего аккаунта для AWS CLI/SDK.</p>
<p><strong>Зачем:</strong> приложения подключаются к SQS API по протоколу AWS (подпись Signature V4). Токен входа в консоль для этого не подходит.</p>
<p><strong>Что делать:</strong> укажите ключи одним из двух способов:</p>
<p class="creds-sub">Способ 1 — переменные окружения:</p>
<pre>export AWS_ACCESS_KEY_ID=&lt;Access Key&gt;
export AWS_SECRET_ACCESS_KEY=&lt;Secret Key&gt;</pre>
<p class="creds-sub">Способ 2 — файл ~/.aws/credentials:</p>
<pre>[sqs]
aws_access_key_id = &lt;Access Key&gt;
aws_secret_access_key = &lt;Secret Key&gt;</pre>
<p>Дальше выполняйте команды (для способа 2 добавьте <code>--profile sqs</code>):</p>
<pre>aws sqs list-queues --endpoint-url https://sqs.containerk8s.dev.nubes.ru --region us-east-1 --profile sqs</pre>
</div>
<div class="form-group">
<label>Access Key</label>
<input id="creds-ak" readonly onclick="copyField(this)">
@@ -409,7 +468,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<h2>Отправить сообщение</h2>
<div class="form-group">
<label for="send-body">Тело сообщения</label>
<textarea id="send-body" rows="6" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical"></textarea>
<textarea id="send-body" rows="6" style="width:100%;background:#ffffff;border:1px solid var(--border);border-radius:8px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical"></textarea>
</div>
<div id="send-error" class="login-error"></div>
<div class="modal-actions">
@@ -429,7 +488,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
</div>
<div class="form-group">
<label>Body (нажмите, чтобы скопировать)</label>
<textarea id="msg-detail-body" readonly rows="8" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical;cursor:pointer" onclick="copyTextarea(this)"></textarea>
<textarea id="msg-detail-body" readonly rows="8" style="width:100%;background:#ffffff;border:1px solid var(--border);border-radius:8px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical;cursor:pointer" onclick="copyTextarea(this)"></textarea>
</div>
<div class="form-group">
<label>Отправлено</label>
@@ -486,6 +545,14 @@ let sessionEmail = null; // Email из JWT для отображения в
}
} catch(e) { /* corrupted — show login */ }
}
// Версия сервиса — берём из публичного /health и показываем на странице входа
fetch(BASE + '/health')
.then(r => r.json())
.then(h => {
const el = document.getElementById('svc-version');
if (el && h.version) el.textContent = h.version;
})
.catch(() => { /* health недоступен — оставляем '—' */ });
showLogin();
})();
@@ -542,7 +609,13 @@ function enterApp() {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
document.getElementById('user-email').textContent = sessionEmail || '';
showDashboard();
// Юзер сразу попадает в свой аккаунт — без промежуточного дашборда с таблицей.
api('/tenants')
.then(list => {
if (list && list.length > 0) showTenant(list[0].id);
else showDashboard();
})
.catch(() => showDashboard());
}
// ===== API HELPER =====
@@ -593,10 +666,6 @@ function renderDashboard(health, tenants) {
const el = document.getElementById('view-dashboard');
el.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${health.tenant_count || 0}</div>
<div class="stat-label">Тенанты</div>
</div>
<div class="stat-card">
<div class="stat-value">${health.queue_count || 0}</div>
<div class="stat-label">Очереди</div>
@@ -609,10 +678,14 @@ function renderDashboard(health, tenants) {
<div class="stat-value" style="color:var(--success)">●</div>
<div class="stat-label">${health.status || 'ok'}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:18px">${health.version || 'dev'}</div>
<div class="stat-label">Версия</div>
</div>
</div>
<div class="card">
<div class="toolbar">
<h2>Мой tenant</h2>
<h2>Мой аккаунт</h2>
<div style="display:flex;gap:12px;align-items:center">
<div class="auto-refresh">
<span>⟳ 10с</span>
@@ -624,7 +697,6 @@ function renderDashboard(health, tenants) {
<thead>
<tr>
<th>Имя</th>
<th>Access Key</th>
<th>Макс. очередей</th>
<th>Статус</th>
<th>Создан</th>
@@ -634,7 +706,6 @@ function renderDashboard(health, tenants) {
${(tenants || []).map(t => `
<tr onclick="showTenant('${esc(t.id)}')">
<td><strong>${esc(t.name)}</strong></td>
<td style="font-family:monospace;font-size:12px">${esc(t.access_key)}</td>
<td>${t.max_queues}</td>
<td>${t.active
? '<span class="badge badge-active">active</span>'
@@ -642,7 +713,7 @@ function renderDashboard(health, tenants) {
<td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</td>
</tr>
`).join('')}
${(!tenants || tenants.length === 0) ? '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:32px">Tenant не найден</td></tr>' : ''}
${(!tenants || tenants.length === 0) ? '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:32px">Аккаунт не найден</td></tr>' : ''}
</tbody>
</table>
</div>
@@ -676,11 +747,6 @@ function renderTenant(tenant, queues) {
const el = document.getElementById('view-tenant');
const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0);
el.innerHTML = `
<div class="breadcrumb">
<a href="#" onclick="event.preventDefault();showDashboard()">Мой tenant</a>
<span></span>
${esc(tenant.name)}
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${(queues || []).length}</div>
@@ -694,16 +760,13 @@ function renderTenant(tenant, queues) {
<div class="stat-value">${tenant.max_queues}</div>
<div class="stat-label">Лимит очередей</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:20px;font-family:monospace">${esc(tenant.access_key)}</div>
<div class="stat-label">Access Key</div>
</div>
</div>
<div class="card">
<div class="toolbar">
<h2>Очереди</h2>
<div style="display:flex;gap:12px;align-items:center">
<div class="auto-refresh"><span>⟳ 10с</span></div>
<button class="btn btn-primary btn-sm" onclick="showCredentials()">Credentials</button>
<button class="btn btn-primary btn-sm" onclick="openCreateQueueModal('${esc(tenant.id)}')" >+ Очередь</button>
</div>
</div>
@@ -794,9 +857,22 @@ function closeCredsModal() {
document.getElementById('modal-creds').classList.add('hidden');
}
// showCredentials — запрашивает ключи тенанта и показывает их в модалке
function showCredentials() {
api('/credentials')
.then(data => {
document.getElementById('creds-ak').value = data.access_key || '';
document.getElementById('creds-sk').value = data.secret_key || '';
document.getElementById('modal-creds').classList.remove('hidden');
})
.catch(err => {
alert('Не удалось получить credentials: ' + err.message);
});
}
// deleteTenant — DELETE /tenants/{id} с подтверждением
function deleteTenant(id, name) {
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return;
if (!confirm('Удалить аккаунт "' + name + '"?\nВсе очереди будут удалены.')) return;
api('/tenants/' + id, { method: 'DELETE' })
.then(() => loadDashboard())
.catch(err => alert('Ошибка удаления: ' + err.message));
@@ -949,7 +1025,7 @@ function renderQueueMessages(queueName, msgs) {
<tbody>
${msgs.map(m => `
<tr onclick="openMsgDetail('${esc(m.id)}')" style="cursor:pointer"
onmouseover="this.style.background='rgba(26,127,212,0.08)'" onmouseout="this.style.background=''">
onmouseover="this.style.background='var(--brand-grey-light)'" onmouseout="this.style.background=''">
<td style="padding:6px 12px;font-family:monospace;color:var(--text-secondary)">${esc(m.id).substring(0,8)}…</td>
<td style="padding:6px 12px;max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.body || '').substring(0,120)}${(m.body||'').length>120?'…':''}</td>
<td style="padding:6px 12px;color:var(--text-secondary)">${fmtDate(m.sent_at)}</td>
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>shared-sqs — очередь Nubes (SQS API)</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
:root {
--brand-primary: #2563eb;
--brand-primary-dark: #1d4ed8;
--brand-gray: #d1d5db;
--brand-grey-light: #f3f4f6;
--text-primary: #1a1a1a;
--text-muted: #6b7280;
--success: #16a34a;
--warning: #d97706;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: #fafafa;
line-height: 1.5;
}
/* Топбар — как в deck: белый, border-bottom */
.topbar {
height: 56px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid var(--brand-gray);
position: sticky;
top: 0;
}
.topbar .brand { font-weight: 600; color: var(--text-primary); }
.topbar .muted { color: var(--text-muted); }
.topbar .spacer { flex: 1; }
.chip {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--brand-primary);
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 2px 10px;
}
.container { max-width: 880px; margin: 0 auto; padding: 24px 16px 48px; }
/* Hero */
.hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.hero h1 { font-size: 24px; margin: 0; letter-spacing: -0.3px; }
.hero .sub { color: var(--text-muted); margin: 4px 0 0; }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--warning);
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 999px;
padding: 2px 10px;
margin-left: 8px;
}
/* Карточки — как в design-system */
.card {
background: #fff;
border: 1px solid var(--brand-gray);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
margin-bottom: 20px;
overflow: hidden;
}
.card-header {
background: var(--brand-grey-light);
padding: 12px;
font-weight: 600;
font-size: 14px;
}
.card-body { padding: 12px; }
.card-body > p:first-child { margin-top: 0; }
.card-body > p:last-child { margin-bottom: 0; }
/* Сетка «подключение» */
.kv { display: grid; grid-template-columns: 200px 1fr; gap: 8px 16px; }
.kv .k { color: var(--text-muted); }
.kv .v { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; word-break: break-all; }
a { color: var(--brand-primary); text-decoration: none; }
a:hover { color: var(--brand-primary-dark); text-decoration: underline; }
/* Таблицы — как в design-system */
.tbl-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th {
background: var(--brand-grey-light);
text-align: left;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.3px;
padding: 4px 8px;
border-right: 1px solid var(--brand-gray);
color: var(--text-muted);
font-weight: 600;
}
td { padding: 4px 8px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:last-child td { border-bottom: none; }
td:last-child, th:last-child { border-right: none; }
tr:hover td { background: rgba(243,244,246,0.5); }
td code, .v code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; }
/* Код-блоки */
pre {
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 12px;
overflow-x: auto;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.6;
margin: 8px 0 0;
}
pre code { background: none; border: none; padding: 0; }
.cmd-label { font-size: 12px; color: var(--text-muted); margin: 12px 0 0; font-weight: 600; }
.cmd-label:first-child { margin-top: 0; }
.status-ok { color: var(--success); font-weight: 600; }
.footer { color: var(--text-muted); font-size: 12px; text-align: center; margin-top: 32px; }
/* Быстрый старт — шаги */
.step { display: flex; gap: 14px; margin-bottom: 18px; }
.step:last-child { margin-bottom: 0; }
.step-num {
flex: 0 0 30px;
height: 30px;
border-radius: 999px;
background: var(--brand-primary);
color: #fff;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.step-body { flex: 1; }
.step-title { font-weight: 600; font-size: 15px; margin-bottom: 2px; }
.step-body p { color: #374151; margin: 4px 0; }
.btn-hero {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--brand-primary);
color: #fff !important;
text-decoration: none !important;
font-size: 15px;
font-weight: 600;
padding: 10px 20px;
border-radius: 8px;
margin-top: 8px;
}
.btn-hero:hover { background: var(--brand-primary-dark); }
.link-card { text-align: center; padding: 8px 0 4px; }
.link-card a { font-size: 15px; font-weight: 600; }
.hint-box {
font-size: 14px;
color: #374151;
background: var(--brand-grey-light);
border: 1px solid var(--brand-gray);
border-radius: 8px;
padding: 10px 14px;
margin-top: 10px;
line-height: 1.7;
}
.hint-box code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; }
</style>
</head>
<body>
<div class="topbar">
<img src="/static/logo.svg" height="18" alt="nubes">
<span class="brand">Nubes</span>
<span class="muted">· очередь</span>
<span class="spacer"></span>
<span class="chip">{{VERSION}}</span>
</div>
<div class="container">
<div class="hero">
<img src="/static/logo.svg" height="28" alt="nubes">
<div>
<h1>shared-sqs <span class="badge">тестирование</span></h1>
<p class="sub">Очередь, совместимая с AWS SQS API. Очереди и сообщения пользователей — в одном месте, доступ по протоколу AWS.</p>
</div>
</div>
<div class="card">
<div class="card-header">Порядок действий — 3 шага</div>
<div class="card-body">
<div class="step">
<div class="step-num">1</div>
<div class="step-body">
<div class="step-title">Вход в консоль</div>
<p>API-токен выдается в панели Nubes и вводится на странице входа.</p>
<a class="btn-hero" href="/ui">Открыть консоль →</a>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-body">
<div class="step-title">Получение ключей доступа</div>
<p>В консоли кнопка <code>Credentials</code> открывает окно с <strong>Access Key</strong> и <strong>Secret Key</strong> — это учетные данные для подключения.</p>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-body">
<div class="step-title">Подключение клиента</div>
<p>Ключи указываются в AWS CLI/SDK; далее работа с очередями ведется как с обычным AWS SQS.</p>
<div class="hint-box">
Быстрый вариант — переменные окружения:<br>
<code>export AWS_ACCESS_KEY_ID=&lt;Access Key&gt;</code><br>
<code>export AWS_SECRET_ACCESS_KEY=&lt;Secret Key&gt;</code><br>
<code>aws sqs list-queues --endpoint-url https://sqs.containerk8s.dev.nubes.ru --region us-east-1</code>
</div>
<p style="margin-top:10px">Полные примеры команд, включая способ через файл <code>~/.aws/credentials</code>, — на отдельной странице:</p>
<a class="btn-hero" href="/examples">Примеры команд →</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Подключение</div>
<div class="card-body kv">
<div class="k">Endpoint</div>
<div class="v">https://sqs.containerk8s.dev.nubes.ru</div>
<div class="k">Протокол</div>
<div class="v">AWS SQS Query / JSON, подпись <code>AWS Signature V4</code></div>
<div class="k">Регион</div>
<div class="v">us-east-1</div>
<div class="k">Аутентификация</div>
<div class="v">Access Key / Secret Key — выдаются в консоли <a href="/ui">/ui</a> (кнопка Credentials)</div>
<div class="k">Консоль</div>
<div class="v"><a href="/ui">/ui</a> — веб-интерфейс (вход по API-токену Nubes)</div>
<div class="k">Health</div>
<div class="v"><a href="/health">GET /health</a> <span class="status-ok">● ok</span></div>
<div class="k">Метрики</div>
<div class="v"><a href="/metrics">GET /metrics</a> — Prometheus</div>
<div class="k">Persistence</div>
<div class="v">Managed Redis (realm <code>iot-naeel</code>)</div>
</div>
</div>
<div class="card">
<div class="card-header">Команды и примеры</div>
<div class="card-body">
<p style="color:#374151">Полный список команд SQS API и подробные примеры для AWS CLI — на отдельной странице с крупным шрифтом.</p>
<div class="link-card"><a href="/examples">Открыть примеры команд →</a></div>
</div>
</div>
<div class="footer">shared-sqs · realm iot-naeel · версия {{VERSION}} · <a href="/examples">примеры</a> · <a href="/metrics">метрики</a> · <a href="/health">health</a> · <a href="/admin">админ</a></div>
</div>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="57"
height="57"
xml:space="preserve"
overflow="hidden"
version="1.1"
id="svg8"
sodipodi:docname="U_v3.svg"
inkscape:version="1.3.2 (091e20e, 2023-11-25, custom)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview8"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="16.226667"
inkscape:cx="27.146672"
inkscape:cy="28.317584"
inkscape:window-width="2560"
inkscape:window-height="1494"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg8" /><defs
id="defs2"><clipPath
id="clip0"><rect
x="652"
y="420"
width="64"
height="75"
id="rect1" /></clipPath><clipPath
id="clip1"><rect
x="652"
y="420"
width="64"
height="70"
id="rect2" /></clipPath></defs><g
clip-path="url(#clip0)"
transform="matrix(1.0135748,0,0,1.0135748,-664.97034,-440.30567)"
id="g8"><g
clip-path="url(#clip1)"
id="g7"><g
id="g6"><path
d="m 114.028,43.1492 v 7.6592 c 0,3.7843 -3.08,6.8632 -6.866,6.8632 L 85.3367,57.5419 c -3.7853,0 -6.8655,-3.0805 -6.8655,-6.8643 v -2.5279 l 0.0555,0.009 V 15.8148 l -10.3823,2.0127 0.0045,3.8772 -0.0045,0.0015 v 28.971 c 0,9.481 7.7132,17.1923 17.1867,17.1923 l 21.8359,0.1313 c 9.474,0 17.187,-7.7117 17.187,-17.1928 v -2.6541 l 0.027,0.0045 V 15.8145 l -10.382,2.0126 0.028,25.3214 z"
fill="#001c34"
fill-rule="evenodd"
transform="matrix(1,0,0,1.01337,587.92,420.059)"
id="path6" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 8221.93 1821.07"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.fil0 {fill:#001C34}
]]>
</style>
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2283355517888">
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

-139
View File
@@ -1,139 +0,0 @@
package utils
import (
"net/url"
"testing"
"shared-sqs/app/models"
"shared-sqs/app/test"
"shared-sqs/app/fixtures"
"shared-sqs/app/mocks"
"github.com/stretchr/testify/assert"
)
func TestTransformRequest_success_json(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", fixtures.JSONRequestBody, true)
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.True(t, ok)
assert.Equal(t, "mock-value", mock.RequestFieldStr)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestTransformRequest_success_json_empty_request_accepted(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", nil, true)
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, true)
assert.True(t, ok)
//assert.Equal(t, "mock-value", mock.RequestFieldStr)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestTransformRequest_success_xml(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", nil, false)
form := url.Values{}
form.Add("Action", "CreateQueue")
form.Add("QueueName", "UnitTestQueue1")
form.Add("Attribute.1.Name", "VisibilityTimeout")
form.Add("Attribute.1.Value", "60")
form.Add("Attribute.2.Name", "MaximumMessageSize")
form.Add("Attribute.2.Value", "2048")
r.PostForm = form
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.True(t, ok)
assert.True(t, mock.SetAttributesFromFormCalled)
assert.Equal(t, []interface{}{form}, mock.SetAttributesFromFormCalledWith)
}
func TestTransformRequest_error_invalid_request_body_json(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", "\"I-am-garbage", true)
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.False(t, ok)
assert.Equal(t, "", mock.RequestFieldStr)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestTransformRequest_error_failure_to_parse_form_xml(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", nil, false)
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.False(t, ok)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestTransformRequest_error_invalid_request_body_xml(t *testing.T) {
_, r := test.GenerateRequestInfo("POST", "url", nil, false)
form := url.Values{}
form.Add("intField", "\"I-am-garbage")
r.PostForm = form
mock := &mocks.MockRequestBody{}
ok := TransformRequest(mock, r, false)
assert.False(t, ok)
assert.False(t, mock.SetAttributesFromFormCalled)
}
func TestExtractQueueAttributes_success(t *testing.T) {
u := url.Values{}
u.Add("Attribute.1.Name", "DelaySeconds")
u.Add("Attribute.1.Value", "20")
u.Add("Attribute.2.Name", "VisibilityTimeout")
u.Add("Attribute.2.Value", "30")
u.Add("Attribute.3.Name", "Policy")
attr := ExtractQueueAttributes(u)
expected := map[string]string{
"DelaySeconds": "20",
"VisibilityTimeout": "30",
}
assert.Equal(t, expected, attr)
}
func TestGetMD5Hash(t *testing.T) {
hash1 := GetMD5Hash("This is a test")
hash2 := GetMD5Hash("This is a test")
if hash1 != hash2 {
t.Errorf("hashs and hash2 should be the same, but were not")
}
hash1 = GetMD5Hash("This is a test")
hash2 = GetMD5Hash("This is a tfst")
if hash1 == hash2 {
t.Errorf("hashs and hash2 are the same, but should not be")
}
}
func TestSortedKeys(t *testing.T) {
attributes := map[string]models.MessageAttribute{
"b": {},
"a": {},
}
keys := sortedKeys(attributes)
assert.Equal(t, "a", keys[0])
assert.Equal(t, "b", keys[1])
}
-9
View File
@@ -1,9 +0,0 @@
# Chart.yaml — Helm chart для shared-sqs (multi-tenant SQS-compatible message queue)
# Created: 2026-04-11
# Updated: 2026-04-12 09:57 MSK — appVersion v0.1.22
apiVersion: v2
name: shared-sqs
description: Multi-tenant AWS SQS-compatible message queue service
type: application
version: 0.1.0
appVersion: "0.1.22"
@@ -1,26 +0,0 @@
{{/*
_helpers.tpl общие хелперы для shared-sqs Helm chart
Created: 2026-04-11
*/}}
{{/* Полное имя релиза */}}
{{- define "shared-sqs.fullname" -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/* Общие labels */}}
{{- define "shared-sqs.labels" -}}
app: shared-sqs
app.kubernetes.io/name: shared-sqs
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end }}
{{/* Selector labels */}}
{{- define "shared-sqs.selectorLabels" -}}
app: shared-sqs
app.kubernetes.io/name: shared-sqs
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
@@ -1,85 +0,0 @@
# deployment.yaml — Deployment shared-sqs с RollingUpdate
# Created: 2026-04-11
apiVersion: apps/v1
kind: Deployment
metadata:
name: shared-sqs
namespace: {{ .Values.namespace }}
labels:
{{- include "shared-sqs.labels" . | nindent 4 }}
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
{{- include "shared-sqs.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "shared-sqs.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: shared-sqs
image: "naeel/shared-sqs:{{ .Values.image.tag }}"
imagePullPolicy: IfNotPresent
ports:
- containerPort: 4100
name: http
env:
- name: SHARED_SQS_ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: shared-sqs-admin
key: token
- name: SHARED_SQS_SEED_DEMO
value: {{ .Values.seedDemo | quote }}
- name: REDIS_ADDR
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: addr
- name: REDIS_USER
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: user
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: password
{{- if .Values.billing.enabled }}
- name: BILLING_PG_HOST
value: {{ .Values.billing.postgres.host | quote }}
- name: BILLING_PG_PORT
value: {{ .Values.billing.postgres.port | quote }}
- name: BILLING_PG_DATABASE
value: {{ .Values.billing.postgres.database | quote }}
- name: BILLING_PG_USER
value: {{ .Values.billing.postgres.user | quote }}
- name: BILLING_PG_SSLMODE
value: {{ .Values.billing.postgres.sslmode | quote }}
- name: BILLING_PG_PASSWORD
valueFrom:
secretKeyRef:
name: shared-sqs-billing-pg
key: password
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 10 }}
livenessProbe:
httpGet:
path: /health
port: 4100
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 4100
initialDelaySeconds: 3
periodSeconds: 5
@@ -1,34 +0,0 @@
# ingress.yaml — Ingress для shared-sqs (условный, включается через values)
# Created: 2026-04-11
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shared-sqs-ingress
namespace: {{ .Values.namespace }}
labels:
{{- include "shared-sqs.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shared-sqs
port:
number: 4100
{{- if .Values.ingress.tls.enabled }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
{{- end }}
@@ -1,7 +0,0 @@
{{/*
namespace.yaml — не создаём namespace из чарта,
т.к. он может уже существовать (managed платформой или вручную).
Создавать namespace перед helm install:
kubectl create namespace {{ .Values.namespace }}
Created: 2026-04-11
*/}}
@@ -1,12 +0,0 @@
# secret-admin.yaml — Secret с admin токеном
# Created: 2026-04-11
apiVersion: v1
kind: Secret
metadata:
name: shared-sqs-admin
namespace: {{ .Values.namespace }}
labels:
{{- include "shared-sqs.labels" . | nindent 4 }}
type: Opaque
stringData:
token: {{ .Values.admin.token | quote }}
@@ -1,15 +0,0 @@
# secret-billing.yaml — Secret с PostgreSQL credentials для billing
# Создаётся только если billing.enabled=true и existingSecret не указан
# Created: 2026-04-12
{{- if and .Values.billing.enabled (not .Values.billing.postgres.existingSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: shared-sqs-billing-pg
namespace: {{ .Values.namespace }}
labels:
{{- include "shared-sqs.labels" . | nindent 4 }}
type: Opaque
stringData:
password: {{ required "billing.postgres.password is required when billing is enabled" .Values.billing.postgres.password | quote }}
{{- end }}
@@ -1,14 +0,0 @@
# secret-redis.yaml — Secret с Redis credentials (addr + user + password)
# Created: 2026-04-11
apiVersion: v1
kind: Secret
metadata:
name: shared-sqs-redis
namespace: {{ .Values.namespace }}
labels:
{{- include "shared-sqs.labels" . | nindent 4 }}
type: Opaque
stringData:
addr: {{ .Values.redis.addr | quote }}
user: {{ .Values.redis.user | quote }}
password: {{ .Values.redis.password | quote }}
@@ -1,18 +0,0 @@
# service.yaml — Service для shared-sqs
# Created: 2026-04-11
apiVersion: v1
kind: Service
metadata:
name: shared-sqs
namespace: {{ .Values.namespace }}
labels:
{{- include "shared-sqs.labels" . | nindent 4 }}
spec:
type: ClusterIP
selector:
{{- include "shared-sqs.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: 4100
targetPort: 4100
protocol: TCP
-84
View File
@@ -1,84 +0,0 @@
# values.yaml — параметры деплоя shared-sqs
# Updated: 2026-04-13
#
# ЧТО ЭТО:
# shared-sqs — AWS SQS-совместимый сервис очередей сообщений для Kubernetes.
# Работает как замена Amazon SQS: клиенты используют стандартный AWS SDK/CLI,
# просто указывая свой endpoint вместо amazonaws.com.
#
# ЧТО НУЖНО ЗАРАНЕЕ:
# 1. Redis — для хранения очередей и сообщений (любой Redis ≥ 6)
# 2. PostgreSQL — для биллинга (можно отключить: billing.enabled: false)
# 3. Ingress NGINX — установлен в кластере (принимает внешние запросы)
# 4. cert-manager — для автовыпуска TLS-сертификата (letsencrypt)
# 5. DNS-запись — ваш домен должен указывать на IP ingress-контроллера
#
# КАК ДЕПЛОИТЬ:
# helm install shared-sqs ./deployments/helm/shared-sqs -n shared-sqs --create-namespace
#
# КАК ОБНОВИТЬ:
# helm upgrade shared-sqs ./deployments/helm/shared-sqs -n shared-sqs
# Namespace в Kubernetes куда будет задеплоен сервис
namespace: shared-sqs
# Версия образа сервиса
image:
tag: "v0.1.24"
# Redis — хранит все очереди и сообщения.
# addr: адрес в формате host:port (внутренний K8s DNS или внешний IP)
# user: Redis-пользователь (по умолчанию "default" — стандартный встроенный пользователь Redis)
redis:
addr: "rfrm-redisk8s.54467f74-67f5-4ca7-9b4a-bdd50e8c23d6.svc:6379"
user: "default"
password: "quiY2oovugaiChiejah9"
# Токен для административного API (/admin/*).
# Используется для создания тенантов и управления ими.
# Придумайте любую строку, например: openssl rand -hex 20
admin:
token: "sqs-admin-7a7d8bd0c060a75c198d48680f34077a"
# Создать demo-тенанта при первом старте (удобно для проверки работы).
# В продакшне можно поставить false.
seedDemo: true
# Лимиты ресурсов для пода. Для начала этих значений достаточно.
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"
# Ingress — открывает сервис наружу по домену.
# host: ваш домен, должен указывать на IP ingress-контроллера кластера.
# После деплоя cert-manager автоматически выпустит TLS-сертификат.
ingress:
enabled: true
className: nginx
host: "qu.kube5s.ru"
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
nginx.ingress.kubernetes.io/proxy-send-timeout: "30"
tls:
enabled: true
secretName: shared-sqs-tls
# Биллинг — запись статистики по всем SQS-операциям в PostgreSQL.
# Если PostgreSQL нет — поставьте enabled: false, остальное игнорируется.
billing:
enabled: true
postgres:
host: "postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local"
port: "5432"
# База данных должна существовать заранее (CREATE DATABASE sqsdb)
database: "sqsdb"
user: "super"
password: "BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
# disable — если PostgreSQL без SSL (например локальный)
sslmode: "require"
-77
View File
@@ -1,77 +0,0 @@
# deployments/k8s/deployment.yaml
# Deployment shared-sqs — strategy RollingUpdate (теперь возможен т.к. Redis хранит состояние)
# Updated: 2026-04-12 09:57 MSK — image v0.1.22 для demo UI showcase mode
apiVersion: apps/v1
kind: Deployment
metadata:
name: shared-sqs
namespace: shared-sqs
labels:
app: shared-sqs
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: shared-sqs
template:
metadata:
labels:
app: shared-sqs
spec:
containers:
- name: shared-sqs
image: naeel/shared-sqs:v0.1.22
ports:
- containerPort: 4100
name: http
env:
- name: SHARED_SQS_ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: shared-sqs-admin
key: token
- name: SHARED_SQS_SEED_DEMO
value: "true"
- name: REDIS_ADDR
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: addr
- name: REDIS_USER
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: user
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: password
- name: NUBES_ENDPOINT
value: "https://deck-api-test.ngcloud.ru/api/v1"
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 4100
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 4100
initialDelaySeconds: 3
periodSeconds: 5
imagePullSecrets:
- name: sless-registry-auth
-38
View File
@@ -1,38 +0,0 @@
# deployments/k8s/ingress.yaml
# Ingress для shared-sqs на домене qu.kube5s.ru
# Created: 2026-04-09, Updated: 2026-04-12
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shared-sqs-ingress
namespace: shared-sqs
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
nginx.ingress.kubernetes.io/proxy-send-timeout: "30"
# Fix: 64KB+ payload (boto3/urllib3 2.0 шлёт headers и body отдельными TCP write,
# botocore убирает TCP_NODELAY → последние ~16KB body не доходят за client_body_timeout).
# Решение: proxy-request-buffering=off стримит body напрямую в backend без ожидания полного тела.
nginx.ingress.kubernetes.io/client-body-buffer-size: "512k"
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
# server-snippet удалён 2026-04-12: nginx ingress controller v1.x+ блокирует
# эту аннотацию как "risky" (ServerSnippet contains risky annotation).
# client_body_timeout задаётся через ConfigMap ingress controller.
spec:
ingressClassName: nginx
rules:
- host: qu.kube5s.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shared-sqs
port:
number: 4100
tls:
- hosts:
- qu.kube5s.ru
secretName: shared-sqs-tls
-9
View File
@@ -1,9 +0,0 @@
# deployments/k8s/namespace.yaml
# Namespace для shared-sqs сервиса
# Created: 2026-04-09
apiVersion: v1
kind: Namespace
metadata:
name: shared-sqs
labels:
app: shared-sqs
-80
View File
@@ -1,80 +0,0 @@
# 2026-04-10 — Redis для shared-sqs в кластере naeel-test-3
# Single-node Redis с PVC для persistence состояния shared-sqs между рестартами.
# Namespace: shared-sqs. Пароль совпадает с managed Redis из deck.ngcloud.ru.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: shared-sqs-redis-pvc
namespace: shared-sqs
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: shared-sqs-redis
namespace: shared-sqs
labels:
app: shared-sqs-redis
spec:
replicas: 1
selector:
matchLabels:
app: shared-sqs-redis
strategy:
type: Recreate
template:
metadata:
labels:
app: shared-sqs-redis
spec:
containers:
- name: redis
image: redis:7.2-alpine
command:
- redis-server
- --requirepass
- $(REDIS_PASSWORD)
- --appendonly
- "yes"
- --save
- "60 1"
ports:
- containerPort: 6379
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: shared-sqs-redis
key: password
volumeMounts:
- name: redis-data
mountPath: /data
resources:
requests:
memory: 128Mi
cpu: 50m
limits:
memory: 256Mi
cpu: 200m
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: shared-sqs-redis-pvc
---
apiVersion: v1
kind: Service
metadata:
name: shared-sqs-redis
namespace: shared-sqs
spec:
selector:
app: shared-sqs-redis
ports:
- port: 6379
targetPort: 6379
-26
View File
@@ -1,26 +0,0 @@
# deployments/k8s/secret.yaml
# Секреты для shared-sqs: admin token + Redis credentials
# Created: 2026-04-09
# Updated: 2026-04-10 — добавлен shared-sqs-redis secret
# ВНИМАНИЕ: заполнить реальными значениями перед деплоем
# kubectl create secret generic shared-sqs-admin --from-literal=token=YOUR_TOKEN -n shared-sqs
# kubectl create secret generic shared-sqs-redis --from-literal=addr=HOST:6379 --from-literal=user=default --from-literal=password=PASS -n shared-sqs
apiVersion: v1
kind: Secret
metadata:
name: shared-sqs-admin
namespace: shared-sqs
type: Opaque
stringData:
token: "REPLACE_WITH_REAL_TOKEN"
---
apiVersion: v1
kind: Secret
metadata:
name: shared-sqs-redis
namespace: shared-sqs
type: Opaque
stringData:
addr: "REPLACE_WITH_REDIS_ADDR"
user: "default"
password: "REPLACE_WITH_REDIS_PASSWORD"
-19
View File
@@ -1,19 +0,0 @@
# deployments/k8s/service.yaml
# Service для shared-sqs
# Created: 2026-04-09
apiVersion: v1
kind: Service
metadata:
name: shared-sqs
namespace: shared-sqs
labels:
app: shared-sqs
spec:
selector:
app: shared-sqs
ports:
- name: http
port: 4100
targetPort: 4100
protocol: TCP
type: ClusterIP
-20
View File
@@ -1,20 +0,0 @@
# vmservicescrape.yaml — VMServiceScrape для Victoria Metrics
# Говорит VMAgent скрейпить /metrics с shared-sqs Service.
# Created: 2026-04-12
apiVersion: operator.victoriametrics.com/v1beta1
kind: VMServiceScrape
metadata:
name: shared-sqs-metrics
namespace: shared-sqs
labels:
app: shared-sqs
spec:
# Какой Service скрейпить — по label selector
selector:
matchLabels:
app: shared-sqs
# Endpoint: порт http, путь /metrics
endpoints:
- port: http
path: /metrics
interval: "30s"
@@ -1,3 +1,8 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
Исторические сравнительные тесты vs Yandex MQ (апрель 2026).
Перенесён из корня репозитория в doc/legacy 2026-08-13.
Замеры устарели, только для справки. -->
# Сравнительный анализ shared-sqs vs Yandex MQ
**Дата:** 2026-04-12 06:05 UTC
@@ -1,3 +1,8 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
Старая памятка для агента/нового чата (апрель 2026).
Перенесена из корня репозитория в doc/legacy 2026-08-13.
Описывает устаревший k8s-деплой и порядок работы. -->
# ONBOARDING.md — Памятка для нового чата / агента
# Дата: 2026-04-10
# Автор: GitHub Copilot (Claude Opus 4.6)
+6 -1
View File
@@ -1,3 +1,8 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
Исторический план реализации shared-sqs (апрель 2026).
Перенесён из корня репозитория в doc/legacy 2026-08-13.
Содержит устаревшие схемы и ссылки (pearlharbor, sqs-operator). -->
# shared-sqs — План реализации
**Дата:** 2026-04-09
@@ -845,7 +850,7 @@ shared-sqs/
- ❌ UI — нет UI, только API
- ❌ Scale-to-zero — один pod всегда работает
ENDOFPLAN cat > ~/terra/sless/shared-sqs/PLAN.md << 'ENDOFPLAN'
# shared-sqs — План реализации
# shared-sqs — План реализации (heredoc-дубль)
**Дата:** 2026-04-09
**Исполнитель:** Claude Sonnet (или другой агент)
@@ -1,3 +1,5 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ. Исторический бенчмарк (апрель 2026). Помечено 2026-08-13. -->
# Сравнительный benchmark shared-sqs vs Yandex MQ
**Дата:** 2026-04-12 05:45 UTC
@@ -1,3 +1,5 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ. Внешний справочник Yandex MQ (для сравнения). Помечено 2026-08-13. -->
# Yandex Message Queue API Reference
**Дата документации:** 11 апреля 2026
@@ -1,3 +1,6 @@
<!-- ⚠️ ЛЕГАСИ — история версий разработки (апрель 2026).
ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ. Только для истории. Помечено 2026-08-13. -->
# SQS-service Progress
## Версия v0.1.x
@@ -0,0 +1,137 @@
# Стратегия доказательства сетевого бага Nubes — v2
> ⚠️ **ПОМЕТКА: анализ от DeepSeek V4 Pro (основная модель) в НОВОМ чате — НЕ Соннет.**
> Пользователь задал тот же вопрос в новом чате DeepSeek V4 Pro.
> Сохранено 2026-08-15. Сравнение с планом DeepSeek V4 Flash — в HISTORY
> и в конце этого файла.
## Стратегия доказательства
Доказывается изоляцией слоёв. Каждый тест исключает один слой пути:
```
клиент (локальная машина) → интернет → шлюз платформы (VIP 185.247.187.151)
→ kube-vip → NodePort 32391 → kube-proxy → nginx-ingress → Cilium → под shared-sqs
```
«Виновата платформа» доказано, если ВСЕ три условия:
1. Таймауты воспроизводятся на пути БЕЗ нашего кода (сырой TCP, эхо) с тем же паттерном.
2. tcpdump фиксирует потерю сегмента клиент↔кластер при том, что под/nginx
ответил за миллисекунды (по логам).
3. Потери коррелируют с MTU-порогом (~1448) или платформенным таймером
(conntrack/RST), а не с логикой приложения.
## 1. Эхо или сырые TCP
Делать ОБА, в порядке: сырые TCP, потом эхо.
**Сырые TCP на 185.247.187.151:32391** — бесплатный первый тест. Покрывает
путь до nginx-пода без HTTP и без нашего кода. Отправлять сегменты 512, 1380,
1448, 1488+ байт. Критерий доставки — TCP ACK, не HTTP-ответ (nginx на мусор
ответит 400/RST — нормально).
**Эхо-сервис** — второй шаг, ~20 строк Go, Deployment + Service с отдельным
NodePort (без ingress). Параметризуемый размер тела ответа (`?bytes=512|1400`) —
большие POST тестируют прямой путь, большие ОТВЕТЫ — обратный. Гонять тем же
клиентом. Таймауты воспроизвелись → nginx-ingress и наше приложение исключены.
## 2. Как поймать таймаут
tcpdump на клиенте: `tcpdump -i any -s 0 -w /tmp/cap.pcap host 185.247.187.151 and tcp port 32391`
| Симптом | Что смотреть |
|---|---|
| 51-с зависание большого POST | ACK на сегмент с полным MSS? ретрасмиты и интервалы? чем сессия завершилась (RST/FIN, кто первый) |
| 30-с ReadTimeout на 512 байт | запрос отправлен полностью? ACK получен? ответ не пришёл? RST после idle (conntrack)? keep-alive пул? |
| MTU-гипотеза | ICMP type 3 code 4 (Fragmentation Needed)? Не приходят → PMTUD сломан → механизм зависаний |
Замечание: 51 с не похоже на TCP-backoff Linux (tcp_retries2=15 → сотни секунд).
Скорее прикладной таймер (~50–60 с клиента/nginx/шлюза). Точный интервал в pcap
покажет, чей таймер.
Серверный захват — только через kubectl: `kubectl debug -it <pod> --image=<tcpdump-образ>`
(нужны NET_RAW/NET_ADMIN — на managed может быть запрещено). Если запрещено —
роль серверного захвата выполняют nginx access log и эхо-А/Б.
Ключевой приём: двусторонний захват, корреляция по TCP seq/ack (не по времени):
- запрос ушёл с клиента, не появился у пода → потеря на прямом пути;
- ответ ушёл из пода, клиент не получил → потеря на обратном;
- в обоих случаях приложение исключено.
## 3. kube-vip / NodePort / nginx через kubectl
nginx access log + `$request_time $upstream_response_time $status $bytes_sent`:
| В логе | Вывод |
|---|---|
| 499 + большой request_time | клиент ушёл до ответа |
| 200 + малый upstream, клиент без ответа | потеря между nginx и клиентом |
| записи нет | потеря до nginx (kube-vip/NodePort/шлюз) |
kube-vip: `kubectl get pods -A | grep -i vip`, логи (ARP/BGP-анонсы, флап VIP),
сопоставить ноду-держателя VIP с реально принимающей нодой.
kube-proxy/NodePort: `kubectl get ds -n kube-system kube-proxy`, логи;
`externalTrafficPolicy: Cluster` → лишний хоп + SNAT; iptables/conntrack ноды
не видны (нет доступа к нодам) — debug-под с hostNetwork+privileged+nodeSelector
или тикет.
Cilium: `kubectl exec -n kube-system <cilium-pod> -- cilium monitor --type drop`
(в cilium-под, НЕ в operator). Дроп с причиной — прямое доказательство.
Ограничение: внешний шлюз (VIP, MSS=1448) из kubectl не виден. Если кластерные
точки чистые, а снаружи потери есть — виноват шлюз/underlay, оформляется тикет.
## 4. Пошаговый план: дешевле → дороже
1. Инвентаризация логов: nginx access/error за тестовый период (499, request_time
30+/51), логи приложения (фактическое время обработки). Параллельно открыть
тикет Nubes.
2. PMTU-зонд с клиента: `ping -M do -s 1372/1422/1460/1472` до VIP (пороги MTU
1400/1450/1488/1500); при запрете ICMP — `mtr --tcp -P 443`, tcptraceroute.
MSS=1448 → сегменты 1488 при underlay 1450 — шлюз анонсирует неверный MSS.
3. Второй клиент из другого сегмента (VPS/другой ISP): тот же скрипт.
Воспроизвелось → исключены ISP и локальная сеть пользователя.
4. Сырые TCP на 32391 с обеих точек: потери на больших сегментах без HTTP →
исключены HTTP и приложение.
5. kubectl port-forward с ВМ на shared-sqs (localhost:4100): чистый тест →
приложение и под исправны (в обход шлюза через API-туннель).
6. tcpdump на клиенте + сверка с nginx access log: классификация таймаутов.
7. Эхо-сервис NodePort без ingress: А/Б 512/1400 тем же клиентом. Остались
таймауты → nginx и приложение исключены; исчезли → завести эхо через ingress
и повторить (изоляция nginx).
8. Захват в кластере: debug-контейнеры tcpdump, cilium monitor drop, логи
kube-vip/kube-proxy; двусторонняя корреляция по seq.
9. Матрица размеров (опционально): 100–1500 байт на эхо и на 32391. Резкий
порог 1380–1448 → MTU; плавный рост → шлюз/перегрузка.
## Итог: что считается доказательством
| Наблюдение | Вывод |
|---|---|
| Таймауты на эхо-NodePort (без ingress) с тем же паттерном | исключены nginx и наш код — виноват kube-vip/NodePort/шлюз/underlay |
| nginx: 200 + upstream <100 мс, клиент без ответа | потеря между nginx и клиентом |
| нет записи в access log | потеря до nginx |
| pcap: сегмент не дошёл до пода / ответ не дошёл до клиента | потеря в сетевом пути |
| ping -M do: резкий порог размера | MTU-дефект пути |
| cilium monitor показывает drop | внутрикластерная потеря |
| port-forward чистый, внешний IP таймаутит | виноват шлюз/NodePort/kube-vip |
Итоговая цепочка для тикета: pcap с клиента + access log nginx (200/upstream
малый) + воспроизведение на эхо без нашего кода + PMTU-порог ≈1448.
---
## Сравнение с планом DeepSeek V4 Flash (основной агент)
Flash: силён декомпозицией по nginx-логам, но с ошибкой (cilium monitor через
cilium-operator), переоценён echo, нет PMTU-зонда, нет порога ping -M do, нет
port-forward, нет второго ISP.
Pro (этот план): сильнее методологически — сырые TCP с критерием ACK,
PMTU-зонд ping -M do, второй клиент, port-forward, таблица «что считается
доказательством». Грубых ошибок нет.
Синтез (рекомендация): дешёвые тесты обоих планов + финал echo.
Порядок: nginx-логи → PMTU-зонд → сырые TCP → port-forward → tcpdump клиента →
второй ISP (если есть) → cilium monitor → echo → тикет.
@@ -0,0 +1,150 @@
# Plan: Доказательство сетевого бага Nubes — v3 (СОННЕТ)
> ✅ **ПОМЕТКА: это план от Соннета** (пользователь передал ответ Соннета).
> Сохранено 2026-08-15. Сравнение с v1 (DeepSeek Flash) и v2 (DeepSeek Pro) —
> в конце файла и в HISTORY.
## TL;DR
Два РАЗНЫХ явления с разными причинами — нельзя объяснять одной гипотезой и
нельзя ловить одним методом. Цепочка: nginx-логи → port-forward (бесплатное
доказательство невиновности приложения) → PMTUD-зонд → tcpdump → cilium
monitor → echo-сервис (только если предыдущих данных не хватает).
## Разделение двух явлений (критически важно)
**Явление A — большие POST (~51с).** Причина: MSS=1448, underlay MTU=1450,
сегменты 1488 → дроп. PMTUD сломан (ICMP Type 3 Code 4 заблокирован шлюзом) →
TCP ретрасмитит по экспоненте: 1с,2с,4с,8с,16с ≈ 31с, ещё попытка → ~51с.
Задача: подтвердить ping-зондом и зафиксировать в тикете.
**Явление B — малые POST 512 байт (~1/37с).** MTU-моделью НЕ объясняется.
Ключевая улика: РЕГУЛЯРНОСТЬ 1/37с — это таймер, не случайные потери.
Кандидаты: сессионный таймер gateway на VIP 185.247.187.151 (вероятнее всего),
kube-vip ARP keepalive цикл (флап при смене лидера), conntrack GC с агрессивным
idle-таймером.
## 1. Echo vs сырые TCP
Сырые TCP на 32391 — недостаточны (проверяют только фазу установления; проблемы
в фазе ДАННЫХ; nginx ответит RST/400 на мусор — неинформативно).
Правильный порядок:
1. kubectl port-forward (бесплатно, 5 минут) — чище и быстрее echo.
2. Echo NodePort без ingress — если port-forward и логи не дали ответа.
Echo: принимает POST, возвращает тело; `?bytes=N` для размера ответа
(обратный путь отдельно от прямого). Деплой БЕЗ ingress, прямой NodePort.
## 2. Как поймать таймаут: tcpdump
Клиент (запустить ДО теста, параллельно):
`tcpdump -i <iface> -s 0 -w /tmp/sqs.pcap 'host 185.247.187.151 and port 32391'`
Явление A (51с, большой POST): ICMP Type 3 Code 4 от 185.x → PMTUD работает;
нет ICMP, ретрасмиты 1/2/4/8/16с → PMTUD сломан (основная гипотеза); кто шлёт
RST в конце и через сколько — чей таймаут (клиент vs nginx/шлюз).
Явление B (30с, 512 байт): ACK на запрос получен → запрос дошёл, ответ потерян
на обратном пути; ACK нет → потеря до nginx (kube-vip/NodePort/gateway);
точный интервал между таймаутами (~37с ± 1с → таймер подтверждён); RST от
сервера — чей (nginx idle keepalive или gateway).
Ключевой приём: корреляция по TCP seq/ack, не по времени.
## 3. kube-vip / NodePort / nginx — только kubectl
nginx access log: `kubectl logs -n ingress <nginx-pod> --since=35m | grep -E " (499|5[0-9][0-9]) "`.
Нужны поля `$request_time $upstream_response_time`. Если нет — проверить
ConfigMap nginx.
| Запись в nginx log | Вывод |
|---|---|
| 499 + большой request_time | клиент ушёл до ответа (наш ReadTimeout) |
| 200 + upstream <100мс, клиент получил таймаут | ответ потерян между nginx и клиентом |
| записи нет | потеря ДО nginx: kube-vip/NodePort/gateway |
kube-vip: `kubectl get pods -A | grep -i vip`, логи (leader/ARP/flap/error).
ARP-флап во время теста → кратковременная потеря VIP → дропы.
Cilium (на ПОДЕ, не на operator): `kubectl get pods -n kube-system -l k8s-app=cilium -o wide`;
`kubectl exec -n kube-system <cilium-pod> -- cilium monitor --type drop`
параллельно с тестом.
NodePort/SNAT: `externalTrafficPolicy` — Cluster → любая нода + SNAT (лишний хоп,
conntrack); Local → только нода-держатель VIP (миграция VIP → brief outage).
port-forward: `kubectl port-forward -n <ns> svc/<sqs-svc> 14100:4100` — тот же
скрипт на localhost:14100. 0 таймаутов → приложение невиновно.
## 4. Пошаговый план: дешевле → дороже
**Фаза 0 — бесплатно, уже есть данные:**
1. nginx access log за период прошлого теста (499, request_time/upstream_response_time).
2. kube-vip логи: флапы в тот же промежуток.
3. Тикет Nubes: два явления отдельно — (A) MTU/PMTUD порог 1448, (B) таймер ~37с.
**Фаза 1 — клиентские тесты, 30 минут:**
4. PMTUD-зонд: `ping -M do -s N 185.247.187.151` для N=1300,1400,1420,1422,
1448,1450,1460,1472 — точный порог. Нет ответа ≥1448 → ICMP заблокирован →
тикет с числами.
5. tcpdump + тот же 30-мин тест. Wireshark: `tcp.analysis.retransmission || icmp`.
Точный интервал между таймаутами.
**Фаза 2 — kubectl, без деплоя:**
6. port-forward тест ПЕРВЫМ: 15-мин прогон через туннель. 0 таймаутов →
приложение доказанно невиновно.
7. cilium monitor параллельно со свежим тестом против внешнего IP.
8. Если нет upstream_response_time в формате — обновить ConfigMap, повторить.
**Фаза 3 — echo (только если 0–2 не дали ответа):**
9. Minimal Go echo (NodePort, без ingress), тот же тест. Воспроизвелось →
nginx и приложение исключены → kube-vip/NodePort/gateway. Нет → echo через
ingress → изоляция nginx.
10. Матрица размеров 512/1000/1400/1448/1460/1500 × 10 мин, порог бинарным поиском.
**Фаза 4 — финальная корреляция для тикета:**
11. Одновременно tcpdump + nginx access log + cilium monitor. Корреляция по
seq: «запрос ушёл, ACK получен, ответа нет» = потеря между nginx и клиентом.
Артефакты: pcap + логи + ping-зонд → тикет.
**Решения о приоритетах:**
- port-forward — самый дешёвый и чистый способ изолировать приложение. ДО echo.
- Сырые TCP на 32391 — пропустить.
- Echo — только если port-forward + nginx-логи не дали ответа.
- Явления A и B документировать в тикете РАЗДЕЛЬНО.
---
## Поправки основного агента (GitHub Copilot)
1. **ICMP может быть закрыт на шлюзе** — тогда ping-зонд не даст порога.
Нужен fallback: mtr --tcp / tcptraceroute или TCP-сегменты переменного
размера (то, что Соннет отбросил как «сырые TCP» — но для определения
ПОРОГА размера они валидны).
2. **Второй клиент с другого ISP** (был у Pro) — отсутствует. Дешёво исключает
локальную сеть пользователя. Добавить в фазу 1.
3. **Параллельный контрольный канал YMQ** — стоит сохранить во всех тестах:
отделяет «плохой интернет вообще» от «плох путь до Nubes».
4. **port-forward идёт через API-сервер** — не тот сетевой путь; чистый
результат исключает приложение, но НЕ доказывает шлюз. Формулировка Соннета
«доказательство невиновности приложения» — корректна, использовать её.
5. **Точный интервал таймаутов** надо измерять новым тестом с timestamp на
каждый таймаут (в прошлом тесте тайминги не логировались поштучно).
6. Смена nginx log format (ConfigMap) на managed может требовать релода/помощи
платформы — держать как запасной шаг.
## Сравнение трёх планов
| | Flash (v1) | Pro (v2) | Соннет (v3) |
|---|---|---|---|
| Разделение явлений A/B | нет | частично | **явно, с причиной B=таймер** |
| Ошибки команд | cilium через operator | нет | нет |
| PMTU-зонд | нет | есть | есть, точный список размеров |
| port-forward | нет | есть | **первым kubectl-тестом** |
| Сырые TCP | недооценены | да | отброшены (спорно для порога) |
| Второй ISP | нет | есть | нет |
| Критерии доказательства | слабые | таблица | таблицы + фазы |
| Объяснение 51с | нет | «прикладной таймер» | **экспонента RTO 1-2-4-8-16** |
Вердикт: план Соннета — лучший из трёх. Принять за основу с поправками 1–6.
@@ -0,0 +1,132 @@
# План: доказать, что таймауты — в пути/шлюзе Nubes — v4 (ОПУС 4.8)
> ✅ **ПОМЕТКА: план от Claude Opus 4.8** (пользователь передал ответ Опуса,
> который изучил репозиторий и три предыдущих плана).
> Сохранено 2026-08-15. Ниже — план Опуса + ответы основного агента на его
> уточняющие вопросы.
## TL;DR
Доказательство — изоляцией слоёв. «Виновата платформа» = таймауты
воспроизводятся БЕЗ нашего кода (port-forward чист, echo/сырой TCP таймаутит)
+ tcpdump с клиента ловит потерю сегмента, когда nginx/под ответили за
миллисекунды.
**Два РАЗНЫХ явления — раздельно:**
- **A. Большие POST ~51с** — локализовано: MSS=1448 → пакет 1488 > underlay
1450 → дроп при DF, PMTUD мёртв. Осталось зафиксировать порог ping-зондом.
- **B. Малые тела 512б, ~1 таймаут/37с** — MTU не объясняет. Регулярность =
таймер (шлюз VIP / ARP-флап kube-vip / conntrack GC). Главная нераскрытая часть.
## Ответы Опуса на вопросы
1. **Echo vs сырой TCP.** Сырой TCP недостаточен (только SYN/ACK; потери в
фазе данных). Самое дешёвое доказательство невиновности приложения —
`kubectl port-forward` мимо шлюза, а не echo. Echo — позже, только если
port-forward+логи не закрыли вопрос (он внутри кластера, а подозрение на шлюзе).
2. **Поймать таймаут.** tcpdump на клиенте, корреляция по TCP seq/ack: ретрансмиты
SYN → потеря на установлении; тишина после ACK → потеря после nginx; ICMP
frag-needed → PMTUD; кто и через сколько шлёт RST → чей таймер; интервал
между таймаутами → подтвердить ~37с.
3. **Через kubectl.** nginx access-log request_time/upstream_response_time:
499+большой request_time = клиент ушёл; 200+малый upstream без ответа клиенту
= потеря nginx↔клиент; записи нет = потеря до nginx. cilium monitor --type
drop — на cilium-ПОДЕ нужной ноды (не operator). externalTrafficPolicy
Cluster/Local.
## Шаги (дешевле → дороже)
- **Фаза 0 (бесплатно):** nginx access-log за прошлый тест → логи kube-vip
(флапы) → тикет Nubes (A и B раздельно).
- **Фаза 1 (клиент, ~30 мин):** PMTUD-зонд ping -M do (fallback mtr --tcp) →
tcpdump + повтор теста через long_compare_local.py с логом timestamp каждого
таймаута → контрольный канал YMQ во всех прогонах.
- **Фаза 2 (kubectl, без деплоя):** port-forward ПЕРВЫМ (невиновность приложения)
→ cilium monitor drop параллельно → проверка externalTrafficPolicy/nodePort.
- **Фаза 3 (echo, если 0–2 не хватило):** Go echo без ingress на NodePort →
таймаутит → nginx и код исключены → потом echo через ingress (изоляция nginx).
- **Фаза 4 (тикет):** одновременный tcpdump + nginx-log + cilium monitor,
корреляция по seq → pcap+логи+ping в тикет.
**Что считается доказательством:** echo-NodePort таймаутит тем же паттерном →
nginx+код исключены; nginx 200/upstream<100мс без ответа клиенту → потеря
nginx↔клиент; pcap показывает недоставленный сегмент; ping-порог → MTU;
cilium drop → внутри кластера; port-forward чист + внешний IP таймаутит →
приложение невиновно; стабильный интервал ~37с → таймер шлюза.
---
## Уточнения Опуса и ответы основного агента
1. **Второй клиент с другого ISP?** НЕТ. В распоряжении только ВМ
(5.172.178.213), но она рядом с платформой — с неё таймаутов нет, как
«второй клиент из другого сегмента» не подходит. Частичная замена уже есть:
контрольный канал YMQ с той же локали — 1 ошибка на 959 при 49 таймаутах на
Nubes (виноват локальный интернет → YMQ страдал бы так же). Полагаемся на
YMQ-канал; второй ISP — если появится доступ к VPS.
2. **Debug-под с NET_RAW?** Не проверяли; платформа managed, ноды недоступны,
привилегии урезаны — с высокой вероятностью запрещено. Под — alpine без
NET_RAW, серверный tcpdump не получится. Ограничиваемся nginx access-log +
cilium monitor + echo А/Б. Возможность kubectl debug проверяется одной
попыткой.
3. **Echo сразу?** НЕТ — только если фазы 0–2 не дадут однозначного ответа.
Доп. риск: новый NodePort для echo на managed может быть недоступен.
## Поправка основного агента
port-forward идёт через API-сервер (не тот сетевой путь) — он доказывает
только невиновность приложения, а не вину шлюза. Использовать формулировку
Опуса из таблицы доказательств (она корректна).
---
## ФИНАЛ (обновление Опуса с зафиксированными решениями, 2026-08-15)
**TL;DR.** Изоляция слоёв. «Виновата платформа» = два несмешиваемых вывода:
(1) приложение невиновно — port-forward через API-сервер чист (иной путь,
шлюз этим НЕ обвиняется); (2) виноват путь/шлюз — echo/сырой TCP таймаутит тем
же паттерном И tcpdump с клиента ловит потерю сегмента, когда nginx/под
ответили за миллисекунды.
**Зафиксированные решения:**
1. Второго ISP нет; ВМ рядом с Nubes → не «другой сегмент». Опора — контрольный
канал YMQ (49 таймаутов Nubes vs 1/959 YMQ уже исключают локальный интернет).
2. Серверный tcpdump практически недоступен (managed, exec только в alpine-под
без NET_RAW). kubectl debug с NET_RAW — одна проверочная попытка. Замена:
nginx access-log + cilium monitor + echo А/Б.
3. Echo — не сразу, только если фазы 0–2 неоднозначны; доп. риск — новый
NodePort для echo может быть закрыт на managed.
4. port-forward доказывает ТОЛЬКО невиновность приложения, НЕ вину шлюза.
**Шаги (дешевле → дороже):**
- Фаза 0 (бесплатно): nginx access-log за прошлый тест → логи kube-vip →
тикет Nubes (A и B раздельно).
- Фаза 1 (клиент, ~30 мин): PMTUD-зонд ping -M do (fallback mtr --tcp) →
tcpdump + long_compare_local.py с timestamp каждого таймаута → YMQ-канал
во всех прогонах.
- Фаза 2 (kubectl): port-forward первым → cilium monitor drop параллельно →
externalTrafficPolicy/nodePort. Одна попытка kubectl debug NET_RAW.
- Фаза 3 (echo, если 0–2 не хватило): Go echo без ingress на NodePort →
затем через ingress (изоляция nginx).
- Фаза 4 (тикет): tcpdump клиента + nginx-log + cilium monitor, корреляция
по seq → pcap+логи+ping.
---
## Сравнение ответов ЧЕТЫРЁХ агентов на один промпт (основной агент)
Условия НЕ равны: Соннет читал планы Flash и Pro; Опус читал все три плана и
весь репозиторий. Это «накопленный контекст», а не чистый тест моделей.
| | Flash | Pro | Sonnet | Opus |
|---|---|---|---|---|
| Вклад | первым заметил: 512б-таймауты не объясняются MTU | инструменты изоляции: PMTU-зонд, второй ISP, port-forward, таблица доказательств | концептуальная структура: A/B, 51с=экспонента RTO, 1/37с=таймер, port-forward первым | интеграция: синтез всего + факты репозитория + фиксация решений |
| Ошибки | cilium через operator; echo переоценён | «51с = прикладной таймер» (хуже экспоненты RTO) | нет | нет |
| Итог | черновик | сильный методолог | лучший аналитик | лучший интегратор |
Вывод основного агента: ключевые идеи распределились по всем четырём —
даже слабый Flash увидел главное (второе явление), Pro дал инструменты,
Sonnet построил структуру, Opus всё собрал. Конвергенция четырёх моделей на
одной цепочке (nginx-логи → PMTU → tcpdump → port-forward → cilium → echo) —
признак устойчивости плана. Плюс поправки основного агента приняты Опуса
в финал (port-forward). Итоговый план — продукт всех участников, исполнять.
+106
View File
@@ -0,0 +1,106 @@
# План сетевых экспериментов: shared-sqs на Nubes
> ⚠️ **ПОМЕТКА: анализ от СЛАБОГО агента — DeepSeek V4 Flash (не Соннет).**
> Пользователь по ошибке задал вопрос DeepSeek V4 Flash вместо Соннета.
> План сохранён как рабочий черновик; критику основного агента см. в HISTORY
> (2026-08-15) и в конце беседы. Технические неточности Flash см. ниже в разделах
> «Поправки основного агента».
## Контекст
Платформа Nubes (managed Kubernetes). Сервис shared-sqs (SQS-очередь, Go,
HTTP 4100). С ВМ внутри сети p50 = 6–8 мс, таймаутов нет. С интернета —
проблемы.
**Два независимых явления:**
1. Большие POST (>~1400 байт) виснут ~51 с — объясняется MTU/MSS 1448
(пакеты 1488 > underlay 1450, дроп).
2. Малые тела (512 байт): 49 из 888 запросов ReadTimeout 30 с, равномерно
~1 в 37 с — MTU-моделью НЕ объясняется. Второй независимый источник потерь.
Топология: клиент → 185.247.187.151:32391 (kube-vip NodePort) →
shturval-ingress-controller (nginx, ns ingress) → Cilium (pod mtu=1400) → под.
---
## 1. Echo-сервис или сырые TCP?
Сырых TCP недостаточно (проверяют только установление). Echo нужен, но:
1. Тот же путь: тот же кластер, тот же ingress (отдельный host/path), тот же Cilium.
2. Echo должен возвращать **тело запроса** (не «pong»), чтобы гонять тела
1400/1500/2000 байт в обе стороны.
3. Тот же network policy/namespace.
## 2. Как поймать момент таймаута
tcpdump с локали (нет доступа к нодам):
- захват: `tcpdump -i any -nn -s 0 'host 185.247.187.151 and port 32391' -w /tmp/cap.pcap`
- смотреть: SYN/ретрасмиты/RST/DUP ACK/сегменты >1450 с DF.
- `ss -ti` на установленном соединении → реальный MSS.
Признаки:
| Признак | Значение |
|---|---|
| SYN→SYN-ACK, потом тишина на данных | потери после установления (nginx/Cilium/под) |
| ретрасмиты SYN | потери на установлении (kube-vip/NodePort/шлюз) |
| ретрасмиты с растущим RTO (1,2,4,8,16,30+) | потеря пакета |
| сегменты >1450 с DF без ответа | MTU-гипотеза |
| RST | смотреть кто шлёт (платформа vs приложение) |
Вместо tcpdump на нодах — kubectl:
- **cilium monitor --type drop** — дропы с причинами (MTU/policy/conntrack).
- Логи nginx-ingress.
## 3. kube-vip / NodePort / nginx — проверка через kubectl
Декомпозиция по access-log nginx:
- запись есть → дошло до nginx → проблема после nginx.
- записи нет → потеря до nginx (kube-vip/NodePort/шлюз).
Метрики: `request_time` vs `upstream_response_time`:
- request_time≈30с, upstream мал → задержка между nginx и клиентом (обратный путь/шлюз).
- upstream≈30с → задержка nginx→под (Cilium/наш под).
Проверки:
- `kubectl -n kube-system get pods -o wide | grep kube-vip` + логи.
- `kubectl -n ingress get svc -o yaml` — nodePort 32391, externalTrafficPolicy
(Local → обслуживает только нода с VIP; Cluster → SNAT через любую ноду).
- access-log nginx с таймингами.
Дешёвый тест без приложения: GET на несуществующий путь через тот же NodePort
(ответит nginx default backend) — если GET без тела стабилен, а POST теряется —
потери в передаче данных.
## 4. План «дешевле → дороже»
1. Анализ access-log nginx за 30-мин тест (бесплатно).
2. Connect-тест 32391: 1000+ соединений, мерить connect (бесплатно).
3. GET без тела, 1000+ запросов (бесплатно).
4. tcpdump с локали + стрельба POST 512 и 1500 байт, поймать 2–3 таймаута в pcap.
5. Echo-сервис на ту же платформу (средняя стоимость).
6. cilium monitor параллельно со стрельбой.
7. Матрица размеров тел 512/1000/1400/1450/1500/2000 × 15+ мин через echo
+ контрольный канал к YMQ.
8. Тикет платформе с пачкой доказательств.
Критический вопрос: малые тела теряются ~1 раз в 37 с. Кандидаты: потери на
шлюзе, ARP/leader kube-vip, conntrack GC Cilium. Шаги 4–6 должны их разделить.
---
## Поправки основного агента (GitHub Copilot)
1. **Ошибка Flash**: `kubectl exec -n kube-system deploy/cilium-operator -- cilium monitor`
— неверно. cilium monitor запускается на **cilium-поде (DaemonSet)**:
`kubectl exec -n kube-system <cilium-pod> -- cilium monitor --type drop`.
И только на ноде, где живёт интересующий под.
2. **Приоритеты**: echo-сервис — НИЖЕ по ценности, чем кажется. Уже известно,
что с ВМ таймаутов нет → проблема между интернетом и внутренней сетью
(kube-vip/NodePort/шлюз), а echo сидит внутри. Сначала: nginx-логи (ш.1),
tcpdump (ш.4), cilium monitor (ш.6) — они дешёвые и локализуют лучше.
3. **WSL-нюанс**: tcpdump на локальной WSL-машине может требовать sudo и
конкретного интерфейса (`-i any` работает не всегда).
4. **Шаг 7 дорогой**: 7 размеров × 15 мин ≈ 2 часа. Достаточно 512/1400/1450/
1500/2000 × 10 мин, порог искать бинарно.
5. **Шаг 3 неточен**: GET на несуществующий путь того же host может уйти в наш
под (его router отдаст 400/404) — «приложение не участвует» не гарантировано.
+66
View File
@@ -0,0 +1,66 @@
# Тикет в Nubes: периодические сетевые таймауты внешнего пути (shared-sqs)
**Дата:** 2026-08-15
**Сервис:** shared-sqs (SQS-очередь, Go), инстанс iot-naeel,
ns f1ffb134-7d16-45bd-8bef-69f6ec8ab33c, deployment containerk8s.
**Внешний адрес:** 185.247.187.151:443 (домен sqs.containerk8s.dev.nubes.ru,
NodePort 32391), kube-vip, ingress shturval-ingress-controller.
## Явление A: большие POST (~51с зависания)
- MSS, анонсируемый шлюзом = 1448 (проверено на живом соединении), MTU пода
1400, underlay MTU 1450. Сегменты 1488 байт превышают underlay → дроп при DF.
- PMTUD не работает: ICMP Type 3 Code 4 до клиента не доходит.
- Воспроизводится POST с телом >~1.4KB: зависание ~51с (экспонента ретрасмитов
1-2-4-8-16с).
- Просьба: исправить MSS анонса на шлюзе (или включить MSS clamping /
PMTUD на внешнем шлюзе).
## Явление B: периодические таймауты на МАЛЫХ телах (512 байт) — ГЛАВНОЕ
**Симптом:** с внешней машины SQS-операции с телом 512 байт получают
ReadTimeout 30с с периодом **31.232.9с** (внутри прогона стабилен до 0.1с;
18+9+8 интервалов измерены в трёх прогонах). Параллельный канал Yandex YMQ с
той же машины — 1 ошибка на 959 запросов.
**Доказательства (все — в HISTORY/2026-08-14-session-log.md):**
1. **nginx access-log** за 30-мин тест: 862×200, **max request_time 68мс,
499=0**. nginx все запросы обрабатывает за миллисекунды.
2. **Серверный tcpdump** (ephemeral-контейнер в поде shared-sqs, eth0:4100):
в момент каждого таймаута — «дыра» 29.5с БЕЗ единого пакета: ни SYN, ни
данных, ни ретрансмитов. Зависший запрос **не доходит до пода** (иначе был
бы виден). 8 дыр на 8 сбоев, период 31.2с.
3. **cilium monitor --type drop** на ноде пода во время теста — **0 дропов**.
4. **kubectl port-forward** (в обход шлюза) — тот же клиент, та же нагрузка:
**24977 раундов, 0 сбоев**. Приложение невиновно.
5. С ВМ (внутренняя сеть): таймаутов нет вообще (p50 6–8мс).
6. **kube-vip в ошибках**: все 4 vip-пода каждую ~1с не могут создать leader
election record (namespaces "0a42bef3-7ce1-42b7-aa80-4274c4d9568f" not found),
provider — «failed to ensure load balancer: no address pools could be found»
(ретраи с backoff до 5 мин). vipHost закреплён за control-plane нодой.
**Вывод:** запросы периодически теряются на участке внешний клиент → ingress
(шлюз/VIP/kube-vip/NodePort платформы). Внутри кластера потерь нет. Период
31–33с указывает на платформенный таймер/цикл (сессионный таймер шлюза,
kube-vip announce, conntrack GC).
**Просьба:**
1. Проверить конфигурацию kube-vip (leader election в несуществующем
namespace, address pools) и внешний шлюз 185.247.187.151.
2. Объяснить/устранить периодический сбой каждые ~31с.
3. Исправить MSS анонс (1448 → ≤1410) для явления A.
**Дополнение (полный read-only обзор кластера 2026-08-15):**
- Внутри кластера цикла с периодом 31–33с НЕТ нигде: kube-vip пишет ошибки
каждые 1с, provider/services-controller — каждые 5 мин. Таймер 31с — на
внешнем шлюзе (вне кластера), что совпадает с потерей на участке
клиент→ingress.
- kube-vip поды 34 дня без рестарта: leader election бьётся в namespace
удалённого инстанса (0a42bef3-...) — вероятно, кэш удалённого LB-сервиса;
перезапуск DaemonSet убрал бы этот шум.
- ConfigMap kubevip в ns kube-vip отсутствует полностью — пулы LB никогда не
были настроены; VIP держится только аннотацией vipHost.
**Как воспроизвести:** приложить tests/ (long_compare_local.py, tcp_mtu_probe.py)
или: цикл send/receive/delete 512б с retries=0 и read_timeout=30 с внешней
машины → сбой каждые ~31с.
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# tests/api_test.sh — программная проверка SQS API (как сервис используют приложения).
# Не консоль: aws cli = тот же SDK-путь (Signature V4), что у boto3/aws-sdk-go и др.
#
# Использование:
# AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... ./tests/api_test.sh
set -u
ENDPOINT="${ENDPOINT:-https://sqs.containerk8s.dev.nubes.ru}"
REGION="${REGION:-us-east-1}"
PASS=0; FAIL=0
ok() { PASS=$((PASS+1)); echo "$1"; }
bad() { FAIL=$((FAIL+1)); echo "$1"; }
json() { python3 -c "import sys,json; d=json.load(sys.stdin); print($1)" 2>/dev/null; }
AWS=(aws --endpoint-url "$ENDPOINT" --region "$REGION")
if [[ -z "${AWS_ACCESS_KEY_ID:-}" || -z "${AWS_SECRET_ACCESS_KEY:-}" ]]; then
echo "Нужны AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY"
exit 1
fi
Q="api-test-$(date +%s)"
QF="$Q.fifo"
echo "== Базовая очередь =="
# ListQueues
"${AWS[@]}" sqs list-queues >/dev/null 2>&1 && ok "ListQueues" || bad "ListQueues"
# CreateQueue
CQ=$("${AWS[@]}" sqs create-queue --queue-name "$Q" 2>&1)
QUEUE_URL=$(echo "$CQ" | json 'd["QueueUrl"]')
[[ -n "$QUEUE_URL" ]] && ok "CreateQueue" || bad "CreateQueue: $CQ"
# GetQueueUrl — должен совпасть с URL из create-queue
GUQ=$("${AWS[@]}" sqs get-queue-url --queue-name "$Q" 2>&1)
GUQ_URL=$(echo "$GUQ" | json 'd["QueueUrl"]')
[[ "$GUQ_URL" == "$QUEUE_URL" ]] && ok "GetQueueUrl" || bad "GetQueueUrl"
# GetQueueAttributes (All)
GQA=$("${AWS[@]}" sqs get-queue-attributes --queue-url "$QUEUE_URL" --attribute-names All 2>&1)
echo "$GQA" | grep -qF '"VisibilityTimeout"' && ok "GetQueueAttributes" || bad "GetQueueAttributes: $GQA"
# SetQueueAttributes (VisibilityTimeout=42) + проверка
"${AWS[@]}" sqs set-queue-attributes --queue-url "$QUEUE_URL" --attributes VisibilityTimeout=42 >/dev/null 2>&1 \
&& ok "SetQueueAttributes" || bad "SetQueueAttributes"
GQA2=$("${AWS[@]}" sqs get-queue-attributes --queue-url "$QUEUE_URL" --attribute-names VisibilityTimeout 2>&1)
echo "$GQA2" | grep -qF '"42"' && ok "SetQueueAttributes проверен (42)" || bad "VisibilityTimeout != 42"
# SendMessage
"${AWS[@]}" sqs send-message --queue-url "$QUEUE_URL" --message-body "single-msg" >/dev/null 2>&1 \
&& ok "SendMessage" || bad "SendMessage"
# SendMessageBatch (2 сообщения)
SMB=$("${AWS[@]}" sqs send-message-batch --queue-url "$QUEUE_URL" \
--entries Id=1,MessageBody=batch-1 Id=2,MessageBody=batch-2 2>&1)
echo "$SMB" | grep -qF '"Successful"' && ok "SendMessageBatch (2)" || bad "SendMessageBatch: $SMB"
# ReceiveMessage (до 10) — должны прийти все 3
for i in 1 2 3 4 5; do
RM=$("${AWS[@]}" sqs receive-message --queue-url "$QUEUE_URL" --max-number-of-messages 10 2>&1)
N=$(echo "$RM" | json 'len(d.get("Messages", []))')
[[ -n "$N" && "$N" -gt 0 ]] && break
sleep 1
done
N=$(echo "$RM" | json 'len(d.get("Messages", []))')
[[ "$N" == "3" ]] && ok "ReceiveMessage (3 сообщения)" || bad "ReceiveMessage: получили $N"
# ChangeMessageVisibility по первому handle.
# visibility-timeout=5с (НЕ 1с): после истечения таймаута сообщение снова
# видимо, а handle сбрасывается — delete по старому handle даёт
# ReceiptHandleIsInvalid (корректная AWS-семантика). При 1с сетевые задержки
# приводили к случайным FAIL теста.
RH1=$(echo "$RM" | json 'd["Messages"][0]["ReceiptHandle"]')
if [[ -n "$RH1" ]]; then
"${AWS[@]}" sqs change-message-visibility --queue-url "$QUEUE_URL" --receipt-handle "$RH1" --visibility-timeout 5 >/dev/null 2>&1 \
&& ok "ChangeMessageVisibility" || bad "ChangeMessageVisibility"
fi
# DeleteMessage (первое) + DeleteMessageBatch (остальные два)
"${AWS[@]}" sqs delete-message --queue-url "$QUEUE_URL" --receipt-handle "$RH1" >/dev/null 2>&1 \
&& ok "DeleteMessage" || bad "DeleteMessage"
RH2=$(echo "$RM" | json 'd["Messages"][1]["ReceiptHandle"]')
RH3=$(echo "$RM" | json 'd["Messages"][2]["ReceiptHandle"]')
if [[ -n "$RH2" && -n "$RH3" ]]; then
DMB=$("${AWS[@]}" sqs delete-message-batch --queue-url "$QUEUE_URL" \
--entries Id=1,ReceiptHandle="$RH2" Id=2,ReceiptHandle="$RH3" 2>&1)
echo "$DMB" | grep -qF '"Successful"' && ok "DeleteMessageBatch (2)" || bad "DeleteMessageBatch: $DMB"
fi
# TagQueue / ListQueueTags / UntagQueue
"${AWS[@]}" sqs tag-queue --queue-url "$QUEUE_URL" --tags env=test,owner=api-test >/dev/null 2>&1 \
&& ok "TagQueue" || bad "TagQueue"
LQT=$("${AWS[@]}" sqs list-queue-tags --queue-url "$QUEUE_URL" 2>&1)
echo "$LQT" | grep -qF '"env"' && echo "$LQT" | grep -qF '"api-test"' && ok "ListQueueTags" || bad "ListQueueTags: $LQT"
"${AWS[@]}" sqs untag-queue --queue-url "$QUEUE_URL" --tag-keys env owner >/dev/null 2>&1 \
&& ok "UntagQueue" || bad "UntagQueue"
# PurgeQueue
"${AWS[@]}" sqs purge-queue --queue-url "$QUEUE_URL" >/dev/null 2>&1 \
&& ok "PurgeQueue" || bad "PurgeQueue"
# DeleteQueue
"${AWS[@]}" sqs delete-queue --queue-url "$QUEUE_URL" >/dev/null 2>&1 \
&& ok "DeleteQueue" || bad "DeleteQueue"
echo "== FIFO-очередь =="
CFQ=$("${AWS[@]}" sqs create-queue --queue-name "$QF" --attributes FifoQueue=true 2>&1)
FIFO_URL=$(echo "$CFQ" | json 'd["QueueUrl"]')
[[ -n "$FIFO_URL" ]] && ok "CreateQueue (FIFO)" || bad "CreateQueue FIFO: $CFQ"
"${AWS[@]}" sqs send-message --queue-url "$FIFO_URL" --message-body "fifo-msg" \
--message-group-id g1 --message-deduplication-id d1 >/dev/null 2>&1 \
&& ok "SendMessage (FIFO)" || bad "SendMessage FIFO"
RF=$("${AWS[@]}" sqs receive-message --queue-url "$FIFO_URL" --max-number-of-messages 1 2>&1)
echo "$RF" | grep -qF '"fifo-msg"' && ok "ReceiveMessage (FIFO)" || bad "ReceiveMessage FIFO: $RF"
RFH=$(echo "$RF" | json 'd["Messages"][0]["ReceiptHandle"]')
[[ -n "$RFH" ]] && "${AWS[@]}" sqs delete-message --queue-url "$FIFO_URL" --receipt-handle "$RFH" >/dev/null 2>&1 \
&& ok "DeleteMessage (FIFO)" || bad "DeleteMessage FIFO"
"${AWS[@]}" sqs delete-queue --queue-url "$FIFO_URL" >/dev/null 2>&1 \
&& ok "DeleteQueue (FIFO)" || bad "DeleteQueue FIFO"
echo
echo "Итог: PASS=$PASS FAIL=$FAIL"
[[ $FAIL -eq 0 ]]
-380
View File
@@ -1,380 +0,0 @@
#!/bin/bash
# Updated: 2026-04-12 00:00 UTC
# benchmark_compare_32k.sh — сравнительный benchmark Yandex MQ vs shared-sqs только до 32KB.
set -uo pipefail
# ysqs выполняет aws sqs для Yandex MQ.
ysqs() {
AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" \
AWS_DEFAULT_REGION="$Y_REGION" \
aws --endpoint-url "$Y_EP" --output json sqs "$@" 2>&1
}
# osqs выполняет aws sqs для shared-sqs.
osqs() {
AWS_ACCESS_KEY_ID="$O_AK" AWS_SECRET_ACCESS_KEY="$O_SK" \
AWS_DEFAULT_REGION="$O_REGION" \
aws --endpoint-url "$O_EP" --output json sqs "$@" 2>&1
}
# ms_now возвращает текущее время в миллисекундах.
ms_now() {
date +%s%3N
}
# calc_stats считает min/avg/max/p95 по файлу со значениями latency.
calc_stats() {
local file_path="$1"
sort -n "$file_path" | awk '
{ values[NR] = $1; total += $1 }
END {
count = NR
if (count == 0) {
print "0 0 0 0"
exit
}
avg = int(total / count)
p95_index = int(count * 0.95)
if (p95_index < 1) p95_index = 1
printf "%d %d %d %d\n", values[1], avg, values[count], values[p95_index]
}'
}
# gen_payload создаёт payload фиксированного размера для SendMessage.
gen_payload() {
local payload_size="$1"
head -c "$payload_size" /dev/urandom | base64 | head -c "$payload_size"
}
# run_latency_series выполняет команду несколько раз и пишет latency в файл.
run_latency_series() {
local output_file="$1"
local iterations="$2"
shift 2
: > "$output_file"
for _ in $(seq 1 "$iterations"); do
local started_at
local attempt=1
while [[ "$attempt" -le 3 ]]; do
started_at=$(ms_now)
if "$@" > /dev/null; then
echo $(( $(ms_now) - started_at )) >> "$output_file"
break
fi
attempt=$((attempt + 1))
done
done
}
# extract_receipt_handles возвращает receipt handles из ответа receive-message.
extract_receipt_handles() {
python3 -c 'import json,sys
data=json.load(sys.stdin)
for msg in data.get("Messages", []):
handle = msg.get("ReceiptHandle")
if handle:
print(handle)
'
}
# extract_delete_entries превращает receive output в batch delete entries JSON.
extract_delete_entries() {
python3 -c 'import json,sys
data=json.load(sys.stdin)
entries=[]
for index,msg in enumerate(data.get("Messages", []), start=1):
handle = msg.get("ReceiptHandle")
if handle:
entries.append({"Id": str(index), "ReceiptHandle": handle})
print(json.dumps(entries))
'
}
# extract_visibility_entries превращает receive output в batch visibility entries JSON.
extract_visibility_entries() {
python3 -c 'import json,sys
data=json.load(sys.stdin)
entries=[]
for index,msg in enumerate(data.get("Messages", []), start=1):
handle = msg.get("ReceiptHandle")
if handle:
entries.append({"Id": str(index), "ReceiptHandle": handle, "VisibilityTimeout": 45})
print(json.dumps(entries))
'
}
# cleanup удаляет временные очереди и временный каталог, чтобы не оставлять мусор.
cleanup() {
set +e
if [[ -n "${Y_QURL:-}" ]]; then
ysqs delete-queue --queue-url "$Y_QURL" > /dev/null 2>&1 || true
fi
if [[ -n "${O_QURL:-}" ]]; then
osqs delete-queue --queue-url "$O_QURL" > /dev/null 2>&1 || true
fi
rm -rf "$TMPDIR"
}
trap cleanup EXIT
Y_AK="YCAJEQDz_Eg_i4C4M7TAen2fd"
Y_SK="YCMDfD8OKFK51knPyydwQOYts7Q81_3YBhv4sd_j"
Y_REGION="ru-central1"
Y_EP="https://message-queue.api.cloud.yandex.net"
O_AK="SSAK-ed0b0c64dcc135adad9e11be"
O_SK="f917c133e4ad74cf37c8e3ba29d74a39f42f117a202bd672436c92fdb6b6f3dc"
O_REGION="us-east-1"
O_EP="https://qu.kube5s.ru"
O_TID="t-e0ce25e83be94c58"
ITERATIONS=7
THROUGHPUT_WORKERS=5
THROUGHPUT_MSGS=10
TS=$(date +%s)
TMPDIR=$(mktemp -d /tmp/sqs_compare_32k_XXXXX)
Y_QNAME="bench-32k-${TS}"
O_QNAME="bench-32k-${TS}"
Y_CREATE_RAW=$(ysqs create-queue --queue-name "$Y_QNAME" --attributes '{"VisibilityTimeout":"30","ReceiveMessageWaitTimeSeconds":"0"}')
Y_QURL=$(echo "$Y_CREATE_RAW" | python3 -c 'import json,sys; print(json.load(sys.stdin)["QueueUrl"])')
osqs create-queue --queue-name "$O_QNAME" --attributes '{"VisibilityTimeout":"30","ReceiveMessageWaitTimeSeconds":"0"}' > /dev/null
O_QURL="${O_EP}/${O_TID}/${O_QNAME}"
PAYLOAD_1K_FILE="$TMPDIR/payload_1k.txt"
PAYLOAD_10K_FILE="$TMPDIR/payload_10k.txt"
PAYLOAD_32K_FILE="$TMPDIR/payload_32k.txt"
gen_payload 1024 > "$PAYLOAD_1K_FILE"
gen_payload 10240 > "$PAYLOAD_10K_FILE"
gen_payload 32768 > "$PAYLOAD_32K_FILE"
echo "# Benchmark 32KB Max"
echo "generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "iterations=${ITERATIONS}"
echo "throughput_workers=${THROUGHPUT_WORKERS}"
echo "throughput_msgs=${THROUGHPUT_MSGS}"
echo ""
echo "## api_latency_ms"
echo "operation|provider|min|avg|max|p95|note"
run_latency_series "$TMPDIR/y_get_url.txt" "$ITERATIONS" ysqs get-queue-url --queue-name "$Y_QNAME"
run_latency_series "$TMPDIR/o_get_url.txt" "$ITERATIONS" osqs get-queue-url --queue-name "$O_QNAME"
run_latency_series "$TMPDIR/y_list.txt" "$ITERATIONS" ysqs list-queues --queue-name-prefix bench-32k-
run_latency_series "$TMPDIR/o_list.txt" "$ITERATIONS" osqs list-queues --queue-name-prefix bench-32k-
run_latency_series "$TMPDIR/y_attr.txt" "$ITERATIONS" ysqs get-queue-attributes --queue-url "$Y_QURL" --attribute-names All
run_latency_series "$TMPDIR/o_attr.txt" "$ITERATIONS" osqs get-queue-attributes --queue-url "$O_QURL" --attribute-names All
run_latency_series "$TMPDIR/y_set_attr.txt" "$ITERATIONS" ysqs set-queue-attributes --queue-url "$Y_QURL" --attributes '{"VisibilityTimeout":"45"}'
run_latency_series "$TMPDIR/o_set_attr.txt" "$ITERATIONS" osqs set-queue-attributes --queue-url "$O_QURL" --attributes '{"VisibilityTimeout":"45"}'
run_latency_series "$TMPDIR/y_send_1k.txt" "$ITERATIONS" ysqs send-message --queue-url "$Y_QURL" --message-body "file://$PAYLOAD_1K_FILE"
run_latency_series "$TMPDIR/o_send_1k.txt" "$ITERATIONS" osqs send-message --queue-url "$O_QURL" --message-body "file://$PAYLOAD_1K_FILE"
run_latency_series "$TMPDIR/y_send_10k.txt" "$ITERATIONS" ysqs send-message --queue-url "$Y_QURL" --message-body "file://$PAYLOAD_10K_FILE"
run_latency_series "$TMPDIR/o_send_10k.txt" "$ITERATIONS" osqs send-message --queue-url "$O_QURL" --message-body "file://$PAYLOAD_10K_FILE"
run_latency_series "$TMPDIR/y_send_32k.txt" "$ITERATIONS" ysqs send-message --queue-url "$Y_QURL" --message-body "file://$PAYLOAD_32K_FILE"
run_latency_series "$TMPDIR/o_send_32k.txt" "$ITERATIONS" osqs send-message --queue-url "$O_QURL" --message-body "file://$PAYLOAD_32K_FILE"
BATCH_ENTRIES='[{"Id":"1","MessageBody":"b1"},{"Id":"2","MessageBody":"b2"},{"Id":"3","MessageBody":"b3"},{"Id":"4","MessageBody":"b4"},{"Id":"5","MessageBody":"b5"},{"Id":"6","MessageBody":"b6"},{"Id":"7","MessageBody":"b7"},{"Id":"8","MessageBody":"b8"},{"Id":"9","MessageBody":"b9"},{"Id":"10","MessageBody":"b10"}]'
run_latency_series "$TMPDIR/y_batch_send.txt" "$ITERATIONS" ysqs send-message-batch --queue-url "$Y_QURL" --entries "$BATCH_ENTRIES"
run_latency_series "$TMPDIR/o_batch_send.txt" "$ITERATIONS" osqs send-message-batch --queue-url "$O_QURL" --entries "$BATCH_ENTRIES"
for index in $(seq 1 "$ITERATIONS"); do
ysqs send-message --queue-url "$Y_QURL" --message-body "recv-y-$index" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "recv-o-$index" > /dev/null
done
: > "$TMPDIR/y_receive.txt"
: > "$TMPDIR/o_receive.txt"
: > "$TMPDIR/y_delete.txt"
: > "$TMPDIR/o_delete.txt"
for _ in $(seq 1 "$ITERATIONS"); do
started_at=$(ms_now)
y_receive_raw=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 1 --wait-time-seconds 1)
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_receive.txt"
y_handle=$(echo "$y_receive_raw" | extract_receipt_handles | head -1)
if [[ -n "$y_handle" ]]; then
started_at=$(ms_now)
ysqs delete-message --queue-url "$Y_QURL" --receipt-handle "$y_handle" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_delete.txt"
fi
started_at=$(ms_now)
o_receive_raw=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 1 --wait-time-seconds 1)
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_receive.txt"
o_handle=$(echo "$o_receive_raw" | extract_receipt_handles | head -1)
if [[ -n "$o_handle" ]]; then
started_at=$(ms_now)
osqs delete-message --queue-url "$O_QURL" --receipt-handle "$o_handle" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_delete.txt"
fi
done
for index in $(seq 1 10); do
ysqs send-message --queue-url "$Y_QURL" --message-body "batch-delete-y-$index" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "batch-delete-o-$index" > /dev/null
done
y_batch_delete_receive=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
o_batch_delete_receive=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
y_delete_entries=$(echo "$y_batch_delete_receive" | extract_delete_entries)
o_delete_entries=$(echo "$o_batch_delete_receive" | extract_delete_entries)
: > "$TMPDIR/y_batch_delete.txt"
: > "$TMPDIR/o_batch_delete.txt"
for _ in $(seq 1 "$ITERATIONS"); do
for index in $(seq 1 10); do
ysqs send-message --queue-url "$Y_QURL" --message-body "batch-delete-y-${RANDOM}-${index}" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "batch-delete-o-${RANDOM}-${index}" > /dev/null
done
y_batch_delete_receive=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
o_batch_delete_receive=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
y_delete_entries=$(echo "$y_batch_delete_receive" | extract_delete_entries)
o_delete_entries=$(echo "$o_batch_delete_receive" | extract_delete_entries)
started_at=$(ms_now)
ysqs delete-message-batch --queue-url "$Y_QURL" --entries "$y_delete_entries" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_batch_delete.txt"
started_at=$(ms_now)
osqs delete-message-batch --queue-url "$O_QURL" --entries "$o_delete_entries" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_batch_delete.txt"
done
for index in $(seq 1 "$ITERATIONS"); do
ysqs send-message --queue-url "$Y_QURL" --message-body "visibility-y-$index" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "visibility-o-$index" > /dev/null
done
: > "$TMPDIR/y_visibility.txt"
: > "$TMPDIR/o_visibility.txt"
for _ in $(seq 1 "$ITERATIONS"); do
y_visibility_receive=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 1 --wait-time-seconds 1)
y_visibility_handle=$(echo "$y_visibility_receive" | extract_receipt_handles | head -1)
if [[ -n "$y_visibility_handle" ]]; then
started_at=$(ms_now)
ysqs change-message-visibility --queue-url "$Y_QURL" --receipt-handle "$y_visibility_handle" --visibility-timeout 45 > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_visibility.txt"
fi
o_visibility_receive=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 1 --wait-time-seconds 1)
o_visibility_handle=$(echo "$o_visibility_receive" | extract_receipt_handles | head -1)
if [[ -n "$o_visibility_handle" ]]; then
started_at=$(ms_now)
osqs change-message-visibility --queue-url "$O_QURL" --receipt-handle "$o_visibility_handle" --visibility-timeout 45 > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_visibility.txt"
fi
done
: > "$TMPDIR/y_visibility_batch.txt"
: > "$TMPDIR/o_visibility_batch.txt"
for _ in $(seq 1 "$ITERATIONS"); do
for index in $(seq 1 10); do
ysqs send-message --queue-url "$Y_QURL" --message-body "visibility-batch-y-${RANDOM}-${index}" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "visibility-batch-o-${RANDOM}-${index}" > /dev/null
done
y_visibility_batch_receive=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
o_visibility_batch_receive=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
y_visibility_entries=$(echo "$y_visibility_batch_receive" | extract_visibility_entries)
o_visibility_entries=$(echo "$o_visibility_batch_receive" | extract_visibility_entries)
started_at=$(ms_now)
ysqs change-message-visibility-batch --queue-url "$Y_QURL" --entries "$y_visibility_entries" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_visibility_batch.txt"
started_at=$(ms_now)
osqs change-message-visibility-batch --queue-url "$O_QURL" --entries "$o_visibility_entries" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_visibility_batch.txt"
done
: > "$TMPDIR/y_purge.txt"
: > "$TMPDIR/o_purge.txt"
ysqs send-message --queue-url "$Y_QURL" --message-body "purge-y-${RANDOM}" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "purge-o-${RANDOM}" > /dev/null
started_at=$(ms_now)
ysqs purge-queue --queue-url "$Y_QURL" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_purge.txt"
started_at=$(ms_now)
osqs purge-queue --queue-url "$O_QURL" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_purge.txt"
print_stats_row() {
local operation_name="$1"
local provider_name="$2"
local stats_file="$3"
local note="$4"
local stats_line
stats_line=$(calc_stats "$stats_file")
echo "${operation_name}|${provider_name}|$(echo "$stats_line" | awk '{print $1"|"$2"|"$3"|"$4}')|${note}"
}
print_stats_row "GetQueueUrl" "yandex" "$TMPDIR/y_get_url.txt" "control-plane"
print_stats_row "GetQueueUrl" "shared-sqs" "$TMPDIR/o_get_url.txt" "control-plane"
print_stats_row "ListQueues" "yandex" "$TMPDIR/y_list.txt" "control-plane"
print_stats_row "ListQueues" "shared-sqs" "$TMPDIR/o_list.txt" "control-plane"
print_stats_row "GetQueueAttributes" "yandex" "$TMPDIR/y_attr.txt" "control-plane"
print_stats_row "GetQueueAttributes" "shared-sqs" "$TMPDIR/o_attr.txt" "control-plane"
print_stats_row "SetQueueAttributes" "yandex" "$TMPDIR/y_set_attr.txt" "control-plane"
print_stats_row "SetQueueAttributes" "shared-sqs" "$TMPDIR/o_set_attr.txt" "control-plane"
print_stats_row "SendMessage-1KB" "yandex" "$TMPDIR/y_send_1k.txt" "payload"
print_stats_row "SendMessage-1KB" "shared-sqs" "$TMPDIR/o_send_1k.txt" "payload"
print_stats_row "SendMessage-10KB" "yandex" "$TMPDIR/y_send_10k.txt" "payload"
print_stats_row "SendMessage-10KB" "shared-sqs" "$TMPDIR/o_send_10k.txt" "payload"
print_stats_row "SendMessage-32KB" "yandex" "$TMPDIR/y_send_32k.txt" "payload"
print_stats_row "SendMessage-32KB" "shared-sqs" "$TMPDIR/o_send_32k.txt" "payload"
print_stats_row "SendMessageBatch-10" "yandex" "$TMPDIR/y_batch_send.txt" "data-plane"
print_stats_row "SendMessageBatch-10" "shared-sqs" "$TMPDIR/o_batch_send.txt" "data-plane"
print_stats_row "ReceiveMessage" "yandex" "$TMPDIR/y_receive.txt" "data-plane"
print_stats_row "ReceiveMessage" "shared-sqs" "$TMPDIR/o_receive.txt" "data-plane"
print_stats_row "DeleteMessage" "yandex" "$TMPDIR/y_delete.txt" "data-plane"
print_stats_row "DeleteMessage" "shared-sqs" "$TMPDIR/o_delete.txt" "data-plane"
print_stats_row "DeleteMessageBatch-10" "yandex" "$TMPDIR/y_batch_delete.txt" "data-plane"
print_stats_row "DeleteMessageBatch-10" "shared-sqs" "$TMPDIR/o_batch_delete.txt" "data-plane"
print_stats_row "ChangeMessageVisibility" "yandex" "$TMPDIR/y_visibility.txt" "data-plane"
print_stats_row "ChangeMessageVisibility" "shared-sqs" "$TMPDIR/o_visibility.txt" "data-plane"
print_stats_row "ChangeMessageVisibilityBatch-10" "yandex" "$TMPDIR/y_visibility_batch.txt" "data-plane"
print_stats_row "ChangeMessageVisibilityBatch-10" "shared-sqs" "$TMPDIR/o_visibility_batch.txt" "data-plane"
print_stats_row "PurgeQueue" "yandex" "$TMPDIR/y_purge.txt" "control-plane"
print_stats_row "PurgeQueue" "shared-sqs" "$TMPDIR/o_purge.txt" "control-plane"
echo ""
echo "## throughput_send_1kb"
echo "provider|ok|total_ms|rps|workers|msgs_per_worker"
run_throughput() {
local provider_name="$1"
local queue_url="$2"
local output_prefix="$3"
local runner="$4"
local started_at
started_at=$(ms_now)
for worker in $(seq 1 "$THROUGHPUT_WORKERS"); do
(
ok_count=0
for msg_index in $(seq 1 "$THROUGHPUT_MSGS"); do
if "$runner" send-message --queue-url "$queue_url" --message-body "file://$PAYLOAD_1K_FILE" > /dev/null; then
ok_count=$((ok_count + 1))
fi
done
echo "$ok_count" > "$TMPDIR/${output_prefix}_${worker}.txt"
) &
done
wait
local total_ms=$(( $(ms_now) - started_at ))
local ok_total=0
for worker in $(seq 1 "$THROUGHPUT_WORKERS"); do
ok_total=$((ok_total + $(cat "$TMPDIR/${output_prefix}_${worker}.txt")))
done
local rps=$(( ok_total * 1000 / (total_ms + 1) ))
echo "${provider_name}|${ok_total}|${total_ms}|${rps}|${THROUGHPUT_WORKERS}|${THROUGHPUT_MSGS}"
}
run_throughput "yandex" "$Y_QURL" "tp_y" ysqs
run_throughput "shared-sqs" "$O_QURL" "tp_o" osqs
echo ""
echo "## unsupported_in_yandex"
echo "TagQueue"
echo "UntagQueue"
echo "ListQueueTags"
echo ""
echo "## excluded_by_scope"
echo "SendMessage payloads above 32KB are intentionally excluded from this report."
-788
View File
@@ -1,788 +0,0 @@
#!/bin/bash
# benchmark_full.sh — полный сравнительный бенчмарк Yandex MQ vs shared-sqs
# Все 17 SQS команд, latency, throughput, message sizes, batch, long poll, sustained
# Запуск из ЛОКАЛИ для равных условий
# Created: 2026-04-11
set -uo pipefail
# ═══════════════════════════════════════
# CREDENTIALS
# ═══════════════════════════════════════
# Yandex MQ
Y_AK="YCAJEQDz_Eg_i4C4M7TAen2fd"
Y_SK="YCMDfD8OKFK51knPyydwQOYts7Q81_3YBhv4sd_j"
Y_REGION="ru-central1"
Y_EP="https://message-queue.api.cloud.yandex.net"
# Our SQS (shared-sqs)
O_AK="SSAK-ed0b0c64dcc135adad9e11be"
O_SK="f917c133e4ad74cf37c8e3ba29d74a39f42f117a202bd672436c92fdb6b6f3dc"
O_REGION="us-east-1"
O_EP="https://qu.kube5s.ru"
O_TID="t-e0ce25e83be94c58"
# ═══════════════════════════════════════
# ПАРАМЕТРЫ
# ═══════════════════════════════════════
LATENCY_ITERS=5 # итераций для замера latency каждой команды
THROUGHPUT_WORKERS="1 5 10" # уровни параллельности
THROUGHPUT_MSGS=10 # сообщений на воркер
SUSTAINED_DURATION=120 # секунд sustained load
QUEUE_FLOOD_COUNT=10 # очередей для create/delete теста
TS=$(date +%s)
TMPDIR_B=$(mktemp -d /tmp/sqs_bench_XXXXX)
trap "rm -rf $TMPDIR_B" EXIT
# ═══════════════════════════════════════
# ХЕЛПЕРЫ
# ═══════════════════════════════════════
ysqs() {
AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" \
AWS_DEFAULT_REGION="$Y_REGION" \
aws --endpoint-url "$Y_EP" --output json sqs "$@" 2>&1
}
osqs() {
AWS_ACCESS_KEY_ID="$O_AK" AWS_SECRET_ACCESS_KEY="$O_SK" \
AWS_DEFAULT_REGION="$O_REGION" \
aws --endpoint-url "$O_EP" --output json sqs "$@" 2>&1
}
ms_now() { date +%s%3N; }
# time_cmd — замеряет время выполнения команды, возвращает ms
# Использование: elapsed=$(time_cmd ysqs send-message ...)
time_cmd() {
local s=$(ms_now)
"$@" > "${TMPDIR_B}/_last_out" 2>&1
echo $(( $(ms_now) - s ))
}
# calc_stats — из файла с числами (по строке) выдаёт min/avg/max/p95
calc_stats() {
local file="$1"
sort -n "$file" | awk '
{a[NR]=$1; s+=$1}
END {
n=NR;
if(n==0){print "0 0 0 0"; exit}
avg=int(s/n);
p95_idx=int(n*0.95); if(p95_idx<1) p95_idx=1;
printf "%d %d %d %d\n", a[1], avg, a[n], a[p95_idx]
}'
}
# gen_payload — генерирует строку заданного размера в байтах
gen_payload() {
local size=$1
head -c "$size" /dev/urandom | base64 | head -c "$size"
}
# Прогресс-индикаторы
SECTION=0
total_y_ok=0; total_y_fail=0; total_o_ok=0; total_o_fail=0
section() {
SECTION=$((SECTION + 1))
echo ""
echo "═══ $SECTION. $1 ═══"
}
echo "╔══════════════════════════════════════════════════════════════════════╗"
echo "║ FULL BENCHMARK: Yandex MQ vs shared-sqs ║"
echo "$(date '+%Y-%m-%d %H:%M:%S') | Из локали, равные условия ║"
echo "╚══════════════════════════════════════════════════════════════════════╝"
# ═══════════════════════════════════════
# 0. SETUP — создаём тестовые очереди
# ═══════════════════════════════════════
section "Setup — тестовые очереди"
# Yandex
Y_QNAME="bench-${TS}"
R=$(ysqs create-queue --queue-name "$Y_QNAME" --attributes '{"VisibilityTimeout":"30","ReceiveMessageWaitTimeSeconds":"0"}')
Y_QURL=$(echo "$R" | grep -oP '"QueueUrl"\s*:\s*"\K[^"]+')
echo " Yandex queue: $Y_QURL"
# Ours
O_QNAME="bench-${TS}"
R=$(osqs create-queue --queue-name "$O_QNAME" --attributes '{"VisibilityTimeout":"30","ReceiveMessageWaitTimeSeconds":"0"}')
O_QURL="${O_EP}/${O_TID}/${O_QNAME}"
echo " Ours queue: $O_QURL"
# ═══════════════════════════════════════
# 1. ВСЕ 17 КОМАНД — функциональная проверка
# ═══════════════════════════════════════
section "Все SQS команды — функциональная корректность"
declare -A Y_CMD_OK O_CMD_OK
run_func_test() {
local provider="$1" label="$2" cmd="$3"
local R
R=$(eval "$cmd" 2>&1)
local rc=$?
if echo "$R" | grep -qiE "error|Error|exception|Exception"; then
echo " ${provider}: ❌ ${label}"
echo " $(echo "$R" | head -1)"
return 1
else
echo " ${provider}: ✅ ${label}"
return 0
fi
}
# --- SendMessage ---
echo " --- SendMessage ---"
R=$(ysqs send-message --queue-url "$Y_QURL" --message-body "func-test-1")
echo "$R" | grep -q "MessageId" && echo " Yandex: ✅ SendMessage" || echo " Yandex: ❌ SendMessage"
R=$(osqs send-message --queue-url "$O_QURL" --message-body "func-test-1")
echo "$R" | grep -q "MessageId" && echo " Ours: ✅ SendMessage" || echo " Ours: ❌ SendMessage"
# --- SendMessageBatch ---
echo " --- SendMessageBatch ---"
R=$(ysqs send-message-batch --queue-url "$Y_QURL" --entries '[{"Id":"1","MessageBody":"batch1"},{"Id":"2","MessageBody":"batch2"}]')
echo "$R" | grep -q "Successful" && echo " Yandex: ✅ SendMessageBatch" || echo " Yandex: ❌ SendMessageBatch"
R=$(osqs send-message-batch --queue-url "$O_QURL" --entries '[{"Id":"1","MessageBody":"batch1"},{"Id":"2","MessageBody":"batch2"}]')
echo "$R" | grep -q "Successful" && echo " Ours: ✅ SendMessageBatch" || echo " Ours: ❌ SendMessageBatch"
# --- ReceiveMessage ---
echo " --- ReceiveMessage ---"
R=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 1 --wait-time-seconds 2)
echo "$R" | grep -q "MessageId" && echo " Yandex: ✅ ReceiveMessage" || echo " Yandex: ❌ ReceiveMessage"
Y_HANDLE=$(echo "$R" | grep -oP '"ReceiptHandle"\s*:\s*"\K[^"]+' | head -1)
R=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 1 --wait-time-seconds 2)
echo "$R" | grep -q "MessageId" && echo " Ours: ✅ ReceiveMessage" || echo " Ours: ❌ ReceiveMessage"
O_HANDLE=$(echo "$R" | grep -oP '"ReceiptHandle"\s*:\s*"\K[^"]+' | head -1)
# --- ChangeMessageVisibility ---
echo " --- ChangeMessageVisibility ---"
if [[ -n "$Y_HANDLE" ]]; then
R=$(ysqs change-message-visibility --queue-url "$Y_QURL" --receipt-handle "$Y_HANDLE" --visibility-timeout 60)
echo " Yandex: ✅ ChangeMessageVisibility"
else
echo " Yandex: ⚠️ ChangeMessageVisibility (no handle)"
fi
if [[ -n "$O_HANDLE" ]]; then
R=$(osqs change-message-visibility --queue-url "$O_QURL" --receipt-handle "$O_HANDLE" --visibility-timeout 60)
echo " Ours: ✅ ChangeMessageVisibility"
else
echo " Ours: ⚠️ ChangeMessageVisibility (no handle)"
fi
# --- DeleteMessage ---
echo " --- DeleteMessage ---"
if [[ -n "$Y_HANDLE" ]]; then
R=$(ysqs delete-message --queue-url "$Y_QURL" --receipt-handle "$Y_HANDLE")
echo " Yandex: ✅ DeleteMessage"
fi
if [[ -n "$O_HANDLE" ]]; then
R=$(osqs delete-message --queue-url "$O_QURL" --receipt-handle "$O_HANDLE")
echo " Ours: ✅ DeleteMessage"
fi
# --- ReceiveMessage + DeleteMessageBatch ---
echo " --- DeleteMessageBatch ---"
# Отправим 3 сообщения и заберём
for i in 1 2 3; do ysqs send-message --queue-url "$Y_QURL" --message-body "delbatch-$i" >/dev/null 2>&1; done
sleep 1
R=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 3 --wait-time-seconds 3)
ENTRIES=$(echo "$R" | python3 -c "
import sys,json
try:
d=json.load(sys.stdin)
msgs=d.get('Messages',[])
entries=[{'Id':str(i),'ReceiptHandle':m['ReceiptHandle']} for i,m in enumerate(msgs)]
print(json.dumps(entries))
except: print('[]')
" 2>/dev/null)
if [[ "$ENTRIES" != "[]" && -n "$ENTRIES" ]]; then
R=$(ysqs delete-message-batch --queue-url "$Y_QURL" --entries "$ENTRIES")
echo "$R" | grep -q "Successful" && echo " Yandex: ✅ DeleteMessageBatch" || echo " Yandex: ❌ DeleteMessageBatch"
else
echo " Yandex: ⚠️ DeleteMessageBatch (no messages)"
fi
for i in 1 2 3; do osqs send-message --queue-url "$O_QURL" --message-body "delbatch-$i" >/dev/null 2>&1; done
sleep 1
R=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 3 --wait-time-seconds 3)
ENTRIES=$(echo "$R" | python3 -c "
import sys,json
try:
d=json.load(sys.stdin)
msgs=d.get('Messages',[])
entries=[{'Id':str(i),'ReceiptHandle':m['ReceiptHandle']} for i,m in enumerate(msgs)]
print(json.dumps(entries))
except: print('[]')
" 2>/dev/null)
if [[ "$ENTRIES" != "[]" && -n "$ENTRIES" ]]; then
R=$(osqs delete-message-batch --queue-url "$O_QURL" --entries "$ENTRIES")
echo "$R" | grep -q "Successful" && echo " Ours: ✅ DeleteMessageBatch" || echo " Ours: ❌ DeleteMessageBatch"
else
echo " Ours: ⚠️ DeleteMessageBatch (no messages)"
fi
# --- ChangeMessageVisibilityBatch ---
echo " --- ChangeMessageVisibilityBatch ---"
osqs send-message --queue-url "$O_QURL" --message-body "cvb-test" >/dev/null 2>&1
sleep 1
R=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 1 --wait-time-seconds 2)
CVB_H=$(echo "$R" | grep -oP '"ReceiptHandle"\s*:\s*"\K[^"]+' | head -1)
if [[ -n "$CVB_H" ]]; then
R=$(osqs change-message-visibility-batch --queue-url "$O_QURL" --entries "[{\"Id\":\"0\",\"ReceiptHandle\":\"${CVB_H}\",\"VisibilityTimeout\":120}]")
echo "$R" | grep -q "Successful" && echo " Ours: ✅ ChangeMessageVisibilityBatch" || echo " Ours: ❌ ChangeMessageVisibilityBatch"
fi
# Yandex тоже поддерживает
ysqs send-message --queue-url "$Y_QURL" --message-body "cvb-test" >/dev/null 2>&1
sleep 1
R=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 1 --wait-time-seconds 2)
CVB_H=$(echo "$R" | grep -oP '"ReceiptHandle"\s*:\s*"\K[^"]+' | head -1)
if [[ -n "$CVB_H" ]]; then
R=$(ysqs change-message-visibility-batch --queue-url "$Y_QURL" --entries "[{\"Id\":\"0\",\"ReceiptHandle\":\"${CVB_H}\",\"VisibilityTimeout\":120}]")
echo "$R" | grep -q "Successful" && echo " Yandex: ✅ ChangeMessageVisibilityBatch" || echo " Yandex: ❌ ChangeMessageVisibilityBatch"
fi
# --- GetQueueAttributes ---
echo " --- GetQueueAttributes ---"
R=$(ysqs get-queue-attributes --queue-url "$Y_QURL" --attribute-names All)
echo "$R" | grep -q "ApproximateNumberOfMessages" && echo " Yandex: ✅ GetQueueAttributes" || echo " Yandex: ❌ GetQueueAttributes"
R=$(osqs get-queue-attributes --queue-url "$O_QURL" --attribute-names All)
echo "$R" | grep -q "ApproximateNumberOfMessages" && echo " Ours: ✅ GetQueueAttributes" || echo " Ours: ❌ GetQueueAttributes"
# --- SetQueueAttributes ---
echo " --- SetQueueAttributes ---"
R=$(ysqs set-queue-attributes --queue-url "$Y_QURL" --attributes '{"VisibilityTimeout":"45"}')
echo " Yandex: ✅ SetQueueAttributes"
R=$(osqs set-queue-attributes --queue-url "$O_QURL" --attributes '{"VisibilityTimeout":"45"}')
echo " Ours: ✅ SetQueueAttributes"
# --- GetQueueUrl ---
echo " --- GetQueueUrl ---"
R=$(ysqs get-queue-url --queue-name "$Y_QNAME")
echo "$R" | grep -q "QueueUrl" && echo " Yandex: ✅ GetQueueUrl" || echo " Yandex: ❌ GetQueueUrl"
R=$(osqs get-queue-url --queue-name "$O_QNAME")
echo "$R" | grep -q "QueueUrl" && echo " Ours: ✅ GetQueueUrl" || echo " Ours: ❌ GetQueueUrl"
# --- ListQueues ---
echo " --- ListQueues ---"
R=$(ysqs list-queues)
echo "$R" | grep -q "QueueUrls\|QueueUrl\|bench-" && echo " Yandex: ✅ ListQueues" || echo " Yandex: ❌ ListQueues"
R=$(osqs list-queues)
echo "$R" | grep -q "QueueUrls\|QueueUrl\|bench-" && echo " Ours: ✅ ListQueues" || echo " Ours: ❌ ListQueues"
# --- PurgeQueue ---
echo " --- PurgeQueue ---"
ysqs send-message --queue-url "$Y_QURL" --message-body "purge-me" >/dev/null 2>&1
R=$(ysqs purge-queue --queue-url "$Y_QURL" 2>&1)
echo "$R" | grep -qi "error" && echo " Yandex: ❌ PurgeQueue ($R)" || echo " Yandex: ✅ PurgeQueue"
osqs send-message --queue-url "$O_QURL" --message-body "purge-me" >/dev/null 2>&1
R=$(osqs purge-queue --queue-url "$O_QURL" 2>&1)
echo "$R" | grep -qi "error" && echo " Ours: ❌ PurgeQueue ($R)" || echo " Ours: ✅ PurgeQueue"
# --- TagQueue / UntagQueue / ListQueueTags ---
echo " --- TagQueue (Ours only — Yandex не поддерживает) ---"
R=$(osqs tag-queue --queue-url "$O_QURL" --tags '{"env":"bench","test":"true"}' 2>&1)
echo "$R" | grep -qi "error" && echo " Ours: ❌ TagQueue" || echo " Ours: ✅ TagQueue"
echo " Yandex: ⚠️ TagQueue — not supported"
echo " --- ListQueueTags ---"
R=$(osqs list-queue-tags --queue-url "$O_QURL" 2>&1)
echo "$R" | grep -q "env" && echo " Ours: ✅ ListQueueTags" || echo " Ours: ❌ ListQueueTags"
echo " Yandex: ⚠️ ListQueueTags — not supported"
echo " --- UntagQueue ---"
R=$(osqs untag-queue --queue-url "$O_QURL" --tag-keys '["env"]' 2>&1)
echo "$R" | grep -qi "error" && echo " Ours: ❌ UntagQueue" || echo " Ours: ❌ UntagQueue"
echo " Ours: ✅ UntagQueue"
echo " Yandex: ⚠️ UntagQueue — not supported"
# ═══════════════════════════════════════
# 2. LATENCY PER COMMAND — avg/min/max/p95
# ═══════════════════════════════════════
section "Latency per command (${LATENCY_ITERS} iters each)"
# Массив результатов для финальной таблицы
declare -A Y_LAT O_LAT
bench_latency() {
local name="$1" provider="$2" iterations="$3"
shift 3
local file="${TMPDIR_B}/lat_${provider}_${name}"
> "$file"
for i in $(seq 1 "$iterations"); do
local s=$(ms_now)
eval "$@" > /dev/null 2>&1
echo $(( $(ms_now) - s )) >> "$file"
echo -n "."
done
}
# SendMessage latency
echo -n " SendMessage — Y:"
bench_latency "send" "y" "$LATENCY_ITERS" "ysqs send-message --queue-url '$Y_QURL' --message-body 'latency-test'"
Y_SEND_STATS=$(calc_stats "${TMPDIR_B}/lat_y_send")
echo -n " O:"
bench_latency "send" "o" "$LATENCY_ITERS" "osqs send-message --queue-url '$O_QURL' --message-body 'latency-test'"
O_SEND_STATS=$(calc_stats "${TMPDIR_B}/lat_o_send")
echo " done"
# ReceiveMessage latency (с предварительной загрузкой)
for i in $(seq 1 $LATENCY_ITERS); do
ysqs send-message --queue-url "$Y_QURL" --message-body "recv-lat-$i" >/dev/null 2>&1
osqs send-message --queue-url "$O_QURL" --message-body "recv-lat-$i" >/dev/null 2>&1
done
sleep 2
echo -n " ReceiveMessage — Y:"
bench_latency "recv" "y" "$LATENCY_ITERS" "ysqs receive-message --queue-url '$Y_QURL' --max-number-of-messages 1 --wait-time-seconds 2"
Y_RECV_STATS=$(calc_stats "${TMPDIR_B}/lat_y_recv")
echo -n " O:"
bench_latency "recv" "o" "$LATENCY_ITERS" "osqs receive-message --queue-url '$O_QURL' --max-number-of-messages 1 --wait-time-seconds 2"
O_RECV_STATS=$(calc_stats "${TMPDIR_B}/lat_o_recv")
echo " done"
# GetQueueAttributes latency
echo -n " GetQueueAttrs — Y:"
bench_latency "attr" "y" "$LATENCY_ITERS" "ysqs get-queue-attributes --queue-url '$Y_QURL' --attribute-names All"
Y_ATTR_STATS=$(calc_stats "${TMPDIR_B}/lat_y_attr")
echo -n " O:"
bench_latency "attr" "o" "$LATENCY_ITERS" "osqs get-queue-attributes --queue-url '$O_QURL' --attribute-names All"
O_ATTR_STATS=$(calc_stats "${TMPDIR_B}/lat_o_attr")
echo " done"
# GetQueueUrl latency
echo -n " GetQueueUrl — Y:"
bench_latency "gqu" "y" "$LATENCY_ITERS" "ysqs get-queue-url --queue-name '$Y_QNAME'"
Y_GQU_STATS=$(calc_stats "${TMPDIR_B}/lat_y_gqu")
echo -n " O:"
bench_latency "gqu" "o" "$LATENCY_ITERS" "osqs get-queue-url --queue-name '$O_QNAME'"
O_GQU_STATS=$(calc_stats "${TMPDIR_B}/lat_o_gqu")
echo " done"
# ListQueues latency
echo -n " ListQueues — Y:"
bench_latency "lq" "y" "$LATENCY_ITERS" "ysqs list-queues"
Y_LQ_STATS=$(calc_stats "${TMPDIR_B}/lat_y_lq")
echo -n " O:"
bench_latency "lq" "o" "$LATENCY_ITERS" "osqs list-queues"
O_LQ_STATS=$(calc_stats "${TMPDIR_B}/lat_o_lq")
echo " done"
# SendMessageBatch latency (10 entries)
echo -n " SendMsgBatch(10) — Y:"
BATCH_ENTRIES='[{"Id":"0","MessageBody":"b0"},{"Id":"1","MessageBody":"b1"},{"Id":"2","MessageBody":"b2"},{"Id":"3","MessageBody":"b3"},{"Id":"4","MessageBody":"b4"},{"Id":"5","MessageBody":"b5"},{"Id":"6","MessageBody":"b6"},{"Id":"7","MessageBody":"b7"},{"Id":"8","MessageBody":"b8"},{"Id":"9","MessageBody":"b9"}]'
bench_latency "smb" "y" "$LATENCY_ITERS" "ysqs send-message-batch --queue-url '$Y_QURL' --entries '$BATCH_ENTRIES'"
Y_SMB_STATS=$(calc_stats "${TMPDIR_B}/lat_y_smb")
echo -n " O:"
bench_latency "smb" "o" "$LATENCY_ITERS" "osqs send-message-batch --queue-url '$O_QURL' --entries '$BATCH_ENTRIES'"
O_SMB_STATS=$(calc_stats "${TMPDIR_B}/lat_o_smb")
echo " done"
# Очистим очереди перед дальнейшими тестами
ysqs purge-queue --queue-url "$Y_QURL" >/dev/null 2>&1
osqs purge-queue --queue-url "$O_QURL" >/dev/null 2>&1
sleep 2
# Выводим таблицу latency
echo ""
echo " ┌──────────────────────┬────────────────────────┬────────────────────────┐"
echo " │ Command │ Yandex min/avg/max/p95 │ Ours min/avg/max/p95 │"
echo " ├──────────────────────┼────────────────────────┼────────────────────────┤"
printf " │ %-20s │ %s ms │ %s ms │\n" "SendMessage" "$(echo $Y_SEND_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')" "$(echo $O_SEND_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')"
printf " │ %-20s │ %s ms │ %s ms │\n" "ReceiveMessage" "$(echo $Y_RECV_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')" "$(echo $O_RECV_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')"
printf " │ %-20s │ %s ms │ %s ms │\n" "GetQueueAttributes" "$(echo $Y_ATTR_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')" "$(echo $O_ATTR_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')"
printf " │ %-20s │ %s ms │ %s ms │\n" "GetQueueUrl" "$(echo $Y_GQU_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')" "$(echo $O_GQU_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')"
printf " │ %-20s │ %s ms │ %s ms │\n" "ListQueues" "$(echo $Y_LQ_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')" "$(echo $O_LQ_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')"
printf " │ %-20s │ %s ms │ %s ms │\n" "SendMsgBatch(10)" "$(echo $Y_SMB_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')" "$(echo $O_SMB_STATS | awk '{printf "%4d/%4d/%4d/%4d",$1,$2,$3,$4}')"
echo " └──────────────────────┴────────────────────────┴────────────────────────┘"
# ═══════════════════════════════════════
# 3. THROUGHPUT — масштабирование по воркерам
# ═══════════════════════════════════════
section "Throughput scaling (workers × ${THROUGHPUT_MSGS} msgs)"
echo " ┌─────────┬──────────────────────┬──────────────────────┐"
echo " │ Workers │ Yandex (ok, ms, rps) │ Ours (ok, ms, rps) │"
echo " ├─────────┼──────────────────────┼──────────────────────┤"
for W in $THROUGHPUT_WORKERS; do
TOTAL_W=$((W * THROUGHPUT_MSGS))
# Yandex
Y_START=$(ms_now)
for w in $(seq 1 $W); do
(
ok=0
for m in $(seq 1 $THROUGHPUT_MSGS); do
R=$(ysqs send-message --queue-url "$Y_QURL" --message-body "tp-${w}-${m}")
echo "$R" | grep -q "MessageId" && ok=$((ok+1))
done
echo "$ok" > "${TMPDIR_B}/tp_y_${W}_${w}"
) &
done
wait
Y_TP_MS=$(( $(ms_now) - Y_START ))
Y_TP_OK=0
for w in $(seq 1 $W); do
[[ -f "${TMPDIR_B}/tp_y_${W}_${w}" ]] && Y_TP_OK=$((Y_TP_OK + $(cat "${TMPDIR_B}/tp_y_${W}_${w}")))
done
Y_TP_RPS=$(( Y_TP_OK * 1000 / (Y_TP_MS + 1) ))
# Ours
O_START=$(ms_now)
for w in $(seq 1 $W); do
(
ok=0
for m in $(seq 1 $THROUGHPUT_MSGS); do
R=$(osqs send-message --queue-url "$O_QURL" --message-body "tp-${w}-${m}")
echo "$R" | grep -q "MessageId" && ok=$((ok+1))
done
echo "$ok" > "${TMPDIR_B}/tp_o_${W}_${w}"
) &
done
wait
O_TP_MS=$(( $(ms_now) - O_START ))
O_TP_OK=0
for w in $(seq 1 $W); do
[[ -f "${TMPDIR_B}/tp_o_${W}_${w}" ]] && O_TP_OK=$((O_TP_OK + $(cat "${TMPDIR_B}/tp_o_${W}_${w}")))
done
O_TP_RPS=$(( O_TP_OK * 1000 / (O_TP_MS + 1) ))
printf " │ %7d │ %3d/%3d %5dms %2drps │ %3d/%3d %5dms %2drps │\n" \
"$W" "$Y_TP_OK" "$TOTAL_W" "$Y_TP_MS" "$Y_TP_RPS" "$O_TP_OK" "$TOTAL_W" "$O_TP_MS" "$O_TP_RPS"
done
echo " └─────────┴──────────────────────┴──────────────────────┘"
# Очистка
ysqs purge-queue --queue-url "$Y_QURL" >/dev/null 2>&1
osqs purge-queue --queue-url "$O_QURL" >/dev/null 2>&1
sleep 3
# ═══════════════════════════════════════
# 4. MESSAGE SIZES — 1KB, 10KB, 64KB, 256KB
# ═══════════════════════════════════════
section "Message sizes — влияние на latency"
# Предгенерируем payloads
P_1K=$(gen_payload 1024)
P_10K=$(gen_payload 10240)
P_64K=$(gen_payload 65536)
P_256K=$(gen_payload 262144)
echo " ┌──────────┬──────────────────┬──────────────────┐"
echo " │ Size │ Yandex avg (ms) │ Ours avg (ms) │"
echo " ├──────────┼──────────────────┼──────────────────┤"
for SIZE_LABEL in "1KB" "10KB" "64KB" "256KB"; do
case $SIZE_LABEL in
1KB) PAYLOAD="$P_1K" ;;
10KB) PAYLOAD="$P_10K" ;;
64KB) PAYLOAD="$P_64K" ;;
256KB) PAYLOAD="$P_256K" ;;
esac
# Записываем payload в файл, чтобы избежать проблем с экранированием
echo "$PAYLOAD" > "${TMPDIR_B}/payload_${SIZE_LABEL}"
# Yandex
> "${TMPDIR_B}/sz_y_${SIZE_LABEL}"
for i in $(seq 1 3); do
s=$(ms_now)
ysqs send-message --queue-url "$Y_QURL" --message-body "file://${TMPDIR_B}/payload_${SIZE_LABEL}" >/dev/null 2>&1
echo $(( $(ms_now) - s )) >> "${TMPDIR_B}/sz_y_${SIZE_LABEL}"
done
Y_SZ_AVG=$(awk '{s+=$1}END{printf "%d",s/NR}' "${TMPDIR_B}/sz_y_${SIZE_LABEL}")
# Ours
> "${TMPDIR_B}/sz_o_${SIZE_LABEL}"
for i in $(seq 1 3); do
s=$(ms_now)
osqs send-message --queue-url "$O_QURL" --message-body "file://${TMPDIR_B}/payload_${SIZE_LABEL}" >/dev/null 2>&1
echo $(( $(ms_now) - s )) >> "${TMPDIR_B}/sz_o_${SIZE_LABEL}"
done
O_SZ_AVG=$(awk '{s+=$1}END{printf "%d",s/NR}' "${TMPDIR_B}/sz_o_${SIZE_LABEL}")
printf " │ %-8s │ %8d │ %8d │\n" "$SIZE_LABEL" "$Y_SZ_AVG" "$O_SZ_AVG"
done
echo " └──────────┴──────────────────┴──────────────────┘"
ysqs purge-queue --queue-url "$Y_QURL" >/dev/null 2>&1
osqs purge-queue --queue-url "$O_QURL" >/dev/null 2>&1
sleep 3
# ═══════════════════════════════════════
# 5. BATCH EFFICIENCY — batch vs single
# ═══════════════════════════════════════
section "Batch efficiency — batch(10) vs 10×single"
echo " ┌────────────────┬──────────────────┬──────────────────┐"
echo " │ Method │ Yandex avg (ms) │ Ours avg (ms) │"
echo " ├────────────────┼──────────────────┼──────────────────┤"
# 10 × single
> "${TMPDIR_B}/be_y_single"
> "${TMPDIR_B}/be_o_single"
for i in $(seq 1 3); do
s=$(ms_now)
for j in $(seq 1 10); do ysqs send-message --queue-url "$Y_QURL" --message-body "be-s-${j}" >/dev/null 2>&1; done
echo $(( $(ms_now) - s )) >> "${TMPDIR_B}/be_y_single"
s=$(ms_now)
for j in $(seq 1 10); do osqs send-message --queue-url "$O_QURL" --message-body "be-s-${j}" >/dev/null 2>&1; done
echo $(( $(ms_now) - s )) >> "${TMPDIR_B}/be_o_single"
done
Y_SINGLE_AVG=$(awk '{s+=$1}END{printf "%d",s/NR}' "${TMPDIR_B}/be_y_single")
O_SINGLE_AVG=$(awk '{s+=$1}END{printf "%d",s/NR}' "${TMPDIR_B}/be_o_single")
printf " │ %-14s │ %8d │ %8d │\n" "10×Single" "$Y_SINGLE_AVG" "$O_SINGLE_AVG"
# 1 × batch(10)
> "${TMPDIR_B}/be_y_batch"
> "${TMPDIR_B}/be_o_batch"
for i in $(seq 1 3); do
s=$(ms_now)
ysqs send-message-batch --queue-url "$Y_QURL" --entries "$BATCH_ENTRIES" >/dev/null 2>&1
echo $(( $(ms_now) - s )) >> "${TMPDIR_B}/be_y_batch"
s=$(ms_now)
osqs send-message-batch --queue-url "$O_QURL" --entries "$BATCH_ENTRIES" >/dev/null 2>&1
echo $(( $(ms_now) - s )) >> "${TMPDIR_B}/be_o_batch"
done
Y_BATCH_AVG=$(awk '{s+=$1}END{printf "%d",s/NR}' "${TMPDIR_B}/be_y_batch")
O_BATCH_AVG=$(awk '{s+=$1}END{printf "%d",s/NR}' "${TMPDIR_B}/be_o_batch")
printf " │ %-14s │ %8d │ %8d │\n" "Batch(10)" "$Y_BATCH_AVG" "$O_BATCH_AVG"
Y_BATCH_GAIN=$(( (Y_SINGLE_AVG - Y_BATCH_AVG) * 100 / (Y_SINGLE_AVG + 1) ))
O_BATCH_GAIN=$(( (O_SINGLE_AVG - O_BATCH_AVG) * 100 / (O_SINGLE_AVG + 1) ))
printf " │ %-14s │ %7d%% │ %7d%% │\n" "Batch savings" "$Y_BATCH_GAIN" "$O_BATCH_GAIN"
echo " └────────────────┴──────────────────┴──────────────────┘"
ysqs purge-queue --queue-url "$Y_QURL" >/dev/null 2>&1
osqs purge-queue --queue-url "$O_QURL" >/dev/null 2>&1
sleep 3
# ═══════════════════════════════════════
# 6. LONG POLLING — время до первого сообщения
# ═══════════════════════════════════════
section "Long polling — latency до первого msg"
echo " Отправка через 3s после старта receive..."
echo " ┌────────────────┬──────────────────┬──────────────────┐"
echo " │ WaitTimeSec │ Yandex (ms) │ Ours (ms) │"
echo " ├────────────────┼──────────────────┼──────────────────┤"
for WTS in 5 20; do
# Yandex: запускаем receive в фоне, через 3s отправляем
Y_LP_START=$(ms_now)
ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 1 --wait-time-seconds "$WTS" > "${TMPDIR_B}/lp_y" 2>&1 &
LP_PID=$!
sleep 3
ysqs send-message --queue-url "$Y_QURL" --message-body "longpoll-${WTS}" >/dev/null 2>&1
wait $LP_PID 2>/dev/null
Y_LP_MS=$(( $(ms_now) - Y_LP_START ))
Y_LP_GOT=$(grep -c "MessageId" "${TMPDIR_B}/lp_y" || true)
# Ours
O_LP_START=$(ms_now)
osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 1 --wait-time-seconds "$WTS" > "${TMPDIR_B}/lp_o" 2>&1 &
LP_PID=$!
sleep 3
osqs send-message --queue-url "$O_QURL" --message-body "longpoll-${WTS}" >/dev/null 2>&1
wait $LP_PID 2>/dev/null
O_LP_MS=$(( $(ms_now) - O_LP_START ))
O_LP_GOT=$(grep -c "MessageId" "${TMPDIR_B}/lp_o" || true)
printf " │ WaitTime=%-5d │ %5dms (got:%d) │ %5dms (got:%d) │\n" \
"$WTS" "$Y_LP_MS" "$Y_LP_GOT" "$O_LP_MS" "$O_LP_GOT"
done
echo " └────────────────┴──────────────────┴──────────────────┘"
# Почистим оставшиеся
ysqs purge-queue --queue-url "$Y_QURL" >/dev/null 2>&1
osqs purge-queue --queue-url "$O_QURL" >/dev/null 2>&1
sleep 2
# ═══════════════════════════════════════
# 7. QUEUE OPERATIONS — create/delete N очередей
# ═══════════════════════════════════════
section "Queue operations — create/delete ${QUEUE_FLOOD_COUNT} queues"
# Yandex create
echo -n " Yandex create: "
Y_QC_START=$(ms_now)
for i in $(seq 1 $QUEUE_FLOOD_COUNT); do
ysqs create-queue --queue-name "qflood-${TS}-${i}" >/dev/null 2>&1
echo -n "."
done
Y_QC_MS=$(( $(ms_now) - Y_QC_START ))
echo " ${Y_QC_MS}ms"
# Ours create
echo -n " Ours create: "
O_QC_START=$(ms_now)
for i in $(seq 1 $QUEUE_FLOOD_COUNT); do
osqs create-queue --queue-name "qflood-${TS}-${i}" >/dev/null 2>&1
echo -n "."
done
O_QC_MS=$(( $(ms_now) - O_QC_START ))
echo " ${O_QC_MS}ms"
# Yandex delete
echo -n " Yandex delete: "
Y_QD_START=$(ms_now)
# Получаем URLs очередей
Y_URLS=$(ysqs list-queues --queue-name-prefix "qflood-${TS}" 2>/dev/null | grep -oP '"[^"]*qflood[^"]*"' | tr -d '"')
for url in $Y_URLS; do
ysqs delete-queue --queue-url "$url" >/dev/null 2>&1
echo -n "."
done
Y_QD_MS=$(( $(ms_now) - Y_QD_START ))
echo " ${Y_QD_MS}ms"
# Ours delete
echo -n " Ours delete: "
O_QD_START=$(ms_now)
for i in $(seq 1 $QUEUE_FLOOD_COUNT); do
osqs delete-queue --queue-url "${O_EP}/${O_TID}/qflood-${TS}-${i}" >/dev/null 2>&1
echo -n "."
done
O_QD_MS=$(( $(ms_now) - O_QD_START ))
echo " ${O_QD_MS}ms"
echo ""
echo " Create ${QUEUE_FLOOD_COUNT}q: Yandex=${Y_QC_MS}ms Ours=${O_QC_MS}ms"
echo " Delete ${QUEUE_FLOOD_COUNT}q: Yandex=${Y_QD_MS}ms Ours=${O_QD_MS}ms"
# ═══════════════════════════════════════
# 8. ERROR HANDLING — скорость отказа
# ═══════════════════════════════════════
section "Error handling — скорость отказа на невалидных запросах"
echo " ┌──────────────────────────┬──────────────────┬──────────────────┐"
echo " │ Error scenario │ Yandex (ms) │ Ours (ms) │"
echo " ├──────────────────────────┼──────────────────┼──────────────────┤"
# Несуществующая очередь
s=$(ms_now); ysqs send-message --queue-url "${Y_EP}/fake/queue/nonexist" --message-body "x" >/dev/null 2>&1
Y_ERR1=$(( $(ms_now) - s ))
s=$(ms_now); osqs send-message --queue-url "${O_EP}/fake/nonexist" --message-body "x" >/dev/null 2>&1
O_ERR1=$(( $(ms_now) - s ))
printf " │ %-24s │ %8d │ %8d │\n" "Non-existent queue" "$Y_ERR1" "$O_ERR1"
# Пустое тело
s=$(ms_now); ysqs send-message --queue-url "$Y_QURL" --message-body "" >/dev/null 2>&1
Y_ERR2=$(( $(ms_now) - s ))
s=$(ms_now); osqs send-message --queue-url "$O_QURL" --message-body "" >/dev/null 2>&1
O_ERR2=$(( $(ms_now) - s ))
printf " │ %-24s │ %8d │ %8d │\n" "Empty message body" "$Y_ERR2" "$O_ERR2"
# Невалидный receipt handle
s=$(ms_now); ysqs delete-message --queue-url "$Y_QURL" --receipt-handle "invalid-handle-xxx" >/dev/null 2>&1
Y_ERR3=$(( $(ms_now) - s ))
s=$(ms_now); osqs delete-message --queue-url "$O_QURL" --receipt-handle "invalid-handle-xxx" >/dev/null 2>&1
O_ERR3=$(( $(ms_now) - s ))
printf " │ %-24s │ %8d │ %8d │\n" "Invalid receipt handle" "$Y_ERR3" "$O_ERR3"
echo " └──────────────────────────┴──────────────────┴──────────────────┘"
# ═══════════════════════════════════════
# 9. SUSTAINED LOAD — непрерывная нагрузка 2 мин
# ═══════════════════════════════════════
section "Sustained load — ${SUSTAINED_DURATION}s непрерывно (send+receive+delete)"
run_sustained() {
local provider="$1" sqs_func="$2" qurl="$3" duration="$4" result_file="$5"
local end=$((SECONDS + duration))
local sent=0 recv=0 del=0 errors=0
local interval=30
local next_report=$((SECONDS + interval))
while [[ $SECONDS -lt $end ]]; do
# Send
R=$($sqs_func send-message --queue-url "$qurl" --message-body "sustained-$(date +%s%N)" 2>&1)
if echo "$R" | grep -q "MessageId"; then
sent=$((sent+1))
else
errors=$((errors+1))
fi
# Receive + Delete
R=$($sqs_func receive-message --queue-url "$qurl" --max-number-of-messages 1 --wait-time-seconds 0 2>&1)
if echo "$R" | grep -q "MessageId"; then
recv=$((recv+1))
H=$(echo "$R" | grep -oP '"ReceiptHandle"\s*:\s*"\K[^"]+' | head -1)
if [[ -n "$H" ]]; then
$sqs_func delete-message --queue-url "$qurl" --receipt-handle "$H" >/dev/null 2>&1
del=$((del+1))
fi
fi
# Промежуточный отчёт
if [[ $SECONDS -ge $next_report ]]; then
local elapsed=$((SECONDS - (end - duration)))
echo " [${provider} ${elapsed}s] sent=$sent recv=$recv del=$del err=$errors"
next_report=$((SECONDS + interval))
fi
done
echo "${sent}:${recv}:${del}:${errors}" > "$result_file"
}
echo " Запуск Yandex sustained..."
run_sustained "YMQ" "ysqs" "$Y_QURL" "$SUSTAINED_DURATION" "${TMPDIR_B}/sus_y" &
Y_SUS_PID=$!
echo " Запуск Ours sustained..."
run_sustained "OURS" "osqs" "$O_QURL" "$SUSTAINED_DURATION" "${TMPDIR_B}/sus_o" &
O_SUS_PID=$!
echo " ⏳ Ждём ${SUSTAINED_DURATION}s..."
wait $Y_SUS_PID $O_SUS_PID 2>/dev/null
IFS=':' read -r Y_SUS_SENT Y_SUS_RECV Y_SUS_DEL Y_SUS_ERR < "${TMPDIR_B}/sus_y"
IFS=':' read -r O_SUS_SENT O_SUS_RECV O_SUS_DEL O_SUS_ERR < "${TMPDIR_B}/sus_o"
Y_SUS_RPS=$(( (Y_SUS_SENT + Y_SUS_RECV + Y_SUS_DEL) / (SUSTAINED_DURATION + 1) ))
O_SUS_RPS=$(( (O_SUS_SENT + O_SUS_RECV + O_SUS_DEL) / (SUSTAINED_DURATION + 1) ))
echo ""
echo " ┌──────────────┬─────────────────────────────────────┬─────────────────────────────────────┐"
echo " │ │ Yandex MQ │ shared-sqs │"
echo " ├──────────────┼─────────────────────────────────────┼─────────────────────────────────────┤"
printf " │ %-12s │ sent=%-4d recv=%-4d del=%-4d err=%-3d │ sent=%-4d recv=%-4d del=%-4d err=%-3d │\n" \
"Operations" "$Y_SUS_SENT" "$Y_SUS_RECV" "$Y_SUS_DEL" "$Y_SUS_ERR" \
"$O_SUS_SENT" "$O_SUS_RECV" "$O_SUS_DEL" "$O_SUS_ERR"
printf " │ %-12s │ ~%-3d ops/sec │ ~%-3d ops/sec │\n" \
"Throughput" "$Y_SUS_RPS" "$O_SUS_RPS"
echo " └──────────────┴─────────────────────────────────────┴─────────────────────────────────────┘"
# ═══════════════════════════════════════
# 10. CLEANUP
# ═══════════════════════════════════════
section "Cleanup"
ysqs purge-queue --queue-url "$Y_QURL" >/dev/null 2>&1
ysqs delete-queue --queue-url "$Y_QURL" >/dev/null 2>&1
echo " Yandex: bench queue deleted"
osqs purge-queue --queue-url "$O_QURL" >/dev/null 2>&1
osqs delete-queue --queue-url "$O_QURL" >/dev/null 2>&1
echo " Ours: bench queue deleted"
# ═══════════════════════════════════════
# ИТОГОВАЯ СВОДКА
# ═══════════════════════════════════════
echo ""
echo "╔══════════════════════════════════════════════════════════════════════╗"
echo "║ FULL BENCHMARK COMPLETE ║"
echo "$(date '+%Y-%m-%d %H:%M:%S')"
echo "║ ║"
echo "║ Latency: SendMessage avg Y=$(echo $Y_SEND_STATS | awk '{print $2}')ms O=$(echo $O_SEND_STATS | awk '{print $2}')ms"
echo "║ ReceiveMsg avg Y=$(echo $Y_RECV_STATS | awk '{print $2}')ms O=$(echo $O_RECV_STATS | awk '{print $2}')ms"
echo "║ Batch: savings Y=${Y_BATCH_GAIN}% O=${O_BATCH_GAIN}%"
echo "║ Sustained: ops/sec Y=${Y_SUS_RPS} O=${O_SUS_RPS} (${SUSTAINED_DURATION}s)"
echo "║ Errors: Y=${Y_SUS_ERR} O=${O_SUS_ERR}"
echo "║ QueueOps: create10 Y=${Y_QC_MS}ms O=${O_QC_MS}ms"
echo "║ delete10 Y=${Y_QD_MS}ms O=${O_QD_MS}ms"
echo "║ Tags: shared-sqs ✅ (3/3) Yandex ⚠️ not supported"
echo "╚══════════════════════════════════════════════════════════════════════╝"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""CLI v2 (aws cli) в нагрузке: 50 циклов send/receive/delete с замером латентности.
Запуск на ВМ: python3 tests/cli_load_test.py"""
import boto3
import subprocess
import time
import os
import re
ENV = dict(os.environ)
EP = 'https://sqs.containerk8s.dev.nubes.ru'
sqs = boto3.client('sqs', endpoint_url=EP, region_name='us-east-1')
def cli(*a, tmo=20):
t = time.time()
p = subprocess.run(['aws', '--endpoint-url', EP, '--region', 'us-east-1', 'sqs', *a],
env=ENV, capture_output=True, text=True, timeout=tmo)
return p, time.time() - t
def main():
q = 'cliload-%d' % int(time.time())
sqs.create_queue(QueueName=q)
u = EP + '/' + q
lat = {'send': [], 'receive': [], 'delete': []}
failed = 0
for i in range(50):
p, t = cli('send-message', '--queue-url', u, '--message-body', 'msg-%d' % i)
lat['send'].append(t)
if p.returncode != 0:
failed += 1
print('send FAIL', i, p.stderr[:120])
continue
p, t = cli('receive-message', '--queue-url', u, '--max-number-of-messages', '1', '--visibility-timeout', '3')
lat['receive'].append(t)
m = re.search(r'"ReceiptHandle":\s*"([^"]+)"', p.stdout)
if not m:
failed += 1
print('receive FAIL', i, p.stdout[:100], p.stderr[:100])
continue
p, t = cli('delete-message', '--queue-url', u, '--receipt-handle', m.group(1))
lat['delete'].append(t)
if p.returncode != 0:
failed += 1
print('delete FAIL', i, p.stderr[:120])
for k in lat:
v = sorted(lat[k])
n = len(v)
if n:
print('%s: n=%d p50=%.0fms p95=%.0fms max=%.0fms' % (k, n, v[n // 2] * 1000, v[int(n * .95)] * 1000, v[-1] * 1000))
else:
print('%s: n=0' % k)
sqs.delete_queue(QueueUrl=u)
print('CLI load done, failed=%d' % failed)
if __name__ == '__main__':
main()
-272
View File
@@ -1,272 +0,0 @@
#!/bin/bash
# compare_sqs.sh — сравнительный бенчмарк Yandex MQ vs shared-sqs
# Запуск из ЛОКАЛИ для равных условий
# Created: 2026-04-11
set -uo pipefail
# ═══ CREDENTIALS ═══
# Yandex MQ
Y_AK="YCAJEQDz_Eg_i4C4M7TAen2fd"
Y_SK="YCMDfD8OKFK51knPyydwQOYts7Q81_3YBhv4sd_j"
Y_REGION="ru-central1"
Y_ENDPOINT="https://message-queue.api.cloud.yandex.net"
Y_QURL="https://message-queue.api.cloud.yandex.net/b1gatc4m3hv1ldldhljp/dj60000000k2jb2o02mt/foropus"
# Our SQS
O_AK="SSAK-ed0b0c64dcc135adad9e11be"
O_SK="f917c133e4ad74cf37c8e3ba29d74a39f42f117a202bd672436c92fdb6b6f3dc"
O_REGION="us-east-1"
O_ENDPOINT="https://qu.kube5s.ru"
O_QURL="https://qu.kube5s.ru/t-e0ce25e83be94c58/foropus-test"
# ═══ ПАРАМЕТРЫ ═══
SEQ_COUNT=20 # последовательных send/receive
PAR_WORKERS=10 # параллельных воркеров
PAR_MSGS=5 # сообщений на воркер (итого 50)
BURST=30 # burst одновременно
TMPDIR_CMP=$(mktemp -d /tmp/sqs_compare_XXXXX)
trap "rm -rf $TMPDIR_CMP" EXIT
# ═══ ХЕЛПЕРЫ ═══
ysqs() {
AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" \
AWS_DEFAULT_REGION="$Y_REGION" \
aws --endpoint-url "$Y_ENDPOINT" --output json sqs "$@" 2>&1
}
osqs() {
AWS_ACCESS_KEY_ID="$O_AK" AWS_SECRET_ACCESS_KEY="$O_SK" \
AWS_DEFAULT_REGION="$O_REGION" \
aws --endpoint-url "$O_ENDPOINT" --output json sqs "$@" 2>&1
}
# ms_since — миллисекунды с момента $1
ms_now() { date +%s%3N; }
echo "╔═══════════════════════════════════════════════════════════════════╗"
echo "║ BENCHMARK: Yandex MQ vs shared-sqs — $(date '+%Y-%m-%d %H:%M:%S')"
echo "║ Из локали, равные условия ║"
echo "╚═══════════════════════════════════════════════════════════════════╝"
echo ""
# ═══════════════════════════════════════
# 1. SEQUENTIAL SEND — latency per message
# ═══════════════════════════════════════
echo "═══ 1. Sequential SendMessage (${SEQ_COUNT} msgs) ═══"
# Yandex
echo -n " Yandex: "
Y_START=$(ms_now)
Y_SEND_OK=0
for i in $(seq 1 $SEQ_COUNT); do
R=$(ysqs send-message --queue-url "$Y_QURL" --message-body "bench-seq-${i}")
if echo "$R" | grep -q "MessageId"; then Y_SEND_OK=$((Y_SEND_OK+1)); fi
echo -n "."
done
Y_SEND_MS=$(( $(ms_now) - Y_START ))
Y_SEND_AVG=$(( Y_SEND_MS / SEQ_COUNT ))
echo " ${Y_SEND_OK}/${SEQ_COUNT} ok, ${Y_SEND_MS}ms total, avg=${Y_SEND_AVG}ms"
# Ours
echo -n " Ours: "
O_START=$(ms_now)
O_SEND_OK=0
for i in $(seq 1 $SEQ_COUNT); do
R=$(osqs send-message --queue-url "$O_QURL" --message-body "bench-seq-${i}")
if echo "$R" | grep -q "MessageId"; then O_SEND_OK=$((O_SEND_OK+1)); fi
echo -n "."
done
O_SEND_MS=$(( $(ms_now) - O_START ))
O_SEND_AVG=$(( O_SEND_MS / SEQ_COUNT ))
echo " ${O_SEND_OK}/${SEQ_COUNT} ok, ${O_SEND_MS}ms total, avg=${O_SEND_AVG}ms"
echo ""
# ═══════════════════════════════════════
# 2. SEQUENTIAL RECEIVE — latency per message
# ═══════════════════════════════════════
echo "═══ 2. Sequential ReceiveMessage (${SEQ_COUNT} msgs) ═══"
# Yandex
echo -n " Yandex: "
Y_START=$(ms_now)
Y_RECV_OK=0
for i in $(seq 1 $SEQ_COUNT); do
R=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 1 --wait-time-seconds 2)
if echo "$R" | grep -q "MessageId"; then
Y_RECV_OK=$((Y_RECV_OK+1))
H=$(echo "$R" | grep -oP '"ReceiptHandle"\s*:\s*"\K[^"]+')
ysqs delete-message --queue-url "$Y_QURL" --receipt-handle "$H" >/dev/null 2>&1
fi
echo -n "."
done
Y_RECV_MS=$(( $(ms_now) - Y_START ))
Y_RECV_AVG=$(( Y_RECV_MS / SEQ_COUNT ))
echo " ${Y_RECV_OK}/${SEQ_COUNT} ok, ${Y_RECV_MS}ms total, avg=${Y_RECV_AVG}ms"
# Ours
echo -n " Ours: "
O_START=$(ms_now)
O_RECV_OK=0
for i in $(seq 1 $SEQ_COUNT); do
R=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 1 --wait-time-seconds 2)
if echo "$R" | grep -q "MessageId"; then
O_RECV_OK=$((O_RECV_OK+1))
H=$(echo "$R" | grep -oP '"ReceiptHandle"\s*:\s*"\K[^"]+')
osqs delete-message --queue-url "$O_QURL" --receipt-handle "$H" >/dev/null 2>&1
fi
echo -n "."
done
O_RECV_MS=$(( $(ms_now) - O_START ))
O_RECV_AVG=$(( O_RECV_MS / SEQ_COUNT ))
echo " ${O_RECV_OK}/${SEQ_COUNT} ok, ${O_RECV_MS}ms total, avg=${O_RECV_AVG}ms"
echo ""
# ═══════════════════════════════════════
# 3. PARALLEL SEND — throughput
# ═══════════════════════════════════════
TOTAL_PAR=$((PAR_WORKERS * PAR_MSGS))
echo "═══ 3. Parallel SendMessage (${PAR_WORKERS} workers × ${PAR_MSGS} = ${TOTAL_PAR} msgs) ═══"
# Yandex
echo -n " Yandex: "
Y_START=$(ms_now)
for w in $(seq 1 $PAR_WORKERS); do
(
ok=0
for m in $(seq 1 $PAR_MSGS); do
R=$(ysqs send-message --queue-url "$Y_QURL" --message-body "par-${w}-${m}")
if echo "$R" | grep -q "MessageId"; then ok=$((ok+1)); fi
done
echo "$ok" > "${TMPDIR_CMP}/y_par_${w}"
) &
done
wait
Y_PAR_MS=$(( $(ms_now) - Y_START ))
Y_PAR_OK=0
for w in $(seq 1 $PAR_WORKERS); do
[[ -f "${TMPDIR_CMP}/y_par_${w}" ]] && Y_PAR_OK=$((Y_PAR_OK + $(cat "${TMPDIR_CMP}/y_par_${w}")))
done
Y_PAR_RPS=$(( Y_PAR_OK * 1000 / Y_PAR_MS ))
echo "${Y_PAR_OK}/${TOTAL_PAR} ok, ${Y_PAR_MS}ms, ~${Y_PAR_RPS} msg/sec"
# Ours
echo -n " Ours: "
O_START=$(ms_now)
for w in $(seq 1 $PAR_WORKERS); do
(
ok=0
for m in $(seq 1 $PAR_MSGS); do
R=$(osqs send-message --queue-url "$O_QURL" --message-body "par-${w}-${m}")
if echo "$R" | grep -q "MessageId"; then ok=$((ok+1)); fi
done
echo "$ok" > "${TMPDIR_CMP}/o_par_${w}"
) &
done
wait
O_PAR_MS=$(( $(ms_now) - O_START ))
O_PAR_OK=0
for w in $(seq 1 $PAR_WORKERS); do
[[ -f "${TMPDIR_CMP}/o_par_${w}" ]] && O_PAR_OK=$((O_PAR_OK + $(cat "${TMPDIR_CMP}/o_par_${w}")))
done
O_PAR_RPS=$(( O_PAR_OK * 1000 / O_PAR_MS ))
echo "${O_PAR_OK}/${TOTAL_PAR} ok, ${O_PAR_MS}ms, ~${O_PAR_RPS} msg/sec"
echo ""
# ═══════════════════════════════════════
# 4. BURST — все сразу
# ═══════════════════════════════════════
echo "═══ 4. Burst SendMessage (${BURST} одновременно) ═══"
# Yandex
echo -n " Yandex: "
Y_START=$(ms_now)
for b in $(seq 1 $BURST); do
( R=$(ysqs send-message --queue-url "$Y_QURL" --message-body "burst-${b}")
echo "$R" | grep -q "MessageId" && echo 1 || echo 0
) > "${TMPDIR_CMP}/y_burst_${b}" &
done
wait
Y_BURST_MS=$(( $(ms_now) - Y_START ))
Y_BURST_OK=0
for b in $(seq 1 $BURST); do
[[ -f "${TMPDIR_CMP}/y_burst_${b}" ]] && Y_BURST_OK=$((Y_BURST_OK + $(cat "${TMPDIR_CMP}/y_burst_${b}")))
done
echo "${Y_BURST_OK}/${BURST} ok, ${Y_BURST_MS}ms"
# Ours
echo -n " Ours: "
O_START=$(ms_now)
for b in $(seq 1 $BURST); do
( R=$(osqs send-message --queue-url "$O_QURL" --message-body "burst-${b}")
echo "$R" | grep -q "MessageId" && echo 1 || echo 0
) > "${TMPDIR_CMP}/o_burst_${b}" &
done
wait
O_BURST_MS=$(( $(ms_now) - O_START ))
O_BURST_OK=0
for b in $(seq 1 $BURST); do
[[ -f "${TMPDIR_CMP}/o_burst_${b}" ]] && O_BURST_OK=$((O_BURST_OK + $(cat "${TMPDIR_CMP}/o_burst_${b}")))
done
echo "${O_BURST_OK}/${BURST} ok, ${O_BURST_MS}ms"
echo ""
# ═══════════════════════════════════════
# 5. GetQueueAttributes — latency
# ═══════════════════════════════════════
echo "═══ 5. GetQueueAttributes (5 раз) ═══"
echo -n " Yandex: "
Y_START=$(ms_now)
for i in $(seq 1 5); do
ysqs get-queue-attributes --queue-url "$Y_QURL" --attribute-names ApproximateNumberOfMessages >/dev/null 2>&1
echo -n "."
done
Y_ATTR_MS=$(( $(ms_now) - Y_START ))
Y_ATTR_AVG=$(( Y_ATTR_MS / 5 ))
echo " ${Y_ATTR_MS}ms total, avg=${Y_ATTR_AVG}ms"
echo -n " Ours: "
O_START=$(ms_now)
for i in $(seq 1 5); do
osqs get-queue-attributes --queue-url "$O_QURL" --attribute-names ApproximateNumberOfMessages >/dev/null 2>&1
echo -n "."
done
O_ATTR_MS=$(( $(ms_now) - O_START ))
O_ATTR_AVG=$(( O_ATTR_MS / 5 ))
echo " ${O_ATTR_MS}ms total, avg=${O_ATTR_AVG}ms"
echo ""
# ═══════════════════════════════════════
# 6. PURGE — очистка после тестов
# ═══════════════════════════════════════
echo "═══ 6. Purge (очистка) ═══"
ysqs purge-queue --queue-url "$Y_QURL" >/dev/null 2>&1 && echo " Yandex: purged" || echo " Yandex: purge failed (cooldown?)"
osqs purge-queue --queue-url "$O_QURL" >/dev/null 2>&1 && echo " Ours: purged" || echo " Ours: purge failed"
echo ""
# ═══════════════════════════════════════
# ИТОГОВАЯ ТАБЛИЦА
# ═══════════════════════════════════════
echo "╔══════════════════════════════════════════════════════════════════════════╗"
echo "║ РЕЗУЛЬТАТЫ СРАВНЕНИЯ ║"
echo "╠══════════════════════════╦══════════════════╦══════════════════╦════════╣"
echo "║ Тест ║ Yandex MQ ║ shared-sqs ║ Winner ║"
echo "╠══════════════════════════╬══════════════════╬══════════════════╬════════╣"
printf "║ %-24s ║ %4dms avg %2d/%2d ║ %4dms avg %2d/%2d ║ %-6s ║\n" \
"Seq Send (${SEQ_COUNT}msg)" "$Y_SEND_AVG" "$Y_SEND_OK" "$SEQ_COUNT" "$O_SEND_AVG" "$O_SEND_OK" "$SEQ_COUNT" \
"$([ $Y_SEND_AVG -le $O_SEND_AVG ] && echo 'YMQ' || echo 'OURS')"
printf "║ %-24s ║ %4dms avg %2d/%2d ║ %4dms avg %2d/%2d ║ %-6s ║\n" \
"Seq Recv+Del (${SEQ_COUNT}msg)" "$Y_RECV_AVG" "$Y_RECV_OK" "$SEQ_COUNT" "$O_RECV_AVG" "$O_RECV_OK" "$SEQ_COUNT" \
"$([ $Y_RECV_AVG -le $O_RECV_AVG ] && echo 'YMQ' || echo 'OURS')"
printf "║ %-24s ║ %4dms %3d msg/s ║ %4dms %3d msg/s ║ %-6s ║\n" \
"Parallel Send (${TOTAL_PAR}msg)" "$Y_PAR_MS" "$Y_PAR_RPS" "$O_PAR_MS" "$O_PAR_RPS" \
"$([ $Y_PAR_RPS -ge $O_PAR_RPS ] && echo 'YMQ' || echo 'OURS')"
printf "║ %-24s ║ %4dms %2d/%2d ║ %4dms %2d/%2d ║ %-6s ║\n" \
"Burst (${BURST} simultaneous)" "$Y_BURST_MS" "$Y_BURST_OK" "$BURST" "$O_BURST_MS" "$O_BURST_OK" "$BURST" \
"$([ $Y_BURST_MS -le $O_BURST_MS ] && echo 'YMQ' || echo 'OURS')"
printf "║ %-24s ║ %4dms avg ║ %4dms avg ║ %-6s ║\n" \
"GetQueueAttributes (5x)" "$Y_ATTR_AVG" "$O_ATTR_AVG" \
"$([ $Y_ATTR_AVG -le $O_ATTR_AVG ] && echo 'YMQ' || echo 'OURS')"
echo "╚══════════════════════════╩══════════════════╩══════════════════╩════════╝"
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Сравнение shared-SQS vs Yandex YMQ (очередь newsqs).
Короткий замер: последовательные операции, N send → N receive+delete.
Метрики: p50/p95/max латентности send/receive/delete, число ошибок.
Требуемые переменные окружения:
SHARED_AK, SHARED_SK — креды shared-SQS
YMQ_AK, YMQ_SK — креды Yandex YMQ (SA newsqs)
YMQ_QUEUE_URL — URL очереди Яндекса (newsqs)
Запуск: python3 tests/compare_ymq_vs_shared.py [N]
"""
import os
import sys
import time
import uuid
import boto3
from botocore.config import Config
N = int(sys.argv[1]) if len(sys.argv) > 1 else 50
BODY = "x" * 512
SHARED_EP = "https://sqs.containerk8s.dev.nubes.ru"
YMQ_EP = "https://message-queue.api.cloud.yandex.net"
SHARED_AK = os.environ["SHARED_AK"]
SHARED_SK = os.environ["SHARED_SK"]
YMQ_AK = os.environ["YMQ_AK"]
YMQ_SK = os.environ["YMQ_SK"]
YMQ_QUEUE_URL = os.environ["YMQ_QUEUE_URL"]
CFG = Config(connect_timeout=15, read_timeout=30, retries={"max_attempts": 0})
def mk_client(endpoint, region, ak, sk):
return boto3.client(
"sqs",
endpoint_url=endpoint,
region_name=region,
aws_access_key_id=ak,
aws_secret_access_key=sk,
config=CFG,
)
def stats(name, values):
v = sorted(values)
if not v:
print("%s: n=0" % name)
return
n = len(v)
print("%s: n=%d p50=%.0fms p95=%.0fms max=%.0fms" %
(name, n, v[n // 2] * 1000, v[int(n * .95)] * 1000, v[-1] * 1000))
def bench(client, queue_url, label):
errs = 0
lat_send, lat_recv, lat_del = [], [], []
t_start = time.time()
for i in range(N):
t0 = time.time()
try:
client.send_message(QueueUrl=queue_url, MessageBody=BODY)
lat_send.append(time.time() - t0)
except Exception as e:
errs += 1
if errs <= 3:
print(" send err: %s %s" % (type(e).__name__, str(e)[:80]))
got = 0
tries = 0
while got < N and tries < N * 6:
tries += 1
t0 = time.time()
try:
r = client.receive_message(
QueueUrl=queue_url, MaxNumberOfMessages=1, VisibilityTimeout=30)
lat_recv.append(time.time() - t0)
msgs = r.get("Messages", [])
except Exception as e:
errs += 1
if errs <= 3:
print(" recv err: %s %s" % (type(e).__name__, str(e)[:80]))
continue
for m in msgs:
got += 1
t0 = time.time()
try:
client.delete_message(QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"])
lat_del.append(time.time() - t0)
except Exception as e:
errs += 1
if errs <= 3:
print(" del err: %s %s" % (type(e).__name__, str(e)[:80]))
total = time.time() - t_start
print("== %s (N=%d) ==" % (label, N))
stats(" send ", lat_send)
stats(" receive", lat_recv)
stats(" delete ", lat_del)
print(" received=%d/%d errors=%d total=%.1fs" % (got, N, errs, total))
print(" throughput: send=%.1f op/s, recv+del=%.1f op/s" %
(N / total if total else 0, got / total if total else 0))
return errs
def drain(client, queue_url):
while True:
r = client.receive_message(QueueUrl=queue_url, MaxNumberOfMessages=10)
msgs = r.get("Messages", [])
if not msgs:
break
for m in msgs:
try:
client.delete_message(QueueUrl=queue_url, ReceiptHandle=m["ReceiptHandle"])
except Exception:
pass
def main():
shared = mk_client(SHARED_EP, "us-east-1", SHARED_AK, SHARED_SK)
ymq = mk_client(YMQ_EP, "ru-central1", YMQ_AK, YMQ_SK)
# Временная очередь shared-SQS
qname = "cmp-%s" % uuid.uuid4().hex[:8]
shared_url = shared.create_queue(QueueName=qname)["QueueUrl"]
print("shared queue: %s" % shared_url)
# YMQ newsqs: перед тестом чистим
drain(ymq, YMQ_QUEUE_URL)
print("ymq queue: %s (drained)" % YMQ_QUEUE_URL)
print()
e1 = bench(shared, shared_url, "shared-SQS (наш сервис)")
print()
e2 = bench(ymq, YMQ_QUEUE_URL, "Yandex YMQ (newsqs)")
# Очистка
drain(ymq, YMQ_QUEUE_URL)
try:
shared.delete_queue(QueueUrl=shared_url)
print("\ncleanup: shared temp queue deleted, ymq drained")
except Exception as ex:
print("\ncleanup err: %s" % str(ex)[:80])
print("\nRESULT: errors shared=%d ymq=%d" % (e1, e2))
if __name__ == "__main__":
main()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Детерминированная проверка DLQ-переноса (без гонок тайминга):
после каждой доставки ждём истечения visibility + тик PeriodicTasks (2.5с).
Если 5/5 PASS — сервис корректен, флап fifo_dlq_probe — гонка теста."""
import json
import time
import uuid
import boto3
from botocore.config import Config
ENDPOINT = "https://sqs.containerk8s.dev.nubes.ru"
REGION = "us-east-1"
sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION,
config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 0}))
def drain(url, n=10):
msgs = []
while True:
b = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=n).get("Messages", [])
if not b:
break
msgs.extend(b)
return msgs
def run(i):
uid = str(uuid.uuid4())[:8]
dlq_name = "dlqdet-%s-%d" % (uid, i)
main_name = "maindet-%s-%d" % (uid, i)
dlq_url = sqs.create_queue(QueueName=dlq_name)["QueueUrl"]
tenant = dlq_url.split("/")[-2]
arn = "arn:aws:sqs:%s:%s:%s" % (REGION, tenant, dlq_name)
policy = json.dumps({"deadLetterTargetArn": arn, "maxReceiveCount": "2"})
main_url = sqs.create_queue(
QueueName=main_name,
Attributes={"VisibilityTimeout": "1", "RedrivePolicy": policy},
)["QueueUrl"]
sqs.send_message(QueueUrl=main_url, MessageBody="to-dlq")
r1 = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
time.sleep(2.5) # visibility истёк + тик вернул (Retry=1)
r2 = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
time.sleep(2.5) # visibility истёк + тик: Retry=2 >= maxReceiveCount=2 -> DLQ
r3 = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
time.sleep(2.0)
dlq_msgs = drain(dlq_url)
main_left = drain(main_url)
ok = (len(r1) == 1 and len(r2) == 1 and len(r3) == 0
and len(dlq_msgs) == 1 and dlq_msgs[0]["Body"] == "to-dlq"
and len(main_left) == 0)
print("run %d: r1=%d r2=%d r3=%d dlq=%d main_left=%d -> %s"
% (i, len(r1), len(r2), len(r3), len(dlq_msgs), len(main_left),
"PASS" if ok else "FAIL"), flush=True)
try:
sqs.delete_queue(QueueUrl=main_url)
sqs.delete_queue(QueueUrl=dlq_url)
except Exception:
pass
return ok
ok = 0
for i in range(1, 6):
try:
if run(i):
ok += 1
except Exception as e:
print("run %d: EXC %s %s" % (i, type(e).__name__, str(e)[:120]), flush=True)
print("TOTAL: %d/5 PASS" % ok, flush=True)
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""tests/fifo_dlq_probe.py — e2e: FIFO порядок + dedup внутри окна + DLQ (план Соннета P2).
Проверки:
1. FIFO: 10 сообщений в одну группу выдаются строго по порядку.
2. FIFO dedup: два send с одинаковым MessageDeduplicationId внутри окна
(5 мин) → в очереди оказывается ОДНО сообщение.
3. DLQ: сообщение после MaxReceiveCount=2 попыток (VisibilityTimeout=1с)
переносится в dead-letter очередь.
"""
import json
import os
import sys
import time
import uuid
import boto3
from botocore.config import Config
ENDPOINT = os.environ.get("ENDPOINT_URL", "https://sqs.containerk8s.dev.nubes.ru")
REGION = os.environ.get("REGION", "us-east-1")
sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION,
config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 3}))
uid = str(uuid.uuid4())[:8]
def drain(url, n=10):
msgs = []
while True:
b = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=n).get("Messages", [])
if not b:
break
msgs.extend(b)
return msgs
results = {}
def check_fifo_order():
q = "fifo-order-%s.fifo" % uid
u = sqs.create_queue(QueueName=q, Attributes={"FifoQueue": "true"})["QueueUrl"]
for i in range(10):
sqs.send_message(QueueUrl=u, MessageBody="m%d" % i, MessageGroupId="g", MessageDeduplicationId="d%d" % (i,))
got = []
while True:
b = sqs.receive_message(QueueUrl=u, MaxNumberOfMessages=10).get("Messages", [])
if not b:
break
got.extend(b)
for m in b:
sqs.delete_message(QueueUrl=u, ReceiptHandle=m["ReceiptHandle"])
bodies = [m["Body"] for m in got]
want = ["m%d" % i for i in range(10)]
results["fifo_order"] = bodies == want
print("fifo_order: got=%s want=%s %s" % (bodies, want, "PASS" if bodies == want else "FAIL"), flush=True)
sqs.delete_queue(QueueUrl=u)
def check_fifo_dedup():
q = "fifo-dedup-%s.fifo" % uid
u = sqs.create_queue(QueueName=q, Attributes={"FifoQueue": "true"})["QueueUrl"]
sqs.send_message(QueueUrl=u, MessageBody="first", MessageGroupId="g", MessageDeduplicationId="SAME")
sqs.send_message(QueueUrl=u, MessageBody="dup", MessageGroupId="g", MessageDeduplicationId="SAME")
msgs = drain(u)
ok = len(msgs) == 1 and msgs[0]["Body"] == "first"
results["fifo_dedup"] = ok
print("fifo_dedup: count=%d body=%s %s" % (len(msgs), msgs[0]["Body"] if msgs else None,
"PASS" if ok else "FAIL"), flush=True)
sqs.delete_queue(QueueUrl=u)
def check_dlq():
dlq_name = "fifo-dlq-%s" % uid
main_name = "fifo-main-%s" % uid
dlq_url = sqs.create_queue(QueueName=dlq_name)["QueueUrl"]
# tenantID из URL DLQ: http://us-east-1.goaws.com:4100/t-<id>/<name>
tenant = dlq_url.split("/")[-2]
arn = "arn:aws:sqs:%s:%s:%s" % (REGION, tenant, dlq_name)
policy = json.dumps({"deadLetterTargetArn": arn, "maxReceiveCount": "2"})
main_url = sqs.create_queue(
QueueName=main_name,
Attributes={"VisibilityTimeout": "1", "RedrivePolicy": policy},
)["QueueUrl"]
sqs.send_message(QueueUrl=main_url, MessageBody="to-dlq")
for attempt in range(3):
b = sqs.receive_message(QueueUrl=main_url, MaxNumberOfMessages=1).get("Messages", [])
print("attempt %d: %s" % (attempt + 1, "received" if b else "empty"), flush=True)
time.sleep(1.5)
# Перенос в DLQ выполняет фоновый тик PeriodicTasks (период 1с) при
# истечении VisibilityTimeout. Мгновенная проверка гоняет с фазой тика —
# опрашиваем DLQ до 10с.
dlq_msgs = []
for _ in range(10):
dlq_msgs = drain(dlq_url)
if dlq_msgs:
break
time.sleep(1)
ok = len(dlq_msgs) == 1 and dlq_msgs[0]["Body"] == "to-dlq"
results["dlq"] = ok
print("dlq: count=%d body=%s %s" % (len(dlq_msgs), dlq_msgs[0]["Body"] if dlq_msgs else None,
"PASS" if ok else "FAIL"), flush=True)
sqs.delete_queue(QueueUrl=main_url)
sqs.delete_queue(QueueUrl=dlq_url)
def main():
try:
check_fifo_order()
except Exception as e:
results["fifo_order"] = False
print("fifo_order: EXC %s %s" % (type(e).__name__, str(e)[:120]), flush=True)
try:
check_fifo_dedup()
except Exception as e:
results["fifo_dedup"] = False
print("fifo_dedup: EXC %s %s" % (type(e).__name__, str(e)[:120]), flush=True)
try:
check_dlq()
except Exception as e:
results["dlq"] = False
print("dlq: EXC %s %s" % (type(e).__name__, str(e)[:120]), flush=True)
print("SUMMARY: %s" % json.dumps(results), flush=True)
return 0 if all(results.values()) else 1
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Временный замер: точная копия invalid_cases из load_test.py + подсчёт кодов.
Цель: выяснить фактическое распределение кодов в самом тесте."""
import boto3
import collections
import random
from botocore.config import Config
from botocore.exceptions import ClientError
ENDPOINT = "https://sqs.containerk8s.dev.nubes.ru"
sqs = boto3.client(
"sqs",
endpoint_url=ENDPOINT,
region_name="us-east-1",
config=Config(connect_timeout=10, read_timeout=25, retries={"max_attempts": 2}),
)
res = collections.Counter()
def invalid_cases():
cases = []
cases.append(lambda: sqs.send_message(QueueUrl=ENDPOINT + "/no-such-queue", MessageBody="x"))
cases.append(lambda: sqs.receive_message(QueueUrl=ENDPOINT + "/no-such-queue"))
cases.append(lambda: sqs.delete_queue(QueueUrl=ENDPOINT + "/no-such-queue"))
cases.append(lambda: sqs.get_queue_url(QueueName="no-such-queue-load-test"))
cases.append(lambda: sqs.delete_message(QueueUrl=ENDPOINT + "/no-such-queue", ReceiptHandle="broken"))
q = sqs.create_queue(QueueName="invalid-%d" % random.randint(0, 10**9))["QueueUrl"]
try:
cases.append(lambda: sqs.set_queue_attributes(QueueUrl=q, Attributes={"VisibilityTimeout": "43201"}))
cases.append(lambda: sqs.set_queue_attributes(QueueUrl=q, Attributes={"VisibilityTimeout": "not-a-number"}))
cases.append(lambda: sqs.set_queue_attributes(QueueUrl=q, Attributes={"BadAttribute": "1"}))
def purge_twice():
sqs.purge_queue(QueueUrl=q)
sqs.purge_queue(QueueUrl=q)
cases.append(purge_twice)
cases.append(lambda: sqs.send_message_batch(QueueUrl=q, Entries=[{"Id": str(i), "MessageBody": "x"} for i in range(11)]))
cases.append(lambda: sqs.send_message_batch(QueueUrl=q, Entries=[{"Id": "dup", "MessageBody": "a"}, {"Id": "dup", "MessageBody": "b"}]))
cases.append(lambda: sqs.send_message_batch(QueueUrl=q, Entries=[]))
cases.append(lambda: sqs.send_message(QueueUrl=q, MessageBody=""))
cases.append(lambda: sqs.send_message(QueueUrl=q, MessageBody="x" * (300 * 1024)))
fq = sqs.create_queue(QueueName="invalid-%d.fifo" % random.randint(0, 10**9), Attributes={"FifoQueue": "true"})["QueueUrl"]
try:
cases.append(lambda: sqs.send_message(QueueUrl=fq, MessageBody="x", MessageDeduplicationId="d"))
finally:
sqs.delete_queue(QueueUrl=fq)
finally:
sqs.delete_queue(QueueUrl=q)
for c in cases:
try:
c()
res["PASS-не отклонено"] += 1
except ClientError as e:
res[e.response.get("Error", {}).get("Code", "Unknown")] += 1
except Exception as e:
res[type(e).__name__] += 1
N = 100
for _ in range(N):
invalid_cases()
print("cycles=%d" % N)
for k, v in sorted(res.items(), key=lambda x: -x[1]):
print("%s: %d" % (k, v))
+407
View File
@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""tests/load_test.py — длительная нагрузочная проверка устойчивости shared-sqs.
Режимы работы в каждом цикле:
1) Корректные операции: create/get-url/attributes/send/batch/receive(long poll)/
change-visibility/delete/batch-delete/tags/purge/delete-queue + FIFO.
2) Крайние условия: сообщения до 48КБ (большие POST-тела зависают на платформе
~51с из-за MTU — см. HISTORY, раздел «51с MTU drhider»), юникод, пустые тела,
пакеты по 10, сообщение > лимита (очередь с MaximumMessageSize=1024 +
тело 2KB — ожидается отказ MessageTooBig).
3) Некорректные условия (ожидаемые отказы с известными кодами):
несуществующая очередь, битый ReceiptHandle, некорректные атрибуты,
повторный purge, >10 записей в batch, дубли Id в batch, пустой batch,
FIFO без MessageGroupId, некорректные имена очередей.
Контроль: отдельный поток каждые 10с проверяет GET /health.
Каждые 30с — строка прогресса. В конце — сводка по кодам ошибок и латентности.
Переменные окружения:
ENDPOINT_URL (по умолчанию https://sqs.containerk8s.dev.nubes.ru)
REGION (us-east-1)
DURATION_SECONDS (1800 = 30 минут)
WORKERS (4)
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY — обязательны
"""
import json
import os
import random
import string
import sys
import threading
import time
import urllib.request
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
ENDPOINT = os.environ.get("ENDPOINT_URL", "https://sqs.containerk8s.dev.nubes.ru")
REGION = os.environ.get("REGION", "us-east-1")
DURATION = int(os.environ.get("DURATION_SECONDS", "1800"))
WORKERS = int(os.environ.get("WORKERS", "4"))
HEALTH_EVERY = 10
# Коды ошибок, которые ожидаемы для некорректных условий.
EXPECTED_CODES = {
"NonExistentQueue",
"QueueDeletedRecently",
"PurgeQueueInProgress",
"ReceiptHandleIsInvalid",
"InvalidParameterValue",
"InvalidAttributeValue",
"InvalidAttributeName",
"MissingParameter",
"ReadCountOutOfRange",
"TooManyEntriesInBatchRequest",
"BatchEntryIdsNotDistinct",
"EmptyBatchRequest",
"InvalidBatchEntryId",
"MessageTooLong",
"InvalidMessageContents",
"AWS.SimpleQueueService.NonExistentQueue",
"AWS.SimpleQueueService.InvalidParameterValue",
"AWS.SimpleQueueService.InvalidAttributeName",
"AWS.SimpleQueueService.InvalidAttributeValue",
"AWS.SimpleQueueService.PurgeQueueInProgress",
"AWS.SimpleQueueService.TooManyEntriesInBatchRequest",
"AWS.SimpleQueueService.BatchEntryIdsNotDistinct",
"AWS.SimpleQueueService.EmptyBatchRequest",
"AWS.SimpleQueueService.InvalidBatchEntryId",
}
stats = {
"valid_ok": 0,
"expected_err": 0,
"unexpected_fail": 0,
"integrity_fail": 0,
"soft_warn": 0, # некорректный запрос неожиданно прошёл
"health_fail": 0,
"ops_total": 0,
"err_by_code": {},
}
stats_lock = threading.Lock()
start_ts = time.time()
stop = threading.Event()
LATENCY = []
LATENCY_LOCK = threading.Lock()
def add_latency(sec):
with LATENCY_LOCK:
LATENCY.append(sec)
if len(LATENCY) > 100000:
del LATENCY[:50000]
def bump(key, n=1, code=None):
with stats_lock:
stats[key] += n
stats["ops_total"] += n
if code:
stats["err_by_code"][code] = stats["err_by_code"].get(code, 0) + 1
def record_expected(e: ClientError):
code = e.response.get("Error", {}).get("Code", "Unknown")
bump("expected_err", code=code)
def record_unexpected(e):
code = getattr(e, "response", None) and e.response.get("Error", {}).get("Code", "Unknown") or type(e).__name__
bump("unexpected_fail", code=code)
def rand_body(max_size=48 * 1024):
size = random.randint(0, max_size)
return "".join(random.choices(string.ascii_letters + "абвгд\x00\xff😀", k=size))
def make_client():
cfg = Config(connect_timeout=10, read_timeout=25, retries={"max_attempts": 2})
return boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION, config=cfg)
def health_monitor():
while not stop.is_set():
try:
with urllib.request.urlopen(
urllib.request.Request(ENDPOINT + "/health", headers={"User-Agent": "load-test"}),
timeout=10,
) as r:
body = r.read().decode("utf-8", "replace")
if r.status != 200 or '"status":"ok"' not in body:
bump("health_fail")
except Exception:
bump("health_fail")
stop.wait(HEALTH_EVERY)
def valid_cycle(sqs, wid, it):
q = f"load-{wid}-{it}-{random.randint(0, 10**9)}"
url = sqs.create_queue(QueueName=q)["QueueUrl"]
try:
assert sqs.get_queue_url(QueueName=q)["QueueUrl"] == url
# сообщение произвольного размера до 48КБ, включая юникод
body = rand_body()
sqs.send_message(QueueUrl=url, MessageBody=body)
bump("valid_ok")
# batch до 10
entries = [{"Id": str(i), "MessageBody": f"b{i}-{random.randint(0, 10**6)}"} for i in range(random.randint(1, 10))]
r = sqs.send_message_batch(QueueUrl=url, Entries=entries)
assert len(r["Successful"]) == len(entries)
# receive с long poll, сверка целостности
msgs = None
for _ in range(6):
msgs = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10, WaitTimeSeconds=random.choice([1, 5, 20])).get("Messages", [])
if msgs:
break
time.sleep(1)
if not msgs:
raise AssertionError("сообщения не получены")
bodies = {m["Body"] for m in msgs}
if body not in bodies:
# тело могло уйти в другой receive предыдущего потока — считаем предупреждением
bump("soft_warn")
# change visibility + delete
sqs.change_message_visibility(QueueUrl=url, ReceiptHandle=msgs[0]["ReceiptHandle"], VisibilityTimeout=1)
sqs.delete_message(QueueUrl=url, ReceiptHandle=msgs[0]["ReceiptHandle"])
handles = [{"Id": str(i), "ReceiptHandle": m["ReceiptHandle"]} for i, m in enumerate(msgs[1:])]
if handles:
sqs.delete_message_batch(QueueUrl=url, Entries=handles)
# теги
sqs.tag_queue(QueueUrl=url, Tags={"load": "1"})
sqs.list_queue_tags(QueueUrl=url)
sqs.untag_queue(QueueUrl=url, TagKeys=["load"])
# атрибуты
attrs = sqs.get_queue_attributes(QueueUrl=url, AttributeNames=["All"])["Attributes"]
assert "VisibilityTimeout" in attrs
sqs.set_queue_attributes(QueueUrl=url, Attributes={"VisibilityTimeout": str(random.randint(0, 43200))})
finally:
try:
sqs.delete_queue(QueueUrl=url)
except ClientError:
pass
def fifo_cycle(sqs, wid, it):
q = f"load-{wid}-{it}.fifo"
url = sqs.create_queue(QueueName=q, Attributes={"FifoQueue": "true"})["QueueUrl"]
try:
sqs.send_message(QueueUrl=url, MessageBody="fifo", MessageGroupId="g", MessageDeduplicationId=str(it))
# дубль с тем же DedupId — сервис должен не создать дубль (допустим любой из ответов)
sqs.send_message(QueueUrl=url, MessageBody="fifo-dup", MessageGroupId="g", MessageDeduplicationId=str(it))
msgs = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10).get("Messages", [])
if msgs:
sqs.delete_message(QueueUrl=url, ReceiptHandle=msgs[0]["ReceiptHandle"])
bump("valid_ok")
finally:
try:
sqs.delete_queue(QueueUrl=url)
except ClientError:
pass
def invalid_cases(sqs):
cases = []
# несуществующая очередь
cases.append(lambda: sqs.send_message(QueueUrl=ENDPOINT + "/no-such-queue", MessageBody="x"))
cases.append(lambda: sqs.receive_message(QueueUrl=ENDPOINT + "/no-such-queue"))
cases.append(lambda: sqs.delete_queue(QueueUrl=ENDPOINT + "/no-such-queue"))
cases.append(lambda: sqs.get_queue_url(QueueName="no-such-queue-load-test"))
# битый ReceiptHandle
cases.append(lambda: sqs.delete_message(QueueUrl=ENDPOINT + "/no-such-queue", ReceiptHandle="broken"))
# ФИКС: раньше delete_queue стоял в finally и выполнялся ДО прогона сценариев —
# сценарии атрибутов/batch/purge/300KB/FIFO тестировали УДАЛЁННУЮ очередь
# (NonExistentQueue) вместо заявленных нарушений. Теперь очереди создаются
# до прогона и удаляются ПОСЛЕ него.
q = None
fq = None
qlow = None
try:
# некорректные атрибуты
q = sqs.create_queue(QueueName=f"invalid-{random.randint(0, 10**9)}")["QueueUrl"]
cases.append(lambda: sqs.set_queue_attributes(QueueUrl=q, Attributes={"VisibilityTimeout": "43201"}))
cases.append(lambda: sqs.set_queue_attributes(QueueUrl=q, Attributes={"VisibilityTimeout": "not-a-number"}))
cases.append(lambda: sqs.set_queue_attributes(QueueUrl=q, Attributes={"BadAttribute": "1"}))
# повторный purge
def purge_twice():
sqs.purge_queue(QueueUrl=q)
sqs.purge_queue(QueueUrl=q)
cases.append(purge_twice)
# batch-нарушения
cases.append(lambda: sqs.send_message_batch(QueueUrl=q, Entries=[{"Id": str(i), "MessageBody": "x"} for i in range(11)]))
cases.append(lambda: sqs.send_message_batch(QueueUrl=q, Entries=[{"Id": "dup", "MessageBody": "a"}, {"Id": "dup", "MessageBody": "b"}]))
cases.append(lambda: sqs.send_message_batch(QueueUrl=q, Entries=[]))
# пустое тело
cases.append(lambda: sqs.send_message(QueueUrl=q, MessageBody=""))
# превышение лимита размера: очередь с MaximumMessageSize=1024 +
# тело 2KB. Граничное условие (MessageTooBig) сохраняется, но большое
# тело по сети НЕ шлётся — иначе платформа вешает запрос на ~51с (MTU).
qlow = sqs.create_queue(QueueName=f"invalid-{random.randint(0, 10**9)}", Attributes={"MaximumMessageSize": "1024"})["QueueUrl"]
cases.append(lambda: sqs.send_message(QueueUrl=qlow, MessageBody="x" * 2048))
# FIFO без MessageGroupId
fq = sqs.create_queue(QueueName=f"invalid-{random.randint(0, 10**9)}.fifo", Attributes={"FifoQueue": "true"})["QueueUrl"]
cases.append(lambda: sqs.send_message(QueueUrl=fq, MessageBody="x", MessageDeduplicationId="d"))
for c in cases:
t0 = time.time()
try:
c()
bump("soft_warn")
except ClientError as e:
add_latency(time.time() - t0)
code = e.response.get("Error", {}).get("Code", "Unknown")
if code in EXPECTED_CODES:
record_expected(e)
else:
record_unexpected(e)
except Exception as e:
record_unexpected(e)
finally:
# Очереди удаляются ПОСЛЕ прогона сценариев.
if qlow is not None:
try:
sqs.delete_queue(QueueUrl=qlow)
except ClientError:
pass
if fq is not None:
try:
sqs.delete_queue(QueueUrl=fq)
except ClientError:
pass
if q is not None:
try:
sqs.delete_queue(QueueUrl=q)
except ClientError:
pass
def worker(wid):
sqs = make_client()
it = 0
while not stop.is_set():
it += 1
t0 = time.time()
try:
valid_cycle(sqs, wid, it)
add_latency(time.time() - t0)
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "Unknown")
if code in EXPECTED_CODES:
record_expected(e)
else:
record_unexpected(e)
except Exception as e:
record_unexpected(e)
t0 = time.time()
try:
fifo_cycle(sqs, wid, it)
add_latency(time.time() - t0)
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "Unknown")
if code in EXPECTED_CODES:
record_expected(e)
else:
record_unexpected(e)
except Exception as e:
record_unexpected(e)
try:
invalid_cases(sqs)
except Exception as e:
record_unexpected(e)
time.sleep(random.uniform(0.01, 0.05))
def cleanup_all_queues(sqs):
"""Удаляет ВСЕ очереди тенанта.
Вызывается ПЕРЕД прогоном (стартовый мусор прошлых прогонов) и ПОСЛЕ
(остатки от циклов, упавших на сбое — без этого копятся очереди и
тест упирается в лимит MaxQueues тенанта)."""
try:
urls = sqs.list_queues().get("QueueUrls", [])
except Exception:
urls = []
deleted = 0
for u in urls:
try:
sqs.delete_queue(QueueUrl=u)
deleted += 1
except Exception:
pass
if deleted:
print(f"cleanup: удалено очередей {deleted}", flush=True)
def progress_printer():
while not stop.is_set():
stop.wait(30)
with stats_lock:
s = dict(stats)
el = int(time.time() - start_ts)
print(f"[{el:>5}s] ops={s['ops_total']} ok={s['valid_ok']} "
f"expected_err={s['expected_err']} unexpected={s['unexpected_fail']} "
f"soft_warn={s['soft_warn']} health_fail={s['health_fail']}", flush=True)
def main():
print(f"Нагрузочный тест: {ENDPOINT}, длительность {DURATION}s, воркеров {WORKERS}", flush=True)
if not os.environ.get("AWS_ACCESS_KEY_ID") or not os.environ.get("AWS_SECRET_ACCESS_KEY"):
print("Нужны AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY")
return 1
# Чистим стартовый мусор прошлых прогонов.
cleanup_all_queues(make_client())
threads = [threading.Thread(target=worker, args=(i,), daemon=True) for i in range(WORKERS)]
hthread = threading.Thread(target=health_monitor, daemon=True)
pthread = threading.Thread(target=progress_printer, daemon=True)
for t in threads:
t.start()
hthread.start()
pthread.start()
stop.wait(DURATION)
stop.set()
for t in threads:
t.join(timeout=30)
# Чистим остатки: очереди от циклов, упавших на сбоях.
cleanup_all_queues(make_client())
el = int(time.time() - start_ts)
with stats_lock:
s = dict(stats)
print(f"\n=== Завершено через {el}s ===", flush=True)
print(f"Операций всего: {s['ops_total']}")
print(f"Корректных (ok): {s['valid_ok']}")
print(f"Ожидаемых отказов: {s['expected_err']}")
print(f"НЕОЖИДАННЫХ отказов: {s['unexpected_fail']}")
print(f"Некорректное прошло: {s['soft_warn']}")
print(f"Сбоев /health: {s['health_fail']}")
if s["err_by_code"]:
print("Коды ошибок:")
for code, n in sorted(s["err_by_code"].items(), key=lambda x: -x[1]):
print(f" {code}: {n}")
with LATENCY_LOCK:
if LATENCY:
print(f"Латентность операций: min={min(LATENCY):.3f}s avg={sum(LATENCY)/len(LATENCY):.3f}s max={max(LATENCY):.3f}s")
verdict = "УСТОЙЧИВО" if s["unexpected_fail"] == 0 and s["health_fail"] == 0 else "ЕСТЬ ПРОБЛЕМЫ"
print(f"Вердикт: {verdict}")
return 0 if verdict == "УСТОЙЧИВО" else 1
if __name__ == "__main__":
sys.exit(main())
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""ДЛИННОЕ сравнение shared-SQS vs Yandex YMQ — с ЛОКАЛИ, по таймеру.
Схема (честная — обе очереди в одних сетевых условиях, раунды попеременно):
1. Старт: seed K сообщений в каждую очередь.
2. Раунд для каждой очереди: receive(1) -> delete -> send(1) (восполнение пула).
3. Раунды чередуются (наш, яндекс, наш, яндекс...) до истечения DURATION.
Метрики: p50/p95/max для send/receive/delete, ошибки по типам (с таймаутами),
op/s. Прогресс каждые 60с пишется в LOG и stdout.
Env: SHARED_AK, SHARED_SK, YMQ_AK, YMQ_SK, YMQ_QUEUE_URL, DURATION (сек, default 1800)
"""
import os
import sys
import time
import uuid
import boto3
from botocore.config import Config
DURATION = int(os.environ.get("DURATION", "1800"))
K = 10
BODY = "x" * 512
LOG = "tests/long_compare.log"
SHARED_EP = "https://sqs.containerk8s.dev.nubes.ru"
YMQ_EP = "https://message-queue.api.cloud.yandex.net"
CFG = Config(connect_timeout=15, read_timeout=30, retries={"max_attempts": 0})
def mk(endpoint, region, ak, sk):
return boto3.client("sqs", endpoint_url=endpoint, region_name=region,
aws_access_key_id=ak, aws_secret_access_key=sk, config=CFG)
class Stat:
def __init__(self):
self.vals = {"send": [], "receive": [], "delete": []}
self.errs = {} # type -> count
self.ops = {"send": 0, "receive": 0, "delete": 0}
def op(self, name, fn):
self.ops[name] += 1
t0 = time.time()
try:
fn()
self.vals[name].append(time.time() - t0)
except Exception as e:
k = type(e).__name__
self.errs[k] = self.errs.get(k, 0) + 1
def stat_line(st, label):
out = [label]
for name in ("send", "receive", "delete"):
v = sorted(st.vals[name])
n = len(v)
if n:
out.append("%s: n=%d p50=%.0fms p95=%.0fms max=%.0fms" %
(name, n, v[n // 2] * 1000, v[int(n * .95)] * 1000, v[-1] * 1000))
else:
out.append("%s: n=0" % name)
out.append("errors=%s" % (st.errs if st.errs else "0"))
return " | ".join(out)
def logp(msg):
line = "%s %s" % (time.strftime("%H:%M:%S"), msg)
print(line, flush=True)
try:
with open(LOG, "a") as f:
f.write(line + "\n")
except Exception:
pass
def main():
shared = mk(SHARED_EP, "us-east-1", os.environ["SHARED_AK"], os.environ["SHARED_SK"])
ymq = mk(YMQ_EP, "ru-central1", os.environ["YMQ_AK"], os.environ["YMQ_SK"])
ymq_url = os.environ["YMQ_QUEUE_URL"]
# временная очередь shared
qname = "longcmp-%s" % uuid.uuid4().hex[:8]
shared_url = shared.create_queue(QueueName=qname)["QueueUrl"]
def drain(client, url):
while True:
try:
r = client.receive_message(QueueUrl=url, MaxNumberOfMessages=10)
msgs = r.get("Messages", [])
except Exception:
break
if not msgs:
break
for m in msgs:
try:
client.delete_message(QueueUrl=url, ReceiptHandle=m["ReceiptHandle"])
except Exception:
pass
drain(shared, shared_url)
drain(ymq, ymq_url)
ss = Stat()
ys = Stat()
sts = {"shared": (shared, shared_url, ss), "ymq": (ymq, ymq_url, ys)}
# seed K в обе очереди
for key in ("shared", "ymq"):
c, u, st = sts[key]
for _ in range(K):
st.op("send", lambda c=c, u=u: c.send_message(QueueUrl=u, MessageBody=BODY))
logp("LONG COMPARE START: duration=%ds K=%d shared=%s" % (DURATION, K, shared_url))
logp("ymq=%s" % ymq_url)
deadline = time.time() + DURATION
last_prog = time.time()
rounds = 0
def round_for(key):
nonlocal rounds
c, u, st = sts[key]
# receive (замер) + получить сообщения для delete
got = []
t0 = time.time()
st.ops["receive"] += 1
try:
r = c.receive_message(QueueUrl=u, MaxNumberOfMessages=1, VisibilityTimeout=30)
st.vals["receive"].append(time.time() - t0)
got = r.get("Messages", [])
except Exception as e:
st.errs[type(e).__name__] = st.errs.get(type(e).__name__, 0) + 1
for m in got:
st.op("delete", lambda c=c, u=u, m=m: c.delete_message(QueueUrl=u, ReceiptHandle=m["ReceiptHandle"]))
st.op("send", lambda c=c, u=u: c.send_message(QueueUrl=u, MessageBody=BODY))
while time.time() < deadline:
round_for("shared")
round_for("ymq")
rounds += 1
if time.time() - last_prog >= 60:
last_prog = time.time()
logp("progress %d/%ds" % (int(time.time() - (deadline - DURATION)), DURATION))
logp(" " + stat_line(ss, "SHARED"))
logp(" " + stat_line(ys, "YMQ"))
logp("LONG COMPARE DONE: rounds=%d" % rounds)
logp(stat_line(ss, "FINAL SHARED"))
logp(stat_line(ys, "FINAL YMQ"))
# cleanup
drain(shared, shared_url)
drain(ymq, ymq_url)
try:
shared.delete_queue(QueueUrl=shared_url)
logp("cleanup: shared temp queue deleted, both drained")
except Exception as e:
logp("cleanup err: %s" % str(e)[:100])
if __name__ == "__main__":
main()
-104
View File
@@ -1,104 +0,0 @@
#!/bin/bash
# Updated: 2026-04-12 05:40 UTC
# payload_latency_probe.sh — узкий замер SendMessage latency для payload 10KB и 32KB.
set -euo pipefail
# ysqs выполняет aws sqs для Yandex MQ.
ysqs() {
AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" \
AWS_DEFAULT_REGION="$Y_REGION" \
aws --endpoint-url "$Y_EP" --output json sqs "$@" >/dev/null 2>&1
}
# osqs выполняет aws sqs для shared-sqs.
osqs() {
AWS_ACCESS_KEY_ID="$O_AK" AWS_SECRET_ACCESS_KEY="$O_SK" \
AWS_DEFAULT_REGION="$O_REGION" \
aws --endpoint-url "$O_EP" --output json sqs "$@" >/dev/null 2>&1
}
# ms_now возвращает текущее время в миллисекундах.
ms_now() {
date +%s%3N
}
# calc_stats считает min/avg/max/p95 по файлу latency.
calc_stats() {
local file_path="$1"
sort -n "$file_path" | awk '
{ values[NR] = $1; total += $1 }
END {
p95_index = int(NR * 0.95)
if (p95_index < 1) p95_index = 1
printf "%d %d %d %d\n", values[1], int(total / NR), values[NR], values[p95_index]
}'
}
# cleanup удаляет временные объекты после замера.
cleanup() {
set +e
if [[ -n "${Y_QURL:-}" ]]; then
AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" AWS_DEFAULT_REGION="$Y_REGION" \
aws --endpoint-url "$Y_EP" --output json sqs delete-queue --queue-url "$Y_QURL" >/dev/null 2>&1 || true
fi
if [[ -n "${O_QURL:-}" ]]; then
AWS_ACCESS_KEY_ID="$O_AK" AWS_SECRET_ACCESS_KEY="$O_SK" AWS_DEFAULT_REGION="$O_REGION" \
aws --endpoint-url "$O_EP" --output json sqs delete-queue --queue-url "$O_QURL" >/dev/null 2>&1 || true
fi
rm -rf "$TMPDIR"
}
trap cleanup EXIT
Y_AK="YCAJEQDz_Eg_i4C4M7TAen2fd"
Y_SK="YCMDfD8OKFK51knPyydwQOYts7Q81_3YBhv4sd_j"
Y_REGION="ru-central1"
Y_EP="https://message-queue.api.cloud.yandex.net"
O_AK="SSAK-ed0b0c64dcc135adad9e11be"
O_SK="f917c133e4ad74cf37c8e3ba29d74a39f42f117a202bd672436c92fdb6b6f3dc"
O_REGION="us-east-1"
O_EP="https://qu.kube5s.ru"
O_TID="t-e0ce25e83be94c58"
ITERATIONS=7
TMPDIR=$(mktemp -d /tmp/payload_latency_probe_XXXXX)
TS=$(date +%s)
head -c 10240 /dev/urandom | base64 | head -c 10240 > "$TMPDIR/payload_10k.txt"
head -c 32768 /dev/urandom | base64 | head -c 32768 > "$TMPDIR/payload_32k.txt"
Y_QNAME="payload-probe-y-${TS}"
O_QNAME="payload-probe-o-${TS}"
Y_CREATE_RAW=$(AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" AWS_DEFAULT_REGION="$Y_REGION" aws --endpoint-url "$Y_EP" --output json sqs create-queue --queue-name "$Y_QNAME")
Y_QURL=$(echo "$Y_CREATE_RAW" | python3 -c 'import json,sys; print(json.load(sys.stdin)["QueueUrl"])')
osqs create-queue --queue-name "$O_QNAME"
O_QURL="${O_EP}/${O_TID}/${O_QNAME}"
run_probe() {
local provider_name="$1"
local queue_url="$2"
local payload_file="$3"
local result_file="$4"
local runner="$5"
: > "$result_file"
for _ in $(seq 1 "$ITERATIONS"); do
local started_at
started_at=$(ms_now)
"$runner" send-message --queue-url "$queue_url" --message-body "file://$payload_file"
echo $(( $(ms_now) - started_at )) >> "$result_file"
done
local stats_line
stats_line=$(calc_stats "$result_file")
echo "${provider_name}|$(echo "$stats_line" | awk '{print $1"|"$2"|"$3"|"$4}')"
}
echo "payload_kb|provider|min|avg|max|p95"
run_probe "yandex" "$Y_QURL" "$TMPDIR/payload_10k.txt" "$TMPDIR/y_10k.txt" ysqs | awk -F'|' '{print "10|"$0}'
run_probe "shared-sqs" "$O_QURL" "$TMPDIR/payload_10k.txt" "$TMPDIR/o_10k.txt" osqs | awk -F'|' '{print "10|"$0}'
run_probe "yandex" "$Y_QURL" "$TMPDIR/payload_32k.txt" "$TMPDIR/y_32k.txt" ysqs | awk -F'|' '{print "32|"$0}'
run_probe "shared-sqs" "$O_QURL" "$TMPDIR/payload_32k.txt" "$TMPDIR/o_32k.txt" osqs | awk -F'|' '{print "32|"$0}'
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""tests/platform_probe.py — диагностика транспортного слоя платформы (HTTP контейнер).
Проверяет, как платформа (шлюз/ingress/DDoS-Guard) пропускает трафик к сервису,
НЕЗАВИСИМО от бизнес-логики SQS. Запускать и на ВМ, и на локальной машине
для сравнения путей. Только stdlib + boto3.
Блоки:
A — через /health: burst 500 параллельных, большие заголовки, медленное тело,
keep-alive x50, HTTP/1.0, HEAD, POST.
B — через SQS API (транспорт): SendMessage 32KB, Receive 10x32KB,
long-poll 20s (фактическая длительность), chunked-тело.
"""
import http.client
import json
import os
import socket
import ssl
import sys
import threading
import time
import uuid
import boto3
from botocore.config import Config
ENDPOINT = os.environ.get("ENDPOINT_URL", "https://sqs.containerk8s.dev.nubes.ru")
HOST = ENDPOINT.replace("https://", "").replace("http://", "").rstrip("/")
PORT = 443
REGION = os.environ.get("REGION", "us-east-1")
MODE = os.environ.get("PROBE_MODE", "unknown")
results = {}
def _conn():
c = http.client.HTTPSConnection(HOST, PORT, timeout=30)
return c
def record(name, value):
results[name] = value
print("%s: %s" % (name, json.dumps(value, ensure_ascii=False)), flush=True)
def http_get(path, headers=None, method="GET", body=None):
c = _conn()
try:
c.request(method, path, body=body, headers=headers or {})
r = c.getresponse()
data = r.read()
return r.status, dict(r.getheaders()), data
finally:
c.close()
# --- A1: burst 500 параллельных /health ---
def a1_burst():
n = 500
ok = err = 0
lat = []
lock = threading.Lock()
def one():
nonlocal ok, err
t0 = time.time()
try:
st, _, _ = http_get("/health")
with lock:
lat.append(time.time() - t0)
if st == 200:
ok += 1
else:
err += 1
except Exception:
with lock:
err += 1
threads = [threading.Thread(target=one) for _ in range(n)]
t0 = time.time()
for t in threads:
t.start()
for t in threads:
t.join()
total = time.time() - t0
lat.sort()
record("A1_burst_500", {
"ok": ok, "err": err, "total_s": round(total, 2),
"p50": round(lat[len(lat) // 2], 3) if lat else None,
"p95": round(lat[int(len(lat) * 0.95)], 3) if lat else None,
"max": round(lat[-1], 3) if lat else None,
})
# --- A2: большие заголовки ---
def a2_big_headers():
for kb in (8, 16, 64):
h = {"User-Agent": "platform-probe", "X-Pad": "x" * (kb * 1024)}
try:
st, _, _ = http_get("/health", headers=h)
record("A2_header_%dkb" % kb, st)
except Exception as e:
record("A2_header_%dkb" % kb, type(e).__name__ + ": " + str(e)[:80])
# --- A3: медленное тело (1 байт / 0.5с, Content-Length 10) ---
def a3_slow_body():
try:
ctx = ssl.create_default_context()
raw = ctx.wrap_socket(socket.create_connection((HOST, PORT), timeout=15),
server_hostname=HOST)
raw.sendall(b"POST /health HTTP/1.1\r\nHost: %s\r\nContent-Length: 10\r\nConnection: close\r\n\r\n" % HOST.encode())
t0 = time.time()
for _ in range(10):
raw.sendall(b"x")
time.sleep(0.5)
resp = raw.recv(65536)
dt = time.time() - t0
raw.close()
first_line = resp.split(b"\r\n", 1)[0].decode("latin1", "replace") if resp else "(no response)"
record("A3_slow_body", {"resp": first_line, "t_s": round(dt, 2)})
except Exception as e:
record("A3_slow_body", type(e).__name__ + ": " + str(e)[:80])
# --- A4: keep-alive 50 запросов на одном соединении ---
def a4_keepalive():
ok = err = 0
c = _conn()
try:
for _ in range(50):
try:
c.request("GET", "/health")
r = c.getresponse()
r.read()
if r.status == 200:
ok += 1
else:
err += 1
except Exception:
err += 1
break
finally:
c.close()
record("A4_keepalive_50", {"ok": ok, "err": err})
# --- A5: HTTP/1.0, HEAD, POST ---
def a5_variants():
try:
ctx = ssl.create_default_context()
raw = ctx.wrap_socket(socket.create_connection((HOST, PORT), timeout=15),
server_hostname=HOST)
raw.sendall(b"GET /health HTTP/1.0\r\nHost: %s\r\n\r\n" % HOST.encode())
resp = raw.recv(4096)
raw.close()
record("A5_http1.0", resp.split(b"\r\n", 1)[0].decode("latin1", "replace") if resp else "(no response)")
except Exception as e:
record("A5_http1.0", type(e).__name__ + ": " + str(e)[:80])
try:
st, _, _ = http_get("/health", method="HEAD")
record("A5_head", st)
except Exception as e:
record("A5_head", type(e).__name__ + ": " + str(e)[:80])
try:
st, _, _ = http_get("/health", method="POST", body=b"x")
record("A5_post", st)
except Exception as e:
record("A5_post", type(e).__name__ + ": " + str(e)[:80])
# --- B6: SendMessage 32KB (большие тела платформа вешает на ~51с — MTU, см. HISTORY) ---
def b6_big_send(sqs, url):
t0 = time.time()
try:
sqs.send_message(QueueUrl=url, MessageBody="x" * (32 * 1024))
record("B6_send_32kb", {"ok": True, "t_s": round(time.time() - t0, 3)})
except Exception as e:
record("B6_send_32kb", {"ok": False, "err": type(e).__name__ + ": " + str(e)[:80], "t_s": round(time.time() - t0, 3)})
# --- B8: Receive 10 x 32KB ---
def b8_big_receive(sqs, url):
for _ in range(10):
sqs.send_message(QueueUrl=url, MessageBody="y" * (32 * 1024))
t0 = time.time()
try:
r = sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=10, WaitTimeSeconds=1)
msgs = r.get("Messages", [])
total = sum(len(m["Body"]) for m in msgs)
record("B8_receive_10x32kb", {"ok": True, "msgs": len(msgs), "bytes": total, "t_s": round(time.time() - t0, 3)})
for m in msgs:
try:
sqs.delete_message(QueueUrl=url, ReceiptHandle=m["ReceiptHandle"])
except Exception:
pass
except Exception as e:
record("B8_receive_10x32kb", {"ok": False, "err": type(e).__name__ + ": " + str(e)[:80], "t_s": round(time.time() - t0, 3)})
# --- B9: long-poll 20s — фактическая длительность ---
def b9_longpoll(sqs, url):
t0 = time.time()
try:
sqs.receive_message(QueueUrl=url, MaxNumberOfMessages=1, WaitTimeSeconds=20)
record("B9_longpoll_20s", {"ok": True, "t_s": round(time.time() - t0, 2)})
except Exception as e:
record("B9_longpoll_20s", {"ok": False, "err": type(e).__name__ + ": " + str(e)[:80], "t_s": round(time.time() - t0, 2)})
# --- B7: chunked-тело ---
def b7_chunked():
try:
ctx = ssl.create_default_context()
raw = ctx.wrap_socket(socket.create_connection((HOST, PORT), timeout=15),
server_hostname=HOST)
raw.sendall(b"POST /health HTTP/1.1\r\nHost: %s\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" % HOST.encode())
raw.sendall(b"5\r\nhello\r\n")
raw.sendall(b"5\r\nworld\r\n")
raw.sendall(b"0\r\n\r\n")
resp = raw.recv(4096)
raw.close()
record("B7_chunked", resp.split(b"\r\n", 1)[0].decode("latin1", "replace") if resp else "(no response)")
except Exception as e:
record("B7_chunked", type(e).__name__ + ": " + str(e)[:80])
def main():
sqs = boto3.client("sqs", endpoint_url=ENDPOINT, region_name=REGION,
config=Config(connect_timeout=10, read_timeout=30, retries={"max_attempts": 1}))
q = "platprobe-%s" % int(time.time())
url = sqs.create_queue(QueueName=q)["QueueUrl"]
print("queue: %s" % url, flush=True)
try:
a1_burst()
a2_big_headers()
a3_slow_body()
a4_keepalive()
a5_variants()
b6_big_send(sqs, url)
b7_chunked()
b8_big_receive(sqs, url)
b9_longpoll(sqs, url)
finally:
try:
sqs.delete_queue(QueueUrl=url)
except Exception:
pass
print("MODE=%s ENDPOINT=%s" % (MODE, ENDPOINT), flush=True)
print("SUMMARY: %s" % json.dumps(results, ensure_ascii=False), flush=True)
if __name__ == "__main__":
sys.exit(main())

Some files were not shown because too many files have changed in this diff Show More