Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chanbackup: archive old channel backup files #9232

Merged
merged 3 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 101 additions & 5 deletions chanbackup/backupfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package chanbackup

import (
"fmt"
"io"
"os"
"path/filepath"
"time"

"github.com/lightningnetwork/lnd/keychain"
"github.com/lightningnetwork/lnd/lnrpc"
)

const (
Expand All @@ -17,6 +20,10 @@ const (
// file that we'll use to atomically update the primary back up file
// when new channel are detected.
DefaultTempBackupFileName = "temp-dont-use.backup"

// DefaultChanBackupArchiveDirName is the default name of the directory
// that we'll use to store old channel backups.
DefaultChanBackupArchiveDirName = "chan-backup-archives"
)

var (
Expand Down Expand Up @@ -44,28 +51,40 @@ type MultiFile struct {

// tempFile is an open handle to the temp back up file.
tempFile *os.File

// archiveDir is the directory where we'll store old channel backups.
archiveDir string

// noBackupArchive indicates whether old backups should be deleted
// rather than archived.
noBackupArchive bool
}

// NewMultiFile create a new multi-file instance at the target location on the
// file system.
func NewMultiFile(fileName string) *MultiFile {

func NewMultiFile(fileName string, noBackupArchive bool) *MultiFile {
// We'll our temporary backup file in the very same directory as the
// main backup file.
backupFileDir := filepath.Dir(fileName)
tempFileName := filepath.Join(
backupFileDir, DefaultTempBackupFileName,
)
archiveDir := filepath.Join(
backupFileDir, DefaultChanBackupArchiveDirName,
)

return &MultiFile{
fileName: fileName,
tempFileName: tempFileName,
fileName: fileName,
tempFileName: tempFileName,
archiveDir: archiveDir,
noBackupArchive: noBackupArchive,
}
}

// UpdateAndSwap will attempt write a new temporary backup file to disk with
// the newBackup encoded, then atomically swap (via rename) the old file for
// the new file by updating the name of the new file to the old.
// the new file by updating the name of the new file to the old. It also checks
// if the old file should be archived first before swapping it.
func (b *MultiFile) UpdateAndSwap(newBackup PackedMulti) error {
// If the main backup file isn't set, then we can't proceed.
if b.fileName == "" {
Expand Down Expand Up @@ -117,6 +136,12 @@ func (b *MultiFile) UpdateAndSwap(newBackup PackedMulti) error {
return fmt.Errorf("unable to close file: %w", err)
}

// Archive the old channel backup file before replacing.
if err := b.createArchiveFile(); err != nil {
return fmt.Errorf("unable to archive old channel "+
"backup file: %w", err)
}

// Finally, we'll attempt to atomically rename the temporary file to
// the main back up file. If this succeeds, then we'll only have a
// single file on disk once this method exits.
Expand Down Expand Up @@ -147,3 +172,74 @@ func (b *MultiFile) ExtractMulti(keyChain keychain.KeyRing) (*Multi, error) {
packedMulti := PackedMulti(multiBytes)
return packedMulti.Unpack(keyChain)
}

// createArchiveFile creates an archive file with a timestamped name in the
// specified archive directory, and copies the contents of the main backup file
// to the new archive file.
func (b *MultiFile) createArchiveFile() error {
// User can skip archiving of old backup files to save disk space.
if b.noBackupArchive {
log.Debug("Skipping archive of old backup file as configured")
return nil
}

// Check for old channel backup file.
oldFileExists := lnrpc.FileExists(b.fileName)
if !oldFileExists {
log.Debug("No old channel backup file to archive")
return nil
}

log.Infof("Archiving old channel backup to %v", b.archiveDir)

// Generate archive file path with timestamped name.
baseFileName := filepath.Base(b.fileName)
timestamp := time.Now().Format("2006-01-02-15-04-05")

archiveFileName := fmt.Sprintf("%s-%s", baseFileName, timestamp)
archiveFilePath := filepath.Join(b.archiveDir, archiveFileName)

oldBackupFile, err := os.Open(b.fileName)
if err != nil {
return fmt.Errorf("unable to open old channel backup file: "+
"%w", err)
}
defer func() {
err := oldBackupFile.Close()
if err != nil {
log.Errorf("unable to close old channel backup file: "+
"%v", err)
}
}()

// Ensure the archive directory exists. If it doesn't we create it.
const archiveDirPermissions = 0o700
err = os.MkdirAll(b.archiveDir, archiveDirPermissions)
if err != nil {
return fmt.Errorf("unable to create archive directory: %w", err)
}

// Create new archive file.
archiveFile, err := os.Create(archiveFilePath)
if err != nil {
return fmt.Errorf("unable to create archive file: %w", err)
}
defer func() {
err := archiveFile.Close()
if err != nil {
log.Errorf("unable to close archive file: %v", err)
}
}()

// Copy contents of old backup to the newly created archive files.
_, err = io.Copy(archiveFile, oldBackupFile)
if err != nil {
return fmt.Errorf("unable to copy to archive file: %w", err)
}
err = archiveFile.Sync()
if err != nil {
return fmt.Errorf("unable to sync archive file: %w", err)
}

Abdulkbk marked this conversation as resolved.
Show resolved Hide resolved
return nil
}
Abdulkbk marked this conversation as resolved.
Show resolved Hide resolved
157 changes: 153 additions & 4 deletions chanbackup/backupfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ func assertFileDeleted(t *testing.T, filePath string) {
// TestUpdateAndSwap test that we're able to properly swap out old backups on
// disk with new ones. Additionally, after a swap operation succeeds, then each
// time we should only have the main backup file on disk, as the temporary file
// has been removed.
// has been removed. Finally, we check for noBackupArchive to ensure that the
// archive file is created when it's set to false, and not created when it's
// set to true.
func TestUpdateAndSwap(t *testing.T) {
t.Parallel()

Expand All @@ -58,7 +60,8 @@ func TestUpdateAndSwap(t *testing.T) {
fileName string
tempFileName string

oldTempExists bool
oldTempExists bool
noBackupArchive bool

valid bool
}{
Expand Down Expand Up @@ -92,9 +95,37 @@ func TestUpdateAndSwap(t *testing.T) {
),
valid: true,
},

// Test with noBackupArchive set to true - should not create
// archive.
{
fileName: filepath.Join(
tempTestDir, DefaultBackupFileName,
),
tempFileName: filepath.Join(
tempTestDir, DefaultTempBackupFileName,
),
noBackupArchive: true,
valid: true,
},

// Test with v set to false - should create
// archive.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

{
fileName: filepath.Join(
tempTestDir, DefaultBackupFileName,
),
tempFileName: filepath.Join(
tempTestDir, DefaultTempBackupFileName,
),
noBackupArchive: false,
valid: true,
},
}
for i, testCase := range testCases {
backupFile := NewMultiFile(testCase.fileName)
backupFile := NewMultiFile(
testCase.fileName, testCase.noBackupArchive,
)

// To start with, we'll make a random byte slice that'll pose
// as our packed multi backup.
Expand Down Expand Up @@ -160,6 +191,41 @@ func TestUpdateAndSwap(t *testing.T) {
// Additionally, we shouldn't be able to find the temp backup
// file on disk, as it should be deleted each time.
assertFileDeleted(t, testCase.tempFileName)

// Now check if archive was created when noBackupArchive is
// false.
archiveDir := filepath.Join(
filepath.Dir(testCase.fileName),
DefaultChanBackupArchiveDirName,
)
if !testCase.noBackupArchive {
files, err := os.ReadDir(archiveDir)
require.NoError(t, err)
require.Len(t, files, 1)

// Verify the archive contents match the previous
// backup.
archiveFile := filepath.Join(
archiveDir, files[0].Name(),
)
// The archived content should match the previous
// backup (newPackedMulti) that was just swapped out.
assertBackupMatches(t, archiveFile, newPackedMulti)
guggero marked this conversation as resolved.
Show resolved Hide resolved

// Clean up the archive directory.
os.RemoveAll(archiveDir)

continue
}

// When noBackupArchive is true, no new archive file should be
// created. Note: In a real environment, the archive directory
// might exist with older backups before the feature is
// disabled, but for test simplicity (since we clean up the
// directory between test cases), we verify the directory
// doesn't exist at all.
require.NoDirExists(t, archiveDir)

}
}

Expand Down Expand Up @@ -238,7 +304,7 @@ func TestExtractMulti(t *testing.T) {
}
for i, testCase := range testCases {
// First, we'll make our backup file with the specified name.
backupFile := NewMultiFile(testCase.fileName)
backupFile := NewMultiFile(testCase.fileName, false)

// With our file made, we'll now attempt to read out the
// multi-file.
Expand Down Expand Up @@ -274,3 +340,86 @@ func TestExtractMulti(t *testing.T) {
assertMultiEqual(t, &unpackedMulti, freshUnpackedMulti)
}
}

// TestCreateArchiveFile tests that we're able to create an archive file
// with a timestamped name in the specified archive directory, and copy the
// contents of the main backup file to the new archive file.
func TestCreateArchiveFile(t *testing.T) {
Abdulkbk marked this conversation as resolved.
Show resolved Hide resolved
t.Parallel()

// First, we'll create a temporary directory for our test files.
tempDir := t.TempDir()
archiveDir := filepath.Join(tempDir, DefaultChanBackupArchiveDirName)

// Next, we'll create a test backup file and write some content to it.
backupFile := filepath.Join(tempDir, DefaultBackupFileName)
testContent := []byte("test backup content")
err := os.WriteFile(backupFile, testContent, 0644)
require.NoError(t, err)

tests := []struct {
name string
setup func()
noBackupArchive bool
wantError bool
}{
{
name: "successful archive",
noBackupArchive: false,
},
{
name: "skip archive when disabled",
noBackupArchive: true,
},
{
name: "invalid archive directory permissions",
setup: func() {
// Create dir with no write permissions.
err := os.MkdirAll(archiveDir, 0500)
require.NoError(t, err)
},
noBackupArchive: false,
wantError: true,
},
}

for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
defer os.RemoveAll(archiveDir)
if tc.setup != nil {
yyforyongyu marked this conversation as resolved.
Show resolved Hide resolved
tc.setup()
}

multiFile := NewMultiFile(
backupFile, tc.noBackupArchive,
)

err := multiFile.createArchiveFile()
if tc.wantError {
require.Error(t, err)
return
}

require.NoError(t, err)

// If archiving is disabled, verify no archive was
// created.
if tc.noBackupArchive {
require.NoDirExists(t, archiveDir)
return
}

// Verify archive exists and content matches.
files, err := os.ReadDir(archiveDir)
require.NoError(t, err)
require.Len(t, files, 1)

archivedContent, err := os.ReadFile(
filepath.Join(archiveDir, files[0].Name()),
)
require.NoError(t, err)
assertBackupMatches(t, backupFile, archivedContent)
})
}
}
2 changes: 2 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,8 @@ type Config struct {
MaxPendingChannels int `long:"maxpendingchannels" description:"The maximum number of incoming pending channels permitted per peer."`
BackupFilePath string `long:"backupfilepath" description:"The target location of the channel backup file"`

NoBackupArchive bool `long:"no-backup-archive" description:"If set to true, channel backups will be deleted or replaced rather than being archived to a separate location."`

FeeURL string `long:"feeurl" description:"DEPRECATED: Use 'fee.url' option. Optional URL for external fee estimation. If no URL is specified, the method for fee estimation will depend on the chosen backend and network. Must be set for neutrino on mainnet." hidden:"true"`

Bitcoin *lncfg.Chain `group:"Bitcoin" namespace:"bitcoin"`
Expand Down
5 changes: 5 additions & 0 deletions docs/release-notes/release-notes-0.19.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@
initial historical sync may be blocked due to a race condition in handling the
syncer's internal state.

* Add support for [archiving channel backup](https://github.com/lightningnetwork/lnd/pull/9232)
in a designated folder which allows for easy referencing in the future. A new
config is added `disable-backup-archive`, with default set to false, to
determine if previous channel backups should be archived or not.

## Functional Enhancements
* [Add ability](https://github.com/lightningnetwork/lnd/pull/8998) to paginate
wallet transactions.
Expand Down
4 changes: 4 additions & 0 deletions sample-lnd.conf
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,10 @@
; Example:
; backupfilepath=~/.lnd/data/chain/bitcoin/mainnet/channel.backup

; When false (default), old channel backups are archived to a designated location.
; When true, old backups are simply replaced.
; no-backup-archive=false

; The maximum capacity of the block cache in bytes. Increasing this will result
; in more blocks being kept in memory but will increase performance when the
; same block is required multiple times.
Expand Down
4 changes: 3 additions & 1 deletion server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1648,7 +1648,9 @@ func newServer(cfg *Config, listenAddrs []net.Addr,
chanNotifier: s.channelNotifier,
addrs: s.addrSource,
}
backupFile := chanbackup.NewMultiFile(cfg.BackupFilePath)
backupFile := chanbackup.NewMultiFile(
cfg.BackupFilePath, cfg.NoBackupArchive,
)
startingChans, err := chanbackup.FetchStaticChanBackups(
s.chanStateDB, s.addrSource,
)
Expand Down
Loading