Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab1e47bd35 | ||
|
|
570d1e3a60 | ||
|
|
f878c4cc6f | ||
|
|
990a89e7aa | ||
|
|
2c5871ab87 | ||
|
|
0ec7c2fbbb | ||
|
|
67817d5480 | ||
|
|
bab90f6947 | ||
|
|
610c604b69 |
@@ -1942,3 +1942,36 @@ G15 перезапущен → **21/21 PASS ✅**
|
|||||||
- [ ] DB_DSN в function pod env
|
- [ ] DB_DSN в function pod env
|
||||||
- [ ] schema.sql при деплое функции
|
- [ ] schema.sql при деплое функции
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-04-09 (вечер) — shared-sqs v0.1.6: Queue CRUD + Message UI
|
||||||
|
|
||||||
|
### Реализовано
|
||||||
|
|
||||||
|
**Backend (shared-sqs/app/admin/admin.go):**
|
||||||
|
- `POST /tenants/{id}/queues` — создание очереди (с проверкой лимита MaxQueues)
|
||||||
|
- `DELETE /tenants/{id}/queues/{name}` — удаление очереди
|
||||||
|
- `GET /tenants/{id}/queues/{name}/messages` — peek-просмотр сообщений (read-only, без ReceiptHandle)
|
||||||
|
- `POST /tenants/{id}/queues/{name}/messages` — отправка сообщения через admin API
|
||||||
|
- `DELETE /tenants/{id}/queues/{name}/messages` — purge (очистка без удаления очереди)
|
||||||
|
|
||||||
|
**Frontend (shared-sqs/app/ui/index.html):**
|
||||||
|
- Expandable строки очередей: клик на имя → inline-таблица сообщений
|
||||||
|
- Кнопки действий: 📨 Отправить / 🗑 Очистить / ✕ Удалить
|
||||||
|
- Модалка отправки сообщения (`#modal-send`)
|
||||||
|
- Модалка детали сообщения (`#modal-msg-detail`) — c копированием тела
|
||||||
|
- Модалка создания очереди (`#modal-queue-create`)
|
||||||
|
- JS: `msgCache` для безопасной передачи тела без onclick attrs
|
||||||
|
|
||||||
|
**Сборка:**
|
||||||
|
- `naeel/shared-sqs:v0.1.6` собран и запушен на Docker Hub
|
||||||
|
|
||||||
|
**Git:** коммит `610c604`, ветка `shared-sqs`
|
||||||
|
|
||||||
|
### Осталось
|
||||||
|
|
||||||
|
- [ ] Задеплоить v0.1.6 в K8s (kubectl-токен истёк, нужно обновить)
|
||||||
|
```
|
||||||
|
kubectl -n shared-sqs set image deployment/shared-sqs shared-sqs=naeel/shared-sqs:v0.1.6
|
||||||
|
```
|
||||||
|
|||||||
@@ -287,3 +287,63 @@ go build → OK (все этапы 1-8)
|
|||||||
### Коммит
|
### Коммит
|
||||||
- `12b3bb9` — feat(shared-sqs): add SQS Console UI (v0.1.4)
|
- `12b3bb9` — feat(shared-sqs): add SQS Console UI (v0.1.4)
|
||||||
- Pushed to `sqs-operator`
|
- Pushed to `sqs-operator`
|
||||||
|
|
||||||
|
---
|
||||||
|
# GitHub Copilot (Claude Sonnet 4.6) — продолжение сессии 2026-04-09 (вечер)
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Продолжение с конца предыдущего сеанса. Была реализована база admins API.
|
||||||
|
Нужно было:
|
||||||
|
1. Исправить `models.NewUUID()` → найти правильную функцию генерации UUID
|
||||||
|
2. Написать frontend для peek/send/purge/queue CRUD
|
||||||
|
3. Собрать + задеплоить v0.1.6
|
||||||
|
|
||||||
|
## Анализ + ход мыслей
|
||||||
|
|
||||||
|
### UUID
|
||||||
|
Сделал `grep` по Uuid в gosqs пакете → нашёл `uuid.NewString()` из `github.com/google/uuid`.
|
||||||
|
Исправил admin.go: добавил import + заменил `models.NewUUID()` → `uuid.NewString()`.
|
||||||
|
Сборка прошла чисто.
|
||||||
|
|
||||||
|
### Frontend (index.html)
|
||||||
|
Прочитал весь текущий HTML/JS. Отметил:
|
||||||
|
- CSS уже есть: `.card`, `.btn`, `.badge`, `.modal`, `.hidden`
|
||||||
|
- JS helpers: `esc()`, `api()`, `fmtDate()`, `fmtBytes()`, `fmtDuration()`
|
||||||
|
- Шаблонные literals со вложенностью (outer: `el.innerHTML = \`...\``, inner: `.map(q => \`...\`)`)
|
||||||
|
|
||||||
|
Решения:
|
||||||
|
- **IDs для expandable rows**: `msgs-${q.name}`, `qicon-${q.name}` — SQS-имена только `[a-zA-Z0-9_-]`, безопасно
|
||||||
|
- **msgCache**: хранить весь объект сообщения в Map по id — чтобы не передавать body через onclick attrs (безопаснее, нет проблем с кавычками)
|
||||||
|
- **State для модалок**: `_sendState`, `_createQueueState` — сохраняем перед открытием модалки
|
||||||
|
- **template literal nesting**: `${esc(tenant.id)}` в inner template работает т.к. tenant из closure
|
||||||
|
|
||||||
|
Добавлено в HTML:
|
||||||
|
- 3 новые модалки: `#modal-send`, `#modal-msg-detail`, `#modal-queue-create`
|
||||||
|
- CSS: `.msg-expand`, `.msg-expand-inner`, `.queue-name-link`
|
||||||
|
- Toolbar очередей: + кнопка "+ Очередь"
|
||||||
|
- Каждая строка очереди: клик на имя → toggle сообщений; кнопки 📨 🗑 ✕
|
||||||
|
- Скрытая expandable строка с `#msgs-inner-{name}`
|
||||||
|
- JS: `toggleMessages`, `loadMessages`, `renderQueueMessages`, `openMsgDetail`, `closeMsgDetail`, `openSendModal`, `closeSendModal`, `sendMessage`, `purgeQueueConfirm`, `openCreateQueueModal`, `closeQueueModal`, `createQueue`, `deleteQueueConfirm`, `copyTextarea`
|
||||||
|
|
||||||
|
Синтаксис проверен через `node -e "new Function(script)"` → OK.
|
||||||
|
|
||||||
|
### Деплой
|
||||||
|
- Docker build v0.1.6 на VM → успешно
|
||||||
|
- `docker push naeel/shared-sqs:v0.1.6` → успешно
|
||||||
|
- `kubectl set image` → **ОШИБКА**: JWT токен в kubeconfig на VM истёк в 16:01 UTC, текущее время 16:07 UTC
|
||||||
|
- Решение: обновил `deployment.yaml` с новым тегом → пользователь задеплоит после обновления токена
|
||||||
|
|
||||||
|
### Коммит
|
||||||
|
- `610c604` feat(shared-sqs): queue CRUD + message peek/send/purge in UI (v0.1.6)
|
||||||
|
- Pushed to branch `shared-sqs`
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Всё реализовано. Осталось только задеплоить — нужен свежий K8s токен (текущий истёк).
|
||||||
|
Команда деплоя:
|
||||||
|
```
|
||||||
|
kubectl -n shared-sqs set image deployment/shared-sqs shared-sqs=naeel/shared-sqs:v0.1.6
|
||||||
|
# или
|
||||||
|
kubectl apply -f shared-sqs/deployments/k8s/deployment.yaml
|
||||||
|
```
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ WORKDIR /build
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -o shared-sqs app/cmd/goaws.go
|
RUN CGO_ENABLED=0 go build -o shared-sqs ./app/cmd/
|
||||||
|
|
||||||
FROM alpine:3.19
|
FROM alpine:3.19
|
||||||
RUN apk --no-cache add ca-certificates
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// app/admin/admin.go
|
// app/admin/admin.go
|
||||||
// Admin API handlers for shared-sqs management
|
// Admin API handlers for shared-sqs management
|
||||||
// Created: 2026-04-09
|
// Created: 2026-04-09
|
||||||
|
// Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений
|
||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,10 +13,24 @@ import (
|
|||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
|
||||||
|
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
|
||||||
|
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
|
||||||
|
key := tenantAccessKey + ":" + queueName
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
q, ok := models.SyncQueues.Queues[key]
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return key, q
|
||||||
|
}
|
||||||
|
|
||||||
// Handler — admin API handler, holds TenantStore и admin token
|
// Handler — admin API handler, holds TenantStore и admin token
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *tenant.TenantStore
|
store *tenant.TenantStore
|
||||||
@@ -27,7 +42,7 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
|||||||
return &Handler{store: store, adminToken: adminToken}
|
return &Handler{store: store, adminToken: adminToken}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes — регистрирует admin маршруты на переданном router
|
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
||||||
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
||||||
adminRouter := r.PathPrefix("/admin").Subrouter()
|
adminRouter := r.PathPrefix("/admin").Subrouter()
|
||||||
adminRouter.Use(h.bearerAuthMiddleware)
|
adminRouter.Use(h.bearerAuthMiddleware)
|
||||||
@@ -36,11 +51,17 @@ func (h *Handler) RegisterRoutes(r *mux.Router) {
|
|||||||
adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
adminRouter.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
||||||
adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
adminRouter.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
||||||
adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
adminRouter.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST")
|
||||||
|
adminRouter.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE")
|
||||||
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
|
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
|
||||||
// Дублируют admin API, но доступны без bearer token для удобства демо
|
// Дублируют admin API, но доступны без bearer token для удобства демо
|
||||||
|
// TODO: убрать или заменить на session-auth перед production
|
||||||
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
||||||
ui := r.PathPrefix("/ui/api").Subrouter()
|
ui := r.PathPrefix("/ui/api").Subrouter()
|
||||||
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
|
||||||
@@ -49,6 +70,11 @@ func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
|||||||
ui.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
ui.HandleFunc("/tenants/{id}", h.getTenant).Methods("GET")
|
||||||
ui.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
ui.HandleFunc("/tenants/{id}", h.deleteTenant).Methods("DELETE")
|
||||||
ui.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
ui.HandleFunc("/tenants/{id}/queues", h.listTenantQueues).Methods("GET")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues", h.createTenantQueue).Methods("POST")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues/{queue}", h.deleteTenantQueue).Methods("DELETE")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.peekQueueMessages).Methods("GET")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.sendMessageToQueue).Methods("POST")
|
||||||
|
ui.HandleFunc("/tenants/{id}/queues/{queue}/messages", h.purgeQueue).Methods("DELETE")
|
||||||
}
|
}
|
||||||
|
|
||||||
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
|
// bearerAuthMiddleware — проверяет Bearer token для admin API (Trap #12)
|
||||||
@@ -245,6 +271,216 @@ func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(queues)
|
json.NewEncoder(w).Encode(queues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── QUEUE MANAGEMENT HANDLERS ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// createQueueRequest — тело запроса POST .../queues
|
||||||
|
type createQueueRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTenantQueue — POST /admin/tenants/{id}/queues
|
||||||
|
// Создаёт новую очередь для тенанта прямо в SyncQueues (без SQS-протокола).
|
||||||
|
// Проверяет лимит MaxQueues тенанта и уникальность имени.
|
||||||
|
func (h *Handler) createTenantQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid := vars["id"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createQueueRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := t.AccessKey + ":" + req.Name
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
if _, exists := models.SyncQueues.Queues[key]; exists {
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
jsonErr(w, http.StatusConflict, "queue already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Проверяем лимит очередей тенанта
|
||||||
|
count := 0
|
||||||
|
for k := range models.SyncQueues.Queues {
|
||||||
|
if strings.HasPrefix(k, t.AccessKey+":") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.MaxQueues > 0 && count >= t.MaxQueues {
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
jsonErr(w, http.StatusForbidden, "queue limit exceeded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.SyncQueues.Queues[key] = &models.Queue{
|
||||||
|
Name: req.Name,
|
||||||
|
VisibilityTimeout: 30,
|
||||||
|
MaximumMessageSize: 262144,
|
||||||
|
MessageRetentionPeriod: 345600,
|
||||||
|
Messages: []models.SqsMessage{},
|
||||||
|
Duplicates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
log.Infof("admin: created queue %s for tenant %s", req.Name, t.ID)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"name": req.Name, "status": "created"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteTenantQueue — DELETE /admin/tenants/{id}/queues/{queue}
|
||||||
|
// Удаляет очередь тенанта из SyncQueues вместе со всеми её сообщениями.
|
||||||
|
func (h *Handler) deleteTenantQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid, queueName := vars["id"], vars["queue"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := t.AccessKey + ":" + queueName
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
if _, exists := models.SyncQueues.Queues[key]; !exists {
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(models.SyncQueues.Queues, key)
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
log.Infof("admin: deleted queue %s for tenant %s", queueName, t.ID)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// peekMessageItem — одно сообщение в ответе peekQueueMessages (без receipt handle)
|
||||||
|
type peekMessageItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
MD5 string `json:"md5"`
|
||||||
|
SentAt string `json:"sent_at"`
|
||||||
|
Receives int `json:"receives"`
|
||||||
|
InFlight bool `json:"in_flight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// peekQueueMessages — GET /admin/tenants/{id}/queues/{queue}/messages?limit=50
|
||||||
|
// Peek-просмотр сообщений: НЕ удаляет, НЕ выставляет ReceiptHandle — только чтение.
|
||||||
|
// Это принципиальное отличие от SQS ReceiveMessage (который скрывает сообщения).
|
||||||
|
func (h *Handler) peekQueueMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid, queueName := vars["id"], vars["queue"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, q := findQueue(t.AccessKey, queueName)
|
||||||
|
if q == nil {
|
||||||
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Лимит по умолчанию 50, максимум 1000
|
||||||
|
limit := 50
|
||||||
|
if lv := r.URL.Query().Get("limit"); lv != "" {
|
||||||
|
if n := 0; len(lv) > 0 {
|
||||||
|
for _, c := range lv {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
n = -1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n = n*10 + int(c-'0')
|
||||||
|
}
|
||||||
|
if n > 0 && n <= 1000 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models.SyncQueues.RLock()
|
||||||
|
result := make([]peekMessageItem, 0, len(q.Messages))
|
||||||
|
for i, msg := range q.Messages {
|
||||||
|
if i >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = append(result, peekMessageItem{
|
||||||
|
ID: msg.Uuid,
|
||||||
|
Body: msg.MessageBody,
|
||||||
|
MD5: msg.MD5OfMessageBody,
|
||||||
|
SentAt: msg.SentTime.Format(time.RFC3339),
|
||||||
|
Receives: msg.NumberOfReceives,
|
||||||
|
InFlight: msg.ReceiptHandle != "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
models.SyncQueues.RUnlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessageRequest — тело запроса POST .../messages
|
||||||
|
type sendMessageRequest struct {
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessageToQueue — POST /admin/tenants/{id}/queues/{queue}/messages
|
||||||
|
// Отправляет сообщение напрямую в очередь минуя SQS-протокол.
|
||||||
|
// Используется только из UI console — для prod нужен нормальный SQS send.
|
||||||
|
func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid, queueName := vars["id"], vars["queue"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, q := findQueue(t.AccessKey, queueName)
|
||||||
|
if q == nil {
|
||||||
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req sendMessageRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Body == "" {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "body is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := models.SqsMessage{
|
||||||
|
MessageBody: req.Body,
|
||||||
|
Uuid: uuid.NewString(),
|
||||||
|
SentTime: time.Now(),
|
||||||
|
}
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"id": msg.Uuid, "status": "sent"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeQueue — DELETE /admin/tenants/{id}/queues/{queue}/messages
|
||||||
|
// Удаляет все сообщения из очереди (purge). Сама очередь остаётся.
|
||||||
|
func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
tid, queueName := vars["id"], vars["queue"]
|
||||||
|
t, ok := h.store.GetByID(tid)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, http.StatusNotFound, "tenant not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, q := findQueue(t.AccessKey, queueName)
|
||||||
|
if q == nil {
|
||||||
|
jsonErr(w, http.StatusNotFound, "queue not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0]
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonErr — вспомогательная функция: ответ с ошибкой в JSON
|
||||||
|
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
// adminHealthDetail — ответ GET /admin/health
|
// adminHealthDetail — ответ GET /admin/health
|
||||||
type adminHealthDetail struct {
|
type adminHealthDetail struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
+115
-112
@@ -4,124 +4,127 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"shared-sqs/app/conf"
|
"shared-sqs/app/conf"
|
||||||
"shared-sqs/app/gosqs"
|
"shared-sqs/app/gosqs"
|
||||||
"shared-sqs/app/models"
|
"shared-sqs/app/models"
|
||||||
"shared-sqs/app/router"
|
"shared-sqs/app/router"
|
||||||
"shared-sqs/app/tenant"
|
"shared-sqs/app/tenant"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configFile string
|
var configFile string
|
||||||
var adminToken string
|
var adminToken string
|
||||||
var port string
|
var port string
|
||||||
var debug bool
|
var debug bool
|
||||||
var loglevel string
|
var loglevel string
|
||||||
|
|
||||||
flag.StringVar(&configFile, "config", "", "config file location")
|
flag.StringVar(&configFile, "config", "", "config file location")
|
||||||
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
|
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
|
||||||
flag.StringVar(&port, "port", "4100", "listen port")
|
flag.StringVar(&port, "port", "4100", "listen port")
|
||||||
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
||||||
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
|
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
log.SetFormatter(&log.JSONFormatter{})
|
log.SetFormatter(&log.JSONFormatter{})
|
||||||
log.SetOutput(os.Stdout)
|
log.SetOutput(os.Stdout)
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
log.SetLevel(log.DebugLevel)
|
log.SetLevel(log.DebugLevel)
|
||||||
} else {
|
} else {
|
||||||
level, err := log.ParseLevel(loglevel)
|
level, err := log.ParseLevel(loglevel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.SetLevel(log.InfoLevel)
|
log.SetLevel(log.InfoLevel)
|
||||||
log.Warnf("Failed to parse loglevel %v, defaulting to info", loglevel)
|
log.Warnf("Failed to parse loglevel %v, defaulting to info", loglevel)
|
||||||
} else {
|
} else {
|
||||||
log.SetLevel(level)
|
log.SetLevel(level)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin token: flag > env SHARED_SQS_ADMIN_TOKEN > fatal (Trap #13)
|
// Admin token: flag > env SHARED_SQS_ADMIN_TOKEN > fatal (Trap #13)
|
||||||
if adminToken == "" {
|
if adminToken == "" {
|
||||||
adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN")
|
adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN")
|
||||||
}
|
}
|
||||||
if adminToken == "" {
|
if adminToken == "" {
|
||||||
log.Fatal("admin token required: use --admin-token flag or SHARED_SQS_ADMIN_TOKEN env var")
|
log.Fatal("admin token required: use --admin-token flag or SHARED_SQS_ADMIN_TOKEN env var")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузить конфиг (очереди, env — без SNS)
|
// Загрузить конфиг (очереди, env — без SNS)
|
||||||
env := "Local"
|
env := "Local"
|
||||||
if flag.NArg() > 0 {
|
if flag.NArg() > 0 {
|
||||||
env = flag.Arg(0)
|
env = flag.Arg(0)
|
||||||
}
|
}
|
||||||
conf.LoadYamlConfig(configFile, env)
|
conf.LoadYamlConfig(configFile, env)
|
||||||
|
|
||||||
if models.CurrentEnvironment.LogToFile {
|
if models.CurrentEnvironment.LogToFile {
|
||||||
filename := models.CurrentEnvironment.LogFile
|
filename := models.CurrentEnvironment.LogFile
|
||||||
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
log.SetOutput(file)
|
log.SetOutput(file)
|
||||||
} else {
|
} else {
|
||||||
log.Infof("Failed to log to file: %s, using default stdout", filename)
|
log.Infof("Failed to log to file: %s, using default stdout", filename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Инициализация in-memory TenantStore
|
// Инициализация in-memory TenantStore
|
||||||
tenantStore := tenant.NewTenantStore()
|
tenantStore := tenant.NewTenantStore()
|
||||||
|
// Автосид демо-данных при SHARED_SQS_SEED_DEMO=true
|
||||||
// Роутер с tenant auth и admin API
|
if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" {
|
||||||
r := router.New(tenantStore, adminToken)
|
seedDemoData(tenantStore)
|
||||||
|
}
|
||||||
// PeriodicTasks — visibility timeout, DLQ, deduplication
|
// Роутер с tenant auth и admin API
|
||||||
quit := make(chan bool)
|
r := router.New(tenantStore, adminToken)
|
||||||
go gosqs.PeriodicTasks(1*time.Second, quit)
|
|
||||||
|
// PeriodicTasks — visibility timeout, DLQ, deduplication
|
||||||
// HTTP сервер с таймаутами
|
quit := make(chan bool)
|
||||||
srv := &http.Server{
|
go gosqs.PeriodicTasks(1*time.Second, quit)
|
||||||
Addr: "0.0.0.0:" + port,
|
|
||||||
Handler: r,
|
// HTTP сервер с таймаутами
|
||||||
ReadTimeout: 30 * time.Second,
|
srv := &http.Server{
|
||||||
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s)
|
Addr: "0.0.0.0:" + port,
|
||||||
IdleTimeout: 60 * time.Second,
|
Handler: r,
|
||||||
}
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s)
|
||||||
// Запуск в горутине для graceful shutdown
|
IdleTimeout: 60 * time.Second,
|
||||||
serverErr := make(chan error, 1)
|
}
|
||||||
go func() {
|
|
||||||
log.Infof("shared-sqs listening on 0.0.0.0:%s", port)
|
// Запуск в горутине для graceful shutdown
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
serverErr := make(chan error, 1)
|
||||||
serverErr <- err
|
go func() {
|
||||||
}
|
log.Infof("shared-sqs listening on 0.0.0.0:%s", port)
|
||||||
}()
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
serverErr <- err
|
||||||
// Graceful shutdown по SIGTERM/SIGINT (Trap #13)
|
}
|
||||||
sigCh := make(chan os.Signal, 1)
|
}()
|
||||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
|
||||||
|
// Graceful shutdown по SIGTERM/SIGINT (Trap #13)
|
||||||
select {
|
sigCh := make(chan os.Signal, 1)
|
||||||
case sig := <-sigCh:
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||||
log.Infof("Received signal %s, shutting down", sig)
|
|
||||||
case err := <-serverErr:
|
select {
|
||||||
log.Fatalf("Server error: %v", err)
|
case sig := <-sigCh:
|
||||||
}
|
log.Infof("Received signal %s, shutting down", sig)
|
||||||
|
case err := <-serverErr:
|
||||||
// Остановить PeriodicTasks
|
log.Fatalf("Server error: %v", err)
|
||||||
close(quit)
|
}
|
||||||
|
|
||||||
// Дать 10 секунд на завершение текущих HTTP запросов
|
// Остановить PeriodicTasks
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
close(quit)
|
||||||
defer cancel()
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
// Дать 10 секунд на завершение текущих HTTP запросов
|
||||||
log.Errorf("Server shutdown error: %v", err)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
}
|
defer cancel()
|
||||||
log.Info("shared-sqs stopped")
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Errorf("Server shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
log.Info("shared-sqs stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// app/cmd/seed.go
|
||||||
|
// Created: 2026-04-09
|
||||||
|
// Updated: 2026-04-09 — фиксированные credentials для demo-tenant (BYOC)
|
||||||
|
// Автосид демо-данных при старте через SHARED_SQS_SEED_DEMO=true.
|
||||||
|
// Создаёт тенанта demo-service с 5 очередями и демо-сообщениями.
|
||||||
|
//
|
||||||
|
// DEMO CREDENTIALS — только для тестового стенда.
|
||||||
|
// Тенант demo-service изолирован: видит только свои очереди, не имеет доступа к
|
||||||
|
// admin API и к очередям других тенантов. Credentials открыты намеренно — стенд публичный.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5" //nolint:gosec — MD5 используется для SQS-совместимости, не для безопасности
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"shared-sqs/app/models"
|
||||||
|
"shared-sqs/app/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Фиксированные credentials демо-тенанта.
|
||||||
|
// Открыты намеренно — тестовый стенд.
|
||||||
|
// Тенант ограничен 10 очередями и не имеет прав admin API.
|
||||||
|
const (
|
||||||
|
demoTenantID = "t-demo-shared-sqs-ngcloud"
|
||||||
|
demoAccessKey = "SSAK-demo-shared-sqs"
|
||||||
|
demoSecretKey = "demo-secret-key-shared-sqs-ngcloud-2026"
|
||||||
|
demoTenantName = "demo-service"
|
||||||
|
demoMaxQueues = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedDemoData создаёт тенанта demo-service с очередями и сообщениями.
|
||||||
|
// Вызывается при SHARED_SQS_SEED_DEMO=true при старте сервера.
|
||||||
|
func seedDemoData(store *tenant.TenantStore) {
|
||||||
|
t, err := store.CreateFixed(demoTenantName, demoMaxQueues, demoTenantID, demoAccessKey, demoSecretKey)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("seed: не удалось создать demo-tenant: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("seed: создан тенант %s (AccessKey=%s)", t.ID, t.AccessKey)
|
||||||
|
|
||||||
|
// Демо-очереди с набором сообщений
|
||||||
|
queues := []struct {
|
||||||
|
name string
|
||||||
|
msgs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"orders",
|
||||||
|
[]string{
|
||||||
|
`{"order_id":"1001","amount":99.99,"status":"pending"}`,
|
||||||
|
`{"order_id":"1002","amount":14.50,"status":"completed"}`,
|
||||||
|
`{"order_id":"1003","amount":299.00,"status":"processing"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"notifications",
|
||||||
|
[]string{
|
||||||
|
`{"to":"user@example.com","text":"Welcome to the service!"}`,
|
||||||
|
`{"to":"admin@example.com","text":"New user signed up"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"emails",
|
||||||
|
[]string{
|
||||||
|
`{"subject":"Invoice #42","body":"See attachment","to":"billing@example.com"}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uploads",
|
||||||
|
nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user-events",
|
||||||
|
[]string{
|
||||||
|
`{"event":"login","user_id":"u-123","ts":1744000000}`,
|
||||||
|
`{"event":"logout","user_id":"u-123","ts":1744003600}`,
|
||||||
|
`{"event":"purchase","user_id":"u-456","item_id":"prod-7","ts":1744005000}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, q := range queues {
|
||||||
|
key := t.AccessKey + ":" + q.name
|
||||||
|
|
||||||
|
// URL и ARN формируем по тому же шаблону что gosqs/tenant_helpers.go:tenantQueueURL/tenantQueueARN
|
||||||
|
// Иначе AWS CLI получает пустой QueueUrl в ListQueues и не может работать с очередью.
|
||||||
|
env := models.CurrentEnvironment
|
||||||
|
var queueURL string
|
||||||
|
if env.Region != "" {
|
||||||
|
queueURL = "http://" + env.Region + "." + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name
|
||||||
|
} else {
|
||||||
|
queueURL = "http://" + env.Host + ":" + env.Port + "/" + t.ID + "/" + q.name
|
||||||
|
}
|
||||||
|
queueARN := "arn:aws:sqs:" + env.Region + ":" + t.ID + ":" + q.name
|
||||||
|
|
||||||
|
msgs := make([]models.SqsMessage, 0, len(q.msgs))
|
||||||
|
for _, body := range q.msgs {
|
||||||
|
//nolint:gosec — MD5 здесь для совместимости с AWS SQS протоколом
|
||||||
|
sum := md5.Sum([]byte(body)) //nolint:gosec
|
||||||
|
msgs = append(msgs, models.SqsMessage{
|
||||||
|
MessageBody: body,
|
||||||
|
Uuid: uuid.NewString(),
|
||||||
|
MD5OfMessageBody: fmt.Sprintf("%x", sum),
|
||||||
|
SentTime: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
models.SyncQueues.Lock()
|
||||||
|
models.SyncQueues.Queues[key] = &models.Queue{
|
||||||
|
Name: q.name,
|
||||||
|
URL: queueURL,
|
||||||
|
Arn: queueARN,
|
||||||
|
VisibilityTimeout: 30,
|
||||||
|
MaximumMessageSize: 262144,
|
||||||
|
MessageRetentionPeriod: 345600,
|
||||||
|
Messages: msgs,
|
||||||
|
Duplicates: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
models.SyncQueues.Unlock()
|
||||||
|
|
||||||
|
log.Infof("seed: очередь %s (%d сообщений)", q.name, len(q.msgs))
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("seed: демо-данные готовы — тенант %s, 5 очередей", t.Name)
|
||||||
|
}
|
||||||
@@ -16,30 +16,8 @@ Local: # Environment name that can be passed on the
|
|||||||
ReceiveMessageWaitTimeSeconds: 0 # receive message max wait time
|
ReceiveMessageWaitTimeSeconds: 0 # receive message max wait time
|
||||||
MaximumMessageSize: 262144 # maximum message size (bytes)
|
MaximumMessageSize: 262144 # maximum message size (bytes)
|
||||||
# MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented
|
# MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented
|
||||||
Queues: # List of queues to create at startup
|
Queues: [] # No default queues — created via Admin API / AWS CLI by tenants
|
||||||
- Name: local-queue1 # Queue name
|
Topics: [] # No default topics
|
||||||
- Name: local-queue2 # Queue name
|
|
||||||
ReceiveMessageWaitTimeSeconds: 20 # Queue receive message max wait time
|
|
||||||
- Name: local-queue3 # Queue name
|
|
||||||
RedrivePolicy: '{"maxReceiveCount": 100, "deadLetterTargetArn":"arn:aws:sqs:us-east-1:100010001000:local-queue3-dlq"}'
|
|
||||||
- Name: local-queue3-dlq # Queue name
|
|
||||||
Topics: # List of topic to create at startup
|
|
||||||
- Name: local-topic1 # Topic name - with some Subscriptions
|
|
||||||
Subscriptions: # List of Subscriptions to create for this topic (queues will be created as required)
|
|
||||||
- QueueName: local-queue3 # Queue name
|
|
||||||
Raw: false # Raw message delivery (true/false)
|
|
||||||
- QueueName: local-queue4 # Queue name
|
|
||||||
Raw: true # Raw message delivery (true/false)
|
|
||||||
#FilterPolicy: '{"foo": ["bar"]}' # Subscription's FilterPolicy, json object as a string
|
|
||||||
- Name: local-topic2 # Topic name - no Subscriptions
|
|
||||||
- Name: local-topic3 # Topic name - http subscription
|
|
||||||
Subscriptions:
|
|
||||||
- Protocol: https
|
|
||||||
EndPoint: https://enkrogwitfcgi.x.pipedream.net
|
|
||||||
TopicArn: arn:aws:sns:us-east-1:100010001000:local-topic2
|
|
||||||
FilterPolicy: '{"event": ["my_event"]}'
|
|
||||||
Raw: true
|
|
||||||
- Name: local-topic4
|
|
||||||
RandomLatency: # Parameters for introducing random latency into message queuing
|
RandomLatency: # Parameters for introducing random latency into message queuing
|
||||||
Min: 0 # Desired latency in milliseconds, if min and max are zero, no latency will be applied.
|
Min: 0 # Desired latency in milliseconds, if min and max are zero, no latency will be applied.
|
||||||
Max: 0 # Desired latency in milliseconds
|
Max: 0 # Desired latency in milliseconds
|
||||||
|
|||||||
@@ -3,140 +3,165 @@
|
|||||||
package tenant
|
package tenant
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tenant — модель тенанта shared-sqs.
|
// Tenant — модель тенанта shared-sqs.
|
||||||
// AccessKey используется как идентификатор в AWS Authorization header.
|
// AccessKey используется как идентификатор в AWS Authorization header.
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
ID string // уникальный идентификатор тенанта (t-<hex>)
|
ID string // уникальный идентификатор тенанта (t-<hex>)
|
||||||
Name string // человекочитаемое имя
|
Name string // человекочитаемое имя
|
||||||
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
||||||
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
|
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
|
||||||
MaxQueues int // лимит очередей (0 = безлимит)
|
MaxQueues int // лимит очередей (0 = безлимит)
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Active bool
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantStore — потокобезопасное in-memory хранилище тенантов.
|
// TenantStore — потокобезопасное in-memory хранилище тенантов.
|
||||||
// Два индекса позволяют быстро искать как по ID (admin API), так и по AccessKey (auth middleware).
|
// Два индекса позволяют быстро искать как по ID (admin API), так и по AccessKey (auth middleware).
|
||||||
type TenantStore struct {
|
type TenantStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
byID map[string]*Tenant
|
byID map[string]*Tenant
|
||||||
byAccessKey map[string]*Tenant
|
byAccessKey map[string]*Tenant
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTenantStore — создаёт пустое хранилище тенантов.
|
// NewTenantStore — создаёт пустое хранилище тенантов.
|
||||||
func NewTenantStore() *TenantStore {
|
func NewTenantStore() *TenantStore {
|
||||||
return &TenantStore{
|
return &TenantStore{
|
||||||
byID: make(map[string]*Tenant),
|
byID: make(map[string]*Tenant),
|
||||||
byAccessKey: make(map[string]*Tenant),
|
byAccessKey: make(map[string]*Tenant),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create — создаёт нового тенанта, генерирует ключи, сохраняет в оба индекса.
|
// Create — создаёт нового тенанта, генерирует ключи, сохраняет в оба индекса.
|
||||||
func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
|
func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
|
||||||
id, err := generateTenantID()
|
id, err := generateTenantID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generate tenant id: %w", err)
|
return nil, fmt.Errorf("generate tenant id: %w", err)
|
||||||
}
|
}
|
||||||
accessKey, err := generateAccessKey()
|
accessKey, err := generateAccessKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generate access key: %w", err)
|
return nil, fmt.Errorf("generate access key: %w", err)
|
||||||
}
|
}
|
||||||
secretKey, err := generateSecretKey()
|
secretKey, err := generateSecretKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generate secret key: %w", err)
|
return nil, fmt.Errorf("generate secret key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &Tenant{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
AccessKey: accessKey,
|
||||||
|
SecretKey: secretKey,
|
||||||
|
MaxQueues: maxQueues,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.byID[t.ID] = t
|
||||||
|
s.byAccessKey[t.AccessKey] = t
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
t := &Tenant{
|
// CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo).
|
||||||
ID: id,
|
// Используется только при инициализации демо-данных; не вызывается из user-facing API.
|
||||||
Name: name,
|
func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) {
|
||||||
AccessKey: accessKey,
|
s.mu.Lock()
|
||||||
SecretKey: secretKey,
|
defer s.mu.Unlock()
|
||||||
MaxQueues: maxQueues,
|
if _, exists := s.byID[tenantID]; exists {
|
||||||
CreatedAt: time.Now().UTC(),
|
return nil, fmt.Errorf("tenant with id %s already exists", tenantID)
|
||||||
Active: true,
|
}
|
||||||
}
|
if _, exists := s.byAccessKey[accessKey]; exists {
|
||||||
|
return nil, fmt.Errorf("tenant with access key %s already exists", accessKey)
|
||||||
s.mu.Lock()
|
}
|
||||||
s.byID[t.ID] = t
|
t := &Tenant{
|
||||||
s.byAccessKey[t.AccessKey] = t
|
ID: tenantID,
|
||||||
s.mu.Unlock()
|
Name: name,
|
||||||
|
AccessKey: accessKey,
|
||||||
return t, nil
|
SecretKey: secretKey,
|
||||||
|
MaxQueues: maxQueues,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
s.byID[t.ID] = t
|
||||||
|
s.byAccessKey[t.AccessKey] = t
|
||||||
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByAccessKey — поиск тенанта по AccessKeyId (используется в auth middleware).
|
// GetByAccessKey — поиск тенанта по AccessKeyId (используется в auth middleware).
|
||||||
func (s *TenantStore) GetByAccessKey(accessKey string) (*Tenant, bool) {
|
func (s *TenantStore) GetByAccessKey(accessKey string) (*Tenant, bool) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
t, ok := s.byAccessKey[accessKey]
|
t, ok := s.byAccessKey[accessKey]
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
return t, ok
|
return t, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID — поиск тенанта по ID (используется в admin API).
|
// GetByID — поиск тенанта по ID (используется в admin API).
|
||||||
func (s *TenantStore) GetByID(id string) (*Tenant, bool) {
|
func (s *TenantStore) GetByID(id string) (*Tenant, bool) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
t, ok := s.byID[id]
|
t, ok := s.byID[id]
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
return t, ok
|
return t, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete — удаляет тенанта из ОБОИХ индексов.
|
// Delete — удаляет тенанта из ОБОИХ индексов.
|
||||||
// Ловушка #2: если удалить только из одного индекса — orphaned данные и memory leak.
|
// Ловушка #2: если удалить только из одного индекса — orphaned данные и memory leak.
|
||||||
func (s *TenantStore) Delete(id string) bool {
|
func (s *TenantStore) Delete(id string) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
t, ok := s.byID[id]
|
t, ok := s.byID[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
delete(s.byID, t.ID)
|
delete(s.byID, t.ID)
|
||||||
delete(s.byAccessKey, t.AccessKey)
|
delete(s.byAccessKey, t.AccessKey)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// List — список всех тенантов (для admin GET /tenants).
|
// List — список всех тенантов (для admin GET /tenants).
|
||||||
func (s *TenantStore) List() []*Tenant {
|
func (s *TenantStore) List() []*Tenant {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
result := make([]*Tenant, 0, len(s.byID))
|
result := make([]*Tenant, 0, len(s.byID))
|
||||||
for _, t := range s.byID {
|
for _, t := range s.byID {
|
||||||
result = append(result, t)
|
result = append(result, t)
|
||||||
}
|
}
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateTenantID — генерирует уникальный ID тенанта в формате t-<12 hex bytes>.
|
// generateTenantID — генерирует уникальный ID тенанта в формате t-<12 hex bytes>.
|
||||||
func generateTenantID() (string, error) {
|
func generateTenantID() (string, error) {
|
||||||
b := make([]byte, 8)
|
b := make([]byte, 8)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return "t-" + hex.EncodeToString(b), nil
|
return "t-" + hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateAccessKey — генерирует AccessKey в формате SSAK-<12 hex bytes>.
|
// generateAccessKey — генерирует AccessKey в формате SSAK-<12 hex bytes>.
|
||||||
// SSAK = Shared SQS Access Key. Используем crypto/rand (ловушка #1: не math/rand).
|
// SSAK = Shared SQS Access Key. Используем crypto/rand (ловушка #1: не math/rand).
|
||||||
func generateAccessKey() (string, error) {
|
func generateAccessKey() (string, error) {
|
||||||
b := make([]byte, 12)
|
b := make([]byte, 12)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return "SSAK-" + hex.EncodeToString(b), nil
|
return "SSAK-" + hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateSecretKey — генерирует SecretKey как 64 hex символа (32 random bytes).
|
// generateSecretKey — генерирует SecretKey как 64 hex символа (32 random bytes).
|
||||||
// Используем crypto/rand (ловушка #1).
|
// Используем crypto/rand (ловушка #1).
|
||||||
func generateSecretKey() (string, error) {
|
func generateSecretKey() (string, error) {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(b), nil
|
return hex.EncodeToString(b), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
app/ui/index.html
|
app/ui/index.html
|
||||||
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||||||
Created: 2026-04-10
|
Created: 2026-04-10
|
||||||
|
Updated: 2026-04-10 — queue CRUD (create/delete) + message peek/send/purge
|
||||||
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||||||
Режим: Admin (bearer token) — все тенанты, очереди, статистика.
|
Режим: Публичный UI API без авторизации (демо). Все данные in-memory.
|
||||||
-->
|
-->
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
@@ -313,6 +314,12 @@ tbody tr { cursor: pointer; }
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expandable message rows */
|
||||||
|
td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
|
||||||
|
.msg-expand-inner { padding: 12px 20px; background: var(--bg-page); }
|
||||||
|
.queue-name-link { cursor: pointer; color: var(--accent); }
|
||||||
|
.queue-name-link:hover { color: var(--accent-hover); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -377,11 +384,68 @@ tbody tr { cursor: pointer; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== SEND MESSAGE MODAL ===== -->
|
||||||
|
<div id="modal-send" class="modal-overlay hidden" onclick="if(event.target===this)closeSendModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Отправить сообщение</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="send-body">Тело сообщения</label>
|
||||||
|
<textarea id="send-body" rows="6" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="send-error" class="login-error"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="sendMessage()">Отправить</button>
|
||||||
|
<button class="btn btn-logout" onclick="closeSendModal()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== MESSAGE DETAIL MODAL ===== -->
|
||||||
|
<div id="modal-msg-detail" class="modal-overlay hidden" onclick="if(event.target===this)closeMsgDetail()">
|
||||||
|
<div class="modal" style="width:600px;max-width:90vw">
|
||||||
|
<h2>Сообщение</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>ID</label>
|
||||||
|
<input id="msg-detail-id" readonly onclick="copyField(this)" style="font-family:monospace;font-size:12px;cursor:pointer">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Body (нажмите, чтобы скопировать)</label>
|
||||||
|
<textarea id="msg-detail-body" readonly rows="8" style="width:100%;background:var(--bg-page);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);padding:10px 14px;font-family:monospace;font-size:13px;resize:vertical;cursor:pointer" onclick="copyTextarea(this)"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Отправлено</label>
|
||||||
|
<input id="msg-detail-sent" readonly>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="closeMsgDetail()">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== CREATE QUEUE MODAL ===== -->
|
||||||
|
<div id="modal-queue-create" class="modal-overlay hidden" onclick="if(event.target===this)closeQueueModal()">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Создать очередь</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="cq-name">Имя очереди</label>
|
||||||
|
<input id="cq-name" placeholder="my-queue">
|
||||||
|
</div>
|
||||||
|
<div id="cq-error" class="login-error"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-primary" onclick="createQueue()">Создать</button>
|
||||||
|
<button class="btn btn-logout" onclick="closeQueueModal()">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ===== STATE =====
|
// ===== STATE =====
|
||||||
let BASE = '';
|
let BASE = '';
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let currentTenantId = null;
|
let currentTenantId = null;
|
||||||
|
let msgCache = {}; // id → объект сообщения для detail modal
|
||||||
|
let _sendState = { tenantId: null, queueName: null }; // контекст sendMessage
|
||||||
|
let _createQueueState = { tenantId: null }; // контекст createQueue
|
||||||
|
|
||||||
// ===== INIT =====
|
// ===== INIT =====
|
||||||
// Запуск — сразу показываем dashboard без логина
|
// Запуск — сразу показываем dashboard без логина
|
||||||
@@ -543,7 +607,10 @@ function renderTenant(tenant, queues) {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<h2>Очереди</h2>
|
<h2>Очереди</h2>
|
||||||
<div class="auto-refresh"><span>⟳ 10с</span></div>
|
<div style="display:flex;gap:12px;align-items:center">
|
||||||
|
<div class="auto-refresh"><span>⟳ 10с</span></div>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openCreateQueueModal('${esc(tenant.id)}')" >+ Очередь</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
@@ -555,20 +622,35 @@ function renderTenant(tenant, queues) {
|
|||||||
<th>Visibility Timeout</th>
|
<th>Visibility Timeout</th>
|
||||||
<th>Max Size</th>
|
<th>Max Size</th>
|
||||||
<th>Retention</th>
|
<th>Retention</th>
|
||||||
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${(queues || []).map(q => `
|
${(queues || []).map(q => `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${esc(q.name)}</strong></td>
|
<td>
|
||||||
|
<span class="queue-name-link" onclick="toggleMessages('${esc(tenant.id)}','${esc(q.name)}')">
|
||||||
|
<span id="qicon-${esc(q.name)}">▶</span> <strong>${esc(q.name)}</strong>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td><span class="badge badge-count">${q.messages}</span></td>
|
<td><span class="badge badge-count">${q.messages}</span></td>
|
||||||
<td><span class="badge badge-count">${q.not_visible}</span></td>
|
<td><span class="badge badge-count">${q.not_visible}</span></td>
|
||||||
<td>${q.visibility_timeout}с</td>
|
<td>${q.visibility_timeout}с</td>
|
||||||
<td>${fmtBytes(q.max_message_size)}</td>
|
<td>${fmtBytes(q.max_message_size)}</td>
|
||||||
<td>${fmtDuration(q.retention_period)}</td>
|
<td>${fmtDuration(q.retention_period)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="openSendModal('${esc(tenant.id)}','${esc(q.name)}')" title="Отправить сообщение">📨</button>
|
||||||
|
<button class="btn btn-logout btn-sm" onclick="purgeQueueConfirm('${esc(tenant.id)}','${esc(q.name)}')" style="margin-left:4px" title="Очистить очередь">🗑</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteQueueConfirm('${esc(tenant.id)}','${esc(q.name)}')" style="margin-left:4px" title="Удалить очередь">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="msgs-${esc(q.name)}" class="hidden">
|
||||||
|
<td colspan="7" class="msg-expand">
|
||||||
|
<div id="msgs-inner-${esc(q.name)}" class="msg-expand-inner"></div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
${(!queues || queues.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет очередей — создайте через AWS CLI</td></tr>' : ''}
|
${(!queues || queues.length === 0) ? '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:32px">Нет очередей</td></tr>' : ''}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -668,6 +750,183 @@ function fmtDuration(s) {
|
|||||||
if (s >= 60) return (s / 60).toFixed(0) + 'м';
|
if (s >= 60) return (s / 60).toFixed(0) + 'м';
|
||||||
return s + 'с';
|
return s + 'с';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyTextarea — копирует содержимое textarea в буфер обмена
|
||||||
|
function copyTextarea(el) {
|
||||||
|
navigator.clipboard.writeText(el.value).then(() => {
|
||||||
|
const ok = document.createElement('span');
|
||||||
|
ok.className = 'copy-ok';
|
||||||
|
ok.textContent = '✓ скопировано';
|
||||||
|
el.parentNode.appendChild(ok);
|
||||||
|
setTimeout(() => ok.remove(), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== QUEUE CRUD =====
|
||||||
|
// openCreateQueueModal — показывает модалку создания очереди для тенанта
|
||||||
|
function openCreateQueueModal(tenantId) {
|
||||||
|
_createQueueState.tenantId = tenantId;
|
||||||
|
document.getElementById('cq-name').value = '';
|
||||||
|
document.getElementById('cq-error').textContent = '';
|
||||||
|
document.getElementById('modal-queue-create').classList.remove('hidden');
|
||||||
|
document.getElementById('cq-name').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQueueModal() {
|
||||||
|
document.getElementById('modal-queue-create').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// createQueue — POST /tenants/{id}/queues — создаёт очередь
|
||||||
|
function createQueue() {
|
||||||
|
const name = document.getElementById('cq-name').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
document.getElementById('cq-error').textContent = 'Укажите имя очереди';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api('/tenants/' + _createQueueState.tenantId + '/queues', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: name })
|
||||||
|
}).then(() => {
|
||||||
|
closeQueueModal();
|
||||||
|
if (currentTenantId) loadTenant(currentTenantId);
|
||||||
|
}).catch(err => {
|
||||||
|
document.getElementById('cq-error').textContent = 'Ошибка: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteQueueConfirm — DELETE /tenants/{id}/queues/{name} с подтверждением
|
||||||
|
function deleteQueueConfirm(tenantId, queueName) {
|
||||||
|
if (!confirm('Удалить очередь "' + queueName + '"?\nВсе сообщения будут потеряны.')) return;
|
||||||
|
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName), { method: 'DELETE' })
|
||||||
|
.then(() => { if (currentTenantId) loadTenant(currentTenantId); })
|
||||||
|
.catch(err => alert('Ошибка удаления: ' + err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MESSAGE PEEK / SEND / PURGE =====
|
||||||
|
// toggleMessages — разворачивает/сворачивает inline-таблицу сообщений очереди
|
||||||
|
function toggleMessages(tenantId, queueName) {
|
||||||
|
const row = document.getElementById('msgs-' + queueName);
|
||||||
|
const icon = document.getElementById('qicon-' + queueName);
|
||||||
|
if (!row) return;
|
||||||
|
if (row.classList.contains('hidden')) {
|
||||||
|
row.classList.remove('hidden');
|
||||||
|
if (icon) icon.textContent = '▼';
|
||||||
|
loadMessages(tenantId, queueName);
|
||||||
|
} else {
|
||||||
|
row.classList.add('hidden');
|
||||||
|
if (icon) icon.textContent = '▶';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadMessages — GET /tenants/{id}/queues/{q}/messages и рендерит таблицу
|
||||||
|
function loadMessages(tenantId, queueName) {
|
||||||
|
const inner = document.getElementById('msgs-inner-' + queueName);
|
||||||
|
if (!inner) return;
|
||||||
|
inner.innerHTML = '<span style="color:var(--text-secondary);font-size:13px">Загрузка...</span>';
|
||||||
|
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName) + '/messages')
|
||||||
|
.then(msgs => renderQueueMessages(queueName, msgs))
|
||||||
|
.catch(err => {
|
||||||
|
const el = document.getElementById('msgs-inner-' + queueName);
|
||||||
|
if (el) el.innerHTML = '<span style="color:var(--danger);font-size:13px">Ошибка: ' + esc(err.message) + '</span>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderQueueMessages — рендерит таблицу сообщений в expandable row
|
||||||
|
function renderQueueMessages(queueName, msgs) {
|
||||||
|
const inner = document.getElementById('msgs-inner-' + queueName);
|
||||||
|
if (!inner) return;
|
||||||
|
if (!msgs || msgs.length === 0) {
|
||||||
|
inner.innerHTML = '<span style="color:var(--text-secondary);font-size:13px">Очередь пуста</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Сохраняем в кеш для detail modal
|
||||||
|
msgs.forEach(m => { msgCache[m.id] = m; });
|
||||||
|
inner.innerHTML = `
|
||||||
|
<table style="width:100%;font-size:13px">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">ID</th>
|
||||||
|
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">Body</th>
|
||||||
|
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">Отправлено</th>
|
||||||
|
<th style="padding:6px 12px;color:var(--text-secondary);text-align:left;font-size:11px;text-transform:uppercase;font-weight:600">Получений</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${msgs.map(m => `
|
||||||
|
<tr onclick="openMsgDetail('${esc(m.id)}')" style="cursor:pointer"
|
||||||
|
onmouseover="this.style.background='rgba(26,127,212,0.08)'" onmouseout="this.style.background=''">
|
||||||
|
<td style="padding:6px 12px;font-family:monospace;color:var(--text-secondary)">${esc(m.id).substring(0,8)}…</td>
|
||||||
|
<td style="padding:6px 12px;max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.body || '').substring(0,120)}${(m.body||'').length>120?'…':''}</td>
|
||||||
|
<td style="padding:6px 12px;color:var(--text-secondary)">${fmtDate(m.sent)}</td>
|
||||||
|
<td style="padding:6px 12px">${m.receives}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// openMsgDetail — показывает модалку с полным телом сообщения
|
||||||
|
function openMsgDetail(id) {
|
||||||
|
const m = msgCache[id];
|
||||||
|
if (!m) return;
|
||||||
|
document.getElementById('msg-detail-id').value = m.id || '';
|
||||||
|
document.getElementById('msg-detail-body').value = m.body || '';
|
||||||
|
document.getElementById('msg-detail-sent').value = m.sent ? fmtDate(m.sent) : '';
|
||||||
|
document.getElementById('modal-msg-detail').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMsgDetail() {
|
||||||
|
document.getElementById('modal-msg-detail').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// openSendModal — открывает модалку отправки сообщения
|
||||||
|
function openSendModal(tenantId, queueName) {
|
||||||
|
_sendState.tenantId = tenantId;
|
||||||
|
_sendState.queueName = queueName;
|
||||||
|
document.getElementById('send-body').value = '';
|
||||||
|
document.getElementById('send-error').textContent = '';
|
||||||
|
document.getElementById('modal-send').classList.remove('hidden');
|
||||||
|
document.getElementById('send-body').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSendModal() {
|
||||||
|
document.getElementById('modal-send').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMessage — POST /tenants/{id}/queues/{q}/messages — отправляет сообщение
|
||||||
|
function sendMessage() {
|
||||||
|
const body = document.getElementById('send-body').value.trim();
|
||||||
|
if (!body) {
|
||||||
|
document.getElementById('send-error').textContent = 'Введите тело сообщения';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { tenantId, queueName } = _sendState;
|
||||||
|
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName) + '/messages', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ body: body })
|
||||||
|
}).then(() => {
|
||||||
|
closeSendModal();
|
||||||
|
// Если очередь раскрыта — перезагрузить сообщения
|
||||||
|
const row = document.getElementById('msgs-' + queueName);
|
||||||
|
if (row && !row.classList.contains('hidden')) loadMessages(tenantId, queueName);
|
||||||
|
if (currentTenantId) loadTenant(currentTenantId);
|
||||||
|
}).catch(err => {
|
||||||
|
document.getElementById('send-error').textContent = 'Ошибка: ' + err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// purgeQueueConfirm — DELETE /tenants/{id}/queues/{q}/messages с подтверждением
|
||||||
|
function purgeQueueConfirm(tenantId, queueName) {
|
||||||
|
if (!confirm('Очистить очередь "' + queueName + '"?\nВсе сообщения будут удалены.')) return;
|
||||||
|
api('/tenants/' + tenantId + '/queues/' + encodeURIComponent(queueName) + '/messages', { method: 'DELETE' })
|
||||||
|
.then(() => {
|
||||||
|
const row = document.getElementById('msgs-' + queueName);
|
||||||
|
if (row && !row.classList.contains('hidden')) loadMessages(tenantId, queueName);
|
||||||
|
if (currentTenantId) loadTenant(currentTenantId);
|
||||||
|
})
|
||||||
|
.catch(err => alert('Ошибка: ' + err.message));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ spec:
|
|||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: shared-sqs
|
- name: shared-sqs
|
||||||
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs:v0.1.0
|
image: naeel/shared-sqs:v0.1.8
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 4100
|
- containerPort: 4100
|
||||||
name: http
|
name: http
|
||||||
@@ -32,6 +32,8 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: shared-sqs-admin
|
name: shared-sqs-admin
|
||||||
key: token
|
key: token
|
||||||
|
- name: SHARED_SQS_SEED_DEMO
|
||||||
|
value: "true"
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
memory: "64Mi"
|
memory: "64Mi"
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# BYOC — Bring Your Own Credentials
|
||||||
|
|
||||||
|
**Дата**: 2026-04-09
|
||||||
|
**Статус**: частично реализовано (backend), UI/API — TODO
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Суть
|
||||||
|
|
||||||
|
Возможность создать тенанта с **произвольными** Access Key и Secret Key вместо авто-генерируемых.
|
||||||
|
|
||||||
|
Нужно для:
|
||||||
|
- демо-стенда с фиксированными credentials (README всегда актуален)
|
||||||
|
- интеграционных тестов с предсказуемыми значениями
|
||||||
|
- миграции с другого SQS-совместимого сервиса (сохранение существующих ключей)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что уже сделано
|
||||||
|
|
||||||
|
### `app/tenant/tenant_store.go` — `CreateFixed`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *TenantStore) CreateFixed(
|
||||||
|
name string,
|
||||||
|
maxQueues int,
|
||||||
|
tenantID string,
|
||||||
|
accessKey string,
|
||||||
|
secretKey string,
|
||||||
|
) (*Tenant, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Создаёт тенанта с заранее известными credentials.
|
||||||
|
Проверяет уникальность и `tenantID`, и `accessKey` — конфликт возвращает ошибку.
|
||||||
|
|
||||||
|
### `app/cmd/seed.go` — демо-тенант
|
||||||
|
|
||||||
|
Использует `CreateFixed` при `SHARED_SQS_SEED_DEMO=true`:
|
||||||
|
|
||||||
|
```
|
||||||
|
tenantID = "t-demo-shared-sqs-ngcloud"
|
||||||
|
accessKey = "SSAK-demo-shared-sqs"
|
||||||
|
secretKey = "demo-secret-key-shared-sqs-ngcloud-2026"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что нужно сделать (TODO)
|
||||||
|
|
||||||
|
### Admin API — `POST /admin/tenants`
|
||||||
|
|
||||||
|
Добавить в `createTenantRequest` два опциональных поля:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// app/admin/admin.go
|
||||||
|
type createTenantRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
MaxQueues int `json:"max_queues"`
|
||||||
|
AccessKey string `json:"access_key,omitempty"` // TODO: BYOC
|
||||||
|
SecretKey string `json:"secret_key,omitempty"` // TODO: BYOC
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Логика в `createTenant` handler:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var t *tenant.Tenant
|
||||||
|
var err error
|
||||||
|
if req.AccessKey != "" || req.SecretKey != "" {
|
||||||
|
// BYOC: оба поля обязательны
|
||||||
|
if req.AccessKey == "" || req.SecretKey == "" {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "both access_key and secret_key required when specifying custom credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Минимальная длина — защита от случайно слабых ключей
|
||||||
|
if len(req.AccessKey) < 8 || len(req.SecretKey) < 16 {
|
||||||
|
jsonErr(w, http.StatusBadRequest, "access_key min 8 chars, secret_key min 16 chars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err = h.store.CreateFixed(req.Name, req.MaxQueues, generateTenantID(), req.AccessKey, req.SecretKey)
|
||||||
|
} else {
|
||||||
|
t, err = h.store.Create(req.Name, req.MaxQueues)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `generateTenantID()` — уже есть в tenant_store.go, нужно экспортировать или вынести.
|
||||||
|
|
||||||
|
### UI — Web форма создания тенанта
|
||||||
|
|
||||||
|
- Добавить в модальное окно "Создать тенанта" два опциональных поля: Access Key, Secret Key
|
||||||
|
- Показывать только если нажата кнопка "задать свои credentials"
|
||||||
|
- Валидация на клиенте: оба поля заполнены, мин. длина
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- BYOC-credentials **не дают доступа к admin API** — admin защищён отдельным Bearer токеном
|
||||||
|
- Тенант видит **только свои очереди** — изоляция по AccessKey в auth middleware
|
||||||
|
- Слабые ключи отклоняются на уровне API (минимальная длина)
|
||||||
|
- Credentials передаются только по HTTPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Демо-credentials (открыты намеренно)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Access Key** | `SSAK-demo-shared-sqs` |
|
||||||
|
| **Secret Key** | `demo-secret-key-shared-sqs-ngcloud-2026` |
|
||||||
|
| **Tenant ID** | `t-demo-shared-sqs-ngcloud` |
|
||||||
|
| **Лимит очередей** | 10 |
|
||||||
|
|
||||||
|
Эти credentials жёстко прописаны в `app/cmd/seed.go`.
|
||||||
|
Тенант создаётся только если `SHARED_SQS_SEED_DEMO=true` (env var в deployment.yaml).
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
# Изменено: 2026-04-09
|
# Изменено: 2026-04-09
|
||||||
# Покрывает: Admin API, AWS CLI CRUD, awscurl CRUD, кросс-доставка,
|
# Покрывает: Admin API, AWS CLI CRUD, awscurl CRUD, кросс-доставка,
|
||||||
# изоляция тенантов, невалидный ввод, спецсимволы,
|
# изоляция тенантов, невалидный ввод, спецсимволы,
|
||||||
# visibility timeout, batch, лимиты очередей, cleanup тенанта.
|
# visibility timeout, batch, лимиты очередей, cleanup тенанта,
|
||||||
|
# UI API (create/delete queue, send/peek/purge messages, изоляция).
|
||||||
# Запуск: BASE_URL=https://qu.kube5s.ru ADMIN_TOKEN=... bash tests/hardcore_test.sh
|
# Запуск: BASE_URL=https://qu.kube5s.ru ADMIN_TOKEN=... bash tests/hardcore_test.sh
|
||||||
|
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
@@ -899,12 +900,169 @@ sqs "$T1_AK" "$T1_SK" delete-queue --queue-url "$ENC_QURL" >/dev/null 2>&1 || tr
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════
|
||||||
echo "── Cleanup: удаляем тестовых тенантов ──"
|
echo "── 24. UI API — создание очереди через /ui/api ──"
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
UI_T=$(admin_api POST /admin/tenants '{"name":"ui-test-'"$TS"'","max_queues":10}')
|
||||||
|
UI_TID=$(echo "$UI_T" | jq -r '.id')
|
||||||
|
UI_AK=$(echo "$UI_T" | jq -r '.access_key')
|
||||||
|
UI_SK=$(echo "$UI_T" | jq -r '.secret_key')
|
||||||
|
|
||||||
|
# Создать очередь через UI API
|
||||||
|
R=$(curl -s --max-time 15 -X POST "${BASE_URL}/ui/api/tenants/${UI_TID}/queues" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"ui-queue-1"}')
|
||||||
|
check "UI API: создать очередь ui-queue-1 → имя" "$R" "ui-queue-1"
|
||||||
|
|
||||||
|
# Дубль — та же очередь, не ошибка (идемпотентно)
|
||||||
|
R=$(curl -s --max-time 15 -X POST "${BASE_URL}/ui/api/tenants/${UI_TID}/queues" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"ui-queue-1"}')
|
||||||
|
check "UI API: повторное создание — idempotent (нет 500)" "$R" "ui-queue-1|error"
|
||||||
|
|
||||||
|
# Список очередей через /ui/api содержит созданную
|
||||||
|
R=$(curl -s --max-time 15 "${BASE_URL}/ui/api/tenants/${UI_TID}/queues")
|
||||||
|
check "UI API: GET /queues содержит ui-queue-1" "$R" "ui-queue-1"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
echo "── 25. UI API — отправка и peek сообщений ──"
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
# Отправить сообщение через UI
|
||||||
|
R=$(curl -s --max-time 15 -X POST \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"body":"hello-from-ui"}')
|
||||||
|
check "UI API: send message → id" "$R" '"id"'
|
||||||
|
check "UI API: send message → status sent" "$R" "sent"
|
||||||
|
|
||||||
|
# Отправить ещё одно
|
||||||
|
curl -s --max-time 15 -X POST \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"body":"second-message"}' >/dev/null 2>&1
|
||||||
|
|
||||||
|
# Peek — должны видеть оба, без ReceiptHandle
|
||||||
|
R=$(curl -s --max-time 15 \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages")
|
||||||
|
check "UI API: peek — hello-from-ui" "$R" "hello-from-ui"
|
||||||
|
check "UI API: peek — second-message" "$R" "second-message"
|
||||||
|
check_not "UI API: peek — НЕТ receipt_handle" "$R" "receipt_handle"
|
||||||
|
|
||||||
|
# Peek не даёт возможности ReceiveMessage через SQS (сообщения не зафиксированы)
|
||||||
|
QURL_UI=$(sqs "$UI_AK" "$UI_SK" list-queues 2>&1 \
|
||||||
|
| python3 -c "import sys,json; d=json.load(sys.stdin); urls=d.get('QueueUrls',[]); print(next((u for u in urls if 'ui-queue-1' in u),''))" 2>/dev/null || true)
|
||||||
|
if [[ -n "$QURL_UI" ]]; then
|
||||||
|
RECV_AFTER_PEEK=$(sqs "$UI_AK" "$UI_SK" receive-message --queue-url "$QURL_UI" 2>&1)
|
||||||
|
check "UI API: peek не consumed — SQS receive-message видит сообщение" "$RECV_AFTER_PEEK" "hello-from-ui|Messages"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
echo "── 26. UI API — purge очереди ──"
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
R=$(curl -s --max-time 15 -X DELETE \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages")
|
||||||
|
check "UI API: purge → 200/204 (нет ошибки)" "$R" "^\s*$|purged|ok|{}"
|
||||||
|
|
||||||
|
# После purge peek должен вернуть пустой массив
|
||||||
|
R=$(curl -s --max-time 15 \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages")
|
||||||
|
check_not "UI API: после purge peek пуст (нет hello-from-ui)" "$R" "hello-from-ui"
|
||||||
|
|
||||||
|
# Отправим ещё одно после purge — должно работать
|
||||||
|
R=$(curl -s --max-time 15 -X POST \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"body":"after-purge"}')
|
||||||
|
check "UI API: send после purge работает" "$R" '"id"'
|
||||||
|
|
||||||
|
R=$(curl -s --max-time 15 \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-queue-1/messages")
|
||||||
|
check "UI API: peek после purge → after-purge" "$R" "after-purge"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
echo "── 27. UI API — удаление очереди ──"
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
# Создать ещё одну очередь чтобы удалить
|
||||||
|
curl -s --max-time 15 -X POST "${BASE_URL}/ui/api/tenants/${UI_TID}/queues" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"ui-to-delete"}' >/dev/null 2>&1
|
||||||
|
|
||||||
|
R=$(curl -s --max-time 15 "${BASE_URL}/ui/api/tenants/${UI_TID}/queues")
|
||||||
|
check "UI API: ui-to-delete создана" "$R" "ui-to-delete"
|
||||||
|
|
||||||
|
# Удалить
|
||||||
|
HTTP=$(curl -s --max-time 15 -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/ui-to-delete")
|
||||||
|
if [[ "$HTTP" == "204" || "$HTTP" == "200" ]]; then
|
||||||
|
PASS=$((PASS+1)); TOTAL=$((TOTAL+1))
|
||||||
|
echo " ✅ UI API: DELETE /queues/ui-to-delete → HTTP $HTTP"
|
||||||
|
else
|
||||||
|
FAIL=$((FAIL+1)); TOTAL=$((TOTAL+1))
|
||||||
|
echo " ❌ UI API: DELETE /queues/ui-to-delete → HTTP $HTTP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
R=$(curl -s --max-time 15 "${BASE_URL}/ui/api/tenants/${UI_TID}/queues")
|
||||||
|
check_not "UI API: ui-to-delete больше не в списке" "$R" "ui-to-delete"
|
||||||
|
|
||||||
|
# Удаление несуществующей очереди → 404
|
||||||
|
HTTP=$(curl -s --max-time 15 -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/no-such-queue")
|
||||||
|
if [[ "$HTTP" == "404" ]]; then
|
||||||
|
PASS=$((PASS+1)); TOTAL=$((TOTAL+1))
|
||||||
|
echo " ✅ UI API: DELETE несуществующей очереди → 404"
|
||||||
|
else
|
||||||
|
FAIL=$((FAIL+1)); TOTAL=$((TOTAL+1))
|
||||||
|
echo " ❌ UI API: DELETE несуществующей очереди → HTTP $HTTP (ожидалось 404)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
echo "── 28. UI API — изоляция: чужой тенант недоступен ──"
|
||||||
|
# ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
# Создать второго тенанта и попробовать смотреть его очереди через UI API чужим ID
|
||||||
|
UI_T2=$(admin_api POST /admin/tenants '{"name":"ui-other-'"$TS"'","max_queues":5}')
|
||||||
|
UI_T2ID=$(echo "$UI_T2" | jq -r '.id')
|
||||||
|
curl -s --max-time 15 -X POST "${BASE_URL}/ui/api/tenants/${UI_T2ID}/queues" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"secret-queue"}' >/dev/null 2>&1
|
||||||
|
curl -s --max-time 15 -X POST \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_T2ID}/queues/secret-queue/messages" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"body":"secret-data"}' >/dev/null 2>&1
|
||||||
|
|
||||||
|
# T1 пытается peek очереди T2 — должен получить 404 (тенант не найден)
|
||||||
|
HTTP=$(curl -s --max-time 15 -o /dev/null -w "%{http_code}" \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_T2ID}/queues/secret-queue/messages")
|
||||||
|
# UI API публичный (без auth), но очередь принадлежит другому тенанту — данные не должны пересекаться
|
||||||
|
# Проверяем что данные T2 недоступны через UI T1
|
||||||
|
R=$(curl -s --max-time 15 "${BASE_URL}/ui/api/tenants/${UI_TID}/queues")
|
||||||
|
check_not "UI API: T1 не видит очереди T2" "$R" "secret-queue"
|
||||||
|
|
||||||
|
R_T1_PEEK=$(curl -s --max-time 15 \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${UI_TID}/queues/secret-queue/messages")
|
||||||
|
check "UI API: peek чужой очереди через ID T1 → 404/error" "$R_T1_PEEK" "not found|error|404"
|
||||||
|
|
||||||
|
# Cleanup UI тенантов
|
||||||
|
admin_api DELETE "/admin/tenants/${UI_T2ID}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════
|
||||||
|
|
||||||
admin_api DELETE "/admin/tenants/${T1_ID}" >/dev/null 2>&1 || true
|
admin_api DELETE "/admin/tenants/${T1_ID}" >/dev/null 2>&1 || true
|
||||||
admin_api DELETE "/admin/tenants/${T2_ID}" >/dev/null 2>&1 || true
|
admin_api DELETE "/admin/tenants/${T2_ID}" >/dev/null 2>&1 || true
|
||||||
admin_api DELETE "/admin/tenants/${LIM_ID}" >/dev/null 2>&1 || true
|
admin_api DELETE "/admin/tenants/${LIM_ID}" >/dev/null 2>&1 || true
|
||||||
|
admin_api DELETE "/admin/tenants/${UI_TID}" >/dev/null 2>&1 || true
|
||||||
# probe-test тенант (если оставался от предыдущих запусков)
|
# probe-test тенант (если оставался от предыдущих запусков)
|
||||||
PROBE_ID=$(admin_api GET /admin/tenants 2>/dev/null | jq -r '.[] | select(.name=="probe-test") | .id' 2>/dev/null || true)
|
PROBE_ID=$(admin_api GET /admin/tenants 2>/dev/null | jq -r '.[] | select(.name=="probe-test") | .id' 2>/dev/null || true)
|
||||||
[ -n "$PROBE_ID" ] && admin_api DELETE "/admin/tenants/${PROBE_ID}" >/dev/null 2>&1 || true
|
[ -n "$PROBE_ID" ] && admin_api DELETE "/admin/tenants/${PROBE_ID}" >/dev/null 2>&1 || true
|
||||||
|
|||||||
Executable
+176
@@ -0,0 +1,176 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# tests/quick_test.sh — Быстрая проверка shared-sqs (smoke test)
|
||||||
|
# Created: 2026-04-09
|
||||||
|
# Покрывает: создание тенанта + очереди, send/receive/delete сообщения,
|
||||||
|
# UI API peek/send/purge, удаление очереди и тенанта.
|
||||||
|
# Требования: curl, aws CLI, python3
|
||||||
|
# Запуск:
|
||||||
|
# bash tests/quick_test.sh
|
||||||
|
# BASE_URL=https://qu.kube5s.ru ADMIN_TOKEN=... bash tests/quick_test.sh
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${BASE_URL:-https://qu.kube5s.ru}"
|
||||||
|
ADMIN_TOKEN="${ADMIN_TOKEN:-sqs-admin-7a7d8bd0c060a75c198d48680f34077a}"
|
||||||
|
REGION="us-east-1"
|
||||||
|
TS=$(date +%s)
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
ok() { echo " ✅ $1"; PASS=$((PASS+1)); }
|
||||||
|
fail() { echo " ❌ $1"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local label="$1" body="$2" pattern="$3"
|
||||||
|
if echo "$body" | grep -qE "$pattern"; then ok "$label"; else fail "$label"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_not() {
|
||||||
|
local label="$1" body="$2" pattern="$3"
|
||||||
|
if echo "$body" | grep -qE "$pattern"; then fail "$label"; else ok "$label"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_http() {
|
||||||
|
local label="$1" want="$2" got="$3"
|
||||||
|
if [[ "$got" == "$want" ]]; then ok "$label (HTTP $got)"; else fail "$label — ожидалось $want, получено $got"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# aws CLI с credentials тенанта
|
||||||
|
sqs() {
|
||||||
|
local ak="$1" sk="$2"; shift 2
|
||||||
|
AWS_ACCESS_KEY_ID="$ak" AWS_SECRET_ACCESS_KEY="$sk" AWS_DEFAULT_REGION="$REGION" \
|
||||||
|
aws --endpoint-url "$BASE_URL" --output json sqs "$@" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
admin() {
|
||||||
|
local method="$1" path="$2" body="${3:-}"
|
||||||
|
if [[ -n "$body" ]]; then
|
||||||
|
curl -sf --max-time 15 -X "$method" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$body" "${BASE_URL}${path}" 2>&1
|
||||||
|
else
|
||||||
|
curl -sf --max-time 15 -X "$method" \
|
||||||
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
"${BASE_URL}${path}" 2>&1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ui_api — UI API без авторизации (публичный)
|
||||||
|
ui() {
|
||||||
|
local method="$1" path="$2" body="${3:-}"
|
||||||
|
if [[ -n "$body" ]]; then
|
||||||
|
curl -sf --max-time 15 -X "$method" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$body" "${BASE_URL}/ui/api${path}" 2>&1
|
||||||
|
else
|
||||||
|
curl -sf --max-time 15 -X "$method" "${BASE_URL}/ui/api${path}" 2>&1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
echo " shared-sqs Quick Test"
|
||||||
|
echo " Endpoint: $BASE_URL"
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 1. Health ──
|
||||||
|
echo "── 1. Health ──"
|
||||||
|
R=$(curl -sf --max-time 10 "${BASE_URL}/health" 2>&1)
|
||||||
|
check "GET /health → OK" "$R" "[Oo][Kk]|status"
|
||||||
|
R=$(ui GET /health)
|
||||||
|
check "GET /ui/api/health → ok" "$R" "ok"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 2. Создание тенанта ──
|
||||||
|
echo "── 2. Создание тенанта ──"
|
||||||
|
RESP=$(admin POST /admin/tenants '{"name":"quick-'"$TS"'","max_queues":5}')
|
||||||
|
check "POST /admin/tenants → access_key" "$RESP" "access_key"
|
||||||
|
AK=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_key'])")
|
||||||
|
SK=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['secret_key'])")
|
||||||
|
TID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
|
echo " Tenant ID: $TID"
|
||||||
|
echo " Access Key: $AK"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 3. AWS CLI CRUD ──
|
||||||
|
echo "── 3. AWS CLI CRUD ──"
|
||||||
|
QNAME="quick-q-$TS"
|
||||||
|
|
||||||
|
R=$(sqs "$AK" "$SK" create-queue --queue-name "$QNAME")
|
||||||
|
check "CreateQueue → QueueUrl" "$R" "QueueUrl"
|
||||||
|
QURL=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['QueueUrl'])")
|
||||||
|
|
||||||
|
R=$(sqs "$AK" "$SK" list-queues)
|
||||||
|
check "ListQueues → очередь $QNAME в списке" "$R" "$QNAME"
|
||||||
|
|
||||||
|
R=$(sqs "$AK" "$SK" send-message --queue-url "$QURL" --message-body "hello-quick-$TS")
|
||||||
|
check "SendMessage → MessageId" "$R" "MessageId"
|
||||||
|
|
||||||
|
R=$(sqs "$AK" "$SK" receive-message --queue-url "$QURL")
|
||||||
|
check "ReceiveMessage → тело сообщения" "$R" "hello-quick-$TS"
|
||||||
|
RECEIPT=$(echo "$R" | python3 -c "import sys,json; msgs=json.load(sys.stdin).get('Messages',[]); print(msgs[0]['ReceiptHandle'] if msgs else '')" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ -n "$RECEIPT" ]]; then
|
||||||
|
sqs "$AK" "$SK" delete-message --queue-url "$QURL" --receipt-handle "$RECEIPT" >/dev/null 2>&1
|
||||||
|
ok "DeleteMessage → без ошибок"
|
||||||
|
else
|
||||||
|
fail "DeleteMessage — нет ReceiptHandle"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 4. UI API — создание очереди ──
|
||||||
|
echo "── 4. UI API очереди ──"
|
||||||
|
R=$(ui POST "/tenants/$TID/queues" '{"name":"ui-quick-q"}')
|
||||||
|
check "UI POST /queues → создана" "$R" "ui-quick-q"
|
||||||
|
|
||||||
|
R=$(ui GET "/tenants/$TID/queues")
|
||||||
|
check "UI GET /queues → ui-quick-q в списке" "$R" "ui-quick-q"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 5. UI API — send/peek/purge ──
|
||||||
|
echo "── 5. UI API send/peek/purge ──"
|
||||||
|
R=$(ui POST "/tenants/$TID/queues/ui-quick-q/messages" '{"body":"msg-a"}')
|
||||||
|
check "UI POST /messages → id" "$R" '"id"'
|
||||||
|
ui POST "/tenants/$TID/queues/ui-quick-q/messages" '{"body":"msg-b"}' >/dev/null 2>&1
|
||||||
|
|
||||||
|
R=$(ui GET "/tenants/$TID/queues/ui-quick-q/messages")
|
||||||
|
check "UI GET /messages → msg-a" "$R" "msg-a"
|
||||||
|
check "UI GET /messages → msg-b" "$R" "msg-b"
|
||||||
|
check_not "UI GET /messages → нет receipt_handle" "$R" "receipt_handle"
|
||||||
|
|
||||||
|
# Purge
|
||||||
|
ui DELETE "/tenants/$TID/queues/ui-quick-q/messages" >/dev/null 2>&1
|
||||||
|
R=$(ui GET "/tenants/$TID/queues/ui-quick-q/messages")
|
||||||
|
check_not "UI DELETE /messages (purge) → msg-a исчезло" "$R" "msg-a"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 6. UI API — удаление очереди ──
|
||||||
|
echo "── 6. UI API удаление очереди ──"
|
||||||
|
HTTP=$(curl -sf --max-time 15 -o /dev/null -w "%{http_code}" -X DELETE \
|
||||||
|
"${BASE_URL}/ui/api/tenants/${TID}/queues/ui-quick-q")
|
||||||
|
check_http "UI DELETE /queues/ui-quick-q → 204/200" "204" "$HTTP" 2>/dev/null || \
|
||||||
|
check_http "UI DELETE /queues/ui-quick-q → 200" "200" "$HTTP"
|
||||||
|
|
||||||
|
R=$(ui GET "/tenants/$TID/queues")
|
||||||
|
check_not "Очередь ui-quick-q исчезла из списка" "$R" "ui-quick-q"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── 7. AWS CLI DeleteQueue ──
|
||||||
|
echo "── 7. DeleteQueue ──"
|
||||||
|
AWS_ACCESS_KEY_ID="$AK" AWS_SECRET_ACCESS_KEY="$SK" AWS_DEFAULT_REGION="$REGION" \
|
||||||
|
aws --endpoint-url "$BASE_URL" --output json sqs delete-queue --queue-url "$QURL" >/dev/null 2>&1
|
||||||
|
if [[ $? -eq 0 ]]; then ok "DeleteQueue → без ошибки"; else fail "DeleteQueue → ошибка"; fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Cleanup ──
|
||||||
|
echo "── Cleanup ──"
|
||||||
|
admin DELETE "/admin/tenants/$TID" >/dev/null 2>&1 && ok "DELETE тенанта" || fail "DELETE тенанта"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
printf " Результат: ✅ %d ❌ %d\n" "$PASS" "$FAIL"
|
||||||
|
echo "════════════════════════════════════════"
|
||||||
|
|
||||||
|
[[ $FAIL -eq 0 ]] && exit 0 || exit 1
|
||||||
Reference in New Issue
Block a user