migrate backup actions to plugins

Signed-off-by: Steve Kriss <steve@heptio.com>
This commit is contained in:
Steve Kriss
2017-11-21 10:03:03 -08:00
parent 2ce15de2f8
commit 0f2d1ab82b
27 changed files with 1817 additions and 678 deletions
+58 -43
View File
@@ -26,12 +26,13 @@ import (
"github.com/sirupsen/logrus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime/schema"
kuberrs "k8s.io/apimachinery/pkg/util/errors"
api "github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/client"
"github.com/heptio/ark/pkg/cloudprovider"
"github.com/heptio/ark/pkg/discovery"
"github.com/heptio/ark/pkg/util/collections"
kubeutil "github.com/heptio/ark/pkg/util/kube"
@@ -42,32 +43,16 @@ import (
type Backupper interface {
// Backup takes a backup using the specification in the api.Backup and writes backup and log data
// to the given writers.
Backup(backup *api.Backup, backupFile, logFile io.Writer) error
Backup(backup *api.Backup, backupFile, logFile io.Writer, actions []ItemAction) error
}
// kubernetesBackupper implements Backupper.
type kubernetesBackupper struct {
dynamicFactory client.DynamicFactory
discoveryHelper discovery.Helper
actions map[schema.GroupResource]Action
podCommandExecutor podCommandExecutor
dynamicFactory client.DynamicFactory
discoveryHelper discovery.Helper
podCommandExecutor podCommandExecutor
groupBackupperFactory groupBackupperFactory
}
// ResourceIdentifier describes a single item by its group, resource, namespace, and name.
type ResourceIdentifier struct {
schema.GroupResource
Namespace string
Name string
}
// Action is an actor that performs an operation on an individual item being backed up.
type Action interface {
// Execute allows the Action to perform arbitrary logic with the item being backed up and the
// backup itself. Implementations may return additional ResourceIdentifiers that indicate specific
// items that also need to be backed up.
Execute(log *logrus.Entry, item runtime.Unstructured, backup *api.Backup) ([]ResourceIdentifier, error)
snapshotService cloudprovider.SnapshotService
}
type itemKey struct {
@@ -76,6 +61,20 @@ type itemKey struct {
name string
}
type resolvedAction struct {
ItemAction
resourceIncludesExcludes *collections.IncludesExcludes
namespaceIncludesExcludes *collections.IncludesExcludes
selector labels.Selector
}
// LogSetter is an interface for a type that allows a FieldLogger
// to be set on it.
type LogSetter interface {
SetLog(logrus.FieldLogger)
}
func (i *itemKey) String() string {
return fmt.Sprintf("resource=%s,namespace=%s,name=%s", i.resource, i.namespace, i.name)
}
@@ -84,38 +83,48 @@ func (i *itemKey) String() string {
func NewKubernetesBackupper(
discoveryHelper discovery.Helper,
dynamicFactory client.DynamicFactory,
actions map[string]Action,
podCommandExecutor podCommandExecutor,
snapshotService cloudprovider.SnapshotService,
) (Backupper, error) {
resolvedActions, err := resolveActions(discoveryHelper, actions)
if err != nil {
return nil, err
}
return &kubernetesBackupper{
discoveryHelper: discoveryHelper,
dynamicFactory: dynamicFactory,
actions: resolvedActions,
podCommandExecutor: podCommandExecutor,
discoveryHelper: discoveryHelper,
dynamicFactory: dynamicFactory,
podCommandExecutor: podCommandExecutor,
groupBackupperFactory: &defaultGroupBackupperFactory{},
snapshotService: snapshotService,
}, nil
}
// resolveActions resolves the string-based map of group-resources to actions and returns a map of
// schema.GroupResources to actions.
func resolveActions(helper discovery.Helper, actions map[string]Action) (map[schema.GroupResource]Action, error) {
ret := make(map[schema.GroupResource]Action)
func resolveActions(actions []ItemAction, helper discovery.Helper) ([]resolvedAction, error) {
var resolved []resolvedAction
for resource, action := range actions {
gvr, _, err := helper.ResourceFor(schema.ParseGroupResource(resource).WithVersion(""))
for _, action := range actions {
resourceSelector, err := action.AppliesTo()
if err != nil {
return nil, err
}
ret[gvr.GroupResource()] = action
resources := getResourceIncludesExcludes(helper, resourceSelector.IncludedResources, resourceSelector.ExcludedResources)
namespaces := collections.NewIncludesExcludes().Includes(resourceSelector.IncludedNamespaces...).Excludes(resourceSelector.ExcludedNamespaces...)
selector := labels.Everything()
if resourceSelector.LabelSelector != "" {
if selector, err = labels.Parse(resourceSelector.LabelSelector); err != nil {
return nil, err
}
}
res := resolvedAction{
ItemAction: action,
resourceIncludesExcludes: resources,
namespaceIncludesExcludes: namespaces,
selector: selector,
}
resolved = append(resolved, res)
}
return ret, nil
return resolved, nil
}
// getResourceIncludesExcludes takes the lists of resources to include and exclude, uses the
@@ -172,7 +181,7 @@ func getResourceHooks(hookSpecs []api.BackupResourceHookSpec, discoveryHelper di
// Backup backs up the items specified in the Backup, placing them in a gzip-compressed tar file
// written to backupFile. The finalized api.Backup is written to metadata.
func (kb *kubernetesBackupper) Backup(backup *api.Backup, backupFile, logFile io.Writer) error {
func (kb *kubernetesBackupper) Backup(backup *api.Backup, backupFile, logFile io.Writer, actions []ItemAction) error {
gzippedData := gzip.NewWriter(backupFile)
defer gzippedData.Close()
@@ -215,6 +224,11 @@ func (kb *kubernetesBackupper) Backup(backup *api.Backup, backupFile, logFile io
"networkpolicies": newCohabitatingResource("networkpolicies", "extensions", "networking.k8s.io"),
}
resolvedActions, err := resolveActions(actions, kb.discoveryHelper)
if err != nil {
return err
}
gb := kb.groupBackupperFactory.newGroupBackupper(
log,
backup,
@@ -225,10 +239,11 @@ func (kb *kubernetesBackupper) Backup(backup *api.Backup, backupFile, logFile io
kb.discoveryHelper,
backedUpItems,
cohabitatingResources,
kb.actions,
resolvedActions,
kb.podCommandExecutor,
tw,
resourceHooks,
kb.snapshotService,
)
for _, group := range kb.discoveryHelper.Resources() {
+14 -6
View File
@@ -30,25 +30,33 @@ import (
// backupPVAction inspects a PersistentVolumeClaim for the PersistentVolume
// that it references and backs it up
type backupPVAction struct {
log logrus.FieldLogger
}
func NewBackupPVAction() Action {
return &backupPVAction{}
func NewBackupPVAction(log logrus.FieldLogger) ItemAction {
return &backupPVAction{log: log}
}
var pvGroupResource = schema.GroupResource{Group: "", Resource: "persistentvolumes"}
func (a *backupPVAction) AppliesTo() (ResourceSelector, error) {
return ResourceSelector{
IncludedResources: []string{"persistentvolumeclaims"},
}, nil
}
// Execute finds the PersistentVolume referenced by the provided
// PersistentVolumeClaim and backs it up
func (a *backupPVAction) Execute(log *logrus.Entry, item runtime.Unstructured, backup *v1.Backup) ([]ResourceIdentifier, error) {
log.Info("Executing backupPVAction")
func (a *backupPVAction) Execute(item runtime.Unstructured, backup *v1.Backup) (runtime.Unstructured, []ResourceIdentifier, error) {
a.log.Info("Executing backupPVAction")
var additionalItems []ResourceIdentifier
pvc := item.UnstructuredContent()
volumeName, err := collections.GetString(pvc, "spec.volumeName")
if err != nil {
return additionalItems, errors.WithMessage(err, "unable to get spec.volumeName")
return nil, nil, errors.WithMessage(err, "unable to get spec.volumeName")
}
additionalItems = append(additionalItems, ResourceIdentifier{
@@ -56,5 +64,5 @@ func (a *backupPVAction) Execute(log *logrus.Entry, item runtime.Unstructured, b
Name: volumeName,
})
return additionalItems, nil
return item, additionalItems, nil
}
+3 -3
View File
@@ -35,13 +35,13 @@ func TestBackupPVAction(t *testing.T) {
backup := &v1.Backup{}
a := NewBackupPVAction()
a := NewBackupPVAction(arktest.NewLogger())
additional, err := a.Execute(arktest.NewLogger(), pvc, backup)
_, additional, err := a.Execute(pvc, backup)
assert.EqualError(t, err, "unable to get spec.volumeName: key volumeName not found")
pvc.Object["spec"].(map[string]interface{})["volumeName"] = "myVolume"
additional, err = a.Execute(arktest.NewLogger(), pvc, backup)
_, additional, err = a.Execute(pvc, backup)
require.NoError(t, err)
require.Len(t, additional, 1)
assert.Equal(t, ResourceIdentifier{GroupResource: pvGroupResource, Name: "myVolume"}, additional[0])
+51 -28
View File
@@ -41,6 +41,7 @@ import (
"github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/client"
"github.com/heptio/ark/pkg/cloudprovider"
"github.com/heptio/ark/pkg/discovery"
"github.com/heptio/ark/pkg/util/collections"
kubeutil "github.com/heptio/ark/pkg/util/kube"
@@ -55,49 +56,73 @@ var (
)
type fakeAction struct {
selector ResourceSelector
ids []string
backups []*v1.Backup
backups []v1.Backup
additionalItems []ResourceIdentifier
}
var _ Action = &fakeAction{}
var _ ItemAction = &fakeAction{}
func (a *fakeAction) Execute(log *logrus.Entry, item runtime.Unstructured, backup *v1.Backup) ([]ResourceIdentifier, error) {
func newFakeAction(resource string) *fakeAction {
return (&fakeAction{}).ForResource(resource)
}
func (a *fakeAction) Execute(item runtime.Unstructured, backup *v1.Backup) (runtime.Unstructured, []ResourceIdentifier, error) {
metadata, err := meta.Accessor(item)
if err != nil {
return a.additionalItems, err
return item, a.additionalItems, err
}
a.ids = append(a.ids, kubeutil.NamespaceAndName(metadata))
a.backups = append(a.backups, backup)
a.backups = append(a.backups, *backup)
return a.additionalItems, nil
return item, a.additionalItems, nil
}
func (a *fakeAction) AppliesTo() (ResourceSelector, error) {
return a.selector, nil
}
func (a *fakeAction) ForResource(resource string) *fakeAction {
a.selector.IncludedResources = []string{resource}
return a
}
func TestResolveActions(t *testing.T) {
tests := []struct {
name string
input map[string]Action
expected map[schema.GroupResource]Action
input []ItemAction
expected []resolvedAction
resourcesWithErrors []string
expectError bool
}{
{
name: "empty input",
input: map[string]Action{},
expected: map[schema.GroupResource]Action{},
input: []ItemAction{},
expected: nil,
},
{
name: "mapper error",
input: map[string]Action{"badresource": &fakeAction{}},
expected: map[schema.GroupResource]Action{},
name: "resolve error",
input: []ItemAction{&fakeAction{selector: ResourceSelector{LabelSelector: "=invalid-selector"}}},
expected: nil,
expectError: true,
},
{
name: "resolved",
input: map[string]Action{"foo": &fakeAction{}, "bar": &fakeAction{}},
expected: map[schema.GroupResource]Action{
{Group: "somegroup", Resource: "foodies"}: &fakeAction{},
{Group: "anothergroup", Resource: "barnacles"}: &fakeAction{},
input: []ItemAction{newFakeAction("foo"), newFakeAction("bar")},
expected: []resolvedAction{
{
ItemAction: newFakeAction("foo"),
resourceIncludesExcludes: collections.NewIncludesExcludes().Includes("foodies.somegroup"),
namespaceIncludesExcludes: collections.NewIncludesExcludes(),
selector: labels.Everything(),
},
{
ItemAction: newFakeAction("bar"),
resourceIncludesExcludes: collections.NewIncludesExcludes().Includes("barnacles.anothergroup"),
namespaceIncludesExcludes: collections.NewIncludesExcludes(),
selector: labels.Everything(),
},
},
},
}
@@ -112,7 +137,7 @@ func TestResolveActions(t *testing.T) {
}
discoveryHelper := arktest.NewFakeDiscoveryHelper(false, resources)
actual, err := resolveActions(discoveryHelper, test.input)
actual, err := resolveActions(test.input, discoveryHelper)
gotError := err != nil
if e, a := test.expectError, gotError; e != a {
@@ -349,7 +374,6 @@ func TestBackup(t *testing.T) {
tests := []struct {
name string
backup *v1.Backup
actions map[string]Action
expectedNamespaces *collections.IncludesExcludes
expectedResources *collections.IncludesExcludes
expectedLabelSelector string
@@ -369,7 +393,6 @@ func TestBackup(t *testing.T) {
ExcludedNamespaces: []string{"c", "d"},
},
},
actions: map[string]Action{},
expectedNamespaces: collections.NewIncludesExcludes().Includes("a", "b").Excludes("c", "d"),
expectedResources: collections.NewIncludesExcludes().Includes("configmaps", "certificatesigningrequests.certificates.k8s.io", "roles.rbac.authorization.k8s.io"),
expectedHooks: []resourceHook{},
@@ -388,7 +411,6 @@ func TestBackup(t *testing.T) {
},
},
},
actions: map[string]Action{},
expectedNamespaces: collections.NewIncludesExcludes(),
expectedResources: collections.NewIncludesExcludes(),
expectedHooks: []resourceHook{},
@@ -402,7 +424,6 @@ func TestBackup(t *testing.T) {
{
name: "backupGroup errors",
backup: &v1.Backup{},
actions: map[string]Action{},
expectedNamespaces: collections.NewIncludesExcludes(),
expectedResources: collections.NewIncludesExcludes(),
expectedHooks: []resourceHook{},
@@ -440,7 +461,6 @@ func TestBackup(t *testing.T) {
},
},
},
actions: map[string]Action{},
expectedNamespaces: collections.NewIncludesExcludes(),
expectedResources: collections.NewIncludesExcludes(),
expectedHooks: []resourceHook{
@@ -491,8 +511,8 @@ func TestBackup(t *testing.T) {
b, err := NewKubernetesBackupper(
discoveryHelper,
dynamicFactory,
test.actions,
podCommandExecutor,
nil,
)
require.NoError(t, err)
kb := b.(*kubernetesBackupper)
@@ -519,10 +539,11 @@ func TestBackup(t *testing.T) {
discoveryHelper,
map[itemKey]struct{}{}, // backedUpItems
cohabitatingResources,
kb.actions,
mock.Anything,
kb.podCommandExecutor,
mock.Anything, // tarWriter
test.expectedHooks,
mock.Anything,
).Return(groupBackupper)
for group, err := range test.backupGroupErrors {
@@ -531,7 +552,7 @@ func TestBackup(t *testing.T) {
var backupFile, logFile bytes.Buffer
err = b.Backup(test.backup, &backupFile, &logFile)
err = b.Backup(test.backup, &backupFile, &logFile, nil)
defer func() {
// print log if anything failed
if t.Failed() {
@@ -560,7 +581,7 @@ type mockGroupBackupperFactory struct {
}
func (f *mockGroupBackupperFactory) newGroupBackupper(
log *logrus.Entry,
log logrus.FieldLogger,
backup *v1.Backup,
namespaces, resources *collections.IncludesExcludes,
labelSelector string,
@@ -568,10 +589,11 @@ func (f *mockGroupBackupperFactory) newGroupBackupper(
discoveryHelper discovery.Helper,
backedUpItems map[itemKey]struct{},
cohabitatingResources map[string]*cohabitatingResource,
actions map[schema.GroupResource]Action,
actions []resolvedAction,
podCommandExecutor podCommandExecutor,
tarWriter tarWriter,
resourceHooks []resourceHook,
snapshotService cloudprovider.SnapshotService,
) groupBackupper {
args := f.Called(
log,
@@ -587,6 +609,7 @@ func (f *mockGroupBackupperFactory) newGroupBackupper(
podCommandExecutor,
tarWriter,
resourceHooks,
snapshotService,
)
return args.Get(0).(groupBackupper)
}
+30 -24
View File
@@ -19,19 +19,21 @@ package backup
import (
"strings"
"github.com/sirupsen/logrus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
kuberrs "k8s.io/apimachinery/pkg/util/errors"
"github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/client"
"github.com/heptio/ark/pkg/cloudprovider"
"github.com/heptio/ark/pkg/discovery"
"github.com/heptio/ark/pkg/util/collections"
"github.com/sirupsen/logrus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
kuberrs "k8s.io/apimachinery/pkg/util/errors"
)
type groupBackupperFactory interface {
newGroupBackupper(
log *logrus.Entry,
log logrus.FieldLogger,
backup *v1.Backup,
namespaces, resources *collections.IncludesExcludes,
labelSelector string,
@@ -39,17 +41,18 @@ type groupBackupperFactory interface {
discoveryHelper discovery.Helper,
backedUpItems map[itemKey]struct{},
cohabitatingResources map[string]*cohabitatingResource,
actions map[schema.GroupResource]Action,
actions []resolvedAction,
podCommandExecutor podCommandExecutor,
tarWriter tarWriter,
resourceHooks []resourceHook,
snapshotService cloudprovider.SnapshotService,
) groupBackupper
}
type defaultGroupBackupperFactory struct{}
func (f *defaultGroupBackupperFactory) newGroupBackupper(
log *logrus.Entry,
log logrus.FieldLogger,
backup *v1.Backup,
namespaces, resources *collections.IncludesExcludes,
labelSelector string,
@@ -57,26 +60,27 @@ func (f *defaultGroupBackupperFactory) newGroupBackupper(
discoveryHelper discovery.Helper,
backedUpItems map[itemKey]struct{},
cohabitatingResources map[string]*cohabitatingResource,
actions map[schema.GroupResource]Action,
actions []resolvedAction,
podCommandExecutor podCommandExecutor,
tarWriter tarWriter,
resourceHooks []resourceHook,
snapshotService cloudprovider.SnapshotService,
) groupBackupper {
return &defaultGroupBackupper{
log: log,
backup: backup,
namespaces: namespaces,
resources: resources,
labelSelector: labelSelector,
dynamicFactory: dynamicFactory,
discoveryHelper: discoveryHelper,
backedUpItems: backedUpItems,
cohabitatingResources: cohabitatingResources,
actions: actions,
podCommandExecutor: podCommandExecutor,
tarWriter: tarWriter,
resourceHooks: resourceHooks,
log: log,
backup: backup,
namespaces: namespaces,
resources: resources,
labelSelector: labelSelector,
dynamicFactory: dynamicFactory,
discoveryHelper: discoveryHelper,
backedUpItems: backedUpItems,
cohabitatingResources: cohabitatingResources,
actions: actions,
podCommandExecutor: podCommandExecutor,
tarWriter: tarWriter,
resourceHooks: resourceHooks,
snapshotService: snapshotService,
resourceBackupperFactory: &defaultResourceBackupperFactory{},
}
}
@@ -86,7 +90,7 @@ type groupBackupper interface {
}
type defaultGroupBackupper struct {
log *logrus.Entry
log logrus.FieldLogger
backup *v1.Backup
namespaces, resources *collections.IncludesExcludes
labelSelector string
@@ -94,10 +98,11 @@ type defaultGroupBackupper struct {
discoveryHelper discovery.Helper
backedUpItems map[itemKey]struct{}
cohabitatingResources map[string]*cohabitatingResource
actions map[schema.GroupResource]Action
actions []resolvedAction
podCommandExecutor podCommandExecutor
tarWriter tarWriter
resourceHooks []resourceHook
snapshotService cloudprovider.SnapshotService
resourceBackupperFactory resourceBackupperFactory
}
@@ -121,6 +126,7 @@ func (gb *defaultGroupBackupper) backupGroup(group *metav1.APIResourceList) erro
gb.podCommandExecutor,
gb.tarWriter,
gb.resourceHooks,
gb.snapshotService,
)
)
+12 -4
View File
@@ -21,6 +21,7 @@ import (
"github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/client"
"github.com/heptio/ark/pkg/cloudprovider"
"github.com/heptio/ark/pkg/discovery"
"github.com/heptio/ark/pkg/util/collections"
arktest "github.com/heptio/ark/pkg/util/test"
@@ -56,8 +57,11 @@ func TestBackupGroup(t *testing.T) {
},
}
actions := map[schema.GroupResource]Action{
{Group: "", Resource: "pods"}: &fakeAction{},
actions := []resolvedAction{
{
ItemAction: newFakeAction("pods"),
resourceIncludesExcludes: collections.NewIncludesExcludes().Includes("pods"),
},
}
podCommandExecutor := &mockPodCommandExecutor{}
@@ -83,6 +87,7 @@ func TestBackupGroup(t *testing.T) {
podCommandExecutor,
tarWriter,
resourceHooks,
nil,
).(*defaultGroupBackupper)
resourceBackupperFactory := &mockResourceBackupperFactory{}
@@ -106,6 +111,7 @@ func TestBackupGroup(t *testing.T) {
podCommandExecutor,
tarWriter,
resourceHooks,
nil,
).Return(resourceBackupper)
group := &metav1.APIResourceList{
@@ -140,7 +146,7 @@ type mockResourceBackupperFactory struct {
}
func (rbf *mockResourceBackupperFactory) newResourceBackupper(
log *logrus.Entry,
log logrus.FieldLogger,
backup *v1.Backup,
namespaces *collections.IncludesExcludes,
resources *collections.IncludesExcludes,
@@ -149,10 +155,11 @@ func (rbf *mockResourceBackupperFactory) newResourceBackupper(
discoveryHelper discovery.Helper,
backedUpItems map[itemKey]struct{},
cohabitatingResources map[string]*cohabitatingResource,
actions map[schema.GroupResource]Action,
actions []resolvedAction,
podCommandExecutor podCommandExecutor,
tarWriter tarWriter,
resourceHooks []resourceHook,
snapshotService cloudprovider.SnapshotService,
) resourceBackupper {
args := rbf.Called(
log,
@@ -168,6 +175,7 @@ func (rbf *mockResourceBackupperFactory) newResourceBackupper(
podCommandExecutor,
tarWriter,
resourceHooks,
snapshotService,
)
return args.Get(0).(resourceBackupper)
}
+37
View File
@@ -0,0 +1,37 @@
package backup
import (
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
api "github.com/heptio/ark/pkg/apis/ark/v1"
)
// ItemAction is an actor that performs an operation on an individual item being backed up.
type ItemAction interface {
// AppliesTo returns information about which resources this action should be invoked for.
AppliesTo() (ResourceSelector, error)
// Execute allows the ItemAction to perform arbitrary logic with the item being backed up and the
// backup itself. Implementations may return additional ResourceIdentifiers that indicate specific
// items that also need to be backed up.
Execute(item runtime.Unstructured, backup *api.Backup) (runtime.Unstructured, []ResourceIdentifier, error)
}
// ResourceIdentifier describes a single item by its group, resource, namespace, and name.
type ResourceIdentifier struct {
schema.GroupResource
Namespace string
Name string
}
// ResourceSelector is a collection of included/excluded namespaces,
// included/excluded resources, and a label-selector that can be used
// to match a set of items from a cluster.
type ResourceSelector struct {
IncludedNamespaces []string
ExcludedNamespaces []string
IncludedResources []string
ExcludedResources []string
LabelSelector string
}
+124 -15
View File
@@ -22,16 +22,21 @@ import (
"path/filepath"
"time"
api "github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/client"
"github.com/heptio/ark/pkg/discovery"
"github.com/heptio/ark/pkg/util/collections"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
api "github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/client"
"github.com/heptio/ark/pkg/cloudprovider"
"github.com/heptio/ark/pkg/discovery"
"github.com/heptio/ark/pkg/util/collections"
kubeutil "github.com/heptio/ark/pkg/util/kube"
)
type itemBackupperFactory interface {
@@ -39,12 +44,13 @@ type itemBackupperFactory interface {
backup *api.Backup,
namespaces, resources *collections.IncludesExcludes,
backedUpItems map[itemKey]struct{},
actions map[schema.GroupResource]Action,
actions []resolvedAction,
podCommandExecutor podCommandExecutor,
tarWriter tarWriter,
resourceHooks []resourceHook,
dynamicFactory client.DynamicFactory,
discoveryHelper discovery.Helper,
snapshotService cloudprovider.SnapshotService,
) ItemBackupper
}
@@ -54,12 +60,13 @@ func (f *defaultItemBackupperFactory) newItemBackupper(
backup *api.Backup,
namespaces, resources *collections.IncludesExcludes,
backedUpItems map[itemKey]struct{},
actions map[schema.GroupResource]Action,
actions []resolvedAction,
podCommandExecutor podCommandExecutor,
tarWriter tarWriter,
resourceHooks []resourceHook,
dynamicFactory client.DynamicFactory,
discoveryHelper discovery.Helper,
snapshotService cloudprovider.SnapshotService,
) ItemBackupper {
ib := &defaultItemBackupper{
backup: backup,
@@ -71,7 +78,7 @@ func (f *defaultItemBackupperFactory) newItemBackupper(
resourceHooks: resourceHooks,
dynamicFactory: dynamicFactory,
discoveryHelper: discoveryHelper,
snapshotService: snapshotService,
itemHookHandler: &defaultItemHookHandler{
podCommandExecutor: podCommandExecutor,
},
@@ -84,7 +91,7 @@ func (f *defaultItemBackupperFactory) newItemBackupper(
}
type ItemBackupper interface {
backupItem(logger *logrus.Entry, obj runtime.Unstructured, groupResource schema.GroupResource) error
backupItem(logger logrus.FieldLogger, obj runtime.Unstructured, groupResource schema.GroupResource) error
}
type defaultItemBackupper struct {
@@ -92,11 +99,12 @@ type defaultItemBackupper struct {
namespaces *collections.IncludesExcludes
resources *collections.IncludesExcludes
backedUpItems map[itemKey]struct{}
actions map[schema.GroupResource]Action
actions []resolvedAction
tarWriter tarWriter
resourceHooks []resourceHook
dynamicFactory client.DynamicFactory
discoveryHelper discovery.Helper
snapshotService cloudprovider.SnapshotService
itemHookHandler itemHookHandler
additionalItemBackupper ItemBackupper
@@ -107,7 +115,7 @@ var namespacesGroupResource = schema.GroupResource{Group: "", Resource: "namespa
// backupItem backs up an individual item to tarWriter. The item may be excluded based on the
// namespaces IncludesExcludes list.
func (ib *defaultItemBackupper) backupItem(logger *logrus.Entry, obj runtime.Unstructured, groupResource schema.GroupResource) error {
func (ib *defaultItemBackupper) backupItem(logger logrus.FieldLogger, obj runtime.Unstructured, groupResource schema.GroupResource) error {
metadata, err := meta.Accessor(obj)
if err != nil {
return err
@@ -154,18 +162,38 @@ func (ib *defaultItemBackupper) backupItem(logger *logrus.Entry, obj runtime.Uns
log.Info("Backing up resource")
item := obj.UnstructuredContent()
// Never save status
delete(item, "status")
delete(obj.UnstructuredContent(), "status")
if err := ib.itemHookHandler.handleHooks(log, groupResource, obj, ib.resourceHooks); err != nil {
return err
}
if action, found := ib.actions[groupResource]; found {
for _, action := range ib.actions {
if !action.resourceIncludesExcludes.ShouldInclude(groupResource.String()) {
log.Debug("Skipping action because it does not apply to this resource")
continue
}
if namespace != "" && !action.namespaceIncludesExcludes.ShouldInclude(namespace) {
log.Debug("Skipping action because it does not apply to this namespace")
continue
}
if !action.selector.Matches(labels.Set(metadata.GetLabels())) {
log.Debug("Skipping action because label selector does not match")
continue
}
log.Info("Executing custom action")
if additionalItemIdentifiers, err := action.Execute(log, obj, ib.backup); err == nil {
if logSetter, ok := action.ItemAction.(LogSetter); ok {
logSetter.SetLog(log)
}
if updatedItem, additionalItemIdentifiers, err := action.Execute(obj, ib.backup); err == nil {
obj = updatedItem
for _, additionalItem := range additionalItemIdentifiers {
gvr, resource, err := ib.discoveryHelper.ResourceFor(additionalItem.GroupResource.WithVersion(""))
if err != nil {
@@ -189,6 +217,16 @@ func (ib *defaultItemBackupper) backupItem(logger *logrus.Entry, obj runtime.Uns
}
}
if groupResource == pvGroupResource {
if ib.snapshotService == nil {
log.Debug("Skipping Persistent Volume snapshot because they're not enabled.")
} else {
if err := ib.takePVSnapshot(obj, ib.backup, log); err != nil {
return err
}
}
}
var filePath string
if namespace != "" {
filePath = filepath.Join(api.ResourcesDir, groupResource.String(), api.NamespaceScopedDir, namespace, name+".json")
@@ -196,7 +234,7 @@ func (ib *defaultItemBackupper) backupItem(logger *logrus.Entry, obj runtime.Uns
filePath = filepath.Join(api.ResourcesDir, groupResource.String(), api.ClusterScopedDir, name+".json")
}
itemBytes, err := json.Marshal(item)
itemBytes, err := json.Marshal(obj.UnstructuredContent())
if err != nil {
return errors.WithStack(err)
}
@@ -219,3 +257,74 @@ func (ib *defaultItemBackupper) backupItem(logger *logrus.Entry, obj runtime.Uns
return nil
}
// zoneLabel is the label that stores availability-zone info
// on PVs
const zoneLabel = "failure-domain.beta.kubernetes.io/zone"
// takePVSnapshot triggers a snapshot for the volume/disk underlying a PersistentVolume if the provided
// backup has volume snapshots enabled and the PV is of a compatible type. Also records cloud
// disk type and IOPS (if applicable) to be able to restore to current state later.
func (ib *defaultItemBackupper) takePVSnapshot(pv runtime.Unstructured, backup *api.Backup, log logrus.FieldLogger) error {
log.Info("Executing takePVSnapshot")
if backup.Spec.SnapshotVolumes != nil && !*backup.Spec.SnapshotVolumes {
log.Info("Backup has volume snapshots disabled; skipping volume snapshot action.")
return nil
}
metadata, err := meta.Accessor(pv)
if err != nil {
return errors.WithStack(err)
}
name := metadata.GetName()
var pvFailureDomainZone string
labels := metadata.GetLabels()
if labels[zoneLabel] != "" {
pvFailureDomainZone = labels[zoneLabel]
} else {
log.Infof("label %q is not present on PersistentVolume", zoneLabel)
}
volumeID, err := kubeutil.GetVolumeID(pv.UnstructuredContent())
// non-nil error means it's a supported PV source but volume ID can't be found
if err != nil {
return errors.Wrapf(err, "error getting volume ID for PersistentVolume")
}
// no volumeID / nil error means unsupported PV source
if volumeID == "" {
log.Info("PersistentVolume is not a supported volume type for snapshots, skipping.")
return nil
}
log = log.WithField("volumeID", volumeID)
log.Info("Snapshotting PersistentVolume")
snapshotID, err := ib.snapshotService.CreateSnapshot(volumeID, pvFailureDomainZone)
if err != nil {
// log+error on purpose - log goes to the per-backup log file, error goes to the backup
log.WithError(err).Error("error creating snapshot")
return errors.WithMessage(err, "error creating snapshot")
}
volumeType, iops, err := ib.snapshotService.GetVolumeInfo(volumeID, pvFailureDomainZone)
if err != nil {
log.WithError(err).Error("error getting volume info")
return errors.WithMessage(err, "error getting volume info")
}
if backup.Status.VolumeBackups == nil {
backup.Status.VolumeBackups = make(map[string]*api.VolumeBackupInfo)
}
backup.Status.VolumeBackups[name] = &api.VolumeBackupInfo{
SnapshotID: snapshotID,
Type: volumeType,
Iops: iops,
AvailabilityZone: pvFailureDomainZone,
}
return nil
}
+271 -8
View File
@@ -22,8 +22,10 @@ import (
"fmt"
"reflect"
"testing"
"time"
"github.com/heptio/ark/pkg/apis/ark/v1"
api "github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/util/collections"
arktest "github.com/heptio/ark/pkg/util/test"
"github.com/pkg/errors"
@@ -33,6 +35,7 @@ import (
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
@@ -134,6 +137,8 @@ func TestBackupItemNoSkips(t *testing.T) {
expectedActionID string
customActionAdditionalItemIdentifiers []ResourceIdentifier
customActionAdditionalItems []runtime.Unstructured
groupResource string
snapshottableVolumes map[string]api.VolumeBackupInfo
}{
{
name: "explicit namespace include",
@@ -223,12 +228,33 @@ func TestBackupItemNoSkips(t *testing.T) {
unstructuredOrDie(`{"apiVersion":"g2/v1","kind":"r1","metadata":{"namespace":"ns2","name":"n2"}}`),
},
},
{
name: "takePVSnapshot is not invoked for PVs when snapshotService == nil",
namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("*"),
item: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "us-east-1c"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-east-1c/vol-abc123"}}}`,
expectError: false,
expectExcluded: false,
expectedTarHeaderName: "resources/persistentvolumes/cluster/mypv.json",
groupResource: "persistentvolumes",
},
{
name: "takePVSnapshot is invoked for PVs when snapshotService != nil",
namespaceIncludesExcludes: collections.NewIncludesExcludes().Includes("*"),
item: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "us-east-1c"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-east-1c/vol-abc123"}}}`,
expectError: false,
expectExcluded: false,
expectedTarHeaderName: "resources/persistentvolumes/cluster/mypv.json",
groupResource: "persistentvolumes",
snapshottableVolumes: map[string]api.VolumeBackupInfo{
"vol-abc123": {SnapshotID: "snapshot-1", AvailabilityZone: "us-east-1c"},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var (
actions map[schema.GroupResource]Action
actions []resolvedAction
action *fakeAction
backup = &v1.Backup{}
groupResource = schema.ParseGroupResource("resource.group")
@@ -237,6 +263,10 @@ func TestBackupItemNoSkips(t *testing.T) {
w = &fakeTarWriter{}
)
if test.groupResource != "" {
groupResource = schema.ParseGroupResource(test.groupResource)
}
item, err := getAsMap(test.item)
if err != nil {
t.Fatal(err)
@@ -258,8 +288,13 @@ func TestBackupItemNoSkips(t *testing.T) {
action = &fakeAction{
additionalItems: test.customActionAdditionalItemIdentifiers,
}
actions = map[schema.GroupResource]Action{
groupResource: action,
actions = []resolvedAction{
{
ItemAction: action,
namespaceIncludesExcludes: collections.NewIncludesExcludes(),
resourceIncludesExcludes: collections.NewIncludesExcludes().Includes(groupResource.String()),
selector: labels.Everything(),
},
}
}
@@ -284,8 +319,15 @@ func TestBackupItemNoSkips(t *testing.T) {
resourceHooks,
dynamicFactory,
discoveryHelper,
nil,
).(*defaultItemBackupper)
var snapshotService *arktest.FakeSnapshotService
if test.snapshottableVolumes != nil {
snapshotService = &arktest.FakeSnapshotService{SnapshottableVolumes: test.snapshottableVolumes}
b.snapshotService = snapshotService
}
// make sure the podCommandExecutor was set correctly in the real hook handler
assert.Equal(t, podCommandExecutor, b.itemHookHandler.(*defaultItemHookHandler).podCommandExecutor)
@@ -361,10 +403,231 @@ func TestBackupItemNoSkips(t *testing.T) {
t.Errorf("action.ids[0]: expected %s, got %s", e, a)
}
if len(action.backups) != 1 {
t.Errorf("unexpected custom action backups: %#v", action.backups)
} else if e, a := backup, action.backups[0]; e != a {
t.Errorf("action.backups[0]: expected %#v, got %#v", e, a)
require.Equal(t, 1, len(action.backups), "unexpected custom action backups: %#v", action.backups)
assert.Equal(t, backup, &(action.backups[0]), "backup")
}
if test.snapshottableVolumes != nil {
require.Equal(t, 1, len(snapshotService.SnapshotsTaken))
var expectedBackups []api.VolumeBackupInfo
for _, vbi := range test.snapshottableVolumes {
expectedBackups = append(expectedBackups, vbi)
}
var actualBackups []api.VolumeBackupInfo
for _, vbi := range backup.Status.VolumeBackups {
actualBackups = append(actualBackups, *vbi)
}
assert.Equal(t, expectedBackups, actualBackups)
}
})
}
}
func TestTakePVSnapshot(t *testing.T) {
iops := int64(1000)
tests := []struct {
name string
snapshotEnabled bool
pv string
ttl time.Duration
expectError bool
expectedVolumeID string
expectedSnapshotsTaken int
existingVolumeBackups map[string]*v1.VolumeBackupInfo
volumeInfo map[string]v1.VolumeBackupInfo
}{
{
name: "snapshot disabled",
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}}`,
snapshotEnabled: false,
},
{
name: "can't find volume id - missing spec",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}}`,
expectError: true,
},
{
name: "unsupported PV source type",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"unsupportedPVSource": {}}}`,
expectError: false,
},
{
name: "can't find volume id - aws but no volume id",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"awsElasticBlockStore": {}}}`,
expectError: true,
},
{
name: "can't find volume id - gce but no volume id",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"gcePersistentDisk": {}}}`,
expectError: true,
},
{
name: "aws - simple volume id",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "us-east-1c"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-east-1c/vol-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "vol-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"vol-abc123": {Type: "gp", SnapshotID: "snap-1", AvailabilityZone: "us-east-1c"},
},
},
{
name: "aws - simple volume id with provisioned IOPS",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "us-east-1c"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-east-1c/vol-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "vol-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"vol-abc123": {Type: "io1", Iops: &iops, SnapshotID: "snap-1", AvailabilityZone: "us-east-1c"},
},
},
{
name: "aws - dynamically provisioned volume id",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "us-west-2a"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-west-2a/vol-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "vol-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"vol-abc123": {Type: "gp", SnapshotID: "snap-1", AvailabilityZone: "us-west-2a"},
},
},
{
name: "gce",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "gcp-zone2"}}, "spec": {"gcePersistentDisk": {"pdName": "pd-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "pd-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"pd-abc123": {Type: "gp", SnapshotID: "snap-1", AvailabilityZone: "gcp-zone2"},
},
},
{
name: "azure",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"azureDisk": {"diskName": "foo-disk"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "foo-disk",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"foo-disk": {Type: "gp", SnapshotID: "snap-1"},
},
},
{
name: "preexisting volume backup info in backup status",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"gcePersistentDisk": {"pdName": "pd-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "pd-abc123",
ttl: 5 * time.Minute,
existingVolumeBackups: map[string]*v1.VolumeBackupInfo{
"anotherpv": {SnapshotID: "anothersnap"},
},
volumeInfo: map[string]v1.VolumeBackupInfo{
"pd-abc123": {Type: "gp", SnapshotID: "snap-1"},
},
},
{
name: "create snapshot error",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"gcePersistentDisk": {"pdName": "pd-abc123"}}}`,
expectError: true,
},
{
name: "PV with label metadata but no failureDomainZone",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/region": "us-east-1"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-east-1c/vol-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "vol-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"vol-abc123": {Type: "gp", SnapshotID: "snap-1"},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
backup := &v1.Backup{
ObjectMeta: metav1.ObjectMeta{
Namespace: v1.DefaultNamespace,
Name: "mybackup",
},
Spec: v1.BackupSpec{
SnapshotVolumes: &test.snapshotEnabled,
TTL: metav1.Duration{Duration: test.ttl},
},
Status: v1.BackupStatus{
VolumeBackups: test.existingVolumeBackups,
},
}
snapshotService := &arktest.FakeSnapshotService{SnapshottableVolumes: test.volumeInfo}
ib := &defaultItemBackupper{snapshotService: snapshotService}
pv, err := getAsMap(test.pv)
if err != nil {
t.Fatal(err)
}
// method under test
err = ib.takePVSnapshot(&unstructured.Unstructured{Object: pv}, backup, arktest.NewLogger())
gotErr := err != nil
if e, a := test.expectError, gotErr; e != a {
t.Errorf("error: expected %v, got %v", e, a)
}
if test.expectError {
return
}
if !test.snapshotEnabled {
// don't need to check anything else if snapshots are disabled
return
}
expectedVolumeBackups := test.existingVolumeBackups
if expectedVolumeBackups == nil {
expectedVolumeBackups = make(map[string]*v1.VolumeBackupInfo)
}
// we should have one snapshot taken exactly
require.Equal(t, test.expectedSnapshotsTaken, snapshotService.SnapshotsTaken.Len())
if test.expectedSnapshotsTaken > 0 {
// the snapshotID should be the one in the entry in snapshotService.SnapshottableVolumes
// for the volume we ran the test for
snapshotID, _ := snapshotService.SnapshotsTaken.PopAny()
expectedVolumeBackups["mypv"] = &v1.VolumeBackupInfo{
SnapshotID: snapshotID,
Type: test.volumeInfo[test.expectedVolumeID].Type,
Iops: test.volumeInfo[test.expectedVolumeID].Iops,
AvailabilityZone: test.volumeInfo[test.expectedVolumeID].AvailabilityZone,
}
if e, a := expectedVolumeBackups, backup.Status.VolumeBackups; !reflect.DeepEqual(e, a) {
t.Errorf("backup.status.VolumeBackups: expected %v, got %v", e, a)
}
}
})
@@ -395,7 +658,7 @@ type mockItemBackupper struct {
mock.Mock
}
func (ib *mockItemBackupper) backupItem(logger *logrus.Entry, obj runtime.Unstructured, groupResource schema.GroupResource) error {
func (ib *mockItemBackupper) backupItem(logger logrus.FieldLogger, obj runtime.Unstructured, groupResource schema.GroupResource) error {
args := ib.Called(logger, obj, groupResource)
return args.Error(0)
}
+14 -10
View File
@@ -19,6 +19,7 @@ package backup
import (
api "github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/client"
"github.com/heptio/ark/pkg/cloudprovider"
"github.com/heptio/ark/pkg/discovery"
"github.com/heptio/ark/pkg/util/collections"
"github.com/pkg/errors"
@@ -33,7 +34,7 @@ import (
type resourceBackupperFactory interface {
newResourceBackupper(
log *logrus.Entry,
log logrus.FieldLogger,
backup *api.Backup,
namespaces *collections.IncludesExcludes,
resources *collections.IncludesExcludes,
@@ -42,17 +43,18 @@ type resourceBackupperFactory interface {
discoveryHelper discovery.Helper,
backedUpItems map[itemKey]struct{},
cohabitatingResources map[string]*cohabitatingResource,
actions map[schema.GroupResource]Action,
actions []resolvedAction,
podCommandExecutor podCommandExecutor,
tarWriter tarWriter,
resourceHooks []resourceHook,
snapshotService cloudprovider.SnapshotService,
) resourceBackupper
}
type defaultResourceBackupperFactory struct{}
func (f *defaultResourceBackupperFactory) newResourceBackupper(
log *logrus.Entry,
log logrus.FieldLogger,
backup *api.Backup,
namespaces *collections.IncludesExcludes,
resources *collections.IncludesExcludes,
@@ -61,10 +63,11 @@ func (f *defaultResourceBackupperFactory) newResourceBackupper(
discoveryHelper discovery.Helper,
backedUpItems map[itemKey]struct{},
cohabitatingResources map[string]*cohabitatingResource,
actions map[schema.GroupResource]Action,
actions []resolvedAction,
podCommandExecutor podCommandExecutor,
tarWriter tarWriter,
resourceHooks []resourceHook,
snapshotService cloudprovider.SnapshotService,
) resourceBackupper {
return &defaultResourceBackupper{
log: log,
@@ -80,8 +83,8 @@ func (f *defaultResourceBackupperFactory) newResourceBackupper(
podCommandExecutor: podCommandExecutor,
tarWriter: tarWriter,
resourceHooks: resourceHooks,
itemBackupperFactory: &defaultItemBackupperFactory{},
snapshotService: snapshotService,
itemBackupperFactory: &defaultItemBackupperFactory{},
}
}
@@ -90,7 +93,7 @@ type resourceBackupper interface {
}
type defaultResourceBackupper struct {
log *logrus.Entry
log logrus.FieldLogger
backup *api.Backup
namespaces *collections.IncludesExcludes
resources *collections.IncludesExcludes
@@ -99,12 +102,12 @@ type defaultResourceBackupper struct {
discoveryHelper discovery.Helper
backedUpItems map[itemKey]struct{}
cohabitatingResources map[string]*cohabitatingResource
actions map[schema.GroupResource]Action
actions []resolvedAction
podCommandExecutor podCommandExecutor
tarWriter tarWriter
resourceHooks []resourceHook
itemBackupperFactory itemBackupperFactory
snapshotService cloudprovider.SnapshotService
itemBackupperFactory itemBackupperFactory
}
// backupResource backs up all the objects for a given group-version-resource.
@@ -177,6 +180,7 @@ func (rb *defaultResourceBackupper) backupResource(
rb.resourceHooks,
rb.dynamicFactory,
rb.discoveryHelper,
rb.snapshotService,
)
namespacesToList := getNamespacesToList(rb.namespaces)
+25 -7
View File
@@ -21,6 +21,7 @@ import (
"github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/client"
"github.com/heptio/ark/pkg/cloudprovider"
"github.com/heptio/ark/pkg/discovery"
"github.com/heptio/ark/pkg/util/collections"
arktest "github.com/heptio/ark/pkg/util/test"
@@ -238,8 +239,11 @@ func TestBackupResource(t *testing.T) {
"networkpolicies": newCohabitatingResource("networkpolicies", "extensions", "networking.k8s.io"),
}
actions := map[schema.GroupResource]Action{
{Group: "", Resource: "pods"}: &fakeAction{},
actions := []resolvedAction{
{
ItemAction: newFakeAction("pods"),
resourceIncludesExcludes: collections.NewIncludesExcludes().Includes("pods"),
},
}
resourceHooks := []resourceHook{
@@ -266,6 +270,7 @@ func TestBackupResource(t *testing.T) {
podCommandExecutor,
tarWriter,
resourceHooks,
nil,
).(*defaultResourceBackupper)
itemBackupperFactory := &mockItemBackupperFactory{}
@@ -287,6 +292,7 @@ func TestBackupResource(t *testing.T) {
resourceHooks,
dynamicFactory,
discoveryHelper,
mock.Anything,
).Return(itemBackupper)
if len(test.listResponses) > 0 {
@@ -393,8 +399,11 @@ func TestBackupResourceCohabitation(t *testing.T) {
"networkpolicies": newCohabitatingResource("networkpolicies", "extensions", "networking.k8s.io"),
}
actions := map[schema.GroupResource]Action{
{Group: "", Resource: "pods"}: &fakeAction{},
actions := []resolvedAction{
{
ItemAction: newFakeAction("pods"),
resourceIncludesExcludes: collections.NewIncludesExcludes().Includes("pods"),
},
}
resourceHooks := []resourceHook{
@@ -420,6 +429,7 @@ func TestBackupResourceCohabitation(t *testing.T) {
podCommandExecutor,
tarWriter,
resourceHooks,
nil,
).(*defaultResourceBackupper)
itemBackupperFactory := &mockItemBackupperFactory{}
@@ -440,6 +450,7 @@ func TestBackupResourceCohabitation(t *testing.T) {
resourceHooks,
dynamicFactory,
discoveryHelper,
mock.Anything,
).Return(itemBackupper)
client := &arktest.FakeDynamicClient{}
@@ -476,7 +487,7 @@ func TestBackupResourceOnlyIncludesSpecifiedNamespaces(t *testing.T) {
cohabitatingResources := map[string]*cohabitatingResource{}
actions := map[schema.GroupResource]Action{}
actions := []resolvedAction{}
resourceHooks := []resourceHook{}
@@ -499,6 +510,7 @@ func TestBackupResourceOnlyIncludesSpecifiedNamespaces(t *testing.T) {
podCommandExecutor,
tarWriter,
resourceHooks,
nil,
).(*defaultResourceBackupper)
itemBackupperFactory := &mockItemBackupperFactory{}
@@ -519,6 +531,7 @@ func TestBackupResourceOnlyIncludesSpecifiedNamespaces(t *testing.T) {
dynamicFactory: dynamicFactory,
discoveryHelper: discoveryHelper,
itemHookHandler: itemHookHandler,
snapshotService: nil,
}
itemBackupperFactory.On("newItemBackupper",
@@ -532,6 +545,7 @@ func TestBackupResourceOnlyIncludesSpecifiedNamespaces(t *testing.T) {
resourceHooks,
dynamicFactory,
discoveryHelper,
mock.Anything,
).Return(itemBackupper)
client := &arktest.FakeDynamicClient{}
@@ -567,7 +581,7 @@ func TestBackupResourceListAllNamespacesExcludesCorrectly(t *testing.T) {
cohabitatingResources := map[string]*cohabitatingResource{}
actions := map[schema.GroupResource]Action{}
actions := []resolvedAction{}
resourceHooks := []resourceHook{}
@@ -590,6 +604,7 @@ func TestBackupResourceListAllNamespacesExcludesCorrectly(t *testing.T) {
podCommandExecutor,
tarWriter,
resourceHooks,
nil,
).(*defaultResourceBackupper)
itemBackupperFactory := &mockItemBackupperFactory{}
@@ -613,6 +628,7 @@ func TestBackupResourceListAllNamespacesExcludesCorrectly(t *testing.T) {
resourceHooks,
dynamicFactory,
discoveryHelper,
mock.Anything,
).Return(itemBackupper)
client := &arktest.FakeDynamicClient{}
@@ -642,12 +658,13 @@ func (ibf *mockItemBackupperFactory) newItemBackupper(
backup *v1.Backup,
namespaces, resources *collections.IncludesExcludes,
backedUpItems map[itemKey]struct{},
actions map[schema.GroupResource]Action,
actions []resolvedAction,
podCommandExecutor podCommandExecutor,
tarWriter tarWriter,
resourceHooks []resourceHook,
dynamicFactory client.DynamicFactory,
discoveryHelper discovery.Helper,
snapshotService cloudprovider.SnapshotService,
) ItemBackupper {
args := ibf.Called(
backup,
@@ -660,6 +677,7 @@ func (ibf *mockItemBackupperFactory) newItemBackupper(
resourceHooks,
dynamicFactory,
discoveryHelper,
snapshotService,
)
return args.Get(0).(ItemBackupper)
}
-118
View File
@@ -1,118 +0,0 @@
/*
Copyright 2017 Heptio Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package backup
import (
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime"
api "github.com/heptio/ark/pkg/apis/ark/v1"
"github.com/heptio/ark/pkg/cloudprovider"
kubeutil "github.com/heptio/ark/pkg/util/kube"
)
// zoneLabel is the label that stores availability-zone info
// on PVs
const zoneLabel = "failure-domain.beta.kubernetes.io/zone"
// volumeSnapshotAction is a struct that knows how to take snapshots of PersistentVolumes
// that are backed by compatible cloud volumes.
type volumeSnapshotAction struct {
snapshotService cloudprovider.SnapshotService
}
func NewVolumeSnapshotAction(snapshotService cloudprovider.SnapshotService) (Action, error) {
if snapshotService == nil {
return nil, errors.New("snapshotService cannot be nil")
}
return &volumeSnapshotAction{
snapshotService: snapshotService,
}, nil
}
// Execute triggers a snapshot for the volume/disk underlying a PersistentVolume if the provided
// backup has volume snapshots enabled and the PV is of a compatible type. Also records cloud
// disk type and IOPS (if applicable) to be able to restore to current state later.
func (a *volumeSnapshotAction) Execute(log *logrus.Entry, item runtime.Unstructured, backup *api.Backup) ([]ResourceIdentifier, error) {
var noAdditionalItems []ResourceIdentifier
log.Info("Executing volumeSnapshotAction")
if backup.Spec.SnapshotVolumes != nil && !*backup.Spec.SnapshotVolumes {
log.Info("Backup has volume snapshots disabled; skipping volume snapshot action.")
return noAdditionalItems, nil
}
metadata, err := meta.Accessor(item)
if err != nil {
return noAdditionalItems, errors.WithStack(err)
}
name := metadata.GetName()
var pvFailureDomainZone string
labels := metadata.GetLabels()
if labels[zoneLabel] != "" {
pvFailureDomainZone = labels[zoneLabel]
} else {
log.Infof("label %q is not present on PersistentVolume", zoneLabel)
}
volumeID, err := kubeutil.GetVolumeID(item.UnstructuredContent())
// non-nil error means it's a supported PV source but volume ID can't be found
if err != nil {
return noAdditionalItems, errors.Wrapf(err, "error getting volume ID for PersistentVolume")
}
// no volumeID / nil error means unsupported PV source
if volumeID == "" {
log.Info("PersistentVolume is not a supported volume type for snapshots, skipping.")
return noAdditionalItems, nil
}
log = log.WithField("volumeID", volumeID)
log.Info("Snapshotting PersistentVolume")
snapshotID, err := a.snapshotService.CreateSnapshot(volumeID, pvFailureDomainZone)
if err != nil {
// log+error on purpose - log goes to the per-backup log file, error goes to the backup
log.WithError(err).Error("error creating snapshot")
return noAdditionalItems, errors.WithMessage(err, "error creating snapshot")
}
volumeType, iops, err := a.snapshotService.GetVolumeInfo(volumeID, pvFailureDomainZone)
if err != nil {
log.WithError(err).Error("error getting volume info")
return noAdditionalItems, errors.WithMessage(err, "error getting volume info")
}
if backup.Status.VolumeBackups == nil {
backup.Status.VolumeBackups = make(map[string]*api.VolumeBackupInfo)
}
backup.Status.VolumeBackups[name] = &api.VolumeBackupInfo{
SnapshotID: snapshotID,
Type: volumeType,
Iops: iops,
AvailabilityZone: pvFailureDomainZone,
}
return noAdditionalItems, nil
}
-242
View File
@@ -1,242 +0,0 @@
/*
Copyright 2017 Heptio Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package backup
import (
"reflect"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/heptio/ark/pkg/apis/ark/v1"
arktest "github.com/heptio/ark/pkg/util/test"
)
func TestVolumeSnapshotAction(t *testing.T) {
iops := int64(1000)
tests := []struct {
name string
snapshotEnabled bool
pv string
ttl time.Duration
expectError bool
expectedVolumeID string
expectedSnapshotsTaken int
existingVolumeBackups map[string]*v1.VolumeBackupInfo
volumeInfo map[string]v1.VolumeBackupInfo
}{
{
name: "snapshot disabled",
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}}`,
snapshotEnabled: false,
},
{
name: "can't find volume id - missing spec",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}}`,
expectError: true,
},
{
name: "unsupported PV source type",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"unsupportedPVSource": {}}}`,
expectError: false,
},
{
name: "can't find volume id - aws but no volume id",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"awsElasticBlockStore": {}}}`,
expectError: true,
},
{
name: "can't find volume id - gce but no volume id",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"gcePersistentDisk": {}}}`,
expectError: true,
},
{
name: "aws - simple volume id",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "us-east-1c"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-east-1c/vol-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "vol-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"vol-abc123": {Type: "gp", SnapshotID: "snap-1", AvailabilityZone: "us-east-1c"},
},
},
{
name: "aws - simple volume id with provisioned IOPS",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "us-east-1c"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-east-1c/vol-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "vol-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"vol-abc123": {Type: "io1", Iops: &iops, SnapshotID: "snap-1", AvailabilityZone: "us-east-1c"},
},
},
{
name: "aws - dynamically provisioned volume id",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "us-west-2a"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-west-2a/vol-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "vol-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"vol-abc123": {Type: "gp", SnapshotID: "snap-1", AvailabilityZone: "us-west-2a"},
},
},
{
name: "gce",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/zone": "gcp-zone2"}}, "spec": {"gcePersistentDisk": {"pdName": "pd-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "pd-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"pd-abc123": {Type: "gp", SnapshotID: "snap-1", AvailabilityZone: "gcp-zone2"},
},
},
{
name: "azure",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"azureDisk": {"diskName": "foo-disk"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "foo-disk",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"foo-disk": {Type: "gp", SnapshotID: "snap-1"},
},
},
{
name: "preexisting volume backup info in backup status",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"gcePersistentDisk": {"pdName": "pd-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "pd-abc123",
ttl: 5 * time.Minute,
existingVolumeBackups: map[string]*v1.VolumeBackupInfo{
"anotherpv": {SnapshotID: "anothersnap"},
},
volumeInfo: map[string]v1.VolumeBackupInfo{
"pd-abc123": {Type: "gp", SnapshotID: "snap-1"},
},
},
{
name: "create snapshot error",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv"}, "spec": {"gcePersistentDisk": {"pdName": "pd-abc123"}}}`,
expectError: true,
},
{
name: "PV with label metadata but no failureDomainZone",
snapshotEnabled: true,
pv: `{"apiVersion": "v1", "kind": "PersistentVolume", "metadata": {"name": "mypv", "labels": {"failure-domain.beta.kubernetes.io/region": "us-east-1"}}, "spec": {"awsElasticBlockStore": {"volumeID": "aws://us-east-1c/vol-abc123"}}}`,
expectError: false,
expectedSnapshotsTaken: 1,
expectedVolumeID: "vol-abc123",
ttl: 5 * time.Minute,
volumeInfo: map[string]v1.VolumeBackupInfo{
"vol-abc123": {Type: "gp", SnapshotID: "snap-1"},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
backup := &v1.Backup{
ObjectMeta: metav1.ObjectMeta{
Namespace: v1.DefaultNamespace,
Name: "mybackup",
},
Spec: v1.BackupSpec{
SnapshotVolumes: &test.snapshotEnabled,
TTL: metav1.Duration{Duration: test.ttl},
},
Status: v1.BackupStatus{
VolumeBackups: test.existingVolumeBackups,
},
}
snapshotService := &arktest.FakeSnapshotService{SnapshottableVolumes: test.volumeInfo}
vsa, _ := NewVolumeSnapshotAction(snapshotService)
action := vsa.(*volumeSnapshotAction)
pv, err := getAsMap(test.pv)
if err != nil {
t.Fatal(err)
}
// method under test
additionalItems, err := action.Execute(arktest.NewLogger(), &unstructured.Unstructured{Object: pv}, backup)
assert.Len(t, additionalItems, 0)
gotErr := err != nil
if e, a := test.expectError, gotErr; e != a {
t.Errorf("error: expected %v, got %v", e, a)
}
if test.expectError {
return
}
if !test.snapshotEnabled {
// don't need to check anything else if snapshots are disabled
return
}
expectedVolumeBackups := test.existingVolumeBackups
if expectedVolumeBackups == nil {
expectedVolumeBackups = make(map[string]*v1.VolumeBackupInfo)
}
// we should have one snapshot taken exactly
require.Equal(t, test.expectedSnapshotsTaken, snapshotService.SnapshotsTaken.Len())
if test.expectedSnapshotsTaken > 0 {
// the snapshotID should be the one in the entry in snapshotService.SnapshottableVolumes
// for the volume we ran the test for
snapshotID, _ := snapshotService.SnapshotsTaken.PopAny()
expectedVolumeBackups["mypv"] = &v1.VolumeBackupInfo{
SnapshotID: snapshotID,
Type: test.volumeInfo[test.expectedVolumeID].Type,
Iops: test.volumeInfo[test.expectedVolumeID].Iops,
AvailabilityZone: test.volumeInfo[test.expectedVolumeID].AvailabilityZone,
}
if e, a := expectedVolumeBackups, backup.Status.VolumeBackups; !reflect.DeepEqual(e, a) {
t.Errorf("backup.status.VolumeBackups: expected %v, got %v", e, a)
}
}
})
}
}