diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f32215 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /server /usr/local/bin/server +COPY config/config.yaml /etc/notification/config.yaml + +EXPOSE 8080 +ENTRYPOINT ["server"] diff --git a/cmd/server/main.go b/cmd/server/main.go index 127c512..b6036b2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,21 +1,160 @@ package main import ( + "context" + "fmt" "log/slog" + "net/http" "os" + "os/signal" + "syscall" + "time" + "aiaa-notification-service/internal/adapter" + "aiaa-notification-service/internal/cache" "aiaa-notification-service/internal/config" + "aiaa-notification-service/internal/engine" + "aiaa-notification-service/internal/handler" + "aiaa-notification-service/internal/store" + + "github.com/gin-gonic/gin" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) + // Load config cfg, err := config.Load("config/config.yaml") if err != nil { slog.Error("failed to load config", "error", err) os.Exit(1) } - slog.Info("config loaded", "port", cfg.Server.Port) + // Connect MySQL + st, err := store.NewStore(cfg.Database) + if err != nil { + slog.Error("failed to connect to MySQL", "error", err) + os.Exit(1) + } + defer st.Close() + + // Connect Redis + var redisCache *cache.Cache + if cfg.Redis.Host != "" { + redisCache, err = cache.NewCache(cfg.Redis) + if err != nil { + slog.Warn("redis connection failed, running without cache", "error", err) + redisCache = nil + } + } else { + slog.Warn("redis not configured, running without cache") + } + if redisCache != nil { + defer redisCache.Close() + } + + // Build engine + matcher := engine.NewMatcher(st, redisCache) + renderer := engine.NewRenderer() + + // Build sender factory + senderFactory := func(channelType string) (adapter.ChannelSender, error) { + return adapter.NewSender(channelType, &cfg.SMTP) + } + router := engine.NewRouter(st, redisCache, senderFactory) + + // Build handlers + notifyH := handler.NewNotifyHandler(st, redisCache, matcher, renderer, router) + sourceH := handler.NewSourceHandler(st, redisCache) + templateH := handler.NewTemplateHandler(st, redisCache) + channelH := handler.NewChannelHandler(st, redisCache) + ruleH := handler.NewRuleHandler(st, redisCache) + msgLogH := handler.NewMessageLogHandler(st) + + // Setup Gin + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) + + // Health check + r.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + api := r.Group("/api/v1") + + // Notify endpoint (source auth + rate limit) + notifyGroup := api.Group("/notify") + notifyGroup.Use(handler.SourceAuth(st)) + if redisCache != nil { + notifyGroup.Use(handler.RateLimit(redisCache, cfg.RateLimit.Default)) + } + notifyGroup.POST("", notifyH.Handle) + + // Management endpoints (admin auth) + admin := api.Group("") + admin.Use(handler.AdminAuth(cfg.Server.AdminKey)) + + // Sources + admin.POST("/sources", sourceH.Create) + admin.GET("/sources", sourceH.List) + admin.GET("/sources/:id", sourceH.Get) + admin.PUT("/sources/:id", sourceH.Update) + admin.DELETE("/sources/:id", sourceH.Delete) + + // Templates + admin.POST("/templates", templateH.Create) + admin.GET("/templates", templateH.List) + admin.GET("/templates/:id", templateH.Get) + admin.PUT("/templates/:id", templateH.Update) + admin.DELETE("/templates/:id", templateH.Delete) + + // Channels + admin.POST("/channels", channelH.Create) + admin.GET("/channels", channelH.List) + admin.GET("/channels/:id", channelH.Get) + admin.PUT("/channels/:id", channelH.Update) + admin.DELETE("/channels/:id", channelH.Delete) + + // Rules + admin.POST("/rules", ruleH.Create) + admin.GET("/rules", ruleH.List) + admin.GET("/rules/:id", ruleH.Get) + admin.PUT("/rules/:id", ruleH.Update) + admin.DELETE("/rules/:id", ruleH.Delete) + admin.PATCH("/rules/:id/enable", ruleH.Enable) + admin.PATCH("/rules/:id/disable", ruleH.Disable) + admin.PATCH("/rules/:id/channels/:channel_id/enable", ruleH.EnableChannel) + admin.PATCH("/rules/:id/channels/:channel_id/disable", ruleH.DisableChannel) + + // Message Logs + admin.GET("/message-logs", msgLogH.List) + + // Start server + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Server.Port), + Handler: r, + } + + go func() { + slog.Info("server starting", "port", cfg.Server.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + // Graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + slog.Info("shutting down...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + slog.Error("forced shutdown", "error", err) + } + slog.Info("server stopped") } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5628f7a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_DATABASE: notification + MYSQL_USER: notify + MYSQL_PASSWORD: notify + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 5s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + + api: + build: . + ports: + - "8080:8080" + environment: + - DB_PASSWORD=notify + - SMTP_PASSWORD= + - NOTIFY_DATABASE_HOST=mysql + - NOTIFY_REDIS_HOST=redis + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + +volumes: + mysql_data: diff --git a/go.mod b/go.mod index 2005ed5..bb606ed 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module aiaa-notification-service go 1.26.2 require ( + github.com/gin-gonic/gin v1.12.0 github.com/go-sql-driver/mysql v1.10.0 github.com/jmoiron/sqlx v1.4.0 github.com/redis/go-redis/v9 v9.21.0 @@ -19,7 +20,6 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/gin-gonic/gin v1.12.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect diff --git a/go.sum b/go.sum index c3e6a47..23d55b1 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -43,15 +45,13 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -81,8 +81,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -118,6 +118,8 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= @@ -127,19 +129,15 @@ golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVo golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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= diff --git a/server b/server new file mode 100755 index 0000000..b1ce6a7 Binary files /dev/null and b/server differ