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. ЖДАТЬ следующей команды
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД — АБСОЛЮТНЫЙ ЗАПРЕТ НАВСЕГДА ## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРИКАЗАНО
- Без додумывания, без «логичных следующих шагов».
**НИКАКИХ самодеятельных изменений рабочего кода:** ## ЕСЛИ ПРАВИШЬ КОД
- Никаких "оптимизаций", "улучшений", "рефакторинга" без команды - Сначала прочитай /home/naeel/nubes/SQS-service/.github/pravila.md (там: git локально + после пуша синк на ВМ, docker/ssh на ВМ, деплой).
- Никаких новых фич без явного разрешения
- Никаких docker/kubectl/helm команд и прочих инфраструктурных изменений без команды
- Перед ЛЮБЫМ изменением рабочего кода — объяснить ЗАЧЕМ и ждать "делай"
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 ## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
> ⚠️ ЛЕГАСИ — старый k8s-деплой (kubectl apply). Уходим на Nubes Managed. ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.
1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/deployment.yaml` (vX.Y.Z → vX.Y.Z+1) 1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/deployment.yaml` (vX.Y.Z → vX.Y.Z+1)
2. rsync на ВМ 2. rsync на ВМ
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки) 3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
@@ -13,14 +31,14 @@
## Файловая система (актуально) ## Файловая система (актуально)
1. Все файлы редактируются локально: `~/SQS-service` 1. Все файлы редактируются локально: `/home/naeel/nubes/SQS-service`
2. После любых изменений — обязательно rsync на ВМ: 2. После любых изменений — обязательно rsync на ВМ:
rsync -az \ rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ -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/ 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 на ВМ 4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена 5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются 6. SCP, sshfs, remote_dev и маунты больше НЕ используются
@@ -28,8 +46,9 @@
Пример: Пример:
1. Редактируешь локально 1. Редактируешь локально
2. rsync на ВМ 2. git add/commit/push ЛОКАЛЬНО
3. Выполняешь команды через SSH на ВМ 3. rsync на ВМ (после пуша)
4. Выполняешь команды через SSH на ВМ
## SSH ## SSH
@@ -39,7 +58,7 @@
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА' 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
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО: ⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/SQS-service`. НИКОГДА через SSH на VM. - Git — ЛОКАЛЬНО в /home/naeel/nubes/SQS-service. После push — синк на ВМ (rsync).
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`. - Разрешены операции: `git add`, `git commit`, `git push` (локально).
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push. - ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме add/commit/push.
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно. - Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
Версионирование тагами: `vMAJOR.MINOR.PATCH` Версионирование тагами: `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 \ 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}" "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" \ 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/ secrets/
goaws goaws
cmd cmd
# Python cache
__pycache__/
*.pyc
+6 -1
View File
@@ -6,13 +6,18 @@ WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 FROM alpine:3.19
RUN apk --no-cache add ca-certificates RUN apk --no-cache add ca-certificates
COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs
COPY --from=builder /build/app/conf/goaws.yaml /conf/goaws.yaml COPY --from=builder /build/app/conf/goaws.yaml /conf/goaws.yaml
EXPOSE 4100 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 \ HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD wget -q -O - http://localhost:4100/health || exit 1 CMD wget -q -O - http://localhost:4100/health || exit 1
ENTRYPOINT ["shared-sqs", "--config", "/conf/goaws.yaml"] 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: 6–8мс (с ВМ) / 89–90мс (с локали). YMQ: 60–62мс / 138–150мс.
- 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 # Makefile — shared-sqs
# Created: 2026-04-09 # Created: 2026-04-09
# Registry: Docker Hub (naeel/shared-sqs) — pearlharbor не используется (нестабилен) # Registry: Docker Hub (naeel/shared-sqs)
IMAGE_REPO=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 BINARY=shared-sqs
.PHONY: build docker-build docker-push test run clean .PHONY: build docker-build docker-push test run clean
build: 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:
docker build -t $(IMAGE_REPO):$(VERSION) . docker build --build-arg VERSION=$(VERSION) -t $(IMAGE_REPO):$(VERSION) -t $(IMAGE_REPO):latest .
docker-push: docker-push:
docker push $(IMAGE_REPO):$(VERSION) docker push $(IMAGE_REPO):$(VERSION)
docker push $(IMAGE_REPO):latest
test: test:
go test ./... go test ./...
+29 -22
View File
@@ -8,29 +8,34 @@
- **Многотенантность** — каждый тенант имеет отдельный Access Key и Secret Key - **Многотенантность** — каждый тенант имеет отдельный Access Key и Secret Key
- **Redis persistence** — очереди и сообщения сохраняются при перезапуске сервиса - **Redis persistence** — очереди и сообщения сохраняются при перезапуске сервиса
- **Web UI Console** — управление тенантами и просмотр сообщений - **Web UI Console** — управление тенантами и просмотр сообщений
- **Kubernetes-ready** — Helm/манифесты, TLS Ingress, здоровье-checks - **Managed-ready** — деплой Docker-образом через Nubes («Простой HTTP контейнер»), health-check /health
- **Admin API** — управление тенантами программно - **Admin API** — управление тенантами программно
## Быстрый старт ## Деплой (Nubes Managed)
### Локально Сервис деплоится как Docker-образ через «Простой HTTP контейнер» (deck-UI) + Managed Redis.
Никакого Kubernetes/VPS.
### 1. Собрать и запушить образ в Gitea registry
```bash ```bash
docker run -d -p 9090:9090 naeel/shared-sqs:latest docker build -t gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25 .
# Откройте http://localhost:9090 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 - Сервис: «Простой HTTP контейнер»
kubectl apply -k deployments/k8s/ - Образ: `gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25`
``` - Realm: `iot-naeel`
- Env (СРАЗУ, потом не меняются): `SHARED_SQS_ADMIN_TOKEN`, `REDIS_PASSWORD`
### Сборка локально ### Локальный запуск
```bash ```bash
go build -o shared-sqs ./app/cmd go build -o shared-sqs ./app/cmd
./shared-sqs --port 9090 ./shared-sqs --port 4100
``` ```
## Архитектура ## Архитектура
@@ -47,26 +52,28 @@ app/
├── router/ — HTTP маршруты ├── router/ — HTTP маршруты
└── ui/ — Web Console (HTML/JS) └── ui/ — Web Console (HTML/JS)
deployments/k8s/ deployments/k8s/ — ЛЕГАСИ (старый k8s-деплой, не использовать)
├── deployment.yaml — Deployment с Redis sidecar deployments/helm/ — ЛЕГАСИ (helm chart, не использовать)
├── ingress.yaml — TLS, qu.kube5s.ru
└── redis.yaml — Redis Persistent Volume
``` ```
## Окружение ## Окружение
| Переменная | Значение | Описание | | Переменная | Значение | Описание |
|-----------|---------|---------| |-----------|---------|---------|
| `PORT` | 9090 | Порт HTTP сервера | | `PORT` (--port) | 4100 | Порт HTTP сервера |
| `REDIS_URL` | localhost:6379 | Redis для persistence | | `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 данные на старте | | `SHARED_SQS_SEED_DEMO` | true/false | Загрузить demo данные на старте |
| `NUBES_ENDPOINT` | https://deck-api.ngcloud.ru/api/v1 | JWT-валидация UI |
## API примеры ## API примеры
### Создать очередь (как тенант) ### Создать очередь (как тенант)
```bash ```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" \ -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" -d "Action=CreateQueue&QueueName=myqueue&Version=2012-11-05"
``` ```
@@ -75,8 +82,8 @@ curl -X POST "http://localhost:9090/" \
```bash ```bash
aws sqs send-message \ aws sqs send-message \
--endpoint-url http://localhost:9090 \ --endpoint-url http://localhost:4100 \
--queue-url http://localhost:9090/demo-shared-sqs/myqueue \ --queue-url http://localhost:4100/demo-shared-sqs/myqueue \
--message-body "Hello World" --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/billing-and-metrics.md](doc/billing-and-metrics.md) — биллинг и Prometheus-метрики
- [doc/byoc-credentials.md](doc/byoc-credentials.md) — BYOC интеграция - [doc/byoc-credentials.md](doc/byoc-credentials.md) — BYOC интеграция
## Ссылки ## Ссылки
- Репо: https://gitea.services.ngcloud.ru/Nail/SQS-service - Репо: 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) - Основана на: [GoAws](https://github.com/Admiral-Piett/goaws)
+42 -12
View File
@@ -64,9 +64,8 @@ type Handler struct {
// NewHandler — создаёт admin handler // NewHandler — создаёт admin handler
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler { func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
nubesEndpoint := os.Getenv("NUBES_ENDPOINT") nubesEndpoint := os.Getenv("NUBES_ENDPOINT")
if nubesEndpoint == "" { // Если не задан — пустой: PingNubesAPI переберёт все стенды сам.
nubesEndpoint = "https://deck-api-test.ngcloud.ru/api/v1" auth.StartTokenCacheCleanup() // фоновая очистка кэша валидации (идемпотентно)
}
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint} return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
} }
@@ -157,6 +156,7 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
// jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint // jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint
ui.Use(h.jwtMiddleware) ui.Use(h.jwtMiddleware)
ui.HandleFunc("/auth", h.jwtAuth).Methods("POST") ui.HandleFunc("/auth", h.jwtAuth).Methods("POST")
ui.HandleFunc("/credentials", h.uiCredentials).Methods("GET")
ui.HandleFunc("/health", h.detailedHealth).Methods("GET") ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
ui.HandleFunc("/tenants", h.listTenants).Methods("GET") ui.HandleFunc("/tenants", h.listTenants).Methods("GET")
ui.HandleFunc("/tenants", h.createTenant).Methods("POST") ui.HandleFunc("/tenants", h.createTenant).Methods("POST")
@@ -223,6 +223,14 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
return 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 // Валидируем через nubes API
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel() 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()) jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
return return
} }
// Логин — всегда свежая проверка; после успеха прогреваем кэш,
// чтобы дальнейшие запросы консоли не дёргали нестабильный шлюз.
auth.WarmTokenCache(req.Token)
// Auto-provisioning: создаём тенанта если не существует // Auto-provisioning: тенант и ключи детерминированы из email.
tenantID := auth.TenantIDFromSub(claims.Sub) // Токен — только аутентификация; ключи в ответ не возвращаем (GET /ui/api/credentials).
t, err := h.store.CreateFromJWT(tenantID, claims.Sub, claims.Email, 10) t, err := h.store.CreateFromJWT(claims.Sub, claims.Email, tenant.DefaultTenantMaxQueues)
if err != nil { if err != nil {
log.Errorf("jwt auth: failed to create tenant: %v", err) log.Errorf("jwt auth: failed to create tenant: %v", err)
jsonErr(w, http.StatusInternalServerError, "failed to provision tenant") 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{}{ json.NewEncoder(w).Encode(map[string]interface{}{
"email": claims.Email, "email": claims.Email,
"tenant_id": t.ID, "tenant_id": t.ID,
"access_key": t.AccessKey,
"secret_key": t.SecretKey,
"max_queues": t.MaxQueues, "max_queues": t.MaxQueues,
"token": req.Token, // возвращаем для использования в последующих запросах "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. // jwtMiddleware — middleware для /ui/api/* endpoints.
// Пропускает /ui/api/auth (публичный endpoint авторизации). // Пропускает /ui/api/auth (публичный endpoint авторизации).
// Проверяет Authorization: Bearer <jwt> заголовок. // Проверяет Authorization: Bearer <jwt> заголовок.
@@ -298,17 +324,18 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
return return
} }
// Повторно валидируем токен в nubes API на каждый UI API запрос, // Повторная валидация через кэш: в течение TTL шлюз не вызывается
// чтобы отозванные токены не оставались валидными до повторного логина. // (шлюз нестабилен с IP платформы), отозванный токен умрёт через TTL,
// явный 401/403 от шлюза сбрасывает кэш.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() 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()) jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
return return
} }
// Проверяем что тенант существует (был создан при /ui/api/auth) // Проверяем что тенант существует (был создан при /ui/api/auth)
jwtTenant, ok := h.store.GetBySub(claims.Sub) jwtTenant, ok := h.store.GetByEmail(claims.Email)
if !ok { if !ok {
jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth") jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth")
return return
@@ -729,6 +756,7 @@ func jsonErr(w http.ResponseWriter, code int, msg string) {
// adminHealthDetail — ответ GET /admin/health // adminHealthDetail — ответ GET /admin/health
type adminHealthDetail struct { type adminHealthDetail struct {
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"`
TenantCount int `json:"tenant_count"` TenantCount int `json:"tenant_count"`
QueueCount int `json:"queue_count"` QueueCount int `json:"queue_count"`
MessageCount int `json:"message_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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{ json.NewEncoder(w).Encode(adminHealthDetail{
Status: "ok", Status: "ok",
Version: models.Version,
TenantCount: 1, TenantCount: 1,
QueueCount: queueCount, QueueCount: queueCount,
MessageCount: msgCount, MessageCount: msgCount,
@@ -770,6 +799,7 @@ func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{ json.NewEncoder(w).Encode(adminHealthDetail{
Status: "ok", Status: "ok",
Version: models.Version,
TenantCount: len(tenants), TenantCount: len(tenants),
QueueCount: queueCount, QueueCount: queueCount,
MessageCount: msgCount, 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 ( import (
"context" "context"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -65,35 +65,60 @@ func ParseJWTClaims(token string) (*JWTClaims, error) {
return &claims, nil return &claims, nil
} }
// TenantIDFromSub — вычисляет tenant ID из JWT subject (sub claim). // ErrTokenRejected — шлюз явно отклонил токен (401/403).
// Алгоритм совместим с sless: SHA256(sub) → первые 8 байт → hex → "sless-{16hex}". // Sentinel, чтобы отличать отказ от сетевых ошибок (см. cache.go).
// Длина: 6 + 16 = 22 символа. Детерминирован, необратим. var ErrTokenRejected = errors.New("token rejected by nubes API")
// Почему "sless-" а не "ssq-": единый namespace для всех сервисов (IoT, funcs, SQS).
func TenantIDFromSub(sub string) string { // nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен.
hash := sha256.Sum256([]byte(sub)) // Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял.
return fmt.Sprintf("sless-%x", hash[:8]) // Юзер может вводить токен от любого стенда — главное, что токен действительный.
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. // PingNubesAPI — проверяет валидность токена перебором стендов Nubes API.
// endpoint — базовый URL (например "https://deck-api-test.ngcloud.ru/api/v1"). // endpoint (если задан через NUBES_ENDPOINT) пробуется первым.
// Логика: 401/403 → токен отклонён. Ошибка соединения → API недоступен. // Логика: 401/403 → токен отклонён ЭТИМ стендом, пробуем следующий.
// Любой другой HTTP статус → токен принят. // Любой другой HTTP статус → токен принят.
func PingNubesAPI(ctx context.Context, endpoint, token string) error { func PingNubesAPI(ctx context.Context, endpoint, token string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) candidates := make([]string, 0, len(nubesAPIEndpoints)+1)
if err != nil { if endpoint != "" {
return fmt.Errorf("build nubes ping request: %w", err) 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} var lastErr error
resp, err := client.Do(req) for _, ep := range candidates {
if err != nil { req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
return fmt.Errorf("nubes API unreachable at %s: %w", endpoint, err) if err != nil {
} lastErr = fmt.Errorf("build nubes ping request: %w", err)
defer resp.Body.Close() continue
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "Mozilla/5.0")
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { client := &http.Client{Timeout: 10 * time.Second}
return fmt.Errorf("nubes API rejected token (HTTP %d)", resp.StatusCode) 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: gauge updater — пересчёт очередей/сообщений per tenant каждые 15 секунд
metrics.StartGaugeUpdater(15*time.Second, quit) 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{ srv := &http.Server{
Addr: "0.0.0.0:" + port, Addr: "0.0.0.0:" + port,
Handler: r, Handler: r,
ReadTimeout: 30 * time.Second, ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s) WriteTimeout: 35 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 90 * time.Second,
MaxHeaderBytes: 8192,
} }
// Запуск в горутине для graceful shutdown // Запуск в горутине для 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) 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) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
models.SyncQueues.Lock()
messageFound := false messageFound := false
var changedMsgUuid string var changedMsgUuid string
var msgRemoved bool var msgRemoved bool
for i := 0; i < len(models.SyncQueues.Queues[key].Messages); i++ { for i := 0; i < len(queue.Messages); i++ {
queue := models.SyncQueues.Queues[key]
msgs := queue.Messages msgs := queue.Messages
if msgs[i].ReceiptHandle == receiptHandle { if msgs[i].ReceiptHandle == receiptHandle {
timeout := models.SyncQueues.Queues[key].VisibilityTimeout timeout := queue.VisibilityTimeout
if visibilityTimeout == 0 { if visibilityTimeout == 0 {
msgs[i].ReceiptTime = time.Now().UTC() msgs[i].ReceiptTime = time.Now().UTC()
msgs[i].ReceiptHandle = "" msgs[i].ReceiptHandle = ""
@@ -83,23 +90,22 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
break break
} }
} }
// Персистим изменения в Redis под Lock // Персистим изменения в Redis под Lock (сетевые записи асинхронны).
if messageFound { if messageFound {
if msgRemoved { if msgRemoved {
// Сообщение удалено (перемещено в DLQ) — удаляем из Redis // Сообщение удалено (перемещено в DLQ) — удаляем из Redis
persistence.DeleteMessagePersist(key, changedMsgUuid) persistence.DeleteMessagePersist(key, changedMsgUuid)
} else { } else {
// Сообщение обновлено — перезаписываем в Redis // Сообщение обновлено — перезаписываем в Redis
for i := range models.SyncQueues.Queues[key].Messages { for i := range queue.Messages {
if models.SyncQueues.Queues[key].Messages[i].Uuid == changedMsgUuid { if queue.Messages[i].Uuid == changedMsgUuid {
persistence.SaveMessage(key, &models.SyncQueues.Queues[key].Messages[i]) persistence.SaveMessage(key, &queue.Messages[i])
break break
} }
} }
} }
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, queue)
} }
models.SyncQueues.Unlock()
if !messageFound { if !messageFound {
return utils.CreateErrorResponseV1("MessageNotInFlight", true) 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) key := tenantQueueKey(t.AccessKey, queueName)
if _, ok := models.SyncQueues.Queues[key]; !ok {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
if len(requestBody.Entries) == 0 { if len(requestBody.Entries) == 0 {
return utils.CreateErrorResponseV1("EmptyBatchRequest", true) return utils.CreateErrorResponseV1("EmptyBatchRequest", true)
} }
@@ -66,6 +62,13 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
models.SyncQueues.Lock() models.SyncQueues.Lock()
defer models.SyncQueues.Unlock() 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) successEntries := make([]models.ChangeMessageVisibilityBatchResultEntry, 0)
failedEntries := make([]models.BatchResultErrorEntry, 0) failedEntries := make([]models.BatchResultErrorEntry, 0)
@@ -81,7 +84,6 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
} }
messageFound := false messageFound := false
queue := models.SyncQueues.Queues[key]
for i := 0; i < len(queue.Messages); i++ { for i := 0; i < len(queue.Messages); i++ {
if queue.Messages[i].ReceiptHandle == entry.ReceiptHandle { if queue.Messages[i].ReceiptHandle == entry.ReceiptHandle {
if entry.VisibilityTimeout == 0 { if entry.VisibilityTimeout == 0 {
@@ -114,7 +116,7 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
// Персистим изменения в Redis под Lock // Персистим изменения в Redis под Lock
if len(successEntries) > 0 { if len(successEntries) > 0 {
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, queue)
} }
respStruct := models.ChangeMessageVisibilityBatchResponse{ 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 package gosqs
import ( import (
@@ -15,6 +12,21 @@ import (
log "github.com/sirupsen/logrus" 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) { func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewCreateQueueRequest() requestBody := models.NewCreateQueueRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -53,7 +65,13 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates, EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
Duplicates: make(map[string]time.Time), 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() models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1(err.Error(), true) 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) key := tenantQueueKey(t.AccessKey, queueName)
if _, ok := models.SyncQueues.Queues[key]; !ok {
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
if len(requestBody.Entries) == 0 { if len(requestBody.Entries) == 0 {
return utils.CreateErrorResponseV1("EmptyBatchRequest", true) return utils.CreateErrorResponseV1("EmptyBatchRequest", true)
} }
@@ -63,6 +59,13 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
models.SyncQueues.Lock() models.SyncQueues.Lock()
defer models.SyncQueues.Unlock() 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) deleteMessageMap := make(map[string]*deleteEntry)
for _, entry := range requestBody.Entries { for _, entry := range requestBody.Entries {
deleteMessageMap[entry.ReceiptHandle] = &deleteEntry{ deleteMessageMap[entry.ReceiptHandle] = &deleteEntry{
@@ -74,13 +77,13 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0) deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0)
deletedUuids := make([]string, 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 { if de, found := deleteMessageMap[message.ReceiptHandle]; found {
log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID) log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID)
models.SyncQueues.Queues[key].UnlockGroup(message.GroupID) queue.UnlockGroup(message.GroupID)
delete(models.SyncQueues.Queues[key].Duplicates, message.DeduplicationID) delete(queue.Duplicates, message.DeduplicationID)
de.Deleted = true de.Deleted = true
deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id}) deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id})
deletedUuids = append(deletedUuids, message.Uuid) 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) // Удаляем сообщения из Redis отдельно — O(batch_size)
persistence.DeleteMessagesPersist(key, deletedUuids) persistence.DeleteMessagesPersist(key, deletedUuids)
// Метаданные очереди (duplicates, FIFO state) // Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, queue)
notFoundEntries := make([]models.BatchResultErrorEntry, 0) notFoundEntries := make([]models.BatchResultErrorEntry, 0)
for _, de := range deleteMessageMap { for _, de := range deleteMessageMap {
+18 -3
View File
@@ -1,5 +1,3 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// DeleteQueueV1 — удаляет очередь тенанта по tenant-scoped ключу.
package gosqs package gosqs
import ( import (
@@ -14,6 +12,17 @@ import (
log "github.com/sirupsen/logrus" 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) { func DeleteQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewDeleteQueueRequest() requestBody := models.NewDeleteQueueRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) 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) log.Infof("Deleting Queue: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.Lock() 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) delete(models.SyncQueues.Queues, key)
models.SyncQueues.Unlock()
// Удаляем из Redis асинхронно // Удаляем из Redis асинхронно
persistence.DeleteQueue(key) persistence.DeleteQueue(key)
+79 -34
View File
@@ -13,46 +13,40 @@ func init() {
models.SyncQueues.Queues = make(map[string]*models.Queue) 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) { func PeriodicTasks(d time.Duration, quit chan bool) {
ticker := time.NewTicker(d) ticker := time.NewTicker(d)
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
models.SyncQueues.Lock() // Снапшот указателей на очереди под коротким RLock.
for qName := range models.SyncQueues.Queues { // Указатели остаются валидными после снятия RLock: очереди из map
queue := models.SyncQueues.Queues[qName] // не удаляются во время итерации, а объекты живут в куче.
models.SyncQueues.RLock()
// Reset deduplication period queues := make([]*models.Queue, 0, len(models.SyncQueues.Queues))
for dedupId, startTime := range queue.Duplicates { for _, queue := range models.SyncQueues.Queues {
if time.Now().After(startTime.Add(models.DeduplicationPeriod)) { queues = append(queues, queue)
log.Debugf("deduplication period for message with deduplicationId [%s] expired", dedupId) }
delete(queue.Duplicates, dedupId) models.SyncQueues.RUnlock()
}
} // Каждую очередь обрабатываем под ОТДЕЛЬНЫМ Lock — блокировка одной
// очереди не мешает обработке остальных и HTTP-запросам.
log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages)) for _, queue := range queues {
for i := 0; i < len(queue.Messages); i++ { models.SyncQueues.Lock()
msg := &queue.Messages[i] processQueueTick(queue)
models.SyncQueues.Unlock()
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--
}
}
}
}
} }
models.SyncQueues.Unlock()
case <-quit: case <-quit:
ticker.Stop() ticker.Stop()
return 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 { func numberOfHiddenMessagesInQueue(queue models.Queue) int {
num := 0 num := 0
for _, m := range queue.Messages { for _, m := range queue.Messages {
+24 -2
View File
@@ -1,5 +1,3 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// PurgeQueueV1 — очищает все сообщения в очереди тенанта.
package gosqs package gosqs
import ( import (
@@ -15,6 +13,21 @@ import (
log "github.com/sirupsen/logrus" 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) { func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewPurgeQueueRequest() requestBody := models.NewPurgeQueueRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -39,9 +52,18 @@ func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
return utils.CreateErrorResponseV1("QueueNotFound", true) 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) log.Infof("Purging Queue: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.Queues[key].Messages = nil models.SyncQueues.Queues[key].Messages = nil
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time) models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time)
// Фиксируем время purge — защита от повторного вызова в течение 60 секунд.
models.SyncQueues.Queues[key].LastPurgeTime = time.Now()
// Удаляем все сообщения из Redis одной командой DEL // Удаляем все сообщения из Redis одной командой DEL
persistence.PurgeMessagesPersist(key) persistence.PurgeMessagesPersist(key)
// Сохраняем пустые метаданные очереди // Сохраняем пустые метаданные очереди
+59 -17
View File
@@ -9,31 +9,73 @@ import (
"shared-sqs/app/models" "shared-sqs/app/models"
) )
// TODO - Support: // setQueueAttributesV1 — применяет атрибуты очереди с валидацией по AWS-спецификации.
// - attr.MessageRetentionPeriod //
// - attr.Policy // Логическая схема:
// - attr.RedriveAllowPolicy // 1. Whitelist имён: неизвестное имя атрибута → InvalidAttributeName.
func setQueueAttributesV1(q *models.Queue, attr models.QueueAttributes) error { // Список имён (provided) доступен для Query-протокола (form-параметры
// AWS-совместимые лимиты: clamp значений к допустимым диапазонам // Attribute.N.Name); для JSON-протокола provided == nil и проверка пропускается
if attr.DelaySeconds >= 0 { // (структура уже разобрана из тела, неизвестные поля отброшены).
q.DelaySeconds = ClampInt(attr.DelaySeconds.Int(), 0, MaxDelaySeconds) // 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 0–900, MaximumMessageSize 1024–262144,
// MessageRetentionPeriod 60–1209600, ReceiveMessageWaitTimeSeconds 0–20,
// VisibilityTimeout 0–43200.
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 { if attr.MaximumMessageSize != 0 {
q.MessageRetentionPeriod = ClampInt(attr.MessageRetentionPeriod.Int(), MinMessageRetentionPeriod, MaxMessageRetentionPeriod) if attr.MaximumMessageSize.Int() < MinMessageSizeLimit || attr.MaximumMessageSize.Int() > MaxMessageSizeLimit {
return fmt.Errorf("InvalidParameterValue")
}
q.MaximumMessageSize = attr.MaximumMessageSize.Int()
} }
if attr.ReceiveMessageWaitTimeSeconds > 0 { if attr.MessageRetentionPeriod != 0 {
q.ReceiveMessageWaitTimeSeconds = ClampInt(attr.ReceiveMessageWaitTimeSeconds.Int(), 0, MaxReceiveMessageWaitTimeSeconds) if attr.MessageRetentionPeriod.Int() < MinMessageRetentionPeriod || attr.MessageRetentionPeriod.Int() > MaxMessageRetentionPeriod {
return fmt.Errorf("InvalidParameterValue")
}
q.MessageRetentionPeriod = attr.MessageRetentionPeriod.Int()
} }
if attr.VisibilityTimeout >= 0 { if attr.ReceiveMessageWaitTimeSeconds != 0 {
q.VisibilityTimeout = ClampInt(attr.VisibilityTimeout.Int(), 0, MaxVisibilityTimeout) 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{}) { if attr.RedrivePolicy != (models.RedrivePolicy{}) {
arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":") arnArray := strings.Split(attr.RedrivePolicy.DeadLetterTargetArn, ":")
queueName := arnArray[len(arnArray)-1] 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 { if !ok {
log.Error("Invalid RedrivePolicy Attribute") log.Error("Invalid RedrivePolicy Attribute")
return fmt.Errorf("InvalidAttributeValue") return fmt.Errorf("InvalidAttributeValue")
+152 -83
View File
@@ -19,7 +19,27 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// ReceiveMessageV1 — получает до MaxNumberOfMessages сообщений из очереди тенанта.
//
// Логическая схема:
// 1. Разбор тела запроса (Query-форма или JSON) → ReceiveMessageRequest.
// 2. Аутентифицированный тенант из контекста (SigV4 → AccessKey).
// 3. Валидация входных параметров (AWS-совместимая):
// - MaxNumberOfMessages: 1–10; 0 (не задан) = 1. Выход за диапазон — ошибка
// InvalidParameterValue (раньше значение молча клэмпилось).
// - VisibilityTimeout: 0–43200 секунд, иначе InvalidParameterValue.
// - WaitTimeSeconds: 0–20 секунд; 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) { func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
// --- Шаг 1: разбор тела запроса ---
requestBody := models.NewReceiveMessageRequest() requestBody := models.NewReceiveMessageRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
if !ok { if !ok {
@@ -27,18 +47,31 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
// --- Шаг 2: тенант из контекста ---
t := getTenantFromContext(req) t := getTenantFromContext(req)
if t == nil { if t == nil {
return utils.CreateErrorResponseV1("InvalidClientTokenId", true) return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
} }
// --- Шаг 3: валидация параметров ---
// MaxNumberOfMessages: AWS допускает 1–10; 0 (параметр не задан) означает 1.
// Выход за диапазон — ошибка, а не молчаливый clamp (так делает AWS).
maxNumberOfMessages := requestBody.MaxNumberOfMessages maxNumberOfMessages := requestBody.MaxNumberOfMessages
if maxNumberOfMessages == 0 { if maxNumberOfMessages == 0 {
maxNumberOfMessages = 1 maxNumberOfMessages = MinNumberOfMessagesLimit
}
if maxNumberOfMessages < MinNumberOfMessagesLimit || maxNumberOfMessages > MaxNumberOfMessagesLimit {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
// Fix #8: clamp MaxNumberOfMessages к AWS лимиту 1–10
maxNumberOfMessages = ClampInt(maxNumberOfMessages, MinNumberOfMessagesLimit, MaxNumberOfMessagesLimit)
// VisibilityTimeout: AWS допускает 0–43200.
if requestBody.VisibilityTimeout < 0 || requestBody.VisibilityTimeout > MaxVisibilityTimeout {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
// --- Шаг 4: имя очереди ---
// QueueUrl не задан → берём из пути /{account}/{queueName} (старый стиль клиентов).
// QueueUrl задан → последний сегмент URL = имя очереди.
queueName := "" queueName := ""
if requestBody.QueueUrl == "" { if requestBody.QueueUrl == "" {
vars := mux.Vars(req) vars := mux.Vars(req)
@@ -50,118 +83,148 @@ func ReceiveMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody)
key := tenantQueueKey(t.AccessKey, queueName) 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) return utils.CreateErrorResponseV1("QueueNotFound", true)
} }
var messages []*models.ResultMessage
respStruct := models.ReceiveMessageResponse{}
// Валидация VisibilityTimeout: AWS SQS допускает 0–43200
if requestBody.VisibilityTimeout < 0 || requestBody.VisibilityTimeout > MaxVisibilityTimeout {
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
}
waitTimeSeconds := requestBody.WaitTimeSeconds waitTimeSeconds := requestBody.WaitTimeSeconds
if waitTimeSeconds == 0 { if waitTimeSeconds == 0 {
models.SyncQueues.RLock() waitTimeSeconds = queue.ReceiveMessageWaitTimeSeconds
waitTimeSeconds = models.SyncQueues.Queues[key].ReceiveMessageWaitTimeSeconds
models.SyncQueues.RUnlock()
} }
// Валидация WaitTimeSeconds: AWS SQS допускает 0–20, иначе ошибка models.SyncQueues.RUnlock()
if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds { if waitTimeSeconds < 0 || waitTimeSeconds > MaxReceiveMessageWaitTimeSeconds {
return utils.CreateErrorResponseV1("InvalidParameterValue", true) return utils.CreateErrorResponseV1("InvalidParameterValue", true)
} }
// --- Шаги 6–7: выборка сообщений (атомарно) + long polling ---
if waitTimeSeconds > 0 { if waitTimeSeconds > 0 {
deadline := time.Now().Add(time.Duration(waitTimeSeconds) * time.Second) deadline := time.Now().Add(time.Duration(waitTimeSeconds) * time.Second)
pollTicker := time.NewTicker(100 * time.Millisecond) pollTicker := time.NewTicker(100 * time.Millisecond)
defer pollTicker.Stop() defer pollTicker.Stop()
for { for {
models.SyncQueues.RLock() // Атомарная проверка+выборка: между проверкой и выдачей другой
queue, queueFound := models.SyncQueues.Queues[key] // получатель не может «съесть» сообщения — они помечаются
if !queueFound { // ReceiptHandle в том же критическом участке (см. receiveMessagesUnderLock).
models.SyncQueues.RUnlock() messages, found := receiveMessagesUnderLock(key, requestBody.VisibilityTimeout, maxNumberOfMessages)
return utils.CreateErrorResponseV1("QueueNotFound", true) if found {
return http.StatusOK, buildReceiveResponse(messages)
} }
messageFound := queueHasReceivableMessages(queue) if time.Now().After(deadline) {
models.SyncQueues.RUnlock() return http.StatusOK, buildReceiveResponse(nil)
if messageFound || time.Now().After(deadline) {
break
} }
select { select {
case <-req.Context().Done(): case <-req.Context().Done():
return http.StatusOK, models.ReceiveMessageResponse{ // Клиент разорвал соединение — завершаем без ошибки.
Xmlns: models.BaseXmlns, return http.StatusOK, buildReceiveResponse(nil)
Result: models.ReceiveMessageResult{},
Metadata: models.BaseResponseMetadata,
}
case <-pollTicker.C: 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() models.SyncQueues.Lock()
defer models.SyncQueues.Unlock() defer models.SyncQueues.Unlock()
if len(models.SyncQueues.Queues[key].Messages) > 0 { queue, queueFound := models.SyncQueues.Queues[key]
numMsg := 0 if !queueFound || len(queue.Messages) == 0 {
messages = make([]*models.ResultMessage, 0) return nil, false
for i := range models.SyncQueues.Queues[key].Messages { }
if numMsg >= maxNumberOfMessages { // Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение.
break if !queueHasReceivableMessages(queue) {
} return nil, false
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"},
}
} }
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 { func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
return &models.ResultMessage{ return &models.ResultMessage{
MessageId: m.Uuid, 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 { func queueHasReceivableMessages(queue *models.Queue) bool {
for i := range queue.Messages { for i := range queue.Messages {
msg := &queue.Messages[i] 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 package gosqs
import ( import (
@@ -21,6 +17,27 @@ import (
"github.com/gorilla/mux" "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) { func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewSendMessageRequest() requestBody := models.NewSendMessageRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
@@ -78,6 +95,13 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
delaySecs := queue.DelaySeconds delaySecs := queue.DelaySeconds
models.SyncQueues.RUnlock() models.SyncQueues.RUnlock()
// FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой
// отправки в FIFO (иначе невозможно гарантировать порядок внутри группы).
// Раньше сообщение просто попадало в группу с пустым именем.
if queueIsFIFO && messageGroupID == "" {
return utils.CreateErrorResponseV1("MissingParameter", true)
}
if maxMessageSize > 0 && len(messageBody) > maxMessageSize { if maxMessageSize > 0 && len(messageBody) > maxMessageSize {
return utils.CreateErrorResponseV1("MessageTooBig", true) return utils.CreateErrorResponseV1("MessageTooBig", true)
} }
@@ -105,22 +129,36 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
msg.DelaySecs = delaySecs msg.DelaySecs = delaySecs
models.SyncQueues.Lock() models.SyncQueues.Lock()
fifoSeqNumber := "" // Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой
if models.SyncQueues.Queues[key].IsFIFO { // выше и захватом Lock (иначе nil-pointer deref).
fifoSeqNumber = models.SyncQueues.Queues[key].NextSequenceNumber(messageGroupID) 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) { fifoSeqNumber := ""
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg) if queue.IsFIFO {
fifoSeqNumber = queue.NextSequenceNumber(messageGroupID)
}
if !queue.IsDuplicate(messageDeduplicationID) {
queue.Messages = append(queue.Messages, msg)
// Персистим одно сообщение отдельно — O(msg_size) вместо O(N*msg_size) // Персистим одно сообщение отдельно — O(msg_size) вместо O(N*msg_size)
persistence.SaveMessage(key, &msg) persistence.SaveMessage(key, &msg)
} else { } else {
log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName) log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName)
} }
models.SyncQueues.Queues[key].InitDuplicatation(messageDeduplicationID) queue.InitDuplicatation(messageDeduplicationID)
// Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages // Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, models.SyncQueues.Queues[key]) persistence.SaveQueue(key, queue)
models.SyncQueues.Unlock() models.SyncQueues.Unlock()
// Логируем только метаданные — тело сообщения не логируется (perf + security) // Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody)) 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() models.SyncQueues.Lock()
// Перепроверка под Lock: очередь могла быть удалена между RLock-проверкой
// выше и захватом Lock (иначе nil-pointer deref).
queue = models.SyncQueues.Queues[key] queue = models.SyncQueues.Queues[key]
if queue == nil {
models.SyncQueues.Unlock()
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
newMsgs := make([]models.SqsMessage, 0, len(sendEntries)) newMsgs := make([]models.SqsMessage, 0, len(sendEntries))
for _, sendEntry := range sendEntries { for _, sendEntry := range sendEntries {
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody} 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 package gosqs
import ( import (
@@ -15,6 +12,17 @@ import (
log "github.com/sirupsen/logrus" 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) { func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
requestBody := models.NewSetQueueAttributesRequest() requestBody := models.NewSetQueueAttributesRequest()
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false) 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) log.Warningf("Set Queue Attributes: %s, queue does not exist for tenant %s", queueName, t.ID)
return utils.CreateErrorResponseV1("QueueNotFound", true) 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) return utils.CreateErrorResponseV1(err.Error(), true)
} }
// Сохраняем атрибуты в Redis пока держим Lock (через defer) // Сохраняем атрибуты в Redis пока держим Lock (через defer)
+4
View File
@@ -4,6 +4,10 @@ import (
"time" "time"
) )
// Version — версия сервиса. Переопределяется при сборке:
// -ldflags "-X shared-sqs/app/models.Version=<версия>"
var Version = "dev"
var BaseXmlns = "http://queue.amazonaws.com/doc/2012-11-05/" var BaseXmlns = "http://queue.amazonaws.com/doc/2012-11-05/"
var BaseResponseMetadata = ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"} 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."}, "LimitExceeded": {HttpError: http.StatusBadRequest, Type: "LimitExceeded", Code: "AWS.SimpleQueueService.LimitExceeded", Message: "You've reached the limit on the number of queues."},
// MissingParameter — обязательный параметр отсутствует (например, пустой MessageBody) // MissingParameter — обязательный параметр отсутствует (например, пустой MessageBody)
"MissingParameter": {HttpError: http.StatusBadRequest, Type: "MissingParameter", Code: "MissingParameter", Message: "The request must contain the parameter 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 EnableDuplicates bool
Duplicates map[string]time.Time Duplicates map[string]time.Time
Tags map[string]string 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 { func (q *Queue) NextSequenceNumber(groupId string) string {
if _, ok := q.FIFOSequenceNumbers[groupId]; !ok { if q.FIFOSequenceNumbers == nil {
q.FIFOSequenceNumbers = map[string]int{ q.FIFOSequenceNumbers = make(map[string]int)
groupId: 0,
}
} }
q.FIFOSequenceNumbers[groupId]++ q.FIFOSequenceNumbers[groupId]++
return strconv.Itoa(q.FIFOSequenceNumbers[groupId]) return strconv.Itoa(q.FIFOSequenceNumbers[groupId])
} }
// IsLocked — проверяет, заблокирована ли FIFO-группа.
// Группа блокируется при выдаче её сообщения в ReceiveMessage и остаётся
// заблокированной, пока сообщение in-flight (до DeleteMessage или таймаута
// видимости) — это гарантирует порядок обработки внутри группы.
func (q *Queue) IsLocked(groupId string) bool { func (q *Queue) IsLocked(groupId string) bool {
_, ok := q.FIFOMessages[groupId] _, ok := q.FIFOMessages[groupId]
return ok return ok
} }
// LockGroup — блокирует FIFO-группу (помечает как занятую).
//
// ВАЖНО (фикс): раньше при ПЕРВОМ обращении map пересоздавалась целиком
// (q.FIFOMessages = map[string]int{groupId: 0}) — блокировки ВСЕХ остальных
// групп терялись, и их сообщения могли выдаваться одновременно. Теперь map
// только инициализируется один раз, а для группы проставляется ключ.
func (q *Queue) LockGroup(groupId string) { func (q *Queue) LockGroup(groupId string) {
if _, ok := q.FIFOMessages[groupId]; !ok { if q.FIFOMessages == nil {
q.FIFOMessages = map[string]int{ q.FIFOMessages = make(map[string]int)
groupId: 0,
}
} }
q.FIFOMessages[groupId] = 0
} }
// UnlockGroup — снимает блокировку FIFO-группы.
// Вызывается после удаления сообщения или при истечении таймаута видимости
// (см. PeriodicTasks → processQueueTick).
func (q *Queue) UnlockGroup(groupId string) { func (q *Queue) UnlockGroup(groupId string) {
if _, ok := q.FIFOMessages[groupId]; ok { if _, ok := q.FIFOMessages[groupId]; ok {
delete(q.FIFOMessages, groupId) delete(q.FIFOMessages, groupId)
} }
} }
// IsDuplicate — проверяет, было ли сообщение с таким deduplicationId уже
// добавлено в очередь в пределах deduplication-окна.
//
// Логическая схема: дубликаты отслеживаются ТОЛЬКО для FIFO-очередей с
// включённой дупликацией (EnableDuplicates) и непустым deduplicationId.
// Для остальных случаев дубликаты не отслеживаются (AWS-семантика).
func (q *Queue) IsDuplicate(deduplicationId string) bool { func (q *Queue) IsDuplicate(deduplicationId string) bool {
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" { if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
return false return false
@@ -105,6 +133,10 @@ func (q *Queue) IsDuplicate(deduplicationId string) bool {
return ok return ok
} }
// InitDuplicatation — регистрирует deduplicationId с текущим временем.
// Эта отметка — начало deduplication-окна; просроченные записи удаляются
// фоновой задачей PeriodicTasks (deduplication period expired).
// Для не-FIFO очередей или пустого deduplicationId — no-op.
func (q *Queue) InitDuplicatation(deduplicationId string) { func (q *Queue) InitDuplicatation(deduplicationId string) {
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" { if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
return 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 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 { type RedrivePolicy struct {
MaxReceiveCount StringToInt `json:"maxReceiveCount"` MaxReceiveCount StringToInt `json:"maxReceiveCount"`
DeadLetterTargetArn string `json:"deadLetterTargetArn"` 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 асинхронно. // DeleteMessagePersist — удаляет одно сообщение из Redis СИНХРОННО.
// Вызывать при DeleteMessage (после удаления из in-memory). // Синхронность обязательна: если удаление потеряется при рестарте пода,
// сообщение «воскреснет» из Redis (факт: удалённые очереди возвращались
// после рестарта, когда удаления были асинхронными).
func DeleteMessagePersist(queueKey string, msgUuid string) { func DeleteMessagePersist(queueKey string, msgUuid string) {
if Client == nil || msgUuid == "" { if Client == nil || msgUuid == "" {
return return
} }
hashKey := redisMsgHashPrefix + queueKey hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel()
defer cancel() if err := Client.HDel(ctx, hashKey, msgUuid).Err(); err != nil {
if err := Client.HDel(ctx, hashKey, msgUuid).Err(); err != nil { log.Errorf("persistence: HDel message %s/%s: %v", queueKey, msgUuid, err)
log.Errorf("persistence: HDel message %s/%s: %v", queueKey, msgUuid, err) }
}
})
} }
// DeleteMessagesPersist — удаляет несколько сообщений из Redis. // DeleteMessagesPersist — удаляет несколько сообщений из Redis СИНХРОННО
// Вызывать при DeleteMessageBatch. // (иначе удалённые сообщения воскреснут после рестарта пода).
func DeleteMessagesPersist(queueKey string, uuids []string) { func DeleteMessagesPersist(queueKey string, uuids []string) {
if Client == nil || len(uuids) == 0 { if Client == nil || len(uuids) == 0 {
return return
} }
hashKey := redisMsgHashPrefix + queueKey hashKey := redisMsgHashPrefix + queueKey
// Копируем uuids — вызывающий может переиспользовать slice ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ids := make([]string, len(uuids)) defer cancel()
copy(ids, uuids) if err := Client.HDel(ctx, hashKey, uuids...).Err(); err != nil {
asyncWrite(func() { log.Errorf("persistence: HDel messages %s (%d): %v", queueKey, len(uuids), err)
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)
}
})
} }
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis (для PurgeQueue). // PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis СИНХРОННО.
// Удаляет весь HASH ssq:msg:{queueKey}. // Синхронность обязательна: при рестарте пода удалённые сообщения не должны
// воскреснуть (факт: асинхронное удаление очередей приводило к их возврату).
func PurgeMessagesPersist(queueKey string) { func PurgeMessagesPersist(queueKey string) {
if Client == nil { if Client == nil {
return return
} }
hashKey := redisMsgHashPrefix + queueKey hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()
defer cancel() if err := Client.Del(ctx, hashKey).Err(); err != nil {
if err := Client.Del(ctx, hashKey).Err(); err != nil { log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err)
log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err) }
}
})
} }
// DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно. // DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно.
@@ -242,21 +236,22 @@ func DeleteQueue(key string) {
} }
writeSeq := nextQueueWriteSeq(key) writeSeq := nextQueueWriteSeq(key)
msgHashKey := redisMsgHashPrefix + key msgHashKey := redisMsgHashPrefix + key
asyncWrite(func() { // СИНХРОННО: удаление очереди обязано попасть в Redis до ответа клиенту.
if !isLatestQueueWriteSeq(key, writeSeq) { // Асинхронное удаление приводило к «воскрешению» удалённых очередей после
return // рестарта пода (факт 2026-08-15: 50 мусорных очередей вернулись из Redis).
} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()
defer cancel() if !isLatestQueueWriteSeq(key, writeSeq) {
// Удаляем метаданные из общего HASH return
if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil { }
log.Errorf("persistence: HDel queue meta %q: %v", key, err) // Удаляем метаданные из общего HASH
} if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil {
// Удаляем весь HASH с сообщениями log.Errorf("persistence: HDel queue meta %q: %v", key, err)
if err := Client.Del(ctx, msgHashKey).Err(); err != nil { }
log.Errorf("persistence: DEL messages hash %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 в память при старте сервиса. // LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
+91 -17
View File
@@ -7,7 +7,6 @@ import (
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io"
"net/http" "net/http"
"strings" "strings"
@@ -19,6 +18,7 @@ import (
sqs "shared-sqs/app/gosqs" sqs "shared-sqs/app/gosqs"
"shared-sqs/app/interfaces" "shared-sqs/app/interfaces"
"shared-sqs/app/metrics" "shared-sqs/app/metrics"
"shared-sqs/app/models"
"shared-sqs/app/tenant" "shared-sqs/app/tenant"
"shared-sqs/app/ui" "shared-sqs/app/ui"
@@ -38,9 +38,26 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
// /health — публичный, без auth // /health — публичный, без auth
r.HandleFunc("/health", health).Methods("GET") r.HandleFunc("/health", health).Methods("GET")
// /static — логотип, favicon для публичных страниц
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
// /metrics — Prometheus endpoint для Victoria Metrics scrape // /metrics — Prometheus endpoint для Victoria Metrics scrape
r.Handle("/metrics", promhttp.Handler()).Methods("GET") 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 // Admin API — Bearer token auth, регистрируется через AdminHandler
adminHandler := admin.NewHandler(tenantStore, adminToken) adminHandler := admin.NewHandler(tenantStore, adminToken)
adminHandler.RegisterRoutes(r) 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 // r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0
// при пустом prefix — ответы теряются. Поэтому используем явную обёртку. // при пустом prefix — ответы теряются. Поэтому используем явную обёртку.
sqsAuth := auth.AuthMiddleware(tenantStore) 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("/{account}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
r.Handle("/queue/{queueName}", 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") 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) { func encodeResponse(w http.ResponseWriter, req *http.Request, statusCode int, body interfaces.AbstractResponseBody) {
protocol := resolveProtocol(req) protocol := resolveProtocol(req)
switch protocol { switch protocol {
case AwsJsonProtocol: 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("x-amzn-RequestId", body.GetRequestId())
w.Header().Set("Content-Type", "application/x-amz-json-1.0") w.Header().Set("Content-Type", "application/x-amz-json-1.0")
w.WriteHeader(statusCode) 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) { func health(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200) w.WriteHeader(200)
fmt.Fprint(w, "OK") fmt.Fprintf(w, `{"status":"ok","version":%q}`, models.Version)
} }
func actionHandler(w http.ResponseWriter, req *http.Request) { func actionHandler(w http.ResponseWriter, req *http.Request) {
@@ -168,9 +220,16 @@ func actionHandler(w http.ResponseWriter, req *http.Request) {
} }
return 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) log.Warnf("Bad Request - Action: %s", action)
w.WriteHeader(http.StatusBadRequest) errType := models.SqsErrors["GeneralError"]
io.WriteString(w, "Bad Request") encodeResponse(w, req, errType.StatusCode(), models.ErrorResponse{
Result: errType.Response(),
RequestId: "00000000-0000-0000-0000-000000000000",
})
} }
type AwsProtocol int type AwsProtocol int
@@ -180,24 +239,39 @@ const (
AwsQueryProtocol AwsProtocol = iota 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 { func extractAction(req *http.Request) string {
protocol := resolveProtocol(req) if action := req.Header.Get("X-Amz-Target"); action != "" {
switch protocol {
case AwsJsonProtocol:
action := req.Header.Get("X-Amz-Target")
parts := strings.SplitN(action, ".", 2) parts := strings.SplitN(action, ".", 2)
if len(parts) != 2 || parts[1] == "" { if len(parts) == 2 && parts[1] != "" {
return "" 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 { func resolveProtocol(req *http.Request) AwsProtocol {
if req.Header.Get("Content-Type") == "application/x-amz-json-1.0" { if req.Header.Get("Content-Type") == "application/x-amz-json-1.0" {
return AwsJsonProtocol 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 ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"sync" "sync"
"time" "time"
@@ -16,10 +18,15 @@ import (
// MaxTenantsGlobal — глобальный лимит тенантов (защита от OOM, Fix #12) // MaxTenantsGlobal — глобальный лимит тенантов (защита от OOM, Fix #12)
const MaxTenantsGlobal = 1000 const MaxTenantsGlobal = 1000
// DefaultTenantMaxQueues — лимит очередей для JWT-тенантов (auto-provisioning).
// Используется в app/admin при логине; при каждом логине лимит существующего
// тенанта обновляется до этого значения (см. CreateFromJWT).
const DefaultTenantMaxQueues = 50
// Tenant — модель тенанта shared-sqs. // Tenant — модель тенанта shared-sqs.
// AccessKey используется как идентификатор в AWS Authorization header. // AccessKey используется как идентификатор в AWS Authorization header.
type Tenant struct { type Tenant struct {
ID string // уникальный идентификатор тенанта (sless-<hex> из JWT sub, или t-<hex> для legacy) ID string // уникальный идентификатор тенанта (t-<hex>: детерминированный из email для JWT-тенантов, случайный для manual)
Name string // человекочитаемое имя Name string // человекочитаемое имя
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>) AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
SecretKey string // аналог AWS SecretAccessKey (64 hex chars) SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
@@ -31,12 +38,12 @@ type Tenant struct {
} }
// TenantStore — потокобезопасное in-memory хранилище тенантов. // TenantStore — потокобезопасное in-memory хранилище тенантов.
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth). // Три индекса: по ID (admin API), по AccessKey (auth middleware), по Email (JWT auth).
type TenantStore struct { type TenantStore struct {
mu sync.RWMutex mu sync.RWMutex
byID map[string]*Tenant byID map[string]*Tenant
byAccessKey map[string]*Tenant byAccessKey map[string]*Tenant
bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim) byEmail map[string]*Tenant // индекс по Email (JWT claim). Тенанты без email сюда НЕ попадают.
} }
// NewTenantStore — создаёт пустое хранилище тенантов. // NewTenantStore — создаёт пустое хранилище тенантов.
@@ -44,7 +51,7 @@ func NewTenantStore() *TenantStore {
return &TenantStore{ return &TenantStore{
byID: make(map[string]*Tenant), byID: make(map[string]*Tenant),
byAccessKey: 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.mu.Lock()
s.byID[t.ID] = t s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t s.byAccessKey[t.AccessKey] = t
if t.NubesSub != "" { if t.Email != "" {
s.bySub[t.NubesSub] = t s.byEmail[t.Email] = t
} }
s.mu.Unlock() s.mu.Unlock()
@@ -156,8 +163,8 @@ func (s *TenantStore) Delete(id string) bool {
} }
delete(s.byID, t.ID) delete(s.byID, t.ID)
delete(s.byAccessKey, t.AccessKey) delete(s.byAccessKey, t.AccessKey)
if t.NubesSub != "" { if t.Email != "" {
delete(s.bySub, t.NubesSub) delete(s.byEmail, t.Email)
} }
s.mu.Unlock() s.mu.Unlock()
@@ -173,60 +180,71 @@ func (s *TenantStore) LoadTenant(t *Tenant) {
s.mu.Lock() s.mu.Lock()
s.byID[t.ID] = t s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t s.byAccessKey[t.AccessKey] = t
if t.NubesSub != "" { if t.Email != "" {
s.bySub[t.NubesSub] = t s.byEmail[t.Email] = t
} }
s.mu.Unlock() s.mu.Unlock()
} }
// GetBySub — поиск тенанта по NubesSub (JWT sub claim). // GetByEmail — поиск тенанта по Email (JWT claim).
// Используется при JWT-авторизации через UI. // Используется при JWT-авторизации через UI.
func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) { func (s *TenantStore) GetByEmail(email string) (*Tenant, bool) {
s.mu.RLock() s.mu.RLock()
t, ok := s.bySub[sub] t, ok := s.byEmail[email]
s.mu.RUnlock() s.mu.RUnlock()
return t, ok return t, ok
} }
// CreateFromJWT — auto-provisioning тенанта из JWT claims. // CreateFromJWT — auto-provisioning тенанта из JWT claims.
// ID = TenantIDFromSub(sub) — совместим с sless namespace. // Идентичность — ТОЛЬКО email: ID/AccessKey/SecretKey детерминированы из email.
// Если тенант с таким sub уже существует — возвращает его (идемпотентно). // Тот же email на любом стенде → тот же тенант и те же креды. sub — информационное поле.
func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) (*Tenant, error) { // Легаси-тенант (старые случайные ключи / другой 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() s.mu.Lock()
// Идемпотентность: если тенант с таким sub уже есть — возвращаем defer s.mu.Unlock()
if existing, ok := s.bySub[sub]; ok {
// Обновляем email если изменился // Идемпотентность: тенант с таким email уже есть
if email != "" && existing.Email != email { if existing, ok := s.byEmail[email]; ok {
existing.Email = email // Полное совпадение — возвращаем как есть
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() // Легаси: убираем старую запись из всех индексов и из Redis, ниже создаём новую
return existing, nil delete(s.byID, existing.ID)
delete(s.byAccessKey, existing.AccessKey)
delete(s.byEmail, existing.Email)
persistence.DeleteTenant(existing.ID)
} }
if len(s.byID) >= MaxTenantsGlobal { if len(s.byID) >= MaxTenantsGlobal {
s.mu.Unlock()
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal) 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{ t := &Tenant{
ID: tenantID, ID: tenantID,
Name: name, Name: email,
AccessKey: accessKey, AccessKey: accessKey,
SecretKey: secretKey, SecretKey: secretKey,
MaxQueues: maxQueues, MaxQueues: maxQueues,
@@ -238,8 +256,7 @@ func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int)
s.byID[t.ID] = t s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t s.byAccessKey[t.AccessKey] = t
s.bySub[t.NubesSub] = t s.byEmail[t.Email] = t
s.mu.Unlock()
// Сохраняем в Redis // Сохраняем в Redis
if data, err := json.Marshal(t); err == nil { 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 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). // List — список всех тенантов (для admin GET /tenants).
func (s *TenantStore) List() []*Tenant { func (s *TenantStore) List() []*Tenant {
s.mu.RLock() 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 ( import (
"embed" "embed"
"io/fs"
"net/http" "net/http"
"strings"
) )
//go:embed index.html //go:embed index.html info.html admin.html examples.html static
var content embed.FS var content embed.FS
// Handler — возвращает http.Handler, раздающий встроенный index.html // Handler — возвращает http.Handler, раздающий встроенный index.html
func Handler() http.Handler { func Handler() http.Handler {
return http.FileServer(http.FS(content)) 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 charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SQS Console — Nubes</title> <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> <style>
/* Nubes palette — идентично iot.kube5s.ru/console */ /* Nubes design system — светлая тема (deck) */
:root { :root {
--bg-page: #001120; --bg-page: #fafafa;
--bg-surface: #001929; --bg-surface: #ffffff;
--bg-navbar: #001C34; --bg-navbar: #ffffff;
--border: #0b2d50; --border: #d1d5db;
--accent: #1a7fd4; --accent: #2563eb;
--accent-hover: #2196f3; --accent-hover: #1d4ed8;
--text-primary: #e2ecf6; --text-primary: #1a1a1a;
--text-secondary: #6b8eaa; --text-secondary: #6b7280;
--danger: #e74c3c; --danger: #dc2626;
--success: #27ae60; --success: #16a34a;
--warning: #f39c12; --warning: #d97706;
--brand-grey-light: #f3f4f6;
} }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { 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); background: var(--bg-page);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
@@ -55,7 +57,6 @@ a:hover { color: var(--accent-hover); }
} }
.navbar-brand img { .navbar-brand img {
height: 28px; height: 28px;
filter: brightness(0) invert(1);
} }
.navbar-brand span { .navbar-brand span {
font-size: 16px; font-size: 16px;
@@ -88,7 +89,8 @@ a:hover { color: var(--accent-hover); }
.card { .card {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
padding: 20px; padding: 20px;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -111,15 +113,17 @@ a:hover { color: var(--accent-hover); }
.stat-card { .stat-card {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
padding: 20px; padding: 20px;
text-align: center; text-align: center;
} }
.stat-value { .stat-value {
font-size: 36px; font-size: 30px;
font-weight: 700; font-weight: 700;
color: var(--accent); color: var(--text-primary);
} }
.stat-value.accent { color: var(--accent); }
.stat-label { .stat-label {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
@@ -149,7 +153,7 @@ tbody td {
padding: 12px 16px; padding: 12px 16px;
border-bottom: 1px solid var(--border); 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; } tbody tr { cursor: pointer; }
/* Buttons */ /* Buttons */
@@ -168,8 +172,8 @@ tbody tr { cursor: pointer; }
.btn-primary { background: var(--accent); color: #fff; } .btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); } .btn-primary:hover { background: var(--accent-hover); }
.btn-danger { background: var(--danger); color: #fff; } .btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #c0392b; } .btn-danger:hover { background: #b91c1c; }
.btn-sm { padding: 4px 12px; font-size: 12px; } .btn-sm { padding: 4px 12px; font-size: 13px; }
/* Forms */ /* Forms */
.form-group { margin-bottom: 16px; } .form-group { margin-bottom: 16px; }
@@ -182,9 +186,9 @@ tbody tr { cursor: pointer; }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;
background: var(--bg-page); background: #ffffff;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: 8px;
color: var(--text-primary); color: var(--text-primary);
font-size: 14px; font-size: 14px;
} }
@@ -208,10 +212,10 @@ tbody tr { cursor: pointer; }
width: 400px; width: 400px;
text-align: center; 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 { .login-box h1 {
font-size: 18px; font-size: 22px;
letter-spacing: 2px; letter-spacing: 1px;
margin-bottom: 32px; margin-bottom: 32px;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -225,15 +229,15 @@ tbody tr { cursor: pointer; }
/* Badges */ /* Badges */
.badge { .badge {
display: inline-block; display: inline-block;
padding: 2px 8px; padding: 2px 10px;
border-radius: 10px; border-radius: 999px;
font-size: 11px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
.badge-active { background: rgba(39,174,96,0.15); color: var(--success); } .badge-active { background: rgba(22,163,74,0.12); color: var(--success); }
.badge-inactive { background: rgba(231,76,60,0.15); color: var(--danger); } .badge-inactive { background: rgba(220,38,38,0.1); color: var(--danger); }
.badge-count { .badge-count {
background: rgba(26,127,212,0.15); background: rgba(37,99,235,0.1);
color: var(--accent); color: var(--accent);
min-width: 24px; min-width: 24px;
text-align: center; text-align: center;
@@ -252,7 +256,7 @@ tbody tr { cursor: pointer; }
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0,0,0,0.6); background: rgba(0,0,0,0.45);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -262,12 +266,15 @@ tbody tr { cursor: pointer; }
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.15);
padding: 32px; padding: 32px;
width: 480px; width: 560px;
max-width: 90vw; max-width: 92vw;
max-height: 88vh;
overflow-y: auto;
} }
.modal h2 { .modal h2 {
font-size: 18px; font-size: 20px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.modal-actions { .modal-actions {
@@ -304,7 +311,7 @@ tbody tr { cursor: pointer; }
margin-bottom: 16px; margin-bottom: 16px;
} }
.toolbar h2 { .toolbar h2 {
font-size: 18px; font-size: 20px;
font-weight: 600; font-weight: 600;
} }
.auto-refresh { .auto-refresh {
@@ -315,6 +322,36 @@ tbody tr { cursor: pointer; }
color: var(--text-secondary); 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 */ /* Expandable message rows */
td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); } td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
.msg-expand-inner { padding: 12px 20px; background: var(--bg-page); } .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 ===== --> <!-- ===== LOGIN PAGE ===== -->
<div id="login-page" class="login-page"> <div id="login-page" class="login-page">
<div class="login-box"> <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> <h1>SQS CONSOLE</h1>
<div class="form-group"> <div class="form-group">
<label for="login-token">API Token или Demo Token</label> <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> <button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px"> <p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br> Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br>
Для личного tenant-а используйте API token из панели Nubes. Для личного аккаунта используйте API token из панели Nubes.
</p> </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>
</div> </div>
@@ -346,7 +394,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<div id="app" class="hidden"> <div id="app" class="hidden">
<nav class="navbar"> <nav class="navbar">
<div class="navbar-brand"> <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> <span>SQS CONSOLE</span>
</div> </div>
<div class="navbar-user"> <div class="navbar-user">
@@ -365,7 +413,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<!-- ===== CREATE TENANT MODAL ===== --> <!-- ===== CREATE TENANT MODAL ===== -->
<div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeModal()"> <div id="modal-create" class="modal-overlay hidden" onclick="if(event.target===this)closeModal()">
<div class="modal"> <div class="modal">
<h2>Создать тенанта</h2> <h2>Создать аккаунт</h2>
<div class="form-group"> <div class="form-group">
<label for="ct-name">Имя</label> <label for="ct-name">Имя</label>
<input id="ct-name" placeholder="my-service"> <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 ===== --> <!-- ===== CREDENTIALS MODAL ===== -->
<div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()"> <div id="modal-creds" class="modal-overlay hidden" onclick="if(event.target===this)closeCredsModal()">
<div class="modal"> <div class="modal">
<h2>Тенант создан</h2> <h2>Credentials</h2>
<p style="color:var(--warning);font-size:13px;margin-bottom:16px"> <div class="creds-hint">
⚠ Сохраните credentials — Secret Key показывается только один раз. <p class="warn">⚠ Сохраните ключи — это креды вашего аккаунта для AWS CLI/SDK.</p>
</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"> <div class="form-group">
<label>Access Key</label> <label>Access Key</label>
<input id="creds-ak" readonly onclick="copyField(this)"> <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> <h2>Отправить сообщение</h2>
<div class="form-group"> <div class="form-group">
<label for="send-body">Тело сообщения</label> <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>
<div id="send-error" class="login-error"></div> <div id="send-error" class="login-error"></div>
<div class="modal-actions"> <div class="modal-actions">
@@ -429,7 +488,7 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Body (нажмите, чтобы скопировать)</label> <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>
<div class="form-group"> <div class="form-group">
<label>Отправлено</label> <label>Отправлено</label>
@@ -486,6 +545,14 @@ let sessionEmail = null; // Email из JWT для отображения в
} }
} catch(e) { /* corrupted — show login */ } } 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(); showLogin();
})(); })();
@@ -542,7 +609,13 @@ function enterApp() {
document.getElementById('login-page').classList.add('hidden'); document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden'); document.getElementById('app').classList.remove('hidden');
document.getElementById('user-email').textContent = sessionEmail || ''; 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 ===== // ===== API HELPER =====
@@ -593,10 +666,6 @@ function renderDashboard(health, tenants) {
const el = document.getElementById('view-dashboard'); const el = document.getElementById('view-dashboard');
el.innerHTML = ` el.innerHTML = `
<div class="stats-grid"> <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-card">
<div class="stat-value">${health.queue_count || 0}</div> <div class="stat-value">${health.queue_count || 0}</div>
<div class="stat-label">Очереди</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-value" style="color:var(--success)">●</div>
<div class="stat-label">${health.status || 'ok'}</div> <div class="stat-label">${health.status || 'ok'}</div>
</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>
<div class="card"> <div class="card">
<div class="toolbar"> <div class="toolbar">
<h2>Мой tenant</h2> <h2>Мой аккаунт</h2>
<div style="display:flex;gap:12px;align-items:center"> <div style="display:flex;gap:12px;align-items:center">
<div class="auto-refresh"> <div class="auto-refresh">
<span>⟳ 10с</span> <span>⟳ 10с</span>
@@ -624,7 +697,6 @@ function renderDashboard(health, tenants) {
<thead> <thead>
<tr> <tr>
<th>Имя</th> <th>Имя</th>
<th>Access Key</th>
<th>Макс. очередей</th> <th>Макс. очередей</th>
<th>Статус</th> <th>Статус</th>
<th>Создан</th> <th>Создан</th>
@@ -634,7 +706,6 @@ function renderDashboard(health, tenants) {
${(tenants || []).map(t => ` ${(tenants || []).map(t => `
<tr onclick="showTenant('${esc(t.id)}')"> <tr onclick="showTenant('${esc(t.id)}')">
<td><strong>${esc(t.name)}</strong></td> <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.max_queues}</td>
<td>${t.active <td>${t.active
? '<span class="badge badge-active">active</span>' ? '<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> <td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</td>
</tr> </tr>
`).join('')} `).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> </tbody>
</table> </table>
</div> </div>
@@ -676,11 +747,6 @@ function renderTenant(tenant, queues) {
const el = document.getElementById('view-tenant'); const el = document.getElementById('view-tenant');
const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0); const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0);
el.innerHTML = ` 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="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-value">${(queues || []).length}</div> <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-value">${tenant.max_queues}</div>
<div class="stat-label">Лимит очередей</div> <div class="stat-label">Лимит очередей</div>
</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>
<div class="card"> <div class="card">
<div class="toolbar"> <div class="toolbar">
<h2>Очереди</h2> <h2>Очереди</h2>
<div style="display:flex;gap:12px;align-items:center"> <div style="display:flex;gap:12px;align-items:center">
<div class="auto-refresh"><span>⟳ 10с</span></div> <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> <button class="btn btn-primary btn-sm" onclick="openCreateQueueModal('${esc(tenant.id)}')" >+ Очередь</button>
</div> </div>
</div> </div>
@@ -794,9 +857,22 @@ function closeCredsModal() {
document.getElementById('modal-creds').classList.add('hidden'); 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} с подтверждением // deleteTenant — DELETE /tenants/{id} с подтверждением
function deleteTenant(id, name) { function deleteTenant(id, name) {
if (!confirm('Удалить тенанта "' + name + '"?\nВсе его очереди будут удалены.')) return; if (!confirm('Удалить аккаунт "' + name + '"?\nВсе очереди будут удалены.')) return;
api('/tenants/' + id, { method: 'DELETE' }) api('/tenants/' + id, { method: 'DELETE' })
.then(() => loadDashboard()) .then(() => loadDashboard())
.catch(err => alert('Ошибка удаления: ' + err.message)); .catch(err => alert('Ошибка удаления: ' + err.message));
@@ -949,7 +1025,7 @@ function renderQueueMessages(queueName, msgs) {
<tbody> <tbody>
${msgs.map(m => ` ${msgs.map(m => `
<tr onclick="openMsgDetail('${esc(m.id)}')" style="cursor:pointer" <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;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;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> <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 # Сравнительный анализ shared-sqs vs Yandex MQ
**Дата:** 2026-04-12 06:05 UTC **Дата:** 2026-04-12 06:05 UTC
@@ -1,3 +1,8 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
Старая памятка для агента/нового чата (апрель 2026).
Перенесена из корня репозитория в doc/legacy 2026-08-13.
Описывает устаревший k8s-деплой и порядок работы. -->
# ONBOARDING.md — Памятка для нового чата / агента # ONBOARDING.md — Памятка для нового чата / агента
# Дата: 2026-04-10 # Дата: 2026-04-10
# Автор: GitHub Copilot (Claude Opus 4.6) # Автор: 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 — План реализации # shared-sqs — План реализации
**Дата:** 2026-04-09 **Дата:** 2026-04-09
@@ -845,7 +850,7 @@ shared-sqs/
- ❌ UI — нет UI, только API - ❌ UI — нет UI, только API
- ❌ Scale-to-zero — один pod всегда работает - ❌ Scale-to-zero — один pod всегда работает
ENDOFPLAN cat > ~/terra/sless/shared-sqs/PLAN.md << 'ENDOFPLAN' ENDOFPLAN cat > ~/terra/sless/shared-sqs/PLAN.md << 'ENDOFPLAN'
# shared-sqs — План реализации # shared-sqs — План реализации (heredoc-дубль)
**Дата:** 2026-04-09 **Дата:** 2026-04-09
**Исполнитель:** Claude Sonnet (или другой агент) **Исполнитель:** Claude Sonnet (или другой агент)
@@ -1,3 +1,5 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ. Исторический бенчмарк (апрель 2026). Помечено 2026-08-13. -->
# Сравнительный benchmark shared-sqs vs Yandex MQ # Сравнительный benchmark shared-sqs vs Yandex MQ
**Дата:** 2026-04-12 05:45 UTC **Дата:** 2026-04-12 05:45 UTC
@@ -1,3 +1,5 @@
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ. Внешний справочник Yandex MQ (для сравнения). Помечено 2026-08-13. -->
# Yandex Message Queue API Reference # Yandex Message Queue API Reference
**Дата документации:** 11 апреля 2026 **Дата документации:** 11 апреля 2026
@@ -1,3 +1,6 @@
<!-- ⚠️ ЛЕГАСИ — история версий разработки (апрель 2026).
ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ. Только для истории. Помечено 2026-08-13. -->
# SQS-service Progress # SQS-service Progress
## Версия v0.1.x ## Версия 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.2–32.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