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
|
||||
- [ ] 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)
|
||||
- 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 ./
|
||||
RUN go mod download
|
||||
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
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// app/admin/admin.go
|
||||
// Admin API handlers for shared-sqs management
|
||||
// Created: 2026-04-09
|
||||
// Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений
|
||||
package admin
|
||||
|
||||
import (
|
||||
@@ -12,10 +13,24 @@ import (
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/tenant"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/mux"
|
||||
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
|
||||
type Handler struct {
|
||||
store *tenant.TenantStore
|
||||
@@ -27,7 +42,7 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
|
||||
return &Handler{store: store, adminToken: adminToken}
|
||||
}
|
||||
|
||||
// RegisterRoutes — регистрирует admin маршруты на переданном router
|
||||
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
|
||||
func (h *Handler) RegisterRoutes(r *mux.Router) {
|
||||
adminRouter := r.PathPrefix("/admin").Subrouter()
|
||||
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.deleteTenant).Methods("DELETE")
|
||||
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")
|
||||
}
|
||||
|
||||
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
|
||||
// Дублируют admin API, но доступны без bearer token для удобства демо
|
||||
// TODO: убрать или заменить на session-auth перед production
|
||||
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
|
||||
ui := r.PathPrefix("/ui/api").Subrouter()
|
||||
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.deleteTenant).Methods("DELETE")
|
||||
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)
|
||||
@@ -245,6 +271,216 @@ func (h *Handler) listTenantQueues(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
type adminHealthDetail struct {
|
||||
Status string `json:"status"`
|
||||
|
||||
+115
-112
@@ -4,124 +4,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
"context"
|
||||
"flag"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"shared-sqs/app/conf"
|
||||
"shared-sqs/app/gosqs"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/router"
|
||||
"shared-sqs/app/tenant"
|
||||
"shared-sqs/app/conf"
|
||||
"shared-sqs/app/gosqs"
|
||||
"shared-sqs/app/models"
|
||||
"shared-sqs/app/router"
|
||||
"shared-sqs/app/tenant"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configFile string
|
||||
var adminToken string
|
||||
var port string
|
||||
var debug bool
|
||||
var loglevel string
|
||||
var configFile string
|
||||
var adminToken string
|
||||
var port string
|
||||
var debug bool
|
||||
var loglevel string
|
||||
|
||||
flag.StringVar(&configFile, "config", "", "config file location")
|
||||
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
|
||||
flag.StringVar(&port, "port", "4100", "listen port")
|
||||
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
||||
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
|
||||
flag.Parse()
|
||||
flag.StringVar(&configFile, "config", "", "config file location")
|
||||
flag.StringVar(&adminToken, "admin-token", "", "admin API bearer token")
|
||||
flag.StringVar(&port, "port", "4100", "listen port")
|
||||
flag.BoolVar(&debug, "debug", false, "set debug log level")
|
||||
flag.StringVar(&loglevel, "loglevel", "info", "log level (info, debug, warn, error)")
|
||||
flag.Parse()
|
||||
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
log.SetOutput(os.Stdout)
|
||||
|
||||
if debug {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
} else {
|
||||
level, err := log.ParseLevel(loglevel)
|
||||
if err != nil {
|
||||
log.SetLevel(log.InfoLevel)
|
||||
log.Warnf("Failed to parse loglevel %v, defaulting to info", loglevel)
|
||||
} else {
|
||||
log.SetLevel(level)
|
||||
}
|
||||
}
|
||||
|
||||
// Admin token: flag > env SHARED_SQS_ADMIN_TOKEN > fatal (Trap #13)
|
||||
if adminToken == "" {
|
||||
adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN")
|
||||
}
|
||||
if adminToken == "" {
|
||||
log.Fatal("admin token required: use --admin-token flag or SHARED_SQS_ADMIN_TOKEN env var")
|
||||
}
|
||||
|
||||
// Загрузить конфиг (очереди, env — без SNS)
|
||||
env := "Local"
|
||||
if flag.NArg() > 0 {
|
||||
env = flag.Arg(0)
|
||||
}
|
||||
conf.LoadYamlConfig(configFile, env)
|
||||
|
||||
if models.CurrentEnvironment.LogToFile {
|
||||
filename := models.CurrentEnvironment.LogFile
|
||||
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err == nil {
|
||||
log.SetOutput(file)
|
||||
} else {
|
||||
log.Infof("Failed to log to file: %s, using default stdout", filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация in-memory TenantStore
|
||||
tenantStore := tenant.NewTenantStore()
|
||||
|
||||
// Роутер с tenant auth и admin API
|
||||
r := router.New(tenantStore, adminToken)
|
||||
|
||||
// PeriodicTasks — visibility timeout, DLQ, deduplication
|
||||
quit := make(chan bool)
|
||||
go gosqs.PeriodicTasks(1*time.Second, quit)
|
||||
|
||||
// HTTP сервер с таймаутами
|
||||
srv := &http.Server{
|
||||
Addr: "0.0.0.0:" + port,
|
||||
Handler: r,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s)
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Запуск в горутине для graceful shutdown
|
||||
serverErr := make(chan error, 1)
|
||||
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)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
log.Infof("Received signal %s, shutting down", sig)
|
||||
case err := <-serverErr:
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
|
||||
// Остановить PeriodicTasks
|
||||
close(quit)
|
||||
|
||||
// Дать 10 секунд на завершение текущих HTTP запросов
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Errorf("Server shutdown error: %v", err)
|
||||
}
|
||||
log.Info("shared-sqs stopped")
|
||||
if debug {
|
||||
log.SetLevel(log.DebugLevel)
|
||||
} else {
|
||||
level, err := log.ParseLevel(loglevel)
|
||||
if err != nil {
|
||||
log.SetLevel(log.InfoLevel)
|
||||
log.Warnf("Failed to parse loglevel %v, defaulting to info", loglevel)
|
||||
} else {
|
||||
log.SetLevel(level)
|
||||
}
|
||||
}
|
||||
|
||||
// Admin token: flag > env SHARED_SQS_ADMIN_TOKEN > fatal (Trap #13)
|
||||
if adminToken == "" {
|
||||
adminToken = os.Getenv("SHARED_SQS_ADMIN_TOKEN")
|
||||
}
|
||||
if adminToken == "" {
|
||||
log.Fatal("admin token required: use --admin-token flag or SHARED_SQS_ADMIN_TOKEN env var")
|
||||
}
|
||||
|
||||
// Загрузить конфиг (очереди, env — без SNS)
|
||||
env := "Local"
|
||||
if flag.NArg() > 0 {
|
||||
env = flag.Arg(0)
|
||||
}
|
||||
conf.LoadYamlConfig(configFile, env)
|
||||
|
||||
if models.CurrentEnvironment.LogToFile {
|
||||
filename := models.CurrentEnvironment.LogFile
|
||||
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
|
||||
if err == nil {
|
||||
log.SetOutput(file)
|
||||
} else {
|
||||
log.Infof("Failed to log to file: %s, using default stdout", filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация in-memory TenantStore
|
||||
tenantStore := tenant.NewTenantStore()
|
||||
// Автосид демо-данных при SHARED_SQS_SEED_DEMO=true
|
||||
if os.Getenv("SHARED_SQS_SEED_DEMO") == "true" {
|
||||
seedDemoData(tenantStore)
|
||||
}
|
||||
// Роутер с tenant auth и admin API
|
||||
r := router.New(tenantStore, adminToken)
|
||||
|
||||
// PeriodicTasks — visibility timeout, DLQ, deduplication
|
||||
quit := make(chan bool)
|
||||
go gosqs.PeriodicTasks(1*time.Second, quit)
|
||||
|
||||
// HTTP сервер с таймаутами
|
||||
srv := &http.Server{
|
||||
Addr: "0.0.0.0:" + port,
|
||||
Handler: r,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 35 * time.Second, // чуть больше чем max WaitTimeSeconds (20s)
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Запуск в горутине для graceful shutdown
|
||||
serverErr := make(chan error, 1)
|
||||
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)
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
log.Infof("Received signal %s, shutting down", sig)
|
||||
case err := <-serverErr:
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
|
||||
// Остановить PeriodicTasks
|
||||
close(quit)
|
||||
|
||||
// Дать 10 секунд на завершение текущих HTTP запросов
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
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
|
||||
MaximumMessageSize: 262144 # maximum message size (bytes)
|
||||
# MessageRetentionPeriod: 445600 # time period to retain messages (seconds) NOTE: Functionality not implemented
|
||||
Queues: # List of queues to create at startup
|
||||
- Name: local-queue1 # Queue name
|
||||
- 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
|
||||
Queues: [] # No default queues — created via Admin API / AWS CLI by tenants
|
||||
Topics: [] # No default topics
|
||||
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.
|
||||
Max: 0 # Desired latency in milliseconds
|
||||
|
||||
@@ -3,140 +3,165 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tenant — модель тенанта shared-sqs.
|
||||
// AccessKey используется как идентификатор в AWS Authorization header.
|
||||
type Tenant struct {
|
||||
ID string // уникальный идентификатор тенанта (t-<hex>)
|
||||
Name string // человекочитаемое имя
|
||||
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
||||
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
|
||||
MaxQueues int // лимит очередей (0 = безлимит)
|
||||
CreatedAt time.Time
|
||||
Active bool
|
||||
ID string // уникальный идентификатор тенанта (t-<hex>)
|
||||
Name string // человекочитаемое имя
|
||||
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
|
||||
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
|
||||
MaxQueues int // лимит очередей (0 = безлимит)
|
||||
CreatedAt time.Time
|
||||
Active bool
|
||||
}
|
||||
|
||||
// TenantStore — потокобезопасное in-memory хранилище тенантов.
|
||||
// Два индекса позволяют быстро искать как по ID (admin API), так и по AccessKey (auth middleware).
|
||||
type TenantStore struct {
|
||||
mu sync.RWMutex
|
||||
byID map[string]*Tenant
|
||||
byAccessKey map[string]*Tenant
|
||||
mu sync.RWMutex
|
||||
byID map[string]*Tenant
|
||||
byAccessKey map[string]*Tenant
|
||||
}
|
||||
|
||||
// NewTenantStore — создаёт пустое хранилище тенантов.
|
||||
func NewTenantStore() *TenantStore {
|
||||
return &TenantStore{
|
||||
byID: make(map[string]*Tenant),
|
||||
byAccessKey: make(map[string]*Tenant),
|
||||
}
|
||||
return &TenantStore{
|
||||
byID: make(map[string]*Tenant),
|
||||
byAccessKey: make(map[string]*Tenant),
|
||||
}
|
||||
}
|
||||
|
||||
// Create — создаёт нового тенанта, генерирует ключи, сохраняет в оба индекса.
|
||||
func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
|
||||
id, err := generateTenantID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate tenant id: %w", err)
|
||||
}
|
||||
accessKey, err := generateAccessKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access key: %w", err)
|
||||
}
|
||||
secretKey, err := generateSecretKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate secret key: %w", err)
|
||||
id, err := generateTenantID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate tenant id: %w", err)
|
||||
}
|
||||
accessKey, err := generateAccessKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access key: %w", err)
|
||||
}
|
||||
secretKey, err := generateSecretKey()
|
||||
if err != nil {
|
||||
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{
|
||||
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
|
||||
// CreateFixed — создаёт тенанта с заранее известными credentials (для seed/demo).
|
||||
// Используется только при инициализации демо-данных; не вызывается из user-facing API.
|
||||
func (s *TenantStore) CreateFixed(name string, maxQueues int, tenantID, accessKey, secretKey string) (*Tenant, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, exists := s.byID[tenantID]; exists {
|
||||
return nil, fmt.Errorf("tenant with id %s already exists", tenantID)
|
||||
}
|
||||
if _, exists := s.byAccessKey[accessKey]; exists {
|
||||
return nil, fmt.Errorf("tenant with access key %s already exists", accessKey)
|
||||
}
|
||||
t := &Tenant{
|
||||
ID: tenantID,
|
||||
Name: name,
|
||||
AccessKey: accessKey,
|
||||
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).
|
||||
func (s *TenantStore) GetByAccessKey(accessKey string) (*Tenant, bool) {
|
||||
s.mu.RLock()
|
||||
t, ok := s.byAccessKey[accessKey]
|
||||
s.mu.RUnlock()
|
||||
return t, ok
|
||||
s.mu.RLock()
|
||||
t, ok := s.byAccessKey[accessKey]
|
||||
s.mu.RUnlock()
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// GetByID — поиск тенанта по ID (используется в admin API).
|
||||
func (s *TenantStore) GetByID(id string) (*Tenant, bool) {
|
||||
s.mu.RLock()
|
||||
t, ok := s.byID[id]
|
||||
s.mu.RUnlock()
|
||||
return t, ok
|
||||
s.mu.RLock()
|
||||
t, ok := s.byID[id]
|
||||
s.mu.RUnlock()
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// Delete — удаляет тенанта из ОБОИХ индексов.
|
||||
// Ловушка #2: если удалить только из одного индекса — orphaned данные и memory leak.
|
||||
func (s *TenantStore) Delete(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t, ok := s.byID[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.byID, t.ID)
|
||||
delete(s.byAccessKey, t.AccessKey)
|
||||
return true
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t, ok := s.byID[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.byID, t.ID)
|
||||
delete(s.byAccessKey, t.AccessKey)
|
||||
return true
|
||||
}
|
||||
|
||||
// List — список всех тенантов (для admin GET /tenants).
|
||||
func (s *TenantStore) List() []*Tenant {
|
||||
s.mu.RLock()
|
||||
result := make([]*Tenant, 0, len(s.byID))
|
||||
for _, t := range s.byID {
|
||||
result = append(result, t)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return result
|
||||
s.mu.RLock()
|
||||
result := make([]*Tenant, 0, len(s.byID))
|
||||
for _, t := range s.byID {
|
||||
result = append(result, t)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
return result
|
||||
}
|
||||
|
||||
// generateTenantID — генерирует уникальный ID тенанта в формате t-<12 hex bytes>.
|
||||
func generateTenantID() (string, error) {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "t-" + hex.EncodeToString(b), nil
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "t-" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// generateAccessKey — генерирует AccessKey в формате SSAK-<12 hex bytes>.
|
||||
// SSAK = Shared SQS Access Key. Используем crypto/rand (ловушка #1: не math/rand).
|
||||
func generateAccessKey() (string, error) {
|
||||
b := make([]byte, 12)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "SSAK-" + hex.EncodeToString(b), nil
|
||||
b := make([]byte, 12)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "SSAK-" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// generateSecretKey — генерирует SecretKey как 64 hex символа (32 random bytes).
|
||||
// Используем crypto/rand (ловушка #1).
|
||||
func generateSecretKey() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
app/ui/index.html
|
||||
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
|
||||
Created: 2026-04-10
|
||||
Updated: 2026-04-10 — queue CRUD (create/delete) + message peek/send/purge
|
||||
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
|
||||
Режим: Admin (bearer token) — все тенанты, очереди, статистика.
|
||||
Режим: Публичный UI API без авторизации (демо). Все данные in-memory.
|
||||
-->
|
||||
<html lang="ru">
|
||||
<head>
|
||||
@@ -313,6 +314,12 @@ tbody tr { cursor: pointer; }
|
||||
font-size: 13px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -377,11 +384,68 @@ tbody tr { cursor: pointer; }
|
||||
</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>
|
||||
// ===== STATE =====
|
||||
let BASE = '';
|
||||
let refreshTimer = null;
|
||||
let currentTenantId = null;
|
||||
let msgCache = {}; // id → объект сообщения для detail modal
|
||||
let _sendState = { tenantId: null, queueName: null }; // контекст sendMessage
|
||||
let _createQueueState = { tenantId: null }; // контекст createQueue
|
||||
|
||||
// ===== INIT =====
|
||||
// Запуск — сразу показываем dashboard без логина
|
||||
@@ -543,7 +607,10 @@ function renderTenant(tenant, queues) {
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<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 class="table-wrap">
|
||||
<table>
|
||||
@@ -555,20 +622,35 @@ function renderTenant(tenant, queues) {
|
||||
<th>Visibility Timeout</th>
|
||||
<th>Max Size</th>
|
||||
<th>Retention</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${(queues || []).map(q => `
|
||||
<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.not_visible}</span></td>
|
||||
<td>${q.visibility_timeout}с</td>
|
||||
<td>${fmtBytes(q.max_message_size)}</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>
|
||||
`).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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -668,6 +750,183 @@ function fmtDuration(s) {
|
||||
if (s >= 60) return (s / 60).toFixed(0) + 'м';
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: shared-sqs
|
||||
image: pearlharbor.registryk8s.services.ngcloud.ru/naeel/shared-sqs:v0.1.0
|
||||
image: naeel/shared-sqs:v0.1.8
|
||||
ports:
|
||||
- containerPort: 4100
|
||||
name: http
|
||||
@@ -32,6 +32,8 @@ spec:
|
||||
secretKeyRef:
|
||||
name: shared-sqs-admin
|
||||
key: token
|
||||
- name: SHARED_SQS_SEED_DEMO
|
||||
value: "true"
|
||||
resources:
|
||||
requests:
|
||||
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
|
||||
# Покрывает: 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
|
||||
|
||||
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 "── 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/${T2_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_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
|
||||
|
||||
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