diff --git a/accounts/store_kvdb.go b/accounts/store_kvdb.go index 13b54fdf..eeb3168d 100644 --- a/accounts/store_kvdb.go +++ b/accounts/store_kvdb.go @@ -89,6 +89,15 @@ func DeprecateKVDB(dbDir string) error { ) } +// HasActiveKVDB reports whether the accounts kvdb file in the given db +// directory still holds data that is pending migration to SQL. +func HasActiveKVDB(dbDir string) (bool, error) { + return tombstone.HasActiveKVDB( + filepath.Join(dbDir, DBFilename), accountBucketName, + DefaultAccountDBTimeout, + ) +} + func newBoltStore(dir, fileName string, clock clock.Clock, allowDeprecated bool) (*BoltStore, error) { diff --git a/db/tombstone/tombstone.go b/db/tombstone/tombstone.go index f5501be8..78589eb4 100644 --- a/db/tombstone/tombstone.go +++ b/db/tombstone/tombstone.go @@ -85,6 +85,31 @@ func CheckKVDBDeprecated(path string, bucketKey []byte, return nil } +// HasActiveKVDB reports whether the legacy bbolt database at the given path +// exists and has not yet been tombstoned by a SQL migration in the specified +// top-level bucket. A missing file or a tombstoned database is reported as +// inactive, so callers can use this to detect kvdb state that is still pending +// migration to SQL. +func HasActiveKVDB(path string, bucketKey []byte, + timeout time.Duration) (bool, error) { + + if _, err := os.Stat(path); os.IsNotExist(err) { + return false, nil + } + + err := CheckKVDBDeprecated(path, bucketKey, timeout) + switch { + case errors.Is(err, ErrKVDBDeprecated): + return false, nil + + case err != nil: + return false, err + + default: + return true, nil + } +} + // IsMigrationTombstoneKey returns true if the given key is the kvdb migration // tombstone marker. func IsMigrationTombstoneKey(key []byte) bool { diff --git a/firewalldb/kvdb_store.go b/firewalldb/kvdb_store.go index 79543e0e..55f0a466 100644 --- a/firewalldb/kvdb_store.go +++ b/firewalldb/kvdb_store.go @@ -75,6 +75,15 @@ func DeprecateKVDB(dbDir string) error { ) } +// HasActiveKVDB reports whether the rules kvdb file in the given db directory +// still holds data that is pending migration to SQL. +func HasActiveKVDB(dbDir string) (bool, error) { + return tombstone.HasActiveKVDB( + filepath.Join(dbDir, DBFilename), rulesBucketKey, + DefaultRulesDBTimeout, + ) +} + func newBoltDB(dir, fileName string, sessionIDIndex SessionDB, accountsDB AccountsDB, clock clock.Clock, allowDeprecated bool) (*BoltDB, error) { diff --git a/itest/litd_migration_test.go b/itest/litd_migration_test.go index 278563ed..24614384 100644 --- a/itest/litd_migration_test.go +++ b/itest/litd_migration_test.go @@ -3,6 +3,7 @@ package itest import ( + "bytes" "context" "database/sql" "fmt" @@ -123,6 +124,11 @@ func testKvdbSQLMigration(ctx context.Context, net *NetworkHarness, rawConn.Close() // Step 4: Restart with configured backend to trigger migration. + // + // During the startup, the user will be prompted to confirm the + // migration by typing yes. We therefore buffer yes to std-in. + migNode.stdin = bytes.NewBufferString("yes\n") + err = net.RestartNode( migNode, func() error { return nil }, []LitArgOption{ WithLitArg("databasebackend", *litDBBackend), @@ -183,6 +189,9 @@ func testKvdbSQLMigration(ctx context.Context, net *NetworkHarness, // Step 9: Delete the SQL database and verify that starting with the // selected SQL backend reruns the kvdb -> SQL migration successfully. + // + // Note that we do not buffer yes to std-in again, as the prompt will + // not be shown when the bbolt db has already been tombstoned. rerunSQLMigrationAndAssert( t, net, migNode, newStepCtx, newAdminCtx, beforeMigration, migrationRefs, diff --git a/itest/litd_node.go b/itest/litd_node.go index 35a88d3d..fc1be2c6 100644 --- a/itest/litd_node.go +++ b/itest/litd_node.go @@ -342,6 +342,7 @@ type HarnessNode struct { cmd *exec.Cmd pidFile string logFile *os.File + stdin io.Reader // processExit is a channel that's closed once it's detected that the // process this instance of HarnessNode is bound to has exited. @@ -636,6 +637,7 @@ func (hn *HarnessNode) Start(litdBinary string, litdError chan<- error, args := hn.Cfg.GenArgs(litArgOpts...) hn.cmd = exec.Command(litdBinary, args...) + hn.cmd.Stdin = hn.stdin // Redirect stderr output to buffer var errb bytes.Buffer diff --git a/migration_prompt.go b/migration_prompt.go new file mode 100644 index 00000000..4743bafd --- /dev/null +++ b/migration_prompt.go @@ -0,0 +1,180 @@ +package terminal + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/lightninglabs/lightning-terminal/accounts" + "github.com/lightninglabs/lightning-terminal/firewalldb" + "github.com/lightninglabs/lightning-terminal/session" +) + +var kvdbToSQLMigrationPromptLines = []string{ + "", + "CAUTION: litd is about to migrate your existing data to a new SQL " + + "database.", + "After this, litd will use the SQL database for your existing data " + + "and any new data added after that point.", + "However, after the migration you will not be able to switch back " + + "to your old database, as it will be incompatible with litd " + + "after the migration.", + "NOTE: This also means that you will not be able to downgrade litd " + + "to a version prior to when SQL database support was added " + + "(v0.17.0-alpha).", + "", + "SQL databases are more performant, quicker to start, and much less " + + "prone to database corruption.", + "It is therefore strongly recommended that you proceed with this " + + "database migration.", + "", + "If you want to abort the migration and keep using the old database " + + "type instead, stop now and restart litd with the following " + + "config option set: `databasebackend=bbolt`.", + "Please note though that your old database type (bbolt) is " + + "deprecated, and support for it will be removed in a future " + + "release. Migration to SQL will at that point be mandatory.", + "", +} + +// confirmPendingKVDBToSQLMigration blocks startup until the user explicitly +// acknowledges that litd is about to migrate legacy kvdb state to SQL and +// tombstone the kvdb files afterwards, unless auto migration is enabled. +func (c *Config) confirmPendingKVDBToSQLMigration() error { + return c.confirmPendingKVDBToSQLMigrationWithInput( + os.Stdin, os.Stderr, + ) +} + +// confirmPendingKVDBToSQLMigrationWithInput is the testable variant of the +// startup migration confirmation. +func (c *Config) confirmPendingKVDBToSQLMigrationWithInput( + input io.Reader, output io.Writer) error { + + hasActiveKVDB, err := hasActiveLegacyKVDB(c) + if err != nil { + return err + } + + if !hasActiveKVDB { + return nil + } + + return promptForKVDBToSQLMigrationConfirmation(input, output) +} + +// hasActiveLegacyKVDB reports whether any legacy LiT kvdb file exists and was +// not already tombstoned by a previous SQL migration. +func hasActiveLegacyKVDB(cfg *Config) (bool, error) { + // The legacy accounts DB follows the macaroon directory, while the + // session and rules DBs live under the network-scoped LiT directory. + // We mirror those runtime locations here so the prompt checks the same + // files that store initialization will later open. + networkDir := filepath.Join(cfg.LitDir, cfg.Network) + accountsDir := filepath.Dir(cfg.MacaroonPath) + + checks := []struct { + name string + fn func(string) (bool, error) + dir string + }{ + { + name: "accounts", + fn: accounts.HasActiveKVDB, + dir: accountsDir, + }, + { + name: "sessions", + fn: session.HasActiveKVDB, + dir: networkDir, + }, + { + name: "rules", + fn: firewalldb.HasActiveKVDB, + dir: networkDir, + }, + } + + for _, check := range checks { + active, err := check.fn(check.dir) + if err != nil { + return false, fmt.Errorf("unable to inspect legacy "+ + "%s kvdb: %w", check.name, err) + } + + if active { + return true, nil + } + } + + return false, nil +} + +// promptForKVDBToSQLMigrationConfirmation requires a literal "yes" response +// before litd continues with a pending kvdb-to-SQL migration. +func promptForKVDBToSQLMigrationConfirmation(input io.Reader, + output io.Writer) error { + + logKVDBToSQLMigrationPrompt() + + for _, line := range kvdbToSQLMigrationPromptLines { + _, err := fmt.Fprintln(output, line) + if err != nil { + return err + } + } + + _, err := fmt.Fprint(output, + "Type \"yes\" to continue with the migration. Any other "+ + "answer will abort the startup of litd: ", + ) + if err != nil { + return err + } + + reader := bufio.NewReader(input) + answer, err := reader.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("manual confirmation required before kvdb "+ + "migration can continue: %w", err) + } + + if strings.TrimSpace(answer) != "yes" { + return errors.New("manual confirmation declined; refusing to " + + "continue kvdb-to-SQL migration") + } + + return nil +} + +// logKVDBToSQLMigrationPrompt mirrors the interactive migration warning to +// the configured logger so the full operator guidance is preserved in logs. +func logKVDBToSQLMigrationPrompt() { + for _, line := range kvdbToSQLMigrationPromptLines { + if line == "" { + continue + } + + log.Infof("%s", line) + } +} + +// sqlMigrationsSkipped reports whether the configured SQL backend will skip +// schema migrations during startup. In that case no kvdb-to-SQL migration is +// attempted and the startup confirmation prompt must not be shown. +func (c *Config) sqlMigrationsSkipped() bool { + switch c.DatabaseBackend { + case DatabaseBackendSqlite: + return c.Sqlite != nil && c.Sqlite.SkipMigrations + + case DatabaseBackendPostgres: + return c.Postgres != nil && c.Postgres.SkipMigrations + + default: + return false + } +} diff --git a/migration_prompt_test.go b/migration_prompt_test.go new file mode 100644 index 00000000..b3815e5d --- /dev/null +++ b/migration_prompt_test.go @@ -0,0 +1,197 @@ +package terminal + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/lightning-terminal/accounts" + "github.com/lightningnetwork/lnd/clock" + "github.com/stretchr/testify/require" +) + +// TestConfirmPendingKVDBToSQLMigration verifies that the startup prompt is +// shown only when active legacy kvdb state is about to be migrated. +func TestConfirmPendingKVDBToSQLMigration(t *testing.T) { + tests := []struct { + name string + + // setsEnv signals that the test mutates the process + // environment via t.Setenv, which means t.Parallel can't be + // used in the subtest. + setsEnv bool + + // setup configures the cfg and creates any on-disk legacy + // kvdb state the test needs before the prompt runs. + setup func(t *testing.T, cfg *Config, dbDir string) + + // input is the response written to the prompt's stdin. + input string + + // expectErr is the substring expected in the error returned + // by the prompt function. An empty value asserts no error. + expectErr string + + // expectOutput lists substrings that must appear in the + // prompt's stdout. + expectOutput []string + + // expectNoOutput asserts that nothing was written to the + // prompt's stdout, e.g. because the prompt was bypassed. + expectNoOutput bool + }{ + { + name: "accepts yes for pending migration", + setup: func(t *testing.T, _ *Config, dbDir string) { + createActiveAccountsKVDB(t, dbDir) + }, + input: "yes\n", + expectOutput: []string{ + "about to migrate", "switch back", + }, + }, + { + name: "rejects non yes answer", + setup: func(t *testing.T, _ *Config, dbDir string) { + createActiveAccountsKVDB(t, dbDir) + }, + input: "no\n", + expectErr: "manual confirmation declined", + }, + { + name: "skips prompt when no legacy kvdb exists", + }, + { + name: "skips prompt for tombstoned kvdb", + setup: func(t *testing.T, _ *Config, dbDir string) { + createActiveAccountsKVDB(t, dbDir) + require.NoError( + t, accounts.DeprecateKVDB(dbDir), + ) + }, + }, + { + name: "uses macaroon dir for accounts kvdb", + setup: func(t *testing.T, cfg *Config, _ string) { + customMacDir := filepath.Join( + t.TempDir(), "custom", + ) + cfg.MacaroonPath = filepath.Join( + customMacDir, "lit.macaroon", + ) + createActiveAccountsKVDB(t, customMacDir) + }, + input: "yes\n", + expectOutput: []string{"about to migrate"}, + }, + { + name: "accepts yes without trailing newline", + setup: func(t *testing.T, _ *Config, dbDir string) { + createActiveAccountsKVDB(t, dbDir) + }, + input: "yes", + expectOutput: []string{"about to migrate"}, + }, + { + name: "rejects non yes without trailing newline", + setup: func(t *testing.T, _ *Config, dbDir string) { + createActiveAccountsKVDB(t, dbDir) + }, + input: "no", + expectErr: "manual confirmation declined", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if !tc.setsEnv { + t.Parallel() + } + + cfg, dbDir := testMigrationPromptConfig(t) + if tc.setup != nil { + tc.setup(t, cfg, dbDir) + } + + var output bytes.Buffer + err := cfg.confirmPendingKVDBToSQLMigrationWithInput( + strings.NewReader(tc.input), &output, + ) + + if tc.expectErr != "" { + require.ErrorContains(t, err, tc.expectErr) + } else { + require.NoError(t, err) + } + + if tc.expectNoOutput { + require.Empty(t, output.String()) + } + for _, want := range tc.expectOutput { + require.Contains(t, output.String(), want) + } + }) + } + + // The remaining subtests don't exercise the prompt path, so they + // don't fit the table above. + t.Run("logs migration prompt text", func(t *testing.T) { + // Note we intentionally don't use t.Parallel() here as the + // subtest calls UseLogger(...), which mutates the + // package-global logger. Running it in parallel with + // the other prompt subtests lets one goroutine read log while + // another replaces it, which can lead to a race. + + var logOutput bytes.Buffer + + testLogger := btclog.NewSLogger( + btclog.NewDefaultHandler(&logOutput), + ) + + oldLogger := log + UseLogger(testLogger.SubSystem(Subsystem)) + t.Cleanup(func() { + UseLogger(oldLogger) + }) + + err := promptForKVDBToSQLMigrationConfirmation( + strings.NewReader("yes\n"), &bytes.Buffer{}, + ) + require.NoError(t, err) + require.Contains(t, logOutput.String(), "about to migrate") + require.Contains(t, logOutput.String(), "databasebackend=bbolt") + }) +} + +// testMigrationPromptConfig creates a config whose LiT directory and network +// point at the same network directory used by the legacy kvdb stores. +func testMigrationPromptConfig(t *testing.T) (*Config, string) { + t.Helper() + + litDir := t.TempDir() + network := "regtest" + dbDir := filepath.Join(litDir, network) + cfg := &Config{ + LitDir: litDir, + Network: network, + MacaroonPath: filepath.Join(dbDir, "lit.macaroon"), + } + + return cfg, dbDir +} + +// createActiveAccountsKVDB creates a minimal non-tombstoned legacy accounts +// database so the startup confirmation sees pending kvdb state to migrate. +func createActiveAccountsKVDB(t *testing.T, dbDir string) { + t.Helper() + + store, err := accounts.NewBoltStore( + dbDir, accounts.DBFilename, clock.NewDefaultClock(), + ) + require.NoError(t, err) + + require.NoError(t, store.Close()) +} diff --git a/session/kvdb_store.go b/session/kvdb_store.go index 40a64cf5..7257f99d 100644 --- a/session/kvdb_store.go +++ b/session/kvdb_store.go @@ -118,6 +118,15 @@ func DeprecateKVDB(dbDir string) error { ) } +// HasActiveKVDB reports whether the session kvdb file in the given db +// directory still holds data that is pending migration to SQL. +func HasActiveKVDB(dbDir string) (bool, error) { + return tombstone.HasActiveKVDB( + filepath.Join(dbDir, DBFilename), sessionBucketKey, + DefaultSessionDBTimeout, + ) +} + func newDB(dir, fileName string, clock clock.Clock, store accounts.Store, allowDeprecated bool) (*BoltStore, error) { diff --git a/terminal.go b/terminal.go index a14605f0..9fda836b 100644 --- a/terminal.go +++ b/terminal.go @@ -444,6 +444,15 @@ func (g *LightningTerminal) start(ctx context.Context) error { return fmt.Errorf("could not create network directory: %v", err) } + if g.cfg.DatabaseBackend != DatabaseBackendBbolt && + !g.cfg.sqlMigrationsSkipped() { + + err = g.cfg.confirmPendingKVDBToSQLMigration() + if err != nil { + return err + } + } + // We create a reference to the `accountRpcServer` here before starting // it and prior to setting up the LND connection. This is because when // the LND connection is set up for an integrated LND instance, LND will