diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9c5b1e26..344036ae 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -169,6 +169,10 @@ jobs: run: river validate --database-url $DATABASE_URL shell: bash + - name: river version + run: river version + shell: bash + - name: river bench run: | if [ "$RUNNER_OS" == "Linux" ]; then @@ -214,7 +218,7 @@ jobs: name: lint runs-on: ubuntu-latest env: - GOLANGCI_LINT_VERSION: v1.60 + GOLANGCI_LINT_VERSION: v1.60.1 permissions: contents: read # allow read access to pull request. Use with `only-new-issues` option. diff --git a/CHANGELOG.md b/CHANGELOG.md index 006f7145..7a2fe3dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## Added + +- A new `river migrate-list` command is available which lists available migrations and which version a target database is migrated to. [PR #534](https://github.com/riverqueue/river/pull/534). +- `river version` or `river --version` now prints River version information. [PR #537](https://github.com/riverqueue/river/pull/537). + ## [0.11.4] - 2024-08-20 ### Fixed diff --git a/cmd/river/main.go b/cmd/river/main.go index a2ed1f85..2a78ef52 100644 --- a/cmd/river/main.go +++ b/cmd/river/main.go @@ -18,7 +18,10 @@ func (p *DriverProcurer) ProcurePgxV5(pool *pgxpool.Pool) riverdriver.Driver[pgx } func main() { - cli := rivercli.NewCLI(&DriverProcurer{}) + cli := rivercli.NewCLI(&rivercli.Config{ + DriverProcurer: &DriverProcurer{}, + Name: "River", + }) if err := cli.BaseCommandSet().Execute(); err != nil { // Cobra will already print an error on problems like an unknown command diff --git a/cmd/river/rivercli/command.go b/cmd/river/rivercli/command.go index 00206cd1..93c88471 100644 --- a/cmd/river/rivercli/command.go +++ b/cmd/river/rivercli/command.go @@ -73,6 +73,7 @@ type RunCommandBundle struct { DatabaseURL *string DriverProcurer DriverProcurer Logger *slog.Logger + OutStd io.Writer } // RunCommand bootstraps and runs a River CLI subcommand. @@ -85,7 +86,7 @@ func RunCommand[TOpts CommandOpts](ctx context.Context, bundle *RunCommandBundle commandBase := &CommandBase{ DriverProcurer: bundle.DriverProcurer, Logger: bundle.Logger, - Out: os.Stdout, + Out: bundle.OutStd, } switch { @@ -124,7 +125,7 @@ func RunCommand[TOpts CommandOpts](ctx context.Context, bundle *RunCommandBundle ok, err := procureAndRun() if err != nil { - fmt.Fprintf(os.Stderr, "failed: %s\n", err) + fmt.Fprintf(os.Stdout, "failed: %s\n", err) } if err != nil || !ok { os.Exit(1) diff --git a/cmd/river/rivercli/river_cli.go b/cmd/river/rivercli/river_cli.go index 5a8668ed..35d4a130 100644 --- a/cmd/river/rivercli/river_cli.go +++ b/cmd/river/rivercli/river_cli.go @@ -12,6 +12,7 @@ import ( "io" "log/slog" "os" + "runtime/debug" "slices" "strings" "time" @@ -23,8 +24,20 @@ import ( "github.com/riverqueue/river/riverdriver" "github.com/riverqueue/river/rivermigrate" + "github.com/riverqueue/river/rivershared/util/valutil" ) +type Config struct { + // DriverProcurer provides a way of procuring drivers for various supported + // databases. + DriverProcurer DriverProcurer + + // Name is the human-friendly named of the executable, used while showing + // version output. Usually this is just "River", but it could be "River + // Pro". + Name string +} + // DriverProcurer is an interface that provides a way of procuring drivers for // various supported databases. type DriverProcurer interface { @@ -34,42 +47,33 @@ type DriverProcurer interface { // CLI provides a common base of commands for the River CLI. type CLI struct { driverProcurer DriverProcurer + name string + out io.Writer } -func NewCLI(driverProcurer DriverProcurer) *CLI { +func NewCLI(config *Config) *CLI { return &CLI{ - driverProcurer: driverProcurer, + driverProcurer: config.DriverProcurer, + name: config.Name, + out: os.Stdout, } } // BaseCommandSet provides a base River CLI command set which may be further // augmented with additional commands. func (c *CLI) BaseCommandSet() *cobra.Command { - var rootOpts struct { + ctx := context.Background() + + var globalOpts struct { Debug bool Verbose bool } - rootCmd := &cobra.Command{ - Use: "river", - Short: "Provides command line facilities for the River job queue", - Long: strings.TrimSpace(` -Provides command line facilities for the River job queue. - `), - Run: func(cmd *cobra.Command, args []string) { - _ = cmd.Usage() - }, - } - rootCmd.PersistentFlags().BoolVar(&rootOpts.Debug, "debug", false, "output maximum logging verbosity (debug level)") - rootCmd.PersistentFlags().BoolVarP(&rootOpts.Verbose, "verbose", "v", false, "output additional logging verbosity (info level)") - rootCmd.MarkFlagsMutuallyExclusive("debug", "verbose") - - ctx := context.Background() makeLogger := func() *slog.Logger { switch { - case rootOpts.Debug: + case globalOpts.Debug: return slog.New(tint.NewHandler(os.Stdout, &tint.Options{Level: slog.LevelDebug})) - case rootOpts.Verbose: + case globalOpts.Verbose: return slog.New(tint.NewHandler(os.Stdout, nil)) default: return slog.New(tint.NewHandler(os.Stdout, &tint.Options{Level: slog.LevelWarn})) @@ -82,9 +86,39 @@ Provides command line facilities for the River job queue. DatabaseURL: databaseURL, DriverProcurer: c.driverProcurer, Logger: makeLogger(), + OutStd: c.out, } } + var rootCmd *cobra.Command + { + var rootOpts struct { + Version bool + } + + rootCmd = &cobra.Command{ + Use: "river", + Short: "Provides command line facilities for the River job queue", + Long: strings.TrimSpace(` +Provides command line facilities for the River job queue. + `), + Run: func(cmd *cobra.Command, args []string) { + if rootOpts.Version { + RunCommand(ctx, makeCommandBundle(nil), &version{}, &versionOpts{Name: c.name}) + } else { + _ = cmd.Usage() + } + }, + } + rootCmd.SetOut(c.out) + + rootCmd.PersistentFlags().BoolVar(&globalOpts.Debug, "debug", false, "output maximum logging verbosity (debug level)") + rootCmd.PersistentFlags().BoolVarP(&globalOpts.Verbose, "verbose", "v", false, "output additional logging verbosity (info level)") + rootCmd.MarkFlagsMutuallyExclusive("debug", "verbose") + + rootCmd.Flags().BoolVar(&rootOpts.Version, "version", false, "print version information") + } + mustMarkFlagRequired := func(cmd *cobra.Command, name string) { // We just panic here because this will never happen outside of an error // in development. @@ -283,9 +317,27 @@ migrations that need to be run, but without running them. rootCmd.AddCommand(cmd) } + // version + { + cmd := &cobra.Command{ + Use: "version", + Short: "Print version information", + Long: strings.TrimSpace(` +Print River and Go version information. + `), + Run: func(cmd *cobra.Command, args []string) { + RunCommand(ctx, makeCommandBundle(nil), &version{}, &versionOpts{Name: c.name}) + }, + } + rootCmd.AddCommand(cmd) + } + return rootCmd } +// SetOut sets standard output. Should be called before BaseCommandSet. +func (c *CLI) SetOut(out io.Writer) { c.out = out } + type benchOpts struct { DatabaseURL string Debug bool @@ -555,3 +607,28 @@ func (c *validate) Run(ctx context.Context, opts *validateOpts) (bool, error) { return res.OK, nil } + +type versionOpts struct { + Name string +} + +func (o *versionOpts) Validate() error { + if o.Name == "" { + return errors.New("name should be set") + } + + return nil +} + +type version struct { + CommandBase +} + +func (c *version) Run(ctx context.Context, opts *versionOpts) (bool, error) { + buildInfo, _ := debug.ReadBuildInfo() + + fmt.Fprintf(c.Out, "%s version %s\n", opts.Name, valutil.ValOrDefault(buildInfo.Main.Version, "(unknown)")) + fmt.Fprintf(c.Out, "Built with %s\n", buildInfo.GoVersion) + + return true, nil +} diff --git a/cmd/river/rivercli/river_cli_test.go b/cmd/river/rivercli/river_cli_test.go index cbb8a988..2602ad02 100644 --- a/cmd/river/rivercli/river_cli_test.go +++ b/cmd/river/rivercli/river_cli_test.go @@ -3,12 +3,19 @@ package rivercli import ( "bytes" "context" + "fmt" + "runtime/debug" "strings" "testing" "time" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/spf13/cobra" "github.com/stretchr/testify/require" + "github.com/riverqueue/river/riverdriver" + "github.com/riverqueue/river/riverdriver/riverpgxv5" "github.com/riverqueue/river/rivermigrate" "github.com/riverqueue/river/rivershared/riversharedtest" ) @@ -69,20 +76,102 @@ var ( testMigrationAll = []rivermigrate.Migration{testMigration01, testMigration02, testMigration03} //nolint:gochecknoglobals ) +type TestDriverProcurer struct{} + +func (p *TestDriverProcurer) ProcurePgxV5(pool *pgxpool.Pool) riverdriver.Driver[pgx.Tx] { + return riverpgxv5.New(pool) +} + +// High level integration tests that operate on the Cobra command directly. This +// isn't always appropriate because there's no way to inject a test transaction. +func TestBaseCommandSetIntegration(t *testing.T) { + t.Parallel() + + type testBundle struct { + out *bytes.Buffer + } + + setup := func(t *testing.T) (*cobra.Command, *testBundle) { + t.Helper() + + cli := NewCLI(&Config{ + DriverProcurer: &TestDriverProcurer{}, + Name: "River", + }) + + var out bytes.Buffer + cli.SetOut(&out) + + return cli.BaseCommandSet(), &testBundle{ + out: &out, + } + } + + t.Run("DebugVerboseMutallyExclusive", func(t *testing.T) { + t.Parallel() + + cmd, _ := setup(t) + + cmd.SetArgs([]string{"--debug", "--verbose"}) + require.EqualError(t, cmd.Execute(), `if any flags in the group [debug verbose] are set none of the others can be; [debug verbose] were all set`) + }) + + t.Run("MigrateDownMissingDatabaseURL", func(t *testing.T) { + t.Parallel() + + cmd, _ := setup(t) + + cmd.SetArgs([]string{"migrate-down"}) + require.EqualError(t, cmd.Execute(), `required flag(s) "database-url" not set`) + }) + + t.Run("VersionFlag", func(t *testing.T) { + t.Parallel() + + cmd, bundle := setup(t) + + cmd.SetArgs([]string{"--version"}) + require.NoError(t, cmd.Execute()) + + buildInfo, _ := debug.ReadBuildInfo() + + require.Equal(t, strings.TrimSpace(fmt.Sprintf(` +River version (unknown) +Built with %s + `, buildInfo.GoVersion)), strings.TrimSpace(bundle.out.String())) + }) + + t.Run("VersionSubcommand", func(t *testing.T) { + t.Parallel() + + cmd, bundle := setup(t) + + cmd.SetArgs([]string{"version"}) + require.NoError(t, cmd.Execute()) + + buildInfo, _ := debug.ReadBuildInfo() + + require.Equal(t, strings.TrimSpace(fmt.Sprintf(` +River version (unknown) +Built with %s + `, buildInfo.GoVersion)), strings.TrimSpace(bundle.out.String())) + }) +} + func TestMigrateList(t *testing.T) { t.Parallel() ctx := context.Background() type testBundle struct { - buf *bytes.Buffer migratorStub *MigratorStub + out *bytes.Buffer } setup := func(t *testing.T) (*migrateList, *testBundle) { t.Helper() - cmd, buf := withMigrateBase(t, &migrateList{}) + cmd, out := withCommandBase(t, &migrateList{}) migratorStub := &MigratorStub{} migratorStub.allVersionsStub = func() []rivermigrate.Migration { return testMigrationAll } @@ -91,7 +180,7 @@ func TestMigrateList(t *testing.T) { cmd.GetCommandBase().GetMigrator = func(config *rivermigrate.Config) MigratorInterface { return migratorStub } return cmd, &testBundle{ - buf: buf, + out: out, migratorStub: migratorStub, } } @@ -99,49 +188,95 @@ func TestMigrateList(t *testing.T) { t.Run("NoExistingMigrations", func(t *testing.T) { t.Parallel() - migrateList, bundle := setup(t) + cmd, bundle := setup(t) - _, err := migrateList.Run(ctx, &migrateListOpts{}) + _, err := runCommand(ctx, t, cmd, &migrateListOpts{}) require.NoError(t, err) require.Equal(t, strings.TrimSpace(` 001 1st migration 002 2nd migration 003 3rd migration - `), strings.TrimSpace(bundle.buf.String())) + `), strings.TrimSpace(bundle.out.String())) }) t.Run("WithExistingMigrations", func(t *testing.T) { t.Parallel() - migrateList, bundle := setup(t) + cmd, bundle := setup(t) bundle.migratorStub.existingVersionsStub = func(ctx context.Context) ([]rivermigrate.Migration, error) { return []rivermigrate.Migration{testMigration01, testMigration02}, nil } - _, err := migrateList.Run(ctx, &migrateListOpts{}) + _, err := runCommand(ctx, t, cmd, &migrateListOpts{}) require.NoError(t, err) require.Equal(t, strings.TrimSpace(` 001 1st migration * 002 2nd migration 003 3rd migration - `), strings.TrimSpace(bundle.buf.String())) + `), strings.TrimSpace(bundle.out.String())) + }) +} + +func TestVersion(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + type testBundle struct { + buf *bytes.Buffer + } + + setup := func(t *testing.T) (*version, *testBundle) { + t.Helper() + + cmd, buf := withCommandBase(t, &version{}) + + return cmd, &testBundle{ + buf: buf, + } + } + + t.Run("PrintsVersion", func(t *testing.T) { + t.Parallel() + + cmd, bundle := setup(t) + + _, err := runCommand(ctx, t, cmd, &versionOpts{Name: "River"}) + require.NoError(t, err) + + buildInfo, _ := debug.ReadBuildInfo() + + require.Equal(t, strings.TrimSpace(fmt.Sprintf(` +River version (unknown) +Built with %s + `, buildInfo.GoVersion)), strings.TrimSpace(bundle.buf.String())) }) } -func withMigrateBase[TCommand Command[TOpts], TOpts CommandOpts](t *testing.T, cmd TCommand) (TCommand, *bytes.Buffer) { +// runCommand runs a CLI command while doing some additional niceties like +// validating options. +func runCommand[TCommand Command[TOpts], TOpts CommandOpts](ctx context.Context, t *testing.T, cmd TCommand, opts TOpts) (bool, error) { + t.Helper() + + require.NoError(t, opts.Validate()) + + return cmd.Run(ctx, opts) +} + +func withCommandBase[TCommand Command[TOpts], TOpts CommandOpts](t *testing.T, cmd TCommand) (TCommand, *bytes.Buffer) { t.Helper() - var buf bytes.Buffer + var out bytes.Buffer cmd.SetCommandBase(&CommandBase{ Logger: riversharedtest.Logger(t), - Out: &buf, + Out: &out, GetMigrator: func(config *rivermigrate.Config) MigratorInterface { return &MigratorStub{} }, }) - return cmd, &buf + return cmd, &out } func TestMigrationComment(t *testing.T) {