Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4ebb3a7a | ||
|
|
716f931544 | ||
|
|
7860c7c3f2 | ||
|
|
4e648f690e | ||
|
|
b864cab5be | ||
|
|
f99f49bd8e | ||
|
|
fa47026400 | ||
|
|
a08d58cba1 | ||
|
|
5284a56f7a | ||
|
|
120a44cb6a | ||
|
|
e8e0879914 | ||
|
|
582ad4a832 | ||
|
|
0512ea2389 | ||
|
|
dd9acb3705 | ||
|
|
e720f1ffcb | ||
|
|
b179ec6de7 | ||
|
|
9e0ad2c0a4 | ||
|
|
09fe0f7e66 | ||
|
|
24dbdaa766 | ||
|
|
84ef8f1fc9 | ||
|
|
ccd070200a | ||
|
|
9f1242aca3 | ||
|
|
22fa2b162a | ||
|
|
bb6dbfd349 | ||
|
|
4e2b462379 | ||
|
|
e21abb8f13 | ||
|
|
521e3d85d0 | ||
|
|
ea7b539d19 | ||
|
|
073977286f | ||
|
|
27fa2e6ae3 | ||
|
|
d7b3c653e0 | ||
|
|
607aebb8ef | ||
|
|
a341508b6d | ||
|
|
064ed6e5d7 | ||
|
|
8e22bc4b9b | ||
|
|
1862f058be | ||
|
|
a26e6da8b7 | ||
|
|
a66b908689 | ||
|
|
c4cc3c80ea | ||
|
|
f7fe2f26e5 | ||
|
|
cf369736a0 | ||
|
|
c011099224 | ||
|
|
f0e7cc905d | ||
|
|
ee5503f9ac | ||
|
|
6800d1abf3 | ||
|
|
7a2a7e1610 | ||
|
|
3b0815155a | ||
|
|
39b162439f | ||
|
|
b99905469d | ||
|
|
d8bdea75f3 | ||
|
|
a1d72b61d4 | ||
|
|
b947f1d467 | ||
|
|
572c617f44 | ||
|
|
33a0adc9be | ||
|
|
2e1c594892 | ||
|
|
f96649b14e | ||
|
|
529d208576 | ||
|
|
ad14fd2f7e | ||
|
|
c5b2c11f5d | ||
|
|
e735563a04 | ||
|
|
31840b9756 | ||
|
|
d53aadecfa | ||
|
|
8988293628 | ||
|
|
fd8ffc329d | ||
|
|
ed17c6cbd0 | ||
|
|
3a52960d5b | ||
|
|
fb833ef01c | ||
|
|
07eace3c6b | ||
|
|
8c39bc5346 | ||
|
|
b7f79b388f | ||
|
|
2aa9a656a7 | ||
|
|
876971eea8 | ||
|
|
424ec32a6a | ||
|
|
db41878951 | ||
|
|
5642b893a8 | ||
|
|
ac06572e25 | ||
|
|
12b25e393d | ||
|
|
c19a056de3 | ||
|
|
928a3fb85d | ||
|
|
6e96a4d007 | ||
|
|
9528441c97 | ||
|
|
e8ccd8bae1 | ||
|
|
60b15fdb1c | ||
|
|
7897fda9e7 | ||
|
|
96f6d64184 | ||
|
|
6d451c33d1 | ||
|
|
5c2180d40e | ||
|
|
cd7f5a7d5a | ||
|
|
5f6c0a5bf7 | ||
|
|
b91f21a50d | ||
|
|
bd63c04ea3 | ||
|
|
7460f59432 | ||
|
|
9950271259 | ||
|
|
904d34904c | ||
|
|
2d47e86425 | ||
|
|
cc20282ab0 | ||
|
|
28f8527f8b | ||
|
|
9f69262b8c | ||
|
|
c66ac28766 | ||
|
|
33735b0deb | ||
|
|
dcafb1ad71 | ||
|
|
d850e56cd9 | ||
|
|
1862975a57 | ||
|
|
97db8f267d | ||
|
|
5599561527 | ||
|
|
d452afe854 | ||
|
|
ff70bbeada | ||
|
|
4e093db632 | ||
|
|
bc198dfb0c | ||
|
|
ee23079267 | ||
|
|
1869ce922b | ||
|
|
23718f0b0e | ||
|
|
072518ec52 | ||
|
|
1c407be9d9 | ||
|
|
a1936b760f | ||
|
|
854effb206 | ||
|
|
63fb4fe93b | ||
|
|
d57aec40b5 | ||
|
|
c088d7b938 | ||
|
|
6466d3cb75 | ||
|
|
4badef12ea | ||
|
|
f689684fef | ||
|
|
10a5853c61 | ||
|
|
a1b1fe3b25 | ||
|
|
7169bf6dfd | ||
|
|
32bc33b8c1 | ||
|
|
a838f756f9 | ||
|
|
936268c217 | ||
|
|
b5695dbd53 | ||
|
|
ae338d2b4a | ||
|
|
a6a67244c8 | ||
|
|
56e87eaf1e | ||
|
|
0e0af356e4 | ||
|
|
9ec4673893 | ||
|
|
de033a1830 | ||
|
|
d3c29d8e3e | ||
|
|
5429f1f62b | ||
|
|
2d1880b54c | ||
|
|
178b9f5a50 | ||
|
|
bb7e5aa702 | ||
|
|
f9435c9126 | ||
|
|
f9846918aa | ||
|
|
d0a08105e3 | ||
|
|
5b5d530091 | ||
|
|
9782e9231c | ||
|
|
bb33963fbb | ||
|
|
816e969025 | ||
|
|
e0ceec7909 | ||
|
|
05e39771a6 | ||
|
|
5ed259fefe | ||
|
|
8a8d232742 | ||
|
|
c1d5408862 | ||
|
|
160816f4db | ||
|
|
dbcd8b1921 | ||
|
|
d1af612b48 | ||
|
|
d7e9d90453 | ||
|
|
dd08427424 | ||
|
|
8933b91aa5 | ||
|
|
4bf3794618 | ||
|
|
1a00ec9799 | ||
|
|
4a83575491 | ||
|
|
d5d3519836 | ||
|
|
70cfc0d83d | ||
|
|
a1ff9c4e52 | ||
|
|
eba01c9580 |
@@ -1,91 +1,20 @@
|
|||||||
# Правила работы агента в этом проекте
|
# Правила (кратко)
|
||||||
|
|
||||||
## ГЛАВНОЕ ПРАВИЛО
|
## ⛔⛔⛔ НИКАКИХ ПРЕДПОЛОЖЕНИЙ — ТОЛЬКО ФАКТЫ
|
||||||
|
- Только проверенные факты. Не проверено — писать «не проверено».
|
||||||
|
|
||||||
**НЕ "СОВЕРШЕНСТВОВАТЬ" РАБОЧИЙ КОД БЕЗ ЯВНОГО УКАЗАНИЯ.**
|
## ⛔ ОТВЕЧАТЬ КРАТКО
|
||||||
|
- Вопрос → короткий ответ → СТОП. Код — только по запросу.
|
||||||
|
|
||||||
---
|
## ⛔⛔⛔ ВОПРОС = СТОП
|
||||||
|
- Любой вопрос → только ответить, ждать «делай». Не начинать работу без команды.
|
||||||
|
|
||||||
## ЗАПРЕТ НА ВЫДУМКИ
|
## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД
|
||||||
|
- Никаких изменений кода/инфраструктуры без явного «делай».
|
||||||
|
|
||||||
**КАТЕГОРИЧЕСКИ ЗАПРЕЩАЕТСЯ придумывать, догадываться или предполагать:**
|
## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРИКАЗАНО
|
||||||
- значения параметров, которые не видны в коде или документации
|
- Без додумывания, без «логичных следующих шагов».
|
||||||
- допустимые значения enum/ролей/типов — если не взяты из реального источника
|
|
||||||
- поведение API, провайдеров, библиотек — если не подтверждено кодом или документацией
|
|
||||||
- любые факты о системе, которые агент "знает" из общих соображений
|
|
||||||
|
|
||||||
**Если информации нет — спросить у пользователя. Не угадывать.**
|
## ЕСЛИ ПРАВИШЬ КОД
|
||||||
|
- Сначала прочитай /home/naeel/nubes/SQS-service/.github/pravila.md (там: git локально + после пуша синк на ВМ, docker/ssh на ВМ, деплой).
|
||||||
|
|
||||||
Если код работает — не трогать. Никаких:
|
|
||||||
- рефакторингов "попутно"
|
|
||||||
- улучшений стиля
|
|
||||||
- добавления комментариев / docstring
|
|
||||||
- переименований переменных
|
|
||||||
- "пока уж заодно поправлю"
|
|
||||||
|
|
||||||
Делай только то, о чём явно попросили. Ничего лишнего.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Комментарии в коде
|
|
||||||
|
|
||||||
Комментарии — обязательны:
|
|
||||||
- В начале каждого файла при создании или правке — дата и время изменения
|
|
||||||
- На каждой функции/методе — краткое назначение
|
|
||||||
- На нетривиальной логике — **почему** сделано именно так (не "что делает", а "зачем")
|
|
||||||
|
|
||||||
Цель: любой агент в новом чате должен понять логику без дополнительных вопросов.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Темп работы
|
|
||||||
|
|
||||||
Не спешить. Перед каждым шагом — убедиться что предыдущий понят и согласован.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
|
|
||||||
Всё важное фиксировать в `doc/`:
|
|
||||||
- `doc/architecture/` — архитектура, стек, схемы
|
|
||||||
- `doc/api/` — дизайн API
|
|
||||||
- `doc/decisions/` — принятые решения с обоснованием
|
|
||||||
- `doc/infrastructure/` — инфраструктура, кластер, сервисы
|
|
||||||
- `doc/errors/` — ошибки и как решили
|
|
||||||
- `doc/progress.md` — трекер задач
|
|
||||||
|
|
||||||
Обновлять после каждого значимого изменения.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Именование
|
|
||||||
|
|
||||||
Имена должны быть **уникальными и осмысленными по всему проекту**:
|
|
||||||
- имена файлов
|
|
||||||
- имена функций/методов
|
|
||||||
- имена переменных/констант
|
|
||||||
- имена ресурсов (Terraform, Kubernetes и т.д.)
|
|
||||||
|
|
||||||
Цель: чтобы поиск по проекту находил нужные сущности без неоднозначности, а имя сразу отражало назначение.
|
|
||||||
|
|
||||||
Запрещены безликие и повторяющиеся имена вида `handler.py`, `handle`, `data`, `value`, `temp` без контекста.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Лог мышления (обязательно)
|
|
||||||
|
|
||||||
Каждый агент в каждом чате **обязан** вести лог своих рассуждений:
|
|
||||||
- Папка: `doc/thinking/`
|
|
||||||
- Файл: `ГГГГ-ММ-ДД.md` (по дате сессии)
|
|
||||||
- В начале файла указать имя агента и модель
|
|
||||||
- Если файл на текущую дату уже существует — дописывать в конец, добавив разделитель `---` и имя агента
|
|
||||||
- Записывать **полный** ход мыслей: что анализирую, какие гипотезы, что нашёл, что отбросил, к чему пришёл, почему
|
|
||||||
- Записывать **до** начала действий (план) и **после** (результат)
|
|
||||||
|
|
||||||
Цель: пользователь должен видеть весь процесс рассуждений в читаемом виде.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git
|
|
||||||
|
|
||||||
Коммитить и пушить после каждого завершённого этапа.
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Правила работы агента
|
||||||
|
|
||||||
|
## ⛔⛔⛔ НИКАКИХ ПРЕДПОЛОЖЕНИЙ — ТОЛЬКО ФАКТЫ
|
||||||
|
|
||||||
|
**АБСОЛЮТНОЕ ПРАВИЛО:**
|
||||||
|
- Не выдавать предположения/допущения за факты.
|
||||||
|
- Говорить только то, что ПРОВЕРЕНО (прочитано, выполнено, подтверждено).
|
||||||
|
- Если не проверено — прямо писать «не проверено», не додумывать.
|
||||||
|
- Не строить выводы о реальном состоянии системы на основе манифестов/доков.
|
||||||
|
|
||||||
|
## ⛔⛔⛔ ВОПРОС = СТОП
|
||||||
|
|
||||||
|
**Если в сообщении есть вопрос в ЛЮБОЙ форме:**
|
||||||
|
1. ТОЛЬКО ответить на вопрос
|
||||||
|
2. ОСТАНОВИТЬСЯ
|
||||||
|
3. ЖДАТЬ следующей команды
|
||||||
|
**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай".
|
||||||
|
|
||||||
|
## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD
|
||||||
|
|
||||||
|
> ⚠️ ЛЕГАСИ — старый k8s-деплой (kubectl apply). Уходим на Nubes Managed. ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.
|
||||||
|
|
||||||
|
1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/deployment.yaml` (vX.Y.Z → vX.Y.Z+1)
|
||||||
|
2. rsync на ВМ
|
||||||
|
3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки)
|
||||||
|
4. docker build с НОВЫМ тегом
|
||||||
|
5. docker push с НОВЫМ тегом
|
||||||
|
6. kubectl apply (не rollout restart — apply подтягивает новый тег)
|
||||||
|
|
||||||
|
**НИКОГДА не делать `docker build` со старым тегом — под не перетянет образ (imagePullPolicy: IfNotPresent)**
|
||||||
|
|
||||||
|
## Файловая система (актуально)
|
||||||
|
|
||||||
|
1. Все файлы редактируются локально: `/home/naeel/nubes/SQS-service`
|
||||||
|
2. После любых изменений — обязательно rsync на ВМ:
|
||||||
|
rsync -az \
|
||||||
|
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
|
/home/naeel/nubes/SQS-service/ \
|
||||||
|
naeel@5.172.178.213:~/terra/SQS-service/
|
||||||
|
|
||||||
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО. После пуша — ОБЯЗАТЕЛЬНО синк на ВМ (rsync).
|
||||||
|
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
|
||||||
|
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
|
||||||
|
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
|
||||||
|
7. Только rsync для синхронизации
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
1. Редактируешь локально
|
||||||
|
2. git add/commit/push ЛОКАЛЬНО
|
||||||
|
3. rsync на ВМ (после пуша)
|
||||||
|
4. Выполняешь команды через SSH на ВМ
|
||||||
|
|
||||||
|
## SSH
|
||||||
|
|
||||||
|
Все команды — только через SSH на ВМ. Локально — только читать и редактировать файлы.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 — разрешён локально)
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- `doc/` — документация проекта
|
||||||
|
- Обновлять после каждого значимого изменения
|
||||||
|
- Старые файлы `doc/` не перезаписывать — новое в новых файлах с датой
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
|
||||||
|
- Git — ЛОКАЛЬНО в /home/naeel/nubes/SQS-service. После push — синк на ВМ (rsync).
|
||||||
|
- Разрешены операции: `git add`, `git commit`, `git push` (локально).
|
||||||
|
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме add/commit/push.
|
||||||
|
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
|
||||||
|
|
||||||
|
Версионирование тагами: `vMAJOR.MINOR.PATCH`
|
||||||
|
- Patch — любое изменение кода
|
||||||
|
- Minor — новая фича / компонент
|
||||||
|
- Major — breaking change
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag vX.Y.Z && git push origin vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⛔ ТЕРМИНАЛЬНЫЙ БУФЕР — НИКОГДА НЕ ЧИТАТЬ СТАРЫЙ
|
||||||
|
|
||||||
|
**АБСОЛЮТНОЕ ПРАВИЛО:**
|
||||||
|
- get_terminal_output из старых сессий — МУСОР. Там старые прогоны.
|
||||||
|
- Всегда запускать новую команду через SSH и читать её вывод напрямую.
|
||||||
|
- НИКОГДА не читать буфер терминала из предыдущей сессии как актуальные данные.
|
||||||
|
- Актуальный результат — только из команды, которая была запущена СЕЙЧАС.
|
||||||
|
|
||||||
|
## ⛔ ДОКУМЕНТАЦИЯ ТЕСТ-ПРОГОНОВ — В РЕАЛЬНОМ ВРЕМЕНИ
|
||||||
|
|
||||||
|
**Правила:**
|
||||||
|
1. Перед запуском тестов — создать файл `tests/results/YYYY-MM-DD_HH-MM.log` и записать в него метку времени и что запускается.
|
||||||
|
2. Запускать тесты с `2>&1 | tee ~/terra/SQS-service/tests/results/YYYY-MM-DD_HH-MM.log` — вывод пишется сразу в файл и отображается в терминале.
|
||||||
|
3. После завершения — rsync лога локально. Лог остаётся как документация.
|
||||||
|
4. Папка `tests/results/` в репозитории — логи коммитить.
|
||||||
|
|
||||||
|
**Формат запуска:**
|
||||||
|
```bash
|
||||||
|
LOG="tests/results/$(date +%Y-%m-%d_%H-%M).log"
|
||||||
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
||||||
|
"bash ~/terra/SQS-service/tests/run_tests.sh 2>&1 | tee ~/terra/SQS-service/${LOG}"
|
||||||
|
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
||||||
|
naeel@5.172.178.213:~/terra/SQS-service/tests/results/ /home/naeel/nubes/SQS-service/tests/results/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
||||||
|
|
||||||
|
## ⛔ РУЧНЫЕ ПАТЧИ — ЗАПРЕЩЕНЫ
|
||||||
|
|
||||||
|
- НИКОГДА не применять ручные патчи (`kubectl patch`, `kubectl apply` отдельных полей, `python -c` замены в yaml и т.д.) без явного указания.
|
||||||
|
- Все изменения — только через код (манифесты, Go-код) + сборка + деплой.
|
||||||
|
- Ручной патч слетает при следующем helm upgrade/redeploy → регрессия.
|
||||||
|
- Исключение: только если пользователь явно написал "примени ручной патч".
|
||||||
|
|
||||||
|
## Поведение агента
|
||||||
|
|
||||||
|
- Не трогать рабочий код без явного указания
|
||||||
|
- Не делать НИЧЕГО сверх того, о чём явно приказали — ни git-команд, ни rebase, ни дополнительных шагов
|
||||||
|
- Если для продолжения нужен выбор — СПРОСИТЬ разрешения, не делать самостоятельно
|
||||||
|
- Деструктивные операции (`kubectl delete`, `rm -rf`, `terraform destroy` и др.) — только после явного подтверждения с указанием конкретных объектов
|
||||||
|
- Отвечать кратко, без вступлений, извинений, благодарностей и прочей воды
|
||||||
+10
-1
@@ -1,5 +1,8 @@
|
|||||||
# Binaries
|
# Binaries
|
||||||
shared-sqs
|
/shared-sqs
|
||||||
|
|
||||||
|
# Публичная репа (независимый git repo, не трекается)
|
||||||
|
/shared-SQS/
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
*.dylib
|
*.dylib
|
||||||
@@ -26,6 +29,12 @@ build/
|
|||||||
|
|
||||||
# Secrets
|
# Secrets
|
||||||
secrets/
|
secrets/
|
||||||
|
goaws
|
||||||
|
cmd
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
/HISTORY/2026-08-13-session-log.md
|
/HISTORY/2026-08-13-session-log.md
|
||||||
/HISTORY/2026-08-13-sonnet-answer-v2.md
|
/HISTORY/2026-08-13-sonnet-answer-v2.md
|
||||||
/HISTORY/2026-08-14-session-log.md
|
/HISTORY/2026-08-14-session-log.md
|
||||||
|
|||||||
+8
-3
@@ -1,18 +1,23 @@
|
|||||||
# Dockerfile — shared-sqs multi-stage build
|
# Dockerfile — shared-sqs multi-stage build
|
||||||
# Updated: 2026-04-09
|
# Updated: 2026-04-12 — Go 1.23 (required by prometheus client)
|
||||||
|
|
||||||
FROM golang:1.22-alpine AS builder
|
FROM golang:1.23-alpine AS builder
|
||||||
WORKDIR /build
|
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"]
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 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 (файл готов).~~ **ОТМЕНЕНО 2026-10-08:** это болезнь
|
||||||
|
платформы; в соседнем проекте drhider ту же проблему не «починили», а обошли
|
||||||
|
(внешний буфер + исходящий pull). Разбор: `doc/thinking/nubes-large-body-workaround-findings-2026-10-08.md`,
|
||||||
|
применимость к SQS: `doc/thinking/nubes-workaround-applicability-to-sqs-2026-10-08.md`.
|
||||||
|
Тикет оставлен только как история.
|
||||||
|
2. Удалить дубликат `secrets/yandex_ymq_fork8s.txt` (одинаков с newsqs.txt) —
|
||||||
|
ждёт команды пользователя.
|
||||||
|
3. Повторный 30-мин бенчмарк vs YMQ и обновление BENCHMARK-COMPARISON — только
|
||||||
|
после того, как внешний путь перестанет рваться (обходной прокси, см. п.1),
|
||||||
|
либо как есть, с оговоркой про таймауты шлюза.
|
||||||
|
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 запрещён (только редакторы файлов).
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# История правок shared-sqs — 2026-10-07 (P1.2 + фикс утечки блокировки)
|
||||||
|
|
||||||
|
> Запись по требованию «документировать всё, включая собственные ошибки».
|
||||||
|
> Предыдущие записи: `doc/thinking/*-2026-10-0[56].md`, `HISTORY/2026-08-1*-session-log.md`.
|
||||||
|
|
||||||
|
## 1. Задача
|
||||||
|
|
||||||
|
Пользователь: «доделать ПОЛНОСТЬЮ и протестировать SQS». Незакрытым по плану
|
||||||
|
`plan-for-flash-2026-10-05.md` оставался единственный пункт — **P1.2
|
||||||
|
(индексация сообщений)**, отложенный 2026-10-06 с обоснованием.
|
||||||
|
|
||||||
|
## 2. Что сделано
|
||||||
|
|
||||||
|
### 2.1. Дизайн (коммит `b58e958`)
|
||||||
|
|
||||||
|
`doc/thinking/p1.2-message-index-design-2026-10-07.md`.
|
||||||
|
|
||||||
|
Ключевое решение: **tombstone + индексы + амортизированная компакция**.
|
||||||
|
Удаление не сдвигает срез (O(1)), поэтому индексы `receipt→index` и `uuid→index`
|
||||||
|
остаются валидными. Физическая чистка — `Compact`/`CompactIfNeeded` в `processQueueTick`.
|
||||||
|
Redis-схема не меняется (сообщения и так лежат в per-message HASH).
|
||||||
|
|
||||||
|
### 2.2. Код (коммит `22e9bc8`)
|
||||||
|
|
||||||
|
- `models.Queue`: `deadIdx`, `receiptIdx`, `uuidIdx`, `liveCount`, `deadCount` (все `json:"-"`)
|
||||||
|
+ примитивы: `AppendMessage`, `RemoveIndex`, `Compact`, `CompactIfNeeded`, `Reset`,
|
||||||
|
`EnsureIndexes`/`RebuildIndexes`, `IndexOfReceipt`, `IndexOfUUID`, `MarkInFlight`,
|
||||||
|
`ClearInFlight`, `InFlightIndexes`, `IsDead`, `LiveCount`.
|
||||||
|
- `DeleteMessage` / `ChangeMessageVisibility(±Batch)` — поиск по индексу вместо скана среза;
|
||||||
|
удаление без сдвига остатка.
|
||||||
|
- `DeleteMessageBatch` — tombstone вместо полной пересборки среза.
|
||||||
|
- `processQueueTick` — visibility/DLQ по индексу in-flight (O(in-flight)), retention — через
|
||||||
|
tombstone, в конце — `CompactIfNeeded`.
|
||||||
|
- send-пути — `AppendMessage`/`LiveCount`; admin/metrics/persistence — учёт живых сообщений;
|
||||||
|
`LoadAllQueues` — `RebuildIndexes` после загрузки.
|
||||||
|
|
||||||
|
### 2.3. Тесты (коммиты `e05af04`, `5b3fb51`)
|
||||||
|
|
||||||
|
- `app/models/message_index_test.go` — 6 тестов примитивов (tombstone, компакция, индексы, Reset, порог).
|
||||||
|
- `app/gosqs/delete_middle_test.go` — удаление СЕРЕДИНЫ очереди через API: остальные не теряются,
|
||||||
|
удалённое не возвращается.
|
||||||
|
- `app/gosqs/tenant_flow_test.go` — существующие тесты переведены на `LiveCount()`; добавлен
|
||||||
|
`TestReceiveEmptyQueue_DoesNotLeakLock`.
|
||||||
|
|
||||||
|
## 3. НАЙДЕННЫЙ ДЕФЕКТ (моя регрессия) — утечка per-queue блокировки
|
||||||
|
|
||||||
|
### 3.1. Проявление
|
||||||
|
|
||||||
|
Стресс (2000 сообщений × циклы): `ReadTimeout`, всплеск 1.9 с на `delete_batch`,
|
||||||
|
дальше — зависание сервиса (health при этом отвечает).
|
||||||
|
|
||||||
|
### 3.2. Диагностика (факты, не догадки)
|
||||||
|
|
||||||
|
- Снят стек горутин по `SIGQUIT`: десятки горутин в `sync.RWMutex.RLock`
|
||||||
|
(`models.go:101 Queue.RLockMu`), включая `metrics.updateGauges` (ждала 15 минут)
|
||||||
|
и HTTP-хендлеры `GetQueueAttributesV1`.
|
||||||
|
- Аудит всех `GetLocked`/`GetRLocked`: дефект ровно один.
|
||||||
|
- `receive_message.go`:
|
||||||
|
```go
|
||||||
|
queue := models.SyncQueues.GetLocked(key) // Lock взят
|
||||||
|
if queue == nil || queue.LiveCount() == 0 {
|
||||||
|
return nil, false // ← возврат БЕЗ Unlock
|
||||||
|
}
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3. Причина и почему проявилось именно сейчас
|
||||||
|
|
||||||
|
Возврат до `defer` оставлял `queue.mu` захваченным навсегда (в Go мьютекс не
|
||||||
|
освобождается при выходе из функции). Прежнее условие `len(queue.Messages) == 0`
|
||||||
|
срабатывало только на полностью пустой очереди; после перехода на tombstone
|
||||||
|
появился новый случай `LiveCount()==0` при `len(Messages)>0` (все сообщения удалены,
|
||||||
|
компакция ещё не прошла) — типовой для последовательных циклов «залить → выгрести».
|
||||||
|
|
||||||
|
### 3.4. Фикс
|
||||||
|
|
||||||
|
`defer queue.UnlockMu()` — до любой проверки/возврата. Регрессионный тест
|
||||||
|
`TestReceiveEmptyQueue_DoesNotLeakLock` проверен на дефекте (падает за 2 с)
|
||||||
|
и на фиксе (зелёный).
|
||||||
|
|
||||||
|
## 4. Результаты проверок (после фикса)
|
||||||
|
|
||||||
|
| Проверка | Результат |
|
||||||
|
|---|---|
|
||||||
|
| `go build ./...`, `go vet ./...` | OK |
|
||||||
|
| `go test -race ./app/...` | зелёные |
|
||||||
|
| `tests/api_test.sh` (aws CLI, локальный сервис) | PASS=22 FAIL=0 |
|
||||||
|
| `tests/sdk_test.py` (boto3 1.43.108) | PASS=15 FAIL=0 |
|
||||||
|
| `tests/fifo_dlq_probe.py` | fifo_order / fifo_dedup / dlq = PASS |
|
||||||
|
| Стресс 3×2000 (send-batch → receive → delete-batch) | send ≈0.6 с, drain ≈1.2 с на цикл, `left=0/0`, вызовов >0.5 с нет |
|
||||||
|
|
||||||
|
## 5. Инфраструктурные заметки (для повторного прогона)
|
||||||
|
|
||||||
|
- Локальный сервис: `SHARED_SQS_SEED_DEMO=true /tmp/shared-sqs --admin-token=... --port=4100`
|
||||||
|
(memory-only без `REDIS_ADDR`; креды демо-тенанта печатаются в лог при старте).
|
||||||
|
- `boto3` в системе НЕ установлен (PEP 668) и `python3.12-venv` отсутствует →
|
||||||
|
wheels скачаны через `pip download` и распакованы в `/tmp/sqlibs_unpacked`,
|
||||||
|
запуск с `PYTHONPATH=/tmp/sqlibs_unpacked`.
|
||||||
|
- Лимит очередей тенанта — 10: падавшие скрипты оставляют очереди, перед прогоном
|
||||||
|
чистить (`aws sqs delete-queue`).
|
||||||
|
- `ENDPOINT` (api_test.sh) и `ENDPOINT_URL` (python-тесты) — разные имена переменных.
|
||||||
|
|
||||||
|
## 6. Уроки
|
||||||
|
|
||||||
|
1. Стресс-проверка обязательна: api/sdk/fifo проходили и с дефектом, он проявлялся
|
||||||
|
только на циклах полного опустошения очереди.
|
||||||
|
2. `SIGQUIT` — быстрый и точный способ найти зависание (кто держит мьютекс), дешевле догадок.
|
||||||
|
3. При замене условия на новый критерий (`len` → `LiveCount`) надо перечитывать
|
||||||
|
весь блок, включая порядок `defer` — старый код содержал латентную утечку лока,
|
||||||
|
которую новый критерий сделал массовой.
|
||||||
|
4. Не запускать сервис с `| head` — SIGPIPE убивает процесс на середине теста.
|
||||||
|
|
||||||
|
## 7. Код-ревью (роль ПРО) и фиксы — коммит `48c38c4`
|
||||||
|
|
||||||
|
Проведён архитектурный разбор всех затронутых файлов. Найдено и исправлено:
|
||||||
|
|
||||||
|
1. **🔥 Батч-видимость не заводила в DLQ** (`change_message_visibility_batch.go`).
|
||||||
|
При `VisibilityTimeout=0` батч лишь инкрементировал `Retry` и НЕ переносил
|
||||||
|
сообщение в DLQ — логика была только в одиночной версии, счётчик и перенос
|
||||||
|
расходились. Вынесен единый метод `models.Queue.ResetVisibilityAndTryDLQ`
|
||||||
|
(сброс видимости + DLQ-перенос), обе версии приведены к одному пути.
|
||||||
|
Тест `TestChangeVisibilityBatch_MovesToDLQ` (падает на старой логике, зелёный на новой).
|
||||||
|
2. **TOCTOU лимит в `SendMessageBatch`**: проверка OOM-лимита была только по
|
||||||
|
устаревшему снимку (RLock → Lock). Добавлена повторная проверка под per-queue Lock.
|
||||||
|
3. Инварианты `InFlightIndexes` (снимок может содержать устаревшие индексы —
|
||||||
|
потребитель обязан перепроверять) и `ensureIndexes` (проверяет согласованность
|
||||||
|
чисел, а не валидность map-индексов → `Messages` нельзя мутировать вне методов)
|
||||||
|
зафиксированы в док-комментариях.
|
||||||
|
4. `Compact` упрощён: один `RebuildIndexes` на выходе + `ensureIndexes` на входе
|
||||||
|
(убран дублирующий блок), `deadCount` пересчитывается внутри.
|
||||||
|
|
||||||
|
Осталось осознанно не тронуто: `DeleteMessageBatch` итерация с `RemoveIndex` внутри
|
||||||
|
безопасна (tombstone не меняет длину) — покрыто тестами.
|
||||||
|
|
||||||
|
## 8. Полный код-ревью сервиса (2026-10-08) — коммит `5b0e89a`
|
||||||
|
|
||||||
|
Разобран весь сервис: auth (SigV4 + JWT), router, billing, tenant, admin/UI API,
|
||||||
|
создание очередей. Критичных проблем не найдено. Исправлено три замечания:
|
||||||
|
|
||||||
|
1. **TOCTOU лимита тенантов** — `TenantStore.Create`: проверка `MaxTenantsGlobal`
|
||||||
|
была только под RLock, вставка — позже под Lock без повторной проверки. Добавлена
|
||||||
|
проверка под Lock (как уже было в `CreateFromJWT`).
|
||||||
|
2. **Потеря `NubesSub` при рестарте** — `CreateFromJWT`: обновлённый `NubesSub`
|
||||||
|
сохранялся в Redis только внутри ветки смены `MaxQueues`; теперь персист при
|
||||||
|
любом реальном изменении, запись только если что-то изменилось.
|
||||||
|
3. **`http.Client` на каждый вызов** — `auth.PingNubesAPI` переведён на общий
|
||||||
|
клиент (keep-alive).
|
||||||
|
|
||||||
|
UI/безопасность проверены отдельно: `listTenants`/`getTenant`/IDOR для UI-тенанта —
|
||||||
|
видит только себя, чужие `{id}` отсекаются, удаление через UI запрещено,
|
||||||
|
`SecretKey` отдаётся только по явному `/ui/api/credentials`.
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
# История правок shared-sqs — 2026-10-10 (публичная точка на ВМ, TLS, замеры)
|
||||||
|
|
||||||
|
> Запись по требованию «документировать всё, включая собственные ошибки» (приказ 2026-09-24).
|
||||||
|
> Предыдущие: `HISTORY/2026-10-07-session-log.md`, `doc/thinking/*-2026-10-08.md`.
|
||||||
|
> Сессия велась двумя моделями: сначала DeepSeek Flash, затем DeepSeek Pro (архитектор) —
|
||||||
|
> Pro перепроверял выводы Flash, часть из них оказалась неверной (см. раздел 4).
|
||||||
|
|
||||||
|
## 1. Задача
|
||||||
|
|
||||||
|
Вынести публичную точку SQS на ВМ `5.172.178.213` (обход больного managed-шлюза) и
|
||||||
|
**проверить гипотезу** из `MANUAL-NEXT-2026-10-08.md` (Шаг 1): нет ли на пути
|
||||||
|
«интернет → ВМ» той же сетевой болезни (зависания ~51 с, таймауты 31–33 с).
|
||||||
|
Требования владельца по пути: домен — только свой (`sinefo.ru`); `kube5s` из проекта убрать.
|
||||||
|
|
||||||
|
## 2. Хронология (что делалось и что получалось)
|
||||||
|
|
||||||
|
### 2.1. Сборка и запуск на ВМ
|
||||||
|
- `make build` → `shared-sqs` v0.1.35 (статический, x86_64, 17 МБ).
|
||||||
|
- `scp` → `naeel@5.172.178.213:~/sqs-test/shared-sqs`.
|
||||||
|
- Запуск memory-only: `SHARED_SQS_SEED_DEMO=true ./shared-sqs --admin-token=dev-token-123 --port=4100`.
|
||||||
|
- Локально на ВМ: `/health` → `200 {"status":"ok","version":"v0.1.35"}`;
|
||||||
|
`ListQueues` без подписи → `403 MissingAuthenticationToken` (корректно).
|
||||||
|
- ⚠️ Предупреждение `Failure to find default config file` — `goaws.yaml` на ВМ нет,
|
||||||
|
приложение работает на встроенных дефолтах (см. 5.1).
|
||||||
|
|
||||||
|
### 2.2. Разведка ВМ
|
||||||
|
- Публичный адрес — `5.172.178.213` (NAT); внутренний `10.10.102.21` (`ens192`), hostname `naeel-vm`.
|
||||||
|
- Снаружи прилетают только 80/443. Проверка портов:
|
||||||
|
- `8443` — занят DNAT: `iptables -t nat`: `--dport 8443 -j DNAT --to-destination 95.179.252.111:443`
|
||||||
|
(сервис `vpn-transit-dnat`, `enabled/active`) → весь трафик на 8443 уходит на Vultr **до** ВМ.
|
||||||
|
- `8888`/`9443` — `/dev/tcp` показывает «OPEN», но локально **никто не слушает** и HTTP не отвечает
|
||||||
|
(curl timeout) → это SYN-proxy провайдерского firewall, порты непригодны.
|
||||||
|
- `/etc/hosts` на ВМ содержит внутренние имена: `gitea.mgmt.nubes.ru`, `sqs.containerk8s.dev.nubes.ru`
|
||||||
|
→ `185.247.187.151`, `tf-registry.containerk8s.services.ngcloud.ru`, `s3.msk-1.ngcloud.ru`.
|
||||||
|
Резолвер ВМ — публичный (`8.8.8.8`, `1.1.1.1`), поэтому имена `*.services.ngcloud.ru` не резолвятся.
|
||||||
|
|
||||||
|
### 2.3. Блокер nginx (возник 10-08, снят 10-10)
|
||||||
|
- 10-08: `sudo nginx -t` **падал**: `host not found in upstream "contractor.pythonk8s.services.ngcloud.ru"
|
||||||
|
in sites-enabled/check.kube5s.ru:44` — внутреннее имя не резолвится.
|
||||||
|
- 10-10 (после ребута ВМ): конфиг `check.kube5s.ru` оказался **изменён** (catch-all `location /`
|
||||||
|
на contractor убран, добавлен `location /rf`) → `nginx -t` **проходит**, nginx перезагружаем.
|
||||||
|
- Следствие: переход к рабочему пути упростился (сначала была построена схема со вторым nginx, см. 2.4).
|
||||||
|
|
||||||
|
### 2.4. Второй nginx на 10443 (сделан, затем убран)
|
||||||
|
- Владелец пробросил порт: firewall `[22, 80, 443, 4443, 10443]`.
|
||||||
|
- Проверено: `10443` реально доходит до ВМ (`/health` 200), `4443` — нет.
|
||||||
|
- Поднят **отдельный** nginx: `/etc/nginx-sqs/nginx.conf`, pid `/run/nginx-sqs.pid`,
|
||||||
|
`listen 10443` → `proxy_pass http://127.0.0.1:4100`. Запуск `sudo nginx -c /etc/nginx-sqs/nginx.conf`.
|
||||||
|
|
||||||
|
### 2.5. Замеры (гипотеза ПОДТВЕРЖДЕНА)
|
||||||
|
|
||||||
|
| Тест | Инструмент | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| HTTP, 30 запросов 1КБ/10КБ/100КБ/256КБ | `curl --aws-sigv4` | 30/30 **200**, max **0.99 с** |
|
||||||
|
| HTTPS, 10 запросов 26КБ…260КБ | `curl --aws-sigv4` | 10/10 **200**, 0.7–3.2 с |
|
||||||
|
| Long-poll `ReceiveMessage` `WaitTimeSeconds=20` | `curl` | **20.3 с, 200** (корректно) |
|
||||||
|
|
||||||
|
Ни зависаний ~51 с, ни таймаутов 31–33 с. **На пути «интернет → ВМ 213» болезни нет.**
|
||||||
|
|
||||||
|
### 2.6. DNS
|
||||||
|
- `sinefo.ru` делегирован в реестре .ru на `ns3.ngcloud.ru` / `ns4.ngcloud.ru` (проверено через `a.dns.ripn.net`).
|
||||||
|
- `sqs.sinefo.ru A 5.172.178.213` — публично резолвится. Ранее запись была `185.247.187.83`,
|
||||||
|
что = **IP самого DNS-сервера** `ns3.ngcloud.ru`, а не ВМ (исправлено владельцем).
|
||||||
|
|
||||||
|
### 2.7. TLS (Let's Encrypt)
|
||||||
|
- `sudo certbot certonly --webroot -w /var/www/acme -d sqs.sinefo.ru --non-interactive --agree-tos`
|
||||||
|
(аккаунт LE на ВМ уже существовал → без нового e-mail) → `/etc/letsencrypt/live/sqs.sinefo.ru/`.
|
||||||
|
- Срок: до 2027-01-08, автопродление настроено.
|
||||||
|
- ACME-челлендж обслуживается главным nginx (vhost 80 с `location /.well-known/acme-challenge/`).
|
||||||
|
|
||||||
|
### 2.8. Публичная точка (итог)
|
||||||
|
- Главный nginx, `/etc/nginx/conf.d/sqs-sinefo.conf`:
|
||||||
|
- **80**: ACME-челлендж + редирект на https;
|
||||||
|
- **443 ssl**: сертификат `sqs.sinefo.ru` + `proxy_pass http://127.0.0.1:4100`, `Host $http_host`.
|
||||||
|
- Проверено снаружи: `https://sqs.sinefo.ru/health` → 200 (cert valid), SigV4, 256 КБ за 1.05 с.
|
||||||
|
|
||||||
|
### 2.9. Уборка
|
||||||
|
- Второй nginx (10443) остановлен, `/etc/nginx-sqs` удалён; порт 10443 свободен.
|
||||||
|
- `conf.d/sqs-sinefo.conf` — заменён на нормальный vhost (было: HTTP-only левак от 10-08).
|
||||||
|
- Из **актуального** дока `doc/2026-08-13-migration-to-managed.md` убран `kube5s.ru`
|
||||||
|
(квинтет: `iot.kube5s.ru`→`iot.nubes.ru`, `qu.kube5s.ru`×3→`sqs.containerk8s.dev.nubes.ru`),
|
||||||
|
коммит `a47743f`. В `doc/legacy/` (история) не трогал.
|
||||||
|
|
||||||
|
### 2.10. Фикс QueueUrl (кодинг)
|
||||||
|
- Проблема: `QueueUrl` = `http://region.host:port/...` (плейсхолдеры) — клиенты без `endpoint_url` сломаны.
|
||||||
|
- Реализовано: `Environment.ExternalURLBase` + env `SHARED_SQS_EXTERNAL_URL_BASE`.
|
||||||
|
- **Ошибка по пути №1:** env-override сначала поместил после `models.CurrentEnvironment = envs[env]`
|
||||||
|
— не сработал: `LoadYamlConfig` делает ранний `return` без `goaws.yaml`. Перенёс в
|
||||||
|
`applyEnvironmentDefaults` (вызывается через defer → всегда).
|
||||||
|
- **Ошибка по пути №2:** после переноса всё равно не работало — демо-seed (`app/cmd/seed.go`) собирал
|
||||||
|
URL **своей копией** логики, а не через `tenantQueueURL`. Вынес `gosqs.TenantQueueURL`, seed переведён на него.
|
||||||
|
- Коммиты `469132b`, `ca0372b`; тест `app/gosqs/tenant_helpers_test.go`. Проверено снаружи:
|
||||||
|
`list-queues` → `https://sqs.sinefo.ru/t-.../orders`.
|
||||||
|
|
||||||
|
### 2.11. Устойчивость: systemd + локальный Redis
|
||||||
|
- Установлен `redis-server` (7.0.15) на 213, включён AOF (`appendonly yes`).
|
||||||
|
- systemd-юнит `shared-sqs.service` (`Restart=always`, `enable`) + `/etc/shared-sqs.env`.
|
||||||
|
- Проверено: сообщение пережило рестарт; `systemctl is-enabled/active` → enabled/active.
|
||||||
|
- **Дефекты seed (обнаружены и исправлены, коммит `c0aa618`):** демо-очереди не персистились;
|
||||||
|
каждый старт плодил нового демо-тенанта (утечка при `Restart=always`). Фикс: `SaveQueue`+
|
||||||
|
`SaveMessages` + идемпотентность через `store.List()`.
|
||||||
|
- Управление: `sudo systemctl {status,restart,stop} shared-sqs`.
|
||||||
|
|
||||||
|
### 2.12. Прод-хардненинг
|
||||||
|
- `SHARED_SQS_SEED_DEMO=false`; admin-token сменён на случайный 64-hex (в `/etc/shared-sqs.env`);
|
||||||
|
logrotate для `sqs.log`.
|
||||||
|
- Billing/JWT не настроены. Проверено: сервис работает на существующем тенанте из Redis.
|
||||||
|
|
||||||
|
### 2.13. Шаг 2 — разбиение data-plane / control-plane
|
||||||
|
- `router.NewDataPlane` (публичный SQS API) + `router.NewControlPlane` (админка/метрики/UI).
|
||||||
|
- `main`: два листенера — `0.0.0.0:4100` (публично) и `127.0.0.1:4101` (control-plane, только локально).
|
||||||
|
- Проверено: публично `/admin`/`/metrics` → 403 (наружу не доступны), SQS API работает; локально
|
||||||
|
`4101/admin` → 200. Коммит `c1520c0`.
|
||||||
|
- Примечание: пока один процесс с двумя листенерами (общее in-memory состояние); два процесса —
|
||||||
|
после Шага 3.
|
||||||
|
|
||||||
|
### 2.14. Шаг 3 — начало: дизайн + тенанты read-through Redis
|
||||||
|
- Дизайн: `doc/thinking/step3-state-extraction-design-2026-10-10.md` (варианты A/B/C, рекомендация
|
||||||
|
C→A, что замерить).
|
||||||
|
- Код (коммит `779a71d`): Redis-индекс `ssq:tenant_ak` (accessKey → JSON тенанта) + бэкфилл при старте;
|
||||||
|
auth-middleware читает тенанта из Redis при промахе локальной памяти.
|
||||||
|
- Нужно для ЛЮБОГО multi-instance варианта; в одном процессе — страховка.
|
||||||
|
- Проверено: индекс заполнен (`hlen ssq:tenant_ak` = 1), регрессий нет.
|
||||||
|
|
||||||
|
### 2.15. Durability batch + billing
|
||||||
|
- `SendMessageBatch` переведён на `SaveMessagesSync` (было async) + не персистит дубли (коммит `3559414`).
|
||||||
|
- Проверено: batch в пустую очередь пережил рестарт.
|
||||||
|
- Billing — предусмотрен (модуль есть), но выключен (`BILLING_PG_HOST` не задан). Решение: включить позже.
|
||||||
|
|
||||||
|
### 2.16. Шаг 3 — HA active/passive (leader-election)
|
||||||
|
- Выбран **вариант C** (active/passive через Redis-лок) вместо полного выноса состояния (A) —
|
||||||
|
обоснование: причина простоя — недоступность ВМ, а не CPU; C стоит ~200 строк и не трогает ядро
|
||||||
|
очередей. Подробно: `doc/thinking/state-extraction-and-ha-2026-10-10.md`.
|
||||||
|
- Код (коммит `bc00643`):
|
||||||
|
- `app/persistence/redis.go`: `TryAcquireLock` (SETNX+PX), `RefreshLock`/`ReleaseLock`
|
||||||
|
(Lua: только если значение наше). При `Client == nil` — всегда `true` (мы одни).
|
||||||
|
- `app/leader/leader.go`: `Elector` — цикл «продлить/взять лок» каждые 3 с, TTL 10 с, `IsLeader()`.
|
||||||
|
- `app/router/router.go`: `/ready` (в обоих плоскостях) — 200 если готов, 503 если пассивный;
|
||||||
|
поведение задаётся пакетной `ReadyFunc`.
|
||||||
|
- `app/cmd/goaws.go`: `--leader-election` (или env `SHARED_SQS_LEADER_ELECTION=true`),
|
||||||
|
`--leader-ttl`, `--leader-interval`; `ReadyFunc` ставится только при включении;
|
||||||
|
лок отпускается при graceful shutdown. **По умолчанию выключено — поведение не изменилось.**
|
||||||
|
- **Замеры (не гипотезы), ВМ 213, два временных инстанса 4200/4210:**
|
||||||
|
- одна копия → `/ready` `200`, вторая → `503` (split-brain нет); `redis-cli get ssq:leader`
|
||||||
|
= `<hostname>-<pid>` лидера;
|
||||||
|
- `kill -9` лидера → пассивный стал лидером, `/ready` → `200`, перехват занял **~12 с**
|
||||||
|
(максимум по формуле `ttl+interval` = 13 с);
|
||||||
|
- прод-API после деплоя: `list-queues` / `send-message` / `receive-message` через
|
||||||
|
`https://sqs.sinefo.ru` — OK; временные инстансы остановлены, ключ `ssq:leader` удалён.
|
||||||
|
- **В проде выключено**: для включения нужны общий Redis + вторая ВМ + LB с проверкой `/ready`
|
||||||
|
(иначе пассивный получит трафик и вернёт «пустые» очереди — главный риск варианта C, описан в доке).
|
||||||
|
- Док (коммит `865db49`): почему C, механика, замеры, ограничения, пошаговое включение, откат,
|
||||||
|
путь к варианту A (5 шагов) и триггеры перехода.
|
||||||
|
|
||||||
|
### 2.17. Код-ревью Opus + критический разбор
|
||||||
|
- Дал промпт Opus: полное код-ревью + советы по архитектуре. Дословный ответ:
|
||||||
|
`doc/thinking/opus-report-2026-10-10.md`; критический разбор + что подтверждено:
|
||||||
|
`doc/thinking/opus-report-analysis-2026-10-10.md`.
|
||||||
|
- Opus указал ~50 пунктов (🔴/🟡/🟢). Моя оценка: силён по конкуренции/durability, но смешивает
|
||||||
|
баги / осознанный дизайн / отклонения от семантики AWS; часть 🔴 надо понизить.
|
||||||
|
- **Подтверждено по коду (чинится без владельца):**
|
||||||
|
- 🔴1 фатал `concurrent map iteration and map write`: `create_queue.go:84` зовёт `SaveQueue`
|
||||||
|
под `SyncQueues.Lock` без `queue.mu`; `SaveQueue`→`json.Marshal` итерирует `Duplicates`/`FIFO*`/`Tags`,
|
||||||
|
а мутаторы держат только `queue.mu`. Условие: идемпотентный CreateQueue существующей FIFO-очереди + SendMessage.
|
||||||
|
- 🔴5 неполный откат dedup при сбое `SaveMessageSync` (`send_message.go:151-160`) → ретрай 200 без сохранения.
|
||||||
|
- 🔴8 пустой `ReceiptHandle` в DeleteMessageBatch удаляет все невыданные сообщения — **только
|
||||||
|
через JSON-протокол** (`x-amz-json-1.0`); Query-форма защищена (`requests.go:568` не на этом пути).
|
||||||
|
- Порядок локов + I/O под глобальным локом; fail-fast на старте без Redis отсутствует.
|
||||||
|
- **Нужны решения владельца:**
|
||||||
|
- JWT: проверяет ли шлюз подпись (trusted-perimeter из `jwt.go:7-11`)? Независимо чинятся:
|
||||||
|
три шлюза prod/test/dev → prod; потолок grace в `cache.go:68-75`; идентичность по `sub`, не email.
|
||||||
|
- Лимит по байтам (OOM, 🔴14): нужны RAM ВМ + число тенантов → cap на тенанта/очередь + глобальный предохранитель по RSS.
|
||||||
|
- Порядок исправления — §5 дока анализа.
|
||||||
|
|
||||||
|
### 2.18. Правки по ревью (применено, 3 коммита)
|
||||||
|
- `654e315` — 🔴1 (SaveQueue под queue.mu), 🔴8 (пустой ReceiptHandle), 🔴5 (dedup только после
|
||||||
|
успешной записи, single+batch), глобальный предохранитель HeapAlloc (`memory_guard.go`,
|
||||||
|
`SHARED_SQS_MAX_HEAP_MB`, 0=выключен).
|
||||||
|
- `1230149` — JWT: только prod-шлюз (убран test/dev); grace в `cache.go` ограничен `graceDeadline`
|
||||||
|
от последней успешной проверки (не продлевается бесконечно).
|
||||||
|
- `2187027` — fail-fast: при заданном `REDIS_ADDR` и недоступном Redis → `log.Fatalf`.
|
||||||
|
- build + vet + test — зелёные. Не задеплоено на ВМ (жду команды).
|
||||||
|
- НЕ сделано (нужны решения владельца): цифры лимита по байтам (RAM/тенанты); идентичность
|
||||||
|
JWT по `sub` вместо email (зависит от того, проверяет ли шлюз подпись). Порядок локов и I/O
|
||||||
|
под глобальным локом — осталось (более крупный рефакторинг).
|
||||||
|
|
||||||
|
### 2.19. Деплой правок на ВМ 213 + проверка
|
||||||
|
- Обновлён `/home/naeel/sqs-test/shared-sqs` (prev-бинарник сохранён, лог бэкапнут
|
||||||
|
`sqs.log.before-deploy-*`), сервис `active`, `/health` 200, `/ready` 200.
|
||||||
|
- В логе подтверждено: `leader-election выключен`, два листенера (4100 public / 4101 localhost),
|
||||||
|
fail-fast не срабатывал (Redis доступен).
|
||||||
|
- **Функциональная проверка 🔴8 (изолированная очередь `probe-empty-handle`):**
|
||||||
|
- отправлено 2 сообщения (не выдавались → `ReceiptHandle == ""`), `ApproximateNumberOfMessages` = 2;
|
||||||
|
- `delete-message-batch` с `ReceiptHandle: ""` → ошибка
|
||||||
|
`AWS.SimpleQueueService.InvalidParameterValue`, **сообщения остались (2)** → фикс работает;
|
||||||
|
- CLI v2 подтверждённо шлёт JSON-протокол (ошибку отдал наш новый валидатор, а не парсер формы);
|
||||||
|
- легитимный `delete-message-batch` по реальному handle → `Successful`, счётчик 2→1;
|
||||||
|
- probe-очередь удалена.
|
||||||
|
- Регрессий нет: `list-queues`, `send-message`, `receive-message` через `https://sqs.sinefo.ru` — OK.
|
||||||
|
|
||||||
|
### 2.20. AWS-совместимость для Terraform-провайдера (цель дня уточнена владельцем)
|
||||||
|
Контекст: это **не глобальный прод с высокой нагрузкой**; цель — рабочие SQS+IoT и **Terraform-провайдер**,
|
||||||
|
чтобы юзеры Nubes подключали сервисы в своих конфигурациях. Приоритет — **надёжность и соответствие
|
||||||
|
AWS**, а не масштабирование. Поэтому HA/перф-оптимизации отложены, в фокус — совместимость.
|
||||||
|
- `95bce88` — атрибуты очереди для провайдера: `GetQueueAttributes` теперь отдаёт `FifoQueue`,
|
||||||
|
`ContentBasedDeduplication`, `Policy` и **реальные** `CreatedTimestamp`/`LastModifiedTimestamp`
|
||||||
|
(были отсутствующие/заглушка `0000000000`); в `Queue` добавлены `CreatedAt`/`LastModifiedAt`/`Policy`/
|
||||||
|
`ContentBasedDeduplication`; разбор FIFO-атрибутов в обоих протоколах (`StringToBool`);
|
||||||
|
`Policy`/`RedriveAllowPolicy` нормализуются из строки в объект (JSON-протокол присылает строку →
|
||||||
|
раньше `SetQueueAttributes` падал с `InvalidParameterValue`); бэкфилл пустых timestamps при загрузке.
|
||||||
|
- `988a6a6` — FIFO-дедупликация по AWS: дедуп — свойство FIFO (гейт по `IsFIFO`, а не по env-флагу
|
||||||
|
`EnableDuplicates=false`, из-за которого дедуп не работал вообще); `ContentBasedDeduplication`
|
||||||
|
считает dedupId из тела (SHA-256); dedup-окно **не сбрасывается при удалении** сообщения (5 мин).
|
||||||
|
- **Проверено локально и на проде** (`aws` CLI v2, JSON-протокол):
|
||||||
|
- `GetQueueAttributes All` отдаёт полный набор (см. лог);
|
||||||
|
- FIFO: два send с одним dedupId → 1 сообщение; `ContentBasedDeduplication` (два одинаковых тела) → 1;
|
||||||
|
- receive+delete, затем повторный send с тем же dedupId → 0 (окно живо) — раньше доставлялся повторно;
|
||||||
|
- `set-queue-attributes` с `Policy` → OK, `get-queue-attributes Policy` возвращает канонический JSON.
|
||||||
|
- На ВМ задеплоено (prev-бинарник + бэкап лога сохранены), `active`, `/health` 200.
|
||||||
|
|
||||||
|
## 3. Грабли (для повторного использования)
|
||||||
|
|
||||||
|
1. **`proxy_set_header Host $host` теряет порт** → SigV4 `SignatureDoesNotMatch`.
|
||||||
|
Нужен `$http_host`. (nginx `$host` — имя без порта; `$http_host` — заголовок целиком.)
|
||||||
|
2. **`~/.aws/config [default]` содержит `endpoint_url = https://s3.msk-1.ngcloud.ru`** →
|
||||||
|
`aws sqs ...` без явного `--endpoint-url` уходит в S3 и получает
|
||||||
|
`405 MethodNotAllowed` (Ceph RGW, `HostId=...-dc1-msk-1`). **Всегда задавать `--endpoint-url`.**
|
||||||
|
3. **Креды демо-тенанта рандомны при каждом старте** — брать из `~/sqs-test/sqs.log`
|
||||||
|
(`seed: создан тенант ... AccessKey=...`). При «address already in use» в логе креды **мёртвого** инстанса.
|
||||||
|
4. `pkill -f "http.server 10443"` по SSH **самоматчится** на строку ssh-команды и убивает сессию —
|
||||||
|
использовать `pkill -x <имя>`.
|
||||||
|
5. Порт `8443` на 213 **нельзя занимать** — там DNAT транзита на Vultr.
|
||||||
|
6. **Managed Redis недоступен с 213**: его endpoint — внутренний k8s DNS
|
||||||
|
(`redisk8s.<uid>.svc.cluster.local:6379`), резолвится только изнутри кластера. С ВМ — только
|
||||||
|
локальный Redis или внешний endpoint от NUBES.
|
||||||
|
7. Локальный Redis на 213 **не спасает от падения ВМ** (SQS и Redis — на одной машине, SPOF).
|
||||||
|
|
||||||
|
## 4. ОШИБКИ (сводка, включая ошибки Flash)
|
||||||
|
|
||||||
|
| # | Что утверждалось/сделано | Что на самом деле | Исправление |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | «При ребуте ВМ nginx не поднимется» (из-за битого upstream) | После ребута nginx `active`, 80/443 слушают | Неверный вывод; снят при перепроверке |
|
||||||
|
| 2 | «`.83` — мёртвый IP» | `185.247.187.83` = живой DNS `ns3.ngcloud.ru` (порт 53); проверял только 80/443 | Признано при перепроверке |
|
||||||
|
| 3 | Запуск второго инстанса при живом первом | `fatal: bind: address already in use`; лог перезаписан кредами мёртвого → `InvalidClientTokenId` | `pkill -x shared-sqs`, один инстанс, креды из лога |
|
||||||
|
| 4 | Второй nginx: `Host $host` | SigV4 `SignatureDoesNotMatch` | `Host $http_host` |
|
||||||
|
| 5 | Прямые DNS-выводы по регексу `location / ` | Первый `grep` не нашёл (паттерн с пробелом) — чуть не сделал ложный вывод | Перечитал файл целиком (`cat`) |
|
||||||
|
| 6 | «Только 80/443 открыты» | 8888/9443 «принимают» TCP (SYN-proxy), но пусты | Уточнено: рабочие — только 80/443 |
|
||||||
|
|
||||||
|
## 5. Состояние и воспроизведение
|
||||||
|
|
||||||
|
### 5.1. Итоговое состояние
|
||||||
|
- **Работает:** `https://sqs.sinefo.ru` (443, TLS) → shared-sqs на ВМ 213 (memory-only, `127.0.0.1:4100`).
|
||||||
|
- SQS: `~/sqs-test/shared-sqs`, лог `~/sqs-test/sqs.log`, pid меняется. Демо-тенант + 5 очередей.
|
||||||
|
- **QueueUrl — ИСПРАВЛЕНО** (см. 2.10): `https://sqs.sinefo.ru/<tenantID>/<queue>`.
|
||||||
|
|
||||||
|
### 5.2. Как поднять заново
|
||||||
|
```bash
|
||||||
|
# на ВМ 213
|
||||||
|
cd ~/sqs-test && setsid nohup env SHARED_SQS_SEED_DEMO=true \
|
||||||
|
./shared-sqs --admin-token=dev-token-123 --port=4100 --loglevel=info > sqs.log 2>&1 </dev/null &
|
||||||
|
# креды демо-тенанта — в sqs.log
|
||||||
|
grep "seed: создан тенант" ~/sqs-test/sqs.log | tail -1
|
||||||
|
# проверка снаружи
|
||||||
|
curl -sS https://sqs.sinefo.ru/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3. Откат
|
||||||
|
```bash
|
||||||
|
sudo rm /etc/nginx/conf.d/sqs-sinefo.conf && sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
pkill -x shared-sqs
|
||||||
|
# бэкапы nginx на ВМ: /etc/nginx/conf.d.bak-*
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Открытые вопросы
|
||||||
|
|
||||||
|
1. Клиенты без явного `endpoint_url` — есть ли такие (вопрос №5 мануала). `QueueUrl` уже корректный.
|
||||||
|
2. Egress ВМ: `curl https://api.ipify.org` с ВМ вернул пусто — влияет на billing (PG) и JWT-API.
|
||||||
|
3. `SaveQueue` (метаданные очереди) всё ещё пишется **асинхронно** — при failover новый лидер
|
||||||
|
может не увидеть очередь, созданную незадолго до падения. Первый шаг к варианту A
|
||||||
|
(см. §8 дока по HA) — сделать синхронно, как `SaveMessagesSync`.
|
||||||
|
4. Для включения HA нужны: **общий Redis** (сейчас только локальный на 213 — SPOF),
|
||||||
|
вторая ВМ, LB с проверкой `/ready`. Пока всё это не готово — флаг выключен.
|
||||||
|
5. Дальше по мануалу: обновить устаревший `MANUAL-NEXT-2026-10-08.md` (ещё говорит «Шаг 1 — проверить
|
||||||
|
гипотезу» и «вопрос по домену открыт»); решить вопрос push в origin (18 коммитов впереди).
|
||||||
|
6. **JWT (решение владельца):** проверяет ли шлюз подпись и отдаёт 200 только для валидных токенов?
|
||||||
|
От этого — 🔴 или «доверяем шлюзу». См. `opus-report-analysis-2026-10-10.md` §3.
|
||||||
|
7. **Лимит по байтам (решение владельца):** нужны RAM ВМ 213 и целевое число тенантов для цифр cap.
|
||||||
|
См. `opus-report-analysis-2026-10-10.md` §4.
|
||||||
|
8. 🔴1/🔴5/🔴8 + fail-fast + JWT + heap-guard — **применены** (коммиты 654e315/1230149/2187027),
|
||||||
|
не задеплоено. Осталось: порядок локов + I/O под глобальным локом (рефакторинг).
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# История правок shared-sqs — 2026-10-11 (внешнее код-ревью и фиксы)
|
||||||
|
|
||||||
|
> Запись по требованию «документировать всё, включая собственные ошибки» (приказ 2026-09-24).
|
||||||
|
> Предыдущие: `HISTORY/2026-10-10-session-log.md`, `doc/thinking/*-2026-10-08.md`.
|
||||||
|
|
||||||
|
## 1. Задача
|
||||||
|
|
||||||
|
Провести **внешнее код-ревью** двух сервисов (SQS-service + IoT) моделью Claude Sonnet
|
||||||
|
(в режиме Plan — правки не вносит, только находит), затем своими силами исправить
|
||||||
|
найденное. Промпт для ревью — `CLAUDE-REVIEW-PROMPT.md` в корне монорепо
|
||||||
|
(со списком файлов, фокус-точками и строгим форматом ответа; явно запрещены
|
||||||
|
правки, оценка тестов/UI/legacy/стиля).
|
||||||
|
|
||||||
|
Ревью дало **40 пунктов** (10 🔴 / 29 🟡 / 1 🟢). Разбор вопросов ревьюера и
|
||||||
|
уточнение некорректных выводов — в п.3.
|
||||||
|
|
||||||
|
## 2. Что исправлено (SQS-service)
|
||||||
|
|
||||||
|
### 🔴 Критичные (10/10)
|
||||||
|
|
||||||
|
| Пункт | Файл | Суть | Коммит |
|
||||||
|
|-------|------|------|--------|
|
||||||
|
| 🔴1 | `app/admin/admin.go` | `jwtMiddleware` пропускал любой путь с суффиксом `/auth` (обход JWT + IDOR на `DELETE .../queues/auth`). Теперь — точное сравнение `/ui/api/auth` | `1241093` |
|
||||||
|
| 🔴2 | `app/gosqs/queue_attributes.go` | Запрет self-/циклического `RedrivePolicy`: перенос в DLQ берёт второй `mu` под первым → deadlock | `9b9bbe1` |
|
||||||
|
| 🔴3 | `app/gosqs/gosqs.go`, `models.go`, `change_message_visibility*.go` | Перенос в DLQ не персистился (сообщение «воскрешало» в исходной и пропадало из DLQ) | `1e334c2` |
|
||||||
|
| 🔴4 | `app/persistence/redis.go`, `app/tenant/tenant_store.go` | `DeleteTenant` не чистил индекс `ssq:tenant_ak` — отозванный тенант возвращался через read-through | `f68b0a4` |
|
||||||
|
| 🔴5 | `app/persistence/redis.go`, `app/cmd/goaws.go` | Глобальный `Client` перезаписывался в `reconnect()` без синхронизации (data race). → `atomic.Pointer` | `d03ce36` |
|
||||||
|
| 🔴6 | `app/conf/config.go` | `return ports` без `SyncQueues.Unlock()` при ошибке `setQueueRedrivePolicy` → вечный deadlock | `1520753` |
|
||||||
|
| 🔴7 | `app/billing/billing.go` | `Close()` писал `db=nil` параллельно с `usageWorker` (nil-паника). → мьютекс + остановка воркера до `db.Close` | `4e36ba0` |
|
||||||
|
| 🔴10 | `app/auth/auth_middleware.go` | `X-Amz-Content-Sha256` не сверялся с телом — подмена тела под подписанным заголовком | `2918a41` |
|
||||||
|
|
||||||
|
(🔴8/🔴9 — на стороне IoT, см. `IoT/HISTORY/2026-10-11-session-log.md`.)
|
||||||
|
|
||||||
|
### 🟡 Важные
|
||||||
|
|
||||||
|
| Пункт | Файл | Суть | Коммит |
|
||||||
|
|-------|------|------|--------|
|
||||||
|
| 🟡20 | `app/admin/admin.go` | `createTenantQueue` не валидировал имя очереди (`:`,`/` ломали ключ) | `efd8c8e` |
|
||||||
|
| 🟡23 | `app/auth/jwt.go` | Токен без `exp` принимался бессрочно → требуем `exp` | `efd8c8e` |
|
||||||
|
| 🟡24 | `app/leader/leader.go` | При сбое `RefreshLock` инстанс оставался лидером (split-brain) → сброс лидерства после TTL | `efd8c8e` |
|
||||||
|
| 🟡21 | `app/tenant/tenant_store.go` | Запрет `:` в BYOC `access_key` (коллизии ключа `{ak}:{name}`) | `dc4815d` |
|
||||||
|
| 🟡22 | `app/admin/admin.go` | `sendMessageToQueue` без лимитов + async persist → лимит `MaxMessagesForQueue` + `SaveMessageSync` | `dc4815d` |
|
||||||
|
| 🟡33 | `app/models/models.go` | `InitDuplicatation` падал на nil-map `Duplicates` → ленивая инициализация | `dc4815d` |
|
||||||
|
| 🟡35 | `app/models/models.go` | `Reset()` не чистил `FIFOMessages` (после purge группы не выдавались) | `dc4815d` |
|
||||||
|
| 🟡36 | `app/gosqs/receive_message.go` | `ApproximateReceiveCount` был завышен на 1 | `dc4815d` |
|
||||||
|
| 🟡38 | `app/cmd/goaws.go` | `billing.Close()` вызывался ДО `Shutdown` серверов → перенесён после | `dc4815d` |
|
||||||
|
| 🟡26 | `app/gosqs/delete_message_batch.go` | Ключ map по `ReceiptHandle` (два entry с одним handle теряли Id) → по `Id` | `e0b5f24` |
|
||||||
|
| 🟡27 | `app/gosqs/send_message.go` | `DelaySeconds` не валидировался → 0–900 | `e0b5f24` |
|
||||||
|
| 🟡25 | `app/gosqs/send_message_batch.go` | Dedup регистрировался после цикла (два entry с одним id оба добавлялись); `NextSequenceNumber` до проверки дубликата; per-entry `DelaySeconds` не применялся | `6f69755` |
|
||||||
|
| 🟡29 | `app/gosqs/delete_message*.go`, `persistence/redis.go` | Ошибка `HDel` только логировалась (клиент получал 200, после рестарта сообщение воскрешало) → `DeleteMessage(s)Persist` возвращает ошибку, 500 клиенту | `5d38118` |
|
||||||
|
| 🟡31 | `app/persistence/redis.go`, `app/cmd/goaws.go` | Ошибка загрузки сообщений очереди давала `continue` (очередь стартовала пустой) → fail-fast (`log.Fatalf`) | `5d38118` |
|
||||||
|
| 🟡30 | `app/persistence/redis.go` | Async `SaveMessage` без порядковой нумерации (старое состояние могло перезаписать новое) → seq по `{queueKey}|{uuid}` + очистка при удалении | `7a2340d` |
|
||||||
|
| 🟡32 | `app/gosqs/gosqs.go` | `PeriodicTasks` без `recover` (паника убивала процесс) | `dc4815d` |
|
||||||
|
| 🟢 | `app/utils/utils.go` | `SqsErrors[errKey]` без проверки → nil-deref на неизвестном ключе | `5d38118` |
|
||||||
|
| (🟡 new) | `app/gosqs/create_queue.go` | Очередь из API получала `VisibilityTimeout=0` и пр. (дефолты не подставлялись) | `5d38118` |
|
||||||
|
|
||||||
|
### Осознанно НЕ менялось — РЕШЕНИЯ (2026-10-11)
|
||||||
|
|
||||||
|
- 🟡34 (атрибуты «0 = не передано»): **решение — не менять** совместимость.
|
||||||
|
Различать «не передано» и явный 0 потребовало бы перевести `QueueAttributes`
|
||||||
|
на указатели — ломает JSON-протокол и Terraform-drift. Явный 0 — редкий сценарий.
|
||||||
|
- 🟡37 (дефолт `SHARED_SQS_MAX_HEAP_MB`): **решение — оставить выключенным (0)**.
|
||||||
|
Безопасное число зависит от RAM конкретной ВМ и числа тенантов — выбор владельца,
|
||||||
|
не хардкод в коде.
|
||||||
|
- 🟡28 (сеть под `queue.mu`): **решение — оставить как есть (вариант A)**. См. п.4.
|
||||||
|
|
||||||
|
## 3. Уточнение выводов ревьюера
|
||||||
|
|
||||||
|
Ревьюер задал вопросы по коду, ответы найдены в коде (файл:строка):
|
||||||
|
|
||||||
|
- **ACL EMQX — единственный барьер для топиков** `{ns}/telemetry/{deviceId}`? Да:
|
||||||
|
`bridge/handler.go` берёт `ns`/`deviceID` из топика без сверки принадлежности,
|
||||||
|
барьер — EMQX authz (`emqx/emqx.conf`). Добавлена defense-in-depth проверка
|
||||||
|
формата (IoT).
|
||||||
|
- **Self-/циклический `RedrivePolicy` реально задаётся через API?** Да
|
||||||
|
(`setQueueAttributesV1` не проверял) — отсюда фикс 🔴2.
|
||||||
|
- **Пустой `adminToken`?** Недопустим — `goaws.go:~74` `log.Fatal` при отсутствии.
|
||||||
|
- **Дефолты очереди расходятся** (`create_queue.go` vs `conf/config.go:100-109`)?
|
||||||
|
Да — отсюда фикс (🟡 new).
|
||||||
|
- **`utils.go` паника на неизвестном ключе?** Текущей паники нет (все ключи есть
|
||||||
|
в `models.SqsErrors`), но добавлена защита.
|
||||||
|
|
||||||
|
Также ревьюер отметил некорректный пункт (удалён из списка по лимиту 40): nil-map
|
||||||
|
`Duplicates` (🟡33) фактически не воспроизводился в текущем коде — но защита
|
||||||
|
добавлена как дешёвая страховка.
|
||||||
|
|
||||||
|
## 4. 🟡28 — РЕШЕНИЕ (2026-10-11)
|
||||||
|
|
||||||
|
- **Проблема**: синхронный `SaveMessageSync` (до 3 с) выполняется под `queue.mu`
|
||||||
|
(`send_message.go:150`, `delete_message.go`, `admin.go`). Медленный Redis блокирует
|
||||||
|
всю очередь.
|
||||||
|
- **Варианты**: A) оставить; B) укоротить таймаут Redis-записи; C) полный рефакторинг
|
||||||
|
с per-message синхронизацией.
|
||||||
|
- **Выбрано A — оставить.** Обоснование:
|
||||||
|
1. in-memory ядро очередей — одиночка, блокировка `queue.mu` локальна (одна очередь).
|
||||||
|
2. Синхронный персист под `mu` = гарантия «нет потери подтверждённого сообщения
|
||||||
|
при крахе пода» — приоритет проекта (надёжность, а не масштабирование).
|
||||||
|
3. Вынос сети за `mu` вносит окно между `Unlock`/`Lock`: два конкурентных
|
||||||
|
`SendMessage` с одним `deduplicationId` оба пройдут `IsDuplicate` (дубль),
|
||||||
|
либо при откате `dedup` теряется сообщение — хуже кратковременной блокировки.
|
||||||
|
4. Redis-авария — глобальная: деградирует всё, 2–3 с на очередь не критично.
|
||||||
|
- 🟡34/🟡37 — см. п.2.
|
||||||
|
|
||||||
|
## 5. Верификация
|
||||||
|
|
||||||
|
- `go build ./...`, `go vet ./...`, `go test -race ./...` — зелёные на всех коммитах.
|
||||||
|
- Push: `2c6d2d7..f80e1b4` в `origin/master`.
|
||||||
|
|
||||||
|
## 6. Деплой на прод (ВМ 213) и smoke
|
||||||
|
|
||||||
|
- `make build` → `scp shared-sqs → /home/naeel/sqs-test/shared-sqs.new` →
|
||||||
|
`systemctl stop shared-sqs; mv .new → shared-sqs; start`. Бэкап — `shared-sqs.prev`.
|
||||||
|
- Проверка: `systemctl is-active` = active; `/health` → 200 `v0.1.35`;
|
||||||
|
в логе «загружено 5 очередей из Redis» (fail-fast-загрузка сработала, не упала).
|
||||||
|
- Smoke по `https://sqs.sinefo.ru` (`tests/api_test.sh`, aws cli, тенант `demo-service`):
|
||||||
|
**PASS=22 FAIL=0** (17 базовых операций + FIFO CreateQueue/Send/Receive/Delete/DeleteQueue).
|
||||||
|
Вывод: 18 фиксов ревью не сломали AWS-совместимость на проде.
|
||||||
|
|
||||||
|
## 7. Образы → Gitea registry
|
||||||
|
|
||||||
|
- Реестр — **Gitea** `gitea.services.ngcloud.ru` (НЕ Docker Hub). Makefile/Dockerfile/доки исправлены.
|
||||||
|
- Залито: `nail/shared-sqs:v0.1.35` (+latest; было только v0.1.25), `nail/iot-service:v0.1.13`,
|
||||||
|
`nail/iot-emqx:v0.2.4`.
|
||||||
|
- Push: `e30bf74..f80e1b4`.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# МАНУАЛ: что делать дальше (состояние на 2026-10-08)
|
||||||
|
|
||||||
|
> Файл для НОВОГО чата. Прочитай его первым, затем документы из раздела 2.
|
||||||
|
> Репозиторий: `/home/naeel/nubes/SQS_und_IoT/SQS-service` (сервис `shared-sqs`).
|
||||||
|
> Всё, что описано как «сделано», уже в ветке `main` и на сервере (`origin/main`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Где мы сейчас
|
||||||
|
|
||||||
|
**Код SQS готов и запушен.** Ветка `main` = `origin/main`, рабочая копия чистая.
|
||||||
|
|
||||||
|
Что сделано за 2026-10-05…08:
|
||||||
|
- закрыт весь план доработок P0/P1/P2 (`doc/thinking/plan-for-flash-2026-10-05.md`);
|
||||||
|
- P1.2 — индексация сообщений (tombstone + индексы + амортизированная компакция);
|
||||||
|
- полный код-ревью сервиса + исправления (утечка блокировки очереди, DLQ в батче,
|
||||||
|
гонка лимита тенантов, потеря `NubesSub`, общий HTTP-клиент);
|
||||||
|
- интеграционные прогоны: `api_test.sh` 22/22, `sdk_test.py` 15/15, `fifo_dlq_probe.py` — PASS,
|
||||||
|
стресс 3×2000 сообщений — без зависаний.
|
||||||
|
|
||||||
|
**Прод-сервис никто не использует** (podтверждено владельцем), IoT переделывается с нуля.
|
||||||
|
Поэтому мы свободны менять архитектуру без обратной совместимости.
|
||||||
|
|
||||||
|
## 2. Документы, которые надо прочитать в этом порядке
|
||||||
|
|
||||||
|
| Документ | Что внутри |
|
||||||
|
|---|---|
|
||||||
|
| `doc/thinking/architecture-analysis-2026-10-08.md` | **ГЛАВНЫЙ**: решения по размещению и разбиению (публичный SQS, обход через ВМ, модульный монолит data-plane/control-plane) |
|
||||||
|
| `doc/thinking/nubes-large-body-workaround-findings-2026-10-08.md` | как та же болезнь платформы обойдена в `~/nubes/drhider` (буфер на ВМ, pull вместо push) — со ссылками «файл:строка» |
|
||||||
|
| `doc/thinking/nubes-workaround-applicability-to-sqs-2026-10-08.md` | применимость к SQS: 4 варианта, обязательные замеры, техусловия (Host для подписи, внешние `QueueUrl`, TLS) |
|
||||||
|
| `HISTORY/2026-10-07-session-log.md` | журнал правок: что и почему менялось, найденные дефекты, уроки |
|
||||||
|
| `doc/thinking/nubes-ticket.md` | исходная диагностика сетевой болезни (MSS/MTU + таймер шлюза). **Тикет отправлять не нужно** — см. `HANDOFF-NEW-CHAT.md`, раздел 7 |
|
||||||
|
| `doc/thinking/plan-for-flash-2026-10-05.md` | исходный план доработок (выполнен) |
|
||||||
|
| `HANDOFF-NEW-CHAT.md` | доступы, креды, конфигурация кластера, история проекта |
|
||||||
|
|
||||||
|
## 3. Принятые решения (не пересматривать без причины)
|
||||||
|
|
||||||
|
1. **SQS — публичный сервис**, как AWS SQS / YMQ. Внешний доступ обязателен,
|
||||||
|
совместимость с AWS SDK — обязательна.
|
||||||
|
2. **Сетевую болезнь платформы не «лечим» тикетом** — обходим: публичная точка входа
|
||||||
|
на ВМ, минуя managed-шлюз.
|
||||||
|
3. **Пока — ВМ (сейчас `5.172.178.213`), потом отдельная прод-ВМ.** Девопсов не дёргаем,
|
||||||
|
пока не отлажено; затем — уже с доказательствами (tcpdump, период 31–33 с, MSS 1448).
|
||||||
|
4. **«Без монолитов» = модульный монолит**: в отдельные процессы выносим только то,
|
||||||
|
что живёт по-разному. Для SQS граница одна: **data-plane** (очереди/сообщения/SigV4)
|
||||||
|
и **control-plane** (тенанты, креды, админ). Биллинг/метрики/UI — модули, не сервисы.
|
||||||
|
5. **Ограничение, которое важнее разбиения**: ядро очередей держит состояние в памяти
|
||||||
|
одного пода → одиночка. Пока хранилище не вынесено, «не монолит» ≠ «масштабируется».
|
||||||
|
|
||||||
|
## 4. План работ (порядок обязателен)
|
||||||
|
|
||||||
|
### Шаг 1. Проверить гипотезу на ВМ — БЕЗ кода (критерий входа: домен и сертификат)
|
||||||
|
Цель: убедиться, что на ВМ болезни нет, **до** вложений в разбиение.
|
||||||
|
|
||||||
|
1. Собрать бинарник: `make build` (или `CGO_ENABLED=0 go build -o shared-sqs ./app/cmd/`).
|
||||||
|
2. Поднять на ВМ (memory-only достаточно): см. команды в разделе 5.
|
||||||
|
3. Поставить nginx с TLS на выбранный домен, проксировать на `127.0.0.1:4100`.
|
||||||
|
**Обязательно** `proxy_set_header Host $host;` — иначе AWS-подпись не сойдётся
|
||||||
|
(сервис берёт хост из запроса: `app/auth/auth_middleware.go:315-317`).
|
||||||
|
4. Замерить **с внешней машины** (не с ВМ): малые тела, 1 КБ, 10 КБ, 100 КБ, 256 КБ,
|
||||||
|
плюс длинный прогон на таймауты. Инструменты: `tests/tcp_mtu_probe.py`,
|
||||||
|
`tests/long_compare_local.py`, `tests/compare_ymq_vs_shared.py`.
|
||||||
|
5. **Критерий успеха:** 0 зависаний ~51 с, 0 таймаутов 31–33 с.
|
||||||
|
|
||||||
|
Если гипотеза не подтвердится — СТОП, вернуться к разбору (раздел 2, документ о применимости).
|
||||||
|
|
||||||
|
### Шаг 2. Код: разбиение на два процесса
|
||||||
|
- `data-plane` — SQS API и очереди (`app/gosqs/*`, `app/router/router.go`, `app/auth/auth_middleware.go`).
|
||||||
|
- `control-plane` — админка (`app/admin/admin.go`) и управление тенантами (`app/tenant/*`).
|
||||||
|
- Что уже учитывать при разбиении:
|
||||||
|
- адреса очередей формируются жёстко: `app/gosqs/tenant_helpers.go:29-37` →
|
||||||
|
`http://{region}.{host}:{port}/{tenantID}/{queue}`; хост/порт только из `app/conf/goaws.yaml`
|
||||||
|
(`app/conf/config.go:87-90`), env-переменных для Host нет → **внешние `QueueUrl` потребуют правки**;
|
||||||
|
- подпись SigV4 считается по заголовку `Host` (см. выше);
|
||||||
|
- ключ очереди — `{accessKey}:{queueName}` (`app/gosqs/tenant_helpers.go:16`).
|
||||||
|
|
||||||
|
### Шаг 3. Код: вынести состояние (стратегический пункт)
|
||||||
|
- Сейчас: память — источник истины, Redis — write-through (`app/persistence/redis.go`,
|
||||||
|
`LoadAllQueues`), восстановление при старте.
|
||||||
|
- На ВМ поставить Redis и решить, как читать: остаться write-through или перейти к Redis
|
||||||
|
как источнику истины (тогда появится возможность нескольких экземпляров).
|
||||||
|
- Это решение определяет потолок сервиса (сейчас — один под).
|
||||||
|
|
||||||
|
### Шаг 4. IoT — отдельно, после SQS
|
||||||
|
- Внешний MQTT-канал неизбежен (устройства), но там только мелкая телеметрия;
|
||||||
|
критичен разрыв wss ~150 с (лечится переподключением).
|
||||||
|
- Крупные тела устройств через managed-шлюз не пускать (лимит/дробление/приём на ВМ).
|
||||||
|
- IoT переделывается с нуля не монолитом: bridge (MQTT), consumer (→storage), API.
|
||||||
|
|
||||||
|
## 5. Команды (проверены)
|
||||||
|
|
||||||
|
**Локальный запуск (memory-only, без Redis):**
|
||||||
|
```bash
|
||||||
|
cd /home/naeel/nubes/SQS_und_IoT/SQS-service
|
||||||
|
go build -o /tmp/shared-sqs ./app/cmd/
|
||||||
|
SHARED_SQS_SEED_DEMO=true /tmp/shared-sqs --admin-token=dev-token-123 --port=4100
|
||||||
|
# креды демо-тенанта печатаются в лог при старте
|
||||||
|
```
|
||||||
|
⚠️ Не запускать через `| head` — SIGPIPE убивает процесс на середине теста.
|
||||||
|
|
||||||
|
**Проверки кода:**
|
||||||
|
```bash
|
||||||
|
go build ./... && go vet ./... && go test -race ./app/...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Деплой на ВМ:**
|
||||||
|
- зеркало репозитория: `naeel@5.172.178.213:~/terra/SQS-service`;
|
||||||
|
- SSH: `ssh -i ~/.ssh/naeel_vm_id_ed25519 naeel@5.172.178.213`;
|
||||||
|
- ⚠️ порт **443 на 213 занят** (nginx/xray/hysteria), поэтому публичная точка SQS —
|
||||||
|
отдельный домен на существующем nginx (виртуальный хост/SNI) или отдельный порт;
|
||||||
|
- ⚠️ в образе `Dockerfile` зашит `REDIS_ADDR` внутреннего кластера — на ВМ переопределить
|
||||||
|
(или не задавать, чтобы работать memory-only).
|
||||||
|
|
||||||
|
**Сборка образа (если через Docker):** `make docker-build` (`Makefile`, образ `naeel/shared-sqs`).
|
||||||
|
|
||||||
|
## 6. Грабли и правила (не наступать повторно)
|
||||||
|
|
||||||
|
1. `app/cmd` в `.gitignore` → при добавлении файлов `git add -f app/cmd/...`.
|
||||||
|
2. `deployments/` в этом репозитории **нет** — не искать.
|
||||||
|
3. `Queue.Messages` — tombstone-хранилище: менять только через `AppendMessage`/`RemoveIndex`/
|
||||||
|
`Compact`/`Reset`; число живых = `queue.LiveCount()`, а НЕ `len(queue.Messages)`
|
||||||
|
(`app/models/models.go`).
|
||||||
|
4. После `GetLocked`/`GetRLocked` — `defer Unlock` **до** любых `return` (иначе очередь
|
||||||
|
зависает навсегда; было в `receive_message.go`, исправлено).
|
||||||
|
5. Интеграционные тесты: `tests/api_test.sh` читает `ENDPOINT`, python-тесты — `ENDPOINT_URL`.
|
||||||
|
`boto3` в системе нет (PEP 668) → скачать колеса `pip download boto3`, распаковать в `/tmp`
|
||||||
|
и запускать с `PYTHONPATH`.
|
||||||
|
6. Лимит очередей тенанта = 10 → после упавших прогонов чистить очереди.
|
||||||
|
7. Диагностика зависаний сервиса: стек горутин по `kill -QUIT` (SIGQUIT).
|
||||||
|
8. Правки ingress на платформе — только через `ShturvalServiceConfig` (kubectl откатывает ArgoCD).
|
||||||
|
|
||||||
|
## 7. Правила работы с владельцем (обязательные)
|
||||||
|
|
||||||
|
- Коммитить после каждой логически завершённой правки; **пуш — только по прямой команде**.
|
||||||
|
- Документировать всё, включая собственные ошибки (HISTORY + `doc/thinking/`).
|
||||||
|
- Не выполнять `terraform apply`/`destroy`, не менять платформу, не удалять инстансы.
|
||||||
|
- Не расширять область работ; не лезть в соседние репозитории без прямой команды.
|
||||||
|
- Рекомендация без замера — не рекомендация, а догадка: сначала мерить, потом предлагать.
|
||||||
|
- Вопрос от владельца = только ответ (ничего не делать, пока не последует команда).
|
||||||
|
|
||||||
|
## 8. Открытые вопросы (нужны решения владельца)
|
||||||
|
|
||||||
|
1. **Домен и сертификат** для публичной точки SQS на ВМ (порт 443 занят → виртуальный хост?).
|
||||||
|
2. **Отдельная прод-ВМ** — когда создавать и с какими параметрами (пока хватит 213 для тестов;
|
||||||
|
хватит ли 213 — проверить замером: ядра, память, диск, загрузка).
|
||||||
|
3. **Redis на ВМ** — ставить сейчас или пока memory-only.
|
||||||
|
4. **Вынос состояния** (Шаг 3) — когда начинать; это определяет масштабируемость.
|
||||||
|
5. **Клиенты без явного `endpoint_url`** — есть ли такие (влияет на внешние `QueueUrl`).
|
||||||
|
|
||||||
|
## 9. Карта кода (что где)
|
||||||
|
|
||||||
|
| Путь | Ответственность |
|
||||||
|
|---|---|
|
||||||
|
| `app/cmd/goaws.go` | точка входа, флаги, загрузка конфига, periodic tasks, HTTP-сервер |
|
||||||
|
| `app/router/router.go` | HTTP-роутинг, лимит тела 3 МБ, биллинг/метрики по операциям |
|
||||||
|
| `app/auth/auth_middleware.go` | SigV4 (header + presigned), извлечение тенанта |
|
||||||
|
| `app/auth/jwt.go`, `app/auth/cache.go` | валидация JWT через внешний API + кэш |
|
||||||
|
| `app/gosqs/*` | SQS-операции (send/receive/delete/visibility/queue/attributes/tags) |
|
||||||
|
| `app/gosqs/receive_message.go` | long-polling, выдача сообщений (в т.ч. per-queue лок) |
|
||||||
|
| `app/gosqs/gosqs.go` | фоновый тик: retention, дедупликация, видимость, DLQ |
|
||||||
|
| `app/models/models.go` | `Queue`/`SqsMessage`, индексация (tombstone), FIFO-состояние |
|
||||||
|
| `app/models/globals.go` | `SyncQueues`: map очередей + tenant-индекс + блокировки |
|
||||||
|
| `app/persistence/redis.go` | write-through в Redis, загрузка при старте, переподключение |
|
||||||
|
| `app/admin/admin.go` | админка (тенанты/очереди/сообщения), UI-API по JWT |
|
||||||
|
| `app/tenant/tenant_store.go` | хранилище тенантов, генерация кредов, авто-провижининг по JWT |
|
||||||
|
| `app/billing/billing.go` | учёт операций в PostgreSQL (воркер + буфер) |
|
||||||
|
| `app/metrics/*` | Prometheus-метрики и gauge-обновление |
|
||||||
|
| `app/conf/goaws.yaml` | Host/Port/Region, дефолты очередей (используется для URL) |
|
||||||
|
| `tests/*` | интеграционные проверки (aws CLI, boto3, пробы сети/MTU) |
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
# Makefile — shared-sqs
|
# Makefile — shared-sqs
|
||||||
# Created: 2026-04-09
|
# Created: 2026-04-09
|
||||||
# Registry: Docker Hub (naeel/shared-sqs) — pearlharbor не используется (нестабилен)
|
# Registry: Gitea (gitea.services.ngcloud.ru) — managed-платформа тянет образы отсюда.
|
||||||
|
# Проверено 2026-10-11: gitea.services.ngcloud.ru/nail/shared-sqs существует (login naeel).
|
||||||
|
|
||||||
IMAGE_REPO=naeel/shared-sqs
|
REGISTRY ?= gitea.services.ngcloud.ru/nail
|
||||||
VERSION=v0.1.0
|
IMAGE_REPO=$(REGISTRY)/shared-sqs
|
||||||
|
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 ./...
|
||||||
|
|||||||
@@ -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,11 +95,12 @@ aws sqs send-message \
|
|||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [PLAN.md](PLAN.md) — детальный план разработки
|
- [PLAN.md](doc/legacy/PLAN.md) — ЛЕГАСИ, исторический план разработки
|
||||||
|
- [doc/billing-and-metrics.md](doc/billing-and-metrics.md) — биллинг и Prometheus-метрики
|
||||||
- [doc/byoc-credentials.md](doc/byoc-credentials.md) — BYOC интеграция
|
- [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)
|
||||||
|
|||||||
+243
-99
@@ -1,11 +1,12 @@
|
|||||||
// app/admin/admin.go
|
// app/admin/admin.go
|
||||||
// Admin API handlers for shared-sqs management
|
// Admin API handlers for shared-sqs management
|
||||||
// Created: 2026-04-09
|
// Created: 2026-04-09
|
||||||
// Updated: 2026-04-10 — JWT auth через nubes API, auto-provisioning тенантов
|
// Updated: 2026-04-12 10:12 MSK — пометки о временном demo showcase режиме
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,15 +14,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"shared-sqs/app/auth"
|
"shared-sqs/app/auth"
|
||||||
|
"shared-sqs/app/gosqs"
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
"shared-sqs/app/persistence"
|
"shared-sqs/app/persistence"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type uiContextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
uiTenantContextKey uiContextKey = "ui-tenant"
|
||||||
|
)
|
||||||
|
|
||||||
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
||||||
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
||||||
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
||||||
@@ -45,12 +52,29 @@ 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}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentUITenant — возвращает tenant, авторизованный через UI middleware.
|
||||||
|
func currentUITenant(r *http.Request) (*tenant.Tenant, bool) {
|
||||||
|
t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant)
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantListItemFromTenant — строит публичный JSON-ответ без SecretKey.
|
||||||
|
func tenantListItemFromTenant(t *tenant.Tenant) tenantListItem {
|
||||||
|
return tenantListItem{
|
||||||
|
ID: t.ID,
|
||||||
|
Name: t.Name,
|
||||||
|
AccessKey: t.AccessKey,
|
||||||
|
MaxQueues: t.MaxQueues,
|
||||||
|
CreatedAt: t.CreatedAt,
|
||||||
|
Active: t.Active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
||||||
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
||||||
adminRouter := r.PathPrefix("/admin").Subrouter()
|
adminRouter := r.PathPrefix("/admin").Subrouter()
|
||||||
@@ -78,6 +102,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")
|
||||||
@@ -95,8 +120,12 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
|||||||
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
// Timing-safe сравнение: длина префикса и сам токен проверяются отдельно,
|
||||||
|
// само сравнение значения — через subtle.ConstantTimeCompare, чтобы не
|
||||||
|
// давать timing side-channel при подборе admin-токена.
|
||||||
expected := "Bearer " + h.adminToken
|
expected := "Bearer " + h.adminToken
|
||||||
if authHeader != expected {
|
if len(authHeader) != len(expected) ||
|
||||||
|
subtle.ConstantTimeCompare([]byte(authHeader), []byte(expected)) != 1 {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||||
@@ -126,6 +155,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()
|
||||||
@@ -134,10 +171,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")
|
||||||
@@ -150,13 +190,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> заголовок.
|
||||||
@@ -164,8 +219,10 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
// НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth.
|
// НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth.
|
||||||
func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// /ui/api/auth — публичный endpoint, пропускаем без проверки JWT
|
// /ui/api/auth — публичный endpoint, пропускаем без проверки JWT.
|
||||||
if strings.HasSuffix(r.URL.Path, "/auth") {
|
// Сравниваем ТОЧНЫЙ путь, а не суффикс "/auth": иначе, например,
|
||||||
|
// DELETE /ui/api/tenants/{id}/queues/auth пропускался бы без JWT и IDOR-проверки.
|
||||||
|
if r.URL.Path == "/ui/api/auth" {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -188,17 +245,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
|
||||||
@@ -210,7 +268,8 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
uiCtx := context.WithValue(r.Context(), uiTenantContextKey, jwtTenant)
|
||||||
|
next.ServeHTTP(w, r.WithContext(uiCtx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +277,10 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
|
|||||||
type createTenantRequest struct {
|
type createTenantRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MaxQueues int `json:"max_queues"`
|
MaxQueues int `json:"max_queues"`
|
||||||
|
// BYOC: необязательные произвольные креды. Если задано одно поле —
|
||||||
|
// оба обязательны.
|
||||||
|
AccessKey string `json:"access_key,omitempty"`
|
||||||
|
SecretKey string `json:"secret_key,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании)
|
// tenantCreateResponse — ответ с secret_key (показывается ТОЛЬКО при создании)
|
||||||
@@ -243,6 +306,10 @@ type tenantListItem struct {
|
|||||||
|
|
||||||
// createTenant — POST /admin/tenants
|
// createTenant — POST /admin/tenants
|
||||||
func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := currentUITenant(r); ok {
|
||||||
|
jsonErr(w, http.StatusForbidden, "tenant creation via UI is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
var req createTenantRequest
|
var req createTenantRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -256,7 +323,23 @@ func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "name is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t, err := h.store.Create(req.Name, req.MaxQueues)
|
|
||||||
|
// BYOC: либо оба поля кредов заданы, либо ни одного.
|
||||||
|
var t *tenant.Tenant
|
||||||
|
var err error
|
||||||
|
if req.AccessKey != "" || req.SecretKey != "" {
|
||||||
|
if req.AccessKey == "" || req.SecretKey == "" {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "both access_key and secret_key are required when specifying custom credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.AccessKey) < 8 || len(req.SecretKey) < 16 {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "access_key min 8 chars, secret_key min 16 chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err = h.store.CreateFixedAutoID(req.Name, req.MaxQueues, req.AccessKey, req.SecretKey)
|
||||||
|
} else {
|
||||||
|
t, err = h.store.Create(req.Name, req.MaxQueues)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("admin: failed to create tenant: %v", err)
|
log.Errorf("admin: failed to create tenant: %v", err)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -279,17 +362,15 @@ func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// listTenants — GET /admin/tenants
|
// listTenants — GET /admin/tenants
|
||||||
func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if uiTenant, ok := currentUITenant(r); ok {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode([]tenantListItem{tenantListItemFromTenant(uiTenant)})
|
||||||
|
return
|
||||||
|
}
|
||||||
tenants := h.store.List()
|
tenants := h.store.List()
|
||||||
items := make([]tenantListItem, 0, len(tenants))
|
items := make([]tenantListItem, 0, len(tenants))
|
||||||
for _, t := range tenants {
|
for _, t := range tenants {
|
||||||
items = append(items, tenantListItem{
|
items = append(items, tenantListItemFromTenant(t))
|
||||||
ID: t.ID,
|
|
||||||
Name: t.Name,
|
|
||||||
AccessKey: t.AccessKey,
|
|
||||||
MaxQueues: t.MaxQueues,
|
|
||||||
CreatedAt: t.CreatedAt,
|
|
||||||
Active: t.Active,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(items)
|
json.NewEncoder(w).Encode(items)
|
||||||
@@ -307,19 +388,16 @@ func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(tenantListItem{
|
json.NewEncoder(w).Encode(tenantListItemFromTenant(t))
|
||||||
ID: t.ID,
|
|
||||||
Name: t.Name,
|
|
||||||
AccessKey: t.AccessKey,
|
|
||||||
MaxQueues: t.MaxQueues,
|
|
||||||
CreatedAt: t.CreatedAt,
|
|
||||||
Active: t.Active,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteTenant — DELETE /admin/tenants/{id}
|
// deleteTenant — DELETE /admin/tenants/{id}
|
||||||
// Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak)
|
// Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak)
|
||||||
func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := currentUITenant(r); ok {
|
||||||
|
jsonErr(w, http.StatusForbidden, "tenant deletion via UI is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
id := vars["id"]
|
id := vars["id"]
|
||||||
t, ok := h.store.GetByID(id)
|
t, ok := h.store.GetByID(id)
|
||||||
@@ -329,15 +407,14 @@ func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
|
json.NewEncoder(w).Encode(map[string]string{"error": "tenant not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Удаляем все очереди тенанта из SyncQueues
|
// Удаляем все очереди тенанта из SyncQueues (через tenant-индекс, без скана всех очередей)
|
||||||
prefix := t.AccessKey + ":"
|
|
||||||
deletedQueueKeys := make([]string, 0)
|
|
||||||
models.SyncQueues.Lock()
|
models.SyncQueues.Lock()
|
||||||
for key := range models.SyncQueues.Queues {
|
tenantQueueNames := models.SyncQueues.TenantQueueNames(t.AccessKey)
|
||||||
if strings.HasPrefix(key, prefix) {
|
deletedQueueKeys := make([]string, 0, len(tenantQueueNames))
|
||||||
deletedQueueKeys = append(deletedQueueKeys, key)
|
for _, name := range tenantQueueNames {
|
||||||
delete(models.SyncQueues.Queues, key)
|
key := t.AccessKey + ":" + name
|
||||||
}
|
deletedQueueKeys = append(deletedQueueKeys, key)
|
||||||
|
models.SyncQueues.RemoveQueue(key)
|
||||||
}
|
}
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
for _, queueKey := range deletedQueueKeys {
|
for _, queueKey := range deletedQueueKeys {
|
||||||
@@ -370,30 +447,42 @@ func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
|
|||||||
RetentionPeriod int `json:"retention_period"`
|
RetentionPeriod int `json:"retention_period"`
|
||||||
}
|
}
|
||||||
queues := make([]queueInfo, 0)
|
queues := make([]queueInfo, 0)
|
||||||
|
type nameQueue struct {
|
||||||
|
name string
|
||||||
|
q *models.Queue
|
||||||
|
}
|
||||||
models.SyncQueues.RLock()
|
models.SyncQueues.RLock()
|
||||||
for key, q := range models.SyncQueues.Queues {
|
pairs := make([]nameQueue, 0)
|
||||||
if strings.HasPrefix(key, prefix) {
|
for _, name := range models.SyncQueues.TenantQueueNames(t.AccessKey) {
|
||||||
name := strings.TrimPrefix(key, prefix)
|
if q := models.SyncQueues.Queues[prefix+name]; q != nil {
|
||||||
visible := 0
|
pairs = append(pairs, nameQueue{name, q})
|
||||||
notVisible := 0
|
|
||||||
for _, msg := range q.Messages {
|
|
||||||
if msg.ReceiptHandle != "" {
|
|
||||||
notVisible++
|
|
||||||
} else {
|
|
||||||
visible++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
queues = append(queues, queueInfo{
|
|
||||||
Name: name,
|
|
||||||
Messages: visible,
|
|
||||||
NotVisible: notVisible,
|
|
||||||
VisibilityTimeout: q.VisibilityTimeout,
|
|
||||||
MaxMessageSize: q.MaximumMessageSize,
|
|
||||||
RetentionPeriod: q.MessageRetentionPeriod,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
models.SyncQueues.RUnlock()
|
models.SyncQueues.RUnlock()
|
||||||
|
for _, p := range pairs {
|
||||||
|
p.q.RLockMu()
|
||||||
|
visible := 0
|
||||||
|
notVisible := 0
|
||||||
|
for i := range p.q.Messages {
|
||||||
|
if p.q.IsDead(i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.q.Messages[i].ReceiptHandle != "" {
|
||||||
|
notVisible++
|
||||||
|
} else {
|
||||||
|
visible++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queues = append(queues, queueInfo{
|
||||||
|
Name: p.name,
|
||||||
|
Messages: visible,
|
||||||
|
NotVisible: notVisible,
|
||||||
|
VisibilityTimeout: p.q.VisibilityTimeout,
|
||||||
|
MaxMessageSize: p.q.MaximumMessageSize,
|
||||||
|
RetentionPeriod: p.q.MessageRetentionPeriod,
|
||||||
|
})
|
||||||
|
p.q.RUnlockMu()
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(queues)
|
json.NewEncoder(w).Encode(queues)
|
||||||
}
|
}
|
||||||
@@ -421,6 +510,12 @@ func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, http.StatusBadRequest, "name is required")
|
jsonErr(w, http.StatusBadRequest, "name is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Та же валидация имени, что и в SQS CreateQueue (иначе имена с ':'/'/'
|
||||||
|
// ломали бы ключ "{accessKey}:{name}").
|
||||||
|
if err := gosqs.ValidateQueueName(req.Name); err != nil {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "invalid queue name")
|
||||||
|
return
|
||||||
|
}
|
||||||
key := t.AccessKey + ":" + req.Name
|
key := t.AccessKey + ":" + req.Name
|
||||||
models.SyncQueues.Lock()
|
models.SyncQueues.Lock()
|
||||||
if _, exists := models.SyncQueues.Queues[key]; exists {
|
if _, exists := models.SyncQueues.Queues[key]; exists {
|
||||||
@@ -428,27 +523,18 @@ func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, http.StatusConflict, "queue already exists")
|
jsonErr(w, http.StatusConflict, "queue already exists")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Проверяем лимит очередей тенанта
|
// Проверяем лимит очередей тенанта (через tenant-индекс, O(1))
|
||||||
count := 0
|
count := models.SyncQueues.CountTenantQueues(t.AccessKey)
|
||||||
for k := range models.SyncQueues.Queues {
|
|
||||||
if strings.HasPrefix(k, t.AccessKey+":") {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if t.MaxQueues > 0 && count >= t.MaxQueues {
|
if t.MaxQueues > 0 && count >= t.MaxQueues {
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
jsonErr(w, http.StatusForbidden, "queue limit exceeded")
|
jsonErr(w, http.StatusForbidden, "queue limit exceeded")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
models.SyncQueues.Queues[key] = &models.Queue{
|
// Очередь создаётся через общий билдер — те же URL/ARN/дефолты и FIFO-детект,
|
||||||
Name: req.Name,
|
// что и в SQS CreateQueue (единый путь, без расхождения поведения UI и API).
|
||||||
VisibilityTimeout: 30,
|
newQueue := gosqs.BuildQueueForTenant(t, req.Name)
|
||||||
MaximumMessageSize: 262144,
|
models.SyncQueues.AddQueue(key, newQueue)
|
||||||
MessageRetentionPeriod: 345600,
|
persistence.SaveQueue(key, newQueue)
|
||||||
Messages: []models.SqsMessage{},
|
|
||||||
Duplicates: make(map[string]time.Time),
|
|
||||||
}
|
|
||||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID)
|
log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -473,7 +559,7 @@ func (h *Handler) deleteTenantQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, http.StatusNotFound, "queue not found")
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
delete(models.SyncQueues.Queues, key)
|
models.SyncQueues.RemoveQueue(key)
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
persistence.DeleteQueue(key)
|
persistence.DeleteQueue(key)
|
||||||
log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID)
|
log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID)
|
||||||
@@ -522,12 +608,16 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
models.SyncQueues.RLock()
|
q.RLockMu()
|
||||||
result := make([]peekMessageItem, 0, len(q.Messages))
|
result := make([]peekMessageItem, 0, limit)
|
||||||
for i, msg := range q.Messages {
|
for i := range q.Messages {
|
||||||
if i >= limit {
|
if len(result) >= limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if q.IsDead(i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := &q.Messages[i]
|
||||||
result = append(result, peekMessageItem{
|
result = append(result, peekMessageItem{
|
||||||
ID: msg.Uuid,
|
ID: msg.Uuid,
|
||||||
Body: msg.MessageBody,
|
Body: msg.MessageBody,
|
||||||
@@ -537,7 +627,7 @@ func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
InFlight: msg.ReceiptHandle != "",
|
InFlight: msg.ReceiptHandle != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
models.SyncQueues.RUnlock()
|
q.RUnlockMu()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(result)
|
json.NewEncoder(w).Encode(result)
|
||||||
}
|
}
|
||||||
@@ -568,15 +658,30 @@ func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, http.StatusBadRequest, "body is required")
|
jsonErr(w, http.StatusBadRequest, "body is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg := models.SqsMessage{
|
// Сообщение строится через общий билдер (UUID + MD5), + проверка размера и
|
||||||
MessageBody: req.Body,
|
// лимита — как в SQS SendMessage (иначе UI обходил бы OOM-защиту).
|
||||||
Uuid: uuid.NewString(),
|
q.LockMu()
|
||||||
SentTime: time.Now(),
|
if q.MaximumMessageSize > 0 && len(req.Body) > q.MaximumMessageSize {
|
||||||
|
q.UnlockMu()
|
||||||
|
jsonErr(w, http.StatusBadRequest, "message too big")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
models.SyncQueues.Lock()
|
if q.LiveCount() >= gosqs.MaxMessagesForQueue(q.IsFIFO) {
|
||||||
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
|
q.UnlockMu()
|
||||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
jsonErr(w, http.StatusTooManyRequests, "queue is full")
|
||||||
models.SyncQueues.Unlock()
|
return
|
||||||
|
}
|
||||||
|
msg := gosqs.BuildSqsMessage(req.Body)
|
||||||
|
idx := q.AppendMessage(msg)
|
||||||
|
// Персистим СИНХРОННО (как SendMessage) — иначе 201 без гарантии записи.
|
||||||
|
if err := persistence.SaveMessageSync(key, &msg); err != nil {
|
||||||
|
q.RemoveIndex(idx)
|
||||||
|
q.UnlockMu()
|
||||||
|
jsonErr(w, http.StatusInternalServerError, "failed to persist message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
persistence.SaveQueue(key, q)
|
||||||
|
q.UnlockMu()
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"})
|
json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"})
|
||||||
@@ -597,10 +702,12 @@ func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonErr(w, http.StatusNotFound, "queue not found")
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
models.SyncQueues.Lock()
|
q.LockMu()
|
||||||
models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0]
|
q.Reset()
|
||||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
// Удаляем все сообщения из Redis одной командой
|
||||||
models.SyncQueues.Unlock()
|
persistence.PurgeMessagesPersist(key)
|
||||||
|
persistence.SaveQueue(key, q)
|
||||||
|
q.UnlockMu()
|
||||||
log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID)
|
log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
@@ -615,6 +722,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"`
|
||||||
@@ -622,18 +730,54 @@ type adminHealthDetail struct {
|
|||||||
|
|
||||||
// detailedHealth — GET /admin/health
|
// detailedHealth — GET /admin/health
|
||||||
func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if uiTenant, ok := currentUITenant(r); ok {
|
||||||
|
prefix := uiTenant.AccessKey + ":"
|
||||||
|
queueCount := 0
|
||||||
|
msgCount := 0
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
uiQueues := make([]*models.Queue, 0)
|
||||||
|
for key, q := range models.SyncQueues.Queues {
|
||||||
|
if strings.HasPrefix(key, prefix) {
|
||||||
|
queueCount++
|
||||||
|
uiQueues = append(uiQueues, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
|
for _, q := range uiQueues {
|
||||||
|
q.RLockMu()
|
||||||
|
msgCount += q.LiveCount()
|
||||||
|
q.RUnlockMu()
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(adminHealthDetail{
|
||||||
|
Status: "ok",
|
||||||
|
Version: models.Version,
|
||||||
|
TenantCount: 1,
|
||||||
|
QueueCount: queueCount,
|
||||||
|
MessageCount: msgCount,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
tenants := h.store.List()
|
tenants := h.store.List()
|
||||||
models.SyncQueues.RLock()
|
models.SyncQueues.RLock()
|
||||||
queueCount := len(models.SyncQueues.Queues)
|
queueCount := len(models.SyncQueues.Queues)
|
||||||
msgCount := 0
|
allQueues := make([]*models.Queue, 0, queueCount)
|
||||||
for _, q := range models.SyncQueues.Queues {
|
for _, q := range models.SyncQueues.Queues {
|
||||||
msgCount += len(q.Messages)
|
allQueues = append(allQueues, q)
|
||||||
}
|
}
|
||||||
models.SyncQueues.RUnlock()
|
models.SyncQueues.RUnlock()
|
||||||
|
msgCount := 0
|
||||||
|
for _, q := range allQueues {
|
||||||
|
q.RLockMu()
|
||||||
|
msgCount += q.LiveCount()
|
||||||
|
q.RUnlockMu()
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// app/admin/admin_test.go
|
||||||
|
// Focused tests for UI auth and scoping
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"shared-sqs/app/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestJWTAuth_RejectsMissingToken verifies jwtAuth rejects a request without a token.
|
||||||
|
func TestJWTAuth_RejectsMissingToken(t *testing.T) {
|
||||||
|
store := tenant.NewTenantStore()
|
||||||
|
h := NewHandler(store, "admin-token")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.jwtAuth(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("jwtAuth() status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListTenants_UIContextReturnsOnlyOwnTenant verifies that UI API listing is scoped to the authenticated tenant.
|
||||||
|
func TestListTenants_UIContextReturnsOnlyOwnTenant(t *testing.T) {
|
||||||
|
store := tenant.NewTenantStore()
|
||||||
|
firstTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFixed(first): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.CreateFixed("other-service", 10, "t-other-test", "SSAK-other-test", "other-secret"); err != nil {
|
||||||
|
t.Fatalf("CreateFixed(second): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(store, "admin-token")
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ui/api/tenants", nil)
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), uiTenantContextKey, firstTenant))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.listTenants(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("listTenants() status = %d, body = %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp []map[string]any
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp) != 1 {
|
||||||
|
t.Fatalf("len(response) = %d, want 1", len(resp))
|
||||||
|
}
|
||||||
|
if got := resp[0]["id"]; got != firstTenant.ID {
|
||||||
|
t.Fatalf("response[0].id = %v, want %s", got, firstTenant.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -21,6 +22,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"shared-sqs/app/persistence"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +61,17 @@ func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t, ok := store.GetByAccessKey(accessKeyID)
|
t, ok := store.GetByAccessKey(accessKeyID)
|
||||||
|
if !ok {
|
||||||
|
// Read-through из Redis: тенант мог быть создан control-plane / другим
|
||||||
|
// инстансом и ещё не попасть в локальную память.
|
||||||
|
if raw, found := persistence.LoadTenantByAccessKey(accessKeyID); found {
|
||||||
|
var loaded tenant.Tenant
|
||||||
|
if err := json.Unmarshal(raw, &loaded); err == nil {
|
||||||
|
store.LoadTenant(&loaded)
|
||||||
|
t, ok = store.GetByAccessKey(accessKeyID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if !ok || !t.Active {
|
if !ok || !t.Active {
|
||||||
writeSQSAuthError(w, "InvalidClientTokenId", "The security token included in the request is invalid")
|
writeSQSAuthError(w, "InvalidClientTokenId", "The security token included in the request is invalid")
|
||||||
return
|
return
|
||||||
@@ -150,9 +163,14 @@ func verifyHeaderSigV4(r *http.Request, t *tenant.Tenant, authHeader string) err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
actualHash := sha256Hex(body)
|
||||||
payloadHash := r.Header.Get("X-Amz-Content-Sha256")
|
payloadHash := r.Header.Get("X-Amz-Content-Sha256")
|
||||||
if payloadHash == "" {
|
if payloadHash == "" {
|
||||||
payloadHash = sha256Hex(body)
|
payloadHash = actualHash
|
||||||
|
} else if !strings.EqualFold(payloadHash, actualHash) && !strings.EqualFold(payloadHash, "UNSIGNED-PAYLOAD") {
|
||||||
|
// Заголовок заявлен, но не совпадает с фактическим хэшем тела: клиент
|
||||||
|
// не должен иметь возможность подменить тело под подписанным заголовком.
|
||||||
|
return errors.New("X-Amz-Content-Sha256 does not match body hash")
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalRequest, err := buildCanonicalRequest(r, signedHeaders, payloadHash, true)
|
canonicalRequest, err := buildCanonicalRequest(r, signedHeaders, payloadHash, true)
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// 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
|
||||||
|
graceDeadline time.Time // потолок grace от последней УСПЕШНОЙ проверки (не продлевается)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, но
|
||||||
|
// НЕ дальше graceDeadline. Раньше validUntil сдвигали вперёд на каждый
|
||||||
|
// сетевой сбой — отозванный токен жил бы вечно при повторных ошибках.
|
||||||
|
if ok && now.Before(entry.graceDeadline) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenCacheMu.Lock()
|
||||||
|
tokenCache[key] = tokenCacheEntry{
|
||||||
|
validUntil: now.Add(cacheTTL),
|
||||||
|
graceDeadline: now.Add(cacheGraceTTL),
|
||||||
|
}
|
||||||
|
tokenCacheMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WarmTokenCache — записывает токен в кэш как валидный без вызова шлюза.
|
||||||
|
// Используется после успешной свежей проверки при логине (jwtAuth).
|
||||||
|
func WarmTokenCache(token string) {
|
||||||
|
now := time.Now()
|
||||||
|
tokenCacheMu.Lock()
|
||||||
|
tokenCache[tokenCacheKey(token)] = tokenCacheEntry{
|
||||||
|
validUntil: now.Add(cacheTTL),
|
||||||
|
graceDeadline: now.Add(cacheGraceTTL),
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
+63
-26
@@ -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"
|
||||||
@@ -59,41 +59,78 @@ func ParseJWTClaims(token string) (*JWTClaims, error) {
|
|||||||
if claims.Sub == "" {
|
if claims.Sub == "" {
|
||||||
return nil, fmt.Errorf("JWT missing 'sub' claim")
|
return nil, fmt.Errorf("JWT missing 'sub' claim")
|
||||||
}
|
}
|
||||||
if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
|
if claims.Exp == 0 {
|
||||||
|
return nil, fmt.Errorf("JWT missing 'exp' claim")
|
||||||
|
}
|
||||||
|
if claims.Exp < time.Now().Unix() {
|
||||||
return nil, fmt.Errorf("JWT token expired")
|
return nil, fmt.Errorf("JWT token expired")
|
||||||
}
|
}
|
||||||
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])
|
// Безопасность: только prod-шлюз. Тестовый/dev-шлюзы принимают токены своих
|
||||||
|
// стендов, и при их наличии токен тестового стенда считался бы валидным на проде.
|
||||||
|
// Специфичный endpoint можно переопределить через NUBES_ENDPOINT.
|
||||||
|
var nubesAPIEndpoints = []string{
|
||||||
|
"https://lk-api-gateway.ngcloud.ru/api/v1/svc",
|
||||||
}
|
}
|
||||||
|
|
||||||
// PingNubesAPI — проверяет валидность токена запросом к nubes API.
|
// nubesHTTPClient — общий клиент для валидации токенов: переиспользует
|
||||||
// endpoint — базовый URL (например "https://deck-api-test.ngcloud.ru/api/v1").
|
// соединения (keep-alive) вместо создания нового клиента на каждый запрос.
|
||||||
// Логика: 401/403 → токен отклонён. Ошибка соединения → API недоступен.
|
var nubesHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
// Любой другой HTTP статус → токен принят.
|
|
||||||
|
// PingNubesAPI — проверяет валидность токена перебором стендов Nubes API.
|
||||||
|
// endpoint (если задан через NUBES_ENDPOINT) пробуется первым.
|
||||||
|
// Логика: токен принимается ТОЛЬКО при явном HTTP 2xx от стенда.
|
||||||
|
// 401/403 → токен отклонён ЭТИМ стендом (ErrTokenRejected), пробуем следующий.
|
||||||
|
// Любой другой статус (4xx/5xx) → стенд НЕ подтвердил токен, пробуем следующий.
|
||||||
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 {
|
resp, err := nubesHTTPClient.Do(req)
|
||||||
return fmt.Errorf("nubes API rejected token (HTTP %d)", resp.StatusCode)
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("nubes API unreachable at %s: %w", ep, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
// Принимаем токен ТОЛЬКО при явном 2xx: никакой другой статус (5xx,
|
||||||
|
// 429, редирект и т.п.) не должен считаться подтверждением валидности.
|
||||||
|
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
lastErr = fmt.Errorf("%w (HTTP %d)", ErrTokenRejected, resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastErr = fmt.Errorf("nubes API %s returned unexpected status HTTP %d", ep, resp.StatusCode)
|
||||||
}
|
}
|
||||||
return nil
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("nubes API rejected token")
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// app/billing/billing.go
|
||||||
|
// Модуль учёта использования SQS-операций для биллинга.
|
||||||
|
// Записывает каждую успешную SQS-операцию в PostgreSQL: tenant_id, operation, msg_count, msg_bytes.
|
||||||
|
// Если PostgreSQL не сконфигурирован — billing отключён, SQS работает как раньше.
|
||||||
|
// Created: 2026-04-12
|
||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
// PostgreSQL драйвер — регистрируется в database/sql через init()
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// db — подключение к PostgreSQL для записи usage-данных.
|
||||||
|
// nil если billing отключён. Доступ — только через getDB/setDB (мьютекс):
|
||||||
|
// usageWorker читает db параллельно с Close/Init — иначе data race и nil-паника.
|
||||||
|
var (
|
||||||
|
db *sql.DB
|
||||||
|
dbMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// getDB — потокобезопасное чтение db.
|
||||||
|
func getDB() *sql.DB {
|
||||||
|
dbMu.Lock()
|
||||||
|
defer dbMu.Unlock()
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDB — потокобезопасная установка db.
|
||||||
|
func setDB(d *sql.DB) {
|
||||||
|
dbMu.Lock()
|
||||||
|
db = d
|
||||||
|
dbMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init — подключается к PostgreSQL и создаёт таблицу sqs_usage_records если не существует.
|
||||||
|
// Env переменные: BILLING_PG_HOST, BILLING_PG_PORT, BILLING_PG_DATABASE, BILLING_PG_USER,
|
||||||
|
// BILLING_PG_PASSWORD, BILLING_PG_SSLMODE.
|
||||||
|
// Если BILLING_PG_HOST не задан — billing отключён, сервис работает без него.
|
||||||
|
func Init() {
|
||||||
|
host := os.Getenv("BILLING_PG_HOST")
|
||||||
|
if host == "" {
|
||||||
|
log.Info("billing: BILLING_PG_HOST not set, usage tracking disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
port := os.Getenv("BILLING_PG_PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "5432"
|
||||||
|
}
|
||||||
|
dbname := os.Getenv("BILLING_PG_DATABASE")
|
||||||
|
user := os.Getenv("BILLING_PG_USER")
|
||||||
|
password := os.Getenv("BILLING_PG_PASSWORD")
|
||||||
|
sslmode := os.Getenv("BILLING_PG_SSLMODE")
|
||||||
|
if sslmode == "" {
|
||||||
|
sslmode = "require"
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
|
||||||
|
host, port, dbname, user, password, sslmode)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
d, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("billing: failed to open PostgreSQL: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем реальное подключение (Open не подключается)
|
||||||
|
if err = d.Ping(); err != nil {
|
||||||
|
log.Errorf("billing: failed to connect to PostgreSQL: %v", err)
|
||||||
|
d.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем пул — billing не должен отжирать коннекты у основной БД
|
||||||
|
d.SetMaxOpenConns(5)
|
||||||
|
d.SetMaxIdleConns(2)
|
||||||
|
|
||||||
|
setDB(d)
|
||||||
|
if err = autoMigrate(); err != nil {
|
||||||
|
log.Errorf("billing: failed to create table: %v", err)
|
||||||
|
setDB(nil)
|
||||||
|
d.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("billing: connected to PostgreSQL %s:%s/%s, usage tracking enabled", host, port, dbname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoMigrate — создаёт таблицу и индекс если не существуют.
|
||||||
|
// Идемпотентно — безопасно вызывать при каждом старте.
|
||||||
|
func autoMigrate() error {
|
||||||
|
_, err := getDB().Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sqs_usage_records (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
operation TEXT NOT NULL,
|
||||||
|
queue_name TEXT NOT NULL DEFAULT '',
|
||||||
|
msg_count INTEGER DEFAULT 1,
|
||||||
|
msg_bytes BIGINT DEFAULT 0,
|
||||||
|
recorded_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sqs_usage_tenant_time
|
||||||
|
ON sqs_usage_records(tenant_id, recorded_at);
|
||||||
|
-- Миграция: добавляем queue_name если таблица уже существует без него
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE sqs_usage_records ADD COLUMN queue_name TEXT NOT NULL DEFAULT '';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageRecord — одна запись биллинга, накапливается в буфере воркера.
|
||||||
|
type usageRecord struct {
|
||||||
|
tenantID string
|
||||||
|
operation string
|
||||||
|
queueName string
|
||||||
|
msgCount int
|
||||||
|
msgBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageChan — канал-буфер для асинхронных записей биллинга.
|
||||||
|
// Вместо горутины на каждую операцию (навал горутин под нагрузкой) используется
|
||||||
|
// один воркер, читающий из буферизованного канала.
|
||||||
|
var usageChan = make(chan usageRecord, 1024)
|
||||||
|
|
||||||
|
// done — сигнал остановки воркера при graceful shutdown.
|
||||||
|
var done = make(chan struct{})
|
||||||
|
|
||||||
|
// closeOnce гарантирует однократный close(done).
|
||||||
|
var closeOnce sync.Once
|
||||||
|
|
||||||
|
// workerWg — ожидание завершения воркера перед закрытием db (иначе Close обнулил
|
||||||
|
// бы db, пока воркер пишет — nil-паника + потеря остатка буфера usageChan).
|
||||||
|
var workerWg sync.WaitGroup
|
||||||
|
|
||||||
|
// init запускает воркер-горутину биллинга. Воркер стартует всегда; если db == nil
|
||||||
|
// (billing отключён) — он просто дропает записи.
|
||||||
|
func init() {
|
||||||
|
workerWg.Add(1)
|
||||||
|
go usageWorker()
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageWorker — единственная горутина, пишущая usage-записи в PostgreSQL.
|
||||||
|
func usageWorker() {
|
||||||
|
defer workerWg.Done()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case rec := <-usageChan:
|
||||||
|
processUsage(rec)
|
||||||
|
case <-done:
|
||||||
|
// Дренируем остаток буфера перед выходом (не теряем usage).
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case rec := <-usageChan:
|
||||||
|
processUsage(rec)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processUsage — одна запись usage в PostgreSQL (db == nil → дроп).
|
||||||
|
func processUsage(rec usageRecord) {
|
||||||
|
d := getDB()
|
||||||
|
if d == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO sqs_usage_records (tenant_id, operation, queue_name, msg_count, msg_bytes) VALUES ($1, $2, $3, $4, $5)`,
|
||||||
|
rec.tenantID, rec.operation, rec.queueName, rec.msgCount, rec.msgBytes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("billing: failed to record usage [%s/%s]: %v", rec.tenantID, rec.operation, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordUsage — ставит запись в очередь биллинга (асинхронно, без блокировки SQS).
|
||||||
|
// Если billing отключён — no-op. Если буфер переполнен — запись дропается (биллинг
|
||||||
|
// жертвуем ради стабильности основного пути), ошибка логируется.
|
||||||
|
func RecordUsage(tenantID, operation, queueName string, msgCount int, msgBytes int64) {
|
||||||
|
if getDB() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case usageChan <- usageRecord{tenantID, operation, queueName, msgCount, msgBytes}:
|
||||||
|
case <-done:
|
||||||
|
// Воркер остановлен (shutdown) — запись не примется.
|
||||||
|
default:
|
||||||
|
log.Warnf("billing: буфер полон, запись [%s/%s] дропнута", tenantID, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled — возвращает true если billing подключён к PostgreSQL
|
||||||
|
func Enabled() bool {
|
||||||
|
return getDB() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close — останавливает воркер, дожидается его завершения и закрывает db.
|
||||||
|
// Вызывается при graceful shutdown. Порядок обязателен: сначала воркер,
|
||||||
|
// потом db.Close — иначе гонка db=nil с Exec воркера (nil-паника).
|
||||||
|
func Close() {
|
||||||
|
closeOnce.Do(func() { close(done) })
|
||||||
|
workerWg.Wait()
|
||||||
|
dbMu.Lock()
|
||||||
|
if db != nil {
|
||||||
|
db.Close()
|
||||||
|
db = nil
|
||||||
|
}
|
||||||
|
dbMu.Unlock()
|
||||||
|
log.Info("billing: PostgreSQL connection closed")
|
||||||
|
}
|
||||||
+121
-20
@@ -7,14 +7,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"shared-sqs/app/billing"
|
||||||
"shared-sqs/app/conf"
|
"shared-sqs/app/conf"
|
||||||
"shared-sqs/app/gosqs"
|
"shared-sqs/app/gosqs"
|
||||||
|
"shared-sqs/app/leader"
|
||||||
|
"shared-sqs/app/metrics"
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
"shared-sqs/app/persistence"
|
"shared-sqs/app/persistence"
|
||||||
"shared-sqs/app/router"
|
"shared-sqs/app/router"
|
||||||
@@ -27,14 +31,24 @@ func main() {
|
|||||||
var configFile string
|
var configFile string
|
||||||
var adminToken string
|
var adminToken string
|
||||||
var port string
|
var port string
|
||||||
|
var controlPort string
|
||||||
var debug bool
|
var debug bool
|
||||||
var loglevel string
|
var loglevel string
|
||||||
|
var leaderElection bool
|
||||||
|
var leaderTTL time.Duration
|
||||||
|
var leaderInterval time.Duration
|
||||||
|
|
||||||
flag.StringVar(&configFile, "config", "", "config file location")
|
flag.StringVar(&configFile, "config", "", "config file location")
|
||||||
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
|
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
|
||||||
flag.StringVar(&port, "port", "4100", "listen port")
|
flag.StringVar(&port, "port", "4100", "data-plane listen port")
|
||||||
|
flag.StringVar(&controlPort, "control-port", "4101", "control-plane listen port (localhost only: admin/metrics/ui)")
|
||||||
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
||||||
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
|
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
|
||||||
|
// Leader-election (active/passive HA): выключено по умолчанию — поведение
|
||||||
|
// одиночного инстанса не меняется. Подробно — doc/thinking/state-extraction-and-ha-2026-10-10.md
|
||||||
|
flag.BoolVar(&leaderElection, "leader-election", false, "enable leader election via Redis lock (active/passive HA); off = always ready")
|
||||||
|
flag.DurationVar(&leaderTTL, "leader-ttl", 10*time.Second, "leader lock TTL (should be > 3x leader-interval)")
|
||||||
|
flag.DurationVar(&leaderInterval, "leader-interval", 3*time.Second, "leader lock refresh/acquire interval")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
log.SetFormatter(&log.JSONFormatter{})
|
log.SetFormatter(&log.JSONFormatter{})
|
||||||
@@ -87,12 +101,15 @@ func main() {
|
|||||||
redisPass := os.Getenv("REDIS_PASSWORD")
|
redisPass := os.Getenv("REDIS_PASSWORD")
|
||||||
if redisAddr != "" {
|
if redisAddr != "" {
|
||||||
if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil {
|
if err := persistence.Connect(redisAddr, redisUser, redisPass); err != nil {
|
||||||
log.Warnf("Не удалось подключиться к Redis: %v — работаем в memory-only режиме", err)
|
// Fail-fast: при явно заданном REDIS_ADDR, но недоступном Redis,
|
||||||
|
// продолжать опасно — SaveQueue перезапишет Redis пустотой, read-through
|
||||||
|
// и failover сломаются. Падаем, чтобы systemd перезапустил.
|
||||||
|
log.Fatalf("Не удалось подключиться к Redis (%s): %v", redisAddr, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Восстановление состояния из Redis (тенанты + очереди)
|
// Восстановление состояния из Redis (тенанты + очереди)
|
||||||
if persistence.Client != nil {
|
if persistence.Client.Load() != nil {
|
||||||
// Загружаем тенантов
|
// Загружаем тенантов
|
||||||
tenantsRaw, err := persistence.LoadAllTenantsRaw()
|
tenantsRaw, err := persistence.LoadAllTenantsRaw()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,41 +127,112 @@ func main() {
|
|||||||
// Загружаем очереди
|
// Загружаем очереди
|
||||||
queues, err := persistence.LoadAllQueues()
|
queues, err := persistence.LoadAllQueues()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("Ошибка загрузки очередей из Redis: %v", err)
|
log.Fatalf("Ошибка загрузки очередей из Redis: %v", err)
|
||||||
} else {
|
} else {
|
||||||
models.SyncQueues.Lock()
|
models.SyncQueues.Lock()
|
||||||
for k, q := range queues {
|
for k, q := range queues {
|
||||||
models.SyncQueues.Queues[k] = q
|
models.SyncQueues.AddQueue(k, q)
|
||||||
}
|
}
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Бэкфилл индекса accessKey→tenant для read-through (идемпотентно; нужен для
|
||||||
|
// тенантов, созданных до появления индекса, и для multi-instance сценария).
|
||||||
|
if persistence.Client.Load() != nil {
|
||||||
|
for _, t := range tenantStore.List() {
|
||||||
|
if data, err := json.Marshal(t); err == nil {
|
||||||
|
persistence.SaveTenantRaw(t.ID, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Автосид демо-данных при SHARED_SQS_SEED_DEMO=true
|
// Автосид демо-данных при SHARED_SQS_SEED_DEMO=true
|
||||||
if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" {
|
if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" {
|
||||||
seedDemoData(tenantStore)
|
seedDemoData(tenantStore)
|
||||||
}
|
}
|
||||||
// Роутер с tenant auth и admin API
|
|
||||||
r := router.New(tenantStore, adminToken)
|
// Billing: подключение к PostgreSQL для учёта использования.
|
||||||
|
// Если BILLING_PG_HOST не задан — billing отключён, SQS работает без него.
|
||||||
|
billing.Init()
|
||||||
|
// Разбиение data-plane/control-plane (Шаг 2):
|
||||||
|
// data-plane — публичный SQS API; control-plane — админка/метрики/UI (только localhost).
|
||||||
|
dataRouter := router.NewDataPlane(tenantStore)
|
||||||
|
controlRouter := router.NewControlPlane(tenantStore, adminToken)
|
||||||
|
|
||||||
|
// Leader-election (active/passive, Шаг 3 — вариант C).
|
||||||
|
// Включается флагом --leader-election или SHARED_SQS_LEADER_ELECTION=true.
|
||||||
|
// Только лидер отдаёт /ready → 200; пассивный — 503 → LB/failover держит
|
||||||
|
// трафик на активном. Без флага ReadyFunc = nil (всегда готов) — одиночный
|
||||||
|
// инстанс работает ровно как раньше.
|
||||||
|
//
|
||||||
|
// Ограничение (осознанное, см. док): in-memory состояние НЕ шарится. Пока
|
||||||
|
// второй инстанс запущен «горячим», очереди живут в Redis, но изменения
|
||||||
|
// второго (пассивного) инстанса не увидит активный до рестарта. Поэтому
|
||||||
|
// на фазе C пассивный НЕ должен принимать SQS-трафик: nginx/LB обязан
|
||||||
|
// проверять /ready и не отправлять API-запросы на 503.
|
||||||
|
if !leaderElection && os.Getenv("SHARED_SQS_LEADER_ELECTION") == "true" {
|
||||||
|
leaderElection = true
|
||||||
|
}
|
||||||
|
var elector *leader.Elector
|
||||||
|
if leaderElection {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = "unknown"
|
||||||
|
}
|
||||||
|
instanceID := fmt.Sprintf("%s-%d", hostname, os.Getpid())
|
||||||
|
elector = leader.New("ssq:leader", instanceID, leaderTTL, leaderInterval)
|
||||||
|
elector.Start()
|
||||||
|
router.ReadyFunc = elector.IsLeader
|
||||||
|
log.Infof("leader-election включён (id=%s, ttl=%s, interval=%s)", instanceID, leaderTTL, leaderInterval)
|
||||||
|
} else {
|
||||||
|
log.Info("leader-election выключен — инстанс всегда ready (одиночный режим)")
|
||||||
|
}
|
||||||
|
|
||||||
// PeriodicTasks — visibility timeout, DLQ, deduplication
|
// PeriodicTasks — visibility timeout, DLQ, deduplication
|
||||||
quit := make(chan bool)
|
quit := make(chan bool)
|
||||||
go gosqs.PeriodicTasks(1*time.Second, quit)
|
go gosqs.PeriodicTasks(1*time.Second, quit)
|
||||||
|
|
||||||
// HTTP сервер с таймаутами
|
// Metrics: gauge updater — пересчёт очередей/сообщений per tenant каждые 15 секунд
|
||||||
srv := &http.Server{
|
metrics.StartGaugeUpdater(15*time.Second, quit)
|
||||||
Addr: "0.0.0.0:" + port,
|
|
||||||
Handler: r,
|
// HTTP сервер с таймаутами (рекомендации ревью Соннета, план v0.1.35):
|
||||||
ReadTimeout: 30 * time.Second,
|
// - ReadHeaderTimeout 10с — лимит ТОЛЬКО на чтение заголовков (slowloris).
|
||||||
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s)
|
// ReadTimeout НЕ ставим: он обрывал бы запросы с медленной/большой
|
||||||
IdleTimeout: 60 * time.Second,
|
// загрузкой тела (в т.ч. большие SendMessage) и ломал бы long-poll.
|
||||||
|
// - WriteTimeout 35с — ответ; чуть больше max WaitTimeSeconds (20с long-poll).
|
||||||
|
// - IdleTimeout 90с — keep-alive; больше интервала ретраев клиентских SDK.
|
||||||
|
// - MaxHeaderBytes 8192 — защита от гигантских заголовков
|
||||||
|
// (платформа режет >16KB, наш лимит строже).
|
||||||
|
dataSrv := &http.Server{
|
||||||
|
Addr: "0.0.0.0:" + port,
|
||||||
|
Handler: dataRouter,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 35 * time.Second,
|
||||||
|
IdleTimeout: 90 * time.Second,
|
||||||
|
MaxHeaderBytes: 8192,
|
||||||
|
}
|
||||||
|
// control-plane слушает ТОЛЬКО localhost — админка/метрики/UI не должны быть публичными.
|
||||||
|
controlSrv := &http.Server{
|
||||||
|
Addr: "127.0.0.1:" + controlPort,
|
||||||
|
Handler: controlRouter,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 35 * time.Second,
|
||||||
|
IdleTimeout: 90 * time.Second,
|
||||||
|
MaxHeaderBytes: 8192,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запуск в горутине для graceful shutdown
|
// Запуск в горутинах для graceful shutdown
|
||||||
serverErr := make(chan error, 1)
|
serverErr := make(chan error, 2)
|
||||||
go func() {
|
go func() {
|
||||||
log.Infof("shared-sqs listening on 0.0.0.0:%s", port)
|
log.Infof("shared-sqs data-plane listening on 0.0.0.0:%s", port)
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := dataSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
serverErr <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
log.Infof("shared-sqs control-plane listening on 127.0.0.1:%s", controlPort)
|
||||||
|
if err := controlSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
serverErr <- err
|
serverErr <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -163,11 +251,24 @@ func main() {
|
|||||||
// Остановить PeriodicTasks
|
// Остановить PeriodicTasks
|
||||||
close(quit)
|
close(quit)
|
||||||
|
|
||||||
|
// Отпустить лидерский лок (если были лидером) до закрытия слушателей,
|
||||||
|
// чтобы пассивный взял его быстрее, чем через TTL.
|
||||||
|
if elector != nil {
|
||||||
|
elector.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
// Дать 10 секунд на завершение текущих HTTP запросов
|
// Дать 10 секунд на завершение текущих HTTP запросов
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
if err := dataSrv.Shutdown(ctx); err != nil {
|
||||||
log.Errorf("Server shutdown error: %v", err)
|
log.Errorf("Data-plane shutdown error: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := controlSrv.Shutdown(ctx); err != nil {
|
||||||
|
log.Errorf("Control-plane shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрыть billing ПОСЛЕ Shutdown — иначе in-flight запросы писали бы usage
|
||||||
|
// в уже закрытую БД (ревью 2026-10-11).
|
||||||
|
billing.Close()
|
||||||
log.Info("shared-sqs stopped")
|
log.Info("shared-sqs stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-23
@@ -1,12 +1,11 @@
|
|||||||
// app/cmd/seed.go
|
// app/cmd/seed.go
|
||||||
// Created: 2026-04-09
|
|
||||||
// Updated: 2026-04-09 — фиксированные credentials для demo-tenant (BYOC)
|
|
||||||
// Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true.
|
// Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true.
|
||||||
// Создаёт тенанта demo-service с 5 очередями и демо-сообщениями.
|
// Создаёт тенанта demo-service со случайными кредами (генерируются через
|
||||||
|
// tenant.Create → crypto/rand) с 5 очередями и демо-сообщениями.
|
||||||
//
|
//
|
||||||
// DEMO CREDENTIALS — только для тестового стенда.
|
|
||||||
// Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к
|
// Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к
|
||||||
// admin API и к очередям других тенантов. Credentials открыты намеренно — стенд публичный.
|
// admin API и к очередям других тенантов. Креды генерируются случайно и печатаются
|
||||||
|
// в лог при старте (только при явном включении SHARED_SQS_SEED_DEMO).
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,30 +16,36 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"shared-sqs/app/gosqs"
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
|
"shared-sqs/app/persistence"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Фиксированные credentials демо-тенанта.
|
|
||||||
// Открыты намеренно — тестовый стенд.
|
|
||||||
// Тенант ограничен 10 очередями и не имеет прав admin API.
|
|
||||||
const (
|
const (
|
||||||
demoTenantID = "t-demo-shared-sqs-ngcloud"
|
|
||||||
demoAccessKey = "SSAK-demo-shared-sqs"
|
|
||||||
demoSecretKey = "demo-secret-key-shared-sqs-ngcloud-2026"
|
|
||||||
demoTenantName = "demo-service"
|
demoTenantName = "demo-service"
|
||||||
demoMaxQueues = 10
|
demoMaxQueues = 10
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedDemoData создаёт тенанта demo-service с очередями и сообщениями.
|
// seedDemoData создаёт тенанта demo-service с очередями и сообщениями.
|
||||||
// Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера.
|
// Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера.
|
||||||
|
// Креды генерируются случайно (tenant.Create) — никаких захардкоженных
|
||||||
|
// AccessKey/SecretKey в исходнике.
|
||||||
func seedDemoData(store *tenant.TenantStore) {
|
func seedDemoData(store *tenant.TenantStore) {
|
||||||
t, err := store.CreateFixed(demoTenantName, demoMaxQueues, demoTenantID, demoAccessKey, demoSecretKey)
|
// Идемпотентность: если демо-тенант уже есть (восстановлен из Redis) — не плодим копии.
|
||||||
|
for _, existing := range store.List() {
|
||||||
|
if existing.Name == demoTenantName {
|
||||||
|
log.Infof("seed: тенант %s уже существует (%s) — сид пропущен", demoTenantName, existing.ID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := store.Create(demoTenantName, demoMaxQueues)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("seed: не удалось создать demo-tenant: %v", err)
|
log.Warnf("seed: не удалось создать demo-tenant: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Infof("seed: создан тенант %s (AccessKey=%s)", t.ID, t.AccessKey)
|
log.Infof("seed: создан тенант %s (AccessKey=%s, SecretKey=%s)", t.ID, t.AccessKey, t.SecretKey)
|
||||||
|
|
||||||
// Демо-очереди с набором сообщений
|
// Демо-очереди с набором сообщений
|
||||||
queues := []struct {
|
queues := []struct {
|
||||||
@@ -87,14 +92,9 @@ func seedDemoData(store *tenant.TenantStore) {
|
|||||||
|
|
||||||
// URL и ARN формируем по тому же шаблону что gosqs/tenant_helpers.go:tenantQueueURL/tenantQueueARN
|
// URL и ARN формируем по тому же шаблону что gosqs/tenant_helpers.go:tenantQueueURL/tenantQueueARN
|
||||||
// Иначе AWS CLI получает пустой QueueUrl в ListQueues и не может работать с очередью.
|
// Иначе AWS CLI получает пустой QueueUrl в ListQueues и не может работать с очередью.
|
||||||
env := models.CurrentEnvironment
|
// URL — единый источник (gosqs.TenantQueueURL), учитывает ExternalURLBase.
|
||||||
var queueURL string
|
queueURL := gosqs.TenantQueueURL(t, q.name)
|
||||||
if env.Region != "" {
|
queueARN := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + q.name
|
||||||
queueURL = "http://" + env.Region + "." + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name
|
|
||||||
} else {
|
|
||||||
queueURL = "http://" + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name
|
|
||||||
}
|
|
||||||
queueARN := "arn:aws:sqs:" + env.Region + ":" + t.ID + ":" + q.name
|
|
||||||
|
|
||||||
msgs := make([]models.SqsMessage, 0, len(q.msgs))
|
msgs := make([]models.SqsMessage, 0, len(q.msgs))
|
||||||
for _, body := range q.msgs {
|
for _, body := range q.msgs {
|
||||||
@@ -108,8 +108,7 @@ func seedDemoData(store *tenant.TenantStore) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
queue := &models.Queue{
|
||||||
models.SyncQueues.Queues[key] = &models.Queue{
|
|
||||||
Name: q.name,
|
Name: q.name,
|
||||||
URL: queueURL,
|
URL: queueURL,
|
||||||
Arn: queueARN,
|
Arn: queueARN,
|
||||||
@@ -119,8 +118,14 @@ func seedDemoData(store *tenant.TenantStore) {
|
|||||||
Messages: msgs,
|
Messages: msgs,
|
||||||
Duplicates: make(map[string]time.Time),
|
Duplicates: make(map[string]time.Time),
|
||||||
}
|
}
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
models.SyncQueues.AddQueue(key, queue)
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
|
|
||||||
|
// Персистим очередь и сообщения в Redis (иначе после рестарта демо-очереди исчезают).
|
||||||
|
persistence.SaveQueue(key, queue)
|
||||||
|
persistence.SaveMessages(key, msgs)
|
||||||
|
|
||||||
log.Infof("seed: очередь %s (%d сообщений)", q.name, len(q.msgs))
|
log.Infof("seed: очередь %s (%d сообщений)", q.name, len(q.msgs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -90,6 +90,9 @@ func LoadYamlConfig(filename string, env string) []string {
|
|||||||
queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" +
|
queueUrl = "http://" + models.CurrentEnvironment.Region + "." + models.CurrentEnvironment.Host + ":" +
|
||||||
models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name
|
models.CurrentEnvironment.Port + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name
|
||||||
}
|
}
|
||||||
|
if base := strings.TrimRight(models.CurrentEnvironment.ExternalURLBase, "/"); base != "" {
|
||||||
|
queueUrl = base + "/" + models.CurrentEnvironment.AccountID + "/" + queue.Name
|
||||||
|
}
|
||||||
queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name
|
queueArn := "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + models.CurrentEnvironment.AccountID + ":" + queue.Name
|
||||||
|
|
||||||
if queue.ReceiveMessageWaitTimeSeconds == 0 {
|
if queue.ReceiveMessageWaitTimeSeconds == 0 {
|
||||||
@@ -105,7 +108,7 @@ func LoadYamlConfig(filename string, env string) []string {
|
|||||||
queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod
|
queue.MessageRetentionPeriod = models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod
|
||||||
}
|
}
|
||||||
|
|
||||||
models.SyncQueues.Queues[queue.Name] = &models.Queue{
|
models.SyncQueues.AddQueue(queue.Name, &models.Queue{
|
||||||
Name: queue.Name,
|
Name: queue.Name,
|
||||||
VisibilityTimeout: queue.VisibilityTimeout,
|
VisibilityTimeout: queue.VisibilityTimeout,
|
||||||
Arn: queueArn,
|
Arn: queueArn,
|
||||||
@@ -116,7 +119,7 @@ func LoadYamlConfig(filename string, env string) []string {
|
|||||||
IsFIFO: utils.HasFIFOQueueName(queue.Name),
|
IsFIFO: utils.HasFIFOQueueName(queue.Name),
|
||||||
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
||||||
Duplicates: make(map[string]time.Time),
|
Duplicates: make(map[string]time.Time),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка
|
// Второй проход — устанавливаем RedrivePolicy, чтобы DLQ были доступны независимо от порядка
|
||||||
@@ -126,6 +129,9 @@ func LoadYamlConfig(filename string, env string) []string {
|
|||||||
err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy)
|
err := setQueueRedrivePolicy(models.SyncQueues.Queues, q, queue.RedrivePolicy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("err: %s", err)
|
log.Errorf("err: %s", err)
|
||||||
|
// Разблокировать глобальный мьютекс перед выходом — иначе все
|
||||||
|
// операции с очередями зависнут навсегда (deadlock).
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
return ports
|
return ports
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +146,10 @@ func LoadYamlConfig(filename string, env string) []string {
|
|||||||
// AccountID и Host. Вызывается через defer в LoadYamlConfig, чтобы дефолты
|
// AccountID и Host. Вызывается через defer в LoadYamlConfig, чтобы дефолты
|
||||||
// устанавливались при любом раннем return (например, если конфиг не найден).
|
// устанавливались при любом раннем return (например, если конфиг не найден).
|
||||||
func applyEnvironmentDefaults() {
|
func applyEnvironmentDefaults() {
|
||||||
|
// Публичный адрес очередей — override через env (работает и когда goaws.yaml не найден).
|
||||||
|
if v := os.Getenv("SHARED_SQS_EXTERNAL_URL_BASE"); v != "" {
|
||||||
|
models.CurrentEnvironment.ExternalURLBase = v
|
||||||
|
}
|
||||||
if models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout <= 0 {
|
if models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout <= 0 {
|
||||||
models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30
|
models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout = 30
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{})
|
|
||||||
}
|
|
||||||
+1
-15
@@ -1,13 +1,10 @@
|
|||||||
Local: # Environment name that can be passed on the command line
|
Local: # Environment name that can be passed on the command line
|
||||||
# (i.e.: ./goaws [Local | Dev] -- defaults to 'Local')
|
# (i.e.: ./goaws [Local | Dev] -- defaults to 'Local')
|
||||||
Host: goaws.com # hostname of the goaws system (for docker-compose this is the tag name of the container)
|
Host: goaws.com # hostname of the goaws system (for docker-compose this is the tag name of the container)
|
||||||
# you can now use either 1 port for both sns and sqs or alternatively you can comment out Port and use SqsPort + SnsPort for compatibilyt with
|
|
||||||
# yopa and (fage-sns + face-sqs). If both ways are in the config file on the one "Port" will be used by GoAws
|
|
||||||
Port: 4100 # port to listen on.
|
Port: 4100 # port to listen on.
|
||||||
# SqsPort: 9324 # alterante Sqs Port
|
|
||||||
# SnsPort: 9292 # alternate Sns Port
|
|
||||||
Region: us-east-1
|
Region: us-east-1
|
||||||
AccountId: "100010001000"
|
AccountId: "100010001000"
|
||||||
|
ExternalURLBase: "" # публичный адрес очередей (напр. https://sqs.sinefo.ru); пусто → http://{region}.{host}:{port}
|
||||||
LogToFile: false # Log messages (true/false)
|
LogToFile: false # Log messages (true/false)
|
||||||
LogFile: .st/goaws_messages.log # Log filename (for message logging
|
LogFile: .st/goaws_messages.log # Log filename (for message logging
|
||||||
EnableDuplicates: false # Enable or not deduplication based on messageDeduplicationId
|
EnableDuplicates: false # Enable or not deduplication based on messageDeduplicationId
|
||||||
@@ -17,7 +14,6 @@ Local: # Environment name that can be passed on the
|
|||||||
MaximumMessageSize: 262144 # maximum message size (bytes)
|
MaximumMessageSize: 262144 # maximum message size (bytes)
|
||||||
# MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented
|
# MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented
|
||||||
Queues: [] # No default queues — created via Admin API / AWS CLI by tenants
|
Queues: [] # No default queues — created via Admin API / AWS CLI by tenants
|
||||||
Topics: [] # No default topics
|
|
||||||
RandomLatency: # Parameters for introducing random latency into message queuing
|
RandomLatency: # Parameters for introducing random latency into message queuing
|
||||||
Min: 0 # Desired latency in milliseconds, if min and max are zero, no latency will be applied.
|
Min: 0 # Desired latency in milliseconds, if min and max are zero, no latency will be applied.
|
||||||
Max: 0 # Desired latency in milliseconds
|
Max: 0 # Desired latency in milliseconds
|
||||||
@@ -25,20 +21,10 @@ Local: # Environment name that can be passed on the
|
|||||||
Dev: # Another environment
|
Dev: # Another environment
|
||||||
Host: localhost
|
Host: localhost
|
||||||
Port: 4100
|
Port: 4100
|
||||||
# SqsPort: 9324
|
|
||||||
# SnsPort: 9292
|
|
||||||
AccountId: "794373491471"
|
AccountId: "794373491471"
|
||||||
LogToFile: false
|
LogToFile: false
|
||||||
LogFile: ./goaws_messages.log
|
LogFile: ./goaws_messages.log
|
||||||
Queues:
|
Queues:
|
||||||
- Name: dev-queue1
|
- Name: dev-queue1
|
||||||
- Name: dev-queue2
|
- Name: dev-queue2
|
||||||
Topics:
|
|
||||||
- Name: dev-topic1
|
|
||||||
Subscriptions:
|
|
||||||
- QueueName: dev-queue3
|
|
||||||
Raw: false
|
|
||||||
- QueueName: dev-queue4
|
|
||||||
Raw: true
|
|
||||||
- Name: dev-topic2
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// app/gosqs/build.go
|
||||||
|
// Общие билдеры очереди и сообщения — чтобы admin/UI-путь создавал объекты
|
||||||
|
// ровно так же, как SQS API (единый путь, без расхождения дефолтов).
|
||||||
|
package gosqs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"shared-sqs/app/models"
|
||||||
|
"shared-sqs/app/tenant"
|
||||||
|
"shared-sqs/app/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildQueueForTenant — создаёт очередь тенанта с теми же URL/ARN/дефолтами и
|
||||||
|
// FIFO-детектом, что и CreateQueueV1. Используется admin-хендлерами, чтобы
|
||||||
|
// поведение UI совпадало с SQS API (URL/ARN заполнены, .fifo → IsFIFO).
|
||||||
|
func BuildQueueForTenant(t *tenant.Tenant, queueName string) *models.Queue {
|
||||||
|
return &models.Queue{
|
||||||
|
Name: queueName,
|
||||||
|
URL: tenantQueueURL(t, queueName),
|
||||||
|
Arn: tenantQueueARN(t, queueName),
|
||||||
|
VisibilityTimeout: 30,
|
||||||
|
MaximumMessageSize: MaxMessageSizeDefault,
|
||||||
|
MessageRetentionPeriod: 345600, // 4 дня
|
||||||
|
IsFIFO: utils.HasFIFOQueueName(queueName),
|
||||||
|
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
||||||
|
Messages: []models.SqsMessage{},
|
||||||
|
Duplicates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSqsMessage — создаёт сообщение с UUID, MD5 тела и временем отправки —
|
||||||
|
// так же, как это делает SendMessageV1 (для admin/UI-пути).
|
||||||
|
func BuildSqsMessage(body string) models.SqsMessage {
|
||||||
|
return models.SqsMessage{
|
||||||
|
MessageBody: body,
|
||||||
|
Uuid: uuid.NewString(),
|
||||||
|
MD5OfMessageBody: utils.GetMD5Hash(body),
|
||||||
|
SentTime: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-04-09
|
// Изменено: 2026-04-11 — добавлена Redis persistence
|
||||||
// ChangeMessageVisibilityV1 — меняет visibility timeout сообщения в очереди тенанта.
|
// ChangeMessageVisibilityV1 — меняет visibility timeout сообщения в очереди тенанта.
|
||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"shared-sqs/app/interfaces"
|
"shared-sqs/app/interfaces"
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
|
"shared-sqs/app/persistence"
|
||||||
"shared-sqs/app/utils"
|
"shared-sqs/app/utils"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -46,36 +47,43 @@ 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 {
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
|
if queue == nil {
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
}
|
}
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
messageFound := false
|
messageFound := false
|
||||||
for i := 0; i < len(models.SyncQueues.Queues[key].Messages); i++ {
|
var changedMsgUuid string
|
||||||
queue := models.SyncQueues.Queues[key]
|
var msgRemoved bool
|
||||||
msgs := queue.Messages
|
var dlqMsg *models.SqsMessage
|
||||||
if msgs[i].ReceiptHandle == receiptHandle {
|
// O(1)-поиск по индексу ReceiptHandle (P1.2) вместо скана всего среза.
|
||||||
timeout := models.SyncQueues.Queues[key].VisibilityTimeout
|
if i, ok := queue.IndexOfReceipt(receiptHandle); ok {
|
||||||
if visibilityTimeout == 0 {
|
if visibilityTimeout == 0 {
|
||||||
msgs[i].ReceiptTime = time.Now().UTC()
|
// Возврат видимости (+ DLQ-перенос при исчерпании MaxReceiveCount) —
|
||||||
msgs[i].ReceiptHandle = ""
|
// единый путь с батчевой версией.
|
||||||
msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(timeout) * time.Second)
|
changedMsgUuid, msgRemoved, dlqMsg = queue.ResetVisibilityAndTryDLQ(i, receiptHandle)
|
||||||
msgs[i].Retry++
|
} else {
|
||||||
if queue.MaxReceiveCount > 0 &&
|
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
|
||||||
queue.DeadLetterQueue != nil &&
|
changedMsgUuid = queue.Messages[i].Uuid
|
||||||
msgs[i].Retry >= queue.MaxReceiveCount {
|
|
||||||
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, msgs[i])
|
|
||||||
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
|
|
||||||
}
|
|
||||||
messageFound = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
messageFound = true
|
||||||
|
}
|
||||||
|
// Персистим изменения в Redis под Lock (сетевые записи асинхронны).
|
||||||
|
if messageFound {
|
||||||
|
if msgRemoved {
|
||||||
|
// Сообщение удалено (перемещено в DLQ) — удаляем из Redis исходной
|
||||||
|
// очереди и сохраняем в DLQ, иначе после рестарта оно пропадёт из DLQ.
|
||||||
|
_ = persistence.DeleteMessagePersist(key, changedMsgUuid)
|
||||||
|
if dlqMsg != nil {
|
||||||
|
persistence.SaveMessage(dlqKeyFor(key, queue.DeadLetterQueue.Name), dlqMsg)
|
||||||
|
}
|
||||||
|
} else if i, ok := queue.IndexOfUUID(changedMsgUuid); ok {
|
||||||
|
// Сообщение обновлено — перезаписываем в Redis
|
||||||
|
persistence.SaveMessage(key, &queue.Messages[i])
|
||||||
|
}
|
||||||
|
persistence.SaveQueue(key, queue)
|
||||||
}
|
}
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
if !messageFound {
|
if !messageFound {
|
||||||
return utils.CreateErrorResponseV1("MessageNotInFlight", true)
|
return utils.CreateErrorResponseV1("MessageNotInFlight", true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Создано: 2026-04-11
|
// Создано: 2026-04-11, Изменено: 2026-04-11 — добавлена Redis persistence
|
||||||
// ChangeMessageVisibilityBatchV1 — пакетная смена таймаута видимости (до 10 сообщений).
|
// ChangeMessageVisibilityBatchV1 — пакетная смена таймаута видимости (до 10 сообщений).
|
||||||
// Паттерн аналогичен DeleteMessageBatchV1: валидация Id, цикл по записям, partial success.
|
// Паттерн аналогичен DeleteMessageBatchV1: валидация Id, цикл по записям, partial success.
|
||||||
package gosqs
|
package gosqs
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"shared-sqs/app/interfaces"
|
"shared-sqs/app/interfaces"
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
|
"shared-sqs/app/persistence"
|
||||||
"shared-sqs/app/utils"
|
"shared-sqs/app/utils"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -41,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)
|
||||||
}
|
}
|
||||||
@@ -62,8 +59,11 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
|
|||||||
ids[entry.Id] = true
|
ids[entry.Id] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
defer models.SyncQueues.Unlock()
|
if queue == nil {
|
||||||
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
|
}
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
|
||||||
successEntries := make([]models.ChangeMessageVisibilityBatchResultEntry, 0)
|
successEntries := make([]models.ChangeMessageVisibilityBatchResultEntry, 0)
|
||||||
failedEntries := make([]models.BatchResultErrorEntry, 0)
|
failedEntries := make([]models.BatchResultErrorEntry, 0)
|
||||||
@@ -80,21 +80,29 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
|
|||||||
}
|
}
|
||||||
|
|
||||||
messageFound := false
|
messageFound := false
|
||||||
queue := models.SyncQueues.Queues[key]
|
// O(1)-поиск по индексу ReceiptHandle (P1.2) вместо скана всего среза.
|
||||||
for i := 0; i < len(queue.Messages); i++ {
|
if i, ok := queue.IndexOfReceipt(entry.ReceiptHandle); ok {
|
||||||
if queue.Messages[i].ReceiptHandle == entry.ReceiptHandle {
|
if entry.VisibilityTimeout == 0 {
|
||||||
if entry.VisibilityTimeout == 0 {
|
// Сброс + DLQ-перенос при исчерпании MaxReceiveCount — единый путь
|
||||||
// Сброс: сообщение снова видимо, счётчик retry++
|
// с одиночной версией (раньше батч лишь инкрементировал Retry и не
|
||||||
queue.Messages[i].ReceiptTime = time.Now().UTC()
|
// переносил в DLQ — счётчик и перенос могли расходиться).
|
||||||
queue.Messages[i].ReceiptHandle = ""
|
uuid, moved, dlqMsg := queue.ResetVisibilityAndTryDLQ(i, entry.ReceiptHandle)
|
||||||
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(queue.VisibilityTimeout) * time.Second)
|
if moved {
|
||||||
queue.Messages[i].Retry++
|
// Сообщение перенесено в DLQ и удалено из очереди — чистим Redis
|
||||||
} else {
|
// исходной и сохраняем в DLQ (иначе пропадёт после рестарта).
|
||||||
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second)
|
_ = persistence.DeleteMessagePersist(key, uuid)
|
||||||
|
if dlqMsg != nil {
|
||||||
|
persistence.SaveMessage(dlqKeyFor(key, queue.DeadLetterQueue.Name), dlqMsg)
|
||||||
|
}
|
||||||
|
} else if j, ok := queue.IndexOfUUID(uuid); ok {
|
||||||
|
persistence.SaveMessage(key, &queue.Messages[j])
|
||||||
}
|
}
|
||||||
messageFound = true
|
} else {
|
||||||
break
|
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second)
|
||||||
|
// Персистим изменённое сообщение отдельно
|
||||||
|
persistence.SaveMessage(key, &queue.Messages[i])
|
||||||
}
|
}
|
||||||
|
messageFound = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if messageFound {
|
if messageFound {
|
||||||
@@ -109,6 +117,11 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Персистим изменения в Redis под Lock
|
||||||
|
if len(successEntries) > 0 {
|
||||||
|
persistence.SaveQueue(key, queue)
|
||||||
|
}
|
||||||
|
|
||||||
respStruct := models.ChangeMessageVisibilityBatchResponse{
|
respStruct := models.ChangeMessageVisibilityBatchResponse{
|
||||||
Xmlns: models.BaseXmlns,
|
Xmlns: models.BaseXmlns,
|
||||||
Result: models.ChangeMessageVisibilityBatchResult{
|
Result: models.ChangeMessageVisibilityBatchResult{
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
+59
-19
@@ -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)
|
||||||
@@ -37,31 +49,59 @@ func CreateQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
queueUrl := tenantQueueURL(t, queueName)
|
queueUrl := tenantQueueURL(t, queueName)
|
||||||
queueArn := tenantQueueARN(t, queueName)
|
queueArn := tenantQueueARN(t, queueName)
|
||||||
|
|
||||||
|
// Готовим очередь и применяем атрибуты ВНЕ глобального лока: setQueueAttributesV1
|
||||||
|
// берёт SyncQueues.RLock (поиск DLQ), а RWMutex не реентрантен — вызов под
|
||||||
|
// уже захваченным SyncQueues.Lock дал бы дедлок.
|
||||||
|
queue := &models.Queue{
|
||||||
|
Name: queueName,
|
||||||
|
URL: queueUrl,
|
||||||
|
Arn: queueArn,
|
||||||
|
IsFIFO: utils.HasFIFOQueueName(queueName),
|
||||||
|
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
||||||
|
Duplicates: make(map[string]time.Time),
|
||||||
|
// Дефолты (как в InitQueues): без них очередь из API получала бы
|
||||||
|
// VisibilityTimeout=0 и т.д. — сообщение сразу видимо, retention 0 = нет
|
||||||
|
// эвикции. Переданные атрибуты перезапишут дефолты в setQueueAttributesV1.
|
||||||
|
VisibilityTimeout: models.CurrentEnvironment.QueueAttributeDefaults.VisibilityTimeout,
|
||||||
|
MaximumMessageSize: models.CurrentEnvironment.QueueAttributeDefaults.MaximumMessageSize,
|
||||||
|
MessageRetentionPeriod: models.CurrentEnvironment.QueueAttributeDefaults.MessageRetentionPeriod,
|
||||||
|
ReceiveMessageWaitTimeSeconds: models.CurrentEnvironment.QueueAttributeDefaults.ReceiveMessageWaitTimeSeconds,
|
||||||
|
// CreatedTimestamp/LastModifiedTimestamp в GetQueueAttributes (AWS их отдаёт).
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
LastModifiedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
// Список имён переданных атрибутов — для 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 {
|
||||||
|
return utils.CreateErrorResponseV1(err.Error(), true)
|
||||||
|
}
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
models.SyncQueues.Lock()
|
||||||
if _, exists := models.SyncQueues.Queues[key]; !exists {
|
existing, exists := models.SyncQueues.Queues[key]
|
||||||
|
if !exists {
|
||||||
// Проверка лимита очередей тенанта
|
// Проверка лимита очередей тенанта
|
||||||
if t.MaxQueues > 0 && countTenantQueues(t.AccessKey) >= t.MaxQueues {
|
if t.MaxQueues > 0 && countTenantQueues(t.AccessKey) >= t.MaxQueues {
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
return utils.CreateErrorResponseV1("LimitExceeded", true)
|
return utils.CreateErrorResponseV1("LimitExceeded", true)
|
||||||
}
|
}
|
||||||
log.Infof("Creating Queue: %s (tenant: %s)", queueName, t.ID)
|
log.Infof("Creating Queue: %s (tenant: %s)", queueName, t.ID)
|
||||||
queue := &models.Queue{
|
models.SyncQueues.AddQueue(key, queue)
|
||||||
Name: queueName,
|
existing = queue
|
||||||
URL: queueUrl,
|
|
||||||
Arn: queueArn,
|
|
||||||
IsFIFO: utils.HasFIFOQueueName(queueName),
|
|
||||||
EnableDuplicates: models.CurrentEnvironment.EnableDuplicates,
|
|
||||||
Duplicates: make(map[string]time.Time),
|
|
||||||
}
|
|
||||||
if err := setQueueAttributesV1(queue, requestBody.Attributes); err != nil {
|
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
return utils.CreateErrorResponseV1(err.Error(), true)
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues[key] = queue
|
|
||||||
}
|
}
|
||||||
// Сохраняем очередь в Redis пока держим Lock — консистентный снапшот
|
|
||||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
|
||||||
models.SyncQueues.Unlock()
|
models.SyncQueues.Unlock()
|
||||||
|
|
||||||
|
// SaveQueue маршалит map'ы очереди (Duplicates/FIFO*/Tags) в JSON. Это нужно
|
||||||
|
// делать под per-queue блокировкой, а НЕ под глобальным SyncQueues.Lock:
|
||||||
|
// мутаторы (send/tag/CMV) пишут в эти map под queue.mu, и идемпотентный
|
||||||
|
// CreateQueue существующей очереди без queue.mu даёт fatal
|
||||||
|
// "concurrent map iteration and map write" (краш всего процесса).
|
||||||
|
existing.LockMu()
|
||||||
|
persistence.SaveQueue(key, existing)
|
||||||
|
existing.UnlockMu()
|
||||||
respStruct := models.CreateQueueResponse{
|
respStruct := models.CreateQueueResponse{
|
||||||
Xmlns: models.BaseXmlns,
|
Xmlns: models.BaseXmlns,
|
||||||
Result: models.CreateQueueResult{QueueUrl: queueUrl},
|
Result: models.CreateQueueResult{QueueUrl: queueUrl},
|
||||||
|
|||||||
@@ -1,254 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/mitchellh/copystructure"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateQueueV1_success(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.CreateQueueRequest)
|
|
||||||
*v = fixtures.CreateQueueRequest
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := CreateQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, fixtures.CreateQueueResponse, response)
|
|
||||||
|
|
||||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
|
||||||
assert.Equal(t, fixtures.FullyPopulatedQueue, actualQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateQueueV1_success_with_redrive_policy(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
|
|
||||||
c, _ := dupe.(models.CreateQueueRequest)
|
|
||||||
c.Attributes.RedrivePolicy = models.RedrivePolicy{
|
|
||||||
MaxReceiveCount: 100,
|
|
||||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", fixtures.DeadLetterQueueName),
|
|
||||||
}
|
|
||||||
|
|
||||||
v := resultingStruct.(*models.CreateQueueRequest)
|
|
||||||
*v = c
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
dlq := &models.Queue{
|
|
||||||
Name: fixtures.DeadLetterQueueName,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues[fixtures.DeadLetterQueueName] = dlq
|
|
||||||
|
|
||||||
expectedQueue := &models.Queue{
|
|
||||||
Name: fixtures.QueueName,
|
|
||||||
URL: fmt.Sprintf("http://%s.%s:%s/%s/%s",
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Region,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Host,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Port,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
|
||||||
fixtures.QueueName,
|
|
||||||
),
|
|
||||||
Arn: fmt.Sprintf("arn:aws:sqs:%s:%s:%s",
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Region,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
|
||||||
fixtures.QueueName,
|
|
||||||
),
|
|
||||||
VisibilityTimeout: 5,
|
|
||||||
ReceiveMessageWaitTimeSeconds: 4,
|
|
||||||
DelaySeconds: 1,
|
|
||||||
MaximumMessageSize: 2,
|
|
||||||
MessageRetentionPeriod: 3,
|
|
||||||
DeadLetterQueue: dlq,
|
|
||||||
MaxReceiveCount: 100,
|
|
||||||
Duplicates: make(map[string]time.Time),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := CreateQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, fixtures.CreateQueueResponse, response)
|
|
||||||
|
|
||||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
|
||||||
assert.Equal(t, expectedQueue, actualQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateQueueV1_success_with_existing_queue(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.CreateQueueRequest)
|
|
||||||
*v = fixtures.CreateQueueRequest
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: fixtures.QueueName,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues[fixtures.QueueName] = q
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := CreateQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, fixtures.CreateQueueResponse, response)
|
|
||||||
|
|
||||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
|
||||||
assert.Equal(t, q, actualQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateQueueV1_success_with_no_request_attributes_falls_back_to_default(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
|
|
||||||
c, _ := dupe.(models.CreateQueueRequest)
|
|
||||||
c.Attributes = models.QueueAttributes{}
|
|
||||||
|
|
||||||
v := resultingStruct.(*models.CreateQueueRequest)
|
|
||||||
*v = c
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedQueue := &models.Queue{
|
|
||||||
Name: fixtures.QueueName,
|
|
||||||
URL: fmt.Sprintf("http://%s.%s:%s/%s/%s",
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Region,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Host,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Port,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
|
||||||
fixtures.QueueName,
|
|
||||||
),
|
|
||||||
Arn: fmt.Sprintf("arn:aws:sqs:%s:%s:%s",
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Region,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
|
||||||
fixtures.QueueName,
|
|
||||||
),
|
|
||||||
VisibilityTimeout: 0,
|
|
||||||
ReceiveMessageWaitTimeSeconds: 0,
|
|
||||||
DelaySeconds: 0,
|
|
||||||
MaximumMessageSize: 0,
|
|
||||||
MessageRetentionPeriod: 0,
|
|
||||||
Duplicates: make(map[string]time.Time),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := CreateQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, fixtures.CreateQueueResponse, response)
|
|
||||||
|
|
||||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
|
||||||
assert.Equal(t, expectedQueue, actualQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateQueueV1_success_no_configured_region_for_queue_url(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
models.CurrentEnvironment.Region = ""
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
|
|
||||||
c, _ := dupe.(models.CreateQueueRequest)
|
|
||||||
c.Attributes = models.QueueAttributes{}
|
|
||||||
|
|
||||||
v := resultingStruct.(*models.CreateQueueRequest)
|
|
||||||
*v = c
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := CreateQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
|
|
||||||
actualQueue := models.SyncQueues.Queues[fixtures.QueueName]
|
|
||||||
assert.Equal(t,
|
|
||||||
fmt.Sprintf("http://%s:%s/%s/%s",
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Host,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.Port,
|
|
||||||
fixtures.LOCAL_ENVIRONMENT.AccountID,
|
|
||||||
fixtures.QueueName,
|
|
||||||
),
|
|
||||||
actualQueue.URL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateQueueV1_request_transformer_error(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := CreateQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateQueueV1_invalid_dead_letter_queue_error(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
dupe, _ := copystructure.Copy(fixtures.CreateQueueRequest)
|
|
||||||
c, _ := dupe.(models.CreateQueueRequest)
|
|
||||||
c.Attributes.RedrivePolicy = models.RedrivePolicy{
|
|
||||||
MaxReceiveCount: 100,
|
|
||||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", "garbage"),
|
|
||||||
}
|
|
||||||
|
|
||||||
v := resultingStruct.(*models.CreateQueueRequest)
|
|
||||||
*v = c
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := CreateQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
+30
-19
@@ -42,27 +42,38 @@ func DeleteMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
log.Info("Deleting Message, Queue:", queueName, ", ReceiptHandle:", receiptHandle)
|
log.Info("Deleting Message, Queue:", queueName, ", ReceiptHandle:", receiptHandle)
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
defer models.SyncQueues.Unlock()
|
if queue == nil {
|
||||||
if _, ok := models.SyncQueues.Queues[key]; ok {
|
|
||||||
for i, msg := range models.SyncQueues.Queues[key].Messages {
|
|
||||||
if msg.ReceiptHandle == receiptHandle {
|
|
||||||
models.SyncQueues.Queues[key].UnlockGroup(msg.GroupID)
|
|
||||||
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages[:i], models.SyncQueues.Queues[key].Messages[i+1:]...)
|
|
||||||
delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID)
|
|
||||||
// Сохраняем очередь в Redis пока держим Lock
|
|
||||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
|
||||||
respStruct := models.DeleteMessageResponse{
|
|
||||||
Xmlns: models.BaseXmlns,
|
|
||||||
Metadata: models.BaseResponseMetadata,
|
|
||||||
}
|
|
||||||
return 200, &respStruct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Warning("Receipt Handle not found")
|
|
||||||
} else {
|
|
||||||
log.Warning("Queue not found")
|
log.Warning("Queue not found")
|
||||||
|
return utils.CreateErrorResponseV1("MessageDoesNotExist", true)
|
||||||
}
|
}
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
|
||||||
|
// O(1)-поиск сообщения по индексу ReceiptHandle → индекс в Messages (P1.2).
|
||||||
|
// Раньше — линейный скан всего среза + сдвиг остатка (O(N) на большое тело).
|
||||||
|
if i, ok := queue.IndexOfReceipt(receiptHandle); ok {
|
||||||
|
msg := queue.Messages[i] // копия ДО мутации: RemoveIndex может сдвинуть срез
|
||||||
|
queue.ClearInFlight(i, receiptHandle)
|
||||||
|
queue.UnlockGroup(msg.GroupID)
|
||||||
|
queue.RemoveIndex(i) // tombstone за O(1) + подрезка хвоста/амортизированная компакция
|
||||||
|
// ВАЖНО: запись в Duplicates НЕ удаляем — AWS держит dedup-окно 5 минут
|
||||||
|
// независимо от удаления сообщения (иначе повторный send с тем же
|
||||||
|
// dedupId доставлялся бы повторно). Чистит только истечение окна (PeriodicTasks).
|
||||||
|
// Удаляем одно сообщение из Redis — O(1). Ошибка → 500: иначе клиент
|
||||||
|
// получил бы 200, а после рестарта сообщение «воскресло» бы.
|
||||||
|
if err := persistence.DeleteMessagePersist(key, msg.Uuid); err != nil {
|
||||||
|
log.Errorf("DeleteMessage: не удалось удалить сообщение из Redis: %v", err)
|
||||||
|
return utils.CreateErrorResponseV1("InternalError", true)
|
||||||
|
}
|
||||||
|
// Метаданные очереди (duplicates, FIFO state)
|
||||||
|
persistence.SaveQueue(key, queue)
|
||||||
|
respStruct := models.DeleteMessageResponse{
|
||||||
|
Xmlns: models.BaseXmlns,
|
||||||
|
Metadata: models.BaseResponseMetadata,
|
||||||
|
}
|
||||||
|
return 200, &respStruct
|
||||||
|
}
|
||||||
|
log.Warning("Receipt Handle not found")
|
||||||
|
|
||||||
return utils.CreateErrorResponseV1("MessageDoesNotExist", true)
|
return utils.CreateErrorResponseV1("MessageDoesNotExist", true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -58,41 +54,72 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
|||||||
return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true)
|
return utils.CreateErrorResponseV1("BatchEntryIdsNotDistinct", true)
|
||||||
}
|
}
|
||||||
ids[v.Id] = true
|
ids[v.Id] = true
|
||||||
|
// Пустой ReceiptHandle опасен: в JSON-протоколе (x-amz-json-1.0) он не
|
||||||
|
// отсекается парсером и совпадает с ReceiptHandle НЕвыданных сообщений (""),
|
||||||
|
// что удаляло бы все такие сообщения очереди одним запросом.
|
||||||
|
if v.ReceiptHandle == "" {
|
||||||
|
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
defer models.SyncQueues.Unlock()
|
if queue == nil {
|
||||||
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
|
}
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
|
||||||
deleteMessageMap := make(map[string]*deleteEntry)
|
// Ключ — Id, а НЕ ReceiptHandle: два entry с одинаковым ReceiptHandle и разными
|
||||||
|
// Id не должны перезаписывать друг друга (первый исчезал бы и не попадал ни в
|
||||||
|
// Successful, ни в Failed — ревью 2026-10-11). entriesByHandle — обратный индекс.
|
||||||
|
entriesByID := make(map[string]*deleteEntry)
|
||||||
|
entriesByHandle := make(map[string][]*deleteEntry)
|
||||||
for _, entry := range requestBody.Entries {
|
for _, entry := range requestBody.Entries {
|
||||||
deleteMessageMap[entry.ReceiptHandle] = &deleteEntry{
|
de := &deleteEntry{
|
||||||
Id: entry.Id,
|
Id: entry.Id,
|
||||||
ReceiptHandle: entry.ReceiptHandle,
|
ReceiptHandle: entry.ReceiptHandle,
|
||||||
Deleted: false,
|
Deleted: false,
|
||||||
}
|
}
|
||||||
|
entriesByID[entry.Id] = de
|
||||||
|
entriesByHandle[entry.ReceiptHandle] = append(entriesByHandle[entry.ReceiptHandle], de)
|
||||||
}
|
}
|
||||||
|
|
||||||
deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0)
|
deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0)
|
||||||
remainingMessages := make([]models.SqsMessage, 0, len(models.SyncQueues.Queues[key].Messages))
|
deletedUuids := make([]string, 0)
|
||||||
|
|
||||||
for _, message := range models.SyncQueues.Queues[key].Messages {
|
// Идём по индексам, не пересобирая срез (P1.2): удаление = tombstone за O(1),
|
||||||
if de, found := deleteMessageMap[message.ReceiptHandle]; found {
|
// индексы не сдвигаются, поэтому итерация остаётся корректной.
|
||||||
log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID)
|
for i := range queue.Messages {
|
||||||
models.SyncQueues.Queues[key].UnlockGroup(message.GroupID)
|
if queue.IsDead(i) {
|
||||||
delete(models.SyncQueues.Queues[key].Duplicates, message.DeduplicationID)
|
continue
|
||||||
|
}
|
||||||
|
message := &queue.Messages[i]
|
||||||
|
des, found := entriesByHandle[message.ReceiptHandle]
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Debugf("FIFO Queue %s unlocking group %s:", queueName, message.GroupID)
|
||||||
|
// Читаем поля ДО RemoveIndex: компакция может перезаписать слот.
|
||||||
|
groupID, uuid := message.GroupID, message.Uuid
|
||||||
|
queue.ClearInFlight(i, message.ReceiptHandle)
|
||||||
|
queue.UnlockGroup(groupID)
|
||||||
|
// Duplicates НЕ чистим — dedup-окно 5 минут живёт независимо от удаления (AWS).
|
||||||
|
for _, de := range des {
|
||||||
de.Deleted = true
|
de.Deleted = true
|
||||||
deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id})
|
deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id})
|
||||||
} else {
|
|
||||||
remainingMessages = append(remainingMessages, message)
|
|
||||||
}
|
}
|
||||||
|
deletedUuids = append(deletedUuids, uuid)
|
||||||
|
queue.RemoveIndex(i)
|
||||||
}
|
}
|
||||||
|
// Удаляем сообщения из Redis отдельно — O(batch_size). Ошибка → InternalError.
|
||||||
models.SyncQueues.Queues[key].Messages = remainingMessages
|
if err := persistence.DeleteMessagesPersist(key, deletedUuids); err != nil {
|
||||||
// Персистим обновлённое состояние очереди, чтобы не терять batch-delete после рестарта.
|
log.Errorf("DeleteMessageBatch: не удалось удалить сообщения из Redis: %v", err)
|
||||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
return utils.CreateErrorResponseV1("InternalError", true)
|
||||||
|
}
|
||||||
|
// Метаданные очереди (duplicates, FIFO state)
|
||||||
|
persistence.SaveQueue(key, queue)
|
||||||
|
|
||||||
notFoundEntries := make([]models.BatchResultErrorEntry, 0)
|
notFoundEntries := make([]models.BatchResultErrorEntry, 0)
|
||||||
for _, de := range deleteMessageMap {
|
for _, de := range entriesByID {
|
||||||
if !de.Deleted {
|
if !de.Deleted {
|
||||||
notFoundEntries = append(notFoundEntries, models.BatchResultErrorEntry{
|
notFoundEntries = append(notFoundEntries, models.BatchResultErrorEntry{
|
||||||
Code: "1",
|
Code: "1",
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDeleteMessageBatchV1_success_all_message(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "testing",
|
|
||||||
Messages: []models.SqsMessage{
|
|
||||||
{
|
|
||||||
MessageBody: "test%20message%20body%201",
|
|
||||||
ReceiptHandle: "test1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MessageBody: "test%20message%20body%202",
|
|
||||||
ReceiptHandle: "test2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MessageBody: "test%20message%20body%203",
|
|
||||||
ReceiptHandle: "test3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["testing"] = q
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
|
||||||
*v = models.DeleteMessageBatchRequest{
|
|
||||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "delete-test-1",
|
|
||||||
ReceiptHandle: "test1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "delete-test-2",
|
|
||||||
ReceiptHandle: "test2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "delete-test-3",
|
|
||||||
ReceiptHandle: "test3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "testing"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, request2 := test.GenerateRequestInfo(
|
|
||||||
"POST",
|
|
||||||
"/",
|
|
||||||
nil,
|
|
||||||
true)
|
|
||||||
|
|
||||||
status, response2 := DeleteMessageBatchV1(request2)
|
|
||||||
deleteMessageBatchResponse := response2.(models.DeleteMessageBatchResponse)
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
assert.Equal(t, "delete-test-1", deleteMessageBatchResponse.Result.Successful[0].Id)
|
|
||||||
assert.Equal(t, "delete-test-2", deleteMessageBatchResponse.Result.Successful[1].Id)
|
|
||||||
assert.Equal(t, "delete-test-3", deleteMessageBatchResponse.Result.Successful[2].Id)
|
|
||||||
assert.Empty(t, deleteMessageBatchResponse.Result.Failed)
|
|
||||||
assert.Empty(t, models.SyncQueues.Queues["testing"].Messages)
|
|
||||||
}
|
|
||||||
func TestDeleteMessageBatchV1_success_not_found_message(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "testing",
|
|
||||||
Messages: []models.SqsMessage{
|
|
||||||
{
|
|
||||||
MessageBody: "test%20message%20body%201",
|
|
||||||
ReceiptHandle: "test1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MessageBody: "test%20message%20body%203",
|
|
||||||
ReceiptHandle: "test3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["testing"] = q
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
|
||||||
*v = models.DeleteMessageBatchRequest{
|
|
||||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "delete-test-1",
|
|
||||||
ReceiptHandle: "test1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "delete-test-2",
|
|
||||||
ReceiptHandle: "test2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "delete-test-3",
|
|
||||||
ReceiptHandle: "test3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "testing"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, request := test.GenerateRequestInfo(
|
|
||||||
"POST",
|
|
||||||
"/",
|
|
||||||
nil,
|
|
||||||
true)
|
|
||||||
|
|
||||||
status, response := DeleteMessageBatchV1(request)
|
|
||||||
deleteMessageBatchResponse := response.(models.DeleteMessageBatchResponse)
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
assert.Equal(t, "delete-test-1", deleteMessageBatchResponse.Result.Successful[0].Id)
|
|
||||||
assert.Equal(t, "delete-test-3", deleteMessageBatchResponse.Result.Successful[1].Id)
|
|
||||||
assert.Equal(t, "1", deleteMessageBatchResponse.Result.Failed[0].Code)
|
|
||||||
assert.Equal(t, "delete-test-2", deleteMessageBatchResponse.Result.Failed[0].Id)
|
|
||||||
assert.Equal(t, "Message not found", deleteMessageBatchResponse.Result.Failed[0].Message)
|
|
||||||
assert.True(t, deleteMessageBatchResponse.Result.Failed[0].SenderFault)
|
|
||||||
assert.Empty(t, models.SyncQueues.Queues["testing"].Messages)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteMessageBatchV1_error_not_found_queue(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
|
||||||
*v = models.DeleteMessageBatchRequest{
|
|
||||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "delete-test-1",
|
|
||||||
ReceiptHandle: "test1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "delete-test-2",
|
|
||||||
ReceiptHandle: "test2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "delete-test-3",
|
|
||||||
ReceiptHandle: "test3",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "not-exist-queue"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, r := test.GenerateRequestInfo(
|
|
||||||
"POST",
|
|
||||||
"/",
|
|
||||||
nil,
|
|
||||||
true)
|
|
||||||
|
|
||||||
status, _ := DeleteMessageBatchV1(r)
|
|
||||||
assert.Equal(t, status, http.StatusBadRequest)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteMessageBatchV1_error_no_entry(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
|
||||||
*v = models.DeleteMessageBatchRequest{
|
|
||||||
Entries: make([]models.DeleteMessageBatchRequestEntry, 0),
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, r := test.GenerateRequestInfo(
|
|
||||||
"POST",
|
|
||||||
"/",
|
|
||||||
nil,
|
|
||||||
true)
|
|
||||||
|
|
||||||
status, _ := DeleteMessageBatchV1(r)
|
|
||||||
assert.Equal(t, status, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteMessageBatchV1_error_too_many_entries(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
|
||||||
*v = models.DeleteMessageBatchRequest{
|
|
||||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "test-1",
|
|
||||||
ReceiptHandle: "test-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-2",
|
|
||||||
ReceiptHandle: "test-2",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-3",
|
|
||||||
ReceiptHandle: "test-3",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-4",
|
|
||||||
ReceiptHandle: "test-4",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-5",
|
|
||||||
ReceiptHandle: "test-5",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-6",
|
|
||||||
ReceiptHandle: "test-6",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-7",
|
|
||||||
ReceiptHandle: "test-7",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-8",
|
|
||||||
ReceiptHandle: "test-8",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-9",
|
|
||||||
ReceiptHandle: "test-9",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-10",
|
|
||||||
ReceiptHandle: "test-10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-11",
|
|
||||||
ReceiptHandle: "test-11",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, r := test.GenerateRequestInfo(
|
|
||||||
"POST",
|
|
||||||
"/",
|
|
||||||
nil,
|
|
||||||
true)
|
|
||||||
|
|
||||||
status, _ := DeleteMessageBatchV1(r)
|
|
||||||
assert.Equal(t, status, http.StatusBadRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteMessageBatchV1_Error_IdNotDistinct(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.DeleteMessageBatchRequest)
|
|
||||||
*v = models.DeleteMessageBatchRequest{
|
|
||||||
Entries: []models.DeleteMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "delete-test-1",
|
|
||||||
ReceiptHandle: "test1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "delete-test-1",
|
|
||||||
ReceiptHandle: "test2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, r := test.GenerateRequestInfo(
|
|
||||||
"POST",
|
|
||||||
"/",
|
|
||||||
nil,
|
|
||||||
true)
|
|
||||||
|
|
||||||
status, _ := DeleteMessageBatchV1(r)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteMessageBatchV1_Error_transformer(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, _ := DeleteMessageBatchV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, status)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,38 +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 TestDeleteMessage(t *testing.T) {
|
|
||||||
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
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/testing",
|
|
||||||
ReceiptHandle: "123",
|
|
||||||
}, true)
|
|
||||||
status, _ := DeleteMessageV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
assert.Empty(t, q.Messages)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// app/gosqs/delete_middle_test.go
|
||||||
|
// Тест P1.2: удаление сообщения из СЕРЕДИНЫ очереди не ломает остальные —
|
||||||
|
// индексы receipt/uuid корректны (tombstone), удалённое не возвращается,
|
||||||
|
// оставшиеся находятся по своим ReceiptHandle.
|
||||||
|
package gosqs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"shared-sqs/app/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDeleteMessageV1_MiddleKeepsOthersReceivable(t *testing.T) {
|
||||||
|
resetTestEnv(t)
|
||||||
|
tnt := testTenant("SSAK-aaa")
|
||||||
|
injectRequest(t, func(v interface{}) { v.(*models.CreateQueueRequest).QueueName = "q" })
|
||||||
|
if code, _ := CreateQueueV1(reqWithTenant("/", tnt)); code != http.StatusOK {
|
||||||
|
t.Fatal("create failed")
|
||||||
|
}
|
||||||
|
q := models.SyncQueues.Queues["SSAK-aaa:q"]
|
||||||
|
q.MaximumMessageSize = 262144
|
||||||
|
|
||||||
|
// Три сообщения.
|
||||||
|
for _, body := range []string{"m1", "m2", "m3"} {
|
||||||
|
b := body
|
||||||
|
injectRequest(t, func(v interface{}) {
|
||||||
|
r := v.(*models.SendMessageRequest)
|
||||||
|
r.QueueUrl = "http://x/tenant/q"
|
||||||
|
r.MessageBody = b
|
||||||
|
})
|
||||||
|
if code, _ := SendMessageV1(reqWithTenant("/", tnt)); code != http.StatusOK {
|
||||||
|
t.Fatal("send failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем все три разом.
|
||||||
|
injectRequest(t, func(v interface{}) {
|
||||||
|
r := v.(*models.ReceiveMessageRequest)
|
||||||
|
r.QueueUrl = "http://x/tenant/q"
|
||||||
|
r.MaxNumberOfMessages = 10
|
||||||
|
})
|
||||||
|
_, resp := ReceiveMessageV1(reqWithTenant("/", tnt))
|
||||||
|
rr := resp.(models.ReceiveMessageResponse)
|
||||||
|
if len(rr.Result.Messages) != 3 {
|
||||||
|
t.Fatalf("receive вернул %d сообщений, want 3", len(rr.Result.Messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем СРЕДНЕЕ.
|
||||||
|
middle := rr.Result.Messages[1]
|
||||||
|
injectRequest(t, func(v interface{}) {
|
||||||
|
r := v.(*models.DeleteMessageRequest)
|
||||||
|
r.QueueUrl = "http://x/tenant/q"
|
||||||
|
r.ReceiptHandle = middle.ReceiptHandle
|
||||||
|
})
|
||||||
|
if code, _ := DeleteMessageV1(reqWithTenant("/", tnt)); code != http.StatusOK {
|
||||||
|
t.Fatal("delete failed")
|
||||||
|
}
|
||||||
|
if q.LiveCount() != 2 {
|
||||||
|
t.Fatalf("LiveCount = %d, want 2", q.LiveCount())
|
||||||
|
}
|
||||||
|
if _, ok := q.IndexOfUUID(middle.MessageId); ok {
|
||||||
|
t.Fatal("удалённое сообщение осталось в uuid-индексе")
|
||||||
|
}
|
||||||
|
// Оставшиеся по-прежнему in-flight и находятся по своим handle (индексы не «съехали»).
|
||||||
|
for _, m := range []*models.ResultMessage{rr.Result.Messages[0], rr.Result.Messages[2]} {
|
||||||
|
if _, ok := q.IndexOfReceipt(m.ReceiptHandle); !ok {
|
||||||
|
t.Fatalf("handle оставшегося сообщения потерян: %s", m.ReceiptHandle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаем видимость оставшимся и получаем их снова — удалённого быть не должно.
|
||||||
|
for _, m := range []*models.ResultMessage{rr.Result.Messages[0], rr.Result.Messages[2]} {
|
||||||
|
handle := m.ReceiptHandle
|
||||||
|
injectRequest(t, func(v interface{}) {
|
||||||
|
r := v.(*models.ChangeMessageVisibilityRequest)
|
||||||
|
r.QueueUrl = "http://x/tenant/q"
|
||||||
|
r.ReceiptHandle = handle
|
||||||
|
r.VisibilityTimeout = 0
|
||||||
|
})
|
||||||
|
if code, _ := ChangeMessageVisibilityV1(reqWithTenant("/", tnt)); code != http.StatusOK {
|
||||||
|
t.Fatal("change visibility failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
injectRequest(t, func(v interface{}) {
|
||||||
|
r := v.(*models.ReceiveMessageRequest)
|
||||||
|
r.QueueUrl = "http://x/tenant/q"
|
||||||
|
r.MaxNumberOfMessages = 10
|
||||||
|
})
|
||||||
|
_, resp2 := ReceiveMessageV1(reqWithTenant("/", tnt))
|
||||||
|
rr2 := resp2.(models.ReceiveMessageResponse)
|
||||||
|
if len(rr2.Result.Messages) != 2 {
|
||||||
|
t.Fatalf("повторный receive вернул %d, want 2", len(rr2.Result.Messages))
|
||||||
|
}
|
||||||
|
for _, m := range rr2.Result.Messages {
|
||||||
|
if m.MessageId == middle.MessageId {
|
||||||
|
t.Fatal("удалённое сообщение вернулось в receive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
delete(models.SyncQueues.Queues, key)
|
defer models.SyncQueues.Unlock()
|
||||||
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)
|
||||||
|
}
|
||||||
|
models.SyncQueues.RemoveQueue(key)
|
||||||
|
|
||||||
// Удаляем из Redis асинхронно
|
// Удаляем из Redis асинхронно
|
||||||
persistence.DeleteQueue(key)
|
persistence.DeleteQueue(key)
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDeleteQueueV1_success(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.DeleteQueueRequest)
|
|
||||||
*v = models.DeleteQueueRequest{
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedResponse := models.DeleteQueueResponse{
|
|
||||||
Xmlns: models.BaseXmlns,
|
|
||||||
Metadata: models.BaseResponseMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := DeleteQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, expectedResponse, response)
|
|
||||||
|
|
||||||
_, ok := models.SyncQueues.Queues["unit-queue1"]
|
|
||||||
assert.False(t, ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteQueueV1_success_unknown_queue(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.DeleteQueueRequest)
|
|
||||||
*v = models.DeleteQueueRequest{
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unknown-queue1"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedResponse := models.DeleteQueueResponse{
|
|
||||||
Xmlns: models.BaseXmlns,
|
|
||||||
Metadata: models.BaseResponseMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := DeleteQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, expectedResponse, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteQueueV1_error_invalid_request(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := DeleteQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
+127
-107
@@ -1,118 +1,138 @@
|
|||||||
// Изменено: 2026-04-09
|
// Изменено: 2026-04-11 — убрана зависимость от copystructure (лишняя аллокация)
|
||||||
// GetQueueAttributesV1 — возвращает атрибуты очереди тенанта.
|
// GetQueueAttributesV1 — возвращает атрибуты очереди тенанта.
|
||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"shared-sqs/app/interfaces"
|
"shared-sqs/app/interfaces"
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
"shared-sqs/app/utils"
|
"shared-sqs/app/utils"
|
||||||
"github.com/mitchellh/copystructure"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
func GetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
||||||
requestBody := models.NewGetQueueAttributesRequest()
|
requestBody := models.NewGetQueueAttributesRequest()
|
||||||
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
|
ok := utils.REQUEST_TRANSFORMER(requestBody, req, false)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Error("Invalid Request - GetQueueAttributesV1")
|
log.Error("Invalid Request - GetQueueAttributesV1")
|
||||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||||
}
|
}
|
||||||
if requestBody.QueueUrl == "" {
|
if requestBody.QueueUrl == "" {
|
||||||
log.Error("Missing QueueUrl - GetQueueAttributesV1")
|
log.Error("Missing QueueUrl - GetQueueAttributesV1")
|
||||||
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := getTenantFromContext(req)
|
||||||
|
if t == nil {
|
||||||
|
return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем набор запрошенных атрибутов (или All)
|
||||||
|
requestedAttributes := func() map[string]bool {
|
||||||
|
attrs := map[string]bool{}
|
||||||
|
if len(requestBody.AttributeNames) == 0 {
|
||||||
|
return map[string]bool{"All": true}
|
||||||
|
}
|
||||||
|
for _, attr := range requestBody.AttributeNames {
|
||||||
|
if "All" == attr {
|
||||||
|
return map[string]bool{"All": true}
|
||||||
|
}
|
||||||
|
attrs[attr] = true
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Фильтруем атрибуты без deep copy — простая проверка через map lookup
|
||||||
|
_, wantAll := requestedAttributes["All"]
|
||||||
|
shouldInclude := func(attr string) bool {
|
||||||
|
if wantAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, ok := requestedAttributes[attr]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
uriSegments := strings.Split(requestBody.QueueUrl, "/")
|
||||||
|
queueName := uriSegments[len(uriSegments)-1]
|
||||||
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
|
|
||||||
|
log.Infof("Get Queue Attributes: %s (tenant: %s)", queueName, t.ID)
|
||||||
|
queueAttributes := make([]models.Attribute, 0)
|
||||||
|
|
||||||
|
queue := models.SyncQueues.GetRLocked(key)
|
||||||
|
if queue == nil {
|
||||||
|
log.Errorf("Get Queue Attributes: %s queue does not exist for tenant %s", queueName, t.ID)
|
||||||
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
|
}
|
||||||
|
defer queue.RUnlockMu()
|
||||||
|
|
||||||
|
if shouldInclude("DelaySeconds") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "DelaySeconds", Value: strconv.Itoa(queue.DelaySeconds)})
|
||||||
|
}
|
||||||
|
if shouldInclude("MaximumMessageSize") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "MaximumMessageSize", Value: strconv.Itoa(queue.MaximumMessageSize)})
|
||||||
|
}
|
||||||
|
if shouldInclude("MessageRetentionPeriod") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "MessageRetentionPeriod", Value: strconv.Itoa(queue.MessageRetentionPeriod)})
|
||||||
|
}
|
||||||
|
if shouldInclude("ReceiveMessageWaitTimeSeconds") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "ReceiveMessageWaitTimeSeconds", Value: strconv.Itoa(queue.ReceiveMessageWaitTimeSeconds)})
|
||||||
|
}
|
||||||
|
if shouldInclude("VisibilityTimeout") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.VisibilityTimeout)})
|
||||||
|
}
|
||||||
|
// FIFO-атрибуты: читаются Terraform (aws_sqs_queue) и aws cli; без них — drift.
|
||||||
|
if shouldInclude("FifoQueue") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "FifoQueue", Value: strconv.FormatBool(queue.IsFIFO)})
|
||||||
|
}
|
||||||
|
if shouldInclude("ContentBasedDeduplication") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "ContentBasedDeduplication", Value: strconv.FormatBool(queue.ContentBasedDeduplication)})
|
||||||
|
}
|
||||||
|
if shouldInclude("ApproximateNumberOfMessages") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(queue.LiveCount())})
|
||||||
|
}
|
||||||
|
if shouldInclude("ApproximateNumberOfMessagesNotVisible") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(queue))})
|
||||||
|
}
|
||||||
|
if shouldInclude("CreatedTimestamp") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: epochSeconds(queue.CreatedAt)})
|
||||||
|
}
|
||||||
|
if shouldInclude("LastModifiedTimestamp") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: epochSeconds(queue.LastModifiedAt)})
|
||||||
|
}
|
||||||
|
if shouldInclude("QueueArn") {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn})
|
||||||
|
}
|
||||||
|
if shouldInclude("RedrivePolicy") && queue.DeadLetterQueue != nil {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{
|
||||||
|
Name: "RedrivePolicy",
|
||||||
|
Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Policy — отдаём сохранённую JSON-строку как есть (совместимость с Terraform).
|
||||||
|
if shouldInclude("Policy") && queue.Policy != "" {
|
||||||
|
queueAttributes = append(queueAttributes, models.Attribute{Name: "Policy", Value: queue.Policy})
|
||||||
|
}
|
||||||
|
|
||||||
|
respStruct := models.GetQueueAttributesResponse{
|
||||||
|
Xmlns: models.BaseXmlns,
|
||||||
|
Result: models.GetQueueAttributesResult{Attrs: queueAttributes},
|
||||||
|
Metadata: models.BaseResponseMetadata,
|
||||||
|
}
|
||||||
|
return http.StatusOK, respStruct
|
||||||
}
|
}
|
||||||
|
|
||||||
t := getTenantFromContext(req)
|
// epochSeconds — timestamp в формате AWS (unix-секунды строкой).
|
||||||
if t == nil {
|
// Нулевое время отдаём как "0" (стабильно, без drift при чтении).
|
||||||
return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
|
func epochSeconds(t time.Time) string {
|
||||||
}
|
if t.IsZero() {
|
||||||
|
return "0"
|
||||||
requestedAttributes := func() map[string]bool {
|
}
|
||||||
attrs := map[string]bool{}
|
return strconv.FormatInt(t.Unix(), 10)
|
||||||
if len(requestBody.AttributeNames) == 0 {
|
|
||||||
return map[string]bool{"All": true}
|
|
||||||
}
|
|
||||||
for _, attr := range requestBody.AttributeNames {
|
|
||||||
if "All" == attr {
|
|
||||||
return map[string]bool{"All": true}
|
|
||||||
}
|
|
||||||
attrs[attr] = true
|
|
||||||
}
|
|
||||||
return attrs
|
|
||||||
}()
|
|
||||||
|
|
||||||
dupe, _ := copystructure.Copy(models.AvailableQueueAttributes)
|
|
||||||
includedAttributes, _ := dupe.(map[string]bool)
|
|
||||||
_, ok = requestedAttributes["All"]
|
|
||||||
if !ok {
|
|
||||||
for attr := range includedAttributes {
|
|
||||||
if _, ok := requestedAttributes[attr]; !ok {
|
|
||||||
delete(includedAttributes, attr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uriSegments := strings.Split(requestBody.QueueUrl, "/")
|
|
||||||
queueName := uriSegments[len(uriSegments)-1]
|
|
||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
|
||||||
|
|
||||||
log.Infof("Get Queue Attributes: %s (tenant: %s)", queueName, t.ID)
|
|
||||||
queueAttributes := make([]models.Attribute, 0)
|
|
||||||
|
|
||||||
models.SyncQueues.RLock()
|
|
||||||
defer models.SyncQueues.RUnlock()
|
|
||||||
queue, ok := models.SyncQueues.Queues[key]
|
|
||||||
if !ok {
|
|
||||||
log.Errorf("Get Queue Attributes: %s queue does not exist for tenant %s", queueName, t.ID)
|
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := includedAttributes["DelaySeconds"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "DelaySeconds", Value: strconv.Itoa(queue.DelaySeconds)})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["MaximumMessageSize"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "MaximumMessageSize", Value: strconv.Itoa(queue.MaximumMessageSize)})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["MessageRetentionPeriod"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "MessageRetentionPeriod", Value: strconv.Itoa(queue.MessageRetentionPeriod)})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["ReceiveMessageWaitTimeSeconds"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "ReceiveMessageWaitTimeSeconds", Value: strconv.Itoa(queue.ReceiveMessageWaitTimeSeconds)})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["VisibilityTimeout"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.VisibilityTimeout)})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["ApproximateNumberOfMessages"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(len(queue.Messages))})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["ApproximateNumberOfMessagesNotVisible"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(*queue))})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["CreatedTimestamp"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: "0000000000"})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["LastModifiedTimestamp"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: "0000000000"})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["QueueArn"]; ok {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn})
|
|
||||||
}
|
|
||||||
if _, ok := includedAttributes["RedrivePolicy"]; ok && queue.DeadLetterQueue != nil {
|
|
||||||
queueAttributes = append(queueAttributes, models.Attribute{
|
|
||||||
Name: "RedrivePolicy",
|
|
||||||
Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
respStruct := models.GetQueueAttributesResponse{
|
|
||||||
Xmlns: models.BaseXmlns,
|
|
||||||
Result: models.GetQueueAttributesResult{Attrs: queueAttributes},
|
|
||||||
Metadata: models.BaseResponseMetadata,
|
|
||||||
}
|
|
||||||
return http.StatusOK, respStruct
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
|
|
||||||
"github.com/mitchellh/copystructure"
|
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetQueueAttributesV1_success_all(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
|
||||||
*v = fixtures.GetQueueAttributesRequest
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := GetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, fixtures.GetQueueAttributesResponse, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetQueueAttributesV1_success_no_request_attrs_returns_all(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
|
||||||
*v = models.GetQueueAttributesRequest{
|
|
||||||
QueueUrl: "unit-queue1",
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := GetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, fixtures.GetQueueAttributesResponse, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetQueueAttributesV1_success_all_with_redrive_queue(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
|
||||||
*v = models.GetQueueAttributesRequest{
|
|
||||||
QueueUrl: "unit-queue2",
|
|
||||||
AttributeNames: []string{"All"},
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := GetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
dupe, _ := copystructure.Copy(fixtures.GetQueueAttributesResponse)
|
|
||||||
expectedResponse, _ := dupe.(models.GetQueueAttributesResponse)
|
|
||||||
expectedResponse.Result.Attrs[9].Value = fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, "unit-queue2")
|
|
||||||
expectedResponse.Result.Attrs = append(expectedResponse.Result.Attrs,
|
|
||||||
models.Attribute{
|
|
||||||
Name: "RedrivePolicy",
|
|
||||||
Value: fmt.Sprintf(`{"maxReceiveCount":"1", "deadLetterTargetArn":"%s:%s"}`, fixtures.BASE_SQS_ARN, "dead-letter-queue1"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, expectedResponse, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetQueueAttributesV1_success_specific_fields(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
|
||||||
*v = models.GetQueueAttributesRequest{
|
|
||||||
QueueUrl: fmt.Sprintf("%s/unit-queue1", fixtures.BASE_URL),
|
|
||||||
AttributeNames: []string{"DelaySeconds"},
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := GetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
expectedResponse := models.GetQueueAttributesResponse{
|
|
||||||
Xmlns: models.BaseXmlns,
|
|
||||||
Result: models.GetQueueAttributesResult{Attrs: []models.Attribute{
|
|
||||||
models.Attribute{
|
|
||||||
Name: "DelaySeconds",
|
|
||||||
Value: "0",
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
Metadata: models.BaseResponseMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, expectedResponse, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetQueueAttributesV1_request_transformer_error(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := GetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetQueueAttributesV1_missing_queue_url_in_request_returns_error(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
|
||||||
*v = models.GetQueueAttributesRequest{
|
|
||||||
QueueUrl: "",
|
|
||||||
AttributeNames: []string{},
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := GetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetQueueAttributesV1_missing_queue_returns_error(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.GetQueueAttributesRequest)
|
|
||||||
*v = fixtures.GetQueueAttributesRequest
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := GetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetQueueUrlV1_success(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.GetQueueUrlRequest)
|
|
||||||
*v = models.GetQueueUrlRequest{
|
|
||||||
QueueName: "unit-queue1",
|
|
||||||
QueueOwnerAWSAccountId: "fugafuga",
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo(
|
|
||||||
"POST",
|
|
||||||
"/",
|
|
||||||
nil,
|
|
||||||
true)
|
|
||||||
code, response := GetQueueUrlV1(r)
|
|
||||||
|
|
||||||
get_queue_url_response := response.(models.GetQueueUrlResponse)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Contains(t, get_queue_url_response.Result.QueueUrl, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"))
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetQueueUrlV1_error_no_queue(t *testing.T) {
|
|
||||||
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.GetQueueUrlRequest)
|
|
||||||
*v = models.GetQueueUrlRequest{
|
|
||||||
QueueName: "not-exist-unit-queue1",
|
|
||||||
QueueOwnerAWSAccountId: "fugafuga",
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo(
|
|
||||||
"POST",
|
|
||||||
"/",
|
|
||||||
nil,
|
|
||||||
true)
|
|
||||||
code, response := GetQueueUrlV1(r)
|
|
||||||
|
|
||||||
expected := models.ErrorResult{
|
|
||||||
Type: "Not Found",
|
|
||||||
Code: "AWS.SimpleQueueService.NonExistentQueue",
|
|
||||||
Message: "The specified queue does not exist for this wsdl version.",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
assert.Equal(t, response.GetResult().(models.ErrorResult), expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetQueueUrlV1_error_request_transformer(t *testing.T) {
|
|
||||||
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo(
|
|
||||||
"POST",
|
|
||||||
"/",
|
|
||||||
nil,
|
|
||||||
true)
|
|
||||||
code, _ := GetQueueUrlV1(r)
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
+152
-36
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
|
"shared-sqs/app/persistence"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -13,46 +14,53 @@ 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()
|
// Снапшот (key, указатель) очередей под коротким RLock.
|
||||||
for qName := range models.SyncQueues.Queues {
|
// Указатели остаются валидными после снятия RLock: очереди из map
|
||||||
queue := models.SyncQueues.Queues[qName]
|
// не удаляются во время итерации, а объекты живут в куче.
|
||||||
|
models.SyncQueues.RLock()
|
||||||
// Reset deduplication period
|
type qEntry struct {
|
||||||
for dedupId, startTime := range queue.Duplicates {
|
key string
|
||||||
if time.Now().After(startTime.Add(models.DeduplicationPeriod)) {
|
queue *models.Queue
|
||||||
log.Debugf("deduplication period for message with deduplicationId [%s] expired", dedupId)
|
}
|
||||||
delete(queue.Duplicates, dedupId)
|
queues := make([]qEntry, 0, len(models.SyncQueues.Queues))
|
||||||
}
|
for key, queue := range models.SyncQueues.Queues {
|
||||||
}
|
queues = append(queues, qEntry{key, queue})
|
||||||
|
}
|
||||||
log.Debugf("Queue [%s] length [%d]", queue.Name, len(queue.Messages))
|
models.SyncQueues.RUnlock()
|
||||||
for i := 0; i < len(queue.Messages); i++ {
|
|
||||||
msg := &queue.Messages[i]
|
// Каждую очередь обрабатываем под ОТДЕЛЬНЫМ Lock — блокировка одной
|
||||||
|
// очереди не мешает обработке остальных и HTTP-запросам.
|
||||||
if msg.ReceiptHandle != "" {
|
for _, qi := range queues {
|
||||||
if msg.VisibilityTimeout.Before(time.Now()) {
|
func() {
|
||||||
log.Debugf("Making message visible again %s", msg.ReceiptHandle)
|
// recover: паника в processQueueTick одной очереди не должна
|
||||||
queue.UnlockGroup(msg.GroupID)
|
// убивать весь процесс; mutex освобождается в defer.
|
||||||
msg.ReceiptHandle = ""
|
defer qi.queue.UnlockMu()
|
||||||
msg.ReceiptTime = time.Now().UTC()
|
defer func() {
|
||||||
msg.Retry++
|
if r := recover(); r != nil {
|
||||||
if queue.MaxReceiveCount > 0 &&
|
log.Errorf("gosqs: panic in processQueueTick for queue %q: %v", qi.key, r)
|
||||||
queue.DeadLetterQueue != nil &&
|
}
|
||||||
msg.Retry >= queue.MaxReceiveCount {
|
}()
|
||||||
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, *msg)
|
qi.queue.LockMu()
|
||||||
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
|
processQueueTick(qi.key, qi.queue)
|
||||||
i--
|
}()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
case <-quit:
|
case <-quit:
|
||||||
ticker.Stop()
|
ticker.Stop()
|
||||||
return
|
return
|
||||||
@@ -60,9 +68,117 @@ func PeriodicTasks(d time.Duration, quit chan bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func numberOfHiddenMessagesInQueue(queue models.Queue) int {
|
// processQueueTick — обработка ОДНОЙ очереди за один такт.
|
||||||
|
//
|
||||||
|
// ВЫЗЫВАТЬ под per-queue блокировкой этой очереди (queue.LockMu).
|
||||||
|
//
|
||||||
|
// Логическая схема:
|
||||||
|
// 0. Эвикция сообщений по MessageRetentionPeriod (старше retention удаляются).
|
||||||
|
// 1. Deduplication-окно (FIFO): удаляем записи старше DeduplicationPeriod.
|
||||||
|
// 2. Проходим по сообщениям очереди:
|
||||||
|
// - пустой ReceiptHandle (не in-flight) — пропускаем;
|
||||||
|
// - VisibilityTimeout ещё не истёк — пропускаем;
|
||||||
|
// - иначе: возвращаем видимость (сброс ReceiptHandle, разблокировка
|
||||||
|
// FIFO-группы, обновление ReceiptTime), инкрементируем Retry;
|
||||||
|
// - если Retry >= MaxReceiveCount и настроена DLQ — переносим сообщение
|
||||||
|
// в DLQ и удаляем из исходной очереди.
|
||||||
|
func processQueueTick(key string, queue *models.Queue) {
|
||||||
|
// Ленивая починка индексов (P1.2): очередь могла быть собрана литералом или
|
||||||
|
// загружена из Redis — тогда индексы строятся здесь один раз.
|
||||||
|
queue.EnsureIndexes()
|
||||||
|
|
||||||
|
// 0. Эвикция просроченных сообщений по MessageRetentionPeriod.
|
||||||
|
// Retention == 0 означает «без истечения» (не эвиктируем).
|
||||||
|
// Удаление — tombstone за O(1); физическая чистка — CompactIfNeeded ниже.
|
||||||
|
if queue.MessageRetentionPeriod > 0 {
|
||||||
|
cutoff := time.Now().Add(-time.Duration(queue.MessageRetentionPeriod) * time.Second)
|
||||||
|
expired := make([]string, 0) // uuid просроченных сообщений для удаления из Redis
|
||||||
|
for i := range queue.Messages {
|
||||||
|
if queue.IsDead(i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := queue.Messages[i]
|
||||||
|
if !msg.SentTime.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expired = append(expired, msg.Uuid)
|
||||||
|
if msg.GroupID != "" {
|
||||||
|
queue.UnlockGroup(msg.GroupID)
|
||||||
|
}
|
||||||
|
if msg.ReceiptHandle != "" {
|
||||||
|
queue.ClearInFlight(i, msg.ReceiptHandle)
|
||||||
|
}
|
||||||
|
queue.RemoveIndex(i)
|
||||||
|
}
|
||||||
|
if len(expired) > 0 {
|
||||||
|
log.Infof("Queue [%s]: evicted %d messages by MessageRetentionPeriod", queue.Name, len(expired))
|
||||||
|
// Удаляем просроченные из Redis, чтобы не воскресли после рестарта.
|
||||||
|
_ = persistence.DeleteMessagesPersist(key, expired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// Итерируем ТОЛЬКО по in-flight (receiptIdx), а не по всему срезу — стоимость
|
||||||
|
// пропорциональна числу сообщений «в полёте», а не размеру очереди (P1.2).
|
||||||
|
log.Debugf("Queue [%s] live [%d]", queue.Name, queue.LiveCount())
|
||||||
|
now := time.Now()
|
||||||
|
for _, i := range queue.InFlightIndexes() {
|
||||||
|
if i < 0 || i >= len(queue.Messages) || queue.IsDead(i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := &queue.Messages[i]
|
||||||
|
rh := msg.ReceiptHandle
|
||||||
|
if rh == "" {
|
||||||
|
continue // уже возвращено в видимость
|
||||||
|
}
|
||||||
|
if msg.VisibilityTimeout.After(now) {
|
||||||
|
continue // таймаут видимости ещё действует
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("Making message visible again %s", rh)
|
||||||
|
queue.ClearInFlight(i, rh)
|
||||||
|
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 {
|
||||||
|
dlqMsg := *msg
|
||||||
|
msgUuid := msg.Uuid
|
||||||
|
dlqName := queue.DeadLetterQueue.Name
|
||||||
|
queue.DeadLetterQueue.LockMu()
|
||||||
|
queue.DeadLetterQueue.AppendMessage(dlqMsg)
|
||||||
|
queue.DeadLetterQueue.UnlockMu()
|
||||||
|
queue.RemoveIndex(i)
|
||||||
|
// Персист переноса: иначе после рестарта сообщение «воскреснет»
|
||||||
|
// в исходной очереди (не удалено из Redis) и пропадёт из DLQ.
|
||||||
|
_ = persistence.DeleteMessagePersist(key, msgUuid)
|
||||||
|
persistence.SaveMessage(dlqKeyFor(key, dlqName), &dlqMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Компакция tombstone-слотов (амортизированно): срез не растёт бесконечно.
|
||||||
|
queue.CompactIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func numberOfHiddenMessagesInQueue(queue *models.Queue) int {
|
||||||
num := 0
|
num := 0
|
||||||
for _, m := range queue.Messages {
|
for i := range queue.Messages {
|
||||||
|
if queue.IsDead(i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := &queue.Messages[i]
|
||||||
if m.ReceiptHandle != "" || m.DelaySecs > 0 && time.Now().Before(m.SentTime.Add(time.Duration(m.DelaySecs)*time.Second)) {
|
if m.ReceiptHandle != "" || m.DelaySecs > 0 && time.Now().Before(m.SentTime.Add(time.Duration(m.DelaySecs)*time.Second)) {
|
||||||
num++
|
num++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,668 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO - Admiral-Piett these are better but still screwy. It's easy to have race conditions in here, so
|
|
||||||
// we have to name all the queues uniquely and leave them around so we're not resetting ourselves.
|
|
||||||
// Stupid. Handle the global memory issues and this can be easily resolved.
|
|
||||||
func Test_PeriodicTasks_deletes_deduplication_period_upon_expiration(t *testing.T) {
|
|
||||||
models.DeduplicationPeriod = 20 * time.Millisecond
|
|
||||||
quit := make(chan bool)
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
quit <- true
|
|
||||||
models.DeduplicationPeriod = 5 * time.Minute
|
|
||||||
}()
|
|
||||||
|
|
||||||
qName := "gosqs-dedupe-queue1"
|
|
||||||
mainQueue := &models.Queue{
|
|
||||||
Name: qName,
|
|
||||||
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName),
|
|
||||||
Arn: fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, qName),
|
|
||||||
Duplicates: map[string]time.Time{
|
|
||||||
"12345": time.Now(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
models.SyncQueues.Queues[qName] = mainQueue
|
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
go PeriodicTasks(10*time.Millisecond, quit)
|
|
||||||
|
|
||||||
assertions := func() bool {
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
defer models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
ok := 0 == len(mainQueue.Duplicates)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_PeriodicTasks_VisibilityTimeout_expires(t *testing.T) {
|
|
||||||
quit := make(chan bool)
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
quit <- true
|
|
||||||
}()
|
|
||||||
qName := "gosqs-visibility-queue1"
|
|
||||||
mainQueue := &models.Queue{
|
|
||||||
Name: qName,
|
|
||||||
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName),
|
|
||||||
Arn: fmt.Sprintf("%s:%s", fixtures.BASE_SQS_ARN, qName),
|
|
||||||
}
|
|
||||||
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
|
|
||||||
MessageBody: "1",
|
|
||||||
ReceiptHandle: "12345",
|
|
||||||
VisibilityTimeout: time.Now().Add(30 * time.Millisecond),
|
|
||||||
})
|
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
models.SyncQueues.Queues[qName] = mainQueue
|
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
go PeriodicTasks(10*time.Millisecond, quit)
|
|
||||||
|
|
||||||
assertions := func() bool {
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
defer models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
ok := !mainQueue.Messages[0].ReceiptTime.IsZero()
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ok = "1" == mainQueue.Messages[0].MessageBody
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ok = "" == mainQueue.Messages[0].ReceiptHandle
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ok = 1 == mainQueue.Messages[0].Retry
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_PeriodicTasks_moves_single_message_to_dead_letter_queue_upon_passing_receive_count(t *testing.T) {
|
|
||||||
quit := make(chan bool)
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
quit <- true
|
|
||||||
}()
|
|
||||||
|
|
||||||
qName := "gosqs-main-queue1"
|
|
||||||
dlqName := "gosqs-dead-letter-queue1"
|
|
||||||
dlqQueue := &models.Queue{
|
|
||||||
Arn: fmt.Sprintf("%s/%s", fixtures.BASE_SQS_ARN, dlqName),
|
|
||||||
Name: dlqName,
|
|
||||||
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, dlqName),
|
|
||||||
}
|
|
||||||
mainQueue := &models.Queue{
|
|
||||||
Arn: fmt.Sprintf("%s/%s", fixtures.BASE_SQS_ARN, qName),
|
|
||||||
DeadLetterQueue: dlqQueue,
|
|
||||||
MaxReceiveCount: 1,
|
|
||||||
Name: qName,
|
|
||||||
URL: fmt.Sprintf("%s/%s", fixtures.BASE_URL, qName),
|
|
||||||
}
|
|
||||||
|
|
||||||
go PeriodicTasks(10*time.Millisecond, quit)
|
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
|
|
||||||
MessageBody: "1",
|
|
||||||
Retry: 100,
|
|
||||||
ReceiptHandle: "12345",
|
|
||||||
VisibilityTimeout: time.Now().Add(10 * time.Millisecond),
|
|
||||||
})
|
|
||||||
models.SyncQueues.Queues[qName] = mainQueue
|
|
||||||
models.SyncQueues.Queues[dlqName] = dlqQueue
|
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
assertions := func() bool {
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
defer models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
ok := len(dlqQueue.Messages) == 1
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ok = "1" == dlqQueue.Messages[0].MessageBody
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_PeriodicTasks_moves_multiple_messages_to_dead_letter_queue_upon_passing_receive_count(t *testing.T) {
|
|
||||||
quit := make(chan bool)
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
quit <- true
|
|
||||||
}()
|
|
||||||
|
|
||||||
mainQueue := models.SyncQueues.Queues["unit-queue2"]
|
|
||||||
dlqQueue := models.SyncQueues.Queues["dead-letter-queue1"]
|
|
||||||
|
|
||||||
assert.Len(t, dlqQueue.Messages, 0)
|
|
||||||
|
|
||||||
go PeriodicTasks(10*time.Millisecond, quit)
|
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
|
|
||||||
MessageBody: "1",
|
|
||||||
Retry: 100,
|
|
||||||
ReceiptHandle: "12345",
|
|
||||||
})
|
|
||||||
mainQueue.Messages = append(mainQueue.Messages, models.SqsMessage{
|
|
||||||
MessageBody: "2",
|
|
||||||
Retry: 100,
|
|
||||||
ReceiptHandle: "23456",
|
|
||||||
})
|
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
assertions := func() bool {
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
defer models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
ok := len(dlqQueue.Messages) == 2
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ok = "1" == dlqQueue.Messages[0].MessageBody
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ok = "2" == dlqQueue.Messages[1].MessageBody
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
assert.Eventually(t, assertions, 10*time.Second, 10*time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO - I think all these below belong in handler tests, not in here. Double check the relevant
|
|
||||||
// handlers for coverage and delete.
|
|
||||||
func TestSendingAndReceivingFromFIFOQueueReturnsSameMessageOnError(t *testing.T) {
|
|
||||||
done := make(chan bool)
|
|
||||||
go PeriodicTasks(1*time.Second, done)
|
|
||||||
|
|
||||||
// create a queue
|
|
||||||
req, err := http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Add("Action", "CreateQueue")
|
|
||||||
form.Add("QueueName", "requeue-reset.fifo")
|
|
||||||
form.Add("Attribute.1.Name", "VisibilityTimeout")
|
|
||||||
form.Add("Attribute.1.Value", "2")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ := CreateQueueV1(req)
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
// send a message
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "SendMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
|
||||||
form.Add("MessageBody", "1")
|
|
||||||
form.Add("MessageGroupId", "GROUP-X")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = SendMessageV1(req)
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// send a message
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "SendMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
|
||||||
form.Add("MessageBody", "2")
|
|
||||||
form.Add("MessageGroupId", "GROUP-X")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = SendMessageV1(req)
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// receive message
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "ReceiveMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, resp := ReceiveMessageV1(req)
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
|
||||||
receiptHandleFirst := result.Messages[0].ReceiptHandle
|
|
||||||
if string(result.Messages[0].Body) != "1" {
|
|
||||||
t.Fatalf("should have received body 1: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// try to receive another message and we should get none
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "ReceiveMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = ReceiveMessageV1(req)
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
if len(models.SyncQueues.Queues["requeue-reset.fifo"].FIFOMessages) != 1 {
|
|
||||||
t.Fatal("there should be only 1 group locked")
|
|
||||||
}
|
|
||||||
|
|
||||||
if models.SyncQueues.Queues["requeue-reset.fifo"].FIFOMessages["GROUP-X"] != 0 {
|
|
||||||
t.Fatal("there should be GROUP-X locked")
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove message
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "DeleteMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
|
||||||
form.Add("ReceiptHandle", receiptHandleFirst)
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = DeleteMessageV1(req)
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
if len(models.SyncQueues.Queues["requeue-reset.fifo"].Messages) != 1 {
|
|
||||||
t.Fatal("there should be only 1 message in queue")
|
|
||||||
}
|
|
||||||
|
|
||||||
// receive message - loop until visibility timeouts
|
|
||||||
for {
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "ReceiveMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/requeue-reset.fifo")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, resp := ReceiveMessageV1(req)
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
|
||||||
if len(result.Messages) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(result.Messages[0].Body) != "2" {
|
|
||||||
t.Fatalf("should have received body 2: %s", err)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
done <- true
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessage_POST_DuplicatationNotAppliedToStandardQueue(t *testing.T) {
|
|
||||||
done := make(chan bool)
|
|
||||||
go PeriodicTasks(1*time.Second, done)
|
|
||||||
|
|
||||||
// create a queue
|
|
||||||
req, err := http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Add("Action", "CreateQueue")
|
|
||||||
form.Add("QueueName", "stantdard-testing")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ := CreateQueueV1(req)
|
|
||||||
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "SendMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/stantdard-testing")
|
|
||||||
form.Add("MessageBody", "Test1")
|
|
||||||
form.Add("MessageDeduplicationId", "123")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = SendMessageV1(req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
if len(models.SyncQueues.Queues["stantdard-testing"].Messages) == 0 {
|
|
||||||
t.Fatal("there should be 1 message in queue")
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "SendMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/stantdard-testing")
|
|
||||||
form.Add("MessageBody", "Test2")
|
|
||||||
form.Add("MessageDeduplicationId", "123")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = SendMessageV1(req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
if len(models.SyncQueues.Queues["stantdard-testing"].Messages) == 1 {
|
|
||||||
t.Fatal("there should be 2 messages in queue")
|
|
||||||
}
|
|
||||||
done <- true
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessage_POST_DuplicatationDisabledOnFifoQueue(t *testing.T) {
|
|
||||||
done := make(chan bool)
|
|
||||||
go PeriodicTasks(1*time.Second, done)
|
|
||||||
|
|
||||||
// create a queue
|
|
||||||
req, err := http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Add("Action", "CreateQueue")
|
|
||||||
form.Add("QueueName", "no-dup-testing.fifo")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ := CreateQueueV1(req)
|
|
||||||
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "SendMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/no-dup-testing.fifo")
|
|
||||||
form.Add("MessageBody", "Test1")
|
|
||||||
form.Add("MessageDeduplicationId", "123")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = SendMessageV1(req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
if len(models.SyncQueues.Queues["no-dup-testing.fifo"].Messages) == 0 {
|
|
||||||
t.Fatal("there should be 1 message in queue")
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "SendMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/no-dup-testing.fifo")
|
|
||||||
form.Add("MessageBody", "Test2")
|
|
||||||
form.Add("MessageDeduplicationId", "123")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = SendMessageV1(req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
if len(models.SyncQueues.Queues["no-dup-testing.fifo"].Messages) != 2 {
|
|
||||||
t.Fatal("there should be 2 message in queue")
|
|
||||||
}
|
|
||||||
done <- true
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessage_POST_DuplicatationEnabledOnFifoQueue(t *testing.T) {
|
|
||||||
done := make(chan bool)
|
|
||||||
go PeriodicTasks(1*time.Second, done)
|
|
||||||
|
|
||||||
// create a queue
|
|
||||||
req, err := http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Add("Action", "CreateQueue")
|
|
||||||
form.Add("QueueName", "dup-testing.fifo")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ := CreateQueueV1(req)
|
|
||||||
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
models.SyncQueues.Queues["dup-testing.fifo"].EnableDuplicates = true
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "SendMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/dup-testing.fifo")
|
|
||||||
form.Add("MessageBody", "Test1")
|
|
||||||
form.Add("MessageDeduplicationId", "123")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = SendMessageV1(req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
if len(models.SyncQueues.Queues["dup-testing.fifo"].Messages) == 0 {
|
|
||||||
t.Fatal("there should be 1 message in queue")
|
|
||||||
}
|
|
||||||
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "SendMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/dup-testing.fifo")
|
|
||||||
form.Add("MessageBody", "Test2")
|
|
||||||
form.Add("MessageDeduplicationId", "123")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = SendMessageV1(req)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
if len(models.SyncQueues.Queues["dup-testing.fifo"].Messages) != 1 {
|
|
||||||
t.Fatal("there should be 1 message in queue")
|
|
||||||
}
|
|
||||||
if body := models.SyncQueues.Queues["dup-testing.fifo"].Messages[0].MessageBody; string(body) == "Test2" {
|
|
||||||
t.Fatal("duplicate message should not be added to queue")
|
|
||||||
}
|
|
||||||
done <- true
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessage_POST_DelaySeconds(t *testing.T) {
|
|
||||||
// create a queue
|
|
||||||
req, err := http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
form := url.Values{}
|
|
||||||
form.Add("Action", "CreateQueue")
|
|
||||||
form.Add("QueueName", "sendmessage-delay")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ := CreateQueueV1(req)
|
|
||||||
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
// send a message
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "SendMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay")
|
|
||||||
form.Add("MessageBody", "1")
|
|
||||||
form.Add("DelaySeconds", "2")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
|
|
||||||
status, _ = SendMessageV1(req)
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v",
|
|
||||||
status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// receive message before delay is up
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "ReceiveMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
status, _ = ReceiveMessageV1(req)
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
|
|
||||||
// receive message with wait should return after delay
|
|
||||||
req, err = http.NewRequest("POST", "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
form = url.Values{}
|
|
||||||
form.Add("Action", "ReceiveMessage")
|
|
||||||
form.Add("QueueUrl", "http://localhost:4100/queue/sendmessage-delay")
|
|
||||||
form.Add("WaitTimeSeconds", "10")
|
|
||||||
form.Add("Version", "2012-11-05")
|
|
||||||
req.PostForm = form
|
|
||||||
start := time.Now()
|
|
||||||
status, _ = ReceiveMessageV1(req)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
assert.Equal(t, status, http.StatusOK)
|
|
||||||
if elapsed < 1*time.Second {
|
|
||||||
t.Errorf("handler didn't wait at all")
|
|
||||||
}
|
|
||||||
if elapsed > 4*time.Second {
|
|
||||||
t.Errorf("handler didn't need to wait all WaitTimeSeconds=10, only DelaySeconds=2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateErrorResponseV1(t *testing.T) {
|
|
||||||
expectedResponse := models.ErrorResponse{
|
|
||||||
Result: models.ErrorResult{
|
|
||||||
Type: "Not Found",
|
|
||||||
Code: "AWS.SimpleQueueService.NonExistentQueue",
|
|
||||||
Message: "The specified queue does not exist for this wsdl version.",
|
|
||||||
},
|
|
||||||
RequestId: "00000000-0000-0000-0000-000000000000",
|
|
||||||
}
|
|
||||||
status, response := utils.CreateErrorResponseV1("QueueNotFound", true)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, status)
|
|
||||||
assert.Equal(t, expectedResponse, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitTimeout waits for the waitgroup for the specified max timeout.
|
|
||||||
// Returns true if waiting timed out.
|
|
||||||
// credits: https://stackoverflow.com/questions/32840687/timeout-for-waitgroup-wait
|
|
||||||
func waitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
|
|
||||||
c := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(c)
|
|
||||||
wg.Wait()
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-c:
|
|
||||||
return false // completed normally
|
|
||||||
case <-time.After(timeout):
|
|
||||||
return true // timed out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -40,13 +40,11 @@ func ListQueueTagsV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
|
|
||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
|
|
||||||
models.SyncQueues.RLock()
|
queue := models.SyncQueues.GetRLocked(key)
|
||||||
defer models.SyncQueues.RUnlock()
|
if queue == nil {
|
||||||
|
|
||||||
queue, exists := models.SyncQueues.Queues[key]
|
|
||||||
if !exists {
|
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
}
|
}
|
||||||
|
defer queue.RUnlockMu()
|
||||||
|
|
||||||
tags := queue.Tags
|
tags := queue.Tags
|
||||||
if tags == nil {
|
if tags == nil {
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestListQueuesV1_success(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.ListQueueRequest)
|
|
||||||
*v = models.ListQueueRequest{}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := ListQueuesV1(r)
|
|
||||||
r1 := response.(models.ListQueuesResponse)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"))
|
|
||||||
assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue2"))
|
|
||||||
assert.Contains(t, r1.Result.QueueUrls, fmt.Sprintf("%s/%s", fixtures.BASE_URL, "dead-letter-queue1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListQueuesV1_success_no_queues(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.ListQueueRequest)
|
|
||||||
*v = models.ListQueueRequest{}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := ListQueuesV1(r)
|
|
||||||
r1 := response.(models.ListQueuesResponse)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, r1.Result.QueueUrls, []string{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListQueuesV1_success_with_queue_name_prefix(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.ListQueueRequest)
|
|
||||||
*v = models.ListQueueRequest{QueueNamePrefix: "dead-letter"}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := ListQueuesV1(r)
|
|
||||||
r1 := response.(models.ListQueuesResponse)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, []string{fmt.Sprintf("%s/%s", fixtures.BASE_URL, "dead-letter-queue1")}, r1.Result.QueueUrls)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListQueuesV1_success_with_queue_name_prefix_no_matching_queues(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.ListQueueRequest)
|
|
||||||
*v = models.ListQueueRequest{QueueNamePrefix: "garbage"}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := ListQueuesV1(r)
|
|
||||||
r1 := response.(models.ListQueuesResponse)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, []string{}, r1.Result.QueueUrls)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListQueuesV1_request_transformer_error(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := ListQueuesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// app/gosqs/memory_guard.go
|
||||||
|
// Глобальный предохранитель памяти процесса (защита от OOM одним тенантом).
|
||||||
|
//
|
||||||
|
// Лимиты по числу сообщений (MaxMessagesForQueue) не ограничивают СУММАРНЫЙ
|
||||||
|
// объём: 120k × 256 КБ ≈ 30 ГБ на одну очередь. Этот предохранитель отказывает
|
||||||
|
// в SendMessage/SendMessageBatch по HeapAlloc выше порога — единственная защита
|
||||||
|
// процесса от суммы всех тенантов.
|
||||||
|
//
|
||||||
|
// Задаётся env `SHARED_SQS_MAX_HEAP_MB` (0 = выключен). По умолчанию выключен:
|
||||||
|
// конкретное число зависит от RAM ВМ и целевого числа тенантов (решение владельца).
|
||||||
|
// Рекомендация на старт: ~70% от RAM ВМ, напр. 1024 для 1.5 ГБ ВМ.
|
||||||
|
package gosqs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxHeapBytes — порог HeapAlloc в байтах; 0 = предохранитель выключен.
|
||||||
|
var maxHeapBytes = func() int64 {
|
||||||
|
mb, err := strconv.ParseInt(os.Getenv("SHARED_SQS_MAX_HEAP_MB"), 10, 64)
|
||||||
|
if err != nil || mb <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return mb * 1024 * 1024
|
||||||
|
}()
|
||||||
|
|
||||||
|
// heapOverLimit — true, если текущий HeapAlloc превышает порог.
|
||||||
|
func heapOverLimit() bool {
|
||||||
|
if maxHeapBytes <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var ms runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&ms)
|
||||||
|
return int64(ms.HeapAlloc) > maxHeapBytes
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -32,18 +45,29 @@ func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
queueName := uriSegments[len(uriSegments)-1]
|
queueName := uriSegments[len(uriSegments)-1]
|
||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
defer models.SyncQueues.Unlock()
|
if queue == nil {
|
||||||
if _, ok := models.SyncQueues.Queues[key]; !ok {
|
|
||||||
log.Errorf("Purge Queue: %s, queue does not exist for tenant %s", queueName, t.ID)
|
log.Errorf("Purge Queue: %s, queue does not exist for tenant %s", queueName, t.ID)
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
}
|
}
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
|
||||||
|
// AWS: PurgeQueue допускается не чаще одного раза в 60 секунд на очередь.
|
||||||
|
// Нулевой LastPurgeTime (purge ещё не выполнялся) → запрет не срабатывает.
|
||||||
|
if time.Since(queue.LastPurgeTime) < 60*time.Second {
|
||||||
|
log.Warnf("Purge Queue: %s rejected — purge in progress (last: %s)", queueName, queue.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
|
queue.Reset()
|
||||||
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time)
|
queue.Duplicates = make(map[string]time.Time)
|
||||||
// Сохраняем пустую очередь в Redis пока держим Lock
|
// Фиксируем время purge — защита от повторного вызова в течение 60 секунд.
|
||||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
queue.LastPurgeTime = time.Now()
|
||||||
|
// Удаляем все сообщения из Redis одной командой DEL
|
||||||
|
persistence.PurgeMessagesPersist(key)
|
||||||
|
// Сохраняем пустые метаданные очереди
|
||||||
|
persistence.SaveQueue(key, queue)
|
||||||
|
|
||||||
respStruct := models.PurgeQueueResponse{
|
respStruct := models.PurgeQueueResponse{
|
||||||
Xmlns: models.BaseXmlns,
|
Xmlns: models.BaseXmlns,
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPurgeQueueV1_success(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.PurgeQueueRequest)
|
|
||||||
*v = models.PurgeQueueRequest{
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put a message on the queue
|
|
||||||
targetQueue := models.SyncQueues.Queues["unit-queue1"]
|
|
||||||
models.SyncQueues.Lock()
|
|
||||||
targetQueue.Messages = []models.SqsMessage{models.SqsMessage{}}
|
|
||||||
targetQueue.Duplicates = map[string]time.Time{
|
|
||||||
"dedupe-id": time.Now(),
|
|
||||||
}
|
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
expectedResponse := models.PurgeQueueResponse{
|
|
||||||
Xmlns: models.BaseXmlns,
|
|
||||||
Metadata: models.BaseResponseMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := PurgeQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, expectedResponse, response)
|
|
||||||
|
|
||||||
assert.Nil(t, targetQueue.Messages)
|
|
||||||
assert.Equal(t, map[string]time.Time{}, targetQueue.Duplicates)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurgeQueueV1_success_no_messages_on_queue(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.PurgeQueueRequest)
|
|
||||||
*v = models.PurgeQueueRequest{
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedResponse := models.PurgeQueueResponse{
|
|
||||||
Xmlns: models.BaseXmlns,
|
|
||||||
Metadata: models.BaseResponseMetadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := PurgeQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, expectedResponse, response)
|
|
||||||
|
|
||||||
targetQueue := models.SyncQueues.Queues["unit-queue1"]
|
|
||||||
assert.Nil(t, targetQueue.Messages)
|
|
||||||
assert.Equal(t, map[string]time.Time{}, targetQueue.Duplicates)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurgeQueueV1_request_transformer_error(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := PurgeQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPurgeQueueV1_requested_queue_does_not_exist(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.PurgeQueueRequest)
|
|
||||||
*v = models.PurgeQueueRequest{
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "garbage"),
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := PurgeQueueV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,125 @@
|
|||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
"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
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
deadLetterQueue, ok := models.SyncQueues.Queues[dlqKey]
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Error("Invalid RedrivePolicy Attribute")
|
log.Error("Invalid RedrivePolicy Attribute")
|
||||||
return fmt.Errorf("InvalidAttributeValue")
|
return fmt.Errorf("InvalidAttributeValue")
|
||||||
}
|
}
|
||||||
|
// Запрет self-DLQ (q → q) и циклов (A → B → A): при переносе в DLQ
|
||||||
|
// переносящий код берёт DeadLetterQueue.LockMu() под уже взятым q.mu —
|
||||||
|
// self/цикл даёт немедленный self-deadlock (или AB/BA-deadlock).
|
||||||
|
if deadLetterQueue == q {
|
||||||
|
log.Error("Invalid RedrivePolicy Attribute: self DLQ")
|
||||||
|
return fmt.Errorf("InvalidAttributeValue")
|
||||||
|
}
|
||||||
|
// Обход цепочки DLQ для детекта циклов. Лимит 100 — защита от уже
|
||||||
|
// существующего в данных цикла (если бы он там был).
|
||||||
|
for cur, steps := deadLetterQueue, 0; cur != nil && steps < 100; cur, steps = cur.DeadLetterQueue, steps+1 {
|
||||||
|
if cur == q {
|
||||||
|
log.Error("Invalid RedrivePolicy Attribute: cyclic DLQ")
|
||||||
|
return fmt.Errorf("InvalidAttributeValue")
|
||||||
|
}
|
||||||
|
}
|
||||||
q.DeadLetterQueue = deadLetterQueue
|
q.DeadLetterQueue = deadLetterQueue
|
||||||
q.MaxReceiveCount = attr.RedrivePolicy.MaxReceiveCount.Int()
|
q.MaxReceiveCount = attr.RedrivePolicy.MaxReceiveCount.Int()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Шаг 4: FIFO-атрибуты и Policy — храним как есть (совместимость с Terraform).
|
||||||
|
// FifoQueue только включаем (выключить FIFO у существующей очереди нельзя —
|
||||||
|
// как и в AWS). ContentBasedDeduplication и Policy не исполняются,
|
||||||
|
// но должны возвращаться в GetQueueAttributes, иначе drift в Terraform.
|
||||||
|
if attr.FifoQueue.Bool() {
|
||||||
|
q.IsFIFO = true
|
||||||
|
}
|
||||||
|
if attr.ContentBasedDeduplication.Bool() {
|
||||||
|
q.ContentBasedDeduplication = true
|
||||||
|
}
|
||||||
|
if attr.Policy != nil {
|
||||||
|
// Сохраняем канонический JSON (map → marshal сортирует ключи) —
|
||||||
|
// детерминированно, без drift при чтении.
|
||||||
|
if data, err := json.Marshal(attr.Policy); err == nil {
|
||||||
|
q.Policy = string(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q.LastModifiedAt = time.Now().UTC()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_success_no_redrive_policy(t *testing.T) {
|
|
||||||
var emptyQueue *models.Queue
|
|
||||||
q := &models.Queue{}
|
|
||||||
attrs := models.QueueAttributes{
|
|
||||||
DelaySeconds: 1,
|
|
||||||
MaximumMessageSize: 2,
|
|
||||||
MessageRetentionPeriod: 3,
|
|
||||||
ReceiveMessageWaitTimeSeconds: 4,
|
|
||||||
VisibilityTimeout: 5,
|
|
||||||
}
|
|
||||||
err := setQueueAttributesV1(q, attrs)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, q.DelaySeconds)
|
|
||||||
assert.Equal(t, 2, q.MaximumMessageSize)
|
|
||||||
assert.Equal(t, 3, q.MessageRetentionPeriod)
|
|
||||||
assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds)
|
|
||||||
assert.Equal(t, 5, q.VisibilityTimeout)
|
|
||||||
assert.Equal(t, emptyQueue, q.DeadLetterQueue)
|
|
||||||
assert.Equal(t, 0, q.MaxReceiveCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_success_no_request_attributes(t *testing.T) {
|
|
||||||
var emptyQueue *models.Queue
|
|
||||||
q := &models.Queue{}
|
|
||||||
attrs := models.QueueAttributes{}
|
|
||||||
err := setQueueAttributesV1(q, attrs)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 0, q.DelaySeconds)
|
|
||||||
assert.Equal(t, 0, q.MaximumMessageSize)
|
|
||||||
assert.Equal(t, 0, q.MessageRetentionPeriod)
|
|
||||||
assert.Equal(t, 0, q.ReceiveMessageWaitTimeSeconds)
|
|
||||||
assert.Equal(t, 0, q.VisibilityTimeout)
|
|
||||||
assert.Equal(t, emptyQueue, q.DeadLetterQueue)
|
|
||||||
assert.Equal(t, 0, q.MaxReceiveCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_success_can_set_0_values_where_applicable(t *testing.T) {
|
|
||||||
var emptyQueue *models.Queue
|
|
||||||
q := &models.Queue{
|
|
||||||
DelaySeconds: 1,
|
|
||||||
MaximumMessageSize: 2,
|
|
||||||
MessageRetentionPeriod: 3,
|
|
||||||
ReceiveMessageWaitTimeSeconds: 4,
|
|
||||||
VisibilityTimeout: 5,
|
|
||||||
}
|
|
||||||
attrs := models.QueueAttributes{}
|
|
||||||
err := setQueueAttributesV1(q, attrs)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 0, q.DelaySeconds)
|
|
||||||
assert.Equal(t, 0, q.MaximumMessageSize)
|
|
||||||
assert.Equal(t, 3, q.MessageRetentionPeriod)
|
|
||||||
assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds)
|
|
||||||
assert.Equal(t, 0, q.VisibilityTimeout)
|
|
||||||
assert.Equal(t, emptyQueue, q.DeadLetterQueue)
|
|
||||||
assert.Equal(t, 0, q.MaxReceiveCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_success_with_redrive_policy(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
}()
|
|
||||||
|
|
||||||
existingQueueName := "existing-queue"
|
|
||||||
existingQueue := &models.Queue{Name: existingQueueName}
|
|
||||||
models.SyncQueues.Queues[existingQueueName] = existingQueue
|
|
||||||
|
|
||||||
q := &models.Queue{}
|
|
||||||
attrs := models.QueueAttributes{
|
|
||||||
DelaySeconds: 1,
|
|
||||||
MaximumMessageSize: 2,
|
|
||||||
MessageRetentionPeriod: 3,
|
|
||||||
ReceiveMessageWaitTimeSeconds: 4,
|
|
||||||
VisibilityTimeout: 5,
|
|
||||||
RedrivePolicy: models.RedrivePolicy{
|
|
||||||
MaxReceiveCount: 10,
|
|
||||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:region:account-id:%s", existingQueueName),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err := setQueueAttributesV1(q, attrs)
|
|
||||||
|
|
||||||
assert.Nil(t, err)
|
|
||||||
assert.Equal(t, 1, q.DelaySeconds)
|
|
||||||
assert.Equal(t, 2, q.MaximumMessageSize)
|
|
||||||
assert.Equal(t, 3, q.MessageRetentionPeriod)
|
|
||||||
assert.Equal(t, 4, q.ReceiveMessageWaitTimeSeconds)
|
|
||||||
assert.Equal(t, 5, q.VisibilityTimeout)
|
|
||||||
assert.Equal(t, existingQueue, q.DeadLetterQueue)
|
|
||||||
assert.Equal(t, 10, q.MaxReceiveCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_error_redrive_policy_targets_missing_queue(t *testing.T) {
|
|
||||||
existingQueueName := "existing-queue"
|
|
||||||
|
|
||||||
q := &models.Queue{}
|
|
||||||
attrs := models.QueueAttributes{
|
|
||||||
DelaySeconds: 1,
|
|
||||||
MaximumMessageSize: 2,
|
|
||||||
MessageRetentionPeriod: 3,
|
|
||||||
ReceiveMessageWaitTimeSeconds: 4,
|
|
||||||
VisibilityTimeout: 5,
|
|
||||||
RedrivePolicy: models.RedrivePolicy{
|
|
||||||
MaxReceiveCount: 10,
|
|
||||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:region:account-id:%s", existingQueueName),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
err := setQueueAttributesV1(q, attrs)
|
|
||||||
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
+172
-92
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-04-09
|
// Изменено: 2026-04-11 — фикс: SentTimestamp из m.SentTime, MD5 из кэша
|
||||||
// ReceiveMessageV1 — получает сообщения из очереди тенанта с поддержкой long polling.
|
// ReceiveMessageV1 — получает сообщения из очереди тенанта с поддержкой long polling.
|
||||||
// Ловушка #4: long polling держит соединение до 20 сек — не прерываем принудительно.
|
// Ловушка #4: long polling держит соединение до 20 сек — не прерываем принудительно.
|
||||||
package gosqs
|
package gosqs
|
||||||
@@ -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,137 +83,184 @@ 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).
|
||||||
|
queue := models.SyncQueues.GetRLocked(key)
|
||||||
|
if queue == nil {
|
||||||
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, иначе ошибка
|
queue.RUnlockMu()
|
||||||
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)
|
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
messages, _ := receiveMessagesUnderLock(key, requestBody.VisibilityTimeout, maxNumberOfMessages)
|
||||||
defer models.SyncQueues.Unlock()
|
return http.StatusOK, buildReceiveResponse(messages)
|
||||||
|
|
||||||
if len(models.SyncQueues.Queues[key].Messages) > 0 {
|
|
||||||
numMsg := 0
|
|
||||||
messages = make([]*models.ResultMessage, 0)
|
|
||||||
for i := range models.SyncQueues.Queues[key].Messages {
|
|
||||||
if numMsg >= maxNumberOfMessages {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if models.SyncQueues.Queues[key].Messages[i].ReceiptHandle != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := &models.SyncQueues.Queues[key].Messages[i]
|
|
||||||
if !msg.IsReadyForReceipt() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if models.SyncQueues.Queues[key].IsFIFO {
|
|
||||||
if models.SyncQueues.Queues[key].IsLocked(msg.GroupID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues[key].LockGroup(msg.GroupID)
|
|
||||||
}
|
|
||||||
|
|
||||||
randomId := uuid.NewString()
|
|
||||||
msg.ReceiptHandle = msg.Uuid + "#" + randomId
|
|
||||||
msg.ReceiptTime = time.Now().UTC()
|
|
||||||
|
|
||||||
if requestBody.VisibilityTimeout != 0 {
|
|
||||||
msg.VisibilityTimeout = time.Now().Add(time.Duration(requestBody.VisibilityTimeout) * time.Second)
|
|
||||||
} else {
|
|
||||||
msg.VisibilityTimeout = time.Now().Add(time.Duration(models.SyncQueues.Queues[key].VisibilityTimeout) * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = append(messages, buildResultMessage(msg))
|
|
||||||
numMsg++
|
|
||||||
}
|
|
||||||
|
|
||||||
respStruct = models.ReceiveMessageResponse{
|
|
||||||
"http://queue.amazonaws.com/doc/2012-11-05/",
|
|
||||||
models.ReceiveMessageResult{Messages: messages},
|
|
||||||
models.ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"},
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Warning("No messages in Queue:", queueName)
|
|
||||||
respStruct = models.ReceiveMessageResponse{
|
|
||||||
Xmlns: "http://queue.amazonaws.com/doc/2012-11-05/",
|
|
||||||
Result: models.ReceiveMessageResult{},
|
|
||||||
Metadata: models.ResponseMetadata{RequestId: "00000000-0000-0000-0000-000000000000"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.StatusOK, respStruct
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
|
if queue == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// ВАЖНО: defer — ДО любых return ниже. Иначе возврат на пустой очереди
|
||||||
|
// оставлял бы per-queue блокировку захваченной навсегда (очередь «зависала»:
|
||||||
|
// все последующие Lock/RLock ждали бы вечно).
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
if queue.LiveCount() == 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// Быстрая предпроверка: есть ли вообще хотя бы одно готовое сообщение.
|
||||||
|
if !queueHasReceivableMessages(queue) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make([]*models.ResultMessage, 0)
|
||||||
|
numMsg := 0
|
||||||
|
for i := range queue.Messages {
|
||||||
|
if numMsg >= maxNumberOfMessages {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if queue.IsDead(i) {
|
||||||
|
continue // tombstone: сообщение логически удалено (P1.2)
|
||||||
|
}
|
||||||
|
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++
|
||||||
|
// Регистрируем handle→index: по нему DeleteMessage/ChangeMessageVisibility
|
||||||
|
// работают за O(1), а visibility-тик итерирует только in-flight (P1.2).
|
||||||
|
queue.MarkInFlight(i, msg.ReceiptHandle)
|
||||||
|
|
||||||
|
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,
|
||||||
Body: m.MessageBody,
|
Body: m.MessageBody,
|
||||||
ReceiptHandle: m.ReceiptHandle,
|
ReceiptHandle: m.ReceiptHandle,
|
||||||
MD5OfBody: utils.GetMD5Hash(m.MessageBody),
|
MD5OfBody: m.MD5OfMessageBody, // Используем кэшированный MD5 вместо пересчёта
|
||||||
MD5OfMessageAttributes: m.MD5OfMessageAttributes,
|
MD5OfMessageAttributes: m.MD5OfMessageAttributes,
|
||||||
MessageAttributes: m.MessageAttributes,
|
MessageAttributes: m.MessageAttributes,
|
||||||
Attributes: map[string]string{
|
Attributes: map[string]string{
|
||||||
"ApproximateFirstReceiveTimestamp": fmt.Sprintf("%d", m.ReceiptTime.UnixNano()/int64(time.Millisecond)),
|
"ApproximateFirstReceiveTimestamp": fmt.Sprintf("%d", m.ReceiptTime.UnixNano()/int64(time.Millisecond)),
|
||||||
"SenderId": models.CurrentEnvironment.AccountID,
|
"SenderId": models.CurrentEnvironment.AccountID,
|
||||||
"ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives+1),
|
"ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives),
|
||||||
"SentTimestamp": fmt.Sprintf("%d", time.Now().UTC().UnixNano()/int64(time.Millisecond)),
|
"SentTimestamp": fmt.Sprintf("%d", m.SentTime.UnixNano()/int64(time.Millisecond)), // Фикс: реальное время отправки
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if queue.IsDead(i) {
|
||||||
|
continue // tombstone (P1.2)
|
||||||
|
}
|
||||||
msg := &queue.Messages[i]
|
msg := &queue.Messages[i]
|
||||||
if msg.ReceiptHandle != "" {
|
if msg.ReceiptHandle != "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TODO Admiral-Piett - fix all these tests, we don't want to be waiting for 5 seconds plus like this.
|
|
||||||
func TestReceiveMessageV1_with_WaitTimeEnforced(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
}()
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "waiting-queue",
|
|
||||||
ReceiveMessageWaitTimeSeconds: 2,
|
|
||||||
//MaximumMessageSize: 262144,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["waiting-queue"] = q
|
|
||||||
|
|
||||||
// receive message ensure delay
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/waiting-queue",
|
|
||||||
}, true)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
status, response := ReceiveMessageV1(r)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
if elapsed < 2*time.Second {
|
|
||||||
t.Fatalf("handler didn't wait ReceiveMessageWaitTimeSeconds %s", response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mock sending a message
|
|
||||||
q.Messages = append(q.Messages, models.SqsMessage{MessageBody: "1"})
|
|
||||||
|
|
||||||
// receive message
|
|
||||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/waiting-queue",
|
|
||||||
}, true)
|
|
||||||
start = time.Now()
|
|
||||||
status, resp := ReceiveMessageV1(r)
|
|
||||||
elapsed = time.Since(start)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
if elapsed > 1*time.Second {
|
|
||||||
t.Fatal("handler waited when message was available, expected not to wait")
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "1", string(resp.GetResult().(models.ReceiveMessageResult).Messages[0].Body))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReceiveMessageV1_CanceledByClient(t *testing.T) {
|
|
||||||
// create a queue
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
}()
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "cancel-queue",
|
|
||||||
ReceiveMessageWaitTimeSeconds: 20,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["cancel-queue"] = q
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
ctx, cancelReceive := context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
// receive message (that will be canceled)
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/cancel-queue",
|
|
||||||
}, true)
|
|
||||||
r = r.WithContext(ctx)
|
|
||||||
|
|
||||||
status, resp := ReceiveMessageV1(r)
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
|
|
||||||
if len(resp.GetResult().(models.ReceiveMessageResult).Messages) != 0 {
|
|
||||||
t.Fatal("expecting this ReceiveMessage() to not pickup this message as it should canceled before the Send()")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
time.Sleep(100 * time.Millisecond) // let enought time for the Receive go to wait mode
|
|
||||||
cancelReceive() // cancel the first ReceiveMessage(), make sure it will not pickup the sent message below
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
|
|
||||||
// send a message
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", models.SendMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/cancel-queue",
|
|
||||||
MessageBody: "12345",
|
|
||||||
}, true)
|
|
||||||
status, _ := SendMessageV1(r)
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v", status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// receive message
|
|
||||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/cancel-queue",
|
|
||||||
}, true)
|
|
||||||
start := time.Now()
|
|
||||||
status, resp := ReceiveMessageV1(r)
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
|
|
||||||
result, ok := resp.GetResult().(models.ReceiveMessageResult)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("handler should return a message")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(result.Messages) == 0 || string(result.Messages[0].Body) == "12345\n" {
|
|
||||||
t.Fatal("handler should return a message")
|
|
||||||
}
|
|
||||||
if elapsed > 1*time.Second {
|
|
||||||
t.Fatal("handler waited when message was available, expected not to wait")
|
|
||||||
}
|
|
||||||
|
|
||||||
if timedout := waitTimeout(&wg, 2*time.Second); timedout {
|
|
||||||
t.Errorf("expected ReceiveMessage() in goroutine to exit quickly due to cancelReceive() called")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReceiveMessageV1_with_DelaySeconds(t *testing.T) {
|
|
||||||
// create a queue
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
}()
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "delay-seconds-queue",
|
|
||||||
DelaySeconds: 2,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["delay-seconds-queue"] = q
|
|
||||||
|
|
||||||
// send a message
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", models.SendMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/delay-seconds-queue",
|
|
||||||
MessageBody: "1",
|
|
||||||
}, true)
|
|
||||||
status, _ := SendMessageV1(r)
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("handler returned wrong status code: got \n%v want %v", status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// receive message before delay is up
|
|
||||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{QueueUrl: "http://localhost:4100/queue/delay-seconds-queue"}, true)
|
|
||||||
status, _ = ReceiveMessageV1(r)
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
|
|
||||||
// receive message with wait should return after delay
|
|
||||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/delay-seconds-queue",
|
|
||||||
WaitTimeSeconds: 10,
|
|
||||||
}, true)
|
|
||||||
start := time.Now()
|
|
||||||
status, _ = ReceiveMessageV1(r)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
if elapsed < 1*time.Second {
|
|
||||||
t.Errorf("handler didn't wait at all")
|
|
||||||
}
|
|
||||||
if elapsed > 4*time.Second {
|
|
||||||
t.Errorf("handler didn't need to wait all WaitTimeSeconds=10, only DelaySeconds=2")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReceiveMessageV1_with_MessageAttributes(t *testing.T) {
|
|
||||||
// create a queue
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
}()
|
|
||||||
|
|
||||||
q := &models.Queue{Name: "waiting-queue"}
|
|
||||||
models.SyncQueues.Queues["waiting-queue"] = q
|
|
||||||
|
|
||||||
// send a message
|
|
||||||
q.Messages = append(q.Messages, models.SqsMessage{
|
|
||||||
MessageBody: "1",
|
|
||||||
MessageAttributes: map[string]models.MessageAttribute{
|
|
||||||
"TestMessageAttrName": {
|
|
||||||
DataType: "String",
|
|
||||||
StringValue: "TestMessageAttrValue",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// receive message
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{QueueUrl: "http://localhost:4100/queue/waiting-queue"}, true)
|
|
||||||
status, resp := ReceiveMessageV1(r)
|
|
||||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
|
||||||
|
|
||||||
assert.NotEmpty(t, result.Messages[0].Attributes["ApproximateFirstReceiveTimestamp"])
|
|
||||||
assert.NotEmpty(t, result.Messages[0].Attributes["SenderId"])
|
|
||||||
assert.NotEmpty(t, result.Messages[0].Attributes["ApproximateReceiveCount"])
|
|
||||||
assert.NotEmpty(t, result.Messages[0].Attributes["SentTimestamp"])
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
assert.Equal(t, "1", string(result.Messages[0].Body))
|
|
||||||
assert.Equal(t, 1, len(result.Messages[0].MessageAttributes))
|
|
||||||
assert.Equal(t, "String", result.Messages[0].MessageAttributes["TestMessageAttrName"].DataType)
|
|
||||||
assert.Equal(t, "TestMessageAttrValue", result.Messages[0].MessageAttributes["TestMessageAttrName"].StringValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReceiveMessageV1_request_transformer_error(t *testing.T) {
|
|
||||||
// TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReceiveMessageV1_with_CustomVisibilityTimeout(t *testing.T) {
|
|
||||||
// create a queue
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Create a queue with a default visibility timeout of 30 seconds
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "custom-visibility-queue",
|
|
||||||
VisibilityTimeout: 30,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["custom-visibility-queue"] = q
|
|
||||||
|
|
||||||
// Add a message to the queue
|
|
||||||
q.Messages = append(q.Messages, models.SqsMessage{
|
|
||||||
MessageBody: "test-message",
|
|
||||||
Uuid: "test-uuid",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test 1: Receive message with custom visibility timeout
|
|
||||||
customTimeout := 60 // 60 seconds
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/custom-visibility-queue",
|
|
||||||
VisibilityTimeout: customTimeout,
|
|
||||||
}, true)
|
|
||||||
|
|
||||||
status, resp := ReceiveMessageV1(r)
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
|
|
||||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
|
||||||
assert.Equal(t, 1, len(result.Messages))
|
|
||||||
assert.Equal(t, "test-message", string(result.Messages[0].Body))
|
|
||||||
|
|
||||||
// Verify the message in the queue has the custom visibility timeout
|
|
||||||
// We can't directly check the exact time, but we can verify it's not using the queue's default
|
|
||||||
// by checking that the visibility timeout is greater than now + default timeout - 1 second
|
|
||||||
// and less than now + custom timeout + 1 second
|
|
||||||
now := time.Now()
|
|
||||||
defaultExpiry := now.Add(time.Duration(q.VisibilityTimeout) * time.Second)
|
|
||||||
customExpiry := now.Add(time.Duration(customTimeout) * time.Second)
|
|
||||||
|
|
||||||
// The first message should have the custom visibility timeout
|
|
||||||
msgVisibilityTimeout := q.Messages[0].VisibilityTimeout
|
|
||||||
assert.True(t, msgVisibilityTimeout.After(defaultExpiry.Add(-1*time.Second)),
|
|
||||||
"Message visibility timeout should be greater than default timeout")
|
|
||||||
assert.True(t, msgVisibilityTimeout.Before(customExpiry.Add(1*time.Second)),
|
|
||||||
"Message visibility timeout should be less than custom timeout + 1 second")
|
|
||||||
|
|
||||||
// Test 2: Reset the queue and test with zero visibility timeout (should use queue default)
|
|
||||||
models.SyncQueues.Queues["custom-visibility-queue"] = &models.Queue{
|
|
||||||
Name: "custom-visibility-queue",
|
|
||||||
VisibilityTimeout: 30,
|
|
||||||
}
|
|
||||||
q = models.SyncQueues.Queues["custom-visibility-queue"]
|
|
||||||
q.Messages = append(q.Messages, models.SqsMessage{
|
|
||||||
MessageBody: "test-message-2",
|
|
||||||
Uuid: "test-uuid-2",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Receive message with zero visibility timeout (should use queue default)
|
|
||||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4100/queue/custom-visibility-queue",
|
|
||||||
VisibilityTimeout: 0, // Zero should use queue default
|
|
||||||
}, true)
|
|
||||||
|
|
||||||
status, resp = ReceiveMessageV1(r)
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
|
|
||||||
// Verify the message in the queue has the default visibility timeout
|
|
||||||
now = time.Now()
|
|
||||||
defaultExpiry = now.Add(time.Duration(q.VisibilityTimeout) * time.Second)
|
|
||||||
|
|
||||||
// The message should have the default visibility timeout
|
|
||||||
msgVisibilityTimeout = q.Messages[0].VisibilityTimeout
|
|
||||||
assert.True(t, msgVisibilityTimeout.After(defaultExpiry.Add(-1*time.Second)),
|
|
||||||
"Message visibility timeout should be greater than default timeout - 1 second")
|
|
||||||
assert.True(t, msgVisibilityTimeout.Before(defaultExpiry.Add(1*time.Second)),
|
|
||||||
"Message visibility timeout should be less than default timeout + 1 second")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReceiveMessageV1_FIFOSecondMessageAvailableAfterDelete(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
}()
|
|
||||||
|
|
||||||
queueName := "fifo-delay-queue"
|
|
||||||
queueURL := fmt.Sprintf("http://localhost:4100/queue/%s", queueName)
|
|
||||||
now := time.Now().Add(-1 * time.Minute)
|
|
||||||
|
|
||||||
// create a queue with a visibility timeout of 10 seconds
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: queueName,
|
|
||||||
VisibilityTimeout: 10,
|
|
||||||
IsFIFO: true,
|
|
||||||
FIFOMessages: map[string]int{},
|
|
||||||
FIFOSequenceNumbers: map[string]int{},
|
|
||||||
Duplicates: map[string]time.Time{},
|
|
||||||
Messages: []models.SqsMessage{
|
|
||||||
{
|
|
||||||
MessageBody: "first",
|
|
||||||
Uuid: "first-uuid",
|
|
||||||
GroupID: "company#worker",
|
|
||||||
SentTime: now,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MessageBody: "second",
|
|
||||||
Uuid: "second-uuid",
|
|
||||||
GroupID: "company#worker",
|
|
||||||
SentTime: now,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues[queueName] = q
|
|
||||||
|
|
||||||
// receive the first FIFO message only
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: queueURL,
|
|
||||||
MaxNumberOfMessages: 1,
|
|
||||||
}, true)
|
|
||||||
status, resp := ReceiveMessageV1(r)
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
result := resp.GetResult().(models.ReceiveMessageResult)
|
|
||||||
if len(result.Messages) != 1 {
|
|
||||||
t.Fatalf("expected to receive the first FIFO message, got %d", len(result.Messages))
|
|
||||||
}
|
|
||||||
assert.Equal(t, "first", result.Messages[0].Body)
|
|
||||||
|
|
||||||
firstReceipt := result.Messages[0].ReceiptHandle
|
|
||||||
|
|
||||||
// verify the second FIFO message is blocked while the first is in flight
|
|
||||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: queueURL,
|
|
||||||
}, true)
|
|
||||||
status, resp = ReceiveMessageV1(r)
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
result = resp.GetResult().(models.ReceiveMessageResult)
|
|
||||||
if len(result.Messages) != 0 {
|
|
||||||
t.Fatalf("expected no FIFO message while the first is outstanding, got %d", len(result.Messages))
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete the first FIFO message
|
|
||||||
_, deleteReq := test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{
|
|
||||||
QueueUrl: queueURL,
|
|
||||||
ReceiptHandle: firstReceipt,
|
|
||||||
}, true)
|
|
||||||
deleteStatus, _ := DeleteMessageV1(deleteReq)
|
|
||||||
assert.Equal(t, http.StatusOK, deleteStatus)
|
|
||||||
|
|
||||||
// receive the second FIFO message and ensure it does not wait for full 10 second visibility timeout
|
|
||||||
_, r = test.GenerateRequestInfo("POST", "/", models.ReceiveMessageRequest{
|
|
||||||
QueueUrl: queueURL,
|
|
||||||
}, true)
|
|
||||||
start := time.Now()
|
|
||||||
status, resp = ReceiveMessageV1(r)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
result = resp.GetResult().(models.ReceiveMessageResult)
|
|
||||||
if len(result.Messages) != 1 {
|
|
||||||
t.Fatalf("expected second FIFO message to be available immediately, got %d", len(result.Messages))
|
|
||||||
}
|
|
||||||
if elapsed > time.Second {
|
|
||||||
t.Fatalf("expected second FIFO message without waiting on visibility timeout, took %s", elapsed)
|
|
||||||
}
|
|
||||||
assert.Equal(t, "second", result.Messages[0].Body)
|
|
||||||
|
|
||||||
// delete the second FIFO message
|
|
||||||
_, deleteReq = test.GenerateRequestInfo("POST", "/", models.DeleteMessageRequest{
|
|
||||||
QueueUrl: queueURL,
|
|
||||||
ReceiptHandle: result.Messages[0].ReceiptHandle,
|
|
||||||
}, true)
|
|
||||||
deleteStatus, _ = DeleteMessageV1(deleteReq)
|
|
||||||
assert.Equal(t, http.StatusOK, deleteStatus)
|
|
||||||
|
|
||||||
if len(q.Messages) != 0 {
|
|
||||||
t.Fatalf("expected all FIFO messages to be deleted, remaining %d", len(q.Messages))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO - other tests
|
|
||||||
+79
-21
@@ -1,7 +1,3 @@
|
|||||||
// Изменено: 2026-04-10 — добавлена Redis persistence
|
|
||||||
// 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)
|
||||||
@@ -66,17 +83,30 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
|
|
||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
|
|
||||||
models.SyncQueues.RLock()
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
queue, exists := models.SyncQueues.Queues[key]
|
if queue == nil {
|
||||||
if !exists {
|
|
||||||
models.SyncQueues.RUnlock()
|
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
}
|
}
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
|
||||||
maxMessageSize := queue.MaximumMessageSize
|
maxMessageSize := queue.MaximumMessageSize
|
||||||
currentMsgCount := len(queue.Messages)
|
currentMsgCount := queue.LiveCount()
|
||||||
queueIsFIFO := queue.IsFIFO
|
queueIsFIFO := queue.IsFIFO
|
||||||
|
contentBasedDedup := queue.ContentBasedDeduplication
|
||||||
delaySecs := queue.DelaySeconds
|
delaySecs := queue.DelaySeconds
|
||||||
models.SyncQueues.RUnlock()
|
|
||||||
|
// FIFO-очередь БЕЗ MessageGroupId — ошибка: AWS требует GroupId для каждой
|
||||||
|
// отправки в FIFO (иначе невозможно гарантировать порядок внутри группы).
|
||||||
|
// Раньше сообщение просто попадало в группу с пустым именем.
|
||||||
|
if queueIsFIFO && messageGroupID == "" {
|
||||||
|
return utils.CreateErrorResponseV1("MissingParameter", true)
|
||||||
|
}
|
||||||
|
// ContentBasedDeduplication: если MessageDeduplicationId не передан, AWS сам
|
||||||
|
// вычисляет его из тела сообщения (SHA-256). Без этого включённый флаг
|
||||||
|
// ничего не делал бы и FIFO-дедупликация не работала.
|
||||||
|
if queueIsFIFO && contentBasedDedup && messageDeduplicationID == "" {
|
||||||
|
messageDeduplicationID = utils.GetSHA256Hash(messageBody)
|
||||||
|
}
|
||||||
|
|
||||||
if maxMessageSize > 0 && len(messageBody) > maxMessageSize {
|
if maxMessageSize > 0 && len(messageBody) > maxMessageSize {
|
||||||
return utils.CreateErrorResponseV1("MessageTooBig", true)
|
return utils.CreateErrorResponseV1("MessageTooBig", true)
|
||||||
@@ -87,6 +117,9 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if requestBody.DelaySeconds < 0 || requestBody.DelaySeconds > 900 {
|
||||||
|
return utils.CreateErrorResponseV1("InvalidParameterValue", true)
|
||||||
|
}
|
||||||
if requestBody.DelaySeconds != 0 {
|
if requestBody.DelaySeconds != 0 {
|
||||||
delaySecs = requestBody.DelaySeconds
|
delaySecs = requestBody.DelaySeconds
|
||||||
}
|
}
|
||||||
@@ -104,23 +137,48 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
msg.SentTime = time.Now()
|
msg.SentTime = time.Now()
|
||||||
msg.DelaySecs = delaySecs
|
msg.DelaySecs = delaySecs
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
// Повторная проверка лимита под per-queue Lock (между снимком выше и текущим
|
||||||
fifoSeqNumber := ""
|
// моментом другие горутины могли добавить сообщения — мягкий OOM-лимит).
|
||||||
if models.SyncQueues.Queues[key].IsFIFO {
|
if queue.LiveCount() >= MaxMessagesForQueue(queue.IsFIFO) {
|
||||||
fifoSeqNumber = models.SyncQueues.Queues[key].NextSequenceNumber(messageGroupID)
|
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||||
|
}
|
||||||
|
// Глобальный предохранитель памяти (HeapAlloc) — защита от OOM суммой тенантов.
|
||||||
|
if heapOverLimit() {
|
||||||
|
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)
|
persistErr := false
|
||||||
|
if !queue.IsDuplicate(messageDeduplicationID) {
|
||||||
|
idx := queue.AppendMessage(msg)
|
||||||
|
// Персистим сообщение СИНХРОННО до ответа клиенту: краш пода в окне
|
||||||
|
// между 200 OK и async-записью приводил бы к потере подтверждённого
|
||||||
|
// сообщения. При ошибке — откатываем append (не оставляем в памяти
|
||||||
|
// сообщение, которого нет в Redis).
|
||||||
|
if err := persistence.SaveMessageSync(key, &msg); err != nil {
|
||||||
|
log.Errorf("SendMessage: не удалось персистить сообщение %s в очередь %s: %v", msg.Uuid, key, err)
|
||||||
|
queue.RemoveIndex(idx)
|
||||||
|
persistErr = true
|
||||||
|
} else {
|
||||||
|
// Регистрируем dedupId и порядковый номер ТОЛЬКО при успешной записи.
|
||||||
|
// Иначе ретрай с тем же dedupId получил бы 200 без сохранённого сообщения
|
||||||
|
// (InitDuplicatation после отката append помечал бы id как обработанный).
|
||||||
|
queue.InitDuplicatation(messageDeduplicationID)
|
||||||
|
if queue.IsFIFO {
|
||||||
|
fifoSeqNumber = queue.NextSequenceNumber(messageGroupID)
|
||||||
|
}
|
||||||
|
// Метаданные очереди (FIFO state, duplicates) — без Messages
|
||||||
|
persistence.SaveQueue(key, queue)
|
||||||
|
}
|
||||||
} 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)
|
if persistErr {
|
||||||
// Сохраняем очередь в Redis пока держим Lock
|
return utils.CreateErrorResponseV1("InternalError", true)
|
||||||
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
|
}
|
||||||
models.SyncQueues.Unlock()
|
// Логируем только метаданные — тело сообщения не логируется (perf + security)
|
||||||
log.Infof("%s: Queue: %s, Message: %s\n", time.Now().Format("2006-01-02 15:04:05"), queueName, msg.MessageBody)
|
log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody))
|
||||||
|
|
||||||
respStruct := models.SendMessageResponse{
|
respStruct := models.SendMessageResponse{
|
||||||
Xmlns: models.BaseXmlns,
|
Xmlns: models.BaseXmlns,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-04-09
|
// Изменено: 2026-04-11 — убрано логирование тела сообщения (perf + security)
|
||||||
// SendMessageBatchV1 — пакетная отправка сообщений в очередь тенанта.
|
// SendMessageBatchV1 — пакетная отправка сообщений в очередь тенанта.
|
||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
@@ -43,16 +43,14 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
|
|||||||
|
|
||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
|
|
||||||
models.SyncQueues.RLock()
|
queue := models.SyncQueues.GetRLocked(key)
|
||||||
queue, exists := models.SyncQueues.Queues[key]
|
if queue == nil {
|
||||||
if !exists {
|
|
||||||
models.SyncQueues.RUnlock()
|
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
}
|
}
|
||||||
maxMsgSize := queue.MaximumMessageSize
|
maxMsgSize := queue.MaximumMessageSize
|
||||||
currentMsgCount := len(queue.Messages)
|
currentMsgCount := queue.LiveCount()
|
||||||
queueIsFIFO := queue.IsFIFO
|
queueIsFIFO := queue.IsFIFO
|
||||||
models.SyncQueues.RUnlock()
|
queue.RUnlockMu()
|
||||||
|
|
||||||
sendEntries := requestBody.Entries
|
sendEntries := requestBody.Entries
|
||||||
|
|
||||||
@@ -104,8 +102,23 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
|
|||||||
return utils.CreateErrorResponseV1("OverLimit", true)
|
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
queue.LockMu()
|
||||||
queue = models.SyncQueues.Queues[key]
|
defer queue.UnlockMu()
|
||||||
|
|
||||||
|
// Повторная проверка лимита под per-queue Lock: снимок currentMsgCount выше
|
||||||
|
// был сделан под RLock, между ним и взятием Lock очередь могла измениться
|
||||||
|
// (гонка TOCTOU). Здесь — обязательная проверка по свежему числу живых.
|
||||||
|
if queue.LiveCount()+len(sendEntries) > MaxMessagesForQueue(queue.IsFIFO) {
|
||||||
|
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||||
|
}
|
||||||
|
// Глобальный предохранитель памяти (HeapAlloc) — защита от OOM суммой тенантов.
|
||||||
|
if heapOverLimit() {
|
||||||
|
return utils.CreateErrorResponseV1("OverLimit", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMsgs := make([]models.SqsMessage, 0, len(sendEntries))
|
||||||
|
appendedIdx := make([]int, 0, len(sendEntries))
|
||||||
|
appendedDedupIDs := make([]string, 0, len(sendEntries))
|
||||||
for _, sendEntry := range sendEntries {
|
for _, sendEntry := range sendEntries {
|
||||||
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
|
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
|
||||||
if len(sendEntry.MessageAttributes) > 0 {
|
if len(sendEntry.MessageAttributes) > 0 {
|
||||||
@@ -114,20 +127,41 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
|
|||||||
}
|
}
|
||||||
msg.MD5OfMessageBody = utils.GetMD5Hash(sendEntry.MessageBody)
|
msg.MD5OfMessageBody = utils.GetMD5Hash(sendEntry.MessageBody)
|
||||||
msg.GroupID = sendEntry.MessageGroupId
|
msg.GroupID = sendEntry.MessageGroupId
|
||||||
msg.DeduplicationID = sendEntry.MessageDeduplicationId
|
msg.DelaySecs = sendEntry.DelaySeconds
|
||||||
|
// ContentBasedDeduplication: dedupId вычисляется из тела (SHA-256),
|
||||||
|
// если не передан явно (AWS-семантика FIFO).
|
||||||
|
dedupID := sendEntry.MessageDeduplicationId
|
||||||
|
if queue.IsFIFO && queue.ContentBasedDeduplication && dedupID == "" {
|
||||||
|
dedupID = utils.GetSHA256Hash(sendEntry.MessageBody)
|
||||||
|
}
|
||||||
|
msg.DeduplicationID = dedupID
|
||||||
msg.Uuid = uuid.NewString()
|
msg.Uuid = uuid.NewString()
|
||||||
msg.SentTime = time.Now()
|
msg.SentTime = time.Now()
|
||||||
|
|
||||||
|
// Дубликат проверяем ДО NextSequenceNumber/Append: иначе seq инкрементировался
|
||||||
|
// бы и для отброшенного дубликата, а два entry с одним dedupID в одном батче
|
||||||
|
// оба добавились бы (InitDuplicatation был после цикла — ревью 2026-10-11).
|
||||||
|
if queue.IsDuplicate(dedupID) {
|
||||||
|
log.Debugf("Duplicate deduplicationId [%s] in queue [%s]", dedupID, queueName)
|
||||||
|
sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{
|
||||||
|
Id: sendEntry.Id,
|
||||||
|
MessageId: msg.Uuid,
|
||||||
|
MD5OfMessageBody: msg.MD5OfMessageBody,
|
||||||
|
MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
|
||||||
|
SequenceNumber: "",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
fifoSeqNumber := ""
|
fifoSeqNumber := ""
|
||||||
if queue.IsFIFO {
|
if queue.IsFIFO {
|
||||||
fifoSeqNumber = queue.NextSequenceNumber(sendEntry.MessageGroupId)
|
fifoSeqNumber = queue.NextSequenceNumber(sendEntry.MessageGroupId)
|
||||||
}
|
}
|
||||||
if !queue.IsDuplicate(sendEntry.MessageDeduplicationId) {
|
// Регистрируем dedupId сразу — второй entry с тем же id увидит его.
|
||||||
queue.Messages = append(queue.Messages, msg)
|
queue.InitDuplicatation(dedupID)
|
||||||
} else {
|
appendedIdx = append(appendedIdx, queue.AppendMessage(msg))
|
||||||
log.Debugf("Duplicate deduplicationId [%s] in queue [%s]", sendEntry.MessageDeduplicationId, queueName)
|
newMsgs = append(newMsgs, msg)
|
||||||
}
|
appendedDedupIDs = append(appendedDedupIDs, dedupID)
|
||||||
queue.InitDuplicatation(sendEntry.MessageDeduplicationId)
|
|
||||||
|
|
||||||
sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{
|
sentEntries = append(sentEntries, models.SendMessageBatchResultEntry{
|
||||||
Id: sendEntry.Id,
|
Id: sendEntry.Id,
|
||||||
@@ -136,11 +170,25 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
|
|||||||
MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
|
MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
|
||||||
SequenceNumber: fifoSeqNumber,
|
SequenceNumber: fifoSeqNumber,
|
||||||
})
|
})
|
||||||
log.Infof("%s: Queue: %s, Message: %s", time.Now().Format("2006-01-02 15:04:05"), queueName, msg.MessageBody)
|
// Логируем только метаданные — тело сообщения не логируется (perf + security)
|
||||||
|
log.Debugf("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(sendEntry.MessageBody))
|
||||||
}
|
}
|
||||||
// Персистим батч-изменение одним снапшотом под lock.
|
// Персистим СИНХРОННО до ответа (как одиночный SendMessage): иначе краш в окне
|
||||||
|
// между 200 OK и async-записью потеряет подтверждённые сообщения.
|
||||||
|
if err := persistence.SaveMessagesSync(key, newMsgs); err != nil {
|
||||||
|
log.Errorf("SendMessageBatch: не удалось персистить батч в %s: %v", key, err)
|
||||||
|
for _, idx := range appendedIdx {
|
||||||
|
queue.RemoveIndex(idx)
|
||||||
|
}
|
||||||
|
// Откат dedup-регистраций: иначе retry с тем же dedupId получил бы
|
||||||
|
// «duplicate» без сохранённых сообщений.
|
||||||
|
for _, id := range appendedDedupIDs {
|
||||||
|
delete(queue.Duplicates, id)
|
||||||
|
}
|
||||||
|
return utils.CreateErrorResponseV1("InternalError", true)
|
||||||
|
}
|
||||||
|
// Метаданные очереди (FIFO state, duplicates) — без Messages
|
||||||
persistence.SaveQueue(key, queue)
|
persistence.SaveQueue(key, queue)
|
||||||
models.SyncQueues.Unlock()
|
|
||||||
|
|
||||||
respStruct := models.SendMessageBatchResponse{
|
respStruct := models.SendMessageBatchResponse{
|
||||||
Xmlns: models.BaseXmlns,
|
Xmlns: models.BaseXmlns,
|
||||||
|
|||||||
@@ -1,346 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSendMessageBatchV1_Success(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_success := models.SendMessageBatchRequest{
|
|
||||||
Entries: []models.SendMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "test-msg-with-non-attribute",
|
|
||||||
MessageBody: "test%20message%20body%201",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-msg-with-single-attirbute",
|
|
||||||
MessageBody: "test%20message%20body%202",
|
|
||||||
MessageAttributes: map[string]models.MessageAttribute{
|
|
||||||
"my-attribute-name": {
|
|
||||||
DataType: "String",
|
|
||||||
StringValue: "my-attribute-string-value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test-msg-with-multi-attirbute",
|
|
||||||
MessageBody: "test%20message%20body%203",
|
|
||||||
MessageAttributes: map[string]models.MessageAttribute{
|
|
||||||
"my-attribute-name-1": {
|
|
||||||
BinaryValue: "binary-value-1",
|
|
||||||
DataType: "Binary",
|
|
||||||
},
|
|
||||||
"my-attribute-name-2": {
|
|
||||||
DataType: "String",
|
|
||||||
StringValue: "my-attribute-string-value-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
|
||||||
*v = sendMessageRequest_success
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageBatchV1(r)
|
|
||||||
sendMessageBatchResponse, ok := response.(models.SendMessageBatchResponse)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
assert.True(t, ok)
|
|
||||||
|
|
||||||
resultEntry := sendMessageBatchResponse.Result.Entry
|
|
||||||
assert.Equal(t, 3, len(resultEntry))
|
|
||||||
assert.Contains(t, resultEntry[0].Id, "test-msg-with-non-attribute")
|
|
||||||
assert.Contains(t, resultEntry[1].Id, "test-msg-with-single-attirbute")
|
|
||||||
assert.Contains(t, resultEntry[2].Id, "test-msg-with-multi-attirbute")
|
|
||||||
assert.Empty(t, resultEntry[0].SequenceNumber)
|
|
||||||
assert.Empty(t, resultEntry[1].SequenceNumber)
|
|
||||||
assert.Empty(t, resultEntry[2].SequenceNumber)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageBatchV1_Success_Fifo_Queue(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_success := models.SendMessageBatchRequest{
|
|
||||||
Entries: []models.SendMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "test_msg_001",
|
|
||||||
MessageBody: "test%20message%20body%201",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_002",
|
|
||||||
MessageBody: "test%20message%20body%202",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_003",
|
|
||||||
MessageBody: "test%20message%20body%203",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "fifo-queue-1"),
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
|
||||||
*v = sendMessageRequest_success
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "fifo-queue-1",
|
|
||||||
MaximumMessageSize: 1024,
|
|
||||||
IsFIFO: true,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["fifo-queue-1"] = q
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageBatchV1(r)
|
|
||||||
sendMessageBatchResponse, ok := response.(models.SendMessageBatchResponse)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
assert.True(t, ok)
|
|
||||||
|
|
||||||
resultEntry := sendMessageBatchResponse.Result.Entry
|
|
||||||
assert.Equal(t, 3, len(resultEntry))
|
|
||||||
assert.Contains(t, resultEntry[0].Id, "test_msg_001")
|
|
||||||
assert.NotEmpty(t, resultEntry[0].SequenceNumber)
|
|
||||||
assert.Contains(t, resultEntry[1].Id, "test_msg_002")
|
|
||||||
assert.NotEmpty(t, resultEntry[1].SequenceNumber)
|
|
||||||
assert.Contains(t, resultEntry[2].Id, "test_msg_003")
|
|
||||||
assert.NotEmpty(t, resultEntry[2].SequenceNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageBatchV1_Error_QueueNotFound(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_not_found_queue := models.SendMessageBatchRequest{
|
|
||||||
Entries: []models.SendMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "test_msg_001",
|
|
||||||
MessageBody: "test%20message%20body%201",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_002",
|
|
||||||
MessageBody: "test%20message%20body%202",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_003",
|
|
||||||
MessageBody: "test%20message%20body%203",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "not-exist-queue1"),
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
|
||||||
*v = sendMessageRequest_not_found_queue
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := models.ErrorResult{
|
|
||||||
Type: "Not Found",
|
|
||||||
Code: "AWS.SimpleQueueService.NonExistentQueue",
|
|
||||||
Message: "The specified queue does not exist for this wsdl version.",
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageBatchV1(r)
|
|
||||||
errorResult := response.GetResult().(models.ErrorResult)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, status)
|
|
||||||
assert.Equal(t, expected, errorResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageBatchV1_Error_NoEntry(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_no_entry := models.SendMessageBatchRequest{
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
|
||||||
*v = sendMessageRequest_no_entry
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := models.ErrorResult{
|
|
||||||
Type: "EmptyBatchRequest",
|
|
||||||
Code: "AWS.SimpleQueueService.EmptyBatchRequest",
|
|
||||||
Message: "The batch request doesn't contain any entries.",
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageBatchV1(r)
|
|
||||||
errorResult := response.GetResult().(models.ErrorResult)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, status)
|
|
||||||
assert.Equal(t, expected, errorResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageBatchV1_Error_IdNotDistinct(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_no_entry := models.SendMessageBatchRequest{
|
|
||||||
Entries: []models.SendMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "test_msg_001",
|
|
||||||
MessageBody: "test%20message%20body%201",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_001",
|
|
||||||
MessageBody: "test%20message%20body%202",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_001",
|
|
||||||
MessageBody: "test%20message%20body%203",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
|
||||||
*v = sendMessageRequest_no_entry
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := models.ErrorResult{
|
|
||||||
Type: "BatchEntryIdsNotDistinct",
|
|
||||||
Code: "AWS.SimpleQueueService.BatchEntryIdsNotDistinct",
|
|
||||||
Message: "Two or more batch entries in the request have the same Id.",
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageBatchV1(r)
|
|
||||||
errorResult := response.GetResult().(models.ErrorResult)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, status)
|
|
||||||
assert.Equal(t, expected, errorResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageBatchV1_Error_TooManyEntries(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_success := models.SendMessageBatchRequest{
|
|
||||||
Entries: []models.SendMessageBatchRequestEntry{
|
|
||||||
{
|
|
||||||
Id: "test_msg_001",
|
|
||||||
MessageBody: "test%20message%20body%201",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_002",
|
|
||||||
MessageBody: "test%20message%20body%202",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_003",
|
|
||||||
MessageBody: "test%20message%20body%203",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_004",
|
|
||||||
MessageBody: "test%20message%20body%204",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_005",
|
|
||||||
MessageBody: "test%20message%20body%205",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_006",
|
|
||||||
MessageBody: "test%20message%20body%206",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_007",
|
|
||||||
MessageBody: "test%20message%20body%207",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_008",
|
|
||||||
MessageBody: "test%20message%20body%208",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_009",
|
|
||||||
MessageBody: "test%20message%20body%209",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_010",
|
|
||||||
MessageBody: "test%20message%20body%210",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Id: "test_msg_011",
|
|
||||||
MessageBody: "test%20message%20body%211",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageBatchRequest)
|
|
||||||
*v = sendMessageRequest_success
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
expected := models.ErrorResult{
|
|
||||||
Type: "TooManyEntriesInBatchRequest",
|
|
||||||
Code: "AWS.SimpleQueueService.TooManyEntriesInBatchRequest",
|
|
||||||
Message: "Maximum number of entries per request are 10.",
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageBatchV1(r)
|
|
||||||
errorResult := response.GetResult().(models.ErrorResult)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, status)
|
|
||||||
assert.Equal(t, expected, errorResult)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageBatchV1_Error_transformer(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := SendMessageBatchV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSendMessageV1_Success(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_success := models.SendMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
|
||||||
MessageBody: "Test Message",
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageRequest)
|
|
||||||
*v = sendMessageRequest_success
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "new-queue-1",
|
|
||||||
MaximumMessageSize: 1024,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["new-queue-1"] = q
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageV1(r)
|
|
||||||
|
|
||||||
// Check the queue
|
|
||||||
assert.Equal(t, 1, len(q.Messages))
|
|
||||||
msg := q.Messages[0]
|
|
||||||
assert.Equal(t, "Test Message", string(msg.MessageBody))
|
|
||||||
|
|
||||||
// Check the response
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
sendMessageResponse, ok := response.(models.SendMessageResponse)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.NotEmpty(t, sendMessageResponse.Result.MD5OfMessageBody)
|
|
||||||
// No FIFO Sequence
|
|
||||||
assert.Empty(t, sendMessageResponse.Result.SequenceNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageV1_Success_FIFOQueue(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_success := models.SendMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
|
||||||
MessageBody: "Test Message",
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageRequest)
|
|
||||||
*v = sendMessageRequest_success
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "new-queue-1",
|
|
||||||
MaximumMessageSize: 1024,
|
|
||||||
IsFIFO: true,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["new-queue-1"] = q
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageV1(r)
|
|
||||||
|
|
||||||
// Check the queue
|
|
||||||
assert.Equal(t, 1, len(q.Messages))
|
|
||||||
msg := q.Messages[0]
|
|
||||||
assert.Equal(t, "Test Message", string(msg.MessageBody))
|
|
||||||
|
|
||||||
// Check the response
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
sendMessageResponse, ok := response.(models.SendMessageResponse)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.NotEmpty(t, sendMessageResponse.Result.MD5OfMessageBody)
|
|
||||||
// Should have FIFO Sequence
|
|
||||||
assert.NotEmpty(t, sendMessageResponse.Result.SequenceNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageV1_Success_Deduplication(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_success := models.SendMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
|
||||||
MessageBody: "Test Message",
|
|
||||||
MessageDeduplicationId: "1",
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageRequest)
|
|
||||||
*v = sendMessageRequest_success
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "new-queue-1",
|
|
||||||
MaximumMessageSize: 1024,
|
|
||||||
IsFIFO: true,
|
|
||||||
EnableDuplicates: true,
|
|
||||||
Duplicates: make(map[string]time.Time),
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["new-queue-1"] = q
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, _ := SendMessageV1(r)
|
|
||||||
|
|
||||||
// Check the queue
|
|
||||||
assert.Equal(t, 1, len(q.Messages))
|
|
||||||
// Check the response
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
|
|
||||||
// Send the same message (have DeduplicationId)
|
|
||||||
status, _ = SendMessageV1(r)
|
|
||||||
// Response is "success"
|
|
||||||
assert.Equal(t, http.StatusOK, status)
|
|
||||||
// Only 1 message should be in the queue
|
|
||||||
assert.Equal(t, 1, len(q.Messages))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageV1_request_transformer_error(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := SendMessageV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageV1_MaximumMessageSize_MessageTooBig(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_success := models.SendMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
|
||||||
MessageBody: "Test Message",
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageRequest)
|
|
||||||
*v = sendMessageRequest_success
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
q := &models.Queue{
|
|
||||||
Name: "new-queue-1",
|
|
||||||
MaximumMessageSize: 1,
|
|
||||||
}
|
|
||||||
models.SyncQueues.Queues["new-queue-1"] = q
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageV1(r)
|
|
||||||
|
|
||||||
// Check the response
|
|
||||||
assert.Equal(t, http.StatusBadRequest, status)
|
|
||||||
errorResponse, ok := response.(models.ErrorResponse)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "MessageTooBig", errorResponse.Result.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMessageV1_POST_QueueNonExistant(t *testing.T) {
|
|
||||||
models.CurrentEnvironment = fixtures.LOCAL_ENVIRONMENT
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
sendMessageRequest_success := models.SendMessageRequest{
|
|
||||||
QueueUrl: "http://localhost:4200/new-queue-1",
|
|
||||||
MessageBody: "Test Message",
|
|
||||||
}
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SendMessageRequest)
|
|
||||||
*v = sendMessageRequest_success
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// No test queue is added to app.SyncQueues
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
status, response := SendMessageV1(r)
|
|
||||||
|
|
||||||
// Check the status code is what we expect.
|
|
||||||
assert.Equal(t, http.StatusBadRequest, status)
|
|
||||||
|
|
||||||
// Check the response body is what we expect.
|
|
||||||
errorResponse, ok := response.(models.ErrorResponse)
|
|
||||||
assert.True(t, ok)
|
|
||||||
assert.Equal(t, "Not Found", errorResponse.Result.Type)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
@@ -37,14 +45,22 @@ func SetQueueAttributesV1(req *http.Request) (int, interfaces.AbstractResponseBo
|
|||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
|
|
||||||
log.Infof("Set Queue Attributes: %s (tenant: %s)", queueName, t.ID)
|
log.Infof("Set Queue Attributes: %s (tenant: %s)", queueName, t.ID)
|
||||||
models.SyncQueues.Lock()
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
defer models.SyncQueues.Unlock()
|
if queue == nil {
|
||||||
queue, ok := models.SyncQueues.Queues[key]
|
|
||||||
if !ok {
|
|
||||||
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 {
|
defer queue.UnlockMu()
|
||||||
|
// Список имён переданных атрибутов — для 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)
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
package gosqs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"shared-sqs/app/test"
|
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
|
||||||
"shared-sqs/app/fixtures"
|
|
||||||
"shared-sqs/app/interfaces"
|
|
||||||
"shared-sqs/app/models"
|
|
||||||
"shared-sqs/app/utils"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_success_multiple_attributes(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
|
||||||
*v = fixtures.SetQueueAttributesRequest
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := SetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
expectedResponse := models.SetQueueAttributesResponse{
|
|
||||||
Xmlns: models.BaseXmlns,
|
|
||||||
Metadata: models.BaseResponseMetadata,
|
|
||||||
}
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, expectedResponse, response)
|
|
||||||
|
|
||||||
actualQueue := models.SyncQueues.Queues["unit-queue1"]
|
|
||||||
assert.Equal(t, 5, actualQueue.VisibilityTimeout)
|
|
||||||
assert.Equal(t, 4, actualQueue.ReceiveMessageWaitTimeSeconds)
|
|
||||||
assert.Equal(t, 1, actualQueue.DelaySeconds)
|
|
||||||
assert.Equal(t, 2, actualQueue.MaximumMessageSize)
|
|
||||||
assert.Equal(t, 3, actualQueue.MessageRetentionPeriod)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_success_single_attribute(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
|
||||||
*v = models.SetQueueAttributesRequest{
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
Attributes: models.QueueAttributes{
|
|
||||||
VisibilityTimeout: 5,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, response := SetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
expectedResponse := models.SetQueueAttributesResponse{
|
|
||||||
Xmlns: models.BaseXmlns,
|
|
||||||
Metadata: models.BaseResponseMetadata,
|
|
||||||
}
|
|
||||||
assert.Equal(t, http.StatusOK, code)
|
|
||||||
assert.Equal(t, expectedResponse, response)
|
|
||||||
|
|
||||||
actualQueue := models.SyncQueues.Queues["unit-queue1"]
|
|
||||||
assert.Equal(t, 5, actualQueue.VisibilityTimeout)
|
|
||||||
assert.Equal(t, 0, actualQueue.ReceiveMessageWaitTimeSeconds)
|
|
||||||
assert.Equal(t, 0, actualQueue.DelaySeconds)
|
|
||||||
assert.Equal(t, 0, actualQueue.MaximumMessageSize)
|
|
||||||
assert.Equal(t, 345600, actualQueue.MessageRetentionPeriod)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_invalid_request_body(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := SetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_missing_queue_url(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
|
||||||
*v = models.SetQueueAttributesRequest{
|
|
||||||
Attributes: models.QueueAttributes{},
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := SetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_missing_expected_queue(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
|
||||||
*v = models.SetQueueAttributesRequest{
|
|
||||||
QueueUrl: "garbage",
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := SetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetQueueAttributesV1_invalid_redrive_queue(t *testing.T) {
|
|
||||||
conf.LoadYamlConfig("../conf/mock-data/mock-config.yaml", "BaseUnitTests")
|
|
||||||
defer func() {
|
|
||||||
models.ResetApp()
|
|
||||||
utils.REQUEST_TRANSFORMER = utils.TransformRequest
|
|
||||||
}()
|
|
||||||
|
|
||||||
utils.REQUEST_TRANSFORMER = func(resultingStruct interfaces.AbstractRequestBody, req *http.Request, emptyRequestValid bool) (success bool) {
|
|
||||||
v := resultingStruct.(*models.SetQueueAttributesRequest)
|
|
||||||
*v = models.SetQueueAttributesRequest{
|
|
||||||
QueueUrl: fmt.Sprintf("%s/%s", fixtures.BASE_URL, "unit-queue1"),
|
|
||||||
Attributes: models.QueueAttributes{
|
|
||||||
RedrivePolicy: models.RedrivePolicy{
|
|
||||||
MaxReceiveCount: 100,
|
|
||||||
DeadLetterTargetArn: fmt.Sprintf("arn:aws:sqs:us-east-1:100010001000:%s", "garbage"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
_, r := test.GenerateRequestInfo("POST", "/", nil, true)
|
|
||||||
code, _ := SetQueueAttributesV1(r)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, code)
|
|
||||||
}
|
|
||||||
@@ -41,13 +41,11 @@ func TagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
|
|
||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
defer models.SyncQueues.Unlock()
|
if queue == nil {
|
||||||
|
|
||||||
queue, exists := models.SyncQueues.Queues[key]
|
|
||||||
if !exists {
|
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
}
|
}
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
|
||||||
// Инициализируем Tags если nil (для старых очередей, созданных без Tags)
|
// Инициализируем Tags если nil (для старых очередей, созданных без Tags)
|
||||||
if queue.Tags == nil {
|
if queue.Tags == nil {
|
||||||
|
|||||||
+26
-16
@@ -4,11 +4,12 @@
|
|||||||
package gosqs
|
package gosqs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"shared-sqs/app/auth"
|
"shared-sqs/app/auth"
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tenantQueueKey — внутренний ключ очереди в SyncQueues в формате "{accessKey}:{queueName}".
|
// tenantQueueKey — внутренний ключ очереди в SyncQueues в формате "{accessKey}:{queueName}".
|
||||||
@@ -17,8 +18,15 @@ func tenantQueueKey(tenantAccessKey, queueName string) string {
|
|||||||
return tenantAccessKey + ":" + queueName
|
return tenantAccessKey + ":" + queueName
|
||||||
}
|
}
|
||||||
|
|
||||||
// getTenantFromContext — извлекает тенанта из request context.
|
// dlqKeyFor — ключ DLQ по ключу исходной очереди (формат "{accessKey}:{name}")
|
||||||
// Возвращает nil если тенант не найден (не должно быть — auth middleware должен это поймать раньше).
|
// и имени DLQ. Используется для персиста перенесённых в DLQ сообщений.
|
||||||
|
func dlqKeyFor(srcKey, dlqName string) string {
|
||||||
|
idx := strings.LastIndex(srcKey, ":")
|
||||||
|
if idx < 0 {
|
||||||
|
return srcKey + ":" + dlqName
|
||||||
|
}
|
||||||
|
return srcKey[:idx] + ":" + dlqName
|
||||||
|
}
|
||||||
func getTenantFromContext(r *http.Request) *tenant.Tenant {
|
func getTenantFromContext(r *http.Request) *tenant.Tenant {
|
||||||
t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant)
|
t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant)
|
||||||
return t
|
return t
|
||||||
@@ -27,6 +35,10 @@ return t
|
|||||||
// tenantQueueURL — формирует URL очереди для тенанта.
|
// tenantQueueURL — формирует URL очереди для тенанта.
|
||||||
// Ловушка #10: QueueUrl ОБЯЗАН содержать tenantID в пути, иначе AWS SDK не сможет send/receive.
|
// Ловушка #10: QueueUrl ОБЯЗАН содержать tenantID в пути, иначе AWS SDK не сможет send/receive.
|
||||||
func tenantQueueURL(t *tenant.Tenant, queueName string) string {
|
func tenantQueueURL(t *tenant.Tenant, queueName string) string {
|
||||||
|
// Публичный адрес (напр. https://sqs.sinefo.ru) — приоритетнее локального формата.
|
||||||
|
if base := strings.TrimRight(models.CurrentEnvironment.ExternalURLBase, "/"); base != "" {
|
||||||
|
return base + "/" + t.ID + "/" + queueName
|
||||||
|
}
|
||||||
host := models.CurrentEnvironment.Host
|
host := models.CurrentEnvironment.Host
|
||||||
port := models.CurrentEnvironment.Port
|
port := models.CurrentEnvironment.Port
|
||||||
region := models.CurrentEnvironment.Region
|
region := models.CurrentEnvironment.Region
|
||||||
@@ -36,21 +48,19 @@ return "http://" + region + "." + host + ":" + port + "/" + t.ID + "/" + queueNa
|
|||||||
return "http://" + host + ":" + port + "/" + t.ID + "/" + queueName
|
return "http://" + host + ":" + port + "/" + t.ID + "/" + queueName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TenantQueueURL — экспортированная обёртка над tenantQueueURL (нужна app/cmd).
|
||||||
|
func TenantQueueURL(t *tenant.Tenant, queueName string) string {
|
||||||
|
return tenantQueueURL(t, queueName)
|
||||||
|
}
|
||||||
|
|
||||||
// tenantQueueARN — формирует ARN очереди для тенанта.
|
// tenantQueueARN — формирует ARN очереди для тенанта.
|
||||||
func tenantQueueARN(t *tenant.Tenant, queueName string) string {
|
func tenantQueueARN(t *tenant.Tenant, queueName string) string {
|
||||||
return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName
|
return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName
|
||||||
}
|
}
|
||||||
|
|
||||||
// countTenantQueues — считает количество очередей тенанта в SyncQueues.
|
// countTenantQueues — считает количество очередей тенанта (через tenant-индекс, O(1)).
|
||||||
// Используется для проверки лимита MaxQueues.
|
// Используется для проверки лимита MaxQueues.
|
||||||
// Вызывать под SyncQueues.RLock().
|
// Вызывать под SyncQueues.RLock()/Lock().
|
||||||
func countTenantQueues(tenantAccessKey string) int {
|
func countTenantQueues(tenantAccessKey string) int {
|
||||||
prefix := tenantAccessKey + ":"
|
return models.SyncQueues.CountTenantQueues(tenantAccessKey)
|
||||||
count := 0
|
|
||||||
for key := range models.SyncQueues.Queues {
|
|
||||||
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package gosqs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"shared-sqs/app/models"
|
||||||
|
"shared-sqs/app/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTenantQueueURL — проверяет формирование QueueUrl: публичный ExternalURLBase
|
||||||
|
// приоритетнее локального формата, пустое значение даёт старый формат.
|
||||||
|
func TestTenantQueueURL(t *testing.T) {
|
||||||
|
old := models.CurrentEnvironment
|
||||||
|
defer func() { models.CurrentEnvironment = old }()
|
||||||
|
|
||||||
|
tt := &tenant.Tenant{ID: "t-abc123"}
|
||||||
|
|
||||||
|
// Публичный адрес; хвостовой слеш обрезается.
|
||||||
|
models.CurrentEnvironment = models.Environment{ExternalURLBase: "https://sqs.sinefo.ru/"}
|
||||||
|
if got, want := tenantQueueURL(tt, "orders"), "https://sqs.sinefo.ru/t-abc123/orders"; got != want {
|
||||||
|
t.Fatalf("external base: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пусто → старый формат с region.
|
||||||
|
models.CurrentEnvironment = models.Environment{Host: "goaws.com", Port: "4100", Region: "us-east-1"}
|
||||||
|
if got, want := tenantQueueURL(tt, "orders"), "http://us-east-1.goaws.com:4100/t-abc123/orders"; got != want {
|
||||||
|
t.Fatalf("local format (region): got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пусто, без region.
|
||||||
|
models.CurrentEnvironment = models.Environment{Host: "localhost", Port: "4100"}
|
||||||
|
if got, want := tenantQueueURL(tt, "orders"), "http://localhost:4100/t-abc123/orders"; got != want {
|
||||||
|
t.Fatalf("local format (no region): got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,13 +41,11 @@ func UntagQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
|
|||||||
|
|
||||||
key := tenantQueueKey(t.AccessKey, queueName)
|
key := tenantQueueKey(t.AccessKey, queueName)
|
||||||
|
|
||||||
models.SyncQueues.Lock()
|
queue := models.SyncQueues.GetLocked(key)
|
||||||
defer models.SyncQueues.Unlock()
|
if queue == nil {
|
||||||
|
|
||||||
queue, exists := models.SyncQueues.Queues[key]
|
|
||||||
if !exists {
|
|
||||||
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
return utils.CreateErrorResponseV1("QueueNotFound", true)
|
||||||
}
|
}
|
||||||
|
defer queue.UnlockMu()
|
||||||
|
|
||||||
if queue.Tags != nil {
|
if queue.Tags != nil {
|
||||||
for _, tagKey := range requestBody.TagKeys {
|
for _, tagKey := range requestBody.TagKeys {
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// app/leader/leader.go
|
||||||
|
// Простейший leader-election для active/passive (Шаг 3, вариант C).
|
||||||
|
//
|
||||||
|
// Лидер держит Redis-лок (ssq:leader) с TTL и продлевает его каждые interval.
|
||||||
|
// Пассивные периодически пытаются взять лок; когда лидер падает, TTL истекает и
|
||||||
|
// один из пассивных становится лидером. Готовность отдаётся через IsLeader().
|
||||||
|
//
|
||||||
|
// Почему так, а не полный вынос состояния (вариант A): дешевле в разы и не требует
|
||||||
|
// переписывания ядра очередей; снимает главную боль «одна ВМ = SPOF». Потолок
|
||||||
|
// (горизонтальное масштабирование) при этом остаётся — см.
|
||||||
|
// doc/thinking/state-extraction-and-ha-2026-10-10.md.
|
||||||
|
//
|
||||||
|
// ВАЖНО: при memory-only (нет Redis) — считаем себя лидером (мы единственные).
|
||||||
|
package leader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"shared-sqs/app/persistence"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Elector — выборы лидера через Redis-лок.
|
||||||
|
type Elector struct {
|
||||||
|
key string
|
||||||
|
id string
|
||||||
|
ttl time.Duration
|
||||||
|
interval time.Duration
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
isLeader bool
|
||||||
|
stopCh chan struct{}
|
||||||
|
stopped bool
|
||||||
|
|
||||||
|
// lastRefresh — время последнего успешного refresh/acquire лока.
|
||||||
|
// Нужен для сброса лидерства при длительном сбое refresh (split-brain).
|
||||||
|
lastRefresh time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New — создаёт Elector. key — ключ лока, id — уникальный id инстанса.
|
||||||
|
func New(key, id string, ttl, interval time.Duration) *Elector {
|
||||||
|
return &Elector{
|
||||||
|
key: key,
|
||||||
|
id: id,
|
||||||
|
ttl: ttl,
|
||||||
|
interval: interval,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
lastRefresh: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start — запускает цикл выборов в фоне (первая попытка — сразу).
|
||||||
|
func (e *Elector) Start() {
|
||||||
|
log.Infof("leader: старт выборов (key=%s, id=%s, ttl=%s)", e.key, e.id, e.ttl)
|
||||||
|
go e.loop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLeader — текущий статус (готовность обслуживать).
|
||||||
|
func (e *Elector) IsLeader() bool {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
return e.isLeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop — останавливает цикл и освобождает лок (если были лидером).
|
||||||
|
func (e *Elector) Stop() {
|
||||||
|
e.mu.Lock()
|
||||||
|
if e.stopped {
|
||||||
|
e.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.stopped = true
|
||||||
|
close(e.stopCh)
|
||||||
|
was := e.isLeader
|
||||||
|
e.mu.Unlock()
|
||||||
|
if was {
|
||||||
|
persistence.ReleaseLock(e.key, e.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Elector) setLeader(v bool) {
|
||||||
|
e.mu.Lock()
|
||||||
|
changed := e.isLeader != v
|
||||||
|
e.isLeader = v
|
||||||
|
e.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
if v {
|
||||||
|
log.Info("leader: инстанс стал ЛИДЕРОМ (активный)")
|
||||||
|
} else {
|
||||||
|
log.Warn("leader: потерян лидерский лок (пассивный)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Elector) loop() {
|
||||||
|
t := time.NewTicker(e.interval)
|
||||||
|
defer t.Stop()
|
||||||
|
e.step()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-e.stopCh:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
e.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Elector) step() {
|
||||||
|
if e.IsLeader() {
|
||||||
|
ok, err := persistence.RefreshLock(e.key, e.id, e.ttl)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("leader: refresh: %v", err)
|
||||||
|
// Refresh не удаётся: если с последнего успеха прошло > TTL — лок
|
||||||
|
// гарантированно истёк, и другой инстанс мог стать лидером (split-brain).
|
||||||
|
// Сбрасываем лидерство (fail-safe), а не остаёмся «лидером» без лока.
|
||||||
|
if time.Since(e.lastRefreshAt()) > e.ttl {
|
||||||
|
e.setLeader(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.markRefreshed()
|
||||||
|
if !ok {
|
||||||
|
e.setLeader(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok, err := persistence.TryAcquireLock(e.key, e.id, e.ttl)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("leader: acquire: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
e.setLeader(true)
|
||||||
|
e.markRefreshed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Elector) lastRefreshAt() time.Time {
|
||||||
|
e.mu.RLock()
|
||||||
|
defer e.mu.RUnlock()
|
||||||
|
return e.lastRefresh
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Elector) markRefreshed() {
|
||||||
|
e.mu.Lock()
|
||||||
|
e.lastRefresh = time.Now()
|
||||||
|
e.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// app/metrics/gauge_updater.go
|
||||||
|
// Периодическое обновление gauge-метрик (количество очередей и сообщений per tenant).
|
||||||
|
// Запускается как горутина из main, обновляет каждые 15 секунд.
|
||||||
|
// Не блокирует SQS-запросы — читает через RLock.
|
||||||
|
// Created: 2026-04-12
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"shared-sqs/app/models"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartGaugeUpdater — запускает горутину, которая каждые interval секунд
|
||||||
|
// пересчитывает количество очередей и сообщений по тенантам для Prometheus gauge.
|
||||||
|
// Останавливается при закрытии канала quit.
|
||||||
|
func StartGaugeUpdater(interval time.Duration, quit <-chan bool) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
updateGauges()
|
||||||
|
case <-quit:
|
||||||
|
log.Debug("metrics: gauge updater stopped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
log.Infof("metrics: gauge updater started, interval=%s", interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateGauges — пересчитывает sqs_queues_count и sqs_messages_count per tenant.
|
||||||
|
// Формат ключа очереди: "{accessKey}:{queueName}" — берём часть до первого ":".
|
||||||
|
func updateGauges() {
|
||||||
|
// Снимок указателей под коротким RLock, затем содержимое — под per-queue RLock
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
snapshot := make(map[string]*models.Queue, len(models.SyncQueues.Queues))
|
||||||
|
for key, queue := range models.SyncQueues.Queues {
|
||||||
|
snapshot[key] = queue
|
||||||
|
}
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
|
|
||||||
|
tenantQueues := make(map[string]int)
|
||||||
|
tenantMessages := make(map[string]int)
|
||||||
|
for key, queue := range snapshot {
|
||||||
|
// Ключ формата "accessKey:queueName" — tenant определяется по accessKey
|
||||||
|
parts := strings.SplitN(key, ":", 2)
|
||||||
|
tenantKey := parts[0]
|
||||||
|
tenantQueues[tenantKey]++
|
||||||
|
queue.RLockMu()
|
||||||
|
tenantMessages[tenantKey] += queue.LiveCount()
|
||||||
|
queue.RUnlockMu()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем старые значения и записываем новые
|
||||||
|
QueuesGauge.Reset()
|
||||||
|
MessagesGauge.Reset()
|
||||||
|
for tenant, count := range tenantQueues {
|
||||||
|
QueuesGauge.WithLabelValues(tenant).Set(float64(count))
|
||||||
|
}
|
||||||
|
for tenant, count := range tenantMessages {
|
||||||
|
MessagesGauge.WithLabelValues(tenant).Set(float64(count))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// app/metrics/metrics.go
|
||||||
|
// Prometheus-совместимые метрики shared-sqs для Victoria Metrics / Grafana.
|
||||||
|
// Отдаёт метрики на /metrics в стандартном формате Prometheus.
|
||||||
|
// VMAgent в кластере скрейпит этот endpoint через VMServiceScrape.
|
||||||
|
// Created: 2026-04-12
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestsTotal — счётчик SQS-запросов по тенанту и операции.
|
||||||
|
// Пример: sqs_requests_total{tenant="t-abc123", operation="SendMessage"} = 42
|
||||||
|
var RequestsTotal = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "sqs_requests_total",
|
||||||
|
Help: "Total number of SQS API requests by tenant and operation",
|
||||||
|
},
|
||||||
|
[]string{"tenant", "operation"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestBytesTotal — суммарный объём тел запросов (Content-Length) по тенанту и операции.
|
||||||
|
// Для биллинга по трафику.
|
||||||
|
var RequestBytesTotal = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "sqs_request_bytes_total",
|
||||||
|
Help: "Total request body bytes by tenant and operation",
|
||||||
|
},
|
||||||
|
[]string{"tenant", "operation"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestDuration — гистограмма latency SQS-операций в секундах.
|
||||||
|
// Бакеты подобраны для типичного SQS: от 1ms до 30s (long polling).
|
||||||
|
var RequestDuration = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "sqs_request_duration_seconds",
|
||||||
|
Help: "SQS API request duration in seconds",
|
||||||
|
Buckets: []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 20, 30},
|
||||||
|
},
|
||||||
|
[]string{"operation"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorsTotal — счётчик ошибочных SQS-ответов (HTTP >= 400) по операции.
|
||||||
|
var ErrorsTotal = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "sqs_errors_total",
|
||||||
|
Help: "Total number of SQS API error responses by operation",
|
||||||
|
},
|
||||||
|
[]string{"operation"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// QueuesGauge — текущее количество очередей по тенанту.
|
||||||
|
// Обновляется периодически, не на каждый запрос.
|
||||||
|
var QueuesGauge = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "sqs_queues_count",
|
||||||
|
Help: "Current number of queues per tenant",
|
||||||
|
},
|
||||||
|
[]string{"tenant"},
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessagesGauge — текущее количество сообщений по тенанту.
|
||||||
|
// Обновляется периодически, не на каждый запрос.
|
||||||
|
var MessagesGauge = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "sqs_messages_count",
|
||||||
|
Help: "Current number of messages per tenant",
|
||||||
|
},
|
||||||
|
[]string{"tenant"},
|
||||||
|
)
|
||||||
@@ -23,6 +23,7 @@ Port string
|
|||||||
SqsPort string
|
SqsPort string
|
||||||
Region string
|
Region string
|
||||||
AccountID string
|
AccountID string
|
||||||
|
ExternalURLBase string
|
||||||
LogToFile bool
|
LogToFile bool
|
||||||
LogFile string
|
LogFile string
|
||||||
EnableDuplicates bool
|
EnableDuplicates bool
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|||||||
@@ -34,3 +34,31 @@ func (s *StringToInt) UnmarshalJSON(data []byte) error {
|
|||||||
func (s *StringToInt) Int() int {
|
func (s *StringToInt) Int() int {
|
||||||
return int(*s)
|
return int(*s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StringToBool — аналог StringToInt для булевых атрибутов: принимает и JSON-bool
|
||||||
|
// (true/false), и строку ("true"/"false"). Query-протокол шлёт строки,
|
||||||
|
// JSON-протокол — булевы значения; без этого FifoQueue/ContentBasedDeduplication
|
||||||
|
// из JSON-запроса молча терялись бы.
|
||||||
|
type StringToBool bool
|
||||||
|
|
||||||
|
func (s *StringToBool) UnmarshalJSON(data []byte) error {
|
||||||
|
var b bool
|
||||||
|
if err := json.Unmarshal(data, &b); err == nil {
|
||||||
|
*s = StringToBool(b)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(data, &str); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp, err := strconv.ParseBool(str)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = StringToBool(tmp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StringToBool) Bool() bool {
|
||||||
|
return bool(*s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
+9
-29
@@ -6,7 +6,7 @@ func init() {
|
|||||||
SqsErrors = map[string]SqsErrorType{
|
SqsErrors = map[string]SqsErrorType{
|
||||||
"QueueNotFound": {HttpError: http.StatusBadRequest, Type: "Not Found", Code: "AWS.SimpleQueueService.NonExistentQueue", Message: "The specified queue does not exist for this wsdl version."},
|
"QueueNotFound": {HttpError: http.StatusBadRequest, Type: "Not Found", Code: "AWS.SimpleQueueService.NonExistentQueue", Message: "The specified queue does not exist for this wsdl version."},
|
||||||
"QueueExists": {HttpError: http.StatusBadRequest, Type: "Duplicate", Code: "AWS.SimpleQueueService.QueueExists", Message: "The specified queue already exists."},
|
"QueueExists": {HttpError: http.StatusBadRequest, Type: "Duplicate", Code: "AWS.SimpleQueueService.QueueExists", Message: "The specified queue already exists."},
|
||||||
"MessageDoesNotExist": {HttpError: http.StatusNotFound, Type: "Not Found", Code: "AWS.SimpleQueueService.QueueExists", Message: "The specified queue does not contain the message specified."},
|
"MessageDoesNotExist": {HttpError: http.StatusNotFound, Type: "Not Found", Code: "ReceiptHandleIsInvalid", Message: "The specified receipt handle is not valid."},
|
||||||
"GeneralError": {HttpError: http.StatusBadRequest, Type: "GeneralError", Code: "AWS.SimpleQueueService.GeneralError", Message: "General Error."},
|
"GeneralError": {HttpError: http.StatusBadRequest, Type: "GeneralError", Code: "AWS.SimpleQueueService.GeneralError", Message: "General Error."},
|
||||||
"TooManyEntriesInBatchRequest": {HttpError: http.StatusBadRequest, Type: "TooManyEntriesInBatchRequest", Code: "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", Message: "Maximum number of entries per request are 10."},
|
"TooManyEntriesInBatchRequest": {HttpError: http.StatusBadRequest, Type: "TooManyEntriesInBatchRequest", Code: "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", Message: "Maximum number of entries per request are 10."},
|
||||||
"BatchEntryIdsNotDistinct": {HttpError: http.StatusBadRequest, Type: "BatchEntryIdsNotDistinct", Code: "AWS.SimpleQueueService.BatchEntryIdsNotDistinct", Message: "Two or more batch entries in the request have the same Id."},
|
"BatchEntryIdsNotDistinct": {HttpError: http.StatusBadRequest, Type: "BatchEntryIdsNotDistinct", Code: "AWS.SimpleQueueService.BatchEntryIdsNotDistinct", Message: "Two or more batch entries in the request have the same Id."},
|
||||||
@@ -24,17 +24,14 @@ 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)
|
||||||
SnsErrors = map[string]SnsErrorType{
|
"InvalidAttributeName": {HttpError: http.StatusBadRequest, Type: "InvalidAttributeName", Code: "AWS.SimpleQueueService.InvalidAttributeName", Message: "Unknown Attribute."},
|
||||||
"InvalidParameterValue": {HttpError: http.StatusBadRequest, Type: "InvalidParameterValue", Code: "AWS.SimpleNotificationService.InvalidParameterValue", Message: "An invalid or out-of-range value was supplied for the input parameter."},
|
// PurgeQueueInProgress — повторный PurgeQueue на очереди ранее чем через 60 секунд
|
||||||
"TopicNotFound": {HttpError: http.StatusBadRequest, Type: "Not Found", Code: "AWS.SimpleNotificationService.NonExistentTopic", Message: "The specified topic does not exist for this wsdl version."},
|
"PurgeQueueInProgress": {HttpError: http.StatusForbidden, Type: "Sender", Code: "AWS.SimpleQueueService.PurgeQueueInProgress", Message: "Only one PurgeQueue operation on a queue is allowed every 60 seconds."},
|
||||||
"SubscriptionNotFound": {HttpError: http.StatusNotFound, Type: "Not Found", Code: "AWS.SimpleNotificationService.NonExistentSubscription", Message: "The specified subscription does not exist for this wsdl version."},
|
// OverLimit — превышен лимит сообщений в очереди (OOM-защита)
|
||||||
"TopicExists": {HttpError: http.StatusBadRequest, Type: "Duplicate", Code: "AWS.SimpleNotificationService.TopicAlreadyExists", Message: "The specified topic already exists."},
|
"OverLimit": {HttpError: http.StatusBadRequest, Type: "OverLimit", Code: "OverLimit", Message: "The queue contains the maximum number of messages."},
|
||||||
"ValidationError": {HttpError: http.StatusBadRequest, Type: "InvalidParameter", Code: "AWS.SimpleNotificationService.ValidationError", Message: "The input fails to satisfy the constraints specified by an AWS service."},
|
// InternalError — внутренняя ошибка сервера (например, не удалось персистить сообщение)
|
||||||
"BatchEntryIdsNotDistinct": {HttpError: http.StatusBadRequest, Type: "BatchEntryIdsNotDistinct", Code: "AWS.SimpleNotificationService.BatchEntryIdsNotDistinct", Message: "Two or more batch entries in the request have the same Id."},
|
"InternalError": {HttpError: http.StatusInternalServerError, Type: "InternalError", Code: "AWS.SimpleQueueService.InternalError", Message: "We encountered an internal error. Please try again."},
|
||||||
"EmptyBatchRequest": {HttpError: http.StatusBadRequest, Type: "EmptyBatchRequest", Code: "AWS.SimpleNotificationService.EmptyBatchRequest", Message: "The batch request doesn't contain any entries."},
|
|
||||||
"TooManyEntriesInBatchRequest": {HttpError: http.StatusBadRequest, Type: "TooManyEntriesInBatchRequest", Code: "AWS.SimpleNotificationService.TooManyEntriesInBatchRequest", Message: "Maximum number of entries per request are 10."},
|
|
||||||
"MalformedInput": {HttpError: http.StatusBadRequest, Type: "Sender", Code: "AWS.SimpleNotificationService.MalformedInput", Message: "Invalid Base64 encoding"},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,20 +51,3 @@ func (s SqsErrorType) Response() ErrorResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var SqsErrors map[string]SqsErrorType
|
var SqsErrors map[string]SqsErrorType
|
||||||
|
|
||||||
type SnsErrorType struct {
|
|
||||||
HttpError int
|
|
||||||
Type string
|
|
||||||
Code string
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s SnsErrorType) StatusCode() int {
|
|
||||||
return s.HttpError
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s SnsErrorType) Response() ErrorResult {
|
|
||||||
return ErrorResult{Type: s.Type, Code: s.Code, Message: s.Message}
|
|
||||||
}
|
|
||||||
|
|
||||||
var SnsErrors map[string]SnsErrorType
|
|
||||||
|
|||||||
+103
-3
@@ -1,6 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +18,106 @@ var CurrentEnvironment = Environment{
|
|||||||
var LogMessages bool
|
var LogMessages bool
|
||||||
var LogFile string
|
var LogFile string
|
||||||
|
|
||||||
var SyncQueues = struct {
|
// QueueStore — хранилище очередей: блокировка + map ключ→очередь + индекс tenant→очереди.
|
||||||
|
//
|
||||||
|
// Ключ очереди в Queues — "{accessKey}:{queueName}" (tenant-scoped изоляция).
|
||||||
|
// Индекс tenantQueues (accessKey → набор имён очередей) позволяет считать и
|
||||||
|
// перечислять очереди тенанта за O(число его очередей), без скана всех очередей сервиса.
|
||||||
|
//
|
||||||
|
// ВАЖНО: методы AddQueue/RemoveQueue/CountTenantQueues/TenantQueueNames НЕ берут
|
||||||
|
// блокировку — вызывать под уже захваченным SyncQueues.Lock()/RLock()
|
||||||
|
// (соответствует существующей дисциплине блокировок в коде).
|
||||||
|
type QueueStore struct {
|
||||||
sync.RWMutex
|
sync.RWMutex
|
||||||
Queues map[string]*Queue
|
Queues map[string]*Queue
|
||||||
}{Queues: make(map[string]*Queue)}
|
tenantQueues map[string]map[string]struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var SyncQueues = QueueStore{
|
||||||
|
Queues: make(map[string]*Queue),
|
||||||
|
tenantQueues: make(map[string]map[string]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitQueueKey — разбирает tenant-scoped ключ "{accessKey}:{queueName}".
|
||||||
|
// Легаси-ключи без ":" (yaml-очереди) tenant-независимы → ok=false.
|
||||||
|
func splitQueueKey(key string) (accessKey, queueName string, ok bool) {
|
||||||
|
idx := strings.Index(key, ":")
|
||||||
|
if idx <= 0 || idx >= len(key)-1 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return key[:idx], key[idx+1:], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddQueue — добавляет очередь и обновляет tenant-индекс.
|
||||||
|
// Вызывать под SyncQueues.Lock().
|
||||||
|
func (s *QueueStore) AddQueue(key string, q *Queue) {
|
||||||
|
s.Queues[key] = q
|
||||||
|
if ak, name, ok := splitQueueKey(key); ok {
|
||||||
|
if s.tenantQueues[ak] == nil {
|
||||||
|
s.tenantQueues[ak] = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
s.tenantQueues[ak][name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveQueue — удаляет очередь и обновляет tenant-индекс.
|
||||||
|
// Вызывать под SyncQueues.Lock().
|
||||||
|
func (s *QueueStore) RemoveQueue(key string) {
|
||||||
|
delete(s.Queues, key)
|
||||||
|
if ak, name, ok := splitQueueKey(key); ok {
|
||||||
|
if set, exists := s.tenantQueues[ak]; exists {
|
||||||
|
delete(set, name)
|
||||||
|
if len(set) == 0 {
|
||||||
|
delete(s.tenantQueues, ak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocked — возвращает очередь с захваченной per-queue write-блокировкой.
|
||||||
|
// nil если очереди нет. Вызывающий ОБЯЗАН вызвать q.UnlockMu().
|
||||||
|
func (s *QueueStore) GetLocked(key string) *Queue {
|
||||||
|
s.RLock()
|
||||||
|
q := s.Queues[key]
|
||||||
|
s.RUnlock()
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
q.LockMu()
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRLocked — то же, но read-блокировка. Вызывающий ОБЯЗАН вызвать q.RUnlockMu().
|
||||||
|
func (s *QueueStore) GetRLocked(key string) *Queue {
|
||||||
|
s.RLock()
|
||||||
|
q := s.Queues[key]
|
||||||
|
s.RUnlock()
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
q.RLockMu()
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountTenantQueues — число очередей тенанта (по accessKey) за O(1).
|
||||||
|
// Вызывать под SyncQueues.RLock() или Lock().
|
||||||
|
func (s *QueueStore) CountTenantQueues(accessKey string) int {
|
||||||
|
return len(s.tenantQueues[accessKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantQueueNames — копия списка имён очередей тенанта.
|
||||||
|
// Вызывать под SyncQueues.RLock() или Lock().
|
||||||
|
func (s *QueueStore) TenantQueueNames(accessKey string) []string {
|
||||||
|
set := s.tenantQueues[accessKey]
|
||||||
|
names := make([]string, 0, len(set))
|
||||||
|
for n := range set {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetQueueIndex — очищает tenant-индекс (для ResetResources/тестов).
|
||||||
|
// Вызывать под SyncQueues.Lock().
|
||||||
|
func (s *QueueStore) ResetQueueIndex() {
|
||||||
|
s.tenantQueues = make(map[string]map[string]struct{})
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func ResetApp() {
|
|||||||
func ResetResources() {
|
func ResetResources() {
|
||||||
SyncQueues.Lock()
|
SyncQueues.Lock()
|
||||||
SyncQueues.Queues = make(map[string]*Queue)
|
SyncQueues.Queues = make(map[string]*Queue)
|
||||||
|
SyncQueues.ResetQueueIndex()
|
||||||
SyncQueues.Unlock()
|
SyncQueues.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// app/models/message_index_test.go
|
||||||
|
// Тесты индексации сообщений (P1.2): tombstone-удаление, компакция, индексы
|
||||||
|
// receipt/uuid. Проверяют ключевые свойства, на которых держится O(1)-удаление
|
||||||
|
// и O(in-flight) visibility-перерасчёт.
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestQueue — очередь с последовательно добавленными сообщениями.
|
||||||
|
func newTestQueue(uuids ...string) *Queue {
|
||||||
|
q := &Queue{Name: "t", Duplicates: map[string]time.Time{}}
|
||||||
|
for _, u := range uuids {
|
||||||
|
q.AppendMessage(SqsMessage{Uuid: u, MessageBody: u})
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueue_RemoveIsTombstoneO1 — удаление не двигает срез и снимает индексы.
|
||||||
|
func TestQueue_RemoveIsTombstoneO1(t *testing.T) {
|
||||||
|
q := newTestQueue("a", "b", "c")
|
||||||
|
if q.LiveCount() != 3 {
|
||||||
|
t.Fatalf("LiveCount = %d, want 3", q.LiveCount())
|
||||||
|
}
|
||||||
|
if i, ok := q.IndexOfUUID("b"); !ok || i != 1 {
|
||||||
|
t.Fatalf("IndexOfUUID(b) = %d,%v; want 1,true", i, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
q.RemoveIndex(1)
|
||||||
|
|
||||||
|
if q.LiveCount() != 2 {
|
||||||
|
t.Fatalf("LiveCount после удаления = %d, want 2", q.LiveCount())
|
||||||
|
}
|
||||||
|
if !q.IsDead(1) {
|
||||||
|
t.Fatal("слот 1 должен быть tombstone")
|
||||||
|
}
|
||||||
|
if len(q.Messages) != 3 {
|
||||||
|
t.Fatalf("физический размер среза изменился (%d) — удаление должно быть tombstone O(1)", len(q.Messages))
|
||||||
|
}
|
||||||
|
if _, ok := q.IndexOfUUID("b"); ok {
|
||||||
|
t.Fatal("удалённый Uuid не должен находиться в индексе")
|
||||||
|
}
|
||||||
|
// Оставшиеся индексы не должны «съехать».
|
||||||
|
if i, ok := q.IndexOfUUID("c"); !ok || i != 2 {
|
||||||
|
t.Fatalf("IndexOfUUID(c) = %d,%v; want 2,true", i, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueue_CompactKeepsOrder — компакция сохраняет порядок живых и перестраивает индексы.
|
||||||
|
func TestQueue_CompactKeepsOrder(t *testing.T) {
|
||||||
|
q := newTestQueue("a", "b", "c", "d")
|
||||||
|
// Помечаем сообщение "c" in-flight, чтобы проверить сохранение receipt-индекса.
|
||||||
|
q.Messages[2].ReceiptHandle = "c#1"
|
||||||
|
q.MarkInFlight(2, "c#1")
|
||||||
|
|
||||||
|
q.RemoveIndex(1) // b
|
||||||
|
q.RemoveIndex(3) // d
|
||||||
|
q.Compact()
|
||||||
|
|
||||||
|
if q.LiveCount() != 2 || len(q.Messages) != 2 {
|
||||||
|
t.Fatalf("после компакции LiveCount=%d len=%d, want 2/2", q.LiveCount(), len(q.Messages))
|
||||||
|
}
|
||||||
|
if q.deadCount != 0 {
|
||||||
|
t.Fatalf("deadCount после компакции = %d, want 0", q.deadCount)
|
||||||
|
}
|
||||||
|
if q.Messages[0].Uuid != "a" || q.Messages[1].Uuid != "c" {
|
||||||
|
t.Fatalf("порядок нарушен: %q, %q", q.Messages[0].Uuid, q.Messages[1].Uuid)
|
||||||
|
}
|
||||||
|
if i, ok := q.IndexOfUUID("c"); !ok || i != 1 {
|
||||||
|
t.Fatalf("IndexOfUUID(c) после компакции = %d,%v; want 1,true", i, ok)
|
||||||
|
}
|
||||||
|
if i, ok := q.IndexOfReceipt("c#1"); !ok || i != 1 {
|
||||||
|
t.Fatalf("IndexOfReceipt(c#1) после компакции = %d,%v; want 1,true", i, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueue_ReceiptIndexLifecycle — регистрация/снятие in-flight.
|
||||||
|
func TestQueue_ReceiptIndexLifecycle(t *testing.T) {
|
||||||
|
q := newTestQueue("a")
|
||||||
|
q.Messages[0].ReceiptHandle = "a#1"
|
||||||
|
q.MarkInFlight(0, "a#1")
|
||||||
|
|
||||||
|
if i, ok := q.IndexOfReceipt("a#1"); !ok || i != 0 {
|
||||||
|
t.Fatalf("IndexOfReceipt = %d,%v; want 0,true", i, ok)
|
||||||
|
}
|
||||||
|
if got := len(q.InFlightIndexes()); got != 1 {
|
||||||
|
t.Fatalf("InFlightIndexes len = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
q.ClearInFlight(0, "a#1")
|
||||||
|
if _, ok := q.IndexOfReceipt("a#1"); ok {
|
||||||
|
t.Fatal("снятый handle не должен находиться в индексе")
|
||||||
|
}
|
||||||
|
if got := len(q.InFlightIndexes()); got != 0 {
|
||||||
|
t.Fatalf("InFlightIndexes len = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueue_RebuildIndexesAfterDirectReplace — защита от прямой замены среза
|
||||||
|
// (загрузка из Redis / тесты): индексы восстанавливаются по содержимому.
|
||||||
|
func TestQueue_RebuildIndexesAfterDirectReplace(t *testing.T) {
|
||||||
|
q := newTestQueue("a")
|
||||||
|
q.Messages = []SqsMessage{{Uuid: "x"}, {Uuid: "y", ReceiptHandle: "y#1"}}
|
||||||
|
q.RebuildIndexes()
|
||||||
|
|
||||||
|
if q.LiveCount() != 2 {
|
||||||
|
t.Fatalf("LiveCount = %d, want 2", q.LiveCount())
|
||||||
|
}
|
||||||
|
if i, ok := q.IndexOfUUID("y"); !ok || i != 1 {
|
||||||
|
t.Fatalf("IndexOfUUID(y) = %d,%v; want 1,true", i, ok)
|
||||||
|
}
|
||||||
|
if i, ok := q.IndexOfReceipt("y#1"); !ok || i != 1 {
|
||||||
|
t.Fatalf("IndexOfReceipt(y#1) = %d,%v; want 1,true", i, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueue_CompactIfNeededThreshold — пороговая компакция чистит tombstone
|
||||||
|
// и сохраняет число живых.
|
||||||
|
func TestQueue_CompactIfNeededThreshold(t *testing.T) {
|
||||||
|
q := &Queue{Name: "t", Duplicates: map[string]time.Time{}}
|
||||||
|
const total = 5000
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
q.AppendMessage(SqsMessage{Uuid: fmt.Sprintf("u%d", i)})
|
||||||
|
}
|
||||||
|
for i := 0; i < 2000; i++ {
|
||||||
|
q.RemoveIndex(i)
|
||||||
|
}
|
||||||
|
if q.deadCount != 2000 {
|
||||||
|
t.Fatalf("deadCount = %d, want 2000", q.deadCount)
|
||||||
|
}
|
||||||
|
q.CompactIfNeeded()
|
||||||
|
if q.deadCount != 0 || len(q.Messages) != total-2000 {
|
||||||
|
t.Fatalf("после CompactIfNeeded deadCount=%d len=%d, want 0/%d", q.deadCount, len(q.Messages), total-2000)
|
||||||
|
}
|
||||||
|
if q.LiveCount() != total-2000 {
|
||||||
|
t.Fatalf("LiveCount = %d, want %d", q.LiveCount(), total-2000)
|
||||||
|
}
|
||||||
|
// Порядок живых сохранён: первый живой — u2000.
|
||||||
|
if q.Messages[0].Uuid != "u2000" {
|
||||||
|
t.Fatalf("первый живой = %q, want u2000", q.Messages[0].Uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestQueue_Reset — purge полностью очищает сообщения и индексы.
|
||||||
|
func TestQueue_Reset(t *testing.T) {
|
||||||
|
q := newTestQueue("a", "b")
|
||||||
|
q.RemoveIndex(0)
|
||||||
|
q.Reset()
|
||||||
|
|
||||||
|
if q.LiveCount() != 0 || len(q.Messages) != 0 || q.deadCount != 0 {
|
||||||
|
t.Fatalf("после Reset: LiveCount=%d len=%d deadCount=%d", q.LiveCount(), len(q.Messages), q.deadCount)
|
||||||
|
}
|
||||||
|
if len(q.InFlightIndexes()) != 0 {
|
||||||
|
t.Fatal("после Reset in-flight индекс должен быть пуст")
|
||||||
|
}
|
||||||
|
// Очередь остаётся работоспособной.
|
||||||
|
if idx := q.AppendMessage(SqsMessage{Uuid: "c"}); idx != 0 || q.LiveCount() != 1 {
|
||||||
|
t.Fatalf("после Reset append: idx=%d LiveCount=%d", idx, q.LiveCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
+379
-21
@@ -2,6 +2,7 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
@@ -46,6 +47,11 @@ func (m *SqsMessage) IsReadyForReceipt() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Queue struct {
|
type Queue struct {
|
||||||
|
// mu — per-queue блокировка содержимого очереди (Messages, Duplicates, FIFO-
|
||||||
|
// состояние, LastPurgeTime). Заменяет удержание глобального SyncQueues.Lock
|
||||||
|
// на время операций с одной очередью. sync.RWMutex сериализации не подлежит.
|
||||||
|
mu sync.RWMutex `json:"-"`
|
||||||
|
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
Arn string
|
Arn string
|
||||||
@@ -53,50 +59,113 @@ type Queue struct {
|
|||||||
ReceiveMessageWaitTimeSeconds int
|
ReceiveMessageWaitTimeSeconds int
|
||||||
DelaySeconds int
|
DelaySeconds int
|
||||||
MaximumMessageSize int
|
MaximumMessageSize int
|
||||||
MessageRetentionPeriod int // seconds // TODO - not used in the code yet
|
MessageRetentionPeriod int // seconds (реализовано в processQueueTick)
|
||||||
Messages []SqsMessage
|
Messages []SqsMessage
|
||||||
DeadLetterQueue *Queue
|
// ─── Индексация сообщений (P1.2) ────────────────────────────────────────
|
||||||
MaxReceiveCount int
|
// deadIdx — параллельный Messages слайс tombstone-флагов: true = слот
|
||||||
IsFIFO bool
|
// логически удалён. Удаление НЕ сдвигает элементы (O(1)), поэтому индексы
|
||||||
FIFOMessages map[string]int
|
// (receiptIdx/uuidIdx) остаются валидными; физическая чистка — Compact.
|
||||||
FIFOSequenceNumbers map[string]int
|
// Менять Messages напрямую НЕЛЬЗЯ: только через AppendMessage/RemoveIndex/
|
||||||
EnableDuplicates bool
|
// Compact/Reset (см. ниже). Прямая замена допустима лишь для полной очистки
|
||||||
Duplicates map[string]time.Time
|
// и загрузки из Redis (с последующим RebuildIndexes).
|
||||||
Tags map[string]string
|
deadIdx []bool `json:"-"`
|
||||||
|
// receiptIdx — ReceiptHandle → индекс в Messages (только in-flight сообщения).
|
||||||
|
receiptIdx map[string]int `json:"-"`
|
||||||
|
// uuidIdx — Uuid → индекс в Messages (только живые сообщения).
|
||||||
|
uuidIdx map[string]int `json:"-"`
|
||||||
|
// liveCount — число живых сообщений; deadCount — число tombstone-слотов.
|
||||||
|
liveCount int `json:"-"`
|
||||||
|
deadCount int `json:"-"`
|
||||||
|
DeadLetterQueue *Queue
|
||||||
|
// DeadLetterQueueName — имя DLQ. В Redis-метаданных хранится ТОЛЬКО имя
|
||||||
|
// (а не вложенный объект): после загрузки указатель DeadLetterQueue
|
||||||
|
// релинкуется на объект из общей map (см. persistence.LoadAllQueues).
|
||||||
|
DeadLetterQueueName string `json:"dead_letter_queue_name,omitempty"`
|
||||||
|
MaxReceiveCount int
|
||||||
|
IsFIFO bool
|
||||||
|
FIFOMessages map[string]int
|
||||||
|
FIFOSequenceNumbers map[string]int
|
||||||
|
EnableDuplicates bool
|
||||||
|
Duplicates map[string]time.Time
|
||||||
|
Tags map[string]string
|
||||||
|
// LastPurgeTime — время последнего успешного PurgeQueue.
|
||||||
|
// Используется для запрета повторного purge ранее чем через 60 секунд
|
||||||
|
// (требование AWS: PurgeQueueInProgress). Нулевое значение = purge ещё не выполнялся.
|
||||||
|
LastPurgeTime time.Time
|
||||||
|
// CreatedAt/LastModifiedAt — для атрибутов CreatedTimestamp/LastModifiedTimestamp
|
||||||
|
// (AWS отдаёт их в GetQueueAttributes; Terraform читает и без них видел бы drift).
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastModifiedAt time.Time
|
||||||
|
// Policy — resource policy (JSON-строка), храним и отдаём как есть для
|
||||||
|
// совместимости с Terraform. НЕ исполняется (нет enforcement).
|
||||||
|
Policy string
|
||||||
|
// ContentBasedDeduplication — FIFO-атрибут. Храним и отдаём значение,
|
||||||
|
// переданное клиентом (для совместимости), см. раздел про dedup.
|
||||||
|
ContentBasedDeduplication bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queue) NextSequenceNumber(groupId string) string {
|
// LockMu/UnlockMu/RLockMu/RUnlockMu — доступ к per-queue блокировке извне пакета models
|
||||||
if _, ok := q.FIFOSequenceNumbers[groupId]; !ok {
|
// (поле mu неэкспортируемо).
|
||||||
q.FIFOSequenceNumbers = map[string]int{
|
func (q *Queue) LockMu() { q.mu.Lock() }
|
||||||
groupId: 0,
|
func (q *Queue) UnlockMu() { q.mu.Unlock() }
|
||||||
}
|
func (q *Queue) RLockMu() { q.mu.RLock() }
|
||||||
}
|
func (q *Queue) RUnlockMu() { q.mu.RUnlock() }
|
||||||
|
|
||||||
|
// NextSequenceNumber — возвращает следующий порядковый номер сообщения FIFO-группы.
|
||||||
|
//
|
||||||
|
// Логическая схема: каждый вызов инкрементирует счётчик группы и возвращает
|
||||||
|
// его строковое представление (порядковые номера в FIFO должны расти монотонно).
|
||||||
|
//
|
||||||
|
// ВАЖНО (фикс): раньше при ПЕРВОМ обращении к группе map пересоздавалась целиком
|
||||||
|
// (q.FIFOSequenceNumbers = map[string]int{groupId: 0}) — счётчики ВСЕХ остальных
|
||||||
|
// групп терялись, и порядковые номера начинали дублироваться. Теперь map только
|
||||||
|
// инициализируется один раз (если nil), а счётчик конкретной группы инкрементируется.
|
||||||
|
func (q *Queue) NextSequenceNumber(groupId string) string {
|
||||||
|
if q.FIFOSequenceNumbers == nil {
|
||||||
|
q.FIFOSequenceNumbers = make(map[string]int)
|
||||||
|
}
|
||||||
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-окна (5 минут).
|
||||||
|
// Дедупликация — НЕОТЪЕМЛЕМОЕ свойство FIFO-очереди (AWS): раньше она зависела
|
||||||
|
// от env-флага EnableDuplicates=false по умолчанию, и FIFO-очереди не
|
||||||
|
// дедуплицировали вообще. Теперь — только по IsFIFO и непустому id.
|
||||||
func (q *Queue) IsDuplicate(deduplicationId string) bool {
|
func (q *Queue) IsDuplicate(deduplicationId string) bool {
|
||||||
if !q.EnableDuplicates || !q.IsFIFO || deduplicationId == "" {
|
if !q.IsFIFO || deduplicationId == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +174,301 @@ 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.IsFIFO || deduplicationId == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ленивая инициализация: очередь, собранная литералом или загруженная из
|
||||||
|
// Redis до появления поля, может иметь nil-map — иначе запись паникует.
|
||||||
|
if q.Duplicates == nil {
|
||||||
|
q.Duplicates = make(map[string]time.Time)
|
||||||
|
}
|
||||||
if _, ok := q.Duplicates[deduplicationId]; !ok {
|
if _, ok := q.Duplicates[deduplicationId]; !ok {
|
||||||
q.Duplicates[deduplicationId] = time.Now()
|
q.Duplicates[deduplicationId] = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── P1.2: индексация сообщений (tombstone + индексы) ──────────────────────
|
||||||
|
//
|
||||||
|
// Все методы ниже мутируют состояние очереди → вызывать ТОЛЬКО под
|
||||||
|
// per-queue write-блокировкой (queue.LockMu). Читающие методы (LiveCount,
|
||||||
|
// IsDead) безопасны под RLock.
|
||||||
|
|
||||||
|
// compactDeadThreshold — минимальное число tombstone-слотов, после которого
|
||||||
|
// компакция при удалении разрешена. Защищает от O(N)-чистки на мелких очередях:
|
||||||
|
// при удалении слот подрезается с хвоста, остальная чистка — в processQueueTick.
|
||||||
|
const compactDeadThreshold = 1024
|
||||||
|
|
||||||
|
// ensureIndexes — ленивая инициализация/починка индексов.
|
||||||
|
// При корректном состоянии — две дешёвые проверки; O(N) — только если состояние
|
||||||
|
// рассинхронизировалось (прямая замена Messages вне методов, загрузка из Redis,
|
||||||
|
// старый JSON-формат). ВАЖНО (инвариант): оценка «в порядке» проверяет лишь
|
||||||
|
// согласованность чисел и не перестраивает уже корректные map-индексы — поэтому
|
||||||
|
// мутировать Messages ВНЕ методов (AppendMessage/RemoveIndex/Compact/Reset),
|
||||||
|
// например менять ReceiptHandle на месте, НЕЛЬЗЯ.
|
||||||
|
// Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) ensureIndexes() {
|
||||||
|
if len(q.deadIdx) == len(q.Messages) &&
|
||||||
|
q.receiptIdx != nil && q.uuidIdx != nil &&
|
||||||
|
q.liveCount == len(q.Messages)-q.deadCount {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q.RebuildIndexes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildIndexes — перестраивает deadIdx/receiptIdx/uuidIdx/liveCount/deadCount
|
||||||
|
// по текущему Messages. Существующие tombstone СОХРАНЯЮТСЯ по позициям (если
|
||||||
|
// deadIdx длиннее Messages — обрезается, короче — добивается false).
|
||||||
|
// Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) RebuildIndexes() {
|
||||||
|
if len(q.deadIdx) > len(q.Messages) {
|
||||||
|
q.deadIdx = q.deadIdx[:len(q.Messages)]
|
||||||
|
}
|
||||||
|
for len(q.deadIdx) < len(q.Messages) {
|
||||||
|
q.deadIdx = append(q.deadIdx, false)
|
||||||
|
}
|
||||||
|
q.receiptIdx = make(map[string]int, len(q.Messages))
|
||||||
|
q.uuidIdx = make(map[string]int, len(q.Messages))
|
||||||
|
q.liveCount = 0
|
||||||
|
q.deadCount = 0
|
||||||
|
for i := range q.Messages {
|
||||||
|
if q.deadIdx[i] {
|
||||||
|
q.deadCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q.liveCount++
|
||||||
|
if u := q.Messages[i].Uuid; u != "" {
|
||||||
|
q.uuidIdx[u] = i
|
||||||
|
}
|
||||||
|
if rh := q.Messages[i].ReceiptHandle; rh != "" {
|
||||||
|
q.receiptIdx[rh] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendMessage — добавляет сообщение в конец очереди, поддерживая индексы.
|
||||||
|
// Возвращает индекс добавленного сообщения. Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) AppendMessage(m SqsMessage) int {
|
||||||
|
q.ensureIndexes()
|
||||||
|
q.Messages = append(q.Messages, m)
|
||||||
|
q.deadIdx = append(q.deadIdx, false)
|
||||||
|
idx := len(q.Messages) - 1
|
||||||
|
q.liveCount++
|
||||||
|
if m.Uuid != "" {
|
||||||
|
q.uuidIdx[m.Uuid] = idx
|
||||||
|
}
|
||||||
|
if m.ReceiptHandle != "" {
|
||||||
|
q.receiptIdx[m.ReceiptHandle] = idx
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveIndex — логически удаляет сообщение по индексу (tombstone) за O(1).
|
||||||
|
// ВАЖНО: длина q.Messages НЕ меняется (слот остаётся tombstone) — поэтому
|
||||||
|
// индексы, взятые до вызова, остаются валидными, а итерации по срезу
|
||||||
|
// безопасны. Физическая чистка — Compact/CompactIfNeeded (в processQueueTick).
|
||||||
|
// Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) RemoveIndex(i int) {
|
||||||
|
q.ensureIndexes()
|
||||||
|
if i < 0 || i >= len(q.Messages) || q.deadIdx[i] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := &q.Messages[i]
|
||||||
|
q.deadIdx[i] = true
|
||||||
|
q.deadCount++
|
||||||
|
if q.liveCount > 0 {
|
||||||
|
q.liveCount--
|
||||||
|
}
|
||||||
|
if msg.Uuid != "" {
|
||||||
|
if idx, ok := q.uuidIdx[msg.Uuid]; ok && idx == i {
|
||||||
|
delete(q.uuidIdx, msg.Uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rh := msg.ReceiptHandle; rh != "" {
|
||||||
|
if idx, ok := q.receiptIdx[rh]; ok && idx == i {
|
||||||
|
delete(q.receiptIdx, rh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactIfNeeded — компакция при заметной доле tombstone-слотов.
|
||||||
|
// Амортизированно: копия среза делается не на каждое удаление, а когда число
|
||||||
|
// «мёртвых» слотов становится заметным (≥ 1024 или ≥ половины среза).
|
||||||
|
// Вызывать под write-блокировкой ВНЕ итераций по Messages (меняет индексы).
|
||||||
|
func (q *Queue) CompactIfNeeded() {
|
||||||
|
if q.deadCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.deadCount >= compactDeadThreshold || q.deadCount*2 >= len(q.Messages) {
|
||||||
|
q.Compact()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact — физически удаляет tombstone-слоты, сохраняя порядок живых, и
|
||||||
|
// перестраивает индексы. O(N). Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) Compact() {
|
||||||
|
// ensureIndexes гарантирует len(deadIdx)==len(Messages) (иначе слоты разъедутся)
|
||||||
|
// и корректность deadCount. При согласованном состоянии — две дешёвые проверки.
|
||||||
|
q.ensureIndexes()
|
||||||
|
if q.deadCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for i := range q.Messages {
|
||||||
|
if q.deadIdx[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n != i {
|
||||||
|
q.Messages[n] = q.Messages[i]
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
// Обнуляем «хвост», чтобы не держать ссылки на удалённые сообщения (GC).
|
||||||
|
tail := q.Messages[n:]
|
||||||
|
for i := range tail {
|
||||||
|
tail[i] = SqsMessage{}
|
||||||
|
}
|
||||||
|
q.Messages = q.Messages[:n]
|
||||||
|
// После чистки tombstone нет: обнуляем флаги и перестраиваем индексы по живым
|
||||||
|
// (RebuildIndexes заново посчитает liveCount/deadCount и заполнит receipt/uuid).
|
||||||
|
q.deadIdx = q.deadIdx[:0]
|
||||||
|
q.RebuildIndexes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset — полностью очищает сообщения и индексы (purge).
|
||||||
|
// Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) Reset() {
|
||||||
|
q.Messages = nil
|
||||||
|
q.deadIdx = nil
|
||||||
|
q.receiptIdx = make(map[string]int)
|
||||||
|
q.uuidIdx = make(map[string]int)
|
||||||
|
q.liveCount = 0
|
||||||
|
q.deadCount = 0
|
||||||
|
// Чистим FIFO-состояние: иначе блокировки групп и порядковые номера остались
|
||||||
|
// бы, и сообщения этих групп не выдавались после purge (ревью 2026-10-11).
|
||||||
|
q.FIFOMessages = make(map[string]int)
|
||||||
|
q.FIFOSequenceNumbers = make(map[string]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureIndexes — экспортируемая обёртка над ensureIndexes: строит индексы,
|
||||||
|
// если очередь была собрана в обход методов (литералом, из Redis, тестами).
|
||||||
|
// Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) EnsureIndexes() {
|
||||||
|
q.ensureIndexes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexOfReceipt — индекс сообщения по ReceiptHandle (только in-flight) за O(1).
|
||||||
|
// Вызывать под write-блокировкой (ensureIndexes может перестроить индексы).
|
||||||
|
func (q *Queue) IndexOfReceipt(receiptHandle string) (int, bool) {
|
||||||
|
q.ensureIndexes()
|
||||||
|
i, ok := q.receiptIdx[receiptHandle]
|
||||||
|
if !ok || i < 0 || i >= len(q.Messages) || q.deadIdx[i] {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return i, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexOfUUID — индекс живого сообщения по Uuid за O(1).
|
||||||
|
// Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) IndexOfUUID(uuid string) (int, bool) {
|
||||||
|
q.ensureIndexes()
|
||||||
|
i, ok := q.uuidIdx[uuid]
|
||||||
|
if !ok || i < 0 || i >= len(q.Messages) || q.deadIdx[i] {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return i, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkInFlight — регистрирует ReceiptHandle → индекс (для tick по in-flight).
|
||||||
|
// Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) MarkInFlight(i int, receiptHandle string) {
|
||||||
|
q.ensureIndexes()
|
||||||
|
if receiptHandle != "" {
|
||||||
|
q.receiptIdx[receiptHandle] = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearInFlight — снимает регистрацию ReceiptHandle (возврат видимости).
|
||||||
|
// Вызывать под write-блокировкой.
|
||||||
|
func (q *Queue) ClearInFlight(i int, receiptHandle string) {
|
||||||
|
if receiptHandle == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if idx, ok := q.receiptIdx[receiptHandle]; ok && idx == i {
|
||||||
|
delete(q.receiptIdx, receiptHandle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetVisibilityAndTryDLQ — сбрасывает видимость сообщения по индексу и, если
|
||||||
|
// исчерпан MaxReceiveCount при настроенной DLQ, переносит его в DLQ и удаляет
|
||||||
|
// из исходной очереди (tombstone). Возвращает false, если сообщение на самом
|
||||||
|
// деле перенесено в DLQ (удалено из очереди).
|
||||||
|
//
|
||||||
|
// Единый путь для одиночной и батчевой ChangeMessageVisibility: раньше логика
|
||||||
|
// DLQ была только в одиночной версии, а батч лишь инкрементировал Retry — из-за
|
||||||
|
// этого retry-счётчик и DLQ-перенос могли расходиться.
|
||||||
|
// Вызывать под write-блокировкой q. Перенос в DLQ выполняется под её блокировкой.
|
||||||
|
// Возвращает: msgUuid, movedToDLQ.
|
||||||
|
// ResetVisibilityAndTryDLQ — сбрасывает видимость сообщения по индексу и, если
|
||||||
|
// исчерпан MaxReceiveCount, переносит его в DLQ. При переносе удаляет сообщение
|
||||||
|
// из исходной очереди (tombstone).
|
||||||
|
//
|
||||||
|
// Единый путь для одиночной и батчевой ChangeMessageVisibility: раньше логика
|
||||||
|
// DLQ была только в одиночной версии, а батч лишь инкрементировал Retry — из-за
|
||||||
|
// этого retry-счётчик и DLQ-перенос могли расходиться.
|
||||||
|
// Вызывать под write-блокировкой q. Перенос в DLQ выполняется под её блокировкой.
|
||||||
|
// Возвращает: msgUuid, movedToDLQ, dlqMsg (копия перенесённого сообщения, чтобы
|
||||||
|
// вызывающий мог персистить его в DLQ; nil если переноса не было).
|
||||||
|
func (q *Queue) ResetVisibilityAndTryDLQ(i int, receiptHandle string) (msgUuid string, movedToDLQ bool, dlqMsg *SqsMessage) {
|
||||||
|
q.ensureIndexes()
|
||||||
|
q.ClearInFlight(i, receiptHandle)
|
||||||
|
msg := &q.Messages[i]
|
||||||
|
msg.ReceiptTime = time.Now().UTC()
|
||||||
|
msg.ReceiptHandle = ""
|
||||||
|
msg.VisibilityTimeout = time.Now().Add(time.Duration(q.VisibilityTimeout) * time.Second)
|
||||||
|
msg.Retry++
|
||||||
|
msgUuid = msg.Uuid
|
||||||
|
if q.MaxReceiveCount > 0 && q.DeadLetterQueue != nil && msg.Retry >= q.MaxReceiveCount {
|
||||||
|
dlqMsg := *msg
|
||||||
|
q.DeadLetterQueue.LockMu()
|
||||||
|
q.DeadLetterQueue.AppendMessage(dlqMsg)
|
||||||
|
q.DeadLetterQueue.UnlockMu()
|
||||||
|
q.RemoveIndex(i)
|
||||||
|
return msgUuid, true, &dlqMsg
|
||||||
|
}
|
||||||
|
return msgUuid, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InFlightIndexes — снимок индексов in-flight сообщений (для visibility-тика).
|
||||||
|
// Возвращает срез индексов в Messages. Вызывать под write-блокировкой.
|
||||||
|
//
|
||||||
|
// ВАЖНО: индексы — снимок на момент вызова. Некоторые могут стать неактуальными
|
||||||
|
// (handle переехал/сообщение удалено), поэтому потребитель ОБЯЗАН перепроверять
|
||||||
|
// каждое сообщение по индексу (живость: в границах среза, не tombstone, handle
|
||||||
|
// не пуст) — как это делает processQueueTick.
|
||||||
|
func (q *Queue) InFlightIndexes() []int {
|
||||||
|
idx := make([]int, 0, len(q.receiptIdx))
|
||||||
|
for _, i := range q.receiptIdx {
|
||||||
|
idx = append(idx, i)
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDead — tombstone-ли слот. Безопасно под RLock (не мутирует состояние).
|
||||||
|
func (q *Queue) IsDead(i int) bool {
|
||||||
|
if i < 0 || i >= len(q.deadIdx) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return q.deadIdx[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveCount — число живых (не tombstone) сообщений.
|
||||||
|
// Безопасно под RLock. Эквивалент прежнего len(queue.Messages).
|
||||||
|
func (q *Queue) LiveCount() int {
|
||||||
|
return len(q.Messages) - q.deadCount
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
+107
-6
@@ -113,6 +113,20 @@ func (r *CreateQueueRequest) SetAttributesFromForm(values url.Values) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r.Attributes.RedriveAllowPolicy = tmp
|
r.Attributes.RedriveAllowPolicy = tmp
|
||||||
|
case "FifoQueue":
|
||||||
|
tmp, err := strconv.ParseBool(attrValue)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Attributes.FifoQueue = StringToBool(tmp)
|
||||||
|
case "ContentBasedDeduplication":
|
||||||
|
tmp, err := strconv.ParseBool(attrValue)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Attributes.ContentBasedDeduplication = StringToBool(tmp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -381,6 +395,20 @@ func (r *SetQueueAttributesRequest) SetAttributesFromForm(values url.Values) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
r.Attributes.RedriveAllowPolicy = tmp
|
r.Attributes.RedriveAllowPolicy = tmp
|
||||||
|
case "FifoQueue":
|
||||||
|
tmp, err := strconv.ParseBool(attrValue)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Attributes.FifoQueue = StringToBool(tmp)
|
||||||
|
case "ContentBasedDeduplication":
|
||||||
|
tmp, err := strconv.ParseBool(attrValue)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to parse form attribute - %s: %s", attrName, attrValue)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Attributes.ContentBasedDeduplication = StringToBool(tmp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -393,12 +421,85 @@ type QueueAttributes struct {
|
|||||||
DelaySeconds StringToInt `json:"DelaySeconds"`
|
DelaySeconds StringToInt `json:"DelaySeconds"`
|
||||||
MaximumMessageSize StringToInt `json:"MaximumMessageSize"`
|
MaximumMessageSize StringToInt `json:"MaximumMessageSize"`
|
||||||
MessageRetentionPeriod StringToInt `json:"MessageRetentionPeriod"` // NOTE: not implemented
|
MessageRetentionPeriod StringToInt `json:"MessageRetentionPeriod"` // NOTE: not implemented
|
||||||
Policy map[string]interface{} `json:"Policy"` // NOTE: not implemented
|
Policy map[string]interface{} `json:"Policy"` // хранится, не исполняется
|
||||||
ReceiveMessageWaitTimeSeconds StringToInt `json:"ReceiveMessageWaitTimeSeconds"`
|
ReceiveMessageWaitTimeSeconds StringToInt `json:"ReceiveMessageWaitTimeSeconds"`
|
||||||
VisibilityTimeout StringToInt `json:"VisibilityTimeout"`
|
VisibilityTimeout StringToInt `json:"VisibilityTimeout"`
|
||||||
// Dead Letter Queues Only
|
// Dead Letter Queues Only
|
||||||
RedrivePolicy RedrivePolicy `json:"RedrivePolicy"`
|
RedrivePolicy RedrivePolicy `json:"RedrivePolicy"`
|
||||||
RedriveAllowPolicy map[string]interface{} `json:"RedriveAllowPolicy"` // NOTE: not implemented
|
RedriveAllowPolicy map[string]interface{} `json:"RedriveAllowPolicy"` // NOTE: not implemented
|
||||||
|
// FIFO-атрибуты. FifoQueue у нас также выводится из суффикса .fifo имени,
|
||||||
|
// но значение от клиента нужно СОХРАНИТЬ и вернуть в GetQueueAttributes —
|
||||||
|
// иначе Terraform (aws_sqs_queue) видел бы постоянный drift.
|
||||||
|
FifoQueue StringToBool `json:"FifoQueue"`
|
||||||
|
ContentBasedDeduplication StringToBool `json:"ContentBasedDeduplication"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Policy/RedriveAllowPolicy в SQS API — СТРОКА с JSON внутри, а поля у нас —
|
||||||
|
// map. JSON-протокол присылает их именно строкой, и unmarshal в map падал бы
|
||||||
|
// (SetQueueAttributes → InvalidParameterValue). Разворачиваем строку в объект.
|
||||||
|
// Также принимаем вариант, где уже пришёл объект — он и так валиден.
|
||||||
|
for _, name := range []string{"Policy", "RedriveAllowPolicy"} {
|
||||||
|
rv, ok := raw[name]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(rv, &s); err == nil {
|
||||||
|
if !json.Valid([]byte(s)) {
|
||||||
|
return fmt.Errorf("invalid JSON in attribute %s", name)
|
||||||
|
}
|
||||||
|
raw[name] = json.RawMessage(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
type plain QueueAttributes
|
||||||
|
return json.Unmarshal(normalized, (*plain)(q))
|
||||||
}
|
}
|
||||||
|
|
||||||
type RedrivePolicy struct {
|
type RedrivePolicy struct {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+585
-36
@@ -4,7 +4,13 @@
|
|||||||
// Redis — источник правды для восстановления после рестарта.
|
// Redis — источник правды для восстановления после рестарта.
|
||||||
// Все записи в Redis асинхронны (горутина) — не блокируют SQS-операции.
|
// Все записи в Redis асинхронны (горутина) — не блокируют SQS-операции.
|
||||||
// Сериализация (json.Marshal) происходит синхронно пока вызывающий держит мьютекс — консистентный снапшот.
|
// Сериализация (json.Marshal) происходит синхронно пока вызывающий держит мьютекс — консистентный снапшот.
|
||||||
// Created: 2026-04-10
|
//
|
||||||
|
// Схема хранения v2 (2026-04-11):
|
||||||
|
// ssq:queues (HASH) — queueKey → JSON(метаданные очереди без Messages)
|
||||||
|
// ssq:msg:{queueKey} (HASH) — uuid → JSON(SqsMessage)
|
||||||
|
// Это позволяет O(1) на каждое сообщение вместо O(N×msg_size) при SaveQueue.
|
||||||
|
// Миграция со старого формата (Messages внутри ssq:queues) — автоматическая при LoadAllQueues.
|
||||||
|
// Created: 2026-04-10 | Modified: 2026-04-11
|
||||||
|
|
||||||
package persistence
|
package persistence
|
||||||
|
|
||||||
@@ -12,7 +18,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -23,19 +31,37 @@ import (
|
|||||||
|
|
||||||
// Client — глобальный Redis клиент.
|
// Client — глобальный Redis клиент.
|
||||||
// nil означает режим "только память" — все функции тихо no-op.
|
// nil означает режим "только память" — все функции тихо no-op.
|
||||||
var Client *redis.Client
|
// atomic.Pointer: reconnect() пишет новый клиент из health-check горутины,
|
||||||
|
// а остальные горутины читают — без атомарности это data race.
|
||||||
|
var Client atomic.Pointer[redis.Client]
|
||||||
|
|
||||||
|
// Параметры подключения — сохраняются для переподключения после обрыва.
|
||||||
|
var (
|
||||||
|
redisAddr string
|
||||||
|
redisUsername string
|
||||||
|
redisPassword string
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
seqMu sync.Mutex
|
seqMu sync.Mutex
|
||||||
queueWriteSeq = map[string]uint64{}
|
queueWriteSeq = map[string]uint64{}
|
||||||
tenantWriteSeq = map[string]uint64{}
|
tenantWriteSeq = map[string]uint64{}
|
||||||
|
// msgWriteSeq — порядковая нумерация async-записей ОТДЕЛЬНЫХ сообщений
|
||||||
|
// (ключ "{queueKey}|{uuid}"). Без неё два SaveMessage одного сообщения из
|
||||||
|
// разных горутин могли завершиться вразнобой: старое состояние перезаписало
|
||||||
|
// бы новое. Записи удаляются при удалении сообщения (DeleteMessagePersist и др.).
|
||||||
|
msgWriteSeq = map[string]uint64{}
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// redisHashTenants — HASH: tenantID → JSON тенанта
|
// redisHashTenants — HASH: tenantID → JSON тенанта
|
||||||
redisHashTenants = "ssq:tenants"
|
redisHashTenants = "ssq:tenants"
|
||||||
// redisHashQueues — HASH: queueKey → JSON очереди (включая сообщения)
|
// redisHashTenantAK — HASH: accessKey → JSON тенанта (индекс read-through в auth)
|
||||||
|
redisHashTenantAK = "ssq:tenant_ak"
|
||||||
|
// redisHashQueues — HASH: queueKey → JSON метаданных очереди (без Messages с v2)
|
||||||
redisHashQueues = "ssq:queues"
|
redisHashQueues = "ssq:queues"
|
||||||
|
// redisMsgHashPrefix — префикс для per-message HASH: ssq:msg:{queueKey} → uuid → JSON(SqsMessage)
|
||||||
|
redisMsgHashPrefix = "ssq:msg:"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Connect — подключается к Redis и проверяет ping.
|
// Connect — подключается к Redis и проверяет ping.
|
||||||
@@ -45,6 +71,10 @@ func Connect(addr, username, password string) error {
|
|||||||
log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме")
|
log.Info("persistence: REDIS_ADDR не задан, работаем в memory-only режиме")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
redisAddr = addr
|
||||||
|
redisUsername = username
|
||||||
|
redisPassword = password
|
||||||
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Username: username,
|
Username: username,
|
||||||
@@ -56,8 +86,57 @@ func Connect(addr, username, password string) error {
|
|||||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||||
return fmt.Errorf("redis ping %s: %w", addr, err)
|
return fmt.Errorf("redis ping %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
Client = rdb
|
Client.Store(rdb)
|
||||||
log.Infof("persistence: подключились к Redis %s", addr)
|
log.Infof("persistence: подключились к Redis %s", addr)
|
||||||
|
startRedisHealthCheck()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRedisHealthCheck — фоновая проверка соединения и переподключение.
|
||||||
|
// После обрыва Redis-клиент остаётся не-nil, но записи молча падают. Здесь мы
|
||||||
|
// периодически пингуем Redis и при сбое создаём новый клиент (reconnect), чтобы
|
||||||
|
// персистентность восстановилась автоматически.
|
||||||
|
func startRedisHealthCheck() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
if Client.Load() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
err := Client.Load().Ping(ctx).Err()
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Warnf("persistence: Redis недоступен (%v), пробуем переподключиться", err)
|
||||||
|
if reErr := reconnect(); reErr != nil {
|
||||||
|
log.Errorf("persistence: переподключение не удалось: %v", reErr)
|
||||||
|
} else {
|
||||||
|
log.Infof("persistence: переподключились к Redis %s", redisAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnect — создаёт новый Redis-клиент с сохранёнными параметрами подключения.
|
||||||
|
func reconnect() error {
|
||||||
|
if redisAddr == "" {
|
||||||
|
return fmt.Errorf("redis addr пуст")
|
||||||
|
}
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: redisAddr,
|
||||||
|
Username: redisUsername,
|
||||||
|
Password: redisPassword,
|
||||||
|
DB: 0,
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
Client.Store(rdb)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,22 +153,90 @@ func asyncWrite(fn func()) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveQueue — сохраняет очередь (с сообщениями) в Redis асинхронно.
|
// queueMetaDTO — сериализуемая копия очереди БЕЗ Messages (и без сообщений DLQ).
|
||||||
// ВАЖНО: вызывать пока вызывающий держит SyncQueues.Lock() — тогда json.Marshal
|
// Отдельный тип, т.к. models.Queue содержит sync.RWMutex и его нельзя копировать/
|
||||||
|
// сериализовать целиком, а временное зануление Messages мутировало бы живую очередь.
|
||||||
|
// Имена полей совпадают с models.Queue → json совместим при загрузке.
|
||||||
|
type queueMetaDTO struct {
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Arn string
|
||||||
|
VisibilityTimeout int
|
||||||
|
ReceiveMessageWaitTimeSeconds int
|
||||||
|
DelaySeconds int
|
||||||
|
MaximumMessageSize int
|
||||||
|
MessageRetentionPeriod int
|
||||||
|
MaxReceiveCount int
|
||||||
|
IsFIFO bool
|
||||||
|
FIFOMessages map[string]int
|
||||||
|
FIFOSequenceNumbers map[string]int
|
||||||
|
EnableDuplicates bool
|
||||||
|
Duplicates map[string]time.Time
|
||||||
|
Tags map[string]string
|
||||||
|
LastPurgeTime time.Time
|
||||||
|
DeadLetterQueueName string `json:"dead_letter_queue_name,omitempty"`
|
||||||
|
// Создание/изменение + совместимые атрибуты (GetQueueAttributes/Terraform).
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastModifiedAt time.Time
|
||||||
|
Policy string
|
||||||
|
ContentBasedDeduplication bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueToMetaDTO — строит снимок очереди без сообщений, НЕ мутируя исходную очередь.
|
||||||
|
// DLQ сохраняется как ИМЯ (не вложенный объект): иначе — detached-копия после
|
||||||
|
// загрузки и неограниченная рекурсия при циклической DLQ.
|
||||||
|
func queueToMetaDTO(q *models.Queue) *queueMetaDTO {
|
||||||
|
if q == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dto := &queueMetaDTO{
|
||||||
|
Name: q.Name,
|
||||||
|
URL: q.URL,
|
||||||
|
Arn: q.Arn,
|
||||||
|
VisibilityTimeout: q.VisibilityTimeout,
|
||||||
|
ReceiveMessageWaitTimeSeconds: q.ReceiveMessageWaitTimeSeconds,
|
||||||
|
DelaySeconds: q.DelaySeconds,
|
||||||
|
MaximumMessageSize: q.MaximumMessageSize,
|
||||||
|
MessageRetentionPeriod: q.MessageRetentionPeriod,
|
||||||
|
MaxReceiveCount: q.MaxReceiveCount,
|
||||||
|
IsFIFO: q.IsFIFO,
|
||||||
|
FIFOMessages: q.FIFOMessages,
|
||||||
|
FIFOSequenceNumbers: q.FIFOSequenceNumbers,
|
||||||
|
EnableDuplicates: q.EnableDuplicates,
|
||||||
|
Duplicates: q.Duplicates,
|
||||||
|
Tags: q.Tags,
|
||||||
|
LastPurgeTime: q.LastPurgeTime,
|
||||||
|
DeadLetterQueueName: q.DeadLetterQueueName,
|
||||||
|
CreatedAt: q.CreatedAt,
|
||||||
|
LastModifiedAt: q.LastModifiedAt,
|
||||||
|
Policy: q.Policy,
|
||||||
|
ContentBasedDeduplication: q.ContentBasedDeduplication,
|
||||||
|
}
|
||||||
|
if q.DeadLetterQueue != nil {
|
||||||
|
dto.DeadLetterQueueName = q.DeadLetterQueue.Name
|
||||||
|
}
|
||||||
|
return dto
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalQueueMeta — сериализует метаданные очереди БЕЗ сообщений,
|
||||||
|
// НЕ мутируя исходную очередь (см. queueMetaDTO). Безопасно вызывать
|
||||||
|
// под per-queue блокировкой только этой очереди.
|
||||||
|
func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
|
||||||
|
return json.Marshal(queueToMetaDTO(queue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveQueue — сохраняет МЕТАДАННЫЕ очереди (без сообщений) в Redis асинхронно.
|
||||||
|
// ВАЖНО: вызывать пока вызывающий держит SyncQueues.Lock() — тогда marshalQueueMeta
|
||||||
// создаёт консистентный снапшот. Горутина только делает сетевой вызов.
|
// создаёт консистентный снапшот. Горутина только делает сетевой вызов.
|
||||||
|
// Для сохранения сообщений используй SaveMessage/SaveMessages.
|
||||||
func SaveQueue(key string, queue *models.Queue) {
|
func SaveQueue(key string, queue *models.Queue) {
|
||||||
if Client == nil {
|
if Client.Load() == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Сериализуем синхронно под мьютексом вызывающего → консистентный снапшот
|
// Сериализуем метаданные синхронно под мьютексом вызывающего → консистентный снапшот
|
||||||
data, err := json.Marshal(queue)
|
data, err := marshalQueueMeta(queue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("persistence: marshal queue %q: %v", key, err)
|
log.Errorf("persistence: marshal queue meta %q: %v", key, err)
|
||||||
return
|
|
||||||
}
|
|
||||||
// Fix #4.3: Redis size guard — не сохранять если > 50MB (OOM protection)
|
|
||||||
if len(data) > 50*1024*1024 {
|
|
||||||
log.Warnf("persistence: queue %q too large for Redis (%d bytes), skipping", key, len(data))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeSeq := nextQueueWriteSeq(key)
|
writeSeq := nextQueueWriteSeq(key)
|
||||||
@@ -99,43 +246,222 @@ func SaveQueue(key string, queue *models.Queue) {
|
|||||||
}
|
}
|
||||||
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.HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil {
|
if err := Client.Load().HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil {
|
||||||
log.Errorf("persistence: HSet queue %q: %v", key, err)
|
log.Errorf("persistence: HSet queue meta %q: %v", key, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteQueue — удаляет очередь из Redis асинхронно.
|
// SaveMessage — сохраняет ОДНО сообщение в Redis асинхронно.
|
||||||
func DeleteQueue(key string) {
|
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
|
||||||
if Client == nil {
|
// Вызывать под Lock — json.Marshal делает консистентный снапшот сообщения.
|
||||||
|
func SaveMessage(queueKey string, msg *models.SqsMessage) {
|
||||||
|
if Client.Load() == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeSeq := nextQueueWriteSeq(key)
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("persistence: marshal message %s/%s: %v", queueKey, msg.Uuid, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hashKey := redisMsgHashPrefix + queueKey
|
||||||
|
msgKey := queueKey + "|" + msg.Uuid
|
||||||
|
writeSeq := nextMsgWriteSeq(msgKey)
|
||||||
asyncWrite(func() {
|
asyncWrite(func() {
|
||||||
if !isLatestQueueWriteSeq(key, writeSeq) {
|
if !isLatestMsgWriteSeq(msgKey, writeSeq) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
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, redisHashQueues, key).Err(); err != nil {
|
if err := Client.Load().HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
|
||||||
log.Errorf("persistence: HDel queue %q: %v", key, err)
|
log.Errorf("persistence: HSet message %s/%s: %v", queueKey, msg.Uuid, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveMessageSync — сохраняет ОДНО сообщение в Redis СИНХРОННО и возвращает ошибку.
|
||||||
|
// Используется на пути SendMessage: ответ 200 клиенту должен выдаваться ТОЛЬКО
|
||||||
|
// после фактической записи сообщения в Redis, иначе краш пода в окне между
|
||||||
|
// ответом и async-записью приведёт к потере подтверждённого сообщения.
|
||||||
|
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
|
||||||
|
// Если Client == nil (memory-only режим) — возвращает nil (персистить некуда).
|
||||||
|
func SaveMessageSync(queueKey string, msg *models.SqsMessage) error {
|
||||||
|
if Client.Load() == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal message %s/%s: %w", queueKey, msg.Uuid, err)
|
||||||
|
}
|
||||||
|
hashKey := redisMsgHashPrefix + queueKey
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := Client.Load().HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
|
||||||
|
return fmt.Errorf("HSet message %s/%s: %w", queueKey, msg.Uuid, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMessages — сохраняет несколько сообщений в Redis одним pipeline.
|
||||||
|
// Вызывать под Lock.
|
||||||
|
func SaveMessages(queueKey string, msgs []models.SqsMessage) {
|
||||||
|
if Client.Load() == nil || len(msgs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Сериализуем все сообщения синхронно под Lock
|
||||||
|
fields := make(map[string]string, len(msgs))
|
||||||
|
for i := range msgs {
|
||||||
|
data, err := json.Marshal(&msgs[i])
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("persistence: marshal message %s/%s: %v", queueKey, msgs[i].Uuid, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields[msgs[i].Uuid] = string(data)
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hashKey := redisMsgHashPrefix + queueKey
|
||||||
|
asyncWrite(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
// Конвертируем map[string]string → []interface{} для HSet
|
||||||
|
args := make([]interface{}, 0, len(fields)*2)
|
||||||
|
for k, v := range fields {
|
||||||
|
args = append(args, k, v)
|
||||||
|
}
|
||||||
|
if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil {
|
||||||
|
log.Errorf("persistence: HSet messages %s (%d msgs): %v", queueKey, len(fields), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveMessagesSync — сохраняет несколько сообщений в Redis СИНХРОННО (pipeline).
|
||||||
|
// Используется на пути SendMessageBatch: ответ 200 выдаётся только после записи
|
||||||
|
// всех сообщений (как в одиночном SendMessage). Возвращает ошибку для отката.
|
||||||
|
func SaveMessagesSync(queueKey string, msgs []models.SqsMessage) error {
|
||||||
|
if Client.Load() == nil || len(msgs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fields := make(map[string]string, len(msgs))
|
||||||
|
for i := range msgs {
|
||||||
|
data, err := json.Marshal(&msgs[i])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal message %s/%s: %w", queueKey, msgs[i].Uuid, err)
|
||||||
|
}
|
||||||
|
fields[msgs[i].Uuid] = string(data)
|
||||||
|
}
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hashKey := redisMsgHashPrefix + queueKey
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
args := make([]interface{}, 0, len(fields)*2)
|
||||||
|
for k, v := range fields {
|
||||||
|
args = append(args, k, v)
|
||||||
|
}
|
||||||
|
if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil {
|
||||||
|
return fmt.Errorf("HSet messages %s (%d msgs): %w", queueKey, len(fields), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessagePersist — удаляет одно сообщение из Redis СИНХРОННО.
|
||||||
|
// Синхронность обязательна: если удаление потеряется при рестарте пода,
|
||||||
|
// сообщение «воскреснет» из Redis (факт: удалённые очереди возвращались
|
||||||
|
// после рестарта, когда удаления были асинхронными).
|
||||||
|
func DeleteMessagePersist(queueKey string, msgUuid string) error {
|
||||||
|
if Client.Load() == nil || msgUuid == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hashKey := redisMsgHashPrefix + queueKey
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := Client.Load().HDel(ctx, hashKey, msgUuid).Err(); err != nil {
|
||||||
|
return fmt.Errorf("HDel message %s/%s: %w", queueKey, msgUuid, err)
|
||||||
|
}
|
||||||
|
clearMsgWriteSeq(queueKey, msgUuid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessagesPersist — удаляет несколько сообщений из Redis СИНХРОННО
|
||||||
|
// (иначе удалённые сообщения воскреснут после рестарта пода).
|
||||||
|
func DeleteMessagesPersist(queueKey string, uuids []string) error {
|
||||||
|
if Client.Load() == nil || len(uuids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hashKey := redisMsgHashPrefix + queueKey
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := Client.Load().HDel(ctx, hashKey, uuids...).Err(); err != nil {
|
||||||
|
return fmt.Errorf("HDel messages %s (%d): %w", queueKey, len(uuids), err)
|
||||||
|
}
|
||||||
|
clearMsgWriteSeq(queueKey, uuids...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis СИНХРОННО.
|
||||||
|
// Синхронность обязательна: при рестарте пода удалённые сообщения не должны
|
||||||
|
// воскреснуть (факт: асинхронное удаление очередей приводило к их возврату).
|
||||||
|
func PurgeMessagesPersist(queueKey string) {
|
||||||
|
if Client.Load() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hashKey := redisMsgHashPrefix + queueKey
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := Client.Load().Del(ctx, hashKey).Err(); err != nil {
|
||||||
|
log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err)
|
||||||
|
}
|
||||||
|
clearAllMsgWriteSeq(queueKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно.
|
||||||
|
func DeleteQueue(key string) {
|
||||||
|
if Client.Load() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeSeq := nextQueueWriteSeq(key)
|
||||||
|
msgHashKey := redisMsgHashPrefix + key
|
||||||
|
// СИНХРОННО: удаление очереди обязано попасть в Redis до ответа клиенту.
|
||||||
|
// Асинхронное удаление приводило к «воскрешению» удалённых очередей после
|
||||||
|
// рестарта пода (факт 2026-08-15: 50 мусорных очередей вернулись из Redis).
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if !isLatestQueueWriteSeq(key, writeSeq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Удаляем метаданные из общего HASH
|
||||||
|
if err := Client.Load().HDel(ctx, redisHashQueues, key).Err(); err != nil {
|
||||||
|
log.Errorf("persistence: HDel queue meta %q: %v", key, err)
|
||||||
|
}
|
||||||
|
// Удаляем весь HASH с сообщениями
|
||||||
|
if err := Client.Load().Del(ctx, msgHashKey).Err(); err != nil {
|
||||||
|
log.Errorf("persistence: DEL messages hash %q: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
|
// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
|
||||||
// Инициализирует nil-maps чтобы избежать panic при deduplication/FIFO операциях.
|
// Схема v2: метаданные из ssq:queues, сообщения из ssq:msg:{key}.
|
||||||
|
// Миграция v1→v2: если в ssq:queues лежит JSON со встроенными Messages,
|
||||||
|
// они извлекаются в отдельный HASH и метаданные пересохраняются без Messages.
|
||||||
func LoadAllQueues() (map[string]*models.Queue, error) {
|
func LoadAllQueues() (map[string]*models.Queue, error) {
|
||||||
if Client == nil {
|
if Client.Load() == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
raw, err := Client.HGetAll(ctx, redisHashQueues).Result()
|
|
||||||
|
// 1. Загружаем метаданные очередей
|
||||||
|
raw, err := Client.Load().HGetAll(ctx, redisHashQueues).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("redis HGetAll queues: %w", err)
|
return nil, fmt.Errorf("redis HGetAll queues: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
queues := make(map[string]*models.Queue, len(raw))
|
queues := make(map[string]*models.Queue, len(raw))
|
||||||
|
migrateKeys := make([]string, 0) // ключи для миграции v1→v2
|
||||||
|
|
||||||
for k, v := range raw {
|
for k, v := range raw {
|
||||||
var q models.Queue
|
var q models.Queue
|
||||||
if err := json.Unmarshal([]byte(v), &q); err != nil {
|
if err := json.Unmarshal([]byte(v), &q); err != nil {
|
||||||
@@ -152,9 +478,105 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
|
|||||||
if q.FIFOSequenceNumbers == nil {
|
if q.FIFOSequenceNumbers == nil {
|
||||||
q.FIFOSequenceNumbers = make(map[string]int)
|
q.FIFOSequenceNumbers = make(map[string]int)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Бэкфилл timestamp-ов: у очередей, созданных до появления полей, они нулевые.
|
||||||
|
// Без этого Terraform видел бы CreatedTimestamp=0 и постоянный drift.
|
||||||
|
// Заполняем сейчас и сохраним при ближайшем SaveQueue.
|
||||||
|
if q.CreatedAt.IsZero() {
|
||||||
|
q.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if q.LastModifiedAt.IsZero() {
|
||||||
|
q.LastModifiedAt = q.CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Миграция v1→v2: если в JSON есть Messages — это старый формат
|
||||||
|
if len(q.Messages) > 0 {
|
||||||
|
migrateKeys = append(migrateKeys, k)
|
||||||
|
log.Infof("persistence: миграция v1→v2 для %q (%d сообщений)", k, len(q.Messages))
|
||||||
|
}
|
||||||
|
|
||||||
queues[k] = &q
|
queues[k] = &q
|
||||||
}
|
}
|
||||||
log.Infof("persistence: загружено %d очередей из Redis", len(queues))
|
|
||||||
|
// 2. Миграция: выносим Messages из старого формата в отдельные HASH'ы
|
||||||
|
for _, k := range migrateKeys {
|
||||||
|
q := queues[k]
|
||||||
|
hashKey := redisMsgHashPrefix + k
|
||||||
|
// Сохраняем каждое сообщение отдельно
|
||||||
|
if len(q.Messages) > 0 {
|
||||||
|
args := make([]interface{}, 0, len(q.Messages)*2)
|
||||||
|
for i := range q.Messages {
|
||||||
|
data, err := json.Marshal(&q.Messages[i])
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("persistence: migrate marshal msg %s/%s: %v", k, q.Messages[i].Uuid, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args = append(args, q.Messages[i].Uuid, string(data))
|
||||||
|
}
|
||||||
|
if len(args) > 0 {
|
||||||
|
if err := Client.Load().HSet(ctx, hashKey, args...).Err(); err != nil {
|
||||||
|
log.Errorf("persistence: migrate HSet messages %q: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Пересохраняем метаданные без Messages
|
||||||
|
metaData, err := marshalQueueMeta(q)
|
||||||
|
if err == nil {
|
||||||
|
if err := Client.Load().HSet(ctx, redisHashQueues, k, string(metaData)).Err(); err != nil {
|
||||||
|
log.Errorf("persistence: migrate HSet meta %q: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Infof("persistence: миграция %q завершена — %d сообщений вынесены в %s", k, len(q.Messages), hashKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Загружаем сообщения из отдельных HASH'ов для каждой очереди
|
||||||
|
for k, q := range queues {
|
||||||
|
hashKey := redisMsgHashPrefix + k
|
||||||
|
msgRaw, err := Client.Load().HGetAll(ctx, hashKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
// Fail-fast: скрытая пустая очередь при недоступных сообщениях
|
||||||
|
// хуже явной ошибки — сообщения остались бы невидимыми в Redis.
|
||||||
|
return nil, fmt.Errorf("redis HGetAll messages %q: %w", k, err)
|
||||||
|
}
|
||||||
|
if len(msgRaw) > 0 {
|
||||||
|
msgs := make([]models.SqsMessage, 0, len(msgRaw))
|
||||||
|
for uuid, v := range msgRaw {
|
||||||
|
var m models.SqsMessage
|
||||||
|
if err := json.Unmarshal([]byte(v), &m); err != nil {
|
||||||
|
log.Errorf("persistence: unmarshal message %s/%s: %v", k, uuid, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgs = append(msgs, m)
|
||||||
|
}
|
||||||
|
q.Messages = msgs
|
||||||
|
} else if len(q.Messages) == 0 {
|
||||||
|
// Пустая очередь — Messages уже nil, оставляем
|
||||||
|
q.Messages = nil
|
||||||
|
}
|
||||||
|
// Строим индексы (P1.2): receiptIdx/uuidIdx/deadIdx по загруженным
|
||||||
|
// сообщениям. Без этого первый же receive/delete перестроил бы их лениво.
|
||||||
|
q.RebuildIndexes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Релинк DLQ: в метаданных хранится только имя DLQ — связываем указатель
|
||||||
|
// с реальным объектом из map. Иначе DeadLetterQueue оказался бы detached-копией
|
||||||
|
// (переносы в DLQ писали бы в неё, а не в живую очередь; мьютекс — чужой).
|
||||||
|
for k, q := range queues {
|
||||||
|
if q.DeadLetterQueueName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.Index(k, ":")
|
||||||
|
if idx <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dlq, ok := queues[k[:idx+1]+q.DeadLetterQueueName]; ok {
|
||||||
|
q.DeadLetterQueue = dlq
|
||||||
|
} else {
|
||||||
|
log.Warnf("persistence: DLQ %q для очереди %q не найдена при загрузке", q.DeadLetterQueueName, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("persistence: загружено %d очередей из Redis (миграций: %d)", len(queues), len(migrateKeys))
|
||||||
return queues, nil
|
return queues, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,12 +584,17 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
|
|||||||
// Принимает []byte чтобы избежать циклического импорта с пакетом tenant.
|
// Принимает []byte чтобы избежать циклического импорта с пакетом tenant.
|
||||||
// Сериализацию делает вызывающий (tenant_store.go).
|
// Сериализацию делает вызывающий (tenant_store.go).
|
||||||
func SaveTenantRaw(id string, jsonData []byte) {
|
func SaveTenantRaw(id string, jsonData []byte) {
|
||||||
if Client == nil {
|
if Client.Load() == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Копируем bytes — вызывающий может переиспользовать буфер
|
// Копируем bytes — вызывающий может переиспользовать буфер
|
||||||
dataCopy := make([]byte, len(jsonData))
|
dataCopy := make([]byte, len(jsonData))
|
||||||
copy(dataCopy, jsonData)
|
copy(dataCopy, jsonData)
|
||||||
|
// Извлекаем accessKey для индекса read-through (без импорта пакета tenant)
|
||||||
|
var meta struct {
|
||||||
|
AccessKey string
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(dataCopy, &meta)
|
||||||
writeSeq := nextTenantWriteSeq(id)
|
writeSeq := nextTenantWriteSeq(id)
|
||||||
asyncWrite(func() {
|
asyncWrite(func() {
|
||||||
if !isLatestTenantWriteSeq(id, writeSeq) {
|
if !isLatestTenantWriteSeq(id, writeSeq) {
|
||||||
@@ -175,15 +602,37 @@ func SaveTenantRaw(id string, jsonData []byte) {
|
|||||||
}
|
}
|
||||||
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.HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil {
|
if err := Client.Load().HSet(ctx, redisHashTenants, id, string(dataCopy)).Err(); err != nil {
|
||||||
log.Errorf("persistence: HSet tenant %q: %v", id, err)
|
log.Errorf("persistence: HSet tenant %q: %v", id, err)
|
||||||
}
|
}
|
||||||
|
if meta.AccessKey != "" {
|
||||||
|
if err := Client.Load().HSet(ctx, redisHashTenantAK, meta.AccessKey, string(dataCopy)).Err(); err != nil {
|
||||||
|
log.Errorf("persistence: HSet tenant_ak %q: %v", meta.AccessKey, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadTenantByAccessKey — read-through тенанта по accessKey (для auth-middleware).
|
||||||
|
// Возвращает сырой JSON; десериализацию делает вызывающий.
|
||||||
|
func LoadTenantByAccessKey(accessKey string) ([]byte, bool) {
|
||||||
|
if Client.Load() == nil || accessKey == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
v, err := Client.Load().HGet(ctx, redisHashTenantAK, accessKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return []byte(v), true
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteTenant — удаляет тенанта из Redis асинхронно.
|
// DeleteTenant — удаляет тенанта из Redis асинхронно.
|
||||||
func DeleteTenant(id string) {
|
// Удаляет из ОБОИХ индексов: ssq:tenants (по id) и ssq:tenant_ak (по accessKey),
|
||||||
if Client == nil {
|
// иначе LoadTenantByAccessKey вернёт удалённого тенанта и отзыв ключей не сработает.
|
||||||
|
func DeleteTenant(id, accessKey string) {
|
||||||
|
if Client.Load() == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeSeq := nextTenantWriteSeq(id)
|
writeSeq := nextTenantWriteSeq(id)
|
||||||
@@ -193,9 +642,14 @@ func DeleteTenant(id string) {
|
|||||||
}
|
}
|
||||||
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, redisHashTenants, id).Err(); err != nil {
|
if err := Client.Load().HDel(ctx, redisHashTenants, id).Err(); err != nil {
|
||||||
log.Errorf("persistence: HDel tenant %q: %v", id, err)
|
log.Errorf("persistence: HDel tenant %q: %v", id, err)
|
||||||
}
|
}
|
||||||
|
if accessKey != "" {
|
||||||
|
if err := Client.Load().HDel(ctx, redisHashTenantAK, accessKey).Err(); err != nil {
|
||||||
|
log.Errorf("persistence: HDel tenant_ak %q: %v", accessKey, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +666,41 @@ func isLatestQueueWriteSeq(key string, seq uint64) bool {
|
|||||||
return queueWriteSeq[key] == seq
|
return queueWriteSeq[key] == seq
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nextMsgWriteSeq(key string) uint64 {
|
||||||
|
seqMu.Lock()
|
||||||
|
defer seqMu.Unlock()
|
||||||
|
msgWriteSeq[key]++
|
||||||
|
return msgWriteSeq[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLatestMsgWriteSeq(key string, seq uint64) bool {
|
||||||
|
seqMu.Lock()
|
||||||
|
defer seqMu.Unlock()
|
||||||
|
return msgWriteSeq[key] == seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearMsgWriteSeq — удаляет записи нумерации для удалённых сообщений
|
||||||
|
// (иначе msgWriteSeq рос бы бесконечно).
|
||||||
|
func clearMsgWriteSeq(queueKey string, uuids ...string) {
|
||||||
|
seqMu.Lock()
|
||||||
|
defer seqMu.Unlock()
|
||||||
|
for _, u := range uuids {
|
||||||
|
delete(msgWriteSeq, queueKey+"|"+u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearAllMsgWriteSeq — удаляет ВСЕ записи нумерации для очереди (purge).
|
||||||
|
func clearAllMsgWriteSeq(queueKey string) {
|
||||||
|
prefix := queueKey + "|"
|
||||||
|
seqMu.Lock()
|
||||||
|
defer seqMu.Unlock()
|
||||||
|
for k := range msgWriteSeq {
|
||||||
|
if strings.HasPrefix(k, prefix) {
|
||||||
|
delete(msgWriteSeq, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func nextTenantWriteSeq(id string) uint64 {
|
func nextTenantWriteSeq(id string) uint64 {
|
||||||
seqMu.Lock()
|
seqMu.Lock()
|
||||||
defer seqMu.Unlock()
|
defer seqMu.Unlock()
|
||||||
@@ -228,12 +717,12 @@ func isLatestTenantWriteSeq(id string, seq uint64) bool {
|
|||||||
// LoadAllTenantsRaw — загружает всех тенантов из Redis при старте.
|
// LoadAllTenantsRaw — загружает всех тенантов из Redis при старте.
|
||||||
// Возвращает map[tenantID]rawJSON — десериализацию делает tenant_store.go.
|
// Возвращает map[tenantID]rawJSON — десериализацию делает tenant_store.go.
|
||||||
func LoadAllTenantsRaw() (map[string][]byte, error) {
|
func LoadAllTenantsRaw() (map[string][]byte, error) {
|
||||||
if Client == nil {
|
if Client.Load() == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
raw, err := Client.HGetAll(ctx, redisHashTenants).Result()
|
raw, err := Client.Load().HGetAll(ctx, redisHashTenants).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("redis HGetAll tenants: %w", err)
|
return nil, fmt.Errorf("redis HGetAll tenants: %w", err)
|
||||||
}
|
}
|
||||||
@@ -244,3 +733,63 @@ func LoadAllTenantsRaw() (map[string][]byte, error) {
|
|||||||
log.Infof("persistence: загружено %d тенантов из Redis", len(result))
|
log.Infof("persistence: загружено %d тенантов из Redis", len(result))
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Leader election (active/passive) ----
|
||||||
|
//
|
||||||
|
// Простейший HA-механизм (вариант C): один инстанс — активный (держит лок),
|
||||||
|
// остальные — пассивные. При падении активного лок истекает по TTL и пассивный
|
||||||
|
// его перехватывает (см. app/leader). Инстанс сообщает готовность через /ready.
|
||||||
|
//
|
||||||
|
// ВАЖНО: при Client == nil (memory-only) — считаем себя лидером (мы единственные).
|
||||||
|
|
||||||
|
// TryAcquireLock — пытается взять лок (SETNX с TTL). true — лок наш.
|
||||||
|
func TryAcquireLock(key, value string, ttl time.Duration) (bool, error) {
|
||||||
|
if Client.Load() == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return Client.Load().SetNX(ctx, key, value, ttl).Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshLockScript — продлить TTL, ТОЛЬКО если лок всё ещё наш (атомарно).
|
||||||
|
var refreshLockScript = redis.NewScript(`
|
||||||
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end`)
|
||||||
|
|
||||||
|
// releaseLockScript — удалить лок, ТОЛЬКО если он наш.
|
||||||
|
var releaseLockScript = redis.NewScript(`
|
||||||
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("DEL", KEYS[1])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end`)
|
||||||
|
|
||||||
|
// RefreshLock — продлевает TTL лока, если он всё ещё наш. false — лок потерян.
|
||||||
|
func RefreshLock(key, value string, ttl time.Duration) (bool, error) {
|
||||||
|
if Client.Load() == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
n, err := refreshLockScript.Run(ctx, Client.Load(), []string{key}, value, ttl.Milliseconds()).Int()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseLock — освобождает лок (если он наш). Вызывается при graceful shutdown.
|
||||||
|
func ReleaseLock(key, value string) {
|
||||||
|
if Client.Load() == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := releaseLockScript.Run(ctx, Client.Load(), []string{key}, value).Int(); err != nil {
|
||||||
|
log.Errorf("persistence: release lock %q: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+240
-28
@@ -4,26 +4,37 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"time"
|
||||||
|
|
||||||
"shared-sqs/app/admin"
|
"shared-sqs/app/admin"
|
||||||
"shared-sqs/app/auth"
|
"shared-sqs/app/auth"
|
||||||
|
"shared-sqs/app/billing"
|
||||||
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/models"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
"shared-sqs/app/ui"
|
"shared-sqs/app/ui"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New — создаёт HTTP router с tenant auth и admin API
|
// NewDataPlane — публичный SQS API (data-plane): SQS actions, /health, статика, описание.
|
||||||
func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
// НЕ содержит админ-консоль, /metrics и /ui — они в control-plane (NewControlPlane).
|
||||||
|
//
|
||||||
|
// Разбиение data-plane/control-plane (Шаг 2): публично доступен только этот роутер.
|
||||||
|
// Пока — два листенера в одном процессе (общее in-memory состояние); отдельные процессы
|
||||||
|
// станут возможны после выноса состояния в Redis (Шаг 3).
|
||||||
|
func NewDataPlane(tenantStore *tenant.TenantStore) http.Handler {
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
|
||||||
// Fix #3.5: глобальный лимит размера тела HTTP запроса — 3MB
|
// Fix #3.5: глобальный лимит размера тела HTTP запроса — 3MB
|
||||||
@@ -33,6 +44,68 @@ 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")
|
||||||
|
|
||||||
|
// /ready — готовность инстанса (для failover/балансировщика)
|
||||||
|
r.HandleFunc("/ready", readyHandler).Methods("GET")
|
||||||
|
|
||||||
|
// /static — логотип, favicon для публичных страниц
|
||||||
|
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
|
||||||
|
|
||||||
|
// Страница примеров команд — публично
|
||||||
|
r.HandleFunc("/examples", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
ui.ExamplesHandler(models.Version).ServeHTTP(w, req)
|
||||||
|
}).Methods("GET")
|
||||||
|
|
||||||
|
// SQS API — tenant auth middleware оборачивает каждый handler отдельно.
|
||||||
|
// r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0
|
||||||
|
// при пустом prefix — ответы теряются. Поэтому используем явную обёртку.
|
||||||
|
sqsAuth := auth.AuthMiddleware(tenantStore)
|
||||||
|
// Корень: GET без Action — публичная страница-описание сервиса;
|
||||||
|
// всё остальное (POST, или GET с Action/X-Amz-Target) — SQS API за tenant auth.
|
||||||
|
rootHandler := func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method == http.MethodGet && extractAction(req) == "" {
|
||||||
|
ui.InfoHandler(models.Version).ServeHTTP(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sqsAuth(http.HandlerFunc(actionHandler)).ServeHTTP(w, req)
|
||||||
|
}
|
||||||
|
r.HandleFunc("/", rootHandler).Methods("GET", "POST")
|
||||||
|
r.Handle("/{account}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
|
||||||
|
r.Handle("/queue/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
|
||||||
|
r.Handle("/{account}/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewControlPlane — админка, метрики и UI (control-plane).
|
||||||
|
// ВАЖНО: этот роутер должен слушать ТОЛЬКО localhost (не проксируется наружу).
|
||||||
|
func NewControlPlane(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
||||||
|
r := mux.NewRouter()
|
||||||
|
|
||||||
|
r.Use(requestBodyLimitMiddleware)
|
||||||
|
|
||||||
|
// /health — для проверки control-plane локально
|
||||||
|
r.HandleFunc("/health", health).Methods("GET")
|
||||||
|
|
||||||
|
// /ready — готовность (лидер ли)
|
||||||
|
r.HandleFunc("/ready", readyHandler).Methods("GET")
|
||||||
|
|
||||||
|
// /static — нужен UI-консоли (логотип/favicon)
|
||||||
|
r.PathPrefix("/static").Handler(ui.StaticHandler()).Methods("GET")
|
||||||
|
|
||||||
|
// /metrics — Prometheus endpoint для Victoria Metrics scrape.
|
||||||
|
// Защищён Bearer admin-токеном: метрики содержат per-tenant данные
|
||||||
|
// (accessKey/число очередей и сообщений), которые нельзя светить наружу.
|
||||||
|
r.Handle("/metrics", metricsAuth(adminToken, promhttp.Handler())).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)
|
||||||
@@ -41,19 +114,10 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
|
|||||||
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
|
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
|
||||||
adminHandler.RegisterPublicRoutes(r)
|
adminHandler.RegisterPublicRoutes(r)
|
||||||
|
|
||||||
// UI console — встроенный SPA, публичный доступ
|
// UI console — встроенный SPA
|
||||||
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
|
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
|
||||||
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
|
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
|
||||||
|
|
||||||
// SQS API — tenant auth middleware оборачивает каждый handler отдельно.
|
|
||||||
// r.NewRoute().Subrouter() с Use() некорректно работает в gorilla/mux v1.8.0
|
|
||||||
// при пустом prefix — ответы теряются. Поэтому используем явную обёртку.
|
|
||||||
sqsAuth := auth.AuthMiddleware(tenantStore)
|
|
||||||
r.Handle("/", 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("/{account}/{queueName}", sqsAuth(http.HandlerFunc(actionHandler))).Methods("GET", "POST")
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,10 +132,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)
|
||||||
@@ -117,12 +206,79 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadyFunc — если задана, /ready отдаёт 200 при true и 503 иначе (для failover/LB).
|
||||||
|
// Задаётся в main при включённом leader-election. nil → всегда готовы.
|
||||||
|
var ReadyFunc func() bool
|
||||||
|
|
||||||
|
// readyHandler — готовность инстанса обслуживать трафик (для балансировщика/failover).
|
||||||
|
// Пассивный инстанс отдаёт 503, чтобы LB не направлял на него трафик.
|
||||||
|
func readyHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if ReadyFunc != nil && !ReadyFunc() {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ready"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricsAuth — оборачивает /metrics в проверку Bearer admin-токена.
|
||||||
|
// В отличие от публичного /health, метрики содержат per-tenant данные
|
||||||
|
// (accessKey, число очередей/сообщений) и не должны быть доступны без auth.
|
||||||
|
func metricsAuth(adminToken string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
expected := "Bearer " + adminToken
|
||||||
|
if len(authHeader) != len(expected) ||
|
||||||
|
subtle.ConstantTimeCompare([]byte(authHeader), []byte(expected)) != 1 {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// countBatchEntries — подсчитывает число записей в batch-запросе SQS.
|
||||||
|
// Для query-протокола записи идут как SendMessageBatchRequestEntry.N.Id (N=1..).
|
||||||
|
// Для JSON-протокола считаем по количеству записей в массиве Entries (requestBody).
|
||||||
|
// Возвращает 0 если определить не удалось (тогда биллинг запишет msg_count=1).
|
||||||
|
func countBatchEntries(req *http.Request, action string) int {
|
||||||
|
// Query-формат: считаем уникальные N в SendMessageBatchRequestEntry.N.Id и т.п.
|
||||||
|
prefix := "SendMessageBatchRequestEntry."
|
||||||
|
if action == "DeleteMessageBatch" {
|
||||||
|
prefix = "DeleteMessageBatchRequestEntry."
|
||||||
|
} else if action == "ChangeMessageVisibilityBatch" {
|
||||||
|
prefix = "ChangeMessageVisibilityBatchRequestEntry."
|
||||||
|
}
|
||||||
|
// Разбираем form, если ещё не разобран (иначе PostForm пуст для lazy-запросов).
|
||||||
|
_ = req.ParseForm()
|
||||||
|
|
||||||
|
if req.PostForm != nil || req.Form != nil {
|
||||||
|
form := req.Form
|
||||||
|
if form == nil {
|
||||||
|
form = req.PostForm
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for key := range form {
|
||||||
|
if strings.HasPrefix(key, prefix) && strings.HasSuffix(key, ".Id") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionHandler(w http.ResponseWriter, req *http.Request) {
|
func actionHandler(w http.ResponseWriter, req *http.Request) {
|
||||||
action := extractAction(req)
|
action := extractAction(req)
|
||||||
|
start := time.Now()
|
||||||
log.WithFields(log.Fields{
|
log.WithFields(log.Fields{
|
||||||
"action": action,
|
"action": action,
|
||||||
"url": req.URL,
|
"url": req.URL,
|
||||||
@@ -131,11 +287,52 @@ func actionHandler(w http.ResponseWriter, req *http.Request) {
|
|||||||
if ok {
|
if ok {
|
||||||
statusCode, responseBody := jsonFn(req)
|
statusCode, responseBody := jsonFn(req)
|
||||||
encodeResponse(w, req, statusCode, responseBody)
|
encodeResponse(w, req, statusCode, responseBody)
|
||||||
|
|
||||||
|
// Metrics: latency гистограмма для каждой операции
|
||||||
|
duration := time.Since(start).Seconds()
|
||||||
|
metrics.RequestDuration.WithLabelValues(action).Observe(duration)
|
||||||
|
|
||||||
|
if statusCode < 400 {
|
||||||
|
if t, _ := req.Context().Value(auth.TenantContextKey).(*tenant.Tenant); t != nil {
|
||||||
|
msgBytes := req.ContentLength
|
||||||
|
if msgBytes < 0 {
|
||||||
|
msgBytes = 0
|
||||||
|
}
|
||||||
|
// Извлекаем имя очереди: из URL path vars или из form-параметра QueueName (CreateQueue)
|
||||||
|
queueName := mux.Vars(req)["queueName"]
|
||||||
|
if queueName == "" {
|
||||||
|
queueName = req.FormValue("QueueName")
|
||||||
|
}
|
||||||
|
// Billing: запись в PostgreSQL (async, no-op если billing выключен).
|
||||||
|
// msgCount для batch-операций — число сообщений в батче; для прочих — 1.
|
||||||
|
msgCount := 1
|
||||||
|
if action == "SendMessageBatch" || action == "DeleteMessageBatch" ||
|
||||||
|
action == "ChangeMessageVisibilityBatch" {
|
||||||
|
if n := countBatchEntries(req, action); n > 0 {
|
||||||
|
msgCount = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
billing.RecordUsage(t.ID, action, queueName, msgCount, msgBytes)
|
||||||
|
// Prometheus counters: requests + bytes
|
||||||
|
metrics.RequestsTotal.WithLabelValues(t.ID, action).Inc()
|
||||||
|
metrics.RequestBytesTotal.WithLabelValues(t.ID, action).Add(float64(msgBytes))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Prometheus: счётчик ошибок
|
||||||
|
metrics.ErrorsTotal.WithLabelValues(action).Inc()
|
||||||
|
}
|
||||||
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
|
||||||
@@ -145,24 +342,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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
+84
-37
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,10 +17,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>: случайный, генерируется через crypto/rand)
|
||||||
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 +37,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 +50,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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,10 +87,16 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
// Повторная проверка лимита ПОД Lock: ранняя проверка выше была под RLock,
|
||||||
|
// между ней и взятием Lock другой поток мог создать тенанта (TOCTOU).
|
||||||
|
if len(s.byID) >= MaxTenantsGlobal {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("global tenant limit reached (%d)", MaxTenantsGlobal)
|
||||||
|
}
|
||||||
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()
|
||||||
|
|
||||||
@@ -98,7 +110,23 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
|
|||||||
|
|
||||||
// CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo).
|
// CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo).
|
||||||
// Используется только при инициализации демо-данных; не вызывается из user-facing API.
|
// Используется только при инициализации демо-данных; не вызывается из user-facing API.
|
||||||
|
// CreateFixedAutoID — создаёт тенанта с заранее известными креды (BYOC),
|
||||||
|
// но СЛУЧАЙНЫМ tenantID (генерируется через crypto/rand). Используется Admin API
|
||||||
|
// при задании произвольных access_key/secret_key без явного tenantID.
|
||||||
|
func (s *TenantStore) CreateFixedAutoID(name string, maxQueues int, accessKey, secretKey string) (*Tenant, error) {
|
||||||
|
id, err := generateTenantID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate tenant id: %w", err)
|
||||||
|
}
|
||||||
|
return s.CreateFixed(name, maxQueues, id, accessKey, secretKey)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) {
|
func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) {
|
||||||
|
// access_key используется как префикс ключа "{accessKey}:{queueName}" —
|
||||||
|
// ':' в нём ломает изоляцию тенантов (ListQueues/коллизии ключей).
|
||||||
|
if strings.Contains(accessKey, ":") {
|
||||||
|
return nil, fmt.Errorf("access key must not contain ':'")
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if _, exists := s.byID[tenantID]; exists {
|
if _, exists := s.byID[tenantID]; exists {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -156,13 +184,13 @@ 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()
|
||||||
|
|
||||||
// Удаляем из Redis асинхронно
|
// Удаляем из Redis асинхронно (оба индекса: по id и по accessKey)
|
||||||
persistence.DeleteTenant(id)
|
persistence.DeleteTenant(id, t.AccessKey)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,60 +201,80 @@ 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: тенант ищется/создаётся по email (индекс byEmail).
|
||||||
// Если тенант с таким sub уже существует — возвращает его (идемпотентно).
|
// Креды (ID/AccessKey/SecretKey) генерируются СЛУЧАЙНО через crypto/rand и
|
||||||
func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) (*Tenant, error) {
|
// НЕ выводятся из email — иначе креды можно было бы вычислить по публичному email
|
||||||
|
// (SHA256(email+соль) при известной соли = полный доступ к чужим очередям).
|
||||||
|
// Тот же email при повторном логине → тот же тенант и те же креды (из индекса/Redis).
|
||||||
|
func (s *TenantStore) CreateFromJWT(sub, email string, maxQueues int) (*Tenant, error) {
|
||||||
|
if email == "" {
|
||||||
|
return nil, fmt.Errorf("JWT email is required for tenant provisioning")
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
// креды НЕ пересчитываем и НЕ меняем (они случайны и неизменны).
|
||||||
existing.Email = email
|
if existing, ok := s.byEmail[email]; ok {
|
||||||
|
// Персистим только если что-то реально изменилось: иначе лишние записи
|
||||||
|
// в Redis на каждый логин, а обновлённый NubesSub раньше вообще терялся
|
||||||
|
// (сохранялся только внутри ветки смены MaxQueues).
|
||||||
|
changed := false
|
||||||
|
if sub != "" && existing.NubesSub != sub {
|
||||||
|
existing.NubesSub = sub
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
// Обновляем лимит очередей на актуальное значение из кода при каждом
|
||||||
|
// логине — иначе после рестарта пода из Redis восстановится старый лимит.
|
||||||
|
if existing.MaxQueues != maxQueues {
|
||||||
|
existing.MaxQueues = maxQueues
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if data, err := json.Marshal(existing); err == nil {
|
||||||
|
persistence.SaveTenantRaw(existing.ID, data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
id, err := generateTenantID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate tenant id: %w", err)
|
||||||
|
}
|
||||||
accessKey, err := generateAccessKey()
|
accessKey, err := generateAccessKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.mu.Unlock()
|
|
||||||
return nil, fmt.Errorf("generate access key: %w", err)
|
return nil, fmt.Errorf("generate access key: %w", err)
|
||||||
}
|
}
|
||||||
secretKey, err := generateSecretKey()
|
secretKey, err := generateSecretKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.mu.Unlock()
|
|
||||||
return nil, fmt.Errorf("generate secret key: %w", err)
|
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: id,
|
||||||
Name: name,
|
Name: email,
|
||||||
AccessKey: accessKey,
|
AccessKey: accessKey,
|
||||||
SecretKey: secretKey,
|
SecretKey: secretKey,
|
||||||
MaxQueues: maxQueues,
|
MaxQueues: maxQueues,
|
||||||
@@ -238,8 +286,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 {
|
||||||
|
|||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||||
|
.replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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=<Access Key>
|
||||||
|
export AWS_SECRET_ACCESS_KEY=<Secret Key></code></pre>
|
||||||
|
<p><span class="step-num">B</span><strong>Файл ~/.aws/credentials</strong> (постоянно, для всех команд):</p>
|
||||||
|
<pre><code>[sqs]
|
||||||
|
aws_access_key_id = <Access Key>
|
||||||
|
aws_secret_access_key = <Secret Key></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 <QueueUrl> \
|
||||||
|
--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 <QueueUrl> \
|
||||||
|
--endpoint-url https://sqs.containerk8s.dev.nubes.ru \
|
||||||
|
--region us-east-1 --profile sqs
|
||||||
|
|
||||||
|
aws sqs delete-message \
|
||||||
|
--queue-url <QueueUrl> \
|
||||||
|
--receipt-handle <ReceiptHandle из ответа receive-message> \
|
||||||
|
--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>
|
||||||
+151
-79
@@ -3,34 +3,36 @@
|
|||||||
app/ui/index.html
|
app/ui/index.html
|
||||||
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||||||
Created: 2026-04-10
|
Created: 2026-04-10
|
||||||
Updated: 2026-04-10 — JWT auth через nubes token, email в navbar, auto-provisioning
|
Updated: 2026-04-12 09:28 MSK — demo token login и UI только для собственного tenant-а
|
||||||
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||||||
Режим: JWT авторизация через nubes API. Пользователь вводит токен → валидация → сессия.
|
Режим: UI принимает либо nubes JWT, либо публичный demo token для seeded demo tenant.
|
||||||
-->
|
-->
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<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,17 +364,29 @@ 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 (nubes JWT)</label>
|
<label for="login-token">API Token или Demo Token</label>
|
||||||
<input id="login-token" type="password" placeholder="eyJhbGciOiJSUzI1NiIs...">
|
<input id="login-token" type="password" placeholder="JWT или demo-ui-shared-sqs-ngcloud-2026">
|
||||||
</div>
|
</div>
|
||||||
<div id="login-error" class="login-error"></div>
|
<div id="login-error" class="login-error"></div>
|
||||||
<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">
|
||||||
Токен можно получить в панели управления облаком Nubes
|
Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br>
|
||||||
|
Для личного аккаунта используйте 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>
|
||||||
|
|
||||||
@@ -345,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">
|
||||||
@@ -364,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">
|
||||||
@@ -384,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=<Access Key>
|
||||||
|
export AWS_SECRET_ACCESS_KEY=<Secret Key></pre>
|
||||||
|
<p class="creds-sub">Способ 2 — файл ~/.aws/credentials:</p>
|
||||||
|
<pre>[sqs]
|
||||||
|
aws_access_key_id = <Access Key>
|
||||||
|
aws_secret_access_key = <Secret Key></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)">
|
||||||
@@ -408,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">
|
||||||
@@ -428,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>
|
||||||
@@ -485,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();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -500,10 +568,10 @@ function showLogin() {
|
|||||||
function doLogin() {
|
function doLogin() {
|
||||||
const token = document.getElementById('login-token').value.trim();
|
const token = document.getElementById('login-token').value.trim();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
document.getElementById('login-error').textContent = 'Введите токен';
|
document.getElementById('login-error').textContent = 'Введите API token или demo token';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById('login-error').textContent = 'Проверка токена...';
|
document.getElementById('login-error').textContent = 'Проверка доступа...';
|
||||||
fetch(BASE + '/ui/api/auth', {
|
fetch(BASE + '/ui/api/auth', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -541,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 =====
|
||||||
@@ -592,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>
|
||||||
@@ -608,15 +678,18 @@ 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>Тенанты</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>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm" onclick="openModal()">+ Создать</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -624,29 +697,23 @@ 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>
|
||||||
<th></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${(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>'
|
||||||
: '<span class="badge badge-inactive">inactive</span>'}</td>
|
: '<span class="badge badge-inactive">inactive</span>'}</td>
|
||||||
<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>
|
||||||
<td>
|
|
||||||
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteTenant('${esc(t.id)}','${esc(t.name)}')">✕</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
${(!tenants || tenants.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет тенантов</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>
|
||||||
@@ -680,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()">Тенанты</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>
|
||||||
@@ -698,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>
|
||||||
@@ -798,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));
|
||||||
@@ -953,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>
|
||||||
|
|||||||
@@ -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=<Access Key></code><br>
|
||||||
|
<code>export AWS_SECRET_ACCESS_KEY=<Secret Key></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>
|
||||||
@@ -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 |
Executable
+25
@@ -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 |
+14
-5
@@ -2,6 +2,7 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -79,12 +80,13 @@ func ExtractQueueAttributes(u url.Values) map[string]string {
|
|||||||
return attr
|
return attr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateErrorResponseV1 — формирует SQS error response по ключу ошибки.
|
||||||
|
// Параметр isSqs оставлен для обратной совместимости (всегда true).
|
||||||
func CreateErrorResponseV1(errKey string, isSqs bool) (int, interfaces.AbstractResponseBody) {
|
func CreateErrorResponseV1(errKey string, isSqs bool) (int, interfaces.AbstractResponseBody) {
|
||||||
var err interfaces.AbstractErrorResponse
|
err, ok := models.SqsErrors[errKey]
|
||||||
if isSqs {
|
if !ok {
|
||||||
err = models.SqsErrors[errKey]
|
// Неизвестный ключ → InternalError вместо nil-deref (ревью 2026-10-11).
|
||||||
} else {
|
err = models.SqsErrors["InternalError"]
|
||||||
err = models.SnsErrors[errKey]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
respStruct := models.ErrorResponse{
|
respStruct := models.ErrorResponse{
|
||||||
@@ -100,6 +102,13 @@ func GetMD5Hash(text string) string {
|
|||||||
return hex.EncodeToString(hasher.Sum(nil))
|
return hex.EncodeToString(hasher.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSHA256Hash — SHA-256 (hex) от значения. Используется для
|
||||||
|
// ContentBasedDeduplication: AWS сам вычисляет MessageDeduplicationId из тела.
|
||||||
|
func GetSHA256Hash(text string) string {
|
||||||
|
sum := sha256.Sum256([]byte(text))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
func HashAttributes(attributes map[string]models.MessageAttribute) string {
|
func HashAttributes(attributes map[string]models.MessageAttribute) string {
|
||||||
hasher := md5.New()
|
hasher := md5.New()
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# deployments/k8s/deployment.yaml
|
|
||||||
# Deployment shared-sqs — strategy RollingUpdate (теперь возможен т.к. Redis хранит состояние)
|
|
||||||
# Updated: 2026-04-10 — v0.1.17: security fixes (20 vulnerabilities), AWS-compatible limits
|
|
||||||
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.17
|
|
||||||
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
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# deployments/k8s/ingress.yaml
|
|
||||||
# Ingress для shared-sqs на домене qu.kube5s.ru
|
|
||||||
# Created: 2026-04-09, Updated: 2026-04-10
|
|
||||||
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"
|
|
||||||
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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# Миграция IoT из Kubernetes → Managed Services (Nubes)
|
||||||
|
## Сессия 2026-08-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Что изучили — текущая архитектура
|
||||||
|
|
||||||
|
### Стек
|
||||||
|
- **Язык:** Go 1.25
|
||||||
|
- **Паттерн:** Kubernetes Operator (controller-runtime)
|
||||||
|
- **Модуль:** `gitea.services.ngcloud.ru/Nail/IoT`
|
||||||
|
- **Docker Hub:** `naeel/iot-operator:v0.2.6`
|
||||||
|
|
||||||
|
### Три бинарника в одном образе
|
||||||
|
| Бинарник | Роль |
|
||||||
|
|---|---|
|
||||||
|
| `cmd/iot-operator` | Controller-manager (CRD IoTDevice) + REST API :9090 |
|
||||||
|
| `cmd/mqtt-bridge` | MQTT (EMQX) → shared-SQS (AWS SDK v2) |
|
||||||
|
| `cmd/sqs-consumer` | shared-SQS → per-tenant Postgres |
|
||||||
|
|
||||||
|
### Поток данных (текущий)
|
||||||
|
```
|
||||||
|
IoT Device (MQTT CONNECT, username="{ns}_{deviceId}")
|
||||||
|
↓
|
||||||
|
EMQX 5.5.1 (HTTP auth/acl → iot-operator:9090/internal/mqtt/auth)
|
||||||
|
↓ MQTT PUBLISH "{ns}/telemetry/{deviceId}"
|
||||||
|
iot-mqtt-bridge (подписка "+/telemetry/+")
|
||||||
|
↓ AWS SDK SQS SendMessage
|
||||||
|
shared-SQS (namespace shared-sqs, очередь "iot-telemetry")
|
||||||
|
↓ long polling (WaitTimeSeconds=20)
|
||||||
|
iot-sqs-consumer (at-least-once, DeleteMessage после успешной записи)
|
||||||
|
↓
|
||||||
|
Managed PostgreSQL 17 (per-tenant DB: tenant_{namespace})
|
||||||
|
↓
|
||||||
|
REST API (iot-operator:9090) → Пользователь (UI/Terraform)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые компоненты k8s
|
||||||
|
- **CRD IoTDevice** (`iot.nubes.ru/v1alpha1`) — регистрация устройств
|
||||||
|
- **k8s Secret** `iot-{deviceId}` — хранит MQTT пароль (64 hex, crypto/rand)
|
||||||
|
- **OwnerReference** — каскадное удаление Secret при удалении IoTDevice
|
||||||
|
- **RBAC** — ClusterRole для чтения/записи IoTDevice + Secrets
|
||||||
|
- **Namespace = ID тенанта** — изоляция устройств и данных
|
||||||
|
|
||||||
|
### Мультитенантность
|
||||||
|
- MQTT topic изоляция: `{ns}/telemetry/{deviceId}` — ACL на уровне EMQX
|
||||||
|
- Per-tenant Postgres DB: `tenant_{namespace}` (дефисы → подчёркивания)
|
||||||
|
- MQTT username: `{namespace}_{deviceId}` — глобально уникален
|
||||||
|
- `sync.Map` кэш `*sql.DB` per tenant — lazy init
|
||||||
|
|
||||||
|
### Наблюдения / потенциальные проблемы
|
||||||
|
1. **`authTestMode = true`** в `middleware/auth.go` — тестовый режим включён в проде, JWT подпись не проверяется
|
||||||
|
2. **`ADMIN_STATS_TOKEN = "iot-admin-2026"`** захардкожен в YAML манифесте вместо Secret
|
||||||
|
3. Устаревшие файлы: `deployments/k8s/kafka.yaml`, `iot-kafka-consumer.yaml` (Kafka удалена, манифесты остались)
|
||||||
|
4. Bridge clientID `"sless-iot-bridge"` захардкожен и в bridge коде и в ACL логике
|
||||||
|
5. `examples/main.tf` и `handler.py` описывают устаревший поток (RabbitMQ/serverless)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Почему хотим уйти из k8s
|
||||||
|
|
||||||
|
- Цель: использовать только **managed services** облака Nubes
|
||||||
|
- Nubes предоставляет: **Managed Node.js, Flask, Lucee, PostgreSQL**
|
||||||
|
- Никаких VPS, никакого k8s — только managed-платформа
|
||||||
|
- SQS тоже **наш собственный сервис** (не сторонний облачный), работает сейчас в k8s, тоже надо вынести
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Принятые решения
|
||||||
|
|
||||||
|
### Node.js — выбранный стек
|
||||||
|
| Задача | Node.js |
|
||||||
|
|---|---|
|
||||||
|
| REST API | Express/Fastify |
|
||||||
|
| MQTT-брокер | `aedes` (embedded, over WebSocket) |
|
||||||
|
| MQTT-клиент для publish | `mqtt` npm |
|
||||||
|
| SQS клиент | `@aws-sdk/client-sqs` |
|
||||||
|
| Postgres | `pg` npm |
|
||||||
|
|
||||||
|
**Почему не Flask:** сложнее держать persistent MQTT и SQS polling в фоне
|
||||||
|
**Почему не Lucee:** не подходит для long-running background workers
|
||||||
|
**Почему Node.js — монолит:** bridge и consumer нельзя масштабировать независимо (один MQTT-клиент = одна подписка), смысла разделять нет
|
||||||
|
|
||||||
|
### EMQX → aedes (embedded в Node.js)
|
||||||
|
- Managed Node.js открывает только HTTP/HTTPS порты
|
||||||
|
- MQTT over WebSocket = HTTP upgrade → работает на любой managed-платформе
|
||||||
|
- Устройства подключаются через `wss://` (уже сейчас так, через ingress emqx-ws-ingress.yaml)
|
||||||
|
- **aedes** — полноценный MQTT-брокер на Node.js, встраивается в Express HTTP-сервер
|
||||||
|
- Auth/ACL становится обычной функцией внутри того же процесса (быстрее, проще)
|
||||||
|
|
||||||
|
### Мультитенантность сохраняется полностью
|
||||||
|
- `namespace` — просто строка в таблице `iot_devices` вместо k8s namespace
|
||||||
|
- Per-tenant Postgres DB остаётся (чистый SQL, без k8s)
|
||||||
|
- MQTT topic изоляция остаётся (`{ns}/telemetry/{deviceId}`)
|
||||||
|
- ACL по топику остаётся — просто функция вместо HTTP endpoint
|
||||||
|
|
||||||
|
### k8s CRD/Secret → таблица в Postgres
|
||||||
|
```sql
|
||||||
|
CREATE TABLE iot_devices (
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
device_id TEXT NOT NULL,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
mqtt_password TEXT NOT NULL, -- было в k8s Secret
|
||||||
|
metadata JSONB,
|
||||||
|
phase TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
PRIMARY KEY (namespace, device_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Итоговая архитектура на Nubes Managed
|
||||||
|
|
||||||
|
```
|
||||||
|
IoT Device (wss://iot.example.ru/mqtt)
|
||||||
|
↓
|
||||||
|
Managed Node.js — IoT сервис
|
||||||
|
├── aedes MQTT-брокер (over WebSocket, порт :3000/mqtt)
|
||||||
|
│ auth/acl → функция → таблица iot_devices в PG
|
||||||
|
│ on publish → SQS SendMessage
|
||||||
|
├── SQS consumer (long polling, фоновый setInterval/async loop)
|
||||||
|
│ → per-tenant Postgres (tenant_{namespace})
|
||||||
|
└── REST API (Express)
|
||||||
|
GET/POST /v1/namespaces/{ns}/iot/devices
|
||||||
|
GET /v1/namespaces/{ns}/iot/telemetry
|
||||||
|
POST /internal/mqtt/auth (совместимость, опционально)
|
||||||
|
GET /console (embedded HTML)
|
||||||
|
GET /iot-admin/stats
|
||||||
|
↓
|
||||||
|
Managed Node.js — shared-SQS сервис ← другие сервисы тоже
|
||||||
|
↓
|
||||||
|
Managed PostgreSQL — IoT данные
|
||||||
|
```
|
||||||
|
|
||||||
|
### Два managed Node.js сервиса
|
||||||
|
| Сервис | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| **shared-SQS** | AWS SQS-совместимая очередь, multi-tenant, HTTP API |
|
||||||
|
| **iot-service** | aedes MQTT + REST API + SQS consumer + PG |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Порядок миграции
|
||||||
|
|
||||||
|
### ⚠️ Сначала shared-SQS, потом IoT
|
||||||
|
|
||||||
|
**Причина:** IoT зависит от SQS. SQS независим — мигрирует первым.
|
||||||
|
|
||||||
|
```
|
||||||
|
Шаг 1: Вынести shared-SQS из k8s → Managed Node.js на Nubes
|
||||||
|
Шаг 2: Переписать IoT сервис на Node.js (aedes + REST + consumer)
|
||||||
|
Шаг 3: Деплой IoT на Managed Node.js на Nubes
|
||||||
|
Шаг 4: Отключить k8s деплой IoT
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Детали для нового чата — shared-SQS миграция
|
||||||
|
|
||||||
|
### Что сейчас
|
||||||
|
- SQS сервис живёт в `namespace: shared-sqs` в кластере `iot-naeel`
|
||||||
|
- Endpoint: `https://sqs.containerk8s.dev.nubes.ru`
|
||||||
|
- Multi-tenant: tenant `iot-service` (id: `t-96afe7e9f781f6ca`), очередь `iot-telemetry`
|
||||||
|
- IoT использует: `SQS_ENDPOINT=https://sqs.containerk8s.dev.nubes.ru`, `SQS_ACCESS_KEY`, `SQS_SECRET_KEY`
|
||||||
|
- Протокол: AWS SQS-совместимый (SendMessage, ReceiveMessage, DeleteMessage, GetQueueUrl, GetQueueAttributes)
|
||||||
|
|
||||||
|
### Что нужно от нового SQS сервиса
|
||||||
|
- AWS SQS-совместимый HTTP API (те же методы что сейчас)
|
||||||
|
- Multi-tenant (разные access key / secret key для разных тенантов)
|
||||||
|
- Очереди создаются по имени (`GetQueueUrl` + `CreateQueue`)
|
||||||
|
- Long polling: `ReceiveMessage` с `WaitTimeSeconds` до 20
|
||||||
|
- Хранение сообщений: in-memory или Postgres/Redis
|
||||||
|
|
||||||
|
### Клиенты SQS в IoT коде
|
||||||
|
1. **mqtt-bridge** (`cmd/mqtt-bridge/main.go`): `SendMessage` при каждом MQTT сообщении
|
||||||
|
2. **sqs-consumer** (`cmd/sqs-consumer/main.go`): `ReceiveMessage` (polling) + `DeleteMessage`
|
||||||
|
3. **iot-admin-stats** (`internal/api/handler/iot_admin_stats_handler.go`): `GetQueueAttributes` для мониторинга
|
||||||
|
|
||||||
|
### Env vars для IoT → SQS
|
||||||
|
```
|
||||||
|
SQS_ENDPOINT=https://sqs.containerk8s.dev.nubes.ru (поменяется на новый managed URL)
|
||||||
|
SQS_ACCESS_KEY=...
|
||||||
|
SQS_SECRET_KEY=...
|
||||||
|
SQS_QUEUE_NAME=iot-telemetry (default)
|
||||||
|
SQS_REGION=us-east-1 (default, не важен для self-hosted)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Что переписывается в IoT (Node.js)
|
||||||
|
|
||||||
|
### Соответствие Go → Node.js
|
||||||
|
|
||||||
|
| Go файл | Node.js файл |
|
||||||
|
|---|---|
|
||||||
|
| `cmd/iot-operator/main.go` | `src/index.js` (точка входа) |
|
||||||
|
| `internal/api/router.go` | `src/routes.js` |
|
||||||
|
| `internal/api/handler/iot_device_handler.go` | `src/handlers/devices.js` |
|
||||||
|
| `internal/api/handler/iot_telemetry_handler.go` | `src/handlers/telemetry.js` |
|
||||||
|
| `internal/api/handler/iot_admin_stats_handler.go` | `src/handlers/admin.js` |
|
||||||
|
| `internal/api/middleware/auth.go` | `src/middleware/auth.js` |
|
||||||
|
| `internal/storage/iotpg/iot_telemetry_store.go` | `src/storage/pg.js` |
|
||||||
|
| `cmd/mqtt-bridge/main.go` | встроен в `src/mqtt.js` (aedes) |
|
||||||
|
| `cmd/sqs-consumer/main.go` | встроен в `src/sqsWorker.js` |
|
||||||
|
| `controllers/iotdevice_controller.go` | **не нужен** (заменён CRUD в БД) |
|
||||||
|
| `api/v1alpha1/device_types.go` | **не нужен** (таблица iot_devices) |
|
||||||
|
|
||||||
|
### npm зависимости
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4",
|
||||||
|
"aedes": "^0.51",
|
||||||
|
"websocket-stream": "^5",
|
||||||
|
"@aws-sdk/client-sqs": "^3",
|
||||||
|
"pg": "^8",
|
||||||
|
"uuid": "^9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Миграция shared-sqs: k8s → Nubes Managed
|
||||||
|
## Сессия 2026-08-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущая архитектура (k8s)
|
||||||
|
|
||||||
|
- Go-бинарник, HTTP-сервер, ~50MB RAM
|
||||||
|
- Deployment в k8s: `naeel/shared-sqs:v0.1.22`
|
||||||
|
- Redis sidecar — write-through persistence
|
||||||
|
- Exposed через Ingress + TLS
|
||||||
|
|
||||||
|
## Целевая архитектура (Nubes Managed)
|
||||||
|
|
||||||
|
| Компонент | Сейчас | После |
|
||||||
|
|---|---|---|
|
||||||
|
| Рантайм | k8s Deployment | Nubes Simple HTTP Container |
|
||||||
|
| Persistence | Redis pod в k8s | Nubes Managed Redis |
|
||||||
|
| Секреты | k8s Secret | Env vars в Container |
|
||||||
|
| Ingress/TLS | k8s Ingress | встроен в Simple HTTP Container |
|
||||||
|
|
||||||
|
**Код не меняется вообще.** Только инфраструктура.
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
Сервис протестирован как AWS SQS совместимый — работает с AWS CLI, AWS SDK, Yandex.
|
||||||
|
|
||||||
|
## Открытые вопросы (уточнить перед деплоем)
|
||||||
|
|
||||||
|
1. Managed Redis: TLS или plain endpoint? → влияет на флаг `REDIS_INSECURE` в коде
|
||||||
|
2. Simple HTTP Container: поддержка custom domain или только `*.containerk8s.services.ngcloud.ru`?
|
||||||
|
|
||||||
|
## План миграции
|
||||||
|
|
||||||
|
**Шаг 1** — Создать Managed Redis на Nubes
|
||||||
|
|
||||||
|
**Шаг 2** — Задеплоить образ в Simple HTTP Container:
|
||||||
|
```
|
||||||
|
image: naeel/shared-sqs:v0.1.22
|
||||||
|
PORT=4100
|
||||||
|
SHARED_SQS_ADMIN_TOKEN=<секрет>
|
||||||
|
REDIS_ADDR=<managed redis endpoint>
|
||||||
|
REDIS_USER=<user>
|
||||||
|
REDIS_PASSWORD=<password>
|
||||||
|
SHARED_SQS_SEED_DEMO=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Шаг 3** — Проверить работоспособность (AWS CLI / SDK)
|
||||||
|
|
||||||
|
**Шаг 4** — Выключить k8s Deployment
|
||||||
|
|
||||||
|
## Что НЕ нужно делать
|
||||||
|
|
||||||
|
- Переписывать код на Node.js/Flask — не нужно
|
||||||
|
- Менять Redis persistence layer — не нужно
|
||||||
|
- Мигрировать данные вручную — Redis восстановит состояние автоматически при старте (LoadAllQueues / LoadAllTenantsRaw)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Разделение доступа пользователей (Tenant Isolation)
|
||||||
|
|
||||||
|
Дата: 2026-04-13
|
||||||
|
|
||||||
|
## Аутентификация (два пути)
|
||||||
|
|
||||||
|
### 1. AWS SigV4 (для SQS-клиентов)
|
||||||
|
|
||||||
|
Стандартная AWS-подпись. В заголовке `Authorization` передаётся `AccessKey` формата `SSAK-{hex}`, по нему находится тенант в `TenantStore`.
|
||||||
|
|
||||||
|
- `AccessKey`: `SSAK-{12 hex bytes}`
|
||||||
|
- `SecretKey`: `{64 hex chars}` (32 random bytes)
|
||||||
|
- Заголовок: `Authorization: AWS4-HMAC-SHA256 Credential={AccessKeyId}/{date}/{region}/sqs/aws4_request, SignedHeaders=..., Signature=...`
|
||||||
|
- Presigned URLs тоже поддерживаются (через `X-Amz-Credential` query parameter)
|
||||||
|
|
||||||
|
### 2. JWT (для UI-консоли)
|
||||||
|
|
||||||
|
Токен от облачной платформы Nubes. При первом входе тенант автоматически создаётся из JWT-клейма `sub`. Возвращаются `access_key` + `secret_key`.
|
||||||
|
|
||||||
|
Клеймы JWT:
|
||||||
|
- `sub` — UUID пользователя (используется для идентификации)
|
||||||
|
- `email` — email пользователя
|
||||||
|
- `exp` — время истечения
|
||||||
|
- `iss` — издатель
|
||||||
|
|
||||||
|
Генерация TenantID из JWT: `SHA256(sub) → первые 8 байт → hex → "sless-{16hex}"`.
|
||||||
|
|
||||||
|
Валидация подписи JWT не делается (trusted perimeter). Проверяется структура, `exp`, и через `PingNubesAPI()` — принимает ли облачный API этот токен.
|
||||||
|
|
||||||
|
## Изоляция очередей
|
||||||
|
|
||||||
|
Каждая очередь в памяти хранится с ключом `"{accessKey}:{queueName}"`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Очередь тенанта A: SSAK-aaa:my-queue
|
||||||
|
Очередь тенанта B: SSAK-bbb:my-queue
|
||||||
|
```
|
||||||
|
|
||||||
|
Все операции (SendMessage, ReceiveMessage, ListQueues и т.д.) строят ключ через `tenantQueueKey(tenant.AccessKey, queueName)` — тенант берётся из контекста запроса. Тенант A физически не может обратиться к ключу `SSAK-bbb:*`, потому что его AccessKey другой.
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
- `app/gosqs/tenant_helpers.go` — `tenantQueueKey()`, `countTenantQueues()`
|
||||||
|
- `app/tenant/tenant_store.go` — `TenantStore` с тремя индексами (`byID`, `byAccessKey`, `bySub`)
|
||||||
|
|
||||||
|
## TenantStore — три индекса
|
||||||
|
|
||||||
|
```
|
||||||
|
byID map[string]*Tenant — для admin API: GetByID(tenantID)
|
||||||
|
byAccessKey map[string]*Tenant — для SQS auth: GetByAccessKey(accessKey)
|
||||||
|
bySub map[string]*Tenant — для JWT auth: GetBySub(sub)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Защита от IDOR в UI
|
||||||
|
|
||||||
|
В `jwtMiddleware` (admin.go) проверяется что `tenantID` из URL совпадает с `tenantID` из JWT-токена. Если не совпадает — 403 Forbidden.
|
||||||
|
|
||||||
|
## Управление тенантами (admin API)
|
||||||
|
|
||||||
|
| Метод | Путь | Назначение | Доступ |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/admin/tenants` | Создать тенанта | Bearer token (admin) |
|
||||||
|
| GET | `/admin/tenants` | Список тенантов | Bearer или JWT (UI видит только себя) |
|
||||||
|
| GET | `/admin/tenants/{id}` | Детали тенанта | Bearer или JWT |
|
||||||
|
| DELETE | `/admin/tenants/{id}` | Удалить тенанта + все очереди | Bearer token (admin) |
|
||||||
|
| GET | `/admin/tenants/{id}/queues` | Очереди тенанта | Bearer или JWT |
|
||||||
|
|
||||||
|
`SecretKey` показывается только при создании. Потом получить нельзя.
|
||||||
|
|
||||||
|
## Роутинг и защита эндпоинтов
|
||||||
|
|
||||||
|
```
|
||||||
|
/health → PUBLIC
|
||||||
|
/metrics → PUBLIC
|
||||||
|
/admin/** → Bearer token auth
|
||||||
|
/ui/api/auth → PUBLIC (вход через JWT)
|
||||||
|
/ui/api/** → JWT auth
|
||||||
|
/ (SQS API) → AWS SigV4 auth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Схема потока запроса
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP запрос
|
||||||
|
↓
|
||||||
|
AuthMiddleware.extractAccessKeyID()
|
||||||
|
↓
|
||||||
|
TenantStore.GetByAccessKey(accessKey)
|
||||||
|
↓
|
||||||
|
verifySigV4 (проверка подписи)
|
||||||
|
↓
|
||||||
|
context.WithValue(TenantContextKey, tenant)
|
||||||
|
↓
|
||||||
|
actionHandler (SendMessage, ReceiveMessage, ...)
|
||||||
|
↓
|
||||||
|
tenantQueueKey(tenant.AccessKey, queueName) → доступ только к своим очередям
|
||||||
|
```
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Биллинг и метрики
|
||||||
|
|
||||||
|
## Биллинг (Usage Tracking)
|
||||||
|
|
||||||
|
Каждая успешная SQS-операция записывается в PostgreSQL — таблица `sqs_usage_records`.
|
||||||
|
|
||||||
|
### Что записывается
|
||||||
|
|
||||||
|
| Поле | Тип | Описание |
|
||||||
|
|------|-----|----------|
|
||||||
|
| `id` | BIGSERIAL | PK, автоинкремент |
|
||||||
|
| `tenant_id` | TEXT | Идентификатор тенанта |
|
||||||
|
| `operation` | TEXT | SQS-операция (SendMessage, ReceiveMessage и т.д.) |
|
||||||
|
| `queue_name` | TEXT | Имя очереди |
|
||||||
|
| `msg_count` | INTEGER | Количество сообщений в запросе |
|
||||||
|
| `msg_bytes` | BIGINT | Объём тел сообщений в байтах |
|
||||||
|
| `recorded_at` | TIMESTAMPTZ | Время записи |
|
||||||
|
|
||||||
|
Индекс: `(tenant_id, recorded_at)`.
|
||||||
|
|
||||||
|
### Конфигурация
|
||||||
|
|
||||||
|
Billing включается автоматически если заданы переменные окружения:
|
||||||
|
|
||||||
|
| Переменная | Пример | Описание |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| `BILLING_PG_HOST` | `postgresqlk8s-master.xxx.svc.cluster.local` | Хост PostgreSQL |
|
||||||
|
| `BILLING_PG_PORT` | `5432` | Порт |
|
||||||
|
| `BILLING_PG_DATABASE` | `sqsdb` | Имя базы |
|
||||||
|
| `BILLING_PG_USER` | `super` | Пользователь |
|
||||||
|
| `BILLING_PG_PASSWORD` | `***` | Пароль |
|
||||||
|
| `BILLING_PG_SSLMODE` | `disable` | SSL режим |
|
||||||
|
|
||||||
|
Если `BILLING_PG_HOST` не задан — billing отключён, SQS работает как раньше без зависимости от PG.
|
||||||
|
|
||||||
|
### Доступ к данным
|
||||||
|
|
||||||
|
Подключение к БД из кластера:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -it pod/postgresqlk8s-master-0 \
|
||||||
|
-n dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5 \
|
||||||
|
-- psql -U super -d sqsdb
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры запросов:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Последние 20 записей
|
||||||
|
SELECT * FROM sqs_usage_records ORDER BY recorded_at DESC LIMIT 20;
|
||||||
|
|
||||||
|
-- Статистика по операциям
|
||||||
|
SELECT operation, COUNT(*), SUM(msg_count), SUM(msg_bytes)
|
||||||
|
FROM sqs_usage_records GROUP BY operation ORDER BY count DESC;
|
||||||
|
|
||||||
|
-- По тенанту за сегодня
|
||||||
|
SELECT operation, COUNT(*) FROM sqs_usage_records
|
||||||
|
WHERE tenant_id = 'MY_TENANT' AND recorded_at >= CURRENT_DATE
|
||||||
|
GROUP BY operation;
|
||||||
|
|
||||||
|
-- Объём трафика по дням
|
||||||
|
SELECT DATE(recorded_at) AS day, SUM(msg_bytes) AS total_bytes
|
||||||
|
FROM sqs_usage_records GROUP BY day ORDER BY day DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prometheus-метрики
|
||||||
|
|
||||||
|
Сервис отдаёт метрики на endpoint `/metrics` в формате Prometheus. Victoria Metrics скрейпит их через VMServiceScrape каждые 30 секунд.
|
||||||
|
|
||||||
|
### Доступные метрики
|
||||||
|
|
||||||
|
| Метрика | Тип | Labels | Описание |
|
||||||
|
|---------|-----|--------|----------|
|
||||||
|
| `sqs_requests_total` | Counter | tenant, operation | Количество SQS-запросов |
|
||||||
|
| `sqs_request_bytes_total` | Counter | tenant, operation | Объём тел запросов (байты) |
|
||||||
|
| `sqs_request_duration_seconds` | Histogram | operation | Latency операций (1ms–30s) |
|
||||||
|
| `sqs_errors_total` | Counter | operation | Количество ошибок (HTTP ≥ 400) |
|
||||||
|
| `sqs_queues_count` | Gauge | tenant | Текущее количество очередей |
|
||||||
|
| `sqs_messages_count` | Gauge | tenant | Текущее количество сообщений |
|
||||||
|
|
||||||
|
### Просмотр метрик
|
||||||
|
|
||||||
|
Напрямую с пода:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl exec -it deploy/shared-sqs -n shared-sqs \
|
||||||
|
-- wget -qO- http://localhost:9090/metrics | grep sqs_
|
||||||
|
```
|
||||||
|
|
||||||
|
### VMServiceScrape
|
||||||
|
|
||||||
|
Файл: `deployments/k8s/vmservicescrape.yaml` — автоматический scrape Victoria Metrics.
|
||||||
|
Namespace `shared-sqs` помечен лейблом `shturval.tech/system-namespace` для обнаружения VMAgent-ом.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
|
||||||
|
Исторические сравнительные тесты vs Yandex MQ (апрель 2026).
|
||||||
|
Перенесён из корня репозитория в doc/legacy 2026-08-13.
|
||||||
|
Замеры устарели, только для справки. -->
|
||||||
|
|
||||||
|
# Сравнительный анализ shared-sqs vs Yandex MQ
|
||||||
|
|
||||||
|
**Дата:** 2026-04-12 06:05 UTC
|
||||||
|
|
||||||
|
Итог сравнительных тестов shared-sqs и Yandex MQ.
|
||||||
|
|
||||||
|
## Главное
|
||||||
|
|
||||||
|
- shared-sqs показал конкурентоспособные результаты относительно Yandex MQ.
|
||||||
|
- На основных API-операциях shared-sqs в текущем прогоне быстрее или на уровне Yandex MQ.
|
||||||
|
- На batch-операциях shared-sqs также выглядит сильно.
|
||||||
|
- Проверкой охвачены все 17 поддерживаемых SQS-операций.
|
||||||
|
|
||||||
|
## Короткий вывод
|
||||||
|
|
||||||
|
Если смотреть на практические сценарии отправки, чтения и batch-обработки сообщений, shared-sqs уже выглядит как сильная реализация с хорошей latency и без явного проигрыша managed-сервису.
|
||||||
|
|
||||||
|
## Самые важные цифры
|
||||||
|
|
||||||
|
Формат: `min / avg / max / p95`, миллисекунды.
|
||||||
|
|
||||||
|
| Операция | Yandex MQ | shared-sqs | Что это значит |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| GetQueueUrl | 766 / 1346 / 2266 / 2060 | 739 / 752 / 770 / 760 | shared-sqs намного стабильнее |
|
||||||
|
| GetQueueAttributes | 804 / 820 / 847 / 828 | 738 / 752 / 768 / 765 | shared-sqs быстрее |
|
||||||
|
| SendMessage 1KB | 804 / 811 / 824 / 823 | 748 / 760 / 770 / 767 | shared-sqs быстрее |
|
||||||
|
| SendMessage 10KB | 916 / 967 / 996 / 987 | 880 / 913 / 965 / 951 | shared-sqs быстрее |
|
||||||
|
| SendMessage 32KB | 905 / 930 / 948 / 947 | 883 / 904 / 939 / 928 | shared-sqs быстрее |
|
||||||
|
| SendMessageBatch 10 | 784 / 803 / 827 / 820 | 718 / 746 / 782 / 759 | shared-sqs быстрее |
|
||||||
|
| ReceiveMessage | 782 / 826 / 869 / 864 | 738 / 748 / 777 / 751 | shared-sqs быстрее |
|
||||||
|
| DeleteMessage | 783 / 800 / 823 / 814 | 727 / 742 / 757 / 755 | shared-sqs быстрее |
|
||||||
|
| DeleteMessageBatch 10 | 818 / 845 / 872 / 864 | 742 / 783 / 821 / 810 | shared-sqs быстрее |
|
||||||
|
| ChangeMessageVisibilityBatch 10 | 806 / 829 / 848 / 841 | 803 / 824 / 838 / 836 | почти паритет, но shared-sqs чуть быстрее |
|
||||||
|
|
||||||
|
## Покрытие тестов по всем операциям
|
||||||
|
|
||||||
|
Ниже показано покрытие по каждой из 17 операций.
|
||||||
|
|
||||||
|
Обозначения:
|
||||||
|
|
||||||
|
- `Сравнение latency` — операция вошла в прямое сравнение shared-sqs и Yandex MQ.
|
||||||
|
- `Функциональная проверка` — операция отдельно проверялась на корректную работу.
|
||||||
|
- `Функционально проверено` — операция подтверждена в shared-sqs, без отдельной публичной latency-таблицы.
|
||||||
|
|
||||||
|
| Операция | Статус проверки | Комментарий |
|
||||||
|
|---|---|---|
|
||||||
|
| CreateQueue | Функциональная проверка | Использовалась в setup и проверялась как часть queue lifecycle |
|
||||||
|
| DeleteQueue | Функциональная проверка | Проверялась как часть queue lifecycle |
|
||||||
|
| GetQueueAttributes | Сравнение latency | Полноценное сравнение с Yandex MQ |
|
||||||
|
| GetQueueUrl | Сравнение latency | Полноценное сравнение с Yandex MQ |
|
||||||
|
| ListQueues | Сравнение latency | Полноценное сравнение с Yandex MQ |
|
||||||
|
| PurgeQueue | Сравнение latency | Контрольный замер, без многократного цикла |
|
||||||
|
| SetQueueAttributes | Сравнение latency | Полноценное сравнение с Yandex MQ |
|
||||||
|
| SendMessage | Сравнение latency | Сравнение для 1KB, 10KB и 32KB |
|
||||||
|
| SendMessageBatch | Сравнение latency | Batch из 10 сообщений |
|
||||||
|
| ReceiveMessage | Сравнение latency | Полноценное сравнение с Yandex MQ |
|
||||||
|
| DeleteMessage | Сравнение latency | Полноценное сравнение с Yandex MQ |
|
||||||
|
| DeleteMessageBatch | Сравнение latency | Batch delete на 10 сообщений |
|
||||||
|
| ChangeMessageVisibility | Сравнение latency | Полноценное сравнение с Yandex MQ |
|
||||||
|
| ChangeMessageVisibilityBatch | Сравнение latency | Batch visibility на 10 сообщений |
|
||||||
|
| TagQueue | Функционально проверено | Операция поддерживается и отдельно проверена |
|
||||||
|
| UntagQueue | Функционально проверено | Операция поддерживается и отдельно проверена |
|
||||||
|
| ListQueueTags | Функционально проверено | Операция поддерживается и отдельно проверена |
|
||||||
|
|
||||||
|
Итог по покрытию:
|
||||||
|
|
||||||
|
- `10` операций вошли в прямое latency-сравнение.
|
||||||
|
- `4` queue/control-plane операции дополнительно подтверждены функциональными сценариями.
|
||||||
|
- `3` tag-операции отдельно подтверждены функционально.
|
||||||
|
|
||||||
|
## Throughput
|
||||||
|
|
||||||
|
Тест: `SendMessage 1KB`, `5` воркеров по `10` сообщений.
|
||||||
|
|
||||||
|
| Провайдер | Успешно | Общее время | Грубая оценка |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| Yandex MQ | 50 / 50 | 9116 ms | ~5 msg/s |
|
||||||
|
| shared-sqs | 50 / 50 | 8580 ms | ~5 msg/s |
|
||||||
|
|
||||||
|
Практический смысл:
|
||||||
|
|
||||||
|
- По грубой оценке `msg/s` здесь паритет.
|
||||||
|
- По общему времени shared-sqs завершает тест немного быстрее.
|
||||||
|
- Основное ограничение этого сценария задаётся AWS CLI, а не backend обоих сервисов.
|
||||||
|
|
||||||
|
## Финальный вывод для пользователя
|
||||||
|
|
||||||
|
shared-sqs уже выглядит достаточно сильным решением: latency на уровне или ниже Yandex MQ, batch-операции быстрые, а в общей рабочей зоне сервис показывает уверенные результаты.
|
||||||
@@ -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)
|
||||||
@@ -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 (или другой агент)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user