1699 lines
65 KiB
Markdown
1699 lines
65 KiB
Markdown
<!-- ⚠️ ЛЕГАСИ — НЕ ИСПОЛЬЗОВАТЬ КАК РУКОВОДСТВО.
|
||
Исторический план реализации 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 всегда работает
|