Files
SQS-service/doc/legacy/PLAN.md
T

1699 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
Исторический план реализации shared-sqs (апрель 2026).
Перенесён из корня репозитория в doc/legacy 2026-08-13.
Содержит устаревшие схемы и ссылки (pearlharbor, sqs-operator). -->
# 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 — План реализации (heredoc-дубль)
**Дата:** 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 всегда работает