From b31f886ae20fc9a56a8a503ff4107215006998a1 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Sat, 4 Jul 2026 14:14:00 +0700 Subject: [PATCH] =?UTF-8?q?feat(server):=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0,=20/metrics,=20graceful=20shutdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 81 ++++++++++++++++++++++++++++++++----- internal/notify/dispatch.go | 23 +++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 7ab73fc..c29fbb0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,9 +2,13 @@ package main import ( "context" + "errors" "log" "net/http" + "os" + "os/signal" "strings" + "syscall" "time" "github.com/jackc/pgx/v5/pgxpool" @@ -13,8 +17,11 @@ import ( "github.com/vasyakrg/dns-autoresolver/internal/auth" "github.com/vasyakrg/dns-autoresolver/internal/config" "github.com/vasyakrg/dns-autoresolver/internal/crypto" + "github.com/vasyakrg/dns-autoresolver/internal/metrics" + "github.com/vasyakrg/dns-autoresolver/internal/notify" "github.com/vasyakrg/dns-autoresolver/internal/provider/registry" "github.com/vasyakrg/dns-autoresolver/internal/provider/selectel" + "github.com/vasyakrg/dns-autoresolver/internal/scheduler" "github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/web" @@ -24,6 +31,16 @@ import ( // user must re-authenticate. const sessionTTL = 720 * time.Hour +// schedulerTick is how often the in-process scheduler checks for due project +// schedules. Individual projects only actually run when their own +// schedules.interval_seconds has elapsed (see internal/store ListDueSchedules) — +// this is just the polling granularity. +const schedulerTick = time.Minute + +// shutdownTimeout bounds how long graceful shutdown waits for in-flight HTTP +// requests to finish before forcing the listener closed. +const shutdownTimeout = 10 * time.Second + // isAPIPath reports whether path must be routed to the API router rather // than the SPA. "/api" (no trailing slash) counts as an API path too — // only strings.HasPrefix(path, "/api/") would otherwise miss it and fall @@ -33,7 +50,9 @@ func isAPIPath(path string) bool { } func main() { - ctx := context.Background() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) @@ -58,7 +77,14 @@ func main() { reg.Register(selectel.New()) svc := service.New(st, st, reg, cipher) - a := &api.API{Svc: svc, Store: st, Cipher: cipher, Reg: reg, Auth: st, Sessions: sessions} + + m := metrics.New() + dispatcher := notify.NewDispatcher(st, cipher) + + a := &api.API{ + Svc: svc, Store: st, Cipher: cipher, Reg: reg, Auth: st, Sessions: sessions, + Schedule: st, Dispatch: dispatcher, + } apiRouter := api.NewRouter(a) webHandler, err := web.Handler() @@ -66,20 +92,53 @@ func main() { log.Printf("web: static UI unavailable: %v", err) } + // The scheduler only checks and notifies — it never applies zone changes + // (Apply stays a manual, explicit API call). Its own errors are logged + // internally and never stop the loop; ctx cancellation (signal) is the + // only thing that ends Run. + sched := scheduler.New(st, svc, dispatcher, m) + go sched.Run(ctx, schedulerTick) + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if isAPIPath(r.URL.Path) { + switch { + case r.URL.Path == "/metrics": + // Public by design (no auth) — Metrics.Handler only ever exposes + // aggregate counters/gauges, never per-domain or secret data. + m.Handler().ServeHTTP(w, r) + case isAPIPath(r.URL.Path): apiRouter.ServeHTTP(w, r) - return - } - if webHandler != nil { + case webHandler != nil: webHandler.ServeHTTP(w, r) - return + default: + http.NotFound(w, r) } - http.NotFound(w, r) }) - log.Printf("listening on %s", cfg.ListenAddr) - if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil { - log.Fatal(err) + srv := &http.Server{Addr: cfg.ListenAddr, Handler: mux} + + serveErr := make(chan error, 1) + go func() { + log.Printf("listening on %s", cfg.ListenAddr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + return + } + serveErr <- nil + }() + + select { + case <-ctx.Done(): + log.Printf("shutdown signal received, draining connections (timeout %s)", shutdownTimeout) + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("server: graceful shutdown failed: %v", err) + } + <-serveErr + log.Printf("server stopped") + case err := <-serveErr: + if err != nil { + log.Fatalf("server: %v", err) + } } } diff --git a/internal/notify/dispatch.go b/internal/notify/dispatch.go index c8d76db..357e772 100644 --- a/internal/notify/dispatch.go +++ b/internal/notify/dispatch.go @@ -2,7 +2,9 @@ package notify import ( "context" + "encoding/json" "errors" + "fmt" "net/http" "time" @@ -76,3 +78,24 @@ func (d *Dispatcher) Send(ctx context.Context, projectID uuid.UUID, ev Event) er } return errors.Join(errs...) } + +// SendTest sends a single synthetic Event directly through the Notifier for +// channelType, bypassing project/channel lookup entirely. It satisfies +// api.TestSender and backs POST /channels/{cid}/test, letting a user verify +// a channel's bot_token/chat_id or webhook URL works before enabling the +// schedule — the api layer resolves the channel and decrypts its secret; this +// method only performs the actual delivery attempt. +func (d *Dispatcher) SendTest(ctx context.Context, channelType string, config json.RawMessage, secret string) error { + n, ok := d.byType[channelType] + if !ok { + return fmt.Errorf("notify: unknown channel type %q", channelType) + } + ev := Event{ + Project: "test", + Domain: "test", + Status: "test", + Summary: "test notification", + At: time.Now(), + } + return n.Send(ctx, config, secret, ev) +}