fix: add http error responses (#494)

This features adds rfc7807 Problem detail responses when an error happens processing a request.

This will greatly improve the common issues  with "blank pages" and "404 pages" issues which should now properly tell the user what input was wrong (group that does not exist, container name that does not exist, etc.)
This commit is contained in:
Alexis Couvreur
2025-02-01 21:00:49 -08:00
committed by GitHub
parent 2515771ce1
commit 00cc153d7a
31 changed files with 933 additions and 655 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ node_modules
.DS_Store .DS_Store
*.wasm *.wasm
kubeconfig.yaml kubeconfig.yaml
.idea

View File

@@ -17,6 +17,9 @@ GO_LDFLAGS := -s -w -X $(VPREFIX).Branch=$(GIT_BRANCH) -X $(VPREFIX).Version=$(V
$(PLATFORMS): $(PLATFORMS):
CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) go build -trimpath -tags=nomsgpack -v -ldflags="${GO_LDFLAGS}" -o 'sablier_$(VERSION)_$(os)-$(arch)' . CGO_ENABLED=0 GOOS=$(os) GOARCH=$(arch) go build -trimpath -tags=nomsgpack -v -ldflags="${GO_LDFLAGS}" -o 'sablier_$(VERSION)_$(os)-$(arch)' .
run:
go run main.go start
build: build:
go build -v . go build -v .

View File

@@ -1,78 +0,0 @@
package middleware
import (
"fmt"
"math"
"net/http"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
)
var timeFormat = "02/Jan/2006:15:04:05 -0700"
// Logger is the logrus logger handler
func Logger(logger logrus.FieldLogger, notLogged ...string) gin.HandlerFunc {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknow"
}
var skip map[string]struct{}
if length := len(notLogged); length > 0 {
skip = make(map[string]struct{}, length)
for _, p := range notLogged {
skip[p] = struct{}{}
}
}
return func(c *gin.Context) {
// other handler can change c.Path so:
path := c.Request.URL.Path
start := time.Now()
c.Next()
stop := time.Since(start)
latency := int(math.Ceil(float64(stop.Nanoseconds()) / 1000000.0))
statusCode := c.Writer.Status()
clientIP := c.ClientIP()
clientUserAgent := c.Request.UserAgent()
referer := c.Request.Referer()
dataLength := c.Writer.Size()
if dataLength < 0 {
dataLength = 0
}
if _, ok := skip[path]; ok {
return
}
entry := logger.WithFields(logrus.Fields{
"hostname": hostname,
"statusCode": statusCode,
"latency": latency, // time to process
"clientIP": clientIP,
"method": c.Request.Method,
"path": path,
"referer": referer,
"dataLength": dataLength,
"userAgent": clientUserAgent,
})
if len(c.Errors) > 0 {
entry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String())
} else {
msg := fmt.Sprintf("%s - %s [%s] \"%s %s\" %d %d \"%s\" \"%s\" (%dms)", clientIP, hostname, time.Now().Format(timeFormat), c.Request.Method, path, statusCode, dataLength, referer, clientUserAgent, latency)
if statusCode >= http.StatusInternalServerError {
entry.Error(msg)
} else if statusCode >= http.StatusBadRequest {
entry.Warn(msg)
} else {
entry.Info(msg)
}
}
}
}

View File

@@ -1,27 +1,11 @@
package routes package routes
import ( import (
"bufio"
"bytes"
"fmt"
"net/http"
"os"
"sort"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/http/routes/models"
"github.com/sablierapp/sablier/app/instance"
"github.com/sablierapp/sablier/app/sessions" "github.com/sablierapp/sablier/app/sessions"
"github.com/sablierapp/sablier/app/theme" "github.com/sablierapp/sablier/app/theme"
"github.com/sablierapp/sablier/config" "github.com/sablierapp/sablier/config"
) )
var osDirFS = os.DirFS
type ServeStrategy struct { type ServeStrategy struct {
Theme *theme.Themes Theme *theme.Themes
@@ -29,152 +13,3 @@ type ServeStrategy struct {
StrategyConfig config.Strategy StrategyConfig config.Strategy
SessionsConfig config.Sessions SessionsConfig config.Sessions
} }
func NewServeStrategy(sessionsManager sessions.Manager, strategyConf config.Strategy, sessionsConf config.Sessions, themes *theme.Themes) *ServeStrategy {
serveStrategy := &ServeStrategy{
Theme: themes,
SessionsManager: sessionsManager,
StrategyConfig: strategyConf,
SessionsConfig: sessionsConf,
}
return serveStrategy
}
func (s *ServeStrategy) ServeDynamic(c *gin.Context) {
request := models.DynamicRequest{
Theme: s.StrategyConfig.Dynamic.DefaultTheme,
ShowDetails: s.StrategyConfig.Dynamic.ShowDetailsByDefault,
RefreshFrequency: s.StrategyConfig.Dynamic.DefaultRefreshFrequency,
SessionDuration: s.SessionsConfig.DefaultDuration,
}
if err := c.ShouldBind(&request); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
var sessionState *sessions.SessionState
if len(request.Names) > 0 {
sessionState = s.SessionsManager.RequestSession(request.Names, request.SessionDuration)
} else {
sessionState = s.SessionsManager.RequestSessionGroup(request.Group, request.SessionDuration)
}
if sessionState == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
if sessionState.IsReady() {
c.Header("X-Sablier-Session-Status", "ready")
} else {
c.Header("X-Sablier-Session-Status", "not-ready")
}
renderOptions := theme.Options{
DisplayName: request.DisplayName,
ShowDetails: request.ShowDetails,
SessionDuration: request.SessionDuration,
RefreshFrequency: request.RefreshFrequency,
InstanceStates: sessionStateToRenderOptionsInstanceState(sessionState),
}
buf := new(bytes.Buffer)
writer := bufio.NewWriter(buf)
if err := s.Theme.Render(request.Theme, renderOptions, writer); err != nil {
log.Error(err)
c.AbortWithError(http.StatusInternalServerError, err)
return
}
writer.Flush()
c.Header("Cache-Control", "no-cache")
c.Header("Content-Type", "text/html")
c.Header("Content-Length", strconv.Itoa(buf.Len()))
c.Writer.Write(buf.Bytes())
}
func (s *ServeStrategy) ServeBlocking(c *gin.Context) {
request := models.BlockingRequest{
Timeout: s.StrategyConfig.Blocking.DefaultTimeout,
}
if err := c.ShouldBind(&request); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
var sessionState *sessions.SessionState
var err error
if len(request.Names) > 0 {
sessionState, err = s.SessionsManager.RequestReadySession(c.Request.Context(), request.Names, request.SessionDuration, request.Timeout)
} else {
sessionState, err = s.SessionsManager.RequestReadySessionGroup(c.Request.Context(), request.Group, request.SessionDuration, request.Timeout)
}
if err != nil {
c.AbortWithError(http.StatusInternalServerError, err)
return
}
if sessionState == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
if err != nil {
c.Header("X-Sablier-Session-Status", "not-ready")
c.JSON(http.StatusGatewayTimeout, map[string]interface{}{"error": err.Error()})
return
}
if sessionState.IsReady() {
c.Header("X-Sablier-Session-Status", "ready")
} else {
c.Header("X-Sablier-Session-Status", "not-ready")
}
c.JSON(http.StatusOK, map[string]interface{}{"session": sessionState})
}
func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionState) (instances []theme.Instance) {
if sessionState == nil {
log.Warnf("sessionStateToRenderOptionsInstanceState: sessionState is nil")
return
}
sessionState.Instances.Range(func(key, value any) bool {
if value != nil {
instances = append(instances, instanceStateToRenderOptionsRequestState(value.(sessions.InstanceState).Instance))
} else {
log.Warnf("sessionStateToRenderOptionsInstanceState: sessionState instance is nil, key: %v", key)
}
return true
})
sort.SliceStable(instances, func(i, j int) bool {
return strings.Compare(instances[i].Name, instances[j].Name) == -1
})
return
}
func instanceStateToRenderOptionsRequestState(instanceState *instance.State) theme.Instance {
var err error
if instanceState.Message == "" {
err = nil
} else {
err = fmt.Errorf(instanceState.Message)
}
return theme.Instance{
Name: instanceState.Name,
Status: instanceState.Status,
CurrentReplicas: instanceState.CurrentReplicas,
DesiredReplicas: instanceState.DesiredReplicas,
Error: err,
}
}

View File

@@ -1,285 +0,0 @@
package routes
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/http/routes/models"
"github.com/sablierapp/sablier/app/instance"
"github.com/sablierapp/sablier/app/sessions"
"github.com/sablierapp/sablier/app/theme"
"github.com/sablierapp/sablier/config"
"gotest.tools/v3/assert"
)
type SessionsManagerMock struct {
SessionState sessions.SessionState
sessions.Manager
}
func (s *SessionsManagerMock) RequestSession(names []string, duration time.Duration) *sessions.SessionState {
return &s.SessionState
}
func (s *SessionsManagerMock) RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout time.Duration) (*sessions.SessionState, error) {
return &s.SessionState, nil
}
func (s *SessionsManagerMock) LoadSessions(io.ReadCloser) error {
return nil
}
func (s *SessionsManagerMock) SaveSessions(io.WriteCloser) error {
return nil
}
func (s *SessionsManagerMock) Stop() {}
func TestServeStrategy_ServeDynamic(t *testing.T) {
type arg struct {
body models.DynamicRequest
session sessions.SessionState
}
tests := []struct {
name string
arg arg
expectedHeaderKey string
expectedHeaderValue string
}{
{
name: "header has not ready value when not ready",
arg: arg{
body: models.DynamicRequest{
Names: []string{"nginx"},
DisplayName: "Test",
Theme: "hacker-terminal",
SessionDuration: 1 * time.Minute,
},
session: sessions.SessionState{
Instances: createMap([]*instance.State{
{Name: "nginx", Status: instance.NotReady},
}),
},
},
expectedHeaderKey: "X-Sablier-Session-Status",
expectedHeaderValue: "not-ready",
},
{
name: "header requests no caching",
arg: arg{
body: models.DynamicRequest{
Names: []string{"nginx"},
DisplayName: "Test",
Theme: "hacker-terminal",
SessionDuration: 1 * time.Minute,
},
session: sessions.SessionState{
Instances: createMap([]*instance.State{
{Name: "nginx", Status: instance.NotReady},
}),
},
},
expectedHeaderKey: "Cache-Control",
expectedHeaderValue: "no-cache",
},
{
name: "header has ready value when session is ready",
arg: arg{
body: models.DynamicRequest{
Names: []string{"nginx"},
DisplayName: "Test",
Theme: "hacker-terminal",
SessionDuration: 1 * time.Minute,
},
session: sessions.SessionState{
Instances: createMap([]*instance.State{
{Name: "nginx", Status: instance.Ready},
}),
},
},
expectedHeaderKey: "X-Sablier-Session-Status",
expectedHeaderValue: "ready",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
theme, err := theme.NewWithCustomThemes(fstest.MapFS{})
if err != nil {
panic(err)
}
s := &ServeStrategy{
SessionsManager: &SessionsManagerMock{
SessionState: tt.arg.session,
},
StrategyConfig: config.NewStrategyConfig(),
Theme: theme,
}
recorder := httptest.NewRecorder()
c := GetTestGinContext(recorder)
MockJsonPost(c, tt.arg.body)
s.ServeDynamic(c)
res := recorder.Result()
defer res.Body.Close()
assert.Equal(t, c.Writer.Header().Get(tt.expectedHeaderKey), tt.expectedHeaderValue)
})
}
}
func TestServeStrategy_ServeBlocking(t *testing.T) {
type arg struct {
body models.BlockingRequest
session sessions.SessionState
}
tests := []struct {
name string
arg arg
expectedBody string
expectedHeaderKey string
expectedHeaderValue string
}{
{
name: "not ready returns session status not ready",
arg: arg{
body: models.BlockingRequest{
Names: []string{"nginx"},
Timeout: 10 * time.Second,
SessionDuration: 1 * time.Minute,
},
session: sessions.SessionState{
Instances: createMap([]*instance.State{
{Name: "nginx", Status: instance.NotReady, CurrentReplicas: 0, DesiredReplicas: 1},
}),
},
},
expectedBody: `{"session":{"instances":[{"instance":{"name":"nginx","currentReplicas":0,"desiredReplicas":1,"status":"not-ready"},"error":null}],"status":"not-ready"}}`,
expectedHeaderKey: "X-Sablier-Session-Status",
expectedHeaderValue: "not-ready",
},
{
name: "ready returns session status ready",
arg: arg{
body: models.BlockingRequest{
Names: []string{"nginx"},
SessionDuration: 1 * time.Minute,
},
session: sessions.SessionState{
Instances: createMap([]*instance.State{
{Name: "nginx", Status: instance.Ready, CurrentReplicas: 1, DesiredReplicas: 1},
}),
},
},
expectedBody: `{"session":{"instances":[{"instance":{"name":"nginx","currentReplicas":1,"desiredReplicas":1,"status":"ready"},"error":null}],"status":"ready"}}`,
expectedHeaderKey: "X-Sablier-Session-Status",
expectedHeaderValue: "ready",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &ServeStrategy{
SessionsManager: &SessionsManagerMock{
SessionState: tt.arg.session,
},
StrategyConfig: config.NewStrategyConfig(),
}
recorder := httptest.NewRecorder()
c := GetTestGinContext(recorder)
MockJsonPost(c, tt.arg.body)
s.ServeBlocking(c)
res := recorder.Result()
defer res.Body.Close()
bytes, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
assert.Equal(t, c.Writer.Header().Get(tt.expectedHeaderKey), tt.expectedHeaderValue)
assert.Equal(t, string(bytes), tt.expectedBody)
})
}
}
// mock gin context
func GetTestGinContext(w *httptest.ResponseRecorder) *gin.Context {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(w)
ctx.Request = &http.Request{
Header: make(http.Header),
URL: &url.URL{},
}
return ctx
}
// mock getrequest
func MockJsonGet(c *gin.Context, params gin.Params, u url.Values) {
c.Request.Method = "GET"
c.Request.Header.Set("Content-Type", "application/json")
c.Params = params
c.Request.URL.RawQuery = u.Encode()
}
func MockJsonPost(c *gin.Context, content interface{}) {
c.Request.Method = "POST"
c.Request.Header.Set("Content-Type", "application/json")
jsonbytes, err := json.Marshal(content)
if err != nil {
panic(err)
}
// the request body must be an io.ReadCloser
// the bytes buffer though doesn't implement io.Closer,
// so you wrap it in a no-op closer
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonbytes))
}
func MockJsonPut(c *gin.Context, content interface{}, params gin.Params) {
c.Request.Method = "PUT"
c.Request.Header.Set("Content-Type", "application/json")
c.Params = params
jsonbytes, err := json.Marshal(content)
if err != nil {
panic(err)
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonbytes))
}
func MockJsonDelete(c *gin.Context, params gin.Params) {
c.Request.Method = "DELETE"
c.Request.Header.Set("Content-Type", "application/json")
c.Params = params
}
func createMap(instances []*instance.State) (store *sync.Map) {
store = &sync.Map{}
for _, v := range instances {
store.Store(v.Name, sessions.InstanceState{
Instance: v,
Error: nil,
})
}
return
}

View File

@@ -1,13 +0,0 @@
package routes
import (
"net/http"
"github.com/gin-gonic/gin"
)
func (s *ServeStrategy) ServeDynamicThemes(c *gin.Context) {
c.JSON(http.StatusOK, map[string]interface{}{
"themes": s.Theme.List(),
})
}

View File

@@ -1,83 +0,0 @@
package http
import (
"context"
"fmt"
"net/http"
"os/signal"
"syscall"
"time"
log "github.com/sirupsen/logrus"
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/http/middleware"
"github.com/sablierapp/sablier/app/http/routes"
"github.com/sablierapp/sablier/app/sessions"
"github.com/sablierapp/sablier/app/theme"
"github.com/sablierapp/sablier/config"
)
func Start(serverConf config.Server, strategyConf config.Strategy, sessionsConf config.Sessions, sessionManager sessions.Manager, t *theme.Themes) {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
r := gin.New()
r.Use(middleware.Logger(log.New()), gin.Recovery())
base := r.Group(serverConf.BasePath)
{
api := base.Group("/api")
{
strategy := routes.NewServeStrategy(sessionManager, strategyConf, sessionsConf, t)
api.GET("/strategies/dynamic", strategy.ServeDynamic)
api.GET("/strategies/dynamic/themes", strategy.ServeDynamicThemes)
api.GET("/strategies/blocking", strategy.ServeBlocking)
}
health := routes.Health{}
health.SetDefaults()
health.WithContext(ctx)
base.GET("/health", health.ServeHTTP)
}
srv := &http.Server{
Addr: fmt.Sprintf(":%d", serverConf.Port),
Handler: r,
}
// Initializing the server in a goroutine so that
// it won't block the graceful shutdown handling below
go func() {
log.Info("server listening ", srv.Addr)
logRoutes(r.Routes())
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
// Listen for the interrupt signal.
<-ctx.Done()
// Restore default behavior on the interrupt signal and notify user of shutdown.
stop()
log.Info("shutting down gracefully, press Ctrl+C again to force")
// The context is used to inform the server it has 10 seconds to finish
// the request it is currently handling
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("server forced to shutdown: ", err)
}
log.Info("server exiting")
}
func logRoutes(routes gin.RoutesInfo) {
for _, route := range routes {
log.Debug(fmt.Sprintf("%s %s %s", route.Method, route.Path, route.Handler))
}
}

View File

@@ -4,24 +4,26 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/sablierapp/sablier/app/discovery" "github.com/sablierapp/sablier/app/discovery"
"github.com/sablierapp/sablier/app/http/routes"
"github.com/sablierapp/sablier/app/providers/docker" "github.com/sablierapp/sablier/app/providers/docker"
"github.com/sablierapp/sablier/app/providers/dockerswarm" "github.com/sablierapp/sablier/app/providers/dockerswarm"
"github.com/sablierapp/sablier/app/providers/kubernetes" "github.com/sablierapp/sablier/app/providers/kubernetes"
"log/slog"
"os" "os"
"github.com/sablierapp/sablier/app/http"
"github.com/sablierapp/sablier/app/instance" "github.com/sablierapp/sablier/app/instance"
"github.com/sablierapp/sablier/app/providers" "github.com/sablierapp/sablier/app/providers"
"github.com/sablierapp/sablier/app/sessions" "github.com/sablierapp/sablier/app/sessions"
"github.com/sablierapp/sablier/app/storage" "github.com/sablierapp/sablier/app/storage"
"github.com/sablierapp/sablier/app/theme" "github.com/sablierapp/sablier/app/theme"
"github.com/sablierapp/sablier/config" "github.com/sablierapp/sablier/config"
"github.com/sablierapp/sablier/internal/server"
"github.com/sablierapp/sablier/pkg/tinykv" "github.com/sablierapp/sablier/pkg/tinykv"
"github.com/sablierapp/sablier/version" "github.com/sablierapp/sablier/version"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
func Start(conf config.Config) error { func Start(ctx context.Context, conf config.Config) error {
logLevel, err := log.ParseLevel(conf.Logging.Level) logLevel, err := log.ParseLevel(conf.Logging.Level)
@@ -30,6 +32,8 @@ func Start(conf config.Config) error {
logLevel = log.InfoLevel logLevel = log.InfoLevel
} }
logger := slog.Default()
log.SetLevel(logLevel) log.SetLevel(logLevel)
log.Info(version.Info()) log.Info(version.Info())
@@ -80,7 +84,14 @@ func Start(conf config.Config) error {
} }
} }
http.Start(conf.Server, conf.Strategy, conf.Sessions, sessionsManager, t) strategy := &routes.ServeStrategy{
Theme: t,
SessionsManager: sessionsManager,
StrategyConfig: conf.Strategy,
SessionsConfig: conf.Sessions,
}
server.Start(ctx, logger, conf.Server, strategy)
return nil return nil
} }

31
app/sessions/errors.go Normal file
View File

@@ -0,0 +1,31 @@
package sessions
import (
"fmt"
"time"
)
type ErrGroupNotFound struct {
Group string
AvailableGroups []string
}
func (g ErrGroupNotFound) Error() string {
return fmt.Sprintf("group %s not found", g.Group)
}
type ErrRequestBinding struct {
Err error
}
func (e ErrRequestBinding) Error() string {
return e.Err.Error()
}
type ErrTimeout struct {
Duration time.Duration
}
func (e ErrTimeout) Error() string {
return fmt.Sprintf("timeout after %s", e.Duration)
}

View File

@@ -6,6 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"slices"
"sync" "sync"
"time" "time"
@@ -17,9 +19,11 @@ import (
const defaultRefreshFrequency = 2 * time.Second const defaultRefreshFrequency = 2 * time.Second
//go:generate mockgen -package sessionstest -source=sessions_manager.go -destination=sessionstest/mocks_sessions_manager.go *
type Manager interface { type Manager interface {
RequestSession(names []string, duration time.Duration) *SessionState RequestSession(names []string, duration time.Duration) (*SessionState, error)
RequestSessionGroup(group string, duration time.Duration) *SessionState RequestSessionGroup(group string, duration time.Duration) (*SessionState, error)
RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout time.Duration) (*SessionState, error) RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout time.Duration) (*SessionState, error)
RequestReadySessionGroup(ctx context.Context, group string, duration time.Duration, timeout time.Duration) (*SessionState, error) RequestReadySessionGroup(ctx context.Context, group string, duration time.Duration, timeout time.Duration) (*SessionState, error)
@@ -112,6 +116,10 @@ type SessionState struct {
func (s *SessionState) IsReady() bool { func (s *SessionState) IsReady() bool {
ready := true ready := true
if s.Instances == nil {
s.Instances = &sync.Map{}
}
s.Instances.Range(func(key, value interface{}) bool { s.Instances.Range(func(key, value interface{}) bool {
state := value.(InstanceState) state := value.(InstanceState)
if state.Error != nil || state.Instance.Status != instance.Ready { if state.Error != nil || state.Instance.Status != instance.Ready {
@@ -132,10 +140,9 @@ func (s *SessionState) Status() string {
return "not-ready" return "not-ready"
} }
func (s *SessionsManager) RequestSession(names []string, duration time.Duration) (sessionState *SessionState) { func (s *SessionsManager) RequestSession(names []string, duration time.Duration) (sessionState *SessionState, err error) {
if len(names) == 0 { if len(names) == 0 {
return nil return nil, fmt.Errorf("names cannot be empty")
} }
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -160,19 +167,24 @@ func (s *SessionsManager) RequestSession(names []string, duration time.Duration)
wg.Wait() wg.Wait()
return sessionState return sessionState, nil
} }
func (s *SessionsManager) RequestSessionGroup(group string, duration time.Duration) (sessionState *SessionState) { func (s *SessionsManager) RequestSessionGroup(group string, duration time.Duration) (sessionState *SessionState, err error) {
if len(group) == 0 { if len(group) == 0 {
return nil return nil, fmt.Errorf("group is mandatory")
} }
names := s.groups[group] names, ok := s.groups[group]
if !ok {
return nil, ErrGroupNotFound{
Group: group,
AvailableGroups: slices.Collect(maps.Keys(s.groups)),
}
}
if len(names) == 0 { if len(names) == 0 {
return nil return nil, fmt.Errorf("group has no member")
} }
return s.RequestSession(names, duration) return s.RequestSession(names, duration)
@@ -227,8 +239,11 @@ func (s *SessionsManager) requestSessionInstance(name string, duration time.Dura
} }
func (s *SessionsManager) RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout time.Duration) (*SessionState, error) { func (s *SessionsManager) RequestReadySession(ctx context.Context, names []string, duration time.Duration, timeout time.Duration) (*SessionState, error) {
session, err := s.RequestSession(names, duration)
if err != nil {
return nil, err
}
session := s.RequestSession(names, duration)
if session.IsReady() { if session.IsReady() {
return session, nil return session, nil
} }
@@ -241,7 +256,10 @@ func (s *SessionsManager) RequestReadySession(ctx context.Context, names []strin
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
session := s.RequestSession(names, duration) session, err := s.RequestSession(names, duration)
if err != nil {
return
}
if session.IsReady() { if session.IsReady() {
readiness <- session readiness <- session
} }
@@ -272,7 +290,13 @@ func (s *SessionsManager) RequestReadySessionGroup(ctx context.Context, group st
return nil, fmt.Errorf("group is mandatory") return nil, fmt.Errorf("group is mandatory")
} }
names := s.groups[group] names, ok := s.groups[group]
if !ok {
return nil, ErrGroupNotFound{
Group: group,
AvailableGroups: slices.Collect(maps.Keys(s.groups)),
}
}
if len(names) == 0 { if len(names) == 0 {
return nil, fmt.Errorf("group has no member") return nil, fmt.Errorf("group has no member")

View File

@@ -0,0 +1,144 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: sessions_manager.go
//
// Generated by this command:
//
// mockgen -package sessionstest -source=sessions_manager.go -destination=sessionstest/mocks_sessions_manager.go *
//
// Package sessionstest is a generated GoMock package.
package sessionstest
import (
context "context"
io "io"
reflect "reflect"
time "time"
sessions "github.com/sablierapp/sablier/app/sessions"
gomock "go.uber.org/mock/gomock"
)
// MockManager is a mock of Manager interface.
type MockManager struct {
ctrl *gomock.Controller
recorder *MockManagerMockRecorder
isgomock struct{}
}
// MockManagerMockRecorder is the mock recorder for MockManager.
type MockManagerMockRecorder struct {
mock *MockManager
}
// NewMockManager creates a new mock instance.
func NewMockManager(ctrl *gomock.Controller) *MockManager {
mock := &MockManager{ctrl: ctrl}
mock.recorder = &MockManagerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockManager) EXPECT() *MockManagerMockRecorder {
return m.recorder
}
// LoadSessions mocks base method.
func (m *MockManager) LoadSessions(arg0 io.ReadCloser) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "LoadSessions", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// LoadSessions indicates an expected call of LoadSessions.
func (mr *MockManagerMockRecorder) LoadSessions(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadSessions", reflect.TypeOf((*MockManager)(nil).LoadSessions), arg0)
}
// RequestReadySession mocks base method.
func (m *MockManager) RequestReadySession(ctx context.Context, names []string, duration, timeout time.Duration) (*sessions.SessionState, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RequestReadySession", ctx, names, duration, timeout)
ret0, _ := ret[0].(*sessions.SessionState)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// RequestReadySession indicates an expected call of RequestReadySession.
func (mr *MockManagerMockRecorder) RequestReadySession(ctx, names, duration, timeout any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestReadySession", reflect.TypeOf((*MockManager)(nil).RequestReadySession), ctx, names, duration, timeout)
}
// RequestReadySessionGroup mocks base method.
func (m *MockManager) RequestReadySessionGroup(ctx context.Context, group string, duration, timeout time.Duration) (*sessions.SessionState, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RequestReadySessionGroup", ctx, group, duration, timeout)
ret0, _ := ret[0].(*sessions.SessionState)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// RequestReadySessionGroup indicates an expected call of RequestReadySessionGroup.
func (mr *MockManagerMockRecorder) RequestReadySessionGroup(ctx, group, duration, timeout any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestReadySessionGroup", reflect.TypeOf((*MockManager)(nil).RequestReadySessionGroup), ctx, group, duration, timeout)
}
// RequestSession mocks base method.
func (m *MockManager) RequestSession(names []string, duration time.Duration) (*sessions.SessionState, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RequestSession", names, duration)
ret0, _ := ret[0].(*sessions.SessionState)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// RequestSession indicates an expected call of RequestSession.
func (mr *MockManagerMockRecorder) RequestSession(names, duration any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestSession", reflect.TypeOf((*MockManager)(nil).RequestSession), names, duration)
}
// RequestSessionGroup mocks base method.
func (m *MockManager) RequestSessionGroup(group string, duration time.Duration) (*sessions.SessionState, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "RequestSessionGroup", group, duration)
ret0, _ := ret[0].(*sessions.SessionState)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// RequestSessionGroup indicates an expected call of RequestSessionGroup.
func (mr *MockManagerMockRecorder) RequestSessionGroup(group, duration any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestSessionGroup", reflect.TypeOf((*MockManager)(nil).RequestSessionGroup), group, duration)
}
// SaveSessions mocks base method.
func (m *MockManager) SaveSessions(arg0 io.WriteCloser) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SaveSessions", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// SaveSessions indicates an expected call of SaveSessions.
func (mr *MockManagerMockRecorder) SaveSessions(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveSessions", reflect.TypeOf((*MockManager)(nil).SaveSessions), arg0)
}
// Stop mocks base method.
func (m *MockManager) Stop() {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Stop")
}
// Stop indicates an expected call of Stop.
func (mr *MockManagerMockRecorder) Stop() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockManager)(nil).Stop))
}

14
app/theme/errors.go Normal file
View File

@@ -0,0 +1,14 @@
package theme
import (
"fmt"
)
type ErrThemeNotFound struct {
Theme string
AvailableThemes []string
}
func (t ErrThemeNotFound) Error() string {
return fmt.Sprintf("theme %s not found", t.Theme)
}

View File

@@ -27,7 +27,10 @@ func (t *Themes) Render(name string, opts Options, writer io.Writer) error {
tpl := t.themes.Lookup(fmt.Sprintf("%s.html", name)) tpl := t.themes.Lookup(fmt.Sprintf("%s.html", name))
if tpl == nil { if tpl == nil {
return fmt.Errorf("theme %s does not exist", name) return ErrThemeNotFound{
Theme: name,
AvailableThemes: t.List(),
}
} }
return tpl.Execute(writer, options) return tpl.Execute(writer, options)

View File

@@ -13,7 +13,7 @@ var newStartCommand = func() *cobra.Command {
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
viper.Unmarshal(&conf) viper.Unmarshal(&conf)
err := app.Start(conf) err := app.Start(cmd.Context(), conf)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@@ -1,6 +1,8 @@
package config package config
import log "github.com/sirupsen/logrus" import (
"log/slog"
)
type Logging struct { type Logging struct {
Level string `mapstructure:"LEVEL" yaml:"level" default:"info"` Level string `mapstructure:"LEVEL" yaml:"level" default:"info"`
@@ -8,6 +10,6 @@ type Logging struct {
func NewLoggingConfig() Logging { func NewLoggingConfig() Logging {
return Logging{ return Logging{
Level: log.InfoLevel.String(), Level: slog.LevelInfo.String(),
} }
} }

18
go.mod
View File

@@ -27,7 +27,7 @@ require (
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ajg/form v1.5.1 // indirect github.com/ajg/form v1.5.1 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect github.com/andybalholm/brotli v1.0.4 // indirect
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.9 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
@@ -42,7 +42,7 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
@@ -51,9 +51,9 @@ require (
github.com/go-openapi/swag v0.22.4 // indirect github.com/go-openapi/swag v0.22.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.8 // indirect github.com/google/gnostic-models v0.6.8 // indirect
@@ -69,7 +69,7 @@ require (
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.2 // indirect github.com/klauspost/compress v1.17.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
@@ -89,6 +89,7 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/samber/slog-gin v1.14.1 // indirect
github.com/sanity-io/litter v1.5.5 // indirect github.com/sanity-io/litter v1.5.5 // indirect
github.com/sergi/go-diff v1.0.0 // indirect github.com/sergi/go-diff v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
@@ -96,6 +97,7 @@ require (
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/tniswong/go.rfcx v0.0.0-20181019234604-07783c52761f // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
@@ -108,11 +110,11 @@ require (
github.com/yudai/gojsondiff v1.0.0 // indirect github.com/yudai/gojsondiff v1.0.0 // indirect
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.27.0 // indirect go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/sdk v1.27.0 // indirect go.opentelemetry.io/otel/sdk v1.27.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect

20
go.sum
View File

@@ -10,6 +10,8 @@ github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
@@ -51,6 +53,8 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
@@ -75,12 +79,16 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -124,6 +132,8 @@ github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQs
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -186,6 +196,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/samber/slog-gin v1.14.1 h1:6DMAcy2gBFyyztrpYIvAcXZH1sA/j75iSSXuqhirLtg=
github.com/samber/slog-gin v1.14.1/go.mod h1:yS2C+cX5tRnPX0MqDby7a3tRFsJuMk7hNwAunyfDxQk=
github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo= github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo=
github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
@@ -223,6 +235,8 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8= github.com/tailscale/depaware v0.0.0-20210622194025-720c4b409502/go.mod h1:p9lPsd+cx33L3H9nNoecRRxPssFKUwwI50I3pZ0yT+8=
github.com/tniswong/go.rfcx v0.0.0-20181019234604-07783c52761f h1:C43EMGXFtvYf/zunHR6ivZV7Z6ytg73t0GXwYyicXMQ=
github.com/tniswong/go.rfcx v0.0.0-20181019234604-07783c52761f/go.mod h1:N+sR0vLSCTtI6o06PMWsjMB4TVqqDttKNq4iC9wvxVY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -255,16 +269,22 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY=
go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI=
go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A=
go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94=
go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=

View File

@@ -364,6 +364,7 @@ golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
@@ -375,6 +376,7 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
@@ -392,6 +394,7 @@ golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
@@ -399,6 +402,7 @@ golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg= golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=

18
internal/api/abort.go Normal file
View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/tniswong/go.rfcx/rfc7807"
"net/url"
)
func AbortWithProblemDetail(c *gin.Context, p rfc7807.Problem) {
_ = c.Error(p)
instance, err := url.Parse(c.Request.RequestURI)
if err != nil {
instance = &url.URL{}
}
p.Instance = *instance
c.Header("Content-Type", rfc7807.JSONMediaType)
c.IndentedJSON(p.Status, p)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/sessions"
)
const SablierStatusHeader = "X-Sablier-Session-Status"
const SablierStatusReady = "ready"
const SablierStatusNotReady = "not-ready"
func AddSablierHeader(c *gin.Context, session *sessions.SessionState) {
if session.IsReady() {
c.Header(SablierStatusHeader, SablierStatusReady)
} else {
c.Header(SablierStatusHeader, SablierStatusNotReady)
}
}

43
internal/api/api_test.go Normal file
View File

@@ -0,0 +1,43 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/http/routes"
"github.com/sablierapp/sablier/app/sessions/sessionstest"
"github.com/sablierapp/sablier/app/theme"
"github.com/sablierapp/sablier/config"
"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"
"net/http"
"net/http/httptest"
"testing"
)
func NewApiTest(t *testing.T) (app *gin.Engine, router *gin.RouterGroup, strategy *routes.ServeStrategy, mock *sessionstest.MockManager) {
t.Helper()
gin.SetMode(gin.TestMode)
ctrl := gomock.NewController(t)
th, err := theme.New()
assert.NilError(t, err)
app = gin.New()
router = app.Group("/api")
mock = sessionstest.NewMockManager(ctrl)
strategy = &routes.ServeStrategy{
Theme: th,
SessionsManager: mock,
StrategyConfig: config.NewStrategyConfig(),
SessionsConfig: config.NewSessionsConfig(),
}
return app, router, strategy, mock
}
// PerformRequest runs an API request with an empty request body.
func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}

View File

@@ -1,4 +1,4 @@
package routes package api
import ( import (
"context" "context"
@@ -31,3 +31,10 @@ func (h *Health) ServeHTTP(c *gin.Context) {
c.String(statusCode, http.StatusText(statusCode)) c.String(statusCode, http.StatusText(statusCode))
} }
func Healthcheck(router *gin.RouterGroup, ctx context.Context) {
health := Health{}
health.SetDefaults()
health.WithContext(ctx)
router.GET("/health", health.ServeHTTP)
}

View File

@@ -0,0 +1,52 @@
package api
import (
"github.com/sablierapp/sablier/app/sessions"
"github.com/sablierapp/sablier/app/theme"
"github.com/tniswong/go.rfcx/rfc7807"
"net/http"
)
func ProblemError(e error) rfc7807.Problem {
return rfc7807.Problem{
Type: "https://sablierapp.dev/#/errors?id=internal-error",
Title: http.StatusText(http.StatusInternalServerError),
Status: http.StatusInternalServerError,
Detail: e.Error(),
}
}
func ProblemValidation(e error) rfc7807.Problem {
return rfc7807.Problem{
Type: "https://sablierapp.dev/#/errors?id=validation-error",
Title: "Validation Failed",
Status: http.StatusBadRequest,
Detail: e.Error(),
}
}
func ProblemGroupNotFound(e sessions.ErrGroupNotFound) rfc7807.Problem {
pb := rfc7807.Problem{
Type: "https://sablierapp.dev/#/errors?id=group-not-found",
Title: "Group not found",
Status: http.StatusNotFound,
Detail: "The group you requested does not exist. It is possible that the group has not been scanned yet.",
}
_ = pb.Extend("availableGroups", e.AvailableGroups)
_ = pb.Extend("requestGroup", e.Group)
_ = pb.Extend("error", e.Error())
return pb
}
func ProblemThemeNotFound(e theme.ErrThemeNotFound) rfc7807.Problem {
pb := rfc7807.Problem{
Type: "https://sablierapp.dev/#/errors?id=theme-not-found",
Title: "Theme not found",
Status: http.StatusNotFound,
Detail: "The theme you requested does not exist among the default themes and the custom themes (if any).",
}
_ = pb.Extend("availableTheme", e.AvailableThemes)
_ = pb.Extend("requestTheme", e.Theme)
_ = pb.Extend("error", e.Error())
return pb
}

View File

@@ -0,0 +1,59 @@
package api
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/http/routes"
"github.com/sablierapp/sablier/app/http/routes/models"
"github.com/sablierapp/sablier/app/sessions"
"net/http"
)
func StartBlocking(router *gin.RouterGroup, s *routes.ServeStrategy) {
router.GET("/strategies/blocking", func(c *gin.Context) {
request := models.BlockingRequest{
Timeout: s.StrategyConfig.Blocking.DefaultTimeout,
}
if err := c.ShouldBind(&request); err != nil {
AbortWithProblemDetail(c, ProblemValidation(err))
return
}
if len(request.Names) == 0 && request.Group == "" {
AbortWithProblemDetail(c, ProblemValidation(errors.New("'names' or 'group' query parameter must be set")))
return
}
if len(request.Names) > 0 && request.Group != "" {
AbortWithProblemDetail(c, ProblemValidation(errors.New("'names' and 'group' query parameters are both set, only one must be set")))
return
}
var sessionState *sessions.SessionState
var err error
if len(request.Names) > 0 {
sessionState, err = s.SessionsManager.RequestReadySession(c.Request.Context(), request.Names, request.SessionDuration, request.Timeout)
} else {
sessionState, err = s.SessionsManager.RequestReadySessionGroup(c.Request.Context(), request.Group, request.SessionDuration, request.Timeout)
var groupNotFoundError sessions.ErrGroupNotFound
if errors.As(err, &groupNotFoundError) {
AbortWithProblemDetail(c, ProblemGroupNotFound(groupNotFoundError))
return
}
}
if err != nil {
AbortWithProblemDetail(c, ProblemError(err))
return
}
if sessionState == nil {
AbortWithProblemDetail(c, ProblemError(errors.New("session could not be created, please check logs for more details")))
return
}
AddSablierHeader(c, sessionState)
c.JSON(http.StatusOK, map[string]interface{}{"session": sessionState})
})
}

View File

@@ -0,0 +1,78 @@
package api
import (
"errors"
"github.com/sablierapp/sablier/app/sessions"
"github.com/tniswong/go.rfcx/rfc7807"
"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"
"net/http"
"testing"
)
func TestStartBlocking(t *testing.T) {
t.Run("StartBlockingInvalidBind", func(t *testing.T) {
app, router, strategy, _ := NewApiTest(t)
StartBlocking(router, strategy)
r := PerformRequest(app, "GET", "/api/strategies/blocking?timeout=invalid")
assert.Equal(t, http.StatusBadRequest, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartBlockingWithoutNamesOrGroup", func(t *testing.T) {
app, router, strategy, _ := NewApiTest(t)
StartBlocking(router, strategy)
r := PerformRequest(app, "GET", "/api/strategies/blocking")
assert.Equal(t, http.StatusBadRequest, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartBlockingWithNamesAndGroup", func(t *testing.T) {
app, router, strategy, _ := NewApiTest(t)
StartBlocking(router, strategy)
r := PerformRequest(app, "GET", "/api/strategies/blocking?names=test&group=test")
assert.Equal(t, http.StatusBadRequest, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartBlockingByNames", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartBlocking(router, strategy)
m.EXPECT().RequestReadySession(gomock.Any(), []string{"test"}, gomock.Any(), gomock.Any()).Return(&sessions.SessionState{}, nil)
r := PerformRequest(app, "GET", "/api/strategies/blocking?names=test")
assert.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, SablierStatusReady, r.Header().Get(SablierStatusHeader))
})
t.Run("StartBlockingByGroup", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartBlocking(router, strategy)
m.EXPECT().RequestReadySessionGroup(gomock.Any(), "test", gomock.Any(), gomock.Any()).Return(&sessions.SessionState{}, nil)
r := PerformRequest(app, "GET", "/api/strategies/blocking?group=test")
assert.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, SablierStatusReady, r.Header().Get(SablierStatusHeader))
})
t.Run("StartBlockingErrGroupNotFound", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartBlocking(router, strategy)
m.EXPECT().RequestReadySessionGroup(gomock.Any(), "test", gomock.Any(), gomock.Any()).Return(nil, sessions.ErrGroupNotFound{
Group: "test",
AvailableGroups: []string{"test1", "test2"},
})
r := PerformRequest(app, "GET", "/api/strategies/blocking?group=test")
assert.Equal(t, http.StatusNotFound, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartBlockingError", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartBlocking(router, strategy)
m.EXPECT().RequestReadySessionGroup(gomock.Any(), "test", gomock.Any(), gomock.Any()).Return(nil, errors.New("unknown error"))
r := PerformRequest(app, "GET", "/api/strategies/blocking?group=test")
assert.Equal(t, http.StatusInternalServerError, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartBlockingSessionNil", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartBlocking(router, strategy)
m.EXPECT().RequestReadySessionGroup(gomock.Any(), "test", gomock.Any(), gomock.Any()).Return(nil, nil)
r := PerformRequest(app, "GET", "/api/strategies/blocking?group=test")
assert.Equal(t, http.StatusInternalServerError, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
}

View File

@@ -0,0 +1,132 @@
package api
import (
"bufio"
"bytes"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/http/routes"
"github.com/sablierapp/sablier/app/http/routes/models"
"github.com/sablierapp/sablier/app/instance"
"github.com/sablierapp/sablier/app/sessions"
"github.com/sablierapp/sablier/app/theme"
log "github.com/sirupsen/logrus"
"sort"
"strconv"
"strings"
)
func StartDynamic(router *gin.RouterGroup, s *routes.ServeStrategy) {
router.GET("/strategies/dynamic", func(c *gin.Context) {
request := models.DynamicRequest{
Theme: s.StrategyConfig.Dynamic.DefaultTheme,
ShowDetails: s.StrategyConfig.Dynamic.ShowDetailsByDefault,
RefreshFrequency: s.StrategyConfig.Dynamic.DefaultRefreshFrequency,
SessionDuration: s.SessionsConfig.DefaultDuration,
}
if err := c.ShouldBind(&request); err != nil {
AbortWithProblemDetail(c, ProblemValidation(err))
return
}
if len(request.Names) == 0 && request.Group == "" {
AbortWithProblemDetail(c, ProblemValidation(errors.New("'names' or 'group' query parameter must be set")))
return
}
if len(request.Names) > 0 && request.Group != "" {
AbortWithProblemDetail(c, ProblemValidation(errors.New("'names' and 'group' query parameters are both set, only one must be set")))
return
}
var sessionState *sessions.SessionState
var err error
if len(request.Names) > 0 {
sessionState, err = s.SessionsManager.RequestSession(request.Names, request.SessionDuration)
} else {
sessionState, err = s.SessionsManager.RequestSessionGroup(request.Group, request.SessionDuration)
var groupNotFoundError sessions.ErrGroupNotFound
if errors.As(err, &groupNotFoundError) {
AbortWithProblemDetail(c, ProblemGroupNotFound(groupNotFoundError))
return
}
}
if err != nil {
AbortWithProblemDetail(c, ProblemError(err))
return
}
if sessionState == nil {
AbortWithProblemDetail(c, ProblemError(errors.New("session could not be created, please check logs for more details")))
return
}
AddSablierHeader(c, sessionState)
renderOptions := theme.Options{
DisplayName: request.DisplayName,
ShowDetails: request.ShowDetails,
SessionDuration: request.SessionDuration,
RefreshFrequency: request.RefreshFrequency,
InstanceStates: sessionStateToRenderOptionsInstanceState(sessionState),
}
buf := new(bytes.Buffer)
writer := bufio.NewWriter(buf)
err = s.Theme.Render(request.Theme, renderOptions, writer)
var themeNotFound theme.ErrThemeNotFound
if errors.As(err, &themeNotFound) {
AbortWithProblemDetail(c, ProblemThemeNotFound(themeNotFound))
return
}
writer.Flush()
c.Header("Cache-Control", "no-cache")
c.Header("Content-Type", "text/html")
c.Header("Content-Length", strconv.Itoa(buf.Len()))
c.Writer.Write(buf.Bytes())
})
}
func sessionStateToRenderOptionsInstanceState(sessionState *sessions.SessionState) (instances []theme.Instance) {
if sessionState == nil {
log.Warnf("sessionStateToRenderOptionsInstanceState: sessionState is nil")
return
}
sessionState.Instances.Range(func(key, value any) bool {
if value != nil {
instances = append(instances, instanceStateToRenderOptionsRequestState(value.(sessions.InstanceState).Instance))
} else {
log.Warnf("sessionStateToRenderOptionsInstanceState: sessionState instance is nil, key: %v", key)
}
return true
})
sort.SliceStable(instances, func(i, j int) bool {
return strings.Compare(instances[i].Name, instances[j].Name) == -1
})
return
}
func instanceStateToRenderOptionsRequestState(instanceState *instance.State) theme.Instance {
var err error
if instanceState.Message == "" {
err = nil
} else {
err = fmt.Errorf(instanceState.Message)
}
return theme.Instance{
Name: instanceState.Name,
Status: instanceState.Status,
CurrentReplicas: instanceState.CurrentReplicas,
DesiredReplicas: instanceState.DesiredReplicas,
Error: err,
}
}

View File

@@ -0,0 +1,78 @@
package api
import (
"errors"
"github.com/sablierapp/sablier/app/sessions"
"github.com/tniswong/go.rfcx/rfc7807"
"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"
"net/http"
"testing"
)
func TestStartDynamic(t *testing.T) {
t.Run("StartDynamicInvalidBind", func(t *testing.T) {
app, router, strategy, _ := NewApiTest(t)
StartDynamic(router, strategy)
r := PerformRequest(app, "GET", "/api/strategies/dynamic?timeout=invalid")
assert.Equal(t, http.StatusBadRequest, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartDynamicWithoutNamesOrGroup", func(t *testing.T) {
app, router, strategy, _ := NewApiTest(t)
StartDynamic(router, strategy)
r := PerformRequest(app, "GET", "/api/strategies/dynamic")
assert.Equal(t, http.StatusBadRequest, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartDynamicWithNamesAndGroup", func(t *testing.T) {
app, router, strategy, _ := NewApiTest(t)
StartDynamic(router, strategy)
r := PerformRequest(app, "GET", "/api/strategies/dynamic?names=test&group=test")
assert.Equal(t, http.StatusBadRequest, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartDynamicByNames", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartDynamic(router, strategy)
m.EXPECT().RequestSession([]string{"test"}, gomock.Any()).Return(&sessions.SessionState{}, nil)
r := PerformRequest(app, "GET", "/api/strategies/dynamic?names=test")
assert.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, SablierStatusReady, r.Header().Get(SablierStatusHeader))
})
t.Run("StartDynamicByGroup", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartDynamic(router, strategy)
m.EXPECT().RequestSessionGroup("test", gomock.Any()).Return(&sessions.SessionState{}, nil)
r := PerformRequest(app, "GET", "/api/strategies/dynamic?group=test")
assert.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, SablierStatusReady, r.Header().Get(SablierStatusHeader))
})
t.Run("StartDynamicErrGroupNotFound", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartDynamic(router, strategy)
m.EXPECT().RequestSessionGroup("test", gomock.Any()).Return(nil, sessions.ErrGroupNotFound{
Group: "test",
AvailableGroups: []string{"test1", "test2"},
})
r := PerformRequest(app, "GET", "/api/strategies/dynamic?group=test")
assert.Equal(t, http.StatusNotFound, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartDynamicError", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartDynamic(router, strategy)
m.EXPECT().RequestSessionGroup("test", gomock.Any()).Return(nil, errors.New("unknown error"))
r := PerformRequest(app, "GET", "/api/strategies/dynamic?group=test")
assert.Equal(t, http.StatusInternalServerError, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
t.Run("StartDynamicSessionNil", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartDynamic(router, strategy)
m.EXPECT().RequestSessionGroup("test", gomock.Any()).Return(nil, nil)
r := PerformRequest(app, "GET", "/api/strategies/dynamic?group=test")
assert.Equal(t, http.StatusInternalServerError, r.Code)
assert.Equal(t, rfc7807.JSONMediaType, r.Header().Get("Content-Type"))
})
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/http/routes"
"net/http"
)
func ListThemes(router *gin.RouterGroup, s *routes.ServeStrategy) {
handler := func(c *gin.Context) {
c.JSON(http.StatusOK, map[string]interface{}{
"themes": s.Theme.List(),
})
}
router.GET("/themes", handler)
router.GET("/dynamic/themes", handler) // Legacy path
}

View File

@@ -0,0 +1,47 @@
package server
import (
"github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin"
"log/slog"
)
// StructuredLogger logs a gin HTTP request in JSON format. Allows to set the
// logger for testing purposes.
func StructuredLogger(logger *slog.Logger) gin.HandlerFunc {
if logger.Enabled(nil, slog.LevelDebug) {
return sloggin.NewWithConfig(logger, sloggin.Config{
DefaultLevel: slog.LevelInfo,
ClientErrorLevel: slog.LevelWarn,
ServerErrorLevel: slog.LevelError,
WithUserAgent: false,
WithRequestID: true,
WithRequestBody: false,
WithRequestHeader: false,
WithResponseBody: false,
WithResponseHeader: false,
WithSpanID: false,
WithTraceID: false,
Filters: []sloggin.Filter{},
})
}
return sloggin.NewWithConfig(logger, sloggin.Config{
DefaultLevel: slog.LevelInfo,
ClientErrorLevel: slog.LevelWarn,
ServerErrorLevel: slog.LevelError,
WithUserAgent: false,
WithRequestID: true,
WithRequestBody: false,
WithRequestHeader: false,
WithResponseBody: false,
WithResponseHeader: false,
WithSpanID: false,
WithTraceID: false,
Filters: []sloggin.Filter{},
})
}

26
internal/server/routes.go Normal file
View File

@@ -0,0 +1,26 @@
package server
import (
"context"
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/http/routes"
"github.com/sablierapp/sablier/config"
"github.com/sablierapp/sablier/internal/api"
)
func registerRoutes(ctx context.Context, router *gin.Engine, serverConf config.Server, s *routes.ServeStrategy) {
// Enables automatic redirection if the current route cannot be matched but a
// handler for the path with (without) the trailing slash exists.
router.RedirectTrailingSlash = true
base := router.Group(serverConf.BasePath)
api.Healthcheck(base, ctx)
// Create REST API router group.
APIv1 := base.Group("/api")
api.StartDynamic(APIv1, s)
api.StartBlocking(APIv1, s)
api.ListThemes(APIv1, s)
}

67
internal/server/server.go Normal file
View File

@@ -0,0 +1,67 @@
package server
import (
"context"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/sablierapp/sablier/app/http/routes"
"github.com/sablierapp/sablier/config"
"log/slog"
"net/http"
"time"
)
func setupRouter(ctx context.Context, logger *slog.Logger, serverConf config.Server, s *routes.ServeStrategy) *gin.Engine {
r := gin.New()
r.Use(StructuredLogger(logger))
r.Use(gin.Recovery())
registerRoutes(ctx, r, serverConf, s)
return r
}
func Start(ctx context.Context, logger *slog.Logger, serverConf config.Server, s *routes.ServeStrategy) {
start := time.Now()
if logger.Enabled(ctx, slog.LevelDebug) {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
r := setupRouter(ctx, logger, serverConf, s)
var server *http.Server
server = &http.Server{
Addr: fmt.Sprintf(":%d", serverConf.Port),
Handler: r,
}
logger.Info("starting ",
slog.String("listen", server.Addr),
slog.Duration("startup", time.Since(start)))
go StartHttp(server, logger)
// Graceful web server shutdown.
<-ctx.Done()
logger.Info("server: shutting down")
err := server.Close()
if err != nil {
logger.Error("server: shutdown failed", slog.Any("error", err))
}
}
// StartHttp starts the Web server in http mode.
func StartHttp(s *http.Server, logger *slog.Logger) {
if err := s.ListenAndServe(); err != nil {
if errors.Is(err, http.ErrServerClosed) {
logger.Info("server: shutdown complete")
} else {
logger.Error("server failed to start", slog.Any("error", err))
}
}
}