Files

65 KiB
Raw Permalink Blame History

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 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/:
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
  1. Поменять module name в go.mod:
module shared-sqs
go 1.22

(Повысить версию Go с 1.18 до 1.22+)

  1. Обновить все import paths:

    • Заменить github.com/Admiral-Piett/goaws/app/shared-sqs/app/
    • Это во ВСЕХ .go файлах
  2. УДАЛИТЬ всё связанное с 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
  3. УДАЛИТЬ тестовые/mock директории (мы напишем свои тесты):

    • app/mocks/
    • app/fixtures/
    • app/servertest/
    • app/test/
    • app/smoke_tests/ (если скопировалась)
  4. Проверить что компилируется:

cd ~/terra/sless/shared-sqs
go mod tidy
go build -o shared-sqs app/cmd/goaws.go
  1. Проверить что стартует:
./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):

  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 (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 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/:
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
  1. Поменять module name в go.mod:
module shared-sqs
go 1.22

(Повысить версию Go с 1.18 до 1.22+)

  1. Обновить все import paths:

    • Заменить github.com/Admiral-Piett/goaws/app/shared-sqs/app/
    • Это во ВСЕХ .go файлах
  2. УДАЛИТЬ всё связанное с 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
  3. УДАЛИТЬ тестовые/mock директории (мы напишем свои тесты):

    • app/mocks/
    • app/fixtures/
    • app/servertest/
    • app/test/
    • app/smoke_tests/ (если скопировалась)
  4. Проверить что компилируется:

cd ~/terra/sless/shared-sqs
go mod tidy
go build -o shared-sqs app/cmd/goaws.go
  1. Проверить что стартует:
./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):

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