Skip to content

Commit f93ce20

Browse files
authored
Add copy from repository in configuration (#486)
* Allow standard copy in addition to the "special case" resticprofile implements a "special case" for the copy command, which does not use the "from-"-prefix and reverses the copy direction. With this change the standard direction is used when the "from" prefix is explicitly used. * Use `from-repository` instead of `from-repository` This is consistent with the `init` section.
1 parent c7e87a1 commit f93ce20

File tree

3 files changed

+231
-66
lines changed

3 files changed

+231
-66
lines changed

config/profile.go

Lines changed: 116 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -344,96 +344,156 @@ func (s *ScheduleBaseSection) getScheduleConfig(p *Profile, command string) *Sch
344344
return s.scheduleConfig
345345
}
346346

347-
// CopySection contains the destination parameters for a copy command
347+
// CopySection contains the source or destination parameters for a copy command
348348
type CopySection struct {
349349
GenericSectionWithSchedule `mapstructure:",squash"`
350350
Initialize bool `mapstructure:"initialize" description:"Initialize the secondary repository if missing"`
351351
InitializeCopyChunkerParams maybe.Bool `mapstructure:"initialize-copy-chunker-params" default:"true" description:"Copy chunker parameters when initializing the secondary repository"`
352-
Repository ConfidentialValue `mapstructure:"repository" description:"Destination repository to copy snapshots to"`
353-
RepositoryFile string `mapstructure:"repository-file" description:"File from which to read the destination repository location to copy snapshots to"`
354-
PasswordFile string `mapstructure:"password-file" description:"File to read the destination repository password from"`
355-
PasswordCommand string `mapstructure:"password-command" description:"Shell command to obtain the destination repository password from"`
356-
KeyHint string `mapstructure:"key-hint" description:"Key ID of key to try decrypting the destination repository first"`
352+
FromRepository ConfidentialValue `mapstructure:"from-repository" argument:"from-repo" description:"Source repository to copy snapshots from"`
353+
FromRepositoryFile string `mapstructure:"from-repository-file" argument:"from-repository-file" description:"File from which to read the source repository location to copy snapshots from"`
354+
FromPasswordFile string `mapstructure:"from-password-file" argument:"from-password-file" description:"File to read the source repository password from"`
355+
FromPasswordCommand string `mapstructure:"from-password-command" argument:"from-password-command" description:"Shell command to obtain the source repository password from"`
356+
FromKeyHint string `mapstructure:"from-key-hint" argument:"from-key-hint" description:"Key ID of key to try decrypting the source repository first"`
357357
Snapshots []string `mapstructure:"snapshot" description:"Snapshot IDs to copy (if empty, all snapshots are copied)"`
358+
ToRepository ConfidentialValue `mapstructure:"repository" description:"Destination repository to copy snapshots to"`
359+
ToRepositoryFile string `mapstructure:"repository-file" description:"File from which to read the destination repository location to copy snapshots to"`
360+
ToPasswordFile string `mapstructure:"password-file" description:"File to read the destination repository password from"`
361+
ToPasswordCommand string `mapstructure:"password-command" description:"Shell command to obtain the destination repository password from"`
362+
ToKeyHint string `mapstructure:"key-hint" description:"Key ID of key to try decrypting the destination repository first"`
358363
}
359364

360365
func (s *CopySection) IsEmpty() bool { return s == nil }
361366

367+
func (s *CopySection) IsCopyTo() bool { return s.ToRepository.HasValue() || s.ToRepositoryFile != "" }
368+
362369
func (c *CopySection) resolve(p *Profile) {
363370
c.ScheduleBaseSection.resolve(p)
364371

365-
c.Repository.setValue(fixPath(c.Repository.Value(), expandEnv, expandUserHome))
372+
if c.IsCopyTo() {
373+
c.ToRepository.setValue(fixPath(c.ToRepository.Value(), expandEnv, expandUserHome))
374+
} else {
375+
c.FromRepository.setValue(fixPath(c.FromRepository.Value(), expandEnv, expandUserHome))
376+
}
366377
}
367378

368379
func (c *CopySection) setRootPath(p *Profile, rootPath string) {
369380
c.GenericSectionWithSchedule.setRootPath(p, rootPath)
370381

371-
c.PasswordFile = fixPath(c.PasswordFile, expandEnv, expandUserHome, absolutePrefix(rootPath))
372-
c.RepositoryFile = fixPath(c.RepositoryFile, expandEnv, expandUserHome, absolutePrefix(rootPath))
382+
if c.IsCopyTo() {
383+
c.ToPasswordFile = fixPath(c.ToPasswordFile, expandEnv, expandUserHome, absolutePrefix(rootPath))
384+
c.ToRepositoryFile = fixPath(c.ToRepositoryFile, expandEnv, expandUserHome, absolutePrefix(rootPath))
385+
} else {
386+
c.FromPasswordFile = fixPath(c.FromPasswordFile, expandEnv, expandUserHome, absolutePrefix(rootPath))
387+
c.FromRepositoryFile = fixPath(c.FromRepositoryFile, expandEnv, expandUserHome, absolutePrefix(rootPath))
388+
}
373389
}
374390

375391
func (s *CopySection) getInitFlags(profile *Profile) *shell.Args {
376392
var init *InitSection
377393

378-
if s.InitializeCopyChunkerParams.IsTrueOrUndefined() {
379-
// Source repo for CopyChunkerParams
380-
init = &InitSection{
381-
CopyChunkerParams: true,
382-
FromKeyHint: profile.KeyHint,
383-
FromRepository: profile.Repository,
384-
FromRepositoryFile: profile.RepositoryFile,
385-
FromPasswordFile: profile.PasswordFile,
386-
FromPasswordCommand: profile.PasswordCommand,
387-
}
388-
init.OtherFlags = profile.OtherFlags
389-
} else {
390-
init = new(InitSection)
391-
}
394+
if s.IsCopyTo() {
395+
if s.InitializeCopyChunkerParams.IsTrueOrUndefined() {
396+
// Source repo for CopyChunkerParams
397+
init = &InitSection{
398+
CopyChunkerParams: true,
399+
FromKeyHint: profile.KeyHint,
400+
FromRepository: profile.Repository,
401+
FromRepositoryFile: profile.RepositoryFile,
402+
FromPasswordFile: profile.PasswordFile,
403+
FromPasswordCommand: profile.PasswordCommand,
404+
}
405+
init.OtherFlags = profile.OtherFlags
406+
} else {
407+
init = new(InitSection)
408+
}
392409

393-
// Repo that should be initialized
394-
ip := *profile
395-
ip.KeyHint = s.KeyHint
396-
ip.Repository = s.Repository
397-
ip.RepositoryFile = s.RepositoryFile
398-
ip.PasswordFile = s.PasswordFile
399-
ip.PasswordCommand = s.PasswordCommand
400-
ip.OtherFlags = s.OtherFlags
410+
// Repo that should be initialized
411+
ip := *profile
412+
ip.KeyHint = s.ToKeyHint
413+
ip.Repository = s.ToRepository
414+
ip.RepositoryFile = s.ToRepositoryFile
415+
ip.PasswordFile = s.ToPasswordFile
416+
ip.PasswordCommand = s.ToPasswordCommand
417+
ip.OtherFlags = s.OtherFlags
418+
return init.getCommandFlags(&ip)
419+
} else {
420+
if s.InitializeCopyChunkerParams.IsTrueOrUndefined() {
421+
// Source repo for CopyChunkerParams
422+
init = &InitSection{
423+
CopyChunkerParams: true,
424+
FromKeyHint: s.FromKeyHint,
425+
FromRepository: s.FromRepository,
426+
FromRepositoryFile: s.FromRepositoryFile,
427+
FromPasswordFile: s.FromPasswordFile,
428+
FromPasswordCommand: s.FromPasswordCommand,
429+
}
430+
init.OtherFlags = profile.OtherFlags
431+
} else {
432+
init = new(InitSection)
433+
}
401434

402-
return init.getCommandFlags(&ip)
435+
// Repo that should be initialized
436+
return init.getCommandFlags(profile)
437+
}
403438
}
404439

405440
func (s *CopySection) getCommandFlags(profile *Profile) (flags *shell.Args) {
406-
repositoryArgs := map[string]string{
407-
constants.ParameterRepository: s.Repository.Value(),
408-
constants.ParameterRepositoryFile: s.RepositoryFile,
409-
constants.ParameterPasswordFile: s.PasswordFile,
410-
constants.ParameterPasswordCommand: s.PasswordCommand,
411-
constants.ParameterKeyHint: s.KeyHint,
412-
}
441+
if s.IsCopyTo() {
442+
repositoryArgs := map[string]string{
443+
constants.ParameterRepository: s.ToRepository.Value(),
444+
constants.ParameterRepositoryFile: s.ToRepositoryFile,
445+
constants.ParameterPasswordFile: s.ToPasswordFile,
446+
constants.ParameterPasswordCommand: s.ToPasswordCommand,
447+
constants.ParameterKeyHint: s.ToKeyHint,
448+
}
413449

414-
// Handle confidential repo in flags
415-
restore := profile.replaceWithRepositoryFile(&s.Repository, &s.RepositoryFile, "-to")
416-
defer restore()
450+
// Handle confidential repo in flags
451+
restore := profile.replaceWithRepositoryFile(&s.ToRepository, &s.ToRepositoryFile, "-to")
452+
defer restore()
417453

418-
flags = profile.GetCommonFlags()
419-
addArgsFromStruct(flags, s)
420-
addArgsFromOtherFlags(flags, profile, s)
454+
flags = profile.GetCommonFlags()
455+
addArgsFromStruct(flags, s)
456+
addArgsFromOtherFlags(flags, profile, s)
421457

422-
if v := profile.resticVersion; v == nil || v.LessThan(resticVersion14) {
423-
// restic < 0.14: repo2, password-file2, etc. is the destination, repo, password-file, etc. the source
424-
for name, value := range repositoryArgs {
425-
if len(value) > 0 {
426-
flags.AddFlag(fmt.Sprintf("%s2", name), shell.NewArg(value, shell.ArgConfigEscape))
458+
if v := profile.resticVersion; v == nil || v.LessThan(resticVersion14) {
459+
// restic < 0.14: repo2, password-file2, etc. is the destination, repo, password-file, etc. the source
460+
for name, value := range repositoryArgs {
461+
if len(value) > 0 {
462+
flags.AddFlag(fmt.Sprintf("%s2", name), shell.NewArg(value, shell.ArgConfigEscape))
463+
}
464+
}
465+
} else {
466+
// restic >= 0.14: from-repo, from-password-file, etc. is the source, repo, password-file, etc. the destination
467+
for name := range maps.Keys(repositoryArgs) {
468+
flags.Rename(name, fmt.Sprintf("from-%s", name))
469+
}
470+
for name, value := range repositoryArgs {
471+
if len(value) > 0 {
472+
flags.AddFlag(name, shell.NewArg(value, shell.ArgConfigEscape))
473+
}
427474
}
428475
}
429476
} else {
430-
// restic >= 0.14: from-repo, from-password-file, etc. is the source, repo, password-file, etc. the destination
431-
for name := range maps.Keys(repositoryArgs) {
432-
flags.Rename(name, fmt.Sprintf("from-%s", name))
477+
legacyArgs := map[string]string{
478+
"from-repo": "repo2",
479+
"from-repository-file": "repository-file2",
480+
"from-password-file": "password-file2",
481+
"from-password-command": "password-command2",
482+
"from-key-hint": "key-hint2",
433483
}
434-
for name, value := range repositoryArgs {
435-
if len(value) > 0 {
436-
flags.AddFlag(name, shell.NewArg(value, shell.ArgConfigEscape))
484+
485+
// Handle confidential repo in flags
486+
restore := profile.replaceWithRepositoryFile(&s.FromRepository, &s.FromRepositoryFile, "-from")
487+
defer restore()
488+
489+
flags = profile.GetCommonFlags()
490+
addArgsFromStruct(flags, s)
491+
addArgsFromOtherFlags(flags, profile, s)
492+
493+
if v := profile.resticVersion; v == nil || v.LessThan(resticVersion14) {
494+
// restic < 0.14: from-repo => repo2, from-password-file => password-file2, etc.
495+
for name, legacyName := range legacyArgs {
496+
flags.Rename(name, legacyName)
437497
}
438498
}
439499
}

config/profile_test.go

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ func TestEnvironmentInProfileRepo(t *testing.T) {
313313
profile.ResolveConfiguration()
314314
assert.Equal(t, repoPath, filepath.ToSlash(profile.Repository.Value()))
315315
assert.Equal(t, repoPath, filepath.ToSlash(profile.Init.FromRepository.Value()))
316-
assert.Equal(t, repoPath, filepath.ToSlash(profile.Copy.Repository.Value()))
316+
assert.Equal(t, repoPath, filepath.ToSlash(profile.Copy.ToRepository.Value()))
317317

318318
profile.SetRootPath("any")
319319
assert.Equal(t, repoPath+".key", filepath.ToSlash(profile.PasswordFile))
@@ -398,7 +398,7 @@ files-from-verbatim = "include-verbatim"
398398
exclude = "exclude"
399399
iexclude = "iexclude"
400400
[` + prefix + `profile.copy]
401-
password-file = "key"
401+
from-password-file = "key"
402402
[` + prefix + `profile.dump]
403403
password-file = "key"
404404
[` + prefix + `profile.init]
@@ -433,7 +433,7 @@ from-password-file = "key"
433433
assert.ElementsMatch(t, []string{"/wd/include-verbatim"}, profile.Backup.FilesFromVerbatim)
434434
assert.ElementsMatch(t, []string{"exclude"}, profile.Backup.Exclude)
435435
assert.ElementsMatch(t, []string{"iexclude"}, profile.Backup.Iexclude)
436-
assert.Equal(t, "/wd/key", profile.Copy.PasswordFile)
436+
assert.Equal(t, "/wd/key", profile.Copy.FromPasswordFile)
437437
assert.Equal(t, []string{"/wd/key"}, profile.OtherSections[constants.CommandDump].OtherFlags["password-file"])
438438
assert.Equal(t, "/wd/key", profile.Init.FromPasswordFile)
439439
assert.Equal(t, "/wd/key", profile.Init.FromRepositoryFile)
@@ -1503,11 +1503,116 @@ func TestGetInitStructFields(t *testing.T) {
15031503

15041504
func TestGetCopyStructFields(t *testing.T) {
15051505
copySection := &CopySection{
1506-
Repository: NewConfidentialValue("dest-repo"),
1507-
RepositoryFile: "dest-repo-file",
1508-
PasswordFile: "dest-pw-file",
1509-
PasswordCommand: "dest-pw-command",
1510-
KeyHint: "dest-key-hint",
1506+
FromRepository: NewConfidentialValue("src-repo"),
1507+
FromRepositoryFile: "src-repo-file",
1508+
FromPasswordFile: "src-pw-file",
1509+
FromPasswordCommand: "src-pw-command",
1510+
FromKeyHint: "src-key-hint",
1511+
}
1512+
1513+
copySection.OtherFlags = map[string]any{"option": "opt=src"}
1514+
1515+
profile := NewProfile(nil, "")
1516+
profile.Repository = NewConfidentialValue("dest-repo")
1517+
profile.RepositoryFile = "dest-repo-file"
1518+
profile.PasswordFile = "dest-pw-file"
1519+
profile.PasswordCommand = "dest-pw-command"
1520+
profile.KeyHint = "dest-key-hint"
1521+
1522+
profile.OtherFlags = map[string]any{"option": "opt=dest"}
1523+
1524+
t.Run("restic<14", func(t *testing.T) {
1525+
require.NoError(t, profile.SetResticVersion(""))
1526+
1527+
// copy
1528+
assert.Equal(t, map[string][]string{
1529+
"key-hint2": {"src-key-hint"},
1530+
"repo2": {"src-repo"},
1531+
"repository-file2": {"src-repo-file"},
1532+
"password-file2": {"src-pw-file"},
1533+
"password-command2": {"src-pw-command"},
1534+
1535+
"option": {"opt=src"}, // TODO: flags should be partitioned (both options are required)
1536+
1537+
"key-hint": {"dest-key-hint"},
1538+
"repo": {"dest-repo"},
1539+
"repository-file": {"dest-repo-file"},
1540+
"password-file": {"dest-pw-file"},
1541+
"password-command": {"dest-pw-command"},
1542+
}, copySection.getCommandFlags(profile).ToMap())
1543+
1544+
// init
1545+
assert.Equal(t, map[string][]string{
1546+
"copy-chunker-params": {},
1547+
"key-hint2": {"src-key-hint"},
1548+
"repo2": {"src-repo"},
1549+
"repository-file2": {"src-repo-file"},
1550+
"password-file2": {"src-pw-file"},
1551+
"password-command2": {"src-pw-command"},
1552+
1553+
"option": {"opt=dest"}, // TODO: flags should be partitioned (both options are required)
1554+
1555+
"key-hint": {"dest-key-hint"},
1556+
"repo": {"dest-repo"},
1557+
"repository-file": {"dest-repo-file"},
1558+
"password-file": {"dest-pw-file"},
1559+
"password-command": {"dest-pw-command"},
1560+
}, copySection.getInitFlags(profile).ToMap())
1561+
})
1562+
1563+
t.Run("restic>=14", func(t *testing.T) {
1564+
require.NoError(t, profile.SetResticVersion(resticVersion14.Original()))
1565+
1566+
// copy
1567+
assert.Equal(t, map[string][]string{
1568+
"from-key-hint": {"src-key-hint"},
1569+
"from-repo": {"src-repo"},
1570+
"from-repository-file": {"src-repo-file"},
1571+
"from-password-file": {"src-pw-file"},
1572+
"from-password-command": {"src-pw-command"},
1573+
1574+
"option": {"opt=src"}, // TODO: flags should be partitioned (both options are required)
1575+
1576+
"key-hint": {"dest-key-hint"},
1577+
"repo": {"dest-repo"},
1578+
"repository-file": {"dest-repo-file"},
1579+
"password-file": {"dest-pw-file"},
1580+
"password-command": {"dest-pw-command"},
1581+
}, copySection.getCommandFlags(profile).ToMap())
1582+
1583+
// init
1584+
assert.Equal(t, map[string][]string{
1585+
"copy-chunker-params": {},
1586+
"from-key-hint": {"src-key-hint"},
1587+
"from-repo": {"src-repo"},
1588+
"from-repository-file": {"src-repo-file"},
1589+
"from-password-file": {"src-pw-file"},
1590+
"from-password-command": {"src-pw-command"},
1591+
1592+
"option": {"opt=dest"}, // TODO: flags should be partitioned (both options are required)
1593+
1594+
"key-hint": {"dest-key-hint"},
1595+
"repo": {"dest-repo"},
1596+
"repository-file": {"dest-repo-file"},
1597+
"password-file": {"dest-pw-file"},
1598+
"password-command": {"dest-pw-command"},
1599+
}, copySection.getInitFlags(profile).ToMap())
1600+
})
1601+
1602+
t.Run("get-init-flags-from-profile", func(t *testing.T) {
1603+
assert.Nil(t, profile.GetCopyInitializeFlags())
1604+
profile.Copy = copySection
1605+
assert.Equal(t, copySection.getInitFlags(profile).GetAll(), profile.GetCopyInitializeFlags().GetAll())
1606+
})
1607+
}
1608+
1609+
func TestGetCopyToStructFields(t *testing.T) {
1610+
copySection := &CopySection{
1611+
ToRepository: NewConfidentialValue("dest-repo"),
1612+
ToRepositoryFile: "dest-repo-file",
1613+
ToPasswordFile: "dest-pw-file",
1614+
ToPasswordCommand: "dest-pw-command",
1615+
ToKeyHint: "dest-key-hint",
15111616
}
15121617

15131618
copySection.OtherFlags = map[string]any{"option": "opt=dest"}

wrapper_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,8 +1630,8 @@ func TestRunInitCopyCommand(t *testing.T) {
16301630
PasswordFile: "password_origin",
16311631
Copy: &config.CopySection{
16321632
InitializeCopyChunkerParams: copyChunkerParams,
1633-
Repository: config.NewConfidentialValue("repo_copy"),
1634-
PasswordFile: "password_copy",
1633+
ToRepository: config.NewConfidentialValue("repo_copy"),
1634+
ToPasswordFile: "password_copy",
16351635
},
16361636
}
16371637
require.NoError(t, p.SetResticVersion(resticVersion))

0 commit comments

Comments
 (0)