Skip to content

Commit af33c0a

Browse files
committedNov 8, 2018
Move to more robust backup manager
1 parent 212d457 commit af33c0a

File tree

10 files changed

+505
-35
lines changed

10 files changed

+505
-35
lines changed
 

‎.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11

22
vendor/
3+
4+
# Don't checking user's local environment file which will be used for storing
5+
# configuration unique to their enviornment.
6+
.env

‎Gopkg.lock

+21-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎README.md

+6
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,9 @@ Finally, you can create the lambda function like so:
5959
```
6060
make create-lambda-ebs-backup
6161
```
62+
63+
### Tagging Volumes for backup
64+
65+
In order to backup a volume, you must opt in by setting a tag key:value pair on
66+
the volume. By default, this is `lambda-ebs-backup:true`. This tells the lambda
67+
function that we are to create a snapshot of the volume.

‎cloudformation/iam.yaml

+4-1
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ Resources:
3030
Statement:
3131
- Effect: Allow
3232
Action:
33-
- "ec2:CreateSnapshot"
33+
- "ec2:DescribeInstances"
34+
- "ec2:DescribeImages"
3435
- "ec2:DescribeVolumes"
36+
- "ec2:CreateImage"
37+
- "ec2:CreateSnapshot"
3538
Resource: "*"
3639
RoleName: lambda-ebs-backup
3740
Outputs:

‎cmd/lambda-ebs-backup/main.go

+13-30
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,11 @@ package main
22

33
import (
44
"context"
5-
"fmt"
65

76
"github.com/aws/aws-lambda-go/lambda"
8-
"github.com/aws/aws-sdk-go/aws"
97
"github.com/aws/aws-sdk-go/aws/session"
108
"github.com/aws/aws-sdk-go/service/ec2"
11-
"github.com/c2fo/lambda-ebs-backup/pkg/config"
9+
"github.com/c2fo/lambda-ebs-backup/pkg/backup"
1210
)
1311

1412
// HandleRequest handles the lambda request
@@ -22,40 +20,25 @@ func HandleRequest(ctx context.Context) error {
2220
)
2321

2422
ec2Client := ec2.New(sess)
25-
26-
params := &ec2.DescribeVolumesInput{
27-
Filters: []*ec2.Filter{
28-
&ec2.Filter{
29-
Name: aws.String(fmt.Sprintf("tag:%s", config.BackupTagKey())),
30-
Values: []*string{aws.String(config.BackupTagValue())},
31-
},
32-
},
33-
MaxResults: aws.Int64(500),
23+
opts, err := backup.NewManagerOptsFromConfig(ec2Client)
24+
if err != nil {
25+
return err
3426
}
3527

36-
fmt.Printf("Searching for volumes with tag %s:%s\n", config.BackupTagKey(), config.BackupTagValue())
37-
err := ec2Client.DescribeVolumesPages(params,
38-
func(page *ec2.DescribeVolumesOutput, lastPage bool) bool {
39-
for _, v := range page.Volumes {
40-
fmt.Printf("Found %s\n", aws.StringValue(v.VolumeId))
41-
snap, snapErr := ec2Client.CreateSnapshot(&ec2.CreateSnapshotInput{
42-
VolumeId: v.VolumeId,
43-
})
44-
if snapErr != nil {
45-
fmt.Printf("Err: %s\n", snapErr)
46-
}
47-
fmt.Printf("Created snapshot '%s' for volume '%s'\n",
48-
aws.StringValue(snap.SnapshotId),
49-
aws.StringValue(v.VolumeId),
50-
)
51-
}
52-
return !lastPage
53-
})
28+
backupManager, err := backup.NewManager(opts)
29+
if err != nil {
30+
return err
31+
}
5432

33+
err = backupManager.Search()
5534
if err != nil {
5635
return err
5736
}
5837

38+
if err = backupManager.Backup(); err != nil {
39+
return err
40+
}
41+
5942
return nil
6043
}
6144

‎pkg/backup/manager.go

+313
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
package backup
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"sync"
7+
"text/template"
8+
9+
"github.com/aws/aws-sdk-go/aws"
10+
"github.com/aws/aws-sdk-go/service/ec2"
11+
"github.com/c2fo/lambda-ebs-backup/pkg/config"
12+
"github.com/c2fo/lambda-ebs-backup/pkg/utils"
13+
)
14+
15+
// ManagerOpts are options to configure the backup manager
16+
type ManagerOpts struct {
17+
Client *ec2.EC2
18+
19+
BackupTagKey string
20+
BackupTagValue string
21+
ImageTagKey string
22+
ImageTagValue string
23+
ImageNameTag string
24+
25+
DefaultImageNameTemplate *template.Template
26+
DefaultMaxKeepImages int
27+
28+
Verbose bool
29+
}
30+
31+
// NewManagerOptsFromConfig creates and initializes a new set of options from
32+
// the config.
33+
func NewManagerOptsFromConfig(client *ec2.EC2) (*ManagerOpts, error) {
34+
var err error
35+
opts := &ManagerOpts{
36+
Client: client,
37+
BackupTagKey: config.BackupTagKey(),
38+
BackupTagValue: config.BackupTagValue(),
39+
ImageTagKey: config.ImageTagKey(),
40+
ImageTagValue: config.ImageTagValue(),
41+
ImageNameTag: config.ImageNameTag(),
42+
Verbose: true,
43+
}
44+
45+
opts.DefaultImageNameTemplate, err = template.New("default-image-name").Parse(config.DefaultImageNameFormat())
46+
if err != nil {
47+
return opts, err
48+
}
49+
50+
opts.DefaultMaxKeepImages, err = config.DefaultMaxKeepImages()
51+
return opts, err
52+
}
53+
54+
// Manager manages backups/images of ec2 resources(volumes, instances, etc.)
55+
type Manager struct {
56+
client *ec2.EC2
57+
58+
volumes []*ec2.Volume
59+
instances []*ec2.Instance
60+
61+
BackupTagKey string
62+
BackupTagValue string
63+
ImageTagKey string
64+
ImageTagValue string
65+
ImageNameTag string
66+
MaxKeepImagesTag string
67+
68+
DefaultImageNameTemplate *template.Template
69+
DefaultMaxKeepImages int
70+
71+
Verbose bool
72+
}
73+
74+
// NewManager creates a new backup manager from the provided options
75+
func NewManager(opts *ManagerOpts) (*Manager, error) {
76+
m := &Manager{
77+
volumes: make([]*ec2.Volume, 0),
78+
instances: make([]*ec2.Instance, 0),
79+
}
80+
m.client = opts.Client
81+
m.BackupTagKey = opts.BackupTagKey
82+
m.BackupTagValue = opts.BackupTagValue
83+
m.ImageTagKey = opts.ImageTagKey
84+
m.ImageTagValue = opts.ImageTagValue
85+
m.ImageNameTag = opts.ImageNameTag
86+
m.Verbose = opts.Verbose
87+
if opts.DefaultImageNameTemplate == nil {
88+
return nil, fmt.Errorf("DefaultImageNameTemplate is a required field for ManagerOpts")
89+
}
90+
91+
m.DefaultImageNameTemplate = opts.DefaultImageNameTemplate
92+
m.DefaultMaxKeepImages = opts.DefaultMaxKeepImages
93+
return m, nil
94+
}
95+
96+
// Search searches for a volumes and instances to backup
97+
func (m *Manager) Search() error {
98+
return m.all(
99+
[]func() error{
100+
m.searchVolumes,
101+
m.searchInstances,
102+
},
103+
)
104+
}
105+
106+
func (m *Manager) searchVolumes() error {
107+
params := &ec2.DescribeVolumesInput{
108+
Filters: []*ec2.Filter{
109+
&ec2.Filter{
110+
Name: aws.String(fmt.Sprintf("tag:%s", m.BackupTagKey)),
111+
Values: []*string{aws.String(m.BackupTagValue)},
112+
},
113+
},
114+
MaxResults: aws.Int64(500),
115+
}
116+
117+
m.logf("Searching for volumes with tag %s:%s\n", m.BackupTagKey, m.BackupTagValue)
118+
119+
return m.client.DescribeVolumesPages(params,
120+
func(page *ec2.DescribeVolumesOutput, lastPage bool) bool {
121+
for _, v := range page.Volumes {
122+
m.volumes = append(m.volumes, v)
123+
}
124+
return !lastPage
125+
})
126+
}
127+
128+
func (m *Manager) searchInstances() error {
129+
params := &ec2.DescribeInstancesInput{
130+
Filters: []*ec2.Filter{
131+
&ec2.Filter{
132+
Name: aws.String(fmt.Sprintf("tag:%s", m.ImageTagKey)),
133+
Values: []*string{aws.String(m.ImageTagValue)},
134+
},
135+
},
136+
MaxResults: aws.Int64(500),
137+
}
138+
139+
m.logf("Searching for instances with tag %s:%s\n", m.ImageTagKey, m.ImageTagValue)
140+
141+
return m.client.DescribeInstancesPages(params,
142+
func(page *ec2.DescribeInstancesOutput, lastPage bool) bool {
143+
for _, r := range page.Reservations {
144+
for _, i := range r.Instances {
145+
tags := utils.TagSliceToMap(i.Tags)
146+
m.logf(
147+
"Found instance %s(%s) with matching tag\n",
148+
aws.StringValue(i.InstanceId),
149+
tags.GetDefault("Name", "<no value>"),
150+
)
151+
m.instances = append(m.instances, i)
152+
}
153+
}
154+
return !lastPage
155+
})
156+
}
157+
158+
// Backup performs the necessary backups
159+
func (m *Manager) Backup() error {
160+
return m.all(
161+
[]func() error{
162+
m.backupVolumes,
163+
m.backupInstances,
164+
},
165+
)
166+
}
167+
168+
func (m *Manager) backupVolumes() error {
169+
var wg sync.WaitGroup
170+
errorChan := make(chan error, 1)
171+
172+
for _, volume := range m.volumes {
173+
wg.Add(1)
174+
go func(v *ec2.Volume) {
175+
defer wg.Done()
176+
snap, err := m.client.CreateSnapshot(&ec2.CreateSnapshotInput{
177+
VolumeId: v.VolumeId,
178+
})
179+
if err != nil {
180+
m.logf("Error creating snapshot for volume '%s'\n", aws.StringValue(v.VolumeId))
181+
errorChan <- err
182+
} else {
183+
m.logf("Created snapshot '%s' for volume '%s'\n",
184+
aws.StringValue(snap.SnapshotId),
185+
aws.StringValue(v.VolumeId),
186+
)
187+
}
188+
}(volume)
189+
}
190+
191+
wg.Wait()
192+
193+
select {
194+
case err := <-errorChan:
195+
return err
196+
default:
197+
}
198+
199+
return nil
200+
}
201+
202+
func (m *Manager) backupInstances() error {
203+
var wg sync.WaitGroup
204+
errorChan := make(chan error, 1)
205+
206+
for _, instance := range m.instances {
207+
wg.Add(1)
208+
go func(i *ec2.Instance) {
209+
defer wg.Done()
210+
tags := utils.TagSliceToMap(i.Tags)
211+
imageName, err := m.formatImageName(i)
212+
if err != nil {
213+
errorChan <- err
214+
return
215+
}
216+
217+
image, err := m.client.CreateImage(&ec2.CreateImageInput{
218+
InstanceId: i.InstanceId,
219+
Name: aws.String(imageName),
220+
})
221+
if err != nil {
222+
m.logf(
223+
"Error creating image for instance %s(%s): %s\n",
224+
aws.StringValue(i.InstanceId),
225+
tags.GetDefault("Name", ""),
226+
err,
227+
)
228+
errorChan <- err
229+
return
230+
}
231+
232+
m.logf("Created image '%s'(%s) for instance '%s'(%s)\n",
233+
aws.StringValue(image.ImageId),
234+
imageName,
235+
aws.StringValue(i.InstanceId),
236+
tags.GetDefault("Name", ""),
237+
)
238+
}(instance)
239+
}
240+
241+
wg.Wait()
242+
243+
select {
244+
case err := <-errorChan:
245+
return err
246+
default:
247+
}
248+
249+
return nil
250+
}
251+
252+
func (m *Manager) all(funcs []func() error) error {
253+
var wg sync.WaitGroup
254+
errorChan := make(chan error, 1)
255+
256+
for _, function := range funcs {
257+
wg.Add(1)
258+
go func(f func() error) {
259+
defer wg.Done()
260+
if err := f(); err != nil {
261+
errorChan <- err
262+
}
263+
}(function)
264+
}
265+
266+
wg.Wait()
267+
268+
select {
269+
case err := <-errorChan:
270+
return err
271+
default:
272+
}
273+
274+
return nil
275+
}
276+
277+
func (m *Manager) formatImageName(i *ec2.Instance) (string, error) {
278+
var nameTemplate *template.Template
279+
var err error
280+
tags := utils.TagSliceToMap(i.Tags)
281+
instanceIDString := aws.StringValue(i.InstanceId)
282+
283+
// User has supplied a template overide for naming the image. We'll need to
284+
// template it out.
285+
if templateString, ok := tags.Get(m.ImageNameTag); ok {
286+
templateName := fmt.Sprintf("image-name-%s", instanceIDString)
287+
m.logf("Using custom image name template for instance '%s'\n", instanceIDString)
288+
nameTemplate, err = template.New(templateName).Parse(templateString)
289+
if err != nil {
290+
return "", err
291+
}
292+
} else {
293+
m.logf("Using DefaultImageNameTemplate for instance '%s'\n", instanceIDString)
294+
nameTemplate = m.DefaultImageNameTemplate
295+
}
296+
297+
var buf bytes.Buffer
298+
// Execute the template
299+
err = nameTemplate.Execute(&buf, newImageNameTemplateContext(i))
300+
return buf.String(), err
301+
}
302+
303+
func (m *Manager) log(v ...interface{}) {
304+
if m.Verbose {
305+
fmt.Println(v...)
306+
}
307+
}
308+
309+
func (m *Manager) logf(format string, v ...interface{}) {
310+
if m.Verbose {
311+
fmt.Printf(format, v...)
312+
}
313+
}

‎pkg/backup/template_context.go

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package backup
2+
3+
import (
4+
"time"
5+
6+
"github.com/aws/aws-sdk-go/service/ec2"
7+
"github.com/c2fo/lambda-ebs-backup/pkg/utils"
8+
)
9+
10+
// ImageNameTemplateContext is what gets passed in as the context to the
11+
// go template when attempting to create the name of the image for backup.
12+
type ImageNameTemplateContext struct {
13+
Name string
14+
Date string
15+
FullDate string
16+
}
17+
18+
func newImageNameTemplateContext(i *ec2.Instance) ImageNameTemplateContext {
19+
tags := utils.TagSliceToMap(i.Tags)
20+
21+
return ImageNameTemplateContext{
22+
Name: tags.GetDefault("Name", ""),
23+
Date: time.Now().Format("2006-01-02"),
24+
FullDate: time.Now().Format("2006-01-02-15-04-05"),
25+
}
26+
}

‎pkg/config/config.go

+46-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package config
22

3-
import "os"
3+
import (
4+
"os"
5+
"strconv"
6+
)
47

58
func envDefault(key, defaultValue string) string {
69
value := os.Getenv(key)
@@ -10,6 +13,10 @@ func envDefault(key, defaultValue string) string {
1013
return value
1114
}
1215

16+
func envDefaultInt(key, defaultValue string) (int, error) {
17+
return strconv.Atoi(envDefault(key, defaultValue))
18+
}
19+
1320
// BackupTagKey is the volume tag key to look for when determing if we should
1421
// perform a backup or not.
1522
func BackupTagKey() string {
@@ -21,3 +28,41 @@ func BackupTagKey() string {
2128
func BackupTagValue() string {
2229
return envDefault("BACKUP_TAG_VALUE", "true")
2330
}
31+
32+
// ImageTagKey is the ec2 instance tag key to look for when deciding if we
33+
// should create an image for the instance.
34+
func ImageTagKey() string {
35+
return envDefault("IMAGE_TAG_KEY", "lambda-ebs-backup/image")
36+
}
37+
38+
// ImageTagValue is the ec2 instance tag value to look for when deciding if an
39+
// image should be created for the instance.
40+
func ImageTagValue() string {
41+
return envDefault("IMAGE_TAG_VALUE", "true")
42+
}
43+
44+
// ImageNameTag is the tag to look for on instances that decides how an image
45+
// will be named. This tag supports a GO Template and overrides the default
46+
// ImageNameFormat on an instance by instance basis.
47+
func ImageNameTag() string {
48+
return envDefault("IMAGE_NAME_TAG", "lambda-ebs-backup/image-name")
49+
}
50+
51+
// DefaultImageNameFormat is the default template to use for nameing ec2 images
52+
// if a tag is not found. By default, we will use the name of the Instance
53+
// postfixed with the date.
54+
func DefaultImageNameFormat() string {
55+
return envDefault("DEFAULT_IMAGE_NAME_FORMAT", "{{.Name}}-{{.Date}}")
56+
}
57+
58+
// MaxKeepImagesTag is the tag to look at for the maximum number of images to
59+
// keep for an instance.
60+
func MaxKeepImagesTag() string {
61+
return envDefault("MAX_KEEP_IMAGES_TAG", "lambda-ebs-backup/max-keep-images")
62+
}
63+
64+
// MaxKeepImagesDefault is the default number of images to keep if not specified
65+
// on the instance via the MaxKeepImagesTag
66+
func DefaultMaxKeepImages() (int, error) {
67+
return envDefaultInt("DEFAULT_MAX_KEEP_IMAGES", "2")
68+
}

‎pkg/utils/ec2.go

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package utils
2+
3+
import (
4+
"github.com/aws/aws-sdk-go/aws"
5+
"github.com/aws/aws-sdk-go/service/ec2"
6+
)
7+
8+
// TagMap stores the ec2 tags as a map and provides some convenience methods.
9+
type TagMap struct {
10+
m map[string]string
11+
}
12+
13+
// Get gets the tag with the given key and whether or not it exists
14+
func (tm *TagMap) Get(key string) (string, bool) {
15+
v, ok := tm.m[key]
16+
return v, ok
17+
}
18+
19+
// GetDefault returns the value of the tag matching the given key. If there is
20+
// no tag matching the key, it returns the default value.
21+
func (tm *TagMap) GetDefault(key string, defaultValue string) string {
22+
v, ok := tm.m[key]
23+
if !ok {
24+
return defaultValue
25+
}
26+
return v
27+
}
28+
29+
// TagSliceToMap takes a slice of *ec2.Tags and returns a mapping of their
30+
// key -> value.
31+
func TagSliceToMap(tags []*ec2.Tag) TagMap {
32+
m := make(map[string]string)
33+
for _, t := range tags {
34+
m[aws.StringValue(t.Key)] = aws.StringValue(t.Value)
35+
}
36+
return TagMap{m: m}
37+
}

‎pkg/utils/ec2_test.go

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package utils
2+
3+
import (
4+
"testing"
5+
6+
"github.com/aws/aws-sdk-go/aws"
7+
"github.com/aws/aws-sdk-go/service/ec2"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestTagMap(t *testing.T) {
12+
tags := []*ec2.Tag{
13+
&ec2.Tag{
14+
Key: aws.String("Name"),
15+
Value: aws.String("TestName"),
16+
},
17+
&ec2.Tag{
18+
Key: aws.String("TestKey"),
19+
Value: aws.String("TestValue"),
20+
},
21+
}
22+
23+
tm := TagSliceToMap(tags)
24+
25+
assert.Equal(t, "TestName", tm.GetDefault("Name", "defaultName"))
26+
assert.Equal(t, "defaultValue", tm.GetDefault("MissingKey", "defaultValue"))
27+
28+
val, ok := tm.Get("TestKey")
29+
assert.True(t, ok)
30+
assert.Equal(t, "TestValue", val)
31+
32+
val, ok = tm.Get("MissingKey")
33+
assert.False(t, ok)
34+
assert.Equal(t, "", val)
35+
}

0 commit comments

Comments
 (0)
Please sign in to comment.