diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5b8a9b3..9888aa0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,63 +1,20 @@ -# Правила +# Правила (кратко) -## ⛔ ОТВЕЧАТЬ КРАТКО — АБСОЛЮТНОЕ ПРАВИЛО -- Вопрос → короткий ответ → СТОП. -- Ничего лишнего. -- Код — только по запросу. +## ⛔⛔⛔ НИКАКИХ ПРЕДПОЛОЖЕНИЙ — ТОЛЬКО ФАКТЫ +- Только проверенные факты. Не проверено — писать «не проверено». + +## ⛔ ОТВЕЧАТЬ КРАТКО +- Вопрос → короткий ответ → СТОП. Код — только по запросу. ## ⛔⛔⛔ ВОПРОС = СТОП +- Любой вопрос → только ответить, ждать «делай». Не начинать работу без команды. -**Если в сообщении есть вопрос в ЛЮБОЙ форме** ("так ?", "верно ?", "почему ?", "как ?", "так же ?" и т.д.): -1. ТОЛЬКО ответить на вопрос -2. ОСТАНОВИТЬСЯ -3. ЖДАТЬ следующей команды -**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай". +## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД +- Никаких изменений кода/инфраструктуры без явного «делай». -## ⛔⛔⛔ НЕ ТРОГАТЬ РАБОЧИЙ КОД — АБСОЛЮТНЫЙ ЗАПРЕТ НАВСЕГДА +## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРИКАЗАНО +- Без додумывания, без «логичных следующих шагов». -**НИКАКИХ самодеятельных изменений рабочего кода:** -- Никаких "оптимизаций", "улучшений", "рефакторинга" без команды -- Никаких новых фич без явного разрешения -- Никаких docker/kubectl/helm команд и прочих инфраструктурных изменений без команды -- Перед ЛЮБЫМ изменением рабочего кода — объяснить ЗАЧЕМ и ждать "делай" +## ЕСЛИ ПРАВИШЬ КОД +- Сначала прочитай /home/naeel/nubes/SQS-service/.github/pravila.md (там: rsync на ВМ, git, docker, ssh, деплой). -1. Не трогать рабочий код без явного указания. - -2. Файлы редактируются локально: - ~/SQS-service - - После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой: - rsync -az \ - -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ - ~/SQS-service/ \ - naeel@5.172.178.213:~/terra/SQS-service/ - - -3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/SQS-service -4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ: - ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА' - - - не выполнять инфраструктурные команды локально - - не открывать интерактивные сессии - - не делать цепочки без необходимости - -4. Перед запуском команд ОБЯЗАТЕЛЬНО убедиться, что синхронизация выполнена. - -5. ЗАПРЕЩЕНО: - - откатывать код - - менять версии - - ломать рабочее состояние - -6. После каждого исправления: - - git add/commit ЛОКАЛЬНО - - затем синхронизация (rsync) на ВМ - -## ⛔⛔⛔ ДЕЛАТЬ ТОЛЬКО ЧТО ПРЯМО ПРИКАЗАНО - -**АБСОЛЮТНЫЙ ЗАПРЕТ на додумывание:** -- Не расширять масштаб работы -- Не выполнять "логичные следующие шаги" -- Не инициировать дополнительные операции -- Не делать ничего кроме того что сказано - -**Исключение:** только если приказ явно включает цепочку ("собери И залей И тесты") diff --git a/.github/pravila.md b/.github/pravila.md index 43aa315..00f54b0 100644 --- a/.github/pravila.md +++ b/.github/pravila.md @@ -1,7 +1,25 @@ # Правила работы агента +## ⛔⛔⛔ НИКАКИХ ПРЕДПОЛОЖЕНИЙ — ТОЛЬКО ФАКТЫ + +**АБСОЛЮТНОЕ ПРАВИЛО:** +- Не выдавать предположения/допущения за факты. +- Говорить только то, что ПРОВЕРЕНО (прочитано, выполнено, подтверждено). +- Если не проверено — прямо писать «не проверено», не додумывать. +- Не строить выводы о реальном состоянии системы на основе манифестов/доков. + +## ⛔⛔⛔ ВОПРОС = СТОП + +**Если в сообщении есть вопрос в ЛЮБОЙ форме:** +1. ТОЛЬКО ответить на вопрос +2. ОСТАНОВИТЬСЯ +3. ЖДАТЬ следующей команды +**ЗАПРЕЩЕНО** начинать работу, писать код, запускать команды — без явного "делай". + ## ⛔⛔⛔ DOCKER — ОБЯЗАТЕЛЬНЫЙ ПОРЯДОК ПЕРЕД КАЖДЫМ BUILD +> ⚠️ ЛЕГАСИ — старый k8s-деплой (kubectl apply). Уходим на Nubes Managed. ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ. + 1. УВЕЛИЧИТЬ ТЕГ в `deployments/k8s/deployment.yaml` (vX.Y.Z → vX.Y.Z+1) 2. rsync на ВМ 3. ПРОВЕРИТЬ что файлы на ВМ новые (grep ключевой строки) @@ -13,14 +31,14 @@ ## Файловая система (актуально) -1. Все файлы редактируются локально: `~/SQS-service` +1. Все файлы редактируются локально: `/home/naeel/nubes/SQS-service` 2. После любых изменений — обязательно rsync на ВМ: rsync -az \ -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ - ~/SQS-service/ \ + /home/naeel/nubes/SQS-service/ \ naeel@5.172.178.213:~/terra/SQS-service/ -3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/SQS-service +3. Git (add/commit/push) выполнять ТОЛЬКО НА ВМ (через SSH) ПОСЛЕ синхронизации 4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ 5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена 6. SCP, sshfs, remote_dev и маунты больше НЕ используются @@ -50,9 +68,9 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout= ## Git ⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО: -- Git — ТОЛЬКО ЛОКАЛЬНО в `~/SQS-service`. НИКОГДА через SSH на VM. -- Разрешены ТОЛЬКО две операции: `git commit` и `git push`. -- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push. +- Git — ТОЛЬКО НА ВМ (через SSH) ПОСЛЕ синхронизации rsync. НИКОГДА локально. +- Разрешены ТОЛЬКО операции: `git add`, `git commit`, `git push` (на ВМ). +- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме add/commit/push. - Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно. Версионирование тагами: `vMAJOR.MINOR.PATCH` @@ -86,7 +104,7 @@ LOG="tests/results/$(date +%Y-%m-%d_%H-%M).log" ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \ "bash ~/terra/SQS-service/tests/run_tests.sh 2>&1 | tee ~/terra/SQS-service/${LOG}" rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \ - naeel@5.172.178.213:~/terra/SQS-service/tests/results/ ~/SQS-service/tests/results/ + naeel@5.172.178.213:~/terra/SQS-service/tests/results/ /home/naeel/nubes/SQS-service/tests/results/ ``` **Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.** diff --git a/HISTORY/2026-08-13-session-log.md b/HISTORY/2026-08-13-session-log.md new file mode 100644 index 0000000..aed516f --- /dev/null +++ b/HISTORY/2026-08-13-session-log.md @@ -0,0 +1,64 @@ +# Журнал сессии — 2026-08-13 (миграция shared-sqs → Nubes Managed) + +**Рабочая папка:** /home/naeel/nubes/SQS-service + +--- + +## Ключевые факты (проверено, актуально) + +### Docker-образ — Gitea registry (НЕ Docker Hub!) +- **Namespace (владелец):** `nail` (владелец репо `Nail` в git remote, в registry — lowercase) +- **Логин для docker login:** `ntazetdinov` +- **Токен:** проверен, работает (ванг: `/v2/` с `ntazetdinov:token` → 200; без auth / неверный → 401) +- **Имя образа:** `gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25` + +### Команды пуша в Gitea registry (на ВМ, через SSH) +```bash +docker login gitea.services.ngcloud.ru -u ntazetdinov -p <токен> +docker build -t gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25 . +docker push gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25 +``` + +### Managed Redis (уже создан, рабочий) +- instanceUid: `4ff5678b-b683-4aef-91da-22d89dec8a25` +- resourceRealm: `iot-naeel` +- internal master: `redisk8s.4ff5678b-b683-4aef-91da-22d89dec8a25.svc.cluster.local:6379` +- user: `default`, пароль в `redis.md` + +### Целевой деплой +- deck-UI → «Простой HTTP контейнер» (nubes_http), realm `iot-naeel` +- Всё в одном кластере, без внешнего доступа к Redis +- env vars контейнера (СРАЗУ, потом не меняются): `SHARED_SQS_ADMIN_TOKEN`, `REDIS_PASSWORD` +- Нечувствительное — в Dockerfile `ENV`: `REDIS_ADDR`, `REDIS_USER`, `NUBES_ENDPOINT`, `SHARED_SQS_SEED_DEMO=false` + +--- + +## Хронология действий + +1. Сохранён план миграции IoT (`doc/2026-08-13-migration-to-managed.md`). +2. Изучен репозиторий SQS-service: Go 1.23, порт 4100, Redis write-through (без TLS), billing опционально. +3. Выяснено: Managed Redis на Nubes есть; «Простой HTTP контейнер» запускает docker-образы. +4. Сохранён план миграции (`doc/2026-08-13-migration-to-nubes.md`). +5. Коммит + push в gitea (обновлены git-креды для `gitea.services.ngcloud.ru`, логин `ntazetdinov`). +6. Поиск «кто создал poc-redis» — локальных следов нет, создано не с этой машины. +7. Проверка утечки кредов в терраформ-репах (JWT, HAR, tfvars) — по требованию пользователя прекращено лезть в чужие репы. +8. `redis.md` приведён в структурированный вид. +9. Промпт для Claude Sonnet v1 — Соннет лазил по всем файлам (слабое ограничение), начитался легаси pearlharbor. +10. Промпт v2 с жёсткими ограничениями (16 файлов, без поиска) — Соннет отработал чисто, план через deck-UI. +11. Создана архивная ветка `archive/2026-08-13-migration-planning`, закоммичены доки + HISTORY, запушено, возврат в main. +12. Правила проекта прочитаны (`.github/copilot-instructions.md`, `.github/pravila.md`, `shared-SQS/AGENTS.md`). +13. Исправлены правила: путь `~/SQS-service` → `/home/naeel/nubes/SQS-service`; git add/commit/push — ТОЛЬКО на ВМ после rsync. +14. Удалены бинарники `goaws` и `cmd` (не в git, уже в .gitignore). +15. Легаси-доки `PLAN.md`, `ONBOARDING.md`, `BENCHMARK-COMPARISON.md` перемещены в `doc/legacy/` с пометкой «ЛЕГАСИ». +16. Определён registry для образа: Gitea (не Docker Hub). Логин `ntazetdinov`, namespace `nail`. + +--- + +## Ошибки сессии (не повторять) + +1. Сначала сохранил в память AI вместо файла, когда просили «в файл». +2. Промпт агенту должен ЖЁСТКО ограничивать файлы и запрещать поиск. +3. Не лезть в чужие репозитории без явной команды. +4. Не упоминать терраформ — деплой только через deck-UI. +5. Ответы агентов проверять на легаси (pearlharbor, kafka, старые планы). +6. Не хардкодить секреты в Dockerfile — только env контейнера. diff --git a/Makefile b/Makefile index 5ec023d..e6fc7a0 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ # Makefile — shared-sqs # Created: 2026-04-09 -# Registry: Docker Hub (naeel/shared-sqs) — pearlharbor не используется (нестабилен) +# Registry: Gitea container registry (gitea.services.ngcloud.ru) — НЕ Docker Hub -IMAGE_REPO=naeel/shared-sqs +IMAGE_REPO=gitea.services.ngcloud.ru/nail/shared-sqs VERSION=v0.1.0 BINARY=shared-sqs diff --git a/README.md b/README.md index 2f6186d..e229dfc 100644 --- a/README.md +++ b/README.md @@ -8,29 +8,34 @@ - **Многотенантность** — каждый тенант имеет отдельный Access Key и Secret Key - **Redis persistence** — очереди и сообщения сохраняются при перезапуске сервиса - **Web UI Console** — управление тенантами и просмотр сообщений -- **Kubernetes-ready** — Helm/манифесты, TLS Ingress, здоровье-checks +- **Managed-ready** — деплой Docker-образом через Nubes («Простой HTTP контейнер»), health-check /health - **Admin API** — управление тенантами программно -## Быстрый старт +## Деплой (Nubes Managed) -### Локально +Сервис деплоится как Docker-образ через «Простой HTTP контейнер» (deck-UI) + Managed Redis. +Никакого Kubernetes/VPS. + +### 1. Собрать и запушить образ в Gitea registry ```bash -docker run -d -p 9090:9090 naeel/shared-sqs:latest -# Откройте http://localhost:9090 +docker build -t gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25 . +docker login gitea.services.ngcloud.ru -u ntazetdinov -p <токен> +docker push gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25 ``` -### На Kubernetes +### 2. Создать контейнер в deck-UI -```bash -kubectl apply -k deployments/k8s/ -``` +- Сервис: «Простой HTTP контейнер» +- Образ: `gitea.services.ngcloud.ru/nail/shared-sqs:v0.1.25` +- Realm: `iot-naeel` +- Env (СРАЗУ, потом не меняются): `SHARED_SQS_ADMIN_TOKEN`, `REDIS_PASSWORD` -### Сборка локально +### Локальный запуск ```bash go build -o shared-sqs ./app/cmd -./shared-sqs --port 9090 +./shared-sqs --port 4100 ``` ## Архитектура @@ -47,26 +52,28 @@ app/ ├── router/ — HTTP маршруты └── ui/ — Web Console (HTML/JS) -deployments/k8s/ -├── deployment.yaml — Deployment с Redis sidecar -├── ingress.yaml — TLS, qu.kube5s.ru -└── redis.yaml — Redis Persistent Volume +deployments/k8s/ — ЛЕГАСИ (старый k8s-деплой, не использовать) +deployments/helm/ — ЛЕГАСИ (helm chart, не использовать) ``` ## Окружение | Переменная | Значение | Описание | |-----------|---------|---------| -| `PORT` | 9090 | Порт HTTP сервера | -| `REDIS_URL` | localhost:6379 | Redis для persistence | +| `PORT` (--port) | 4100 | Порт HTTP сервера | +| `REDIS_ADDR` | host:6379 | Redis для persistence | +| `REDIS_USER` | default | Redis username | +| `REDIS_PASSWORD` | — | Redis password (секрет) | +| `SHARED_SQS_ADMIN_TOKEN` | — | Admin API bearer token (обязательный) | | `SHARED_SQS_SEED_DEMO` | true/false | Загрузить demo данные на старте | +| `NUBES_ENDPOINT` | https://deck-api.ngcloud.ru/api/v1 | JWT-валидация UI | ## API примеры ### Создать очередь (как тенант) ```bash -curl -X POST "http://localhost:9090/" \ +curl -X POST "http://localhost:4100/" \ -H "Authorization: AWS4-HMAC-SHA256 Credential=SSAK-demo-shared-sqs/20260410/us-east-1/sqs/aws4_request" \ -d "Action=CreateQueue&QueueName=myqueue&Version=2012-11-05" ``` @@ -75,8 +82,8 @@ curl -X POST "http://localhost:9090/" \ ```bash aws sqs send-message \ - --endpoint-url http://localhost:9090 \ - --queue-url http://localhost:9090/demo-shared-sqs/myqueue \ + --endpoint-url http://localhost:4100 \ + --queue-url http://localhost:4100/demo-shared-sqs/myqueue \ --message-body "Hello World" ``` @@ -88,12 +95,12 @@ aws sqs send-message \ ## Документация -- [PLAN.md](PLAN.md) — детальный план разработки +- [PLAN.md](doc/legacy/PLAN.md) — ЛЕГАСИ, исторический план разработки - [doc/billing-and-metrics.md](doc/billing-and-metrics.md) — биллинг и Prometheus-метрики - [doc/byoc-credentials.md](doc/byoc-credentials.md) — BYOC интеграция ## Ссылки - Репо: https://gitea.services.ngcloud.ru/Nail/SQS-service -- Demo (dev): https://qu.kube5s.ru +- Registry: https://gitea.services.ngcloud.ru/nail/shared-sqs - Основана на: [GoAws](https://github.com/Admiral-Piett/goaws) diff --git a/deployments/helm/LEGACY.md b/deployments/helm/LEGACY.md new file mode 100644 index 0000000..f7aded1 --- /dev/null +++ b/deployments/helm/LEGACY.md @@ -0,0 +1,5 @@ +# ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ + +Все файлы в этой папке — устаревший Helm chart (до миграции на Nubes Managed). +**ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.** Только для истории. +Помечено: 2026-08-13. diff --git a/deployments/k8s/LEGACY.md b/deployments/k8s/LEGACY.md new file mode 100644 index 0000000..ac1d9ef --- /dev/null +++ b/deployments/k8s/LEGACY.md @@ -0,0 +1,5 @@ +# ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ + +Все файлы в этой папке — устаревший k8s-деплой (до миграции на Nubes Managed). +**ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.** Только для истории. +Помечено: 2026-08-13. diff --git a/doc/decisions/LEGACY.md b/doc/decisions/LEGACY.md new file mode 100644 index 0000000..276d3f7 --- /dev/null +++ b/doc/decisions/LEGACY.md @@ -0,0 +1,5 @@ +# ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ + +Исторические решения этапа разработки (апрель 2026). +**ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.** Только для истории. +Помечено: 2026-08-13. diff --git a/doc/errors/LEGACY.md b/doc/errors/LEGACY.md new file mode 100644 index 0000000..a166571 --- /dev/null +++ b/doc/errors/LEGACY.md @@ -0,0 +1,5 @@ +# ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ + +Исторические инциденты/ошибки этапа разработки. +**ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.** Только для истории. +Помечено: 2026-08-13. diff --git a/doc/legacy/BENCHMARK-COMPARISON.md b/doc/legacy/BENCHMARK-COMPARISON.md new file mode 100644 index 0000000..6d4a68a --- /dev/null +++ b/doc/legacy/BENCHMARK-COMPARISON.md @@ -0,0 +1,93 @@ + + +# Сравнительный анализ 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-операции быстрые, а в общей рабочей зоне сервис показывает уверенные результаты. \ No newline at end of file diff --git a/doc/legacy/ONBOARDING.md b/doc/legacy/ONBOARDING.md new file mode 100644 index 0000000..92a06d8 --- /dev/null +++ b/doc/legacy/ONBOARDING.md @@ -0,0 +1,173 @@ + + +# ONBOARDING.md — Памятка для нового чата / агента +# Дата: 2026-04-10 +# Автор: GitHub Copilot (Claude Opus 4.6) + +--- + +## Что это за проект + +**SQS-service** — multi-tenant сервис очередей сообщений, совместимый с AWS SQS API. +Единственный в open source проект такого рода (подтверждено поиском по GitHub: 0 результатов по `multi-tenant sqs compatible`). + +Ближайшие аналоги — ElasticMQ (Scala) и GoAws (Go), но оба **single-tenant** и предназначены только для local dev/testing. + +--- + +## Откуда взялся код + +Основан на **GoAws** (https://github.com/Admiral-Piett/goaws). +Первый коммит в `sless`: `shared-sqs: Этап 1 — клон GoAWS, удаление SNS, go build OK` +Затем поверх GoAws построены: мультитенантность, auth, admin API, WebUI, Redis persistence. + +--- + +## Текущая версия + +**v0.1.14** — работает в кластере iot-naeel + +| Параметр | Значение | +|----------|---------| +| Endpoint | `https://qu.kube5s.ru` | +| IP | `185.247.187.151` | +| Admin token | `sqs-admin-7a7d8bd0c060a75c198d48680f34077a` | +| Demo Access Key | `SSAK-demo-shared-sqs` | +| Demo Secret Key | `demo-secret-key-shared-sqs-ngcloud-2026` | +| Docker image | `naeel/shared-sqs:v0.1.14` | +| Redis | `rfrm-redisk8s.54467f74-67f5-4ca7-9b4a-bdd50e8c23d6.svc.cluster.local:6379` | +| Kubeconfig | `/home/naeel/.kube/iot-naeel.yaml` (на ВМ) | +| Namespace | `shared-sqs` | + +--- + +## Инфраструктура — ВАЖНО + +### ВМ и монтирование + +- **ВМ**: `naeel@5.172.178.213` +- **SSH-ключ**: `~/.ssh/naeel_vm_id_ed25519` +- **Реальный путь на ВМ**: `/home/naeel/terra/SQS-service` +- **Смонтировано как**: `/home/naeel/remote_dev/SQS-service` (sshfs) + +### ⛔ КРИТИЧЕСКОЕ ПРАВИЛО + +`/home/naeel/remote_dev/SQS-service` — это **СМОНТИРОВАННАЯ ПАПКА**. +Физически файлы на ВМ. **ВСЕ команды выполнять ТОЛЬКО через SSH на ВМ:** + +```bash +ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 \ + naeel@5.172.178.213 'cd /home/naeel/terra/SQS-service && <КОМАНДА>' +``` + +Через IDE — ТОЛЬКО редактирование файлов. Терминальные команды — ТОЛЬКО SSH. + +--- + +## Архитектура кода + +``` +app/ +├── cmd/goaws.go — точка входа, HTTP сервер +├── cmd/seed.go — загрузка демо-данных +├── models/ — Queue, Message, Tenant, SyncQueues (глобальный мьютекс!) +├── gosqs/ — реализация SQS API (CreateQueue, SendMessage, ReceiveMessage и т.д.) +├── admin/admin.go — Admin API: создание/получение тенантов +├── tenant/tenant_store.go — хранилище тенантов (access key → tenant) +├── auth/auth_middleware.go — AWS Signature V4 парсинг credential +├── persistence/redis.go — Redis write-through adapter +├── router/router.go — маршруты HTTP +├── ui/index.html — WebUI (встроен через embed.go) +└── utils/, interfaces/ — утилиты +``` + +--- + +## Известные проблемы (не трогать пока не попросят) + +1. **Глобальный мьютекс** `models.SyncQueues` — Lock/Unlock на ВСЕ операции. Bottleneck при нагрузке. +2. **Нет DLQ** (Dead Letter Queue) +3. **Long Polling naïve** — sleep loop вместо push +4. **Нет rate limiting** per tenant +5. **Нет метрик** (Prometheus) +6. **Single pod** — нет горизонтального масштабирования + +--- + +## Что делать дальше (от пользователя) + +### 1. Статистика для биллинга + +Нужен сбор метрик по каждому тенанту: +- Количество отправленных/полученных сообщений +- Количество созданных очередей +- Объём данных (bytes in/out) +- Время жизни сообщений + +**Где встраивать**: `app/gosqs/send_message.go`, `app/gosqs/receive_message.go`, `app/gosqs/create_queue.go` +**Где хранить**: Новый пакет `app/billing/` или расширить `app/persistence/redis.go` +**Формат**: Redis HINCRBY per tenant per day, или отдельная таблица в PostgreSQL + +### 2. Защита конфиденциальности + +- Тела сообщений не должны логироваться +- Admin API должен быть доступен только из внутренней сети (или по отдельному порту) +- Тенанты не должны видеть друг друга (уже реализовано на уровне auth) +- Рассмотреть шифрование тел сообщений at rest (в Redis) +- Audit log: кто, когда, что делал + +**Где встраивать**: middleware в `app/router/router.go`, новый пакет `app/audit/` + +### 3. Рекомендации по порядку + +1. Сначала **биллинг** — простой счётчик в Redis, можно сделать за 1 сессию +2. Затем **audit log** — middleware логирует tenant_id + action + timestamp +3. Затем **encryption at rest** — обёртка над Redis get/set +4. В последнюю очередь — rate limiting (зависит от биллинга) + +--- + +## Тесты + +| Скрипт | Описание | Как запускать | +|--------|----------|--------------| +| `tests/quick_test.sh` | 6 базовых тестов | `bash tests/quick_test.sh` | +| `tests/hardcore_test.sh` | Суровые тесты | `bash tests/hardcore_test.sh` | +| `tests/shared_sqs_test.sh` | E2E тесты | `bash tests/shared_sqs_test.sh` | + +**ВАЖНО**: В `tests/quick_test.sh` используются demo credentials (`SSAK-demo-shared-sqs`). +В `tests/hardcore_test.sh` может быть admin token — НЕ КОММИТИТЬ в публичные репы! + +--- + +## Git workflow + +- Репо: `https://gitea.services.ngcloud.ru/Nail/SQS-service` +- Ветка: `main` +- Коммитить и пушить после каждого завершённого этапа (правило из copilot-instructions) +- Все команды git — через SSH на ВМ + +--- + +## Сборка и деплой + +```bash +# На ВМ: +cd /home/naeel/terra/SQS-service + +# Сборка +docker build -t naeel/shared-sqs:v0.1.15 . +docker push naeel/shared-sqs:v0.1.15 + +# Деплой +export KUBECONFIG=/home/naeel/.kube/iot-naeel.yaml +kubectl set image deployment/shared-sqs shared-sqs=naeel/shared-sqs:v0.1.15 -n shared-sqs +kubectl rollout status deployment/shared-sqs -n shared-sqs +``` + +--- + +*Создано: 2026-04-10. Обновлять при значимых изменениях.* diff --git a/doc/legacy/PLAN.md b/doc/legacy/PLAN.md new file mode 100644 index 0000000..fdcb021 --- /dev/null +++ b/doc/legacy/PLAN.md @@ -0,0 +1,1693 @@ +# shared-sqs — План реализации + +**Дата:** 2026-04-09 +**Исполнитель:** Claude Sonnet (или другой агент) +**Подготовил:** Claude Opus 4 (анализ GoAWS, архитектура, ловушки) + +--- + +## 1. ЧТО ЭТО + +Multi-tenant SQS-совместимый сервис на базе форка [GoAWS](https://github.com/Admiral-Piett/goaws) (Go, MIT, 835 stars). + +**Отличие от sqs-operator:** sqs-operator деплоит каждому тенанту ОТДЕЛЬНЫЙ pod с ElasticMQ (~300MB RAM каждый). shared-sqs — ОДИН pod обслуживает ВСЕХ тенантов (~50MB RAM base). + +**Что shared-sqs делает:** +- SQS-совместимый API (CreateQueue, SendMessage, ReceiveMessage, DeleteMessage и т.д.) +- Аутентификация по AccessKeyId (из AWS Authorization header) +- Изоляция очередей между тенантами (тенант видит ТОЛЬКО свои очереди) +- Admin API для управления тенантами (CRUD) +- Работает с AWS CLI и AWS SDK без модификаций + +--- + +## 2. АРХИТЕКТУРА GoAWS (то, что форкаем) + +### 2.1 Структура исходников +``` +app/ +├── cmd/goaws.go # Entry point (~40 LOC): флаги, загрузка конфига, HTTP сервер +├── conf/ # Загрузка YAML конфига +├── gosqs/ # SQS handlers (ЯДРО — ~20 файлов) +│ ├── create_queue.go # CreateQueueV1() +│ ├── send_message.go # SendMessageV1() +│ ├── receive_message.go # ReceiveMessageV1() +│ ├── delete_message.go # DeleteMessageV1() +│ ├── delete_message_batch.go +│ ├── delete_queue.go +│ ├── get_queue_attributes.go +│ ├── get_queue_url.go +│ ├── list_queues.go +│ ├── purge_queue.go +│ ├── send_message_batch.go +│ ├── set_queue_attributes.go +│ ├── change_message_visibility.go +│ ├── queue_attributes.go # Helpers для атрибутов +│ └── gosqs.go # PeriodicTasks (visibility timeout, DLQ, dedup) +├── gosns/ # SNS handlers — НЕ НУЖНЫ, УДАЛИТЬ +├── models/ +│ ├── globals.go # SyncQueues, SyncTopics — глобальные map + RWMutex +│ ├── models.go # Queue, SqsMessage, Topic structs +│ ├── configuration.go # Environment, EnvQueue, config structs +│ ├── constants.go +│ ├── conversions.go # Парсинг тел запросов +│ ├── errors.go # AWS-совместимые ошибки +│ ├── helpers.go +│ ├── requests.go # Request structs (CreateQueueRequest, SendMessageRequest и т.д.) +│ └── responses.go # Response structs (XML + JSON) +├── router/ +│ └── router.go # gorilla/mux, actionHandler, routingTableV1 +├── interfaces/ # AbstractResponseBody interface +├── utils/ # Hash, MD5, REQUEST_TRANSFORMER +├── mocks/ # Тестовые моки +├── fixtures/ # Тестовые данные +├── servertest/ +└── test/ +``` + +### 2.2 Критические архитектурные точки + +**Глобальный state** (`models/globals.go`): +```go +var SyncQueues = struct { + sync.RWMutex + Queues map[string]*Queue +}{Queues: make(map[string]*Queue)} +``` +Все очереди храняться В ОДНОМ map. Ключ = имя очереди (string). + +**Роутинг** (`router/router.go`): +```go +r.HandleFunc("/", actionHandler) +r.HandleFunc("/{account}", actionHandler) +r.HandleFunc("/queue/{queueName}", actionHandler) +r.HandleFunc("/{account}/{queueName}", actionHandler) +``` +Все запросы идут в `actionHandler`, который извлекает `Action` из: +- Query param `Action=CreateQueue` (AWS Query Protocol) +- Header `X-Amz-Target: AmazonSQS.CreateQueue` (AWS JSON Protocol) + +**Dispatch table** (`router/router.go`): +```go +var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){ + "CreateQueue": sqs.CreateQueueV1, + "SendMessage": sqs.SendMessageV1, + "ReceiveMessage": sqs.ReceiveMessageV1, + // ... все SQS + SNS actions +} +``` + +**URL конструкция** (в create_queue.go): +```go +queueUrl := "http://" + host + ":" + port + "/" + accountID + "/" + queueName +queueArn := "arn:aws:sqs:" + region + ":" + accountID + ":" + queueName +``` +`accountID` берётся из `models.CurrentEnvironment.AccountID` — ГЛОБАЛЬНАЯ переменная (одна на всех). + +**Зависимости** (go.mod): +- `gorilla/mux v1.8.0` — роутер +- `gorilla/schema v1.4.1` — form decoder +- `google/uuid v1.6.0` — UUID генерация +- `sirupsen/logrus` — логирование +- `ghodss/yaml` — YAML парсинг +- `aws/aws-sdk-go v1.47.3` — только для тестов + +--- + +## 3. ПЛАН ИЗМЕНЕНИЙ + +### 3.0 Общие правила работы + +**КРИТИЧНО — все команды ТОЛЬКО через SSH:** +``` +ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 КОМАНДА +``` + +**Go файлы редактировать ТОЛЬКО через Python patch скрипты на VM**, НЕ через sshfs/VS Code. + +**Markdown/yaml/conf файлы** можно создавать через `cat > file << EOF` по SSH. + +**Перед go build** — убедиться что бинарник в `.gitignore`. + +### Этап 1: Клон GoAWS + чистка (1 час) + +**Действия:** + +1. Клонировать GoAWS в `shared-sqs/`: +```bash +cd ~/terra/sless/shared-sqs +git clone https://github.com/Admiral-Piett/goaws.git _upstream +# Копируем ТОЛЬКО нужное: +cp -r _upstream/app ./app +cp _upstream/go.mod ./go.mod +cp _upstream/go.sum ./go.sum +cp _upstream/Dockerfile ./Dockerfile +rm -rf _upstream +``` + +2. Поменять module name в go.mod: +``` +module shared-sqs +go 1.22 +``` +(Повысить версию Go с 1.18 до 1.22+) + +3. Обновить все import paths: + - Заменить `github.com/Admiral-Piett/goaws/app/` → `shared-sqs/app/` + - Это во ВСЕХ .go файлах + +4. УДАЛИТЬ всё связанное с SNS: + - `app/gosns/` — целиком + - Из `router/router.go` — убрать все SNS записи из `routingTableV1` + - Из `models/globals.go` — убрать `SyncTopics` + - Из `models/models.go` — убрать `Topic`, `Subscription`, `SNSMessage`, `FilterPolicy` + - Из `models/configuration.go` — убрать `EnvTopic`, `EnvSubsciption` + - Из `models/requests.go` и `responses.go` — убрать SNS-related structs + +5. УДАЛИТЬ тестовые/mock директории (мы напишем свои тесты): + - `app/mocks/` + - `app/fixtures/` + - `app/servertest/` + - `app/test/` + - `app/smoke_tests/` (если скопировалась) + +6. Проверить что компилируется: +```bash +cd ~/terra/sless/shared-sqs +go mod tidy +go build -o shared-sqs app/cmd/goaws.go +``` + +7. Проверить что стартует: +```bash +./shared-sqs -debug +# В другом окне: curl http://localhost:4100/health +# Ожидание: 200 OK +``` + +**Тест прохождения этапа:** `go build` успешен, `/health` возвращает 200. + +--- + +### Этап 2: Tenant Model + хранилище (30 мин) + +**Создать файл `app/tenant/tenant.go`:** + +```go +package tenant + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +// Tenant — модель тенанта shared-sqs +type Tenant struct { + ID string // уникальный идентификатор (генерируется) + Name string // имя тенанта (для отображения) + AccessKey string // аналог AWS AccessKeyId + SecretKey string // аналог AWS SecretAccessKey + MaxQueues int // лимит очередей (0 = безлимит) + CreatedAt time.Time + Active bool +} + +// TenantStore — in-memory хранилище тенантов +type TenantStore struct { + sync.RWMutex + // byID — поиск по ID + byID map[string]*Tenant + // byAccessKey — поиск по AccessKey (для auth) + byAccessKey map[string]*Tenant +} + +// NewTenantStore — создаёт пустое хранилище +func NewTenantStore() *TenantStore { ... } + +// Create — создаёт нового тенанта, генерирует ключи +func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) { ... } + +// GetByAccessKey — поиск тенанта по AccessKeyId (для auth middleware) +func (s *TenantStore) GetByAccessKey(accessKey string) (*Tenant, bool) { ... } + +// GetByID — поиск по ID +func (s *TenantStore) GetByID(id string) (*Tenant, bool) { ... } + +// Delete — удаление тенанта +func (s *TenantStore) Delete(id string) bool { ... } + +// List — список всех тенантов +func (s *TenantStore) List() []*Tenant { ... } +``` + +**Генерация ключей (БЕЗОПАСНАЯ):** +```go +func generateAccessKey() string { + // Формат: SSAK-{random hex 12} (SS = Shared SQS) + b := make([]byte, 12) + rand.Read(b) + return "SSAK-" + hex.EncodeToString(b) +} + +func generateSecretKey() string { + // 32 байта random → 64 hex символа + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} +``` + +**ЛОВУШКА #1:** Используй `crypto/rand`, НЕ `math/rand` для ключей. `math/rand` предсказуем. + +**ЛОВУШКА #2:** Два индекса (byID и byAccessKey) — при Delete надо удалить из ОБОИХ. + +**Тест прохождения:** TenantStore создаёт/ищет/удаляет тенантов. Ключи уникальны. + +--- + +### Этап 3: Auth Middleware (30 мин) + +**Создать файл `app/auth/middleware.go`:** + +```go +package auth + +import ( + "context" + "net/http" + "strings" +) + +type contextKey string +const TenantContextKey contextKey = "tenant" + +// AuthMiddleware — извлекает AccessKeyId из AWS Authorization header +// и находит тенанта в store +func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // /health и /admin/** — пропускаем (разная auth) + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/admin/") { + next.ServeHTTP(w, r) + return + } + + accessKeyId := extractAccessKeyId(r) + if accessKeyId == "" { + writeAuthError(w, "MissingAuthenticationToken") + return + } + + t, ok := store.GetByAccessKey(accessKeyId) + if !ok || !t.Active { + writeAuthError(w, "InvalidClientTokenId") + return + } + + ctx := context.WithValue(r.Context(), TenantContextKey, t) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} +``` + +**Извлечение AccessKeyId из Authorization header:** +```go +// extractAccessKeyId — извлекает AWS AccessKeyId из запроса +// Формат header: "AWS4-HMAC-SHA256 Credential={AccessKeyId}/{date}/{region}/sqs/aws4_request, ..." +func extractAccessKeyId(r *http.Request) string { + // Вариант 1: Authorization header (AWS Signature V4) + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { + // Ищем "Credential=" и берём всё до первого "/" + idx := strings.Index(auth, "Credential=") + if idx >= 0 { + rest := auth[idx+len("Credential="):] + slashIdx := strings.Index(rest, "/") + if slashIdx > 0 { + return rest[:slashIdx] + } + } + } + + // Вариант 2: Query parameter (presigned URLs) + if cred := r.URL.Query().Get("X-Amz-Credential"); cred != "" { + parts := strings.SplitN(cred, "/", 2) + if len(parts) > 0 { + return parts[0] + } + } + + return "" +} +``` + +**ЛОВУШКА #3:** AWS CLI ВСЕГДА отправляет Authorization header с Signature V4. Наш middleware должен УМЕТЬ парсить его, даже если мы НЕ ПРОВЕРЯЕМ подпись. + +**ЛОВУШКА #4:** Когда AWS SDK делает `ReceiveMessage` с long polling (WaitTimeSeconds > 0), соединение держится до 20 секунд. Auth middleware не должен ставить таймаут короче. + +**ЛОВУШКА #5:** AWS SDK может отправлять запросы с `X-Amz-Security-Token` (STS). Игнорировать этот header. + +**Тест прохождения:** Запрос с `Authorization: AWS4-HMAC-SHA256 Credential=SSAK-abc123.../...` → тенант найден в контексте. Запрос без header → 403. + +--- + +### Этап 4: Queue Isolation — САМЫЙ КРИТИЧНЫЙ (2 часа) + +Это ЯДРО всех изменений. Все SQS handlers должны работать с tenant-scoped очередями. + +**4.1 Изменение ключей в SyncQueues** + +Сейчас: `SyncQueues.Queues["my-queue"]` +Станет: `SyncQueues.Queues["SSAK-abc123:my-queue"]` + +Формат внутреннего ключа: `{tenantAccessKey}:{queueName}` + +Почему AccessKey а не TenantID: AccessKey уже есть в auth context, не надо лишний lookup. AccessKey уникален. + +**4.2 Helper функции (создать `app/gosqs/tenant_helpers.go`):** + +```go +// tenantQueueKey — внутренний ключ очереди в SyncQueues +func tenantQueueKey(tenantAccessKey, queueName string) string { + return tenantAccessKey + ":" + queueName +} + +// getTenantFromContext — извлекает тенанта из request context +func getTenantFromContext(r *http.Request) *tenant.Tenant { + t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant) + return t +} + +// tenantQueueUrl — формирует URL очереди для тенанта +func tenantQueueUrl(t *tenant.Tenant, queueName string) string { + return "http://" + models.CurrentEnvironment.Host + ":" + + models.CurrentEnvironment.Port + "/" + t.ID + "/" + queueName +} + +// tenantQueueArn — формирует ARN очереди +func tenantQueueArn(t *tenant.Tenant, queueName string) string { + return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName +} +``` + +**4.3 Изменения в КАЖДОМ handler (подробно)** + +**create_queue.go — CreateQueueV1:** +``` +БЫЛО: + queueName := requestBody.QueueName + key := queueName + url := http://host:port/accountID/queueName + +СТАЛО: + t := getTenantFromContext(req) + queueName := requestBody.QueueName + key := tenantQueueKey(t.AccessKey, queueName) + url := tenantQueueUrl(t, queueName) + arn := tenantQueueArn(t, queueName) + // Проверка лимита очередей: + if t.MaxQueues > 0 { + count := countTenantQueues(t.AccessKey) + if count >= t.MaxQueues { + return error "LimitExceeded" + } + } + models.SyncQueues.Queues[key] = queue +``` + +**send_message.go — SendMessageV1:** +``` +БЫЛО: + queueName = lastSegmentOfUrl(queueUrl) + _, ok := models.SyncQueues.Queues[queueName] + +СТАЛО: + t := getTenantFromContext(req) + queueName = lastSegmentOfUrl(queueUrl) + key := tenantQueueKey(t.AccessKey, queueName) + _, ok := models.SyncQueues.Queues[key] +``` + +**receive_message.go — ReceiveMessageV1:** — аналогично send_message + +**delete_message.go — DeleteMessageV1:** — аналогично + +**delete_message_batch.go — DeleteMessageBatchV1:** — аналогично + +**delete_queue.go — DeleteQueueV1:** +``` +СТАЛО: + t := getTenantFromContext(req) + key := tenantQueueKey(t.AccessKey, queueName) + delete(models.SyncQueues.Queues, key) +``` + +**list_queues.go — ListQueuesV1:** +``` +БЫЛО: + for _, queue := range models.SyncQueues.Queues { + urls = append(urls, queue.URL) + } + +СТАЛО: + t := getTenantFromContext(req) + prefix := t.AccessKey + ":" + for key, queue := range models.SyncQueues.Queues { + if strings.HasPrefix(key, prefix) { + urls = append(urls, queue.URL) + } + } +``` + +**get_queue_url.go — GetQueueUrlV1:** — добавить tenant prefix к lookup + +**get_queue_attributes.go — GetQueueAttributesV1:** — добавить tenant prefix + +**set_queue_attributes.go — SetQueueAttributesV1:** — добавить tenant prefix + +**purge_queue.go — PurgeQueueV1:** — добавить tenant prefix + +**change_message_visibility.go — ChangeMessageVisibilityV1:** — добавить tenant prefix + +**send_message_batch.go — SendMessageBatchV1:** — добавить tenant prefix + +**gosqs.go — PeriodicTasks:** — НЕ ТРОГАТЬ. Он итерирует ВСЕ очереди по полному ключу, не по имени. Работает корректно. + +**ЛОВУШКА #6:** `SendMessageV1` извлекает queueName из QueueUrl через `strings.Split(url, "/")` и берёт ПОСЛЕДНИЙ сегмент. Если URL = `http://host:port/tenantID/myqueue`, последний сегмент = `myqueue` — это ПРАВИЛЬНО, не ломается. + +**ЛОВУШКА #7:** `getQueueFromPath()` в gosqs.go тоже парсит URL. Убедиться что при `/{tenantID}/{queueName}` парсинг берёт queueName, а не tenantID. + +**ЛОВУШКА #8:** FIFO очереди имеют имена вида `myqueue.fifo`. Ключ будет `SSAK-xxx:myqueue.fifo` — это OK, `.fifo` стоит в конце имени, не ключа. Проверить что `utils.HasFIFOQueueName()` получает `queueName`, а не `key`. + +**ЛОВУШКА #9:** RedrivePolicy содержит ARN target очереди. При парсинге ARN в DLQ setup — извлечь имя очереди из ARN, затем добавить tenant prefix для lookup. ОБА (основная и DLQ) должны принадлежать одному тенанту. + +**ЛОВУШКА #10:** `QueueUrl` в ответах CreateQueue и GetQueueUrl используется AWS SDK для всех последующих вызовов. Если формат URL неправильный — SDK не сможет Send/Receive. URL ОБЯЗАН содержать tenantID в пути: `http://host:port/{tenantID}/{queueName}`. + +**4.4 Изменения в роутере** + +В `router.go` — маршрут `/{account}/{queueName}` уже существует. `{account}` = наш `{tenantID}`. Но нужно добавить middleware: + +```go +func New(tenantStore *tenant.TenantStore) http.Handler { + r := mux.NewRouter() + r.HandleFunc("/health", health).Methods("GET") + + // Admin API — отдельная auth (bearer token) + admin := r.PathPrefix("/admin").Subrouter() + // ... admin routes (см. Этап 5) + + // SQS API — tenant auth + sqsRouter := r.PathPrefix("/").Subrouter() + sqsRouter.Use(auth.AuthMiddleware(tenantStore)) + sqsRouter.HandleFunc("/", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/{account}", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/queue/{queueName}", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/{account}/{queueName}", actionHandler).Methods("GET", "POST") + + return r +} +``` + +**Тест прохождения этапа 4:** +```bash +# Создать тенанта через Admin API +curl -X POST http://localhost:4100/admin/tenants -H "Authorization: Bearer $ADMIN_TOKEN" \ + -d name:test-tenant +# → возвращает access_key, secret_key + +# Создать очередь как тенант +aws --endpoint-url http://localhost:4100 sqs create-queue --queue-name test-q +# Настроить AWS credentials = access_key/secret_key из шага выше +# → возвращает QueueUrl с tenantID + +# Отправить и получить сообщение +aws --endpoint-url http://localhost:4100 sqs send-message --queue-url $QUEUE_URL --message-body "hello" +aws --endpoint-url http://localhost:4100 sqs receive-message --queue-url $QUEUE_URL +# → сообщение получено + +# Второй тенант НЕ видит очереди первого +# Со вторым access_key: +aws --endpoint-url http://localhost:4100 sqs list-queues +# → пустой список +``` + +--- + +### Этап 5: Admin API (30 мин) + +**Создать файл `app/admin/admin.go`:** + +Эндпоинты: +``` +POST /admin/tenants — создать тенанта +GET /admin/tenants — список тенантов +GET /admin/tenants/{id} — детали тенанта +DELETE /admin/tenants/{id} — удалить тенанта (и ВСЕ его очереди!) +GET /admin/health — детальный health (кол-во тенантов, очередей, сообщений) +``` + +**Auth для Admin API:** Header `Authorization: Bearer {admin_token}`. Admin token задаётся через: +- Переменная окружения `SHARED_SQS_ADMIN_TOKEN` +- Или в конфиге YAML + +**ЛОВУШКА #11:** При DELETE тенанта — ОБЯЗАТЕЛЬНО удалить ВСЕ его очереди из SyncQueues. Иначе memory leak. Итерировать SyncQueues.Queues, удалить все ключи с prefix `{accessKey}:`. + +**ЛОВУШКА #12:** Admin API НЕ должен быть доступен через тот же ingress что SQS API. Либо другой path prefix, либо другой порт. Рекомендация: path prefix `/admin/`, защищённый bearer token. НЕ ЗАБЫТЬ в auth middleware пропускать `/admin/` пути. + +**Request/Response форматы:** + +POST /admin/tenants: +```json +Request: {"name": "customer-001", "max_queues": 50} +Response: {"id": "t-a1b2c3", "name": "customer-001", "access_key": "SSAK-...", "secret_key": "...", "max_queues": 50} +``` +Важно: secret_key показывается ТОЛЬКО при создании. В List/Get — не включать. + +--- + +### Этап 6: Entry Point + Configuration (20 мин) + +**Модифицировать `app/cmd/goaws.go` (переименовать в `app/cmd/main.go`):** + +```go +func main() { + // Флаги + var configFile string + var adminToken string + var port string + flag.StringVar(&configFile, "config", "", "config file") + flag.StringVar(&adminToken, "admin-token", "", "admin API token") + flag.StringVar(&port, "port", "4100", "listen port") + flag.Parse() + + // Admin token: flag > env > config + if adminToken == "" { + adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN") + } + if adminToken == "" { + log.Fatal("admin token required: use --admin-token or SHARED_SQS_ADMIN_TOKEN env") + } + + // Инициализация + tenantStore := tenant.NewTenantStore() + + // Загрузка конфига (если указан) — может содержать pre-created тенантов + if configFile != "" { + conf.LoadConfig(configFile, tenantStore) + } + + // Роутер + r := router.New(tenantStore, adminToken) + + // Periodic tasks + quit := make(chan bool) + go gosqs.PeriodicTasks(1*time.Second, quit) + + // Graceful shutdown + // ... (signal handling, quit channel) + + log.Infof("shared-sqs listening on 0.0.0.0:%s", port) + log.Fatal(http.ListenAndServe("0.0.0.0:"+port, r)) +} +``` + +**ЛОВУШКА #13:** GoAWS не обрабатывает сигналы. ДОБАВИТЬ graceful shutdown (SIGTERM/SIGINT → закрыть quit channel → остановить PeriodicTasks → дождаться завершения). + +--- + +### Этап 7: Dockerfile + Kubernetes (30 мин) + +**Dockerfile:** +```dockerfile +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o shared-sqs app/cmd/main.go + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates +COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs +EXPOSE 4100 +ENTRYPOINT ["shared-sqs"] +``` + +**Kubernetes manifests (`deployments/k8s/`):** + +deployment.yaml: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: shared-sqs + namespace: shared-sqs +spec: + replicas: 1 + strategy: + type: Recreate # НЕ RollingUpdate! Урок из ERR-SQS-06. + selector: + matchLabels: + app: shared-sqs + template: + spec: + containers: + - name: shared-sqs + image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs:v0.1.0 + ports: + - containerPort: 4100 + env: + - name: SHARED_SQS_ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: shared-sqs-admin + key: token + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 4100 + readinessProbe: + httpGet: + path: /health + port: 4100 +``` + +**ЛОВУШКА #14:** strategy: Recreate, НЕ RollingUpdate. In-memory state не шарится между подами. При RollingUpdate новый pod стартует с пустым state, а старый ещё жив = split brain. + +service.yaml, ingress.yaml — стандартные. + +**Реестр:** `pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs` + +--- + +### Этап 8: Makefile (10 мин) + +```makefile +IMAGE_REPO=pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs +VERSION=v0.1.0 + +build: +CGO_ENABLED=0 go build -o shared-sqs app/cmd/main.go + +docker-build: +docker build -t $(IMAGE_REPO):$(VERSION) . + +docker-push: +docker push $(IMAGE_REPO):$(VERSION) + +test: +go test ./... + +run: +./shared-sqs --admin-token=dev-token-123 --port=4100 -debug + +clean: +rm -f shared-sqs +``` + +--- + +### Этап 9: Тесты (1 час) + +**Создать `tests/shared_sqs_test.sh`** — bash тесты (как в customer-sqs): + +1. Admin API: + - Создать тенанта + - Список тенантов + - Удалить тенанта + - Попытка без admin token → 401 + +2. Изоляция: + - Тенант A создаёт очередь "q1" + - Тенант B создаёт очередь "q1" (ДОЛЖНА создаться — разные тенанты!) + - Тенант A видит только свою q1 + - Тенант B видит только свою q1 + - Тенант A отправляет сообщение → Тенант B НЕ получает его + +3. CRUD: стандартный Create → Send → Receive → Delete flow + +4. Лимиты: тенант с max_queues=2 не может создать 3-ю очередь + +--- + +## 4. ИТОГОВАЯ СТРУКТУРА ПРОЕКТА + +``` +shared-sqs/ +├── app/ +│ ├── cmd/ +│ │ └── main.go # Entry point +│ ├── admin/ +│ │ └── admin_handlers.go # Admin API handlers +│ ├── auth/ +│ │ └── auth_middleware.go # Auth middleware +│ ├── tenant/ +│ │ └── tenant_store.go # Tenant model + in-memory store +│ ├── gosqs/ +│ │ ├── create_queue.go # Modified: tenant-scoped +│ │ ├── send_message.go # Modified: tenant-scoped +│ │ ├── receive_message.go # Modified: tenant-scoped +│ │ ├── delete_message.go # Modified: tenant-scoped +│ │ ├── ... (все handlers) +│ │ ├── tenant_helpers.go # NEW: tenant key/url/arn helpers +│ │ └── gosqs.go # PeriodicTasks (без изменений) +│ ├── models/ +│ │ ├── globals.go # Без SyncTopics +│ │ ├── models.go # Без SNS structs +│ │ ├── configuration.go # Упрощённый +│ │ ├── requests.go # Только SQS requests +│ │ └── responses.go # Только SQS responses +│ ├── router/ +│ │ └── router.go # С middleware и admin routes +│ ├── interfaces/ +│ └── utils/ +├── deployments/ +│ └── k8s/ +│ ├── deployment.yaml +│ ├── service.yaml +│ └── ingress.yaml +├── tests/ +│ └── shared_sqs_test.sh +├── doc/ +│ └── PLAN.md # Этот файл +├── Dockerfile +├── Makefile +├── go.mod +├── go.sum +├── .gitignore +└── README.md +``` + +--- + +## 5. ВСЕ ЛОВУШКИ (СВОДКА) + +| # | Ловушка | Где | Последствие если пропустить | +|---|---------|-----|---------------------------| +| 1 | `crypto/rand` vs `math/rand` для ключей | tenant.go | Предсказуемые ключи = уязвимость | +| 2 | Delete tenant: удалить из ОБОИХ индексов (byID + byAccessKey) | tenant.go | Orphaned данные | +| 3 | AWS CLI отправляет Signature V4 — нужно парсить | middleware.go | SDK не работает | +| 4 | Long polling до 20 сек — не ставить короткий таймаут | middleware.go | ReceiveMessage обрывается | +| 5 | X-Amz-Security-Token — игнорировать | middleware.go | Ложная ошибка auth | +| 6 | URL parsing: последний сегмент = queueName | send_message.go | Берёт tenantID вместо queueName | +| 7 | getQueueFromPath() парсит URL — проверить с новым форматом | gosqs.go | Неправильное имя очереди | +| 8 | FIFO: HasFIFOQueueName() должен получить queueName, не key | create_queue.go | FIFO не работает | +| 9 | DLQ ARN → queue name → tenant prefix | set_queue_attributes.go | Чужая DLQ или not found | +| 10 | QueueUrl в ответах ОБЯЗАН содержать tenantID | create_queue.go, get_queue_url.go | SDK не может send/receive | +| 11 | DELETE tenant → удалить ВСЕ очереди | admin.go | Memory leak | +| 12 | Admin API под отдельной auth (bearer token) | router.go | Тенант = admin | +| 13 | Graceful shutdown (SIGTERM) | main.go | Потеря данных при restart | +| 14 | Deployment strategy: Recreate, НЕ RollingUpdate | deployment.yaml | Split brain | +| 15 | .gitignore: бинарник shared-sqs | .gitignore | Бинарник в git | +| 16 | go mod tidy после чистки SNS | go.mod | Лишние зависимости | +| 17 | Все import paths: goaws → shared-sqs | *.go | Не компилируется | + +--- + +## 6. ПОРЯДОК ВЫПОЛНЕНИЯ + +1. Этап 1 → go build → /health работает +2. Этап 2 → tenant store готов (можно юнит-тестом проверить) +3. Этап 3 → middleware готов +4. Этап 4 → САМЫЙ БОЛЬШОЙ. Делать handler за handler, каждый раз проверяя go build +5. Этап 5 → Admin API +6. Этап 6 → Wiring всего вместе в main.go +7. Этап 7 → Docker + K8s +8. Этап 8 → Makefile +9. Этап 9 → Тесты + +**После каждого этапа: `go build` должен проходить. НЕ НАКАПЛИВАТЬ ошибки компиляции.** + +--- + +## 7. ЧЕГО НЕ ДЕЛАЕМ (scope out) + +- ❌ Persistence (восстановление после рестарта) — in-memory OK для MVP +- ❌ AWS Signature V4 verification — только извлечение AccessKeyId +- ❌ SNS — удаляем полностью +- ❌ HTTPS — TLS на ingress, не в приложении +- ❌ Rate limiting — можно добавить позже +- ❌ Metrics/Prometheus — можно добавить позже +- ❌ UI — нет UI, только API +- ❌ Scale-to-zero — один pod всегда работает +ENDOFPLAN cat > ~/terra/sless/shared-sqs/PLAN.md << 'ENDOFPLAN' +# shared-sqs — План реализации + +**Дата:** 2026-04-09 +**Исполнитель:** Claude Sonnet (или другой агент) +**Подготовил:** Claude Opus 4 (анализ GoAWS, архитектура, ловушки) + +--- + +## 1. ЧТО ЭТО + +Multi-tenant SQS-совместимый сервис на базе форка [GoAWS](https://github.com/Admiral-Piett/goaws) (Go, MIT, 835 stars). + +**Отличие от sqs-operator:** sqs-operator деплоит каждому тенанту ОТДЕЛЬНЫЙ pod с ElasticMQ (~300MB RAM каждый). shared-sqs — ОДИН pod обслуживает ВСЕХ тенантов (~50MB RAM base). + +**Что shared-sqs делает:** +- SQS-совместимый API (CreateQueue, SendMessage, ReceiveMessage, DeleteMessage и т.д.) +- Аутентификация по AccessKeyId (из AWS Authorization header) +- Изоляция очередей между тенантами (тенант видит ТОЛЬКО свои очереди) +- Admin API для управления тенантами (CRUD) +- Работает с AWS CLI и AWS SDK без модификаций + +--- + +## 2. АРХИТЕКТУРА GoAWS (то, что форкаем) + +### 2.1 Структура исходников +``` +app/ +├── cmd/goaws.go # Entry point (~40 LOC): флаги, загрузка конфига, HTTP сервер +├── conf/ # Загрузка YAML конфига +├── gosqs/ # SQS handlers (ЯДРО — ~20 файлов) +│ ├── create_queue.go # CreateQueueV1() +│ ├── send_message.go # SendMessageV1() +│ ├── receive_message.go # ReceiveMessageV1() +│ ├── delete_message.go # DeleteMessageV1() +│ ├── delete_message_batch.go +│ ├── delete_queue.go +│ ├── get_queue_attributes.go +│ ├── get_queue_url.go +│ ├── list_queues.go +│ ├── purge_queue.go +│ ├── send_message_batch.go +│ ├── set_queue_attributes.go +│ ├── change_message_visibility.go +│ ├── queue_attributes.go # Helpers для атрибутов +│ └── gosqs.go # PeriodicTasks (visibility timeout, DLQ, dedup) +├── gosns/ # SNS handlers — НЕ НУЖНЫ, УДАЛИТЬ +├── models/ +│ ├── globals.go # SyncQueues, SyncTopics — глобальные map + RWMutex +│ ├── models.go # Queue, SqsMessage, Topic structs +│ ├── configuration.go # Environment, EnvQueue, config structs +│ ├── constants.go +│ ├── conversions.go # Парсинг тел запросов +│ ├── errors.go # AWS-совместимые ошибки +│ ├── helpers.go +│ ├── requests.go # Request structs (CreateQueueRequest, SendMessageRequest и т.д.) +│ └── responses.go # Response structs (XML + JSON) +├── router/ +│ └── router.go # gorilla/mux, actionHandler, routingTableV1 +├── interfaces/ # AbstractResponseBody interface +├── utils/ # Hash, MD5, REQUEST_TRANSFORMER +├── mocks/ # Тестовые моки +├── fixtures/ # Тестовые данные +├── servertest/ +└── test/ +``` + +### 2.2 Критические архитектурные точки + +**Глобальный state** (`models/globals.go`): +```go +var SyncQueues = struct { + sync.RWMutex + Queues map[string]*Queue +}{Queues: make(map[string]*Queue)} +``` +Все очереди храняться В ОДНОМ map. Ключ = имя очереди (string). + +**Роутинг** (`router/router.go`): +```go +r.HandleFunc("/", actionHandler) +r.HandleFunc("/{account}", actionHandler) +r.HandleFunc("/queue/{queueName}", actionHandler) +r.HandleFunc("/{account}/{queueName}", actionHandler) +``` +Все запросы идут в `actionHandler`, который извлекает `Action` из: +- Query param `Action=CreateQueue` (AWS Query Protocol) +- Header `X-Amz-Target: AmazonSQS.CreateQueue` (AWS JSON Protocol) + +**Dispatch table** (`router/router.go`): +```go +var routingTableV1 = map[string]func(r *http.Request) (int, interfaces.AbstractResponseBody){ + "CreateQueue": sqs.CreateQueueV1, + "SendMessage": sqs.SendMessageV1, + "ReceiveMessage": sqs.ReceiveMessageV1, + // ... все SQS + SNS actions +} +``` + +**URL конструкция** (в create_queue.go): +```go +queueUrl := "http://" + host + ":" + port + "/" + accountID + "/" + queueName +queueArn := "arn:aws:sqs:" + region + ":" + accountID + ":" + queueName +``` +`accountID` берётся из `models.CurrentEnvironment.AccountID` — ГЛОБАЛЬНАЯ переменная (одна на всех). + +**Зависимости** (go.mod): +- `gorilla/mux v1.8.0` — роутер +- `gorilla/schema v1.4.1` — form decoder +- `google/uuid v1.6.0` — UUID генерация +- `sirupsen/logrus` — логирование +- `ghodss/yaml` — YAML парсинг +- `aws/aws-sdk-go v1.47.3` — только для тестов + +--- + +## 3. ПЛАН ИЗМЕНЕНИЙ + +### 3.0 Общие правила работы + +**КРИТИЧНО — все команды ТОЛЬКО через SSH:** +``` +ssh -i /home/naeel/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 КОМАНДА +``` + +**Go файлы редактировать ТОЛЬКО через Python patch скрипты на VM**, НЕ через sshfs/VS Code. + +**Markdown/yaml/conf файлы** можно создавать через `cat > file << EOF` по SSH. + +**Перед go build** — убедиться что бинарник в `.gitignore`. + +### Этап 1: Клон GoAWS + чистка (1 час) + +**Действия:** + +1. Клонировать GoAWS в `shared-sqs/`: +```bash +cd ~/terra/sless/shared-sqs +git clone https://github.com/Admiral-Piett/goaws.git _upstream +# Копируем ТОЛЬКО нужное: +cp -r _upstream/app ./app +cp _upstream/go.mod ./go.mod +cp _upstream/go.sum ./go.sum +cp _upstream/Dockerfile ./Dockerfile +rm -rf _upstream +``` + +2. Поменять module name в go.mod: +``` +module shared-sqs +go 1.22 +``` +(Повысить версию Go с 1.18 до 1.22+) + +3. Обновить все import paths: + - Заменить `github.com/Admiral-Piett/goaws/app/` → `shared-sqs/app/` + - Это во ВСЕХ .go файлах + +4. УДАЛИТЬ всё связанное с SNS: + - `app/gosns/` — целиком + - Из `router/router.go` — убрать все SNS записи из `routingTableV1` + - Из `models/globals.go` — убрать `SyncTopics` + - Из `models/models.go` — убрать `Topic`, `Subscription`, `SNSMessage`, `FilterPolicy` + - Из `models/configuration.go` — убрать `EnvTopic`, `EnvSubsciption` + - Из `models/requests.go` и `responses.go` — убрать SNS-related structs + +5. УДАЛИТЬ тестовые/mock директории (мы напишем свои тесты): + - `app/mocks/` + - `app/fixtures/` + - `app/servertest/` + - `app/test/` + - `app/smoke_tests/` (если скопировалась) + +6. Проверить что компилируется: +```bash +cd ~/terra/sless/shared-sqs +go mod tidy +go build -o shared-sqs app/cmd/goaws.go +``` + +7. Проверить что стартует: +```bash +./shared-sqs -debug +# В другом окне: curl http://localhost:4100/health +# Ожидание: 200 OK +``` + +**Тест прохождения этапа:** `go build` успешен, `/health` возвращает 200. + +--- + +### Этап 2: Tenant Model + хранилище (30 мин) + +**Создать файл `app/tenant/tenant.go`:** + +```go +package tenant + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +// Tenant — модель тенанта shared-sqs +type Tenant struct { + ID string // уникальный идентификатор (генерируется) + Name string // имя тенанта (для отображения) + AccessKey string // аналог AWS AccessKeyId + SecretKey string // аналог AWS SecretAccessKey + MaxQueues int // лимит очередей (0 = безлимит) + CreatedAt time.Time + Active bool +} + +// TenantStore — in-memory хранилище тенантов +type TenantStore struct { + sync.RWMutex + // byID — поиск по ID + byID map[string]*Tenant + // byAccessKey — поиск по AccessKey (для auth) + byAccessKey map[string]*Tenant +} + +// NewTenantStore — создаёт пустое хранилище +func NewTenantStore() *TenantStore { ... } + +// Create — создаёт нового тенанта, генерирует ключи +func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) { ... } + +// GetByAccessKey — поиск тенанта по AccessKeyId (для auth middleware) +func (s *TenantStore) GetByAccessKey(accessKey string) (*Tenant, bool) { ... } + +// GetByID — поиск по ID +func (s *TenantStore) GetByID(id string) (*Tenant, bool) { ... } + +// Delete — удаление тенанта +func (s *TenantStore) Delete(id string) bool { ... } + +// List — список всех тенантов +func (s *TenantStore) List() []*Tenant { ... } +``` + +**Генерация ключей (БЕЗОПАСНАЯ):** +```go +func generateAccessKey() string { + // Формат: SSAK-{random hex 12} (SS = Shared SQS) + b := make([]byte, 12) + rand.Read(b) + return "SSAK-" + hex.EncodeToString(b) +} + +func generateSecretKey() string { + // 32 байта random → 64 hex символа + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} +``` + +**ЛОВУШКА #1:** Используй `crypto/rand`, НЕ `math/rand` для ключей. `math/rand` предсказуем. + +**ЛОВУШКА #2:** Два индекса (byID и byAccessKey) — при Delete надо удалить из ОБОИХ. + +**Тест прохождения:** TenantStore создаёт/ищет/удаляет тенантов. Ключи уникальны. + +--- + +### Этап 3: Auth Middleware (30 мин) + +**Создать файл `app/auth/middleware.go`:** + +```go +package auth + +import ( + "context" + "net/http" + "strings" +) + +type contextKey string +const TenantContextKey contextKey = "tenant" + +// AuthMiddleware — извлекает AccessKeyId из AWS Authorization header +// и находит тенанта в store +func AuthMiddleware(store *tenant.TenantStore) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // /health и /admin/** — пропускаем (разная auth) + if r.URL.Path == "/health" { + next.ServeHTTP(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/admin/") { + next.ServeHTTP(w, r) + return + } + + accessKeyId := extractAccessKeyId(r) + if accessKeyId == "" { + writeAuthError(w, "MissingAuthenticationToken") + return + } + + t, ok := store.GetByAccessKey(accessKeyId) + if !ok || !t.Active { + writeAuthError(w, "InvalidClientTokenId") + return + } + + ctx := context.WithValue(r.Context(), TenantContextKey, t) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} +``` + +**Извлечение AccessKeyId из Authorization header:** +```go +// extractAccessKeyId — извлекает AWS AccessKeyId из запроса +// Формат header: "AWS4-HMAC-SHA256 Credential={AccessKeyId}/{date}/{region}/sqs/aws4_request, ..." +func extractAccessKeyId(r *http.Request) string { + // Вариант 1: Authorization header (AWS Signature V4) + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { + // Ищем "Credential=" и берём всё до первого "/" + idx := strings.Index(auth, "Credential=") + if idx >= 0 { + rest := auth[idx+len("Credential="):] + slashIdx := strings.Index(rest, "/") + if slashIdx > 0 { + return rest[:slashIdx] + } + } + } + + // Вариант 2: Query parameter (presigned URLs) + if cred := r.URL.Query().Get("X-Amz-Credential"); cred != "" { + parts := strings.SplitN(cred, "/", 2) + if len(parts) > 0 { + return parts[0] + } + } + + return "" +} +``` + +**ЛОВУШКА #3:** AWS CLI ВСЕГДА отправляет Authorization header с Signature V4. Наш middleware должен УМЕТЬ парсить его, даже если мы НЕ ПРОВЕРЯЕМ подпись. + +**ЛОВУШКА #4:** Когда AWS SDK делает `ReceiveMessage` с long polling (WaitTimeSeconds > 0), соединение держится до 20 секунд. Auth middleware не должен ставить таймаут короче. + +**ЛОВУШКА #5:** AWS SDK может отправлять запросы с `X-Amz-Security-Token` (STS). Игнорировать этот header. + +**Тест прохождения:** Запрос с `Authorization: AWS4-HMAC-SHA256 Credential=SSAK-abc123.../...` → тенант найден в контексте. Запрос без header → 403. + +--- + +### Этап 4: Queue Isolation — САМЫЙ КРИТИЧНЫЙ (2 часа) + +Это ЯДРО всех изменений. Все SQS handlers должны работать с tenant-scoped очередями. + +**4.1 Изменение ключей в SyncQueues** + +Сейчас: `SyncQueues.Queues["my-queue"]` +Станет: `SyncQueues.Queues["SSAK-abc123:my-queue"]` + +Формат внутреннего ключа: `{tenantAccessKey}:{queueName}` + +Почему AccessKey а не TenantID: AccessKey уже есть в auth context, не надо лишний lookup. AccessKey уникален. + +**4.2 Helper функции (создать `app/gosqs/tenant_helpers.go`):** + +```go +// tenantQueueKey — внутренний ключ очереди в SyncQueues +func tenantQueueKey(tenantAccessKey, queueName string) string { + return tenantAccessKey + ":" + queueName +} + +// getTenantFromContext — извлекает тенанта из request context +func getTenantFromContext(r *http.Request) *tenant.Tenant { + t, _ := r.Context().Value(auth.TenantContextKey).(*tenant.Tenant) + return t +} + +// tenantQueueUrl — формирует URL очереди для тенанта +func tenantQueueUrl(t *tenant.Tenant, queueName string) string { + return "http://" + models.CurrentEnvironment.Host + ":" + + models.CurrentEnvironment.Port + "/" + t.ID + "/" + queueName +} + +// tenantQueueArn — формирует ARN очереди +func tenantQueueArn(t *tenant.Tenant, queueName string) string { + return "arn:aws:sqs:" + models.CurrentEnvironment.Region + ":" + t.ID + ":" + queueName +} +``` + +**4.3 Изменения в КАЖДОМ handler (подробно)** + +**create_queue.go — CreateQueueV1:** +``` +БЫЛО: + queueName := requestBody.QueueName + key := queueName + url := http://host:port/accountID/queueName + +СТАЛО: + t := getTenantFromContext(req) + queueName := requestBody.QueueName + key := tenantQueueKey(t.AccessKey, queueName) + url := tenantQueueUrl(t, queueName) + arn := tenantQueueArn(t, queueName) + // Проверка лимита очередей: + if t.MaxQueues > 0 { + count := countTenantQueues(t.AccessKey) + if count >= t.MaxQueues { + return error "LimitExceeded" + } + } + models.SyncQueues.Queues[key] = queue +``` + +**send_message.go — SendMessageV1:** +``` +БЫЛО: + queueName = lastSegmentOfUrl(queueUrl) + _, ok := models.SyncQueues.Queues[queueName] + +СТАЛО: + t := getTenantFromContext(req) + queueName = lastSegmentOfUrl(queueUrl) + key := tenantQueueKey(t.AccessKey, queueName) + _, ok := models.SyncQueues.Queues[key] +``` + +**receive_message.go — ReceiveMessageV1:** — аналогично send_message + +**delete_message.go — DeleteMessageV1:** — аналогично + +**delete_message_batch.go — DeleteMessageBatchV1:** — аналогично + +**delete_queue.go — DeleteQueueV1:** +``` +СТАЛО: + t := getTenantFromContext(req) + key := tenantQueueKey(t.AccessKey, queueName) + delete(models.SyncQueues.Queues, key) +``` + +**list_queues.go — ListQueuesV1:** +``` +БЫЛО: + for _, queue := range models.SyncQueues.Queues { + urls = append(urls, queue.URL) + } + +СТАЛО: + t := getTenantFromContext(req) + prefix := t.AccessKey + ":" + for key, queue := range models.SyncQueues.Queues { + if strings.HasPrefix(key, prefix) { + urls = append(urls, queue.URL) + } + } +``` + +**get_queue_url.go — GetQueueUrlV1:** — добавить tenant prefix к lookup + +**get_queue_attributes.go — GetQueueAttributesV1:** — добавить tenant prefix + +**set_queue_attributes.go — SetQueueAttributesV1:** — добавить tenant prefix + +**purge_queue.go — PurgeQueueV1:** — добавить tenant prefix + +**change_message_visibility.go — ChangeMessageVisibilityV1:** — добавить tenant prefix + +**send_message_batch.go — SendMessageBatchV1:** — добавить tenant prefix + +**gosqs.go — PeriodicTasks:** — НЕ ТРОГАТЬ. Он итерирует ВСЕ очереди по полному ключу, не по имени. Работает корректно. + +**ЛОВУШКА #6:** `SendMessageV1` извлекает queueName из QueueUrl через `strings.Split(url, "/")` и берёт ПОСЛЕДНИЙ сегмент. Если URL = `http://host:port/tenantID/myqueue`, последний сегмент = `myqueue` — это ПРАВИЛЬНО, не ломается. + +**ЛОВУШКА #7:** `getQueueFromPath()` в gosqs.go тоже парсит URL. Убедиться что при `/{tenantID}/{queueName}` парсинг берёт queueName, а не tenantID. + +**ЛОВУШКА #8:** FIFO очереди имеют имена вида `myqueue.fifo`. Ключ будет `SSAK-xxx:myqueue.fifo` — это OK, `.fifo` стоит в конце имени, не ключа. Проверить что `utils.HasFIFOQueueName()` получает `queueName`, а не `key`. + +**ЛОВУШКА #9:** RedrivePolicy содержит ARN target очереди. При парсинге ARN в DLQ setup — извлечь имя очереди из ARN, затем добавить tenant prefix для lookup. ОБА (основная и DLQ) должны принадлежать одному тенанту. + +**ЛОВУШКА #10:** `QueueUrl` в ответах CreateQueue и GetQueueUrl используется AWS SDK для всех последующих вызовов. Если формат URL неправильный — SDK не сможет Send/Receive. URL ОБЯЗАН содержать tenantID в пути: `http://host:port/{tenantID}/{queueName}`. + +**4.4 Изменения в роутере** + +В `router.go` — маршрут `/{account}/{queueName}` уже существует. `{account}` = наш `{tenantID}`. Но нужно добавить middleware: + +```go +func New(tenantStore *tenant.TenantStore) http.Handler { + r := mux.NewRouter() + r.HandleFunc("/health", health).Methods("GET") + + // Admin API — отдельная auth (bearer token) + admin := r.PathPrefix("/admin").Subrouter() + // ... admin routes (см. Этап 5) + + // SQS API — tenant auth + sqsRouter := r.PathPrefix("/").Subrouter() + sqsRouter.Use(auth.AuthMiddleware(tenantStore)) + sqsRouter.HandleFunc("/", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/{account}", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/queue/{queueName}", actionHandler).Methods("GET", "POST") + sqsRouter.HandleFunc("/{account}/{queueName}", actionHandler).Methods("GET", "POST") + + return r +} +``` + +**Тест прохождения этапа 4:** +```bash +# Создать тенанта через Admin API +curl -X POST http://localhost:4100/admin/tenants -H "Authorization: Bearer $ADMIN_TOKEN" \ + -d max_queues:10 +# → возвращает access_key, secret_key + +# Создать очередь как тенант +aws --endpoint-url http://localhost:4100 sqs create-queue --queue-name test-q +# Настроить AWS credentials = access_key/secret_key из шага выше +# → возвращает QueueUrl с tenantID + +# Отправить и получить сообщение +aws --endpoint-url http://localhost:4100 sqs send-message --queue-url $QUEUE_URL --message-body "hello" +aws --endpoint-url http://localhost:4100 sqs receive-message --queue-url $QUEUE_URL +# → сообщение получено + +# Второй тенант НЕ видит очереди первого +# Со вторым access_key: +aws --endpoint-url http://localhost:4100 sqs list-queues +# → пустой список +``` + +--- + +### Этап 5: Admin API (30 мин) + +**Создать файл `app/admin/admin.go`:** + +Эндпоинты: +``` +POST /admin/tenants — создать тенанта +GET /admin/tenants — список тенантов +GET /admin/tenants/{id} — детали тенанта +DELETE /admin/tenants/{id} — удалить тенанта (и ВСЕ его очереди!) +GET /admin/health — детальный health (кол-во тенантов, очередей, сообщений) +``` + +**Auth для Admin API:** Header `Authorization: Bearer {admin_token}`. Admin token задаётся через: +- Переменная окружения `SHARED_SQS_ADMIN_TOKEN` +- Или в конфиге YAML + +**ЛОВУШКА #11:** При DELETE тенанта — ОБЯЗАТЕЛЬНО удалить ВСЕ его очереди из SyncQueues. Иначе memory leak. Итерировать SyncQueues.Queues, удалить все ключи с prefix `{accessKey}:`. + +**ЛОВУШКА #12:** Admin API НЕ должен быть доступен через тот же ingress что SQS API. Либо другой path prefix, либо другой порт. Рекомендация: path prefix `/admin/`, защищённый bearer token. НЕ ЗАБЫТЬ в auth middleware пропускать `/admin/` пути. + +**Request/Response форматы:** + +POST /admin/tenants: +```json +Request: {"name": "customer-001", "max_queues": 50} +Response: {"id": "t-a1b2c3", "name": "customer-001", "access_key": "SSAK-...", "secret_key": "...", "max_queues": 50} +``` +Важно: secret_key показывается ТОЛЬКО при создании. В List/Get — не включать. + +--- + +### Этап 6: Entry Point + Configuration (20 мин) + +**Модифицировать `app/cmd/goaws.go` (переименовать в `app/cmd/main.go`):** + +```go +func main() { + // Флаги + var configFile string + var adminToken string + var port string + flag.StringVar(&configFile, "config", "", "config file") + flag.StringVar(&adminToken, "admin-token", "", "admin API token") + flag.StringVar(&port, "port", "4100", "listen port") + flag.Parse() + + // Admin token: flag > env > config + if adminToken == "" { + adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN") + } + if adminToken == "" { + log.Fatal("admin token required: use --admin-token or SHARED_SQS_ADMIN_TOKEN env") + } + + // Инициализация + tenantStore := tenant.NewTenantStore() + + // Загрузка конфига (если указан) — может содержать pre-created тенантов + if configFile != "" { + conf.LoadConfig(configFile, tenantStore) + } + + // Роутер + r := router.New(tenantStore, adminToken) + + // Periodic tasks + quit := make(chan bool) + go gosqs.PeriodicTasks(1*time.Second, quit) + + // Graceful shutdown + // ... (signal handling, quit channel) + + log.Infof("shared-sqs listening on 0.0.0.0:%s", port) + log.Fatal(http.ListenAndServe("0.0.0.0:"+port, r)) +} +``` + +**ЛОВУШКА #13:** GoAWS не обрабатывает сигналы. ДОБАВИТЬ graceful shutdown (SIGTERM/SIGINT → закрыть quit channel → остановить PeriodicTasks → дождаться завершения). + +--- + +### Этап 7: Dockerfile + Kubernetes (30 мин) + +**Dockerfile:** +```dockerfile +FROM golang:1.22-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o shared-sqs app/cmd/main.go + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates +COPY --from=builder /build/shared-sqs /usr/local/bin/shared-sqs +EXPOSE 4100 +ENTRYPOINT ["shared-sqs"] +``` + +**Kubernetes manifests (`deployments/k8s/`):** + +deployment.yaml: +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: shared-sqs + namespace: shared-sqs +spec: + replicas: 1 + strategy: + type: Recreate # НЕ RollingUpdate! Урок из ERR-SQS-06. + selector: + matchLabels: + app: shared-sqs + template: + spec: + containers: + - name: shared-sqs + image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs:v0.1.0 + ports: + - containerPort: 4100 + env: + - name: SHARED_SQS_ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: shared-sqs-admin + key: token + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 4100 + readinessProbe: + httpGet: + path: /health + port: 4100 +``` + +**ЛОВУШКА #14:** strategy: Recreate, НЕ RollingUpdate. In-memory state не шарится между подами. При RollingUpdate новый pod стартует с пустым state, а старый ещё жив = split brain. + +service.yaml, ingress.yaml — стандартные. + +**Реестр:** `pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs` + +--- + +### Этап 8: Makefile (10 мин) + +```makefile +IMAGE_REPO=pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs +VERSION=v0.1.0 + +build: +CGO_ENABLED=0 go build -o shared-sqs app/cmd/main.go + +docker-build: +docker build -t $(IMAGE_REPO):$(VERSION) . + +docker-push: +docker push $(IMAGE_REPO):$(VERSION) + +test: +go test ./... + +run: +./shared-sqs --admin-token=dev-token-123 --port=4100 -debug + +clean: +rm -f shared-sqs +``` + +--- + +### Этап 9: Тесты (1 час) + +**Создать `tests/shared_sqs_test.sh`** — bash тесты (как в customer-sqs): + +1. Admin API: + - Создать тенанта + - Список тенантов + - Удалить тенанта + - Попытка без admin token → 401 + +2. Изоляция: + - Тенант A создаёт очередь "q1" + - Тенант B создаёт очередь "q1" (ДОЛЖНА создаться — разные тенанты!) + - Тенант A видит только свою q1 + - Тенант B видит только свою q1 + - Тенант A отправляет сообщение → Тенант B НЕ получает его + +3. CRUD: стандартный Create → Send → Receive → Delete flow + +4. Лимиты: тенант с max_queues=2 не может создать 3-ю очередь + +--- + +## 4. ИТОГОВАЯ СТРУКТУРА ПРОЕКТА + +``` +shared-sqs/ +├── app/ +│ ├── cmd/ +│ │ └── main.go # Entry point +│ ├── admin/ +│ │ └── admin_handlers.go # Admin API handlers +│ ├── auth/ +│ │ └── auth_middleware.go # Auth middleware +│ ├── tenant/ +│ │ └── tenant_store.go # Tenant model + in-memory store +│ ├── gosqs/ +│ │ ├── create_queue.go # Modified: tenant-scoped +│ │ ├── send_message.go # Modified: tenant-scoped +│ │ ├── receive_message.go # Modified: tenant-scoped +│ │ ├── delete_message.go # Modified: tenant-scoped +│ │ ├── ... (все handlers) +│ │ ├── tenant_helpers.go # NEW: tenant key/url/arn helpers +│ │ └── gosqs.go # PeriodicTasks (без изменений) +│ ├── models/ +│ │ ├── globals.go # Без SyncTopics +│ │ ├── models.go # Без SNS structs +│ │ ├── configuration.go # Упрощённый +│ │ ├── requests.go # Только SQS requests +│ │ └── responses.go # Только SQS responses +│ ├── router/ +│ │ └── router.go # С middleware и admin routes +│ ├── interfaces/ +│ └── utils/ +├── deployments/ +│ └── k8s/ +│ ├── deployment.yaml +│ ├── service.yaml +│ └── ingress.yaml +├── tests/ +│ └── shared_sqs_test.sh +├── doc/ +│ └── PLAN.md # Этот файл +├── Dockerfile +├── Makefile +├── go.mod +├── go.sum +├── .gitignore +└── README.md +``` + +--- + +## 5. ВСЕ ЛОВУШКИ (СВОДКА) + +| # | Ловушка | Где | Последствие если пропустить | +|---|---------|-----|---------------------------| +| 1 | `crypto/rand` vs `math/rand` для ключей | tenant.go | Предсказуемые ключи = уязвимость | +| 2 | Delete tenant: удалить из ОБОИХ индексов (byID + byAccessKey) | tenant.go | Orphaned данные | +| 3 | AWS CLI отправляет Signature V4 — нужно парсить | middleware.go | SDK не работает | +| 4 | Long polling до 20 сек — не ставить короткий таймаут | middleware.go | ReceiveMessage обрывается | +| 5 | X-Amz-Security-Token — игнорировать | middleware.go | Ложная ошибка auth | +| 6 | URL parsing: последний сегмент = queueName | send_message.go | Берёт tenantID вместо queueName | +| 7 | getQueueFromPath() парсит URL — проверить с новым форматом | gosqs.go | Неправильное имя очереди | +| 8 | FIFO: HasFIFOQueueName() должен получить queueName, не key | create_queue.go | FIFO не работает | +| 9 | DLQ ARN → queue name → tenant prefix | set_queue_attributes.go | Чужая DLQ или not found | +| 10 | QueueUrl в ответах ОБЯЗАН содержать tenantID | create_queue.go, get_queue_url.go | SDK не может send/receive | +| 11 | DELETE tenant → удалить ВСЕ очереди | admin.go | Memory leak | +| 12 | Admin API под отдельной auth (bearer token) | router.go | Тенант = admin | +| 13 | Graceful shutdown (SIGTERM) | main.go | Потеря данных при restart | +| 14 | Deployment strategy: Recreate, НЕ RollingUpdate | deployment.yaml | Split brain | +| 15 | .gitignore: бинарник shared-sqs | .gitignore | Бинарник в git | +| 16 | go mod tidy после чистки SNS | go.mod | Лишние зависимости | +| 17 | Все import paths: goaws → shared-sqs | *.go | Не компилируется | + +--- + +## 6. ПОРЯДОК ВЫПОЛНЕНИЯ + +1. Этап 1 → go build → /health работает +2. Этап 2 → tenant store готов (можно юнит-тестом проверить) +3. Этап 3 → middleware готов +4. Этап 4 → САМЫЙ БОЛЬШОЙ. Делать handler за handler, каждый раз проверяя go build +5. Этап 5 → Admin API +6. Этап 6 → Wiring всего вместе в main.go +7. Этап 7 → Docker + K8s +8. Этап 8 → Makefile +9. Этап 9 → Тесты + +**После каждого этапа: `go build` должен проходить. НЕ НАКАПЛИВАТЬ ошибки компиляции.** + +--- + +## 7. ЧЕГО НЕ ДЕЛАЕМ (scope out) + +- ❌ Persistence (восстановление после рестарта) — in-memory OK для MVP +- ❌ AWS Signature V4 verification — только извлечение AccessKeyId +- ❌ SNS — удаляем полностью +- ❌ HTTPS — TLS на ingress, не в приложении +- ❌ Rate limiting — можно добавить позже +- ❌ Metrics/Prometheus — можно добавить позже +- ❌ UI — нет UI, только API +- ❌ Scale-to-zero — один pod всегда работает diff --git a/doc/progress.md b/doc/progress.md index 73d69f1..4b318ec 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -1,3 +1,6 @@ + + # SQS-service Progress ## Версия v0.1.x diff --git a/doc/thinking/LEGACY.md b/doc/thinking/LEGACY.md new file mode 100644 index 0000000..241ba79 --- /dev/null +++ b/doc/thinking/LEGACY.md @@ -0,0 +1,5 @@ +# ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ + +Исторические размышления/заметки этапа разработки. +**ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.** Только для истории. +Помечено: 2026-08-13. diff --git a/tests/LEGACY.md b/tests/LEGACY.md new file mode 100644 index 0000000..6b4118f --- /dev/null +++ b/tests/LEGACY.md @@ -0,0 +1,5 @@ +# ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ + +Старые тестовые скрипты (этап k8s-деплоя, домен qu.kube5s.ru). +**ЭТИМ НЕ РУКОВОДСТВОВАТЬСЯ.** Только для истории. +Помечено: 2026-08-13.