Skip to content

Commit c65ea27

Browse files
committed
Laying out snapshot pruning functionality
1 parent f9b040a commit c65ea27

File tree

6 files changed

+240
-2
lines changed

6 files changed

+240
-2
lines changed

README.md

+12
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ rsync:
175175
#override_global_excluded: true
176176
#override_global_args: true
177177
178+
# FIXME needs more details (
179+
retention:
180+
daily: int # number of daily backups to keep
181+
weekly: int # number of weekly backups to keep
182+
monthly: int # number of monthly backups to keep
183+
yearly: int # number of yearly backups to keep
184+
178185
# Inline scripts executed on the remote host before and after rsyncing,
179186
# and before any `pre.*.sh` and/or `post.*.sh` scripts for this host.
180187
pre_script: string
@@ -222,6 +229,11 @@ rsync:
222229
- "--hard-links"
223230
- "--block-size=2048"
224231
- "--recursive"
232+
retention:
233+
daily: 14
234+
weekly: 4
235+
monthly: 6
236+
yearly: 5
225237
```
226238

227239
# Copyright

app/prune.go

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package app
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
"time"
8+
9+
"github.com/digineo/zackup/config"
10+
"github.com/sirupsen/logrus"
11+
)
12+
13+
var (
14+
patterns = map[string]string{
15+
"daily": "2006-01-02",
16+
"weekly": "", // See special case in keepers()
17+
"monthly": "2006-01",
18+
"yearly": "2006",
19+
}
20+
)
21+
22+
type snapshot struct {
23+
Ds string // Snapshot dataset name "backups/foo@RFC3339"
24+
Time time.Time // Parsed timestamp from the dataset name
25+
}
26+
27+
func PruneSnapshots(job *config.JobConfig) {
28+
var host = job.Host()
29+
30+
// Set defaults if config is not set
31+
if job.Retention == nil {
32+
job.Retention = &config.RetentionConfig{
33+
Daily: 100000,
34+
Weekly: 100000,
35+
Monthly: 100000,
36+
Yearly: 100000,
37+
}
38+
}
39+
40+
// This catches any gaps in the config
41+
if job.Retention.Daily == 0 {
42+
job.Retention.Daily = 100000
43+
}
44+
if job.Retention.Weekly == 0 {
45+
job.Retention.Weekly = 100000
46+
}
47+
if job.Retention.Monthly == 0 {
48+
job.Retention.Monthly = 100000
49+
}
50+
if job.Retention.Yearly == 0 {
51+
job.Retention.Yearly = 100000
52+
}
53+
54+
// FIXME probably should iterate over a list instead here
55+
for _, snapshot := range listKeepers(host, "daily", job.Retention.Daily) {
56+
log.WithFields(logrus.Fields{
57+
"snapshot": snapshot,
58+
"period": "daily",
59+
}).Debug("keeping snapshot")
60+
}
61+
for _, snapshot := range listKeepers(host, "weekly", job.Retention.Weekly) {
62+
log.WithFields(logrus.Fields{
63+
"snapshot": snapshot,
64+
"period": "weekly",
65+
}).Debug("keeping snapshot")
66+
}
67+
for _, snapshot := range listKeepers(host, "monthly", job.Retention.Monthly) {
68+
log.WithFields(logrus.Fields{
69+
"snapshot": snapshot,
70+
"period": "monthly",
71+
}).Debug("keeping snapshot")
72+
}
73+
for _, snapshot := range listKeepers(host, "yearly", job.Retention.Yearly) {
74+
log.WithFields(logrus.Fields{
75+
"snapshot": snapshot,
76+
"period": "yearly",
77+
}).Debug("keeping snapshot")
78+
}
79+
80+
// TODO subtract keepers from the list of snapshots and rm -rf them
81+
}
82+
83+
// listKeepers returns a list of snapshot that are not subject to deletion
84+
// for a given host, pattern, and keep_count.
85+
func listKeepers(host string, pattern string, keep_count uint) []snapshot {
86+
var keepers []snapshot
87+
var last string
88+
89+
for _, snapshot := range listSnapshots(host) {
90+
var period string
91+
92+
// Weekly is special because golang doesn't have support for "week number in year"
93+
// in Time.Format strings.
94+
if pattern == "weekly" {
95+
year, week := snapshot.Time.Local().ISOWeek()
96+
period = fmt.Sprintf("%d-%d", year, week)
97+
} else {
98+
period = snapshot.Time.Local().Format(patterns[pattern])
99+
}
100+
101+
if period != last {
102+
last = period
103+
keepers = append(keepers, snapshot)
104+
105+
if uint(len(keepers)) == keep_count {
106+
break
107+
}
108+
}
109+
}
110+
111+
return keepers
112+
}
113+
114+
// listSnapshots calls out to ZFS for a list of snapshots for a given host.
115+
// Returned data will be sorted by time, most recent first.
116+
func listSnapshots(host string) []snapshot {
117+
var snapshots []snapshot
118+
119+
ds := newDataset(host)
120+
121+
args := []string{
122+
"list",
123+
"-H", // no field headers in output
124+
"-o", "name", // only name field
125+
"-t", "snapshot", // type snapshot
126+
ds.Name,
127+
}
128+
o, e, err := execProgram("zfs", args...)
129+
if err != nil {
130+
f := appendStdlogs(logrus.Fields{
131+
logrus.ErrorKey: err,
132+
"prefix": "zfs",
133+
"command": append([]string{"zfs"}, args...),
134+
}, o, e)
135+
log.WithFields(f).Errorf("executing zfs list failed")
136+
}
137+
138+
for _, ss := range strings.Fields(o.String()) {
139+
ts, err := time.Parse(time.RFC3339, strings.Split(ss, "@")[1])
140+
141+
if err != nil {
142+
log.WithField("snapshot", ss).Error("Unable to parse timestamp from snapshot")
143+
continue
144+
}
145+
146+
snapshots = append(snapshots, snapshot{
147+
Ds: ss,
148+
Time: ts,
149+
})
150+
}
151+
152+
// ZFS list _should_ be in chronological order but just in case ...
153+
sort.Slice(snapshots, func(i, j int) bool {
154+
return snapshots[i].Time.After(snapshots[j].Time)
155+
})
156+
157+
return snapshots
158+
}

cmd/prune.go

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cmd
2+
3+
import (
4+
"github.com/digineo/zackup/app"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
// pruneCmd represents the prune command
9+
var pruneCmd = &cobra.Command{
10+
Use: "prune [host [...]]",
11+
Short: "Prunes backups per-host ZFS dataset",
12+
Run: func(cmd *cobra.Command, args []string) {
13+
if len(args) == 0 {
14+
args = tree.Hosts()
15+
}
16+
17+
for _, host := range args {
18+
job := tree.Host(host)
19+
if job == nil {
20+
log.WithField("prune", host).Warn("unknown host, ignoring")
21+
continue
22+
}
23+
24+
app.PruneSnapshots(job)
25+
}
26+
},
27+
}
28+
29+
func init() {
30+
rootCmd.AddCommand(pruneCmd)
31+
}

config/job.go

+31-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ package config
44
type JobConfig struct {
55
host string
66

7-
SSH *SSHConfig `yaml:"ssh"`
8-
RSync *RsyncConfig `yaml:"rsync"`
7+
SSH *SSHConfig `yaml:"ssh"`
8+
RSync *RsyncConfig `yaml:"rsync"`
9+
Retention *RetentionConfig `yaml:"retention"`
910

1011
PreScript Script `yaml:"pre_script"` // from yaml file
1112
PostScript Script `yaml:"post_script"` // from yaml file
@@ -18,6 +19,14 @@ type SSHConfig struct {
1819
Timeout *uint `yaml:"timeout"` // number of seconds, defaults to 15
1920
}
2021

22+
// RetentionConfig holds backup retention periods
23+
type RetentionConfig struct {
24+
Daily uint `yaml:"daily"` // defaults to 1000000
25+
Weekly uint `yaml:"weekly"` // defaults to 1000000
26+
Monthly uint `yaml:"monthly"` // defaults to 1000000
27+
Yearly uint `yaml:"yearly"` // defaults to 1000000
28+
}
29+
2130
// Host returns the hostname for this job.
2231
func (j *JobConfig) Host() string {
2332
return j.host
@@ -59,6 +68,26 @@ func (j *JobConfig) mergeGlobals(globals *JobConfig) {
5968
}
6069
}
6170

71+
if globals.Retention != nil {
72+
if j.Retention == nil {
73+
dup := *globals.Retention
74+
j.Retention = &dup
75+
} else {
76+
if j.Retention.Daily == 0 {
77+
j.Retention.Daily = globals.Retention.Daily
78+
}
79+
if j.Retention.Weekly == 0 {
80+
j.Retention.Weekly = globals.Retention.Weekly
81+
}
82+
if j.Retention.Monthly == 0 {
83+
j.Retention.Monthly = globals.Retention.Monthly
84+
}
85+
if j.Retention.Yearly == 0 {
86+
j.Retention.Yearly = globals.Retention.Yearly
87+
}
88+
}
89+
}
90+
6291
// globals.PreScript
6392
j.PreScript.inline = append(globals.PreScript.inline, j.PreScript.inline...)
6493
j.PreScript.scripts = append(globals.PreScript.scripts, j.PreScript.scripts...)

go.mod

+2
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ require (
3535
gopkg.in/gemnasium/logrus-graylog-hook.v2 v2.0.7
3636
gopkg.in/yaml.v2 v2.2.2
3737
)
38+
39+
go 1.13

testdata/globals.yml

+6
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ ssh:
44
port: 22
55
identity_file: /etc/zackup/id_rsa.pub
66

7+
retention:
8+
daily: 14
9+
weekly: 4
10+
monthly: 6
11+
yearly: 5
12+
713
rsync:
814
included:
915
- /etc

0 commit comments

Comments
 (0)