Skip to content

Commit baa34b0

Browse files
authored
Merge pull request #9232 from Abdulkbk/archive-channel-backups
chanbackup: archive old channel backup files
2 parents 6cabc74 + 3bf1548 commit baa34b0

File tree

6 files changed

+268
-10
lines changed

6 files changed

+268
-10
lines changed

chanbackup/backupfile.go

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package chanbackup
22

33
import (
44
"fmt"
5+
"io"
56
"os"
67
"path/filepath"
8+
"time"
79

810
"github.com/lightningnetwork/lnd/keychain"
11+
"github.com/lightningnetwork/lnd/lnrpc"
912
)
1013

1114
const (
@@ -17,6 +20,10 @@ const (
1720
// file that we'll use to atomically update the primary back up file
1821
// when new channel are detected.
1922
DefaultTempBackupFileName = "temp-dont-use.backup"
23+
24+
// DefaultChanBackupArchiveDirName is the default name of the directory
25+
// that we'll use to store old channel backups.
26+
DefaultChanBackupArchiveDirName = "chan-backup-archives"
2027
)
2128

2229
var (
@@ -44,28 +51,40 @@ type MultiFile struct {
4451

4552
// tempFile is an open handle to the temp back up file.
4653
tempFile *os.File
54+
55+
// archiveDir is the directory where we'll store old channel backups.
56+
archiveDir string
57+
58+
// noBackupArchive indicates whether old backups should be deleted
59+
// rather than archived.
60+
noBackupArchive bool
4761
}
4862

4963
// NewMultiFile create a new multi-file instance at the target location on the
5064
// file system.
51-
func NewMultiFile(fileName string) *MultiFile {
52-
65+
func NewMultiFile(fileName string, noBackupArchive bool) *MultiFile {
5366
// We'll our temporary backup file in the very same directory as the
5467
// main backup file.
5568
backupFileDir := filepath.Dir(fileName)
5669
tempFileName := filepath.Join(
5770
backupFileDir, DefaultTempBackupFileName,
5871
)
72+
archiveDir := filepath.Join(
73+
backupFileDir, DefaultChanBackupArchiveDirName,
74+
)
5975

6076
return &MultiFile{
61-
fileName: fileName,
62-
tempFileName: tempFileName,
77+
fileName: fileName,
78+
tempFileName: tempFileName,
79+
archiveDir: archiveDir,
80+
noBackupArchive: noBackupArchive,
6381
}
6482
}
6583

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

139+
// Archive the old channel backup file before replacing.
140+
if err := b.createArchiveFile(); err != nil {
141+
return fmt.Errorf("unable to archive old channel "+
142+
"backup file: %w", err)
143+
}
144+
120145
// Finally, we'll attempt to atomically rename the temporary file to
121146
// the main back up file. If this succeeds, then we'll only have a
122147
// single file on disk once this method exits.
@@ -147,3 +172,74 @@ func (b *MultiFile) ExtractMulti(keyChain keychain.KeyRing) (*Multi, error) {
147172
packedMulti := PackedMulti(multiBytes)
148173
return packedMulti.Unpack(keyChain)
149174
}
175+
176+
// createArchiveFile creates an archive file with a timestamped name in the
177+
// specified archive directory, and copies the contents of the main backup file
178+
// to the new archive file.
179+
func (b *MultiFile) createArchiveFile() error {
180+
// User can skip archiving of old backup files to save disk space.
181+
if b.noBackupArchive {
182+
log.Debug("Skipping archive of old backup file as configured")
183+
return nil
184+
}
185+
186+
// Check for old channel backup file.
187+
oldFileExists := lnrpc.FileExists(b.fileName)
188+
if !oldFileExists {
189+
log.Debug("No old channel backup file to archive")
190+
return nil
191+
}
192+
193+
log.Infof("Archiving old channel backup to %v", b.archiveDir)
194+
195+
// Generate archive file path with timestamped name.
196+
baseFileName := filepath.Base(b.fileName)
197+
timestamp := time.Now().Format("2006-01-02-15-04-05")
198+
199+
archiveFileName := fmt.Sprintf("%s-%s", baseFileName, timestamp)
200+
archiveFilePath := filepath.Join(b.archiveDir, archiveFileName)
201+
202+
oldBackupFile, err := os.Open(b.fileName)
203+
if err != nil {
204+
return fmt.Errorf("unable to open old channel backup file: "+
205+
"%w", err)
206+
}
207+
defer func() {
208+
err := oldBackupFile.Close()
209+
if err != nil {
210+
log.Errorf("unable to close old channel backup file: "+
211+
"%v", err)
212+
}
213+
}()
214+
215+
// Ensure the archive directory exists. If it doesn't we create it.
216+
const archiveDirPermissions = 0o700
217+
err = os.MkdirAll(b.archiveDir, archiveDirPermissions)
218+
if err != nil {
219+
return fmt.Errorf("unable to create archive directory: %w", err)
220+
}
221+
222+
// Create new archive file.
223+
archiveFile, err := os.Create(archiveFilePath)
224+
if err != nil {
225+
return fmt.Errorf("unable to create archive file: %w", err)
226+
}
227+
defer func() {
228+
err := archiveFile.Close()
229+
if err != nil {
230+
log.Errorf("unable to close archive file: %v", err)
231+
}
232+
}()
233+
234+
// Copy contents of old backup to the newly created archive files.
235+
_, err = io.Copy(archiveFile, oldBackupFile)
236+
if err != nil {
237+
return fmt.Errorf("unable to copy to archive file: %w", err)
238+
}
239+
err = archiveFile.Sync()
240+
if err != nil {
241+
return fmt.Errorf("unable to sync archive file: %w", err)
242+
}
243+
244+
return nil
245+
}

chanbackup/backupfile_test.go

Lines changed: 153 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ func assertFileDeleted(t *testing.T, filePath string) {
4848
// TestUpdateAndSwap test that we're able to properly swap out old backups on
4949
// disk with new ones. Additionally, after a swap operation succeeds, then each
5050
// time we should only have the main backup file on disk, as the temporary file
51-
// has been removed.
51+
// has been removed. Finally, we check for noBackupArchive to ensure that the
52+
// archive file is created when it's set to false, and not created when it's
53+
// set to true.
5254
func TestUpdateAndSwap(t *testing.T) {
5355
t.Parallel()
5456

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

61-
oldTempExists bool
63+
oldTempExists bool
64+
noBackupArchive bool
6265

6366
valid bool
6467
}{
@@ -92,9 +95,37 @@ func TestUpdateAndSwap(t *testing.T) {
9295
),
9396
valid: true,
9497
},
98+
99+
// Test with noBackupArchive set to true - should not create
100+
// archive.
101+
{
102+
fileName: filepath.Join(
103+
tempTestDir, DefaultBackupFileName,
104+
),
105+
tempFileName: filepath.Join(
106+
tempTestDir, DefaultTempBackupFileName,
107+
),
108+
noBackupArchive: true,
109+
valid: true,
110+
},
111+
112+
// Test with v set to false - should create
113+
// archive.
114+
{
115+
fileName: filepath.Join(
116+
tempTestDir, DefaultBackupFileName,
117+
),
118+
tempFileName: filepath.Join(
119+
tempTestDir, DefaultTempBackupFileName,
120+
),
121+
noBackupArchive: false,
122+
valid: true,
123+
},
95124
}
96125
for i, testCase := range testCases {
97-
backupFile := NewMultiFile(testCase.fileName)
126+
backupFile := NewMultiFile(
127+
testCase.fileName, testCase.noBackupArchive,
128+
)
98129

99130
// To start with, we'll make a random byte slice that'll pose
100131
// as our packed multi backup.
@@ -160,6 +191,41 @@ func TestUpdateAndSwap(t *testing.T) {
160191
// Additionally, we shouldn't be able to find the temp backup
161192
// file on disk, as it should be deleted each time.
162193
assertFileDeleted(t, testCase.tempFileName)
194+
195+
// Now check if archive was created when noBackupArchive is
196+
// false.
197+
archiveDir := filepath.Join(
198+
filepath.Dir(testCase.fileName),
199+
DefaultChanBackupArchiveDirName,
200+
)
201+
if !testCase.noBackupArchive {
202+
files, err := os.ReadDir(archiveDir)
203+
require.NoError(t, err)
204+
require.Len(t, files, 1)
205+
206+
// Verify the archive contents match the previous
207+
// backup.
208+
archiveFile := filepath.Join(
209+
archiveDir, files[0].Name(),
210+
)
211+
// The archived content should match the previous
212+
// backup (newPackedMulti) that was just swapped out.
213+
assertBackupMatches(t, archiveFile, newPackedMulti)
214+
215+
// Clean up the archive directory.
216+
os.RemoveAll(archiveDir)
217+
218+
continue
219+
}
220+
221+
// When noBackupArchive is true, no new archive file should be
222+
// created. Note: In a real environment, the archive directory
223+
// might exist with older backups before the feature is
224+
// disabled, but for test simplicity (since we clean up the
225+
// directory between test cases), we verify the directory
226+
// doesn't exist at all.
227+
require.NoDirExists(t, archiveDir)
228+
163229
}
164230
}
165231

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

243309
// With our file made, we'll now attempt to read out the
244310
// multi-file.
@@ -274,3 +340,86 @@ func TestExtractMulti(t *testing.T) {
274340
assertMultiEqual(t, &unpackedMulti, freshUnpackedMulti)
275341
}
276342
}
343+
344+
// TestCreateArchiveFile tests that we're able to create an archive file
345+
// with a timestamped name in the specified archive directory, and copy the
346+
// contents of the main backup file to the new archive file.
347+
func TestCreateArchiveFile(t *testing.T) {
348+
t.Parallel()
349+
350+
// First, we'll create a temporary directory for our test files.
351+
tempDir := t.TempDir()
352+
archiveDir := filepath.Join(tempDir, DefaultChanBackupArchiveDirName)
353+
354+
// Next, we'll create a test backup file and write some content to it.
355+
backupFile := filepath.Join(tempDir, DefaultBackupFileName)
356+
testContent := []byte("test backup content")
357+
err := os.WriteFile(backupFile, testContent, 0644)
358+
require.NoError(t, err)
359+
360+
tests := []struct {
361+
name string
362+
setup func()
363+
noBackupArchive bool
364+
wantError bool
365+
}{
366+
{
367+
name: "successful archive",
368+
noBackupArchive: false,
369+
},
370+
{
371+
name: "skip archive when disabled",
372+
noBackupArchive: true,
373+
},
374+
{
375+
name: "invalid archive directory permissions",
376+
setup: func() {
377+
// Create dir with no write permissions.
378+
err := os.MkdirAll(archiveDir, 0500)
379+
require.NoError(t, err)
380+
},
381+
noBackupArchive: false,
382+
wantError: true,
383+
},
384+
}
385+
386+
for _, tc := range tests {
387+
tc := tc
388+
t.Run(tc.name, func(t *testing.T) {
389+
defer os.RemoveAll(archiveDir)
390+
if tc.setup != nil {
391+
tc.setup()
392+
}
393+
394+
multiFile := NewMultiFile(
395+
backupFile, tc.noBackupArchive,
396+
)
397+
398+
err := multiFile.createArchiveFile()
399+
if tc.wantError {
400+
require.Error(t, err)
401+
return
402+
}
403+
404+
require.NoError(t, err)
405+
406+
// If archiving is disabled, verify no archive was
407+
// created.
408+
if tc.noBackupArchive {
409+
require.NoDirExists(t, archiveDir)
410+
return
411+
}
412+
413+
// Verify archive exists and content matches.
414+
files, err := os.ReadDir(archiveDir)
415+
require.NoError(t, err)
416+
require.Len(t, files, 1)
417+
418+
archivedContent, err := os.ReadFile(
419+
filepath.Join(archiveDir, files[0].Name()),
420+
)
421+
require.NoError(t, err)
422+
assertBackupMatches(t, backupFile, archivedContent)
423+
})
424+
}
425+
}

config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,8 @@ type Config struct {
360360
MaxPendingChannels int `long:"maxpendingchannels" description:"The maximum number of incoming pending channels permitted per peer."`
361361
BackupFilePath string `long:"backupfilepath" description:"The target location of the channel backup file"`
362362

363+
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."`
364+
363365
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"`
364366

365367
Bitcoin *lncfg.Chain `group:"Bitcoin" namespace:"bitcoin"`

docs/release-notes/release-notes-0.19.0.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@
7878
initial historical sync may be blocked due to a race condition in handling the
7979
syncer's internal state.
8080

81+
* Add support for [archiving channel backup](https://github.com/lightningnetwork/lnd/pull/9232)
82+
in a designated folder which allows for easy referencing in the future. A new
83+
config is added `disable-backup-archive`, with default set to false, to
84+
determine if previous channel backups should be archived or not.
85+
8186
## Functional Enhancements
8287
* [Add ability](https://github.com/lightningnetwork/lnd/pull/8998) to paginate
8388
wallet transactions.

sample-lnd.conf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,10 @@
309309
; Example:
310310
; backupfilepath=~/.lnd/data/chain/bitcoin/mainnet/channel.backup
311311

312+
; When false (default), old channel backups are archived to a designated location.
313+
; When true, old backups are simply replaced.
314+
; no-backup-archive=false
315+
312316
; The maximum capacity of the block cache in bytes. Increasing this will result
313317
; in more blocks being kept in memory but will increase performance when the
314318
; same block is required multiple times.

server.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1648,7 +1648,9 @@ func newServer(cfg *Config, listenAddrs []net.Addr,
16481648
chanNotifier: s.channelNotifier,
16491649
addrs: s.addrSource,
16501650
}
1651-
backupFile := chanbackup.NewMultiFile(cfg.BackupFilePath)
1651+
backupFile := chanbackup.NewMultiFile(
1652+
cfg.BackupFilePath, cfg.NoBackupArchive,
1653+
)
16521654
startingChans, err := chanbackup.FetchStaticChanBackups(
16531655
s.chanStateDB, s.addrSource,
16541656
)

0 commit comments

Comments
 (0)