add: stand configs

This commit is contained in:
“Naeel”
2026-06-30 15:46:24 +04:00
parent ca276d200f
commit 1a52506fb1
91 changed files with 10766 additions and 0 deletions
@@ -0,0 +1,3 @@
bin/
*.log
.env
@@ -0,0 +1,23 @@
# Сборочный этап: собираем статический бинарник.
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/worker ./
# Рантайм-этап: минимальный образ с ca-certificates.
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=build /out/worker /app/worker
EXPOSE 8080
CMD ["/app/worker"]
+44
View File
@@ -0,0 +1,44 @@
# Rabbit Worker (Go)
Consumes CRUD messages from RabbitMQ and writes to Postgres.
Комментарий: HTTP-эндпоинт нужен только для health-check, остальная логика работает через RabbitMQ.
## Environment variables
RabbitMQ:
- `AMQP_URL` (preferred), example: `amqp://user:pass@host:5672/vhost`
- or `RABBIT_HOST`, `RABBIT_PORT` (default 5672), `RABBIT_USER`, `RABBIT_PASSWORD`, `RABBIT_VHOST` (default `/`)
- `RABBIT_QUEUES` (comma-separated, default `crud_queue`)
- `RABBIT_DURABLE` (default `true`)
- `RABBIT_PREFETCH` (default `1`)
- `REQUEUE_ON_ERROR` (default `true`)
Postgres:
- `DATABASE_URL` (preferred)
- or `PGHOST`, `PGPORT` (default 5432), `PGUSER`, `PGPASSWORD`, `PGDATABASE` (default `postgres`), `PGSSLMODE` (default `require`)
- `PG_TABLE` (default `nubes_test_table`)
## Message format
JSON payload:
```json
{"action":"create","text":"hello","id":1}
```
Fields:
- `action`: `create` | `update` | `delete`
- `id`: required for `update` and `delete`
- `text`: used by `create` and `update`
If the payload is not JSON, it is treated as `create` with body as text.
Примечание: сообщения без `id` для update/delete считаются некорректными и отбрасываются.
## Build
```bash
cd PROD_STAND/RABBIT/rabbit-worker
docker build -t <registry>/<image>:<tag> .
```
+17
View File
@@ -0,0 +1,17 @@
module gitea-naeel.giteak8s.services.ngcloud.ru/naeel/rabbit-worker
go 1.22
require (
github.com/jackc/pgx/v5 v5.5.5
github.com/rabbitmq/amqp091-go v1.10.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
+32
View File
@@ -0,0 +1,32 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+341
View File
@@ -0,0 +1,341 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
amqp "github.com/rabbitmq/amqp091-go"
)
// Воркер держит HTTP /healthz и параллельно слушает RabbitMQ,
// записывая события в Postgres. Даже при ошибках контейнер остается живым.
type Message struct {
Action string `json:"action"`
ID *int64 `json:"id,omitempty"`
Text string `json:"text,omitempty"`
Source string `json:"source,omitempty"`
RequestID string `json:"request_id,omitempty"`
}
type workerStatus struct {
mu sync.Mutex
state string
lastError string
lastSuccess time.Time
}
func (s *workerStatus) set(state, err string) {
s.mu.Lock()
defer s.mu.Unlock()
s.state = state
s.lastError = err
if err == "" {
s.lastSuccess = time.Now()
}
}
func (s *workerStatus) snapshot() map[string]string {
s.mu.Lock()
defer s.mu.Unlock()
result := map[string]string{
"state": s.state,
"error": s.lastError,
"timestamp": s.lastSuccess.Format(time.RFC3339),
}
return result
}
// getenv читает переменную окружения с дефолтом.
func getenv(key, def string) string {
val := os.Getenv(key)
if val == "" {
return def
}
return val
}
// getenvInt читает int-переменную окружения с дефолтом.
func getenvInt(key string, def int) int {
val := os.Getenv(key)
if val == "" {
return def
}
parsed, err := strconv.Atoi(val)
if err != nil {
return def
}
return parsed
}
// buildAMQPURL собирает URL для RabbitMQ.
func buildAMQPURL() (string, error) {
if url := os.Getenv("AMQP_URL"); url != "" {
return url, nil
}
host := getenv("RABBIT_HOST", "")
port := getenv("RABBIT_PORT", "5672")
user := getenv("RABBIT_USER", "")
pass := getenv("RABBIT_PASSWORD", "")
vhost := getenv("RABBIT_VHOST", "/")
if host == "" || user == "" || pass == "" {
return "", errors.New("missing RABBIT_HOST/RABBIT_USER/RABBIT_PASSWORD or AMQP_URL")
}
return fmt.Sprintf("amqp://%s:%s@%s:%s%s", user, pass, host, port, vhost), nil
}
// buildPgConnString собирает строку подключения к Postgres.
func buildPgConnString() (string, error) {
if url := os.Getenv("DATABASE_URL"); url != "" {
return url, nil
}
host := getenv("PGHOST", "")
if host == "" {
return "", errors.New("missing PGHOST or DATABASE_URL")
}
port := getenv("PGPORT", "5432")
user := getenv("PGUSER", "")
pass := getenv("PGPASSWORD", "")
db := getenv("PGDATABASE", "postgres")
ssl := getenv("PGSSLMODE", "require")
if user == "" || pass == "" {
return "", errors.New("missing PGUSER/PGPASSWORD or DATABASE_URL")
}
return fmt.Sprintf(
"postgresql://%s:%s@%s:%s/%s?sslmode=%s",
user,
pass,
host,
port,
db,
ssl,
), nil
}
// ensureTable гарантирует наличие таблицы для демо.
func ensureTable(ctx context.Context, pool *pgxpool.Pool, table string) error {
query := fmt.Sprintf(
"CREATE TABLE IF NOT EXISTS %s (id SERIAL PRIMARY KEY, test_data TEXT, created_at TIMESTAMP DEFAULT NOW())",
table,
)
_, err := pool.Exec(ctx, query)
return err
}
// parseMessage принимает JSON и fallback-ит в текст.
func parseMessage(body []byte) Message {
msg := Message{Action: "create"}
if err := json.Unmarshal(body, &msg); err != nil {
msg.Text = strings.TrimSpace(string(body))
if msg.Text == "" {
msg.Text = "(empty)"
}
return msg
}
if msg.Action == "" {
msg.Action = "create"
}
return msg
}
var errInvalidMessage = errors.New("invalid message")
// handleMessage выполняет CRUD над таблицей.
func handleMessage(ctx context.Context, pool *pgxpool.Pool, table string, msg Message) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
switch strings.ToLower(msg.Action) {
case "create":
text := msg.Text
if text == "" {
text = "(empty)"
}
query := fmt.Sprintf("INSERT INTO %s (test_data) VALUES ($1)", table)
_, err := pool.Exec(ctx, query, text)
return err
case "update":
if msg.ID == nil {
return errInvalidMessage
}
query := fmt.Sprintf("UPDATE %s SET test_data=$1 WHERE id=$2", table)
_, err := pool.Exec(ctx, query, msg.Text, *msg.ID)
return err
case "delete":
if msg.ID == nil {
return errInvalidMessage
}
query := fmt.Sprintf("DELETE FROM %s WHERE id=$1", table)
_, err := pool.Exec(ctx, query, *msg.ID)
return err
default:
return errInvalidMessage
}
}
// startHTTPServer поднимает /healthz и базовый ответ на /.
func startHTTPServer(port string, status *workerStatus) {
http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
payload, _ := json.Marshal(status.snapshot())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
})
http.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("rabbit-worker ready"))
})
log.Printf("listening on :%s", port)
if err := http.ListenAndServe(fmt.Sprintf(":%s", port), nil); err != nil {
log.Fatal(err)
}
}
// runWorker подключается к Rabbit и обрабатывает сообщения.
func runWorker(ctx context.Context, status *workerStatus) error {
amqpURL, err := buildAMQPURL()
if err != nil {
return err
}
pgConn, err := buildPgConnString()
if err != nil {
return err
}
// Список очередей поддерживает несколько значений через запятую.
queues := strings.Split(getenv("RABBIT_QUEUES", "crud_queue"), ",")
for i := range queues {
queues[i] = strings.TrimSpace(queues[i])
}
queueDurable := getenv("RABBIT_DURABLE", "true") == "true"
prefetch := getenvInt("RABBIT_PREFETCH", 1)
requeueOnError := getenv("REQUEUE_ON_ERROR", "true") == "true"
table := getenv("PG_TABLE", "nubes_test_table")
pool, err := pgxpool.New(ctx, pgConn)
if err != nil {
return fmt.Errorf("pg pool: %w", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
return fmt.Errorf("pg ping: %w", err)
}
if err := ensureTable(ctx, pool, table); err != nil {
return fmt.Errorf("ensure table: %w", err)
}
conn, err := amqp.Dial(amqpURL)
if err != nil {
return fmt.Errorf("amqp dial: %w", err)
}
defer conn.Close()
ch, err := conn.Channel()
if err != nil {
return fmt.Errorf("amqp channel: %w", err)
}
defer ch.Close()
if err := ch.Qos(prefetch, 0, false); err != nil {
return fmt.Errorf("amqp qos: %w", err)
}
for _, q := range queues {
if q == "" {
continue
}
if _, err := ch.QueueDeclare(q, queueDurable, false, false, false, nil); err != nil {
return fmt.Errorf("queue declare %s: %w", q, err)
}
}
// Сливаем несколько очередей в один канал сообщений.
msgs := make(chan amqp.Delivery)
for _, q := range queues {
if q == "" {
continue
}
delivery, err := ch.Consume(q, "", false, false, false, false, nil)
if err != nil {
return fmt.Errorf("consume %s: %w", q, err)
}
go func(d <-chan amqp.Delivery) {
for m := range d {
msgs <- m
}
}(delivery)
}
status.set("running", "")
for {
select {
case <-ctx.Done():
return nil
case m := <-msgs:
msg := parseMessage(m.Body)
if err := handleMessage(ctx, pool, table, msg); err != nil {
if errors.Is(err, errInvalidMessage) {
_ = m.Nack(false, false)
log.Printf("reject message: %v", err)
continue
}
_ = m.Nack(false, requeueOnError)
log.Printf("error handling message: %v", err)
continue
}
_ = m.Ack(false)
}
}
}
func main() {
port := getenv("PORT", "8080")
status := &workerStatus{state: "starting"}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go startHTTPServer(port, status)
// Экспоненциальный backoff при ошибках подключения.
backoff := 2 * time.Second
for {
if ctx.Err() != nil {
return
}
if err := runWorker(ctx, status); err != nil {
status.set("degraded", err.Error())
log.Printf("worker error: %v", err)
time.Sleep(backoff)
if backoff < 30*time.Second {
backoff *= 2
}
continue
}
status.set("stopped", "")
return
}
}