# HG changeset patch # User Christophe de Vienne <christophe@cdevienne.info> # Date 1729279742 -7200 # Fri Oct 18 21:29:02 2024 +0200 # Node ID e011a6d13a2908a00a557b440bdc66f7731db1ab # Parent 8156f4fc59595a32db2167a846bddc48a29c1dbc provide a cmd module It provides a Program type that takes care of the main commands needed (serve, migrate, and more to come) diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 --- /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 --- /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 --- /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 --- 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 --- 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 --- 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"`