diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 0000000000000000000000000000000000000000..e011a6d13a2908a00a557b440bdc66f7731db1ab_Y21kL21pZ3JhdGUuZ28= --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + "github.com/rs/zerolog" + "orus.io/orus-io/go-orusapi/database" +) + +type MigrateCmd struct { + program *Program +} + +func (cmd *MigrateCmd) Execute([]string) error { + cmd.program.LoggingOptions.SetMinLoggingLevel(zerolog.InfoLevel) + log := cmd.program.Logger + m, err := database.NewMigrate(cmd.program.DatabaseOptions.DSN, cmd.program.dbMigrateSource) + if err != nil { + return fmt.Errorf("failed to init migration engine: %w", err) + } + defer func() { + if sourceErr, databaseErr := m.Close(); sourceErr != nil || databaseErr != nil { + if sourceErr != nil { + log.Err(err).Msg("error closing Migrate source") + } + if databaseErr != nil { + log.Err(err).Msg("error closing Migrate database") + } + } + }() + + if err := m.Up(); err != nil { + if errors.Is(err, migrate.ErrNoChange) { + log.Info().Msg("The database is already up-to-date") + } else { + return fmt.Errorf("failed to run migration: %w", err) + } + } else { + log.Info().Msg("database successfully upgraded") + } + + return nil +} + +func SetupMigrateCmd(program *Program) *MigrateCmd { + cmd := MigrateCmd{ + program: program, + } + + migrate, err := program.Parser.AddCommand( + "migrate", "Migrate the DB to the right version", "", &cmd) + if err != nil { + program.Logger.Fatal().Msg(err.Error()) + } + migrate.EnvNamespace = "MIGRATE" + + return &cmd +} diff --git a/cmd/program.go b/cmd/program.go new file mode 100644 index 0000000000000000000000000000000000000000..e011a6d13a2908a00a557b440bdc66f7731db1ab_Y21kL3Byb2dyYW0uZ28= --- /dev/null +++ b/cmd/program.go @@ -0,0 +1,195 @@ +package cmd + +import ( + "fmt" + "net/http" + "os" + "strings" + + "github.com/golang-migrate/migrate/v4/source" + "github.com/jessevdk/go-flags" + "github.com/jmoiron/sqlx" + "github.com/rs/zerolog" + + "orus.io/orus-io/go-orusapi" + "orus.io/orus-io/go-orusapi/database" +) + +type ConfigFile struct { + ConfigFile string `long:"config" short:"c" env:"CONFIG" no-ini:"t" description:"A configuration file"` +} + +type InfoOptions struct { + Environment string `long:"environment" env:"ENVIRONMENT" ini-name:"environment" default:"default" description:"A environment name, used in sentry and prometheus"` +} + +type Program struct { + BootstrapParser *flags.Parser + Parser *flags.Parser + + Logger zerolog.Logger + + ConfigFileOption ConfigFile + InfoOptions InfoOptions + LoggingOptions *orusapi.LoggingOptions + DatabaseOptions database.Options + + DB *sqlx.DB + + ServeCmd *ServeCmd + MigrateCmd *MigrateCmd + + hasDB bool + dbMigrateSource source.Driver + + setupHandler func(*Program) http.Handler +} + +type Option func(program *Program) + +func WithHandler(factory func(*Program) http.Handler) Option { + return func(program *Program) { + program.setupHandler = factory + } +} + +func WithDatabase(migrateSource source.Driver) Option { + return func(program *Program) { + program.hasDB = true + program.dbMigrateSource = migrateSource + } +} + +func NewProgram(name string, options ...Option) *Program { + bootstrapParser := flags.NewNamedParser(name, flags.IgnoreUnknown) + parser := flags.NewNamedParser(name, flags.HelpFlag|flags.PassDoubleDash) + + program := Program{ + BootstrapParser: bootstrapParser, + Parser: parser, + Logger: orusapi.DefaultLogger(os.Stdout), + } + + for _, opt := range options { + opt(&program) + } + + program.LoggingOptions = orusapi.MustLoggingOptions( + orusapi.NewLoggingOptions(&program.Logger, os.Stdout)) + + bootstrapParser.NamespaceDelimiter = "-" + bootstrapParser.EnvNamespaceDelimiter = "_" + bootstrapParser.EnvNamespace = strings.ToUpper(name) + + if _, err := bootstrapParser.AddGroup( + "Configuration", "Configuration file", &program.ConfigFileOption, + ); err != nil { + panic(err) + } + + if _, err := parser.AddGroup("Configuration", "Configuration file", &program.ConfigFileOption); err != nil { + panic(err) + } + if _, err := parser.AddGroup("Info", "Info options", &program.InfoOptions); err != nil { + panic(err) + } + + { + g, err := parser.AddGroup("Logging", "Logging options", program.LoggingOptions) + if err != nil { + panic(err) + } + g.Namespace = "log" + g.EnvNamespace = "LOG" + } + + program.ServeCmd = SetupServeCmd(&program) + + if program.hasDB { + g, err := parser.AddGroup("Database", "Database options", &program.DatabaseOptions) + if err != nil { + panic(err) + } + g.Namespace = "db" + g.EnvNamespace = "DB" + + program.MigrateCmd = SetupMigrateCmd(&program) + } + + return &program +} + +func (program *Program) ParseArgs(args []string) int { + if _, err := program.BootstrapParser.ParseArgs(args); err != nil { + program.Logger.Err(err).Msg("could not parse command line") + + return 1 + } + if program.ConfigFileOption.ConfigFile != "" { + program.Logger.Debug().Str("configfile", program.ConfigFileOption.ConfigFile).Msg("parsing configuration file") + iniParser := flags.NewIniParser(program.Parser) + if err := iniParser.ParseFile(program.ConfigFileOption.ConfigFile); err != nil { + program.Logger.Err(err).Msg("") + + return 1 + } + } + + if _, err := program.Parser.Parse(); err != nil { + code := 1 + if fe, ok := err.(*flags.Error); ok { + if fe.Type == flags.ErrHelp { + code = 0 + // this error actually contains a help message for the user + // so we print it on the console + fmt.Println(err) + } else { + program.Logger.Error().Msg(err.Error()) + } + } else { + program.Logger.Err(err).Msg("") + } + + return code + } + + return 0 +} + +func (program *Program) EnsureDB() error { + if !program.hasDB { + return nil + } + + program.Logger.Debug().Msg("Connecting to the database...") + + if err := database.IsUptodate( + program.DatabaseOptions.DSN, program.dbMigrateSource, + ); err != nil { + return err + } + + db, err := program.DatabaseOptions.Open() + if err != nil { + return err + } + + if err := db.Ping(); err != nil { + return err + } + + program.DB = db + + program.Logger.Info().Msg("Connected to the database") + + return nil +} + +func (program *Program) CloseDB() { + if !program.hasDB || program.DB == nil { + return + } + if err := program.DB.Close(); err != nil { + program.Logger.Err(err).Msg("could not close database connection properly") + } +} diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000000000000000000000000000000000000..e011a6d13a2908a00a557b440bdc66f7731db1ab_Y21kL3NlcnZlLmdv --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "net/http" + + "orus.io/orus-io/go-orusapi" +) + +type ServeCmd struct { + Server *orusapi.Server + + program *Program +} + +type serveAPI struct { + name string + handler http.Handler +} + +func (api *serveAPI) Name() string { + return api.name +} + +func (api *serveAPI) Handler() http.Handler { + return api.handler +} + +func (api *serveAPI) PreServerShutdown() { +} + +func (api *serveAPI) ServerShutdown() { +} + +func (cmd *ServeCmd) Execute([]string) error { + if err := cmd.program.EnsureDB(); err != nil { + return err + } + + defer cmd.program.CloseDB() + + handler := cmd.program.setupHandler(cmd.program) + + cmd.Server.SetLog(cmd.program.Logger) + cmd.Server.Environment = cmd.program.InfoOptions.Environment + cmd.Server.SetAPI(&serveAPI{ + name: cmd.program.Parser.Name, + handler: handler, + }) + + defer func() { + if err := cmd.Server.Shutdown(); err != nil { + cmd.program.Logger.Err(err).Msg("error shutting down the server") + } + }() + + if err := cmd.Server.Serve(); err != nil { + return err + } + + return nil +} + +func SetupServeCmd(program *Program) *ServeCmd { + cmd := ServeCmd{ + program: program, + Server: orusapi.NewServer(nil), + } + + serve, err := program.Parser.AddCommand("serve", "Serves the API", "", &cmd) + if err != nil { + program.Logger.Fatal().Msg(err.Error()) + } + serve.EnvNamespace = "SERVE" + + return &cmd +} diff --git a/go.mod b/go.mod index 8156f4fc59595a32db2167a846bddc48a29c1dbc_Z28ubW9k..e011a6d13a2908a00a557b440bdc66f7731db1ab_Z28ubW9k 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ github.com/go-openapi/swag v0.22.4 github.com/golang-migrate/migrate/v4 v4.16.2 github.com/jackc/pgtype v1.14.0 + github.com/jessevdk/go-flags v1.6.1 github.com/jmoiron/sqlx v1.3.5 github.com/json-iterator/go v1.1.12 github.com/justinas/alice v1.2.0 @@ -46,7 +47,7 @@ github.com/prometheus/procfs v0.11.1 // indirect github.com/rs/xid v1.5.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/sys v0.12.0 // indirect + golang.org/x/sys v0.21.0 // indirect golang.org/x/text v0.13.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 8156f4fc59595a32db2167a846bddc48a29c1dbc_Z28uc3Vt..e011a6d13a2908a00a557b440bdc66f7731db1ab_Z28uc3Vt 100644 --- a/go.sum +++ b/go.sum @@ -111,6 +111,8 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -271,8 +273,8 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= diff --git a/server.go b/server.go index 8156f4fc59595a32db2167a846bddc48a29c1dbc_c2VydmVyLmdv..e011a6d13a2908a00a557b440bdc66f7731db1ab_c2VydmVyLmdv 100644 --- a/server.go +++ b/server.go @@ -60,7 +60,7 @@ // Server for the API. type Server struct { - EnabledListeners []string `long:"scheme" description:"the listeners to enable, this can be repeated and defaults to the schemes in the swagger spec"` + EnabledListeners []string `long:"scheme" description:"the listeners to enable, this can be repeated and defaults to the schemes in the swagger spec" env:"SCHEME"` CleanupTimeout time.Duration `long:"cleanup-timeout" description:"grace period for which to wait before killing idle connections" default:"10s"` GracefulTimeout time.Duration `long:"graceful-timeout" description:"grace period for which to wait before shutting down the server" default:"15s"` MaxHeaderSize flagext.ByteSize `long:"max-header-size" description:"controls the maximum number of bytes the server will read parsing the request header's keys and values, including the request line. It does not limit the size of the request body." default:"1MiB"`