feat: set up database connection & add some helper packages (e.g. health, chislog) (#1)
Reviewed-on: twhelp/corev3#1
This commit is contained in:
parent
03e0fde213
commit
e85191d770
|
@ -0,0 +1,52 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appModeProduction = "production"
|
||||||
|
appModeDevelopment = "development"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
appFlagMode = &cli.GenericFlag{
|
||||||
|
Name: "mode",
|
||||||
|
Value: &EnumValue{
|
||||||
|
Enum: []string{appModeDevelopment, appModeProduction},
|
||||||
|
Default: appModeDevelopment,
|
||||||
|
},
|
||||||
|
Usage: fmt.Sprintf("%s or %s", appModeProduction, appModeDevelopment),
|
||||||
|
EnvVars: []string{"APP_MODE"},
|
||||||
|
}
|
||||||
|
appFlags = []cli.Flag{
|
||||||
|
appFlagMode,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type appWrapper struct {
|
||||||
|
*cli.App
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newApp(name, version string) *appWrapper {
|
||||||
|
app := &appWrapper{App: cli.NewApp(), logger: slog.Default()}
|
||||||
|
app.Name = name
|
||||||
|
app.HelpName = name
|
||||||
|
app.Version = version
|
||||||
|
app.Commands = []*cli.Command{cmdDB}
|
||||||
|
app.Flags = appFlags
|
||||||
|
app.Before = app.handleBefore
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *appWrapper) handleBefore(c *cli.Context) error {
|
||||||
|
c.Context = loggerToCtx(c.Context, a.logger)
|
||||||
|
|
||||||
|
a.logger.Debug("executing command", slog.Any("args", c.Args().Slice()))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,114 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
"github.com/uptrace/bun/dialect/pgdialect"
|
||||||
|
"github.com/uptrace/bun/driver/pgdriver"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
dbFlagConnectionString = &cli.StringFlag{
|
||||||
|
Name: "db.connectionString",
|
||||||
|
Required: true,
|
||||||
|
EnvVars: []string{"DB_CONNECTION_STRING"},
|
||||||
|
Usage: "https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING",
|
||||||
|
}
|
||||||
|
dbFlagMaxIdleConns = &cli.IntFlag{
|
||||||
|
Name: "db.maxIdleConns",
|
||||||
|
Value: 2,
|
||||||
|
EnvVars: []string{"DB_MAX_IDLE_CONNS"},
|
||||||
|
Usage: "https://pkg.go.dev/database/sql#DB.SetMaxIdleConns",
|
||||||
|
}
|
||||||
|
dbFlagMaxOpenConns = &cli.IntFlag{
|
||||||
|
Name: "db.maxOpenConns",
|
||||||
|
Value: runtime.NumCPU() * 4,
|
||||||
|
EnvVars: []string{"DB_MAX_OPEN_CONNS"},
|
||||||
|
Usage: "https://pkg.go.dev/database/sql#DB.SetMaxOpenConns",
|
||||||
|
}
|
||||||
|
dbFlagConnMaxLifetime = &cli.DurationFlag{
|
||||||
|
Name: "db.connMaxLifetime",
|
||||||
|
Value: 30 * time.Minute,
|
||||||
|
EnvVars: []string{"DB_CONN_MAX_LIFETIME"},
|
||||||
|
Usage: "https://pkg.go.dev/database/sql#DB.SetConnMaxLifetime",
|
||||||
|
}
|
||||||
|
dbFlagReadTimeout = &cli.DurationFlag{
|
||||||
|
Name: "db.readTimeout",
|
||||||
|
Value: 10 * time.Second,
|
||||||
|
EnvVars: []string{"DB_READ_TIMEOUT"},
|
||||||
|
}
|
||||||
|
dbFlagWriteTimeout = &cli.DurationFlag{
|
||||||
|
Name: "db.writeTimeout",
|
||||||
|
Value: 5 * time.Second,
|
||||||
|
EnvVars: []string{"DB_WRITE_TIMEOUT"},
|
||||||
|
}
|
||||||
|
dbFlags = []cli.Flag{
|
||||||
|
dbFlagConnectionString,
|
||||||
|
dbFlagMaxIdleConns,
|
||||||
|
dbFlagMaxOpenConns,
|
||||||
|
dbFlagConnMaxLifetime,
|
||||||
|
dbFlagReadTimeout,
|
||||||
|
dbFlagWriteTimeout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBunDBFromFlags(c *cli.Context) (*bun.DB, error) {
|
||||||
|
return newBunDB(bundDBConfig{
|
||||||
|
connectionString: c.String(dbFlagConnectionString.Name),
|
||||||
|
maxOpenConns: c.Int(dbFlagMaxOpenConns.Name),
|
||||||
|
maxIdleConns: c.Int(dbFlagMaxIdleConns.Name),
|
||||||
|
connMaxLifetime: c.Duration(dbFlagConnMaxLifetime.Name),
|
||||||
|
writeTimeout: c.Duration(dbFlagWriteTimeout.Name),
|
||||||
|
readTimeout: c.Duration(dbFlagReadTimeout.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type bundDBConfig struct {
|
||||||
|
connectionString string
|
||||||
|
maxOpenConns int
|
||||||
|
maxIdleConns int
|
||||||
|
connMaxLifetime time.Duration
|
||||||
|
readTimeout time.Duration
|
||||||
|
writeTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBunDB(cfg bundDBConfig) (*bun.DB, error) {
|
||||||
|
db := bun.NewDB(newSQLDB(cfg), pgdialect.New())
|
||||||
|
|
||||||
|
if err := pingDB(db); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSQLDB(cfg bundDBConfig) *sql.DB {
|
||||||
|
db := sql.OpenDB(pgdriver.NewConnector(
|
||||||
|
pgdriver.WithDSN(cfg.connectionString),
|
||||||
|
pgdriver.WithReadTimeout(cfg.readTimeout),
|
||||||
|
pgdriver.WithWriteTimeout(cfg.writeTimeout),
|
||||||
|
))
|
||||||
|
db.SetMaxOpenConns(cfg.maxOpenConns)
|
||||||
|
db.SetMaxIdleConns(cfg.maxIdleConns)
|
||||||
|
db.SetConnMaxLifetime(cfg.connMaxLifetime)
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPingTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
func pingDB(db *bun.DB) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), dbPingTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
return fmt.Errorf("couldn't ping db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,196 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.dwysokinski.me/twhelp/corev3/internal/migrations"
|
||||||
|
"github.com/uptrace/bun/migrate"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const migrationGoTemplate = `package %s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
migrations.MustRegister(func(ctx context.Context, db *bun.DB) error {
|
||||||
|
fmt.Print(" [up migration] ")
|
||||||
|
return nil
|
||||||
|
}, func(ctx context.Context, db *bun.DB) error {
|
||||||
|
fmt.Print(" [down migration] ")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var cmdDB = &cli.Command{
|
||||||
|
Name: "db",
|
||||||
|
Usage: "Manages database migrations",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "migrate",
|
||||||
|
Usage: "Migrate database",
|
||||||
|
Flags: dbFlags,
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
logger := loggerFromCtx(c.Context)
|
||||||
|
|
||||||
|
bunDB, err := newBunDBFromFlags(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
logger.Debug("closing db connections...")
|
||||||
|
if dbCloseErr := bunDB.Close(); dbCloseErr != nil {
|
||||||
|
logger.Warn("couldn't close db connections", slog.Any("error", dbCloseErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
migrator := migrations.NewMigrator(bunDB)
|
||||||
|
|
||||||
|
if err = migrator.Init(c.Context); err != nil {
|
||||||
|
return fmt.Errorf("couldn't init migrator: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = migrator.Lock(c.Context); err != nil {
|
||||||
|
return fmt.Errorf("couldn't lock db: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
logger.Debug("unlocking db...")
|
||||||
|
if unlockErr := migrator.Unlock(c.Context); unlockErr != nil {
|
||||||
|
logger.Warn("couldn't unlock db", slog.Any("error", unlockErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
group, err := migrator.Migrate(c.Context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("migration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if group.ID == 0 {
|
||||||
|
logger.Info("there are no new migrations to run")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(
|
||||||
|
"migrations have been successfully applied",
|
||||||
|
slog.Int64("group.id", group.ID),
|
||||||
|
slog.String("group.migrations", group.Migrations.String()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "rollback",
|
||||||
|
Usage: "Rollback the last migration group",
|
||||||
|
Flags: dbFlags,
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
logger := loggerFromCtx(c.Context)
|
||||||
|
|
||||||
|
bunDB, err := newBunDBFromFlags(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
logger.Debug("closing db connections...")
|
||||||
|
if dbCloseErr := bunDB.Close(); dbCloseErr != nil {
|
||||||
|
logger.Warn("couldn't close db connections", slog.Any("error", dbCloseErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
migrator := migrations.NewMigrator(bunDB)
|
||||||
|
|
||||||
|
if err = migrator.Lock(c.Context); err != nil {
|
||||||
|
return fmt.Errorf("couldn't lock db: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
logger.Debug("unlocking db...")
|
||||||
|
if unlockErr := migrator.Unlock(c.Context); unlockErr != nil {
|
||||||
|
logger.Warn("couldn't unlock db", slog.Any("error", unlockErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
group, err := migrator.Rollback(c.Context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't rollback last migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if group.ID == 0 {
|
||||||
|
logger.Info("there are no groups to roll back")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(
|
||||||
|
"last migration group has been rolled back",
|
||||||
|
slog.Int64("group.id", group.ID),
|
||||||
|
slog.String("group.migrations", group.Migrations.String()),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "create",
|
||||||
|
Usage: "Create migration",
|
||||||
|
Subcommands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "go",
|
||||||
|
Usage: "Create Go migration",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
logger := loggerFromCtx(c.Context)
|
||||||
|
|
||||||
|
migrator := migrations.NewMigrator(nil)
|
||||||
|
|
||||||
|
mf, err := migrator.CreateGoMigration(
|
||||||
|
c.Context,
|
||||||
|
strings.Join(c.Args().Slice(), "_"),
|
||||||
|
migrate.WithGoTemplate(migrationGoTemplate),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't create Go migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info(
|
||||||
|
"Go migration has been created",
|
||||||
|
slog.String("migration.path", mf.Path),
|
||||||
|
slog.String("migration.name", mf.Name),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "sql",
|
||||||
|
Usage: "Create SQL migration",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
logger := loggerFromCtx(c.Context)
|
||||||
|
|
||||||
|
migrator := migrations.NewMigrator(nil)
|
||||||
|
|
||||||
|
files, err := migrator.CreateSQLMigrations(c.Context, strings.Join(c.Args().Slice(), "_"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("couldn't create sql migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mf := range files {
|
||||||
|
logger.Info(
|
||||||
|
"SQL migration has been created",
|
||||||
|
slog.String("migration.path", mf.Path),
|
||||||
|
slog.String("migration.name", mf.Name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnumValue struct {
|
||||||
|
Enum []string
|
||||||
|
Default string
|
||||||
|
selected string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EnumValue) Set(value string) error {
|
||||||
|
for _, enum := range e.Enum {
|
||||||
|
if enum == value {
|
||||||
|
e.selected = value
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("allowed values are %s", strings.Join(e.Enum, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EnumValue) String() string {
|
||||||
|
if e.selected == "" {
|
||||||
|
return e.Default
|
||||||
|
}
|
||||||
|
return e.selected
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type loggerCtxKey struct{}
|
||||||
|
|
||||||
|
func loggerToCtx(ctx context.Context, l *slog.Logger) context.Context {
|
||||||
|
return context.WithValue(ctx, loggerCtxKey{}, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loggerFromCtx(ctx context.Context) *slog.Logger {
|
||||||
|
logger, _ := ctx.Value(loggerCtxKey{}).(*slog.Logger)
|
||||||
|
return logger
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const appName = "wendia"
|
||||||
|
|
||||||
|
// this flag will be set by the build flags
|
||||||
|
var version = "development"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app := newApp(appName, version)
|
||||||
|
if err := app.Run(os.Args); err != nil {
|
||||||
|
app.logger.Error("app run failed", slog.Any("error", err))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
17
go.mod
17
go.mod
|
@ -4,11 +4,28 @@ go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/elliotchance/phpserialize v1.3.3
|
github.com/elliotchance/phpserialize v1.3.3
|
||||||
|
github.com/go-chi/chi/v5 v5.0.10
|
||||||
|
github.com/google/go-cmp v0.6.0
|
||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.8.4
|
||||||
|
github.com/uptrace/bun v1.1.16
|
||||||
|
github.com/uptrace/bun/dialect/pgdialect v1.1.16
|
||||||
|
github.com/uptrace/bun/driver/pgdriver v1.1.16
|
||||||
|
github.com/urfave/cli/v2 v2.26.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
|
golang.org/x/crypto v0.13.0 // indirect
|
||||||
|
golang.org/x/sys v0.12.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
mellium.im/sasl v0.3.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
44
go.sum
44
go.sum
|
@ -1,12 +1,54 @@
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/elliotchance/phpserialize v1.3.3 h1:hV4QVmGdCiYgoBbw+ADt6fNgyZ2mYX0OgpnON1adTCM=
|
github.com/elliotchance/phpserialize v1.3.3 h1:hV4QVmGdCiYgoBbw+ADt6fNgyZ2mYX0OgpnON1adTCM=
|
||||||
github.com/elliotchance/phpserialize v1.3.3/go.mod h1:gt7XX9+ETUcLXbtTKEuyrqW3lcLUAeS/AnGZ2e49TZs=
|
github.com/elliotchance/phpserialize v1.3.3/go.mod h1:gt7XX9+ETUcLXbtTKEuyrqW3lcLUAeS/AnGZ2e49TZs=
|
||||||
|
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
||||||
|
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||||
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||||
|
github.com/uptrace/bun v1.1.16 h1:cn9cgEMFwcyYRsQLfxCRMUxyK1WaHwOVrR3TvzEFZ/A=
|
||||||
|
github.com/uptrace/bun v1.1.16/go.mod h1:7HnsMRRvpLFUcquJxp22JO8PsWKpFQO/gNXqqsuGWg8=
|
||||||
|
github.com/uptrace/bun/dialect/pgdialect v1.1.16 h1:eUPZ+YCJ69BA+W1X1ZmpOJSkv1oYtinr0zCXf7zCo5g=
|
||||||
|
github.com/uptrace/bun/dialect/pgdialect v1.1.16/go.mod h1:KQjfx/r6JM0OXfbv0rFrxAbdkPD7idK8VitnjIV9fZI=
|
||||||
|
github.com/uptrace/bun/driver/pgdriver v1.1.16 h1:b/NiSXk6Ldw7KLfMLbOqIkm4odHd7QiNOCPLqPFJjK4=
|
||||||
|
github.com/uptrace/bun/driver/pgdriver v1.1.16/go.mod h1:Rmfbc+7lx1z/umjMyAxkOHK81LgnGj71XC5YpA6k1vU=
|
||||||
|
github.com/urfave/cli/v2 v2.26.0 h1:3f3AMg3HpThFNT4I++TKOejZO8yU55t3JnnSr4S4QEI=
|
||||||
|
github.com/urfave/cli/v2 v2.26.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
|
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo=
|
||||||
|
mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw=
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
package chislog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Slogger interface {
|
||||||
|
Log(ctx context.Context, level slog.Level, msg string, args ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger returns a go-chi middleware that logs requests using the given Slogger.
|
||||||
|
func Logger(logger Slogger, opts ...Option) func(next http.Handler) http.Handler {
|
||||||
|
cfg := newConfig(opts...)
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
for _, f := range cfg.filters {
|
||||||
|
if !f(r) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
|
end := time.Now()
|
||||||
|
status := ww.Status()
|
||||||
|
|
||||||
|
logger.Log(
|
||||||
|
r.Context(),
|
||||||
|
statusLevel(status),
|
||||||
|
statusLabel(status),
|
||||||
|
slog.String("routePattern", chi.RouteContext(r.Context()).RoutePattern()),
|
||||||
|
slog.String("httpRequest.method", r.Method),
|
||||||
|
slog.String("httpRequest.uri", r.RequestURI),
|
||||||
|
slog.String("httpRequest.referer", r.Referer()),
|
||||||
|
slog.String("httpRequest.userAgent", r.UserAgent()),
|
||||||
|
slog.String("httpRequest.proto", r.Proto),
|
||||||
|
slog.String("httpRequest.ip", cfg.ipExtractor(r)),
|
||||||
|
slog.Int("httpResponse.status", status),
|
||||||
|
slog.Int("httpResponse.bytes", ww.BytesWritten()),
|
||||||
|
slog.Float64("httpResponse.duration", float64(end.Sub(start).Nanoseconds())/1000000.0), // in milliseconds
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLevel(status int) slog.Level {
|
||||||
|
switch {
|
||||||
|
case status < http.StatusContinue:
|
||||||
|
return slog.LevelWarn
|
||||||
|
case status >= http.StatusBadRequest && status < http.StatusInternalServerError:
|
||||||
|
return slog.LevelWarn
|
||||||
|
case status >= http.StatusInternalServerError:
|
||||||
|
return slog.LevelError
|
||||||
|
default: // for statuses 100 <= 400
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusLabel(status int) string {
|
||||||
|
switch {
|
||||||
|
case status >= http.StatusContinue && status < http.StatusMultipleChoices:
|
||||||
|
return "OK"
|
||||||
|
case status >= http.StatusMultipleChoices && status < http.StatusBadRequest:
|
||||||
|
return "Redirect"
|
||||||
|
case status >= http.StatusBadRequest && status < http.StatusInternalServerError:
|
||||||
|
return "Client Error"
|
||||||
|
case status >= http.StatusInternalServerError:
|
||||||
|
return "Server Error"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,161 @@
|
||||||
|
package chislog_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dwysokinski.me/twhelp/corev3/internal/chislog"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogger(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
register func(r chi.Router)
|
||||||
|
options []chislog.Option
|
||||||
|
req func(t *testing.T) *http.Request
|
||||||
|
assertEntry func(t *testing.T, entry map[string]any)
|
||||||
|
excluded bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "log level should be Info when status code >= 200 and < 400",
|
||||||
|
register: func(r chi.Router) {
|
||||||
|
r.Get("/info", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
req: func(t *testing.T) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewRequest(http.MethodGet, "/info?test=true", nil)
|
||||||
|
},
|
||||||
|
assertEntry: func(t *testing.T, entry map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, slog.LevelInfo.String(), entry[slog.LevelKey])
|
||||||
|
assert.Equal(t, "192.0.2.1", entry["httpRequest.ip"]) // default ip address set by httptest.NewRequest
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "log level should be Warn when status code >= 400 and < 500",
|
||||||
|
register: func(r chi.Router) {
|
||||||
|
r.Get("/warn", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
req: func(t *testing.T) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewRequest(http.MethodGet, "/warn?test=true", nil)
|
||||||
|
},
|
||||||
|
assertEntry: func(t *testing.T, entry map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, slog.LevelWarn.String(), entry[slog.LevelKey])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "log level should be Error when status code >= 500",
|
||||||
|
register: func(r chi.Router) {
|
||||||
|
r.Get("/error", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
req: func(t *testing.T) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewRequest(http.MethodGet, "/error?test=true", nil)
|
||||||
|
},
|
||||||
|
assertEntry: func(t *testing.T, entry map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, slog.LevelError.String(), entry[slog.LevelKey])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read IP from header X-Forwarded-For",
|
||||||
|
options: []chislog.Option{
|
||||||
|
chislog.WithIPExtractor(func(r *http.Request) string {
|
||||||
|
return r.Header.Get("X-Forwarded-For")
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
register: func(r chi.Router) {
|
||||||
|
r.Post("/x-forwarded-for", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
req: func(t *testing.T) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/x-forwarded-for", nil)
|
||||||
|
req.Header.Set("X-Forwarded-For", "111.111.111.111")
|
||||||
|
return req
|
||||||
|
},
|
||||||
|
assertEntry: func(t *testing.T, entry map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, slog.LevelInfo.String(), entry[slog.LevelKey])
|
||||||
|
assert.Equal(t, "111.111.111.111", entry["httpRequest.ip"])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "log entry should be skipped",
|
||||||
|
options: []chislog.Option{
|
||||||
|
chislog.WithFilter(func(r *http.Request) bool {
|
||||||
|
return !strings.HasPrefix(r.URL.Path, "/meta")
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
register: func(r chi.Router) {
|
||||||
|
r.Get("/meta/test", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
req: func(t *testing.T) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewRequest(http.MethodGet, "/meta/test", nil)
|
||||||
|
},
|
||||||
|
excluded: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
buf := bytes.NewBuffer(nil)
|
||||||
|
logger := slog.New(slog.NewJSONHandler(buf, nil))
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(chislog.Logger(logger, tt.options...))
|
||||||
|
tt.register(r)
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := tt.req(t)
|
||||||
|
|
||||||
|
r.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if tt.excluded {
|
||||||
|
assert.Zero(t, buf.Bytes())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry map[string]any
|
||||||
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &entry))
|
||||||
|
assert.NotEmpty(t, entry["time"])
|
||||||
|
assert.Equal(t, req.Method, entry["httpRequest.method"])
|
||||||
|
assert.Equal(t, req.RequestURI, entry["httpRequest.uri"])
|
||||||
|
assert.Equal(t, req.Referer(), entry["httpRequest.referer"])
|
||||||
|
assert.Equal(t, req.UserAgent(), entry["httpRequest.userAgent"])
|
||||||
|
assert.Equal(t, req.Proto, entry["httpRequest.proto"])
|
||||||
|
assert.Equal(t, float64(rr.Code), entry["httpResponse.status"])
|
||||||
|
assert.GreaterOrEqual(t, entry["httpResponse.bytes"], 0.0)
|
||||||
|
assert.GreaterOrEqual(t, entry["httpResponse.duration"], 0.0)
|
||||||
|
if tt.assertEntry != nil {
|
||||||
|
tt.assertEntry(t, entry)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
package chislog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Filter func(r *http.Request) bool
|
||||||
|
|
||||||
|
type IPExtractor func(r *http.Request) string
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
filters []Filter
|
||||||
|
ipExtractor IPExtractor
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(cfg *config)
|
||||||
|
|
||||||
|
func newConfig(opts ...Option) *config {
|
||||||
|
cfg := &config{
|
||||||
|
ipExtractor: defaultIPExtractor,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFilter adds a filter to the list of filters used by the middleware.
|
||||||
|
// If any filter indicates to exclude a request then the request will not be logged.
|
||||||
|
// All filters must allow a request to be logged.
|
||||||
|
// If no filters are provided, then all requests are logged.
|
||||||
|
func WithFilter(f Filter) Option {
|
||||||
|
return func(c *config) {
|
||||||
|
c.filters = append(c.filters, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithIPExtractor takes a function that will be called on every
|
||||||
|
// request and the returned ip will be added to the log entry.
|
||||||
|
//
|
||||||
|
// http.Request RemoteAddr is logged by default.
|
||||||
|
func WithIPExtractor(extractor IPExtractor) Option {
|
||||||
|
return func(c *config) {
|
||||||
|
c.ipExtractor = extractor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIPExtractor(r *http.Request) string {
|
||||||
|
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
return ip
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package health
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Checker interface {
|
||||||
|
Name() string
|
||||||
|
Check(ctx context.Context) error
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
package health
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
liveChecks []Checker
|
||||||
|
readyChecks []Checker
|
||||||
|
maxConcurrent int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(cfg *config)
|
||||||
|
|
||||||
|
const maxConcurrentDefault = 5
|
||||||
|
|
||||||
|
func newConfig(opts ...Option) *config {
|
||||||
|
cfg := &config{
|
||||||
|
maxConcurrent: maxConcurrentDefault,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithMaxConcurrent(max int) Option {
|
||||||
|
return func(cfg *config) {
|
||||||
|
cfg.maxConcurrent = max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithLiveCheck(check Checker) Option {
|
||||||
|
return func(cfg *config) {
|
||||||
|
cfg.liveChecks = append(cfg.liveChecks, check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithReadyCheck(check Checker) Option {
|
||||||
|
return func(cfg *config) {
|
||||||
|
cfg.readyChecks = append(cfg.readyChecks, check)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
package health
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Health struct {
|
||||||
|
liveChecks []Checker
|
||||||
|
readyChecks []Checker
|
||||||
|
maxConcurrent int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(opts ...Option) *Health {
|
||||||
|
cfg := newConfig(opts...)
|
||||||
|
return &Health{
|
||||||
|
liveChecks: cfg.liveChecks,
|
||||||
|
readyChecks: cfg.readyChecks,
|
||||||
|
maxConcurrent: cfg.maxConcurrent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPass Status = "pass"
|
||||||
|
StatusFail Status = "fail"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Status) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SingleCheckResult struct {
|
||||||
|
Status Status
|
||||||
|
Err error
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Status Status
|
||||||
|
Checks map[string][]SingleCheckResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Health) CheckLive(ctx context.Context) Result {
|
||||||
|
return h.check(ctx, h.liveChecks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Health) CheckReady(ctx context.Context) Result {
|
||||||
|
return h.check(ctx, h.readyChecks)
|
||||||
|
}
|
||||||
|
|
||||||
|
type singleCheckResultWithName struct {
|
||||||
|
Name string
|
||||||
|
SingleCheckResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Health) check(ctx context.Context, checks []Checker) Result {
|
||||||
|
limiterCh := make(chan struct{}, h.maxConcurrent)
|
||||||
|
checkCh := make(chan singleCheckResultWithName)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, ch := range checks {
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func(ch Checker) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
limiterCh <- struct{}{}
|
||||||
|
defer func() {
|
||||||
|
<-limiterCh
|
||||||
|
}()
|
||||||
|
|
||||||
|
check := SingleCheckResult{
|
||||||
|
Status: StatusPass,
|
||||||
|
Time: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ch.Check(ctx); err != nil {
|
||||||
|
check.Status = StatusFail
|
||||||
|
check.Err = err
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCh <- singleCheckResultWithName{
|
||||||
|
Name: ch.Name(),
|
||||||
|
SingleCheckResult: check,
|
||||||
|
}
|
||||||
|
}(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(limiterCh)
|
||||||
|
close(checkCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
res := Result{
|
||||||
|
Status: StatusPass,
|
||||||
|
Checks: make(map[string][]SingleCheckResult, len(checks)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for check := range checkCh {
|
||||||
|
if check.Status == StatusFail {
|
||||||
|
res.Status = StatusFail
|
||||||
|
}
|
||||||
|
|
||||||
|
res.Checks[check.Name] = append(res.Checks[check.Name], check.SingleCheckResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res
|
||||||
|
}
|
|
@ -0,0 +1,206 @@
|
||||||
|
package health_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dwysokinski.me/twhelp/corev3/internal/health"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errCheckFailed = errors.New("failed")
|
||||||
|
|
||||||
|
func TestHealth_CheckReady(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
options []health.Option
|
||||||
|
expectedResult health.Result
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "0 checks",
|
||||||
|
expectedResult: health.Result{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
Checks: make(map[string][]health.SingleCheckResult),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "3 checks passed",
|
||||||
|
options: []health.Option{
|
||||||
|
health.WithReadyCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithReadyCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithReadyCheck(checker{name: "test2", err: nil}),
|
||||||
|
health.WithMaxConcurrent(2),
|
||||||
|
},
|
||||||
|
expectedResult: health.Result{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
Checks: map[string][]health.SingleCheckResult{
|
||||||
|
"test": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test2": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "2 checks passed, 1 check failed",
|
||||||
|
options: []health.Option{
|
||||||
|
health.WithReadyCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithReadyCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithReadyCheck(checker{name: "test2", err: errCheckFailed}),
|
||||||
|
},
|
||||||
|
expectedResult: health.Result{
|
||||||
|
Status: health.StatusFail,
|
||||||
|
Checks: map[string][]health.SingleCheckResult{
|
||||||
|
"test": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test2": {
|
||||||
|
{
|
||||||
|
Status: health.StatusFail,
|
||||||
|
Err: errCheckFailed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := health.New(tt.options...).CheckReady(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, cmp.Diff(
|
||||||
|
tt.expectedResult,
|
||||||
|
result,
|
||||||
|
cmpopts.IgnoreTypes(time.Time{}),
|
||||||
|
cmpopts.EquateErrors(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealth_CheckLive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
options []health.Option
|
||||||
|
expectedResult health.Result
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "0 checks",
|
||||||
|
expectedResult: health.Result{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
Checks: make(map[string][]health.SingleCheckResult),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "3 checks passed",
|
||||||
|
options: []health.Option{
|
||||||
|
health.WithLiveCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithLiveCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithLiveCheck(checker{name: "test2", err: nil}),
|
||||||
|
health.WithMaxConcurrent(2),
|
||||||
|
},
|
||||||
|
expectedResult: health.Result{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
Checks: map[string][]health.SingleCheckResult{
|
||||||
|
"test": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test2": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "2 checks passed, 1 check failed",
|
||||||
|
options: []health.Option{
|
||||||
|
health.WithLiveCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithLiveCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithLiveCheck(checker{name: "test2", err: errCheckFailed}),
|
||||||
|
},
|
||||||
|
expectedResult: health.Result{
|
||||||
|
Status: health.StatusFail,
|
||||||
|
Checks: map[string][]health.SingleCheckResult{
|
||||||
|
"test": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Status: health.StatusPass,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test2": {
|
||||||
|
{
|
||||||
|
Status: health.StatusFail,
|
||||||
|
Err: errCheckFailed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
result := health.New(tt.options...).CheckLive(context.Background())
|
||||||
|
|
||||||
|
assert.Empty(t, cmp.Diff(
|
||||||
|
tt.expectedResult,
|
||||||
|
result,
|
||||||
|
cmpopts.IgnoreTypes(time.Time{}),
|
||||||
|
cmpopts.EquateErrors(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type checker struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c checker) Name() string {
|
||||||
|
return c.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c checker) Check(_ context.Context) error {
|
||||||
|
return c.err
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package healthhttp
|
||||||
|
|
||||||
|
type Response = response
|
||||||
|
|
||||||
|
type SingleCheckResult = singleCheckResult
|
|
@ -0,0 +1,79 @@
|
||||||
|
package healthhttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dwysokinski.me/twhelp/corev3/internal/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
check func(ctx context.Context) health.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ http.Handler = (*handler)(nil)
|
||||||
|
|
||||||
|
func LiveHandler(h *health.Health) http.Handler {
|
||||||
|
return &handler{
|
||||||
|
check: h.CheckLive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadyHandler(h *health.Health) http.Handler {
|
||||||
|
return &handler{
|
||||||
|
check: h.CheckReady,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type singleCheckResult struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
FailureReason string `json:"err,omitempty"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Checks map[string][]singleCheckResult `json:"checks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result := h.check(r.Context())
|
||||||
|
|
||||||
|
respChecks := make(map[string][]singleCheckResult, len(result.Checks))
|
||||||
|
for name, checks := range result.Checks {
|
||||||
|
respChecks[name] = make([]singleCheckResult, 0, len(checks))
|
||||||
|
|
||||||
|
for _, ch := range checks {
|
||||||
|
failureReason := ""
|
||||||
|
if ch.Err != nil {
|
||||||
|
failureReason = ch.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
respChecks[name] = append(respChecks[name], singleCheckResult{
|
||||||
|
Status: ch.Status.String(),
|
||||||
|
FailureReason: failureReason,
|
||||||
|
Time: ch.Time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(healthStatusToHTTPStatus(result.Status))
|
||||||
|
_ = json.NewEncoder(w).Encode(response{
|
||||||
|
Status: result.Status.String(),
|
||||||
|
Checks: respChecks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthStatusToHTTPStatus(status health.Status) int {
|
||||||
|
switch status {
|
||||||
|
case health.StatusPass:
|
||||||
|
return http.StatusOK
|
||||||
|
case health.StatusFail:
|
||||||
|
return http.StatusServiceUnavailable
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,209 @@
|
||||||
|
package healthhttp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.dwysokinski.me/twhelp/corev3/internal/health"
|
||||||
|
"gitea.dwysokinski.me/twhelp/corev3/internal/health/healthhttp"
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadyHandler(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
options []health.Option
|
||||||
|
expectedStatus int
|
||||||
|
expectedResponse healthhttp.Response
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "pass",
|
||||||
|
options: []health.Option{
|
||||||
|
health.WithReadyCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithReadyCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithReadyCheck(checker{name: "test2", err: nil}),
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedResponse: healthhttp.Response{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
Checks: map[string][]healthhttp.SingleCheckResult{
|
||||||
|
"test": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test2": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "2 checks passed, 1 check failed",
|
||||||
|
options: []health.Option{
|
||||||
|
health.WithReadyCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithReadyCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithReadyCheck(checker{name: "test2", err: errors.New("failed")}),
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusServiceUnavailable,
|
||||||
|
expectedResponse: healthhttp.Response{
|
||||||
|
Status: health.StatusFail.String(),
|
||||||
|
Checks: map[string][]healthhttp.SingleCheckResult{
|
||||||
|
"test": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test2": {
|
||||||
|
{
|
||||||
|
Status: health.StatusFail.String(),
|
||||||
|
FailureReason: "failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
healthhttp.ReadyHandler(health.New(tt.options...)).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedStatus, rr.Code)
|
||||||
|
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||||
|
var body healthhttp.Response
|
||||||
|
require.NoError(t, json.NewDecoder(rr.Body).Decode(&body))
|
||||||
|
assert.Empty(t, cmp.Diff(
|
||||||
|
tt.expectedResponse,
|
||||||
|
body,
|
||||||
|
cmpopts.IgnoreTypes(time.Time{}),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLiveHandler(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
options []health.Option
|
||||||
|
expectedStatus int
|
||||||
|
expectedResponse healthhttp.Response
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "pass",
|
||||||
|
options: []health.Option{
|
||||||
|
health.WithLiveCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithLiveCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithLiveCheck(checker{name: "test2", err: nil}),
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusOK,
|
||||||
|
expectedResponse: healthhttp.Response{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
Checks: map[string][]healthhttp.SingleCheckResult{
|
||||||
|
"test": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test2": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "2 checks passed, 1 check failed",
|
||||||
|
options: []health.Option{
|
||||||
|
health.WithLiveCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithLiveCheck(checker{name: "test", err: nil}),
|
||||||
|
health.WithLiveCheck(checker{name: "test2", err: errors.New("failed")}),
|
||||||
|
},
|
||||||
|
expectedStatus: http.StatusServiceUnavailable,
|
||||||
|
expectedResponse: healthhttp.Response{
|
||||||
|
Status: health.StatusFail.String(),
|
||||||
|
Checks: map[string][]healthhttp.SingleCheckResult{
|
||||||
|
"test": {
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Status: health.StatusPass.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"test2": {
|
||||||
|
{
|
||||||
|
Status: health.StatusFail.String(),
|
||||||
|
FailureReason: "failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
healthhttp.LiveHandler(health.New(tt.options...)).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/livez", nil))
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedStatus, rr.Code)
|
||||||
|
assert.Equal(t, "application/json", rr.Header().Get("Content-Type"))
|
||||||
|
var body healthhttp.Response
|
||||||
|
require.NoError(t, json.NewDecoder(rr.Body).Decode(&body))
|
||||||
|
assert.Empty(t, cmp.Diff(
|
||||||
|
tt.expectedResponse,
|
||||||
|
body,
|
||||||
|
cmpopts.IgnoreTypes(time.Time{}),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type checker struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c checker) Name() string {
|
||||||
|
return c.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c checker) Check(_ context.Context) error {
|
||||||
|
return c.err
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists versions CASCADE;
|
|
@ -0,0 +1,31 @@
|
||||||
|
create table if not exists versions
|
||||||
|
(
|
||||||
|
code varchar(6) not null
|
||||||
|
primary key,
|
||||||
|
name varchar(150) not null,
|
||||||
|
host varchar(150) not null
|
||||||
|
unique,
|
||||||
|
timezone varchar(150) not null
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into versions (code, name, host, timezone)
|
||||||
|
values ('pl', 'Poland', 'www.plemiona.pl', 'Europe/Warsaw'),
|
||||||
|
('uk', 'United Kingdom', 'www.tribalwars.co.uk', 'Europe/London'),
|
||||||
|
('hu', 'Hungary', 'www.klanhaboru.hu', 'Europe/Budapest'),
|
||||||
|
('it', 'Italy', 'www.tribals.it', 'Europe/Rome'),
|
||||||
|
('fr', 'France', 'www.guerretribale.fr', 'Europe/Paris'),
|
||||||
|
('us', 'United States', 'www.tribalwars.us', 'America/New_York'),
|
||||||
|
('nl', 'The Netherlands', 'www.tribalwars.nl', 'Europe/Amsterdam'),
|
||||||
|
('es', 'Spain', 'www.guerrastribales.es', 'Europe/Madrid'),
|
||||||
|
('ro', 'Romania', 'www.triburile.ro', 'Europe/Bucharest'),
|
||||||
|
('gr', 'Greece', 'www.fyletikesmaxes.gr', 'Europe/Athens'),
|
||||||
|
('br', 'Brazil', 'www.tribalwars.com.br', 'America/Sao_Paulo'),
|
||||||
|
('tr', 'Turkey', 'www.klanlar.org', 'Europe/Istanbul'),
|
||||||
|
('cs', 'Czech Republic', 'www.divokekmeny.cz', 'Europe/Prague'),
|
||||||
|
('ch', 'Switzerland', 'www.staemme.ch', 'Europe/Zurich'),
|
||||||
|
('pt', 'Portugal', 'www.tribalwars.com.pt', 'Europe/Lisbon'),
|
||||||
|
('en', 'International', 'www.tribalwars.net', 'Europe/London'),
|
||||||
|
('de', 'Germany', 'www.die-staemme.de', 'Europe/Berlin'),
|
||||||
|
('sk', 'Slovakia', 'www.divoke-kmene.sk', 'Europe/Bratislava'),
|
||||||
|
('ru', 'Russia', 'www.voynaplemyon.com', 'Europe/Moscow')
|
||||||
|
ON CONFLICT DO NOTHING;
|
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists servers cascade;
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists tribes cascade;
|
|
@ -0,0 +1,35 @@
|
||||||
|
create table if not exists tribes
|
||||||
|
(
|
||||||
|
id bigint not null,
|
||||||
|
server_key varchar(100) not null
|
||||||
|
references servers,
|
||||||
|
name varchar(255) not null,
|
||||||
|
tag varchar(10) not null,
|
||||||
|
num_members bigint default 0,
|
||||||
|
num_villages bigint default 0,
|
||||||
|
points bigint default 0,
|
||||||
|
all_points bigint default 0,
|
||||||
|
rank bigint default 0,
|
||||||
|
dominance double precision default 0,
|
||||||
|
created_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
deleted_at timestamp with time zone,
|
||||||
|
rank_att bigint default 0,
|
||||||
|
score_att bigint default 0,
|
||||||
|
rank_def bigint default 0,
|
||||||
|
score_def bigint default 0,
|
||||||
|
rank_sup bigint default 0,
|
||||||
|
score_sup bigint default 0,
|
||||||
|
rank_total bigint default 0,
|
||||||
|
score_total bigint default 0,
|
||||||
|
profile_url varchar(150),
|
||||||
|
best_rank bigint default 999999,
|
||||||
|
best_rank_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
most_points bigint default 0,
|
||||||
|
most_points_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
most_villages bigint default 0,
|
||||||
|
most_villages_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
primary key (id, server_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists tribes_server_key_idx
|
||||||
|
on tribes (server_key);
|
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists players cascade;
|
|
@ -0,0 +1,33 @@
|
||||||
|
create table if not exists players
|
||||||
|
(
|
||||||
|
id bigint not null,
|
||||||
|
server_key varchar(100) not null
|
||||||
|
references servers,
|
||||||
|
name varchar(150) not null,
|
||||||
|
num_villages bigint default 0,
|
||||||
|
points bigint default 0,
|
||||||
|
rank bigint default 0,
|
||||||
|
tribe_id bigint,
|
||||||
|
created_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
deleted_at timestamp with time zone,
|
||||||
|
rank_att bigint default 0,
|
||||||
|
score_att bigint default 0,
|
||||||
|
rank_def bigint default 0,
|
||||||
|
score_def bigint default 0,
|
||||||
|
rank_sup bigint default 0,
|
||||||
|
score_sup bigint default 0,
|
||||||
|
rank_total bigint default 0,
|
||||||
|
score_total bigint default 0,
|
||||||
|
profile_url varchar(150),
|
||||||
|
best_rank bigint default 999999,
|
||||||
|
best_rank_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
most_points bigint default 0,
|
||||||
|
most_points_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
most_villages bigint default 0,
|
||||||
|
most_villages_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
last_activity_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
primary key (id, server_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists players_server_key_idx
|
||||||
|
on players (server_key);
|
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists ennoblements cascade;
|
|
@ -0,0 +1,37 @@
|
||||||
|
create table if not exists ennoblements
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
server_key varchar(100) not null
|
||||||
|
references servers,
|
||||||
|
village_id bigint not null,
|
||||||
|
new_owner_id bigint,
|
||||||
|
new_tribe_id bigint,
|
||||||
|
old_owner_id bigint,
|
||||||
|
old_tribe_id bigint,
|
||||||
|
points bigint default 0,
|
||||||
|
created_at timestamp with time zone default CURRENT_TIMESTAMP not null
|
||||||
|
);
|
||||||
|
|
||||||
|
create unique index if not exists ennoblements_hash_key
|
||||||
|
on ennoblements (hash_record_extended(
|
||||||
|
ROW (server_key, village_id, new_owner_id, new_tribe_id, old_owner_id, old_tribe_id, points, created_at),
|
||||||
|
0::bigint));
|
||||||
|
|
||||||
|
create index if not exists ennoblements_server_key_created_at_idx
|
||||||
|
on ennoblements (server_key, created_at);
|
||||||
|
|
||||||
|
create index if not exists ennoblements_server_key_village_id_idx
|
||||||
|
on ennoblements (server_key, village_id);
|
||||||
|
|
||||||
|
create index if not exists ennoblements_server_key_new_owner_id_idx
|
||||||
|
on ennoblements (server_key, new_owner_id);
|
||||||
|
|
||||||
|
create index if not exists ennoblements_server_key_old_owner_id_idx
|
||||||
|
on ennoblements (server_key, old_owner_id);
|
||||||
|
|
||||||
|
create index if not exists ennoblements_server_key_new_tribe_id_idx
|
||||||
|
on ennoblements (server_key, new_tribe_id);
|
||||||
|
|
||||||
|
create index if not exists ennoblements_server_key_old_tribe_id_idx
|
||||||
|
on ennoblements (server_key, old_tribe_id);
|
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists player_snapshots cascade;
|
|
@ -0,0 +1,24 @@
|
||||||
|
create table if not exists player_snapshots
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
player_id bigint not null,
|
||||||
|
num_villages bigint default 0,
|
||||||
|
points bigint default 0,
|
||||||
|
rank bigint default 0,
|
||||||
|
tribe_id bigint,
|
||||||
|
server_key varchar(100) not null
|
||||||
|
references servers,
|
||||||
|
date date not null,
|
||||||
|
created_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
rank_att bigint default 0,
|
||||||
|
score_att bigint default 0,
|
||||||
|
rank_def bigint default 0,
|
||||||
|
score_def bigint default 0,
|
||||||
|
rank_sup bigint default 0,
|
||||||
|
score_sup bigint default 0,
|
||||||
|
rank_total bigint default 0,
|
||||||
|
score_total bigint default 0,
|
||||||
|
unique (player_id, server_key, date),
|
||||||
|
foreign key (player_id, server_key) references players
|
||||||
|
);
|
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists tribe_snapshots cascade;
|
|
@ -0,0 +1,26 @@
|
||||||
|
create table if not exists tribe_snapshots
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
tribe_id bigint not null,
|
||||||
|
server_key varchar(100) not null
|
||||||
|
references servers,
|
||||||
|
num_members bigint default 0,
|
||||||
|
num_villages bigint default 0,
|
||||||
|
points bigint default 0,
|
||||||
|
all_points bigint default 0,
|
||||||
|
rank bigint default 0,
|
||||||
|
dominance double precision default 0,
|
||||||
|
date date not null,
|
||||||
|
created_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
rank_att bigint default 0,
|
||||||
|
score_att bigint default 0,
|
||||||
|
rank_def bigint default 0,
|
||||||
|
score_def bigint default 0,
|
||||||
|
rank_sup bigint default 0,
|
||||||
|
score_sup bigint default 0,
|
||||||
|
rank_total bigint default 0,
|
||||||
|
score_total bigint default 0,
|
||||||
|
unique (tribe_id, server_key, date),
|
||||||
|
foreign key (tribe_id, server_key) references tribes
|
||||||
|
);
|
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists tribe_changes cascade;
|
|
@ -0,0 +1,26 @@
|
||||||
|
create table if not exists tribe_changes
|
||||||
|
(
|
||||||
|
id bigint generated by default as identity
|
||||||
|
primary key,
|
||||||
|
player_id bigint not null,
|
||||||
|
new_tribe_id bigint,
|
||||||
|
old_tribe_id bigint,
|
||||||
|
server_key varchar(100) not null
|
||||||
|
references servers,
|
||||||
|
created_at timestamp with time zone default CURRENT_TIMESTAMP not null,
|
||||||
|
foreign key (player_id, server_key) references players
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists tribe_changes_server_key_player_id_idx
|
||||||
|
on tribe_changes (server_key, player_id);
|
||||||
|
|
||||||
|
create unique index if not exists tribe_changes_hash_key
|
||||||
|
on tribe_changes (hash_record_extended(
|
||||||
|
ROW (player_id, new_tribe_id, old_tribe_id, server_key, date_trunc('hours'::text, (created_at AT TIME ZONE 'UTC'::text))),
|
||||||
|
0::bigint));
|
||||||
|
|
||||||
|
create index if not exists tribe_changes_server_key_new_tribe_id_idx
|
||||||
|
on tribe_changes (server_key, new_tribe_id);
|
||||||
|
|
||||||
|
create index if not exists tribe_changes_server_key_old_tribe_id_idx
|
||||||
|
on tribe_changes (server_key, old_tribe_id);
|
|
@ -0,0 +1,23 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
"github.com/uptrace/bun/migrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
var migrations = migrate.NewMigrations()
|
||||||
|
|
||||||
|
//go:embed *.sql
|
||||||
|
var sqlMigrations embed.FS
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if err := migrations.Discover(sqlMigrations); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMigrator(db *bun.DB, opts ...migrate.MigratorOption) *migrate.Migrator {
|
||||||
|
return migrate.NewMigrator(db, migrations, opts...)
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: twhelp-migrations-job
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: twhelp-migrations
|
||||||
|
image: twhelp
|
||||||
|
args: [db, migrate]
|
||||||
|
env:
|
||||||
|
- name: APP_MODE
|
||||||
|
value: development
|
||||||
|
- name: DB_CONNECTION_STRING
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: twhelp-secret
|
||||||
|
key: db-connection-string
|
||||||
|
- name: DB_MAX_OPEN_CONNS
|
||||||
|
value: "1"
|
||||||
|
- name: DB_MAX_IDLE_CONNS
|
||||||
|
value: "1"
|
||||||
|
- name: DB_READ_TIMEOUT
|
||||||
|
value: 60s
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 128Mi
|
||||||
|
restartPolicy: Never
|
|
@ -0,0 +1,8 @@
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- jobs.yml
|
||||||
|
images:
|
||||||
|
- name: twhelp
|
||||||
|
newName: twhelp
|
||||||
|
newTag: latest
|
|
@ -0,0 +1,6 @@
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
nameSuffix: -dev
|
||||||
|
resources:
|
||||||
|
- secret.yml
|
||||||
|
- ../../base
|
|
@ -0,0 +1,10 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: twhelp-secret
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
# postgres://twhelp:twhelp@twhelpdb-postgresql:5432/twhelp?sslmode=disable
|
||||||
|
db-connection-string: cG9zdGdyZXM6Ly90d2hlbHA6dHdoZWxwQHR3aGVscGRiLXBvc3RncmVzcWw6NTQzMi90d2hlbHA/c3NsbW9kZT1kaXNhYmxl
|
||||||
|
# amqp://twhelp:twhelp@twhelprmq-rabbitmq:5672/
|
||||||
|
rabbit-connection-string: YW1xcDovL3R3aGVscDp0d2hlbHBAdHdoZWxwcm1xLXJhYmJpdG1xOjU2NzIv
|
Loading…
Reference in New Issue