mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
multi: confirm kvdb migration at startup
Prompt before automatically migrating legacy kvdb state to SQL when litd starts with a SQL backend and active bbolt data is still present. Detect prior migrations by checking for the SQL tombstone marker so already-migrated stores can start without prompting. Add unit coverage for the prompt flow and wire stdin through the itest harness so the migration restart path can acknowledge the prompt automatically.
This commit is contained in:
parent
f5046d1f4e
commit
9016bbb3d2
9 changed files with 449 additions and 0 deletions
|
|
@ -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) {
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
180
migration_prompt.go
Normal file
180
migration_prompt.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
197
migration_prompt_test.go
Normal file
197
migration_prompt_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue