feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// Package health — HTTP-endpoint /healthz.
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State хранит состояние цикла reconcile.
|
||||
type State struct {
|
||||
maxAge time.Duration
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
started time.Time
|
||||
lastAttempt time.Time
|
||||
lastSuccess time.Time
|
||||
lastError string
|
||||
}
|
||||
|
||||
// NewState: сервис считается нездоровым, если цикл не отрабатывал дольше maxAge.
|
||||
func NewState(maxAge time.Duration) *State {
|
||||
s := &State{maxAge: maxAge, now: time.Now}
|
||||
s.started = s.now()
|
||||
return s
|
||||
}
|
||||
|
||||
// Record фиксирует результат цикла.
|
||||
func (s *State) Record(err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastAttempt = s.now()
|
||||
if err == nil {
|
||||
s.lastSuccess = s.lastAttempt
|
||||
s.lastError = ""
|
||||
return
|
||||
}
|
||||
s.lastError = err.Error()
|
||||
}
|
||||
|
||||
type status struct {
|
||||
Status string `json:"status"`
|
||||
LastAttempt string `json:"lastAttempt,omitempty"`
|
||||
LastSuccess string `json:"lastSuccess,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
}
|
||||
|
||||
// Handler отдаёт 200, если цикл жив (попытки идут), иначе 503.
|
||||
// Ошибки внешних систем (Traefik/Selectel) не делают сервис unhealthy —
|
||||
// рестарт контейнера их не исправит; они видны в lastError.
|
||||
func (s *State) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
ref := s.lastAttempt
|
||||
if ref.IsZero() {
|
||||
ref = s.started
|
||||
}
|
||||
ok := s.now().Sub(ref) <= s.maxAge
|
||||
st := status{Status: "ok", LastError: s.lastError}
|
||||
if !s.lastAttempt.IsZero() {
|
||||
st.LastAttempt = s.lastAttempt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !s.lastSuccess.IsZero() {
|
||||
st.LastSuccess = s.lastSuccess.UTC().Format(time.RFC3339)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
code := http.StatusOK
|
||||
if !ok {
|
||||
st.Status, code = "stalled", http.StatusServiceUnavailable
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(st)
|
||||
})
|
||||
}
|
||||
|
||||
// Serve запускает HTTP-сервер и завершает его при отмене ctx.
|
||||
func Serve(ctx context.Context, addr string, h http.Handler) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", h)
|
||||
srv := &http.Server{
|
||||
Addr: addr, Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second,
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- srv.ListenAndServe() }()
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(sctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Probe выполняет GET /healthz на локальном адресе listen (для флага -healthcheck).
|
||||
func Probe(listen string, timeout time.Duration) error {
|
||||
host, port, err := net.SplitHostPort(listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
c := &http.Client{Timeout: timeout}
|
||||
resp, err := c.Get("http://" + net.JoinHostPort(host, port) + "/healthz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("healthz: статус %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHandler(t *testing.T) {
|
||||
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
s := NewState(time.Minute)
|
||||
s.now = func() time.Time { return now }
|
||||
s.started = now
|
||||
|
||||
get := func() (int, string) {
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
if code, _ := get(); code != 200 {
|
||||
t.Fatalf("сразу после старта: %d", code)
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
if code, body := get(); code != 503 || !strings.Contains(body, "stalled") {
|
||||
t.Fatalf("застрял: %d %s", code, body)
|
||||
}
|
||||
s.Record(errors.New("traefik down"))
|
||||
code, body := get()
|
||||
if code != 200 || !strings.Contains(body, "traefik down") {
|
||||
t.Fatalf("ошибка внешней системы не должна делать unhealthy: %d %s", code, body)
|
||||
}
|
||||
s.Record(nil)
|
||||
if _, body := get(); !strings.Contains(body, "lastSuccess") || strings.Contains(body, "lastError") {
|
||||
t.Fatalf("body = %s", body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user