- Standalone SQS-service repository - Multi-tenant message queue service, AWS SQS compatible - Based on GoAws, with mutable tenants, auth, WebUI, Redis persistence - Ready for independent development and deployment - See doc/ and README.md for architecture and usage
65 KiB
shared-sqs — План реализации
Дата: 2026-04-09 Исполнитель: Claude Sonnet (или другой агент) Подготовил: Claude Opus 4 (анализ GoAWS, архитектура, ловушки)
1. ЧТО ЭТО
Multi-tenant SQS-совместимый сервис на базе форка 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):
var SyncQueues = struct {
sync.RWMutex
Queues map[string]*Queue
}{Queues: make(map[string]*Queue)}
Все очереди храняться В ОДНОМ map. Ключ = имя очереди (string).
Роутинг (router/router.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):
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):
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 decodergoogle/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 час)
Действия:
- Клонировать GoAWS в
shared-sqs/:
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
- Поменять module name в go.mod:
module shared-sqs
go 1.22
(Повысить версию Go с 1.18 до 1.22+)
-
Обновить все import paths:
- Заменить
github.com/Admiral-Piett/goaws/app/→shared-sqs/app/ - Это во ВСЕХ .go файлах
- Заменить
-
УДАЛИТЬ всё связанное с 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
-
УДАЛИТЬ тестовые/mock директории (мы напишем свои тесты):
app/mocks/app/fixtures/app/servertest/app/test/app/smoke_tests/(если скопировалась)
-
Проверить что компилируется:
cd ~/terra/sless/shared-sqs
go mod tidy
go build -o shared-sqs app/cmd/goaws.go
- Проверить что стартует:
./shared-sqs -debug
# В другом окне: curl http://localhost:4100/health
# Ожидание: 200 OK
Тест прохождения этапа: go build успешен, /health возвращает 200.
Этап 2: Tenant Model + хранилище (30 мин)
Создать файл app/tenant/tenant.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 { ... }
Генерация ключей (БЕЗОПАСНАЯ):
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:
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:
// 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):
// 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:
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:
# Создать тенанта через 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:
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):
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:
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:
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 мин)
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):
-
Admin API:
- Создать тенанта
- Список тенантов
- Удалить тенанта
- Попытка без admin token → 401
-
Изоляция:
- Тенант A создаёт очередь "q1"
- Тенант B создаёт очередь "q1" (ДОЛЖНА создаться — разные тенанты!)
- Тенант A видит только свою q1
- Тенант B видит только свою q1
- Тенант A отправляет сообщение → Тенант B НЕ получает его
-
CRUD: стандартный Create → Send → Receive → Delete flow
-
Лимиты: тенант с 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 → go build → /health работает
- Этап 2 → tenant store готов (можно юнит-тестом проверить)
- Этап 3 → middleware готов
- Этап 4 → САМЫЙ БОЛЬШОЙ. Делать handler за handler, каждый раз проверяя go build
- Этап 5 → Admin API
- Этап 6 → Wiring всего вместе в main.go
- Этап 7 → Docker + K8s
- Этап 8 → Makefile
- Этап 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 (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):
var SyncQueues = struct {
sync.RWMutex
Queues map[string]*Queue
}{Queues: make(map[string]*Queue)}
Все очереди храняться В ОДНОМ map. Ключ = имя очереди (string).
Роутинг (router/router.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):
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):
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 decodergoogle/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 час)
Действия:
- Клонировать GoAWS в
shared-sqs/:
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
- Поменять module name в go.mod:
module shared-sqs
go 1.22
(Повысить версию Go с 1.18 до 1.22+)
-
Обновить все import paths:
- Заменить
github.com/Admiral-Piett/goaws/app/→shared-sqs/app/ - Это во ВСЕХ .go файлах
- Заменить
-
УДАЛИТЬ всё связанное с 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
-
УДАЛИТЬ тестовые/mock директории (мы напишем свои тесты):
app/mocks/app/fixtures/app/servertest/app/test/app/smoke_tests/(если скопировалась)
-
Проверить что компилируется:
cd ~/terra/sless/shared-sqs
go mod tidy
go build -o shared-sqs app/cmd/goaws.go
- Проверить что стартует:
./shared-sqs -debug
# В другом окне: curl http://localhost:4100/health
# Ожидание: 200 OK
Тест прохождения этапа: go build успешен, /health возвращает 200.
Этап 2: Tenant Model + хранилище (30 мин)
Создать файл app/tenant/tenant.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 { ... }
Генерация ключей (БЕЗОПАСНАЯ):
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:
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:
// 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):
// 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:
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:
# Создать тенанта через 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:
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):
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:
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:
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 мин)
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):
-
Admin API:
- Создать тенанта
- Список тенантов
- Удалить тенанта
- Попытка без admin token → 401
-
Изоляция:
- Тенант A создаёт очередь "q1"
- Тенант B создаёт очередь "q1" (ДОЛЖНА создаться — разные тенанты!)
- Тенант A видит только свою q1
- Тенант B видит только свою q1
- Тенант A отправляет сообщение → Тенант B НЕ получает его
-
CRUD: стандартный Create → Send → Receive → Delete flow
-
Лимиты: тенант с 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 → go build → /health работает
- Этап 2 → tenant store готов (можно юнит-тестом проверить)
- Этап 3 → middleware готов
- Этап 4 → САМЫЙ БОЛЬШОЙ. Делать handler за handler, каждый раз проверяя go build
- Этап 5 → Admin API
- Этап 6 → Wiring всего вместе в main.go
- Этап 7 → Docker + K8s
- Этап 8 → Makefile
- Этап 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 всегда работает