# 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 всегда работает