Skip to content

Commit

Permalink
S3 backup support (#91)
Browse files Browse the repository at this point in the history
S3 backup support
  • Loading branch information
leg100 authored Feb 5, 2021
1 parent 8d4388d commit adf6e51
Show file tree
Hide file tree
Showing 20 changed files with 749 additions and 152 deletions.
46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,15 @@ To enable backups, install the operator with the relevant flags. For example, to
etok install --backup-provider=gcs --gcs-bucket=backups-bucket
```

Note: only GCS is supported at present.
Or to backup to an S3 bucket:

Be sure to provide the appropriate credentials of a GCP service account to the operator at install time. Either [create a secret containing credentials](#credentials), or [setup workload identity](#workload-identity). The service account needs the following permissions on the bucket:
```
etok install --backup-provider=s3 --s3-bucket=backups-bucket --s3-region=eu-west-2
```

Be sure to provide the appropriate credentials to the operator at install time. Either [create a secret containing credentials](#credentials), or [setup workload identity](#workload-identity).

For GCP, the service account needs the following IAM permissions on the bucket:

```
storage.buckets.get
Expand All @@ -143,8 +149,42 @@ storage.objects.delete
storage.objects.get
```

On AWS, the user needs the following IAM policy:

```
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:DeleteObject",
"s3:PutObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": [
"arn:aws:s3:::${BACKUP_BUCKET}/*"
]
},
{
"Effect": "Allow",
"Action": [
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::${BACKUP_BUCKET}"
]
}
]
}
```

To opt a workspace out of automatic backup and restore, pass the `--ephemeral` flag when creating a new workspace with `workspace new`. This is useful if you intend for your workspace to be short-lived.

Note: only GCS and S3 are currently supported.

## Credentials

Etok looks for credentials in a secret named `etok` in the relevant namespace. The credentials contained within are made available as environment variables.
Expand All @@ -155,7 +195,7 @@ For instance to set credentials for the [Terraform GCP provider](https://www.ter
kubectl create secret generic etok --from-file=GOOGLE_CREDENTIALS=[path to service account key]
```

Or, to set credentials for the [AWS provider](https://www.terraform.io/docs/providers/aws/index.html):
Or, to set credentials for the [AWS provider](https://www.terraform.io/docs/providers/aws/index.html), or for making backups to S3:

```
kubectl create secret generic etok \
Expand Down
124 changes: 124 additions & 0 deletions cmd/backup/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package backup

import (
"context"
"errors"
"fmt"
"strings"

"github.com/leg100/etok/pkg/backup"
"github.com/spf13/pflag"
corev1 "k8s.io/api/core/v1"
)

// flags represents the backup provider flags
type flags interface {
addToFlagSet(*pflag.FlagSet)
createProvider(context.Context) (backup.Provider, error)
validate() error
}

// flagMaker is a constructor for a flags obj
type flagMaker func() flags

// providerMap maps name of backup provider to a flags constructor
type providerMap struct {
name string
maker flagMaker
}

// providers is the collection of provider mappings
type providerMaps []providerMap

var (
// mappings is a singleton containing collection of provider mappings
mappings = providerMaps{}

// ErrInvalidConfig is wrapped within all errors from this pkg, and can be
// used by downstream to identify errors
ErrInvalidConfig = errors.New("invalid backup config")

ErrInvalidProvider = fmt.Errorf("%w: invalid provider", ErrInvalidConfig)
)

func addProvider(name string, f flagMaker) {
mappings = append(mappings, providerMap{name: name, maker: f})
}

// Config holds the flag configuration for all providers
type Config struct {
providers []string
providerToFlags map[string]flags

flagSet *pflag.FlagSet

// Name of Selected provider
Selected string
}

func NewConfig(optionalMappings ...providerMap) *Config {
cfg := &Config{
flagSet: pflag.NewFlagSet("backup", pflag.ContinueOnError),
providerToFlags: make(map[string]flags),
}

cfgMappings := mappings
if len(optionalMappings) > 0 {
cfgMappings = optionalMappings
}

for _, m := range cfgMappings {
cfg.providers = append(cfg.providers, m.name)
cfg.providerToFlags[m.name] = m.maker()
cfg.providerToFlags[m.name].addToFlagSet(cfg.flagSet)
}

cfg.flagSet.StringVar(&cfg.Selected, "backup-provider", "", fmt.Sprintf("Enable backups specifying a provider (%v)", strings.Join(cfg.providers, ",")))

return cfg
}

// AddToFlagSet adds config's (and its providers') flagsets to fs
func (c *Config) AddToFlagSet(fs *pflag.FlagSet) {
fs.AddFlagSet(c.flagSet)
}

// GetEnvVars converts the complete flagset into a list of k8s environment
// variable objects. Assumes config's flagset is present within fs, and assumes
// fs has been parsed.
func (c *Config) GetEnvVars(fs *pflag.FlagSet) (envvars []corev1.EnvVar) {
c.flagSet.VisitAll(func(flag *pflag.Flag) {
// Only fs has been parsed so we need to get populated value from there
if f := fs.Lookup(flag.Name); f != nil {
envvars = append(envvars, corev1.EnvVar{Name: flagToEnvVarName(f), Value: f.Value.String()})
}
})
return
}

// flagToEnvVarName converts flag f to an etok environment variable name
func flagToEnvVarName(f *pflag.Flag) string {
return fmt.Sprintf("ETOK_%s", strings.Replace(strings.ToUpper(f.Name), "-", "_", -1))
}

func (c *Config) CreateSelectedProvider(ctx context.Context) (backup.Provider, error) {
flags, ok := c.providerToFlags[c.Selected]
if !ok {
return nil, nil
}
return flags.createProvider(ctx)
}

// Validate all user-specified flags
func (c *Config) Validate(fs *pflag.FlagSet) error {
if c.Selected == "" {
return nil
}
flags, ok := c.providerToFlags[c.Selected]
if !ok {
return fmt.Errorf("%w: %s (valid providers: %s)", ErrInvalidProvider, c.Selected, strings.Join(c.providers, ","))
}

// Validate selected provider's flags
return flags.validate()
}
92 changes: 92 additions & 0 deletions cmd/backup/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package backup

import (
"context"
"errors"
"testing"

"github.com/leg100/etok/pkg/testutil"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
)

func TestConfig(t *testing.T) {
tests := []struct {
name string
args []string
assertions func(t *testutil.T, cmd *cobra.Command, cfg *Config)
err error
}{
{
name: "no backup provider specified",
assertions: func(t *testutil.T, cmd *cobra.Command, cfg *Config) {
assert.Contains(t, cfg.GetEnvVars(cmd.Flags()), corev1.EnvVar{
Name: "ETOK_BACKUP_PROVIDER",
Value: "",
})
provider, err := cfg.CreateSelectedProvider(context.Background())
require.NoError(t, err)
require.Nil(t, provider)
},
},
{
name: "valid config",
args: []string{"--backup-provider=fake", "--fake-bucket=backups-bucket", "--fake-region=eu-west2"},
assertions: func(t *testutil.T, cmd *cobra.Command, cfg *Config) {
assert.Contains(t, cfg.GetEnvVars(cmd.Flags()), corev1.EnvVar{
Name: "ETOK_BACKUP_PROVIDER",
Value: "fake",
})
assert.Contains(t, cfg.GetEnvVars(cmd.Flags()), corev1.EnvVar{
Name: "ETOK_FAKE_BUCKET",
Value: "backups-bucket",
})
assert.Contains(t, cfg.GetEnvVars(cmd.Flags()), corev1.EnvVar{
Name: "ETOK_FAKE_REGION",
Value: "eu-west2",
})
provider, err := cfg.CreateSelectedProvider(context.Background())
require.NoError(t, err)
require.NotNil(t, provider)
},
},
{
name: "invalid config",
args: []string{"--backup-provider=fake", "--fake-bucket=backups-bucket"},
err: ErrInvalidConfig,
},
{
name: "invalid provider",
args: []string{"--backup-provider=hpcloud"},
err: ErrInvalidProvider,
},
}

for _, tt := range tests {
testutil.Run(t, tt.name, func(t *testutil.T) {
cfg := NewConfig(providerMap{name: "fake", maker: newFakeFlags})
assert.Equal(t, []string{"fake"}, cfg.providers)

cmd := &cobra.Command{
Use: "foo",
}

cfg.AddToFlagSet(cmd.Flags())

cmd.SetArgs(tt.args)

require.NoError(t, cmd.Execute())

err := cfg.Validate(cmd.Flags())
if !assert.True(t, errors.Is(err, tt.err)) {
t.Errorf("no error in %v's chain matches %v", err, tt.err)
}

if tt.assertions != nil {
tt.assertions(t, cmd, cfg)
}
})
}
}
37 changes: 37 additions & 0 deletions cmd/backup/fake.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package backup

import (
"context"
"fmt"

"github.com/leg100/etok/pkg/backup"
"github.com/spf13/pflag"
)

type fakeFlags struct {
bucket string
region string
}

func newFakeFlags() flags {
return &fakeFlags{}
}

func (f *fakeFlags) addToFlagSet(fs *pflag.FlagSet) {
fs.StringVar(&f.bucket, "fake-bucket", "", "Specify fake bucket for terraform state backups")
fs.StringVar(&f.region, "fake-region", "", "Specify fake region for terraform state backups")
}

func (f *fakeFlags) createProvider(ctx context.Context) (backup.Provider, error) {
return &backup.FakeProvider{}, nil
}

func (f *fakeFlags) validate() error {
if f.bucket == "" {
return fmt.Errorf("%w: missing fake bucket name", ErrInvalidConfig)
}
if f.region == "" {
return fmt.Errorf("%w: missing fake region name", ErrInvalidConfig)
}
return nil
}
36 changes: 36 additions & 0 deletions cmd/backup/gcs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package backup

import (
"context"
"fmt"

"github.com/leg100/etok/pkg/backup"
"github.com/spf13/pflag"
)

func init() {
addProvider("gcs", newGcsFlags)
}

type gcsFlags struct {
bucket string
}

func newGcsFlags() flags {
return &gcsFlags{}
}

func (f *gcsFlags) addToFlagSet(fs *pflag.FlagSet) {
fs.StringVar(&f.bucket, "gcs-bucket", "", "Specify gcs bucket for terraform state backups")
}

func (f *gcsFlags) createProvider(ctx context.Context) (backup.Provider, error) {
return backup.NewGCSProvider(ctx, f.bucket, nil)
}

func (f *gcsFlags) validate() error {
if f.bucket == "" {
return fmt.Errorf("%w: missing gcs bucket name", ErrInvalidConfig)
}
return nil
}
Loading

0 comments on commit adf6e51

Please sign in to comment.