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
52
cmd/twhelp/app.go
Normal file
52
cmd/twhelp/app.go
Normal file
|
@ -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
|
||||
}
|
114
cmd/twhelp/bun.go
Normal file
114
cmd/twhelp/bun.go
Normal file
|
@ -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
|
||||
}
|
196
cmd/twhelp/cmd_db.go
Normal file
196
cmd/twhelp/cmd_db.go
Normal file
|
@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
30
cmd/twhelp/enum_value.go
Normal file
30
cmd/twhelp/enum_value.go
Normal file
|
@ -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
|
||||
}
|
17
cmd/twhelp/logger.go
Normal file
17
cmd/twhelp/logger.go
Normal file
|
@ -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
|
||||
}
|
19
cmd/twhelp/main.go
Normal file
19
cmd/twhelp/main.go
Normal file
|
@ -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 (
|
||||
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/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 (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // 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/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
|
||||
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/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/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/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 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/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=
|
||||
|
|
82
internal/chislog/chislog.go
Normal file
82
internal/chislog/chislog.go
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
161
internal/chislog/chislog_test.go
Normal file
161
internal/chislog/chislog_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
54
internal/chislog/config.go
Normal file
54
internal/chislog/config.go
Normal file
|
@ -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
|
||||
}
|
8
internal/health/checker.go
Normal file
8
internal/health/checker.go
Normal file
|
@ -0,0 +1,8 @@
|
|||
package health
|
||||
|
||||
import "context"
|
||||
|
||||
type Checker interface {
|
||||
Name() string
|
||||
Check(ctx context.Context) error
|
||||
}
|
41
internal/health/config.go
Normal file
41
internal/health/config.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
112
internal/health/health.go
Normal file
112
internal/health/health.go
Normal file
|
@ -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
|
||||
}
|
206
internal/health/health_test.go
Normal file
206
internal/health/health_test.go
Normal file
|
@ -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
|
||||
}
|
5
internal/health/healthhttp/export.go
Normal file
5
internal/health/healthhttp/export.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package healthhttp
|
||||
|
||||
type Response = response
|
||||
|
||||
type SingleCheckResult = singleCheckResult
|
79
internal/health/healthhttp/handler.go
Normal file
79
internal/health/healthhttp/handler.go
Normal file
|
@ -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
|
||||
}
|
||||
}
|
209
internal/health/healthhttp/handler_test.go
Normal file
209
internal/health/healthhttp/handler_test.go
Normal file
|
@ -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);
|
23
internal/migrations/migrations.go
Normal file
23
internal/migrations/migrations.go
Normal file
|
@ -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...)
|
||||
}
|
34
k8s/base/jobs.yml
Normal file
34
k8s/base/jobs.yml
Normal file
|
@ -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
|
8
k8s/base/kustomization.yml
Normal file
8
k8s/base/kustomization.yml
Normal file
|
@ -0,0 +1,8 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- jobs.yml
|
||||
images:
|
||||
- name: twhelp
|
||||
newName: twhelp
|
||||
newTag: latest
|
6
k8s/overlays/dev/kustomization.yml
Normal file
6
k8s/overlays/dev/kustomization.yml
Normal file
|
@ -0,0 +1,6 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
nameSuffix: -dev
|
||||
resources:
|
||||
- secret.yml
|
||||
- ../../base
|
10
k8s/overlays/dev/secret.yml
Normal file
10
k8s/overlays/dev/secret.yml
Normal file
|
@ -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
Block a user