feat: set up database connection & add some helper packages (e.g. health, chislog) (#1)

Reviewed-on: twhelp/corev3#1
This commit is contained in:
Dawid Wysokiński 2023-12-16 07:12:20 +00:00
parent 03e0fde213
commit e85191d770
39 changed files with 1795 additions and 1 deletions

52
cmd/twhelp/app.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -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
View File

@ -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=

View 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"
}
}

View 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)
}
})
}
}

View 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
}

View 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
View 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
View 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
}

View 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
}

View File

@ -0,0 +1,5 @@
package healthhttp
type Response = response
type SingleCheckResult = singleCheckResult

View 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
}
}

View 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
}

View File

@ -0,0 +1 @@
drop table if exists versions CASCADE;

View File

@ -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;

View File

@ -0,0 +1 @@
drop table if exists servers cascade;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
drop table if exists tribes cascade;

View File

@ -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);

View File

@ -0,0 +1 @@
drop table if exists players cascade;

View File

@ -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);

View File

@ -0,0 +1 @@
drop table if exists ennoblements cascade;

View File

@ -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);

View File

@ -0,0 +1 @@
drop table if exists player_snapshots cascade;

View File

@ -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
);

View File

@ -0,0 +1 @@
drop table if exists tribe_snapshots cascade;

View File

@ -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
);

View File

@ -0,0 +1 @@
drop table if exists tribe_changes cascade;

View File

@ -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);

View 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
View 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

View File

@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- jobs.yml
images:
- name: twhelp
newName: twhelp
newTag: latest

View File

@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
nameSuffix: -dev
resources:
- secret.yml
- ../../base

View 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