diff --git a/config.go b/config.go index 0ea6fffb..6ff2b635 100644 --- a/config.go +++ b/config.go @@ -3,6 +3,7 @@ package terminal import ( "context" "crypto/tls" + "database/sql" "errors" "fmt" "io/ioutil" @@ -15,6 +16,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/jessevdk/go-flags" + "github.com/lib/pq" "github.com/lightninglabs/faraday" "github.com/lightninglabs/faraday/chain" "github.com/lightninglabs/faraday/frdrpcserver" @@ -727,6 +729,11 @@ func loadAndValidateConfig(interceptor signal.Interceptor) (*Config, error) { ) } + err = validateExclusiveSQLBackends(cfg, litDir) + if err != nil { + return nil, err + } + err = cfg.DevConfig.Validate() if err != nil { return nil, err @@ -1149,6 +1156,126 @@ func readAutoMigrateKVDB(config *Config) error { return nil } +// validateExclusiveSQLBackends errors and thereby rejects startup when the +// inactive SQL backend still has data at its default location. This prevents +// silently switching to a different SQL store and starting against an empty +// database. +func validateExclusiveSQLBackends(cfg *Config, litDir string) error { + switch cfg.DatabaseBackend { + case DatabaseBackendPostgres: + sqlitePath := filepath.Join( + litDir, cfg.Network, defaultSqliteDatabaseFileName, + ) + + exists, err := sqliteDatabaseExists(sqlitePath) + if err != nil { + return fmt.Errorf("unable to check for existing "+ + "sqlite database file at %s: %w", sqlitePath, + err) + } + if exists { + return fmt.Errorf("cannot start litd with postgres "+ + "backend: sqlite database file already exists "+ + "at %s. If you really intend to switch to "+ + "postgres you must delete the sqlite "+ + "database (effectively deleting all stored "+ + "data) before restarting with postgres again", + sqlitePath) + } + + case DatabaseBackendSqlite: + exists, err := postgresDatabaseExists(cfg.Postgres) + if err != nil { + return fmt.Errorf("unable to check for existing "+ + "postgres database %q at %s:%d for user %q. "+ + "Note that a postgres configuration is set "+ + "despite sqlite being set as the "+ + "databasebackend: %w", cfg.Postgres.DBName, + cfg.Postgres.Host, cfg.Postgres.Port, + cfg.Postgres.User, err) + } + if exists { + return fmt.Errorf("cannot start litd with sqlite "+ + "backend: postgres database %q already exists "+ + "at %s:%d for user %q; If you really intend "+ + "to switch to sqlite you must delete the "+ + "postgres database (effectively deleting all "+ + "stored data) before restarting with sqlite "+ + "again", cfg.Postgres.DBName, cfg.Postgres.Host, + cfg.Postgres.Port, cfg.Postgres.User) + } + } + + return nil +} + +// sqliteDatabaseExists reports whether a SQLite database file exists at the +// given path. +func sqliteDatabaseExists(path string) (bool, error) { + _, err := os.Stat(path) + switch { + case err == nil: + return true, nil + + case os.IsNotExist(err): + return false, nil + + default: + return false, err + } +} + +// postgresDatabaseExists reports whether a Postgres database can be reached +// with the configured connection info. If the configuration does not identify +// a concrete database, the check is skipped. +func postgresDatabaseExists(cfg *db.PostgresConfig) (bool, error) { + if !hasPostgresConnectionInfo(cfg) { + return false, nil + } + + dbConn, err := sql.Open("postgres", cfg.DSN(false)) + if err != nil { + return false, fmt.Errorf("unable to check for existing "+ + "postgres database %q: %w", cfg.DBName, err) + } + defer dbConn.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = dbConn.PingContext(ctx) + switch { + case err == nil: + return true, nil + + case isMissingPostgresDatabase(err): + return false, nil + + default: + return false, fmt.Errorf("unable to check for existing "+ + "postgres database %q: %w", cfg.DBName, err) + } +} + +// hasPostgresConnectionInfo reports whether the config identifies a concrete +// Postgres database to probe. +func hasPostgresConnectionInfo(cfg *db.PostgresConfig) bool { + return cfg != nil && cfg.Host != "" && cfg.Port != 0 && + cfg.User != "" && cfg.DBName != "" +} + +// isMissingPostgresDatabase reports whether the probe failed because the +// target database does not exist. The lib/pq driver surfaces that as SQLSTATE +// 3D000 (invalid_catalog_name). +func isMissingPostgresDatabase(err error) bool { + var pqErr *pq.Error + if errors.As(err, &pqErr) { + return string(pqErr.Code) == "3D000" + } + + return false +} + func buildTLSConfigForHttp2(config *Config) (*tls.Config, error) { var tlsConfig *tls.Config diff --git a/config_test.go b/config_test.go new file mode 100644 index 00000000..03a7c6a4 --- /dev/null +++ b/config_test.go @@ -0,0 +1,143 @@ +package terminal + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/lightninglabs/lightning-terminal/db" + "github.com/stretchr/testify/require" +) + +// TestBlockStartupForPostgresIfSqliteDBExists verifies that a Postgres startup +// is rejected when the default SQLite database file still exists for the +// selected network. +func TestBlockStartupForPostgresIfSqliteDBExists(t *testing.T) { + litDir := t.TempDir() + sqlitePath := filepath.Join( + litDir, "regtest", defaultSqliteDatabaseFileName, + ) + + fixture := db.NewTestPgFixture( + t, db.DefaultPostgresFixtureLifetime, true, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + require.NoError(t, os.MkdirAll(filepath.Dir(sqlitePath), 0700)) + require.NoError(t, os.WriteFile(sqlitePath, []byte("sqlite"), 0600)) + + cfg := &Config{ + DatabaseBackend: DatabaseBackendPostgres, + LitDir: litDir, + Network: "regtest", + Postgres: fixture.GetConfig(), + } + + err := validateExclusiveSQLBackends(cfg, litDir) + require.Error(t, err) + require.Contains(t, err.Error(), "sqlite database file already exists") + require.Contains(t, err.Error(), sqlitePath) +} + +// TestBlockStartupForSqliteIfPostgresDBExists verifies that a SQLite startup is +// rejected when the configured Postgres database already exists. +func TestBlockStartupForSqliteIfPostgresDBExists(t *testing.T) { + fixture := db.NewTestPgFixture( + t, db.DefaultPostgresFixtureLifetime, true, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + cfg := &Config{ + DatabaseBackend: DatabaseBackendSqlite, + Postgres: fixture.GetConfig(), + } + + err := validateExclusiveSQLBackends(cfg, "") + require.Error(t, err) + require.Contains(t, err.Error(), "postgres database") + require.Contains(t, err.Error(), cfg.Postgres.DBName) +} + +// TestDontBlockSqliteOnlyStartup verifies that SQLite startup is allowed when +// no concrete Postgres database is configured. +func TestDontBlockSqliteOnlyStartup(t *testing.T) { + cfg := &Config{ + DatabaseBackend: DatabaseBackendSqlite, + } + + require.NoError(t, validateExclusiveSQLBackends(cfg, "")) +} + +// TestDontBlockSqliteStartupIfConfiguredPostgresDoesntExist verifies that a +// SQLite startup is allowed when the configured Postgres database does not +// exist. +func TestDontBlockSqliteStartupIfConfiguredPostgresDoesntExist(t *testing.T) { + fixture := db.NewTestPgFixture( + t, db.DefaultPostgresFixtureLifetime, true, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + pgCfg := fixture.GetConfig() + pgCfg.DBName = "does_not_exist_for_config_validation" + + cfg := &Config{ + DatabaseBackend: DatabaseBackendSqlite, + Postgres: pgCfg, + } + + require.NoError(t, validateExclusiveSQLBackends(cfg, "")) + + // We also validate that the validateExclusiveSQLBackends passed because + // the db with the configured DBName doesn't exist and not because the + // connection parameters are wrong. This proves that we're ok with a + // postgres setup existing, as long as the specific database doesn't + // exist. + dbConn, err := sql.Open("postgres", pgCfg.DSN(false)) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, dbConn.Close()) + }) + + err = dbConn.Ping() + require.Error(t, err) + require.True(t, isMissingPostgresDatabase(err)) +} + +// TestDontBlockPostgresOnlyStartup verifies that a Postgres is allowed when +// no sqlite database file exists for the selected network, despite the actual +// folder where the file would be placed exists. +func TestDontBlockPostgresOnlyStartup(t *testing.T) { + litDir := t.TempDir() + sqlitePath := filepath.Join( + litDir, "regtest", defaultSqliteDatabaseFileName, + ) + + fixture := db.NewTestPgFixture( + t, db.DefaultPostgresFixtureLifetime, true, + ) + t.Cleanup(func() { + fixture.TearDown(t) + }) + + require.NoError(t, os.MkdirAll(filepath.Dir(sqlitePath), 0700)) + + // NOTE: we don't write any file to the sqlite path here, so the sqlite + // database file never exists, only the default directory where it would + // be located. + + cfg := &Config{ + DatabaseBackend: DatabaseBackendPostgres, + LitDir: litDir, + Network: "regtest", + Postgres: fixture.GetConfig(), + } + + require.NoError(t, validateExclusiveSQLBackends(cfg, litDir)) +}