diff --git a/README.md b/README.md index 61b31ae0..48dc0ec4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 \ diff --git a/cmd/backup/config.go b/cmd/backup/config.go new file mode 100644 index 00000000..60eb5247 --- /dev/null +++ b/cmd/backup/config.go @@ -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() +} diff --git a/cmd/backup/config_test.go b/cmd/backup/config_test.go new file mode 100644 index 00000000..50d3a282 --- /dev/null +++ b/cmd/backup/config_test.go @@ -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) + } + }) + } +} diff --git a/cmd/backup/fake.go b/cmd/backup/fake.go new file mode 100644 index 00000000..95dcf746 --- /dev/null +++ b/cmd/backup/fake.go @@ -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 +} diff --git a/cmd/backup/gcs.go b/cmd/backup/gcs.go new file mode 100644 index 00000000..2c428888 --- /dev/null +++ b/cmd/backup/gcs.go @@ -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 +} diff --git a/cmd/backup/s3.go b/cmd/backup/s3.go new file mode 100644 index 00000000..53d96246 --- /dev/null +++ b/cmd/backup/s3.go @@ -0,0 +1,42 @@ +package backup + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/leg100/etok/pkg/backup" + "github.com/spf13/pflag" +) + +func init() { + addProvider("s3", newS3Flags) +} + +type s3Flags struct { + bucket string + region string +} + +func newS3Flags() flags { + return &s3Flags{} +} + +func (f *s3Flags) addToFlagSet(fs *pflag.FlagSet) { + fs.StringVar(&f.bucket, "s3-bucket", "", "Specify s3 bucket for terraform state backups") + fs.StringVar(&f.region, "s3-region", "", "Specify s3 region for terraform state backups") +} + +func (f *s3Flags) createProvider(ctx context.Context) (backup.Provider, error) { + return backup.NewS3Provider(ctx, f.bucket, &aws.Config{Region: aws.String(f.region)}) +} + +func (f *s3Flags) validate() error { + if f.bucket == "" { + return fmt.Errorf("%w: missing s3 bucket name", ErrInvalidConfig) + } + if f.region == "" { + return fmt.Errorf("%w: missing s3 region name", ErrInvalidConfig) + } + return nil +} diff --git a/cmd/install/deployment.go b/cmd/install/deployment.go index 0412e680..9280ddd9 100644 --- a/cmd/install/deployment.go +++ b/cmd/install/deployment.go @@ -5,7 +5,9 @@ import ( appsv1 "k8s.io/api/apps/v1" + "github.com/leg100/etok/cmd/backup" "github.com/leg100/etok/pkg/version" + "github.com/spf13/pflag" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -40,10 +42,11 @@ func WithSecret(secretPresent bool) podTemplateOption { } } -func WithGCSProvider(bucket string) podTemplateOption { +func WithBackupConfig(cfg *backup.Config, fs *pflag.FlagSet) podTemplateOption { return func(c *podTemplateConfig) { - c.envVars = append(c.envVars, corev1.EnvVar{Name: "ETOK_BACKUP_PROVIDER", Value: "gcs"}) - c.envVars = append(c.envVars, corev1.EnvVar{Name: "ETOK_GCS_BUCKET", Value: bucket}) + for _, ev := range cfg.GetEnvVars(fs) { + c.envVars = append(c.envVars, ev) + } } } diff --git a/cmd/install/deployment_test.go b/cmd/install/deployment_test.go index 4c5f33a1..1c3ad823 100644 --- a/cmd/install/deployment_test.go +++ b/cmd/install/deployment_test.go @@ -38,21 +38,6 @@ func TestDeployment(t *testing.T) { }) }, }, - { - name: "with backup enabled", - namespace: "default", - opts: []podTemplateOption{WithGCSProvider("backups-bucket")}, - assertions: func(deploy *appsv1.Deployment) { - assert.Contains(t, deploy.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{ - Name: "ETOK_BACKUP_PROVIDER", - Value: "gcs", - }) - assert.Contains(t, deploy.Spec.Template.Spec.Containers[0].Env, corev1.EnvVar{ - Name: "ETOK_GCS_BUCKET", - Value: "backups-bucket", - }) - }, - }, } for _, tt := range tests { testutil.Run(t, tt.name, func(t *testutil.T) { diff --git a/cmd/install/install.go b/cmd/install/install.go index 7822dd1d..99c4f05b 100644 --- a/cmd/install/install.go +++ b/cmd/install/install.go @@ -2,7 +2,6 @@ package install import ( "context" - "errors" "fmt" "io/ioutil" "net/http" @@ -21,12 +20,14 @@ import ( apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "github.com/leg100/etok/cmd/backup" "github.com/leg100/etok/cmd/flags" cmdutil "github.com/leg100/etok/cmd/util" "github.com/leg100/etok/pkg/client" "github.com/leg100/etok/pkg/labels" "github.com/leg100/etok/pkg/version" "github.com/spf13/cobra" + "github.com/spf13/pflag" runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/yaml" ) @@ -56,8 +57,6 @@ var ( // Interval between polling deployment status interval = time.Second - - errInvalidBackupConfig = errors.New("invalid backup config") ) type installOptions struct { @@ -87,15 +86,16 @@ type installOptions struct { // Print out resources and don't install dryRun bool - // Toggle state backups - backupProviderName string + // State backup configuration + backupCfg *backup.Config - // GCS backup bucket - gcsBucket string + // flags are the install command's parsed flags + flags *pflag.FlagSet } func InstallCmd(f *cmdutil.Factory) (*cobra.Command, *installOptions) { o := &installOptions{ + backupCfg: backup.NewConfig(), Factory: f, namespace: defaultNamespace, } @@ -104,10 +104,13 @@ func InstallCmd(f *cmdutil.Factory) (*cobra.Command, *installOptions) { Use: "install", Short: "Install etok operator", RunE: func(cmd *cobra.Command, args []string) (err error) { - if err := o.validateBackupOptions(); err != nil { + // Validate backup flags + if err := o.backupCfg.Validate(cmd.Flags()); err != nil { return err } + o.flags = cmd.Flags() + o.Client, err = o.CreateRuntimeClient(o.kubeContext) if err != nil { return err @@ -120,6 +123,8 @@ func InstallCmd(f *cmdutil.Factory) (*cobra.Command, *installOptions) { flags.AddNamespaceFlag(cmd, &o.namespace) flags.AddKubeContextFlag(cmd, &o.kubeContext) + o.backupCfg.AddToFlagSet(cmd.Flags()) + cmd.Flags().StringVar(&o.name, "name", "etok-operator", "Name for kubernetes resources") cmd.Flags().StringVar(&o.image, "image", version.Image, "Docker image used for both the operator and the runner") @@ -131,26 +136,21 @@ func InstallCmd(f *cmdutil.Factory) (*cobra.Command, *installOptions) { cmd.Flags().StringToStringVar(&o.serviceAccountAnnotations, "sa-annotations", map[string]string{}, "Annotations to add to the etok ServiceAccount. Add iam.gke.io/gcp-service-account=[GSA_NAME]@[PROJECT_NAME].iam.gserviceaccount.com for workload identity") cmd.Flags().BoolVar(&o.crdsOnly, "crds-only", o.crdsOnly, "Only generate CRD resources. Useful for updating CRDs for an existing Etok install.") - cmd.Flags().StringVar(&o.backupProviderName, "backup-provider", "", "Enable backups specifying a provider (only 'gcs' supported currently)") - - cmd.Flags().StringVar(&o.gcsBucket, "gcs-bucket", "", "Specify GCS bucket for terraform state backups") - return cmd, o } -func (o *installOptions) validateBackupOptions() error { - if o.backupProviderName != "" { - if o.backupProviderName != "gcs" { - return fmt.Errorf("%w: %s is invalid value for --backup-provider, valid options are: gcs", errInvalidBackupConfig, o.backupProviderName) - } - } - - if (o.backupProviderName == "" && o.gcsBucket != "") || (o.backupProviderName != "" && o.gcsBucket == "") { - return fmt.Errorf("%w: you must specify both --backup-provider and --gcs-bucket", errInvalidBackupConfig) - } - - return nil -} +//func (o *installOptions) validateBackupOptions() error { if +//o.backupProviderName != "" { if o.backupProviderName != "gcs" && +//o.backupProviderName != "s3" { return fmt.Errorf("%w: %s is invalid value for +//--backup-provider, valid options are: gcs, s3", errInvalidBackupConfig, +//o.backupProviderName) } } +// +// if (o.backupProviderName == "" && o.gcsBucket != "") || (o.backupProviderName != "" && o.gcsBucket == "") { +// return fmt.Errorf("%w: you must specify both --backup-provider and --gcs-bucket", errInvalidBackupConfig) +// } +// +// return nil +//} func (o *installOptions) install(ctx context.Context) error { var deploy *appsv1.Deployment @@ -191,11 +191,9 @@ func (o *installOptions) install(ctx context.Context) error { // Deploy options dopts := []podTemplateOption{} + dopts = append(dopts, WithBackupConfig(o.backupCfg, o.flags)) dopts = append(dopts, WithSecret(secretPresent)) dopts = append(dopts, WithImage(o.image)) - if o.backupProviderName == "gcs" { - dopts = append(dopts, WithGCSProvider(o.gcsBucket)) - } deploy = deployment(o.namespace, dopts...) resources = append(resources, deploy) diff --git a/cmd/install/install_test.go b/cmd/install/install_test.go index 6eef52dc..8b42efed 100644 --- a/cmd/install/install_test.go +++ b/cmd/install/install_test.go @@ -9,7 +9,6 @@ import ( "io/ioutil" "net/http" "net/http/httptest" - "os" "strings" "testing" "time" @@ -22,13 +21,13 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" + "github.com/leg100/etok/cmd/backup" cmdutil "github.com/leg100/etok/cmd/util" etokclient "github.com/leg100/etok/pkg/client" "github.com/leg100/etok/pkg/scheme" "github.com/leg100/etok/pkg/testobj" "github.com/leg100/etok/pkg/testutil" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" runtimeclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -37,11 +36,12 @@ import ( func TestInstall(t *testing.T) { tests := []struct { - name string - args []string - objs []runtimeclient.Object - err error - assertions func(*testutil.T, runtimeclient.Client) + name string + args []string + objs []runtimeclient.Object + err error + assertions func(*testutil.T, runtimeclient.Client) + dryRunAssertions func(*testutil.T, *bytes.Buffer) }{ { name: "fresh install", @@ -110,17 +110,21 @@ func TestInstall(t *testing.T) { { name: "missing backup bucket name", args: []string{"install", "--wait=false", "--backup-provider=gcs"}, - err: errInvalidBackupConfig, - }, - { - name: "missing backup provider name", - args: []string{"install", "--wait=false", "--gcs-bucket=backups-bucket"}, - err: errInvalidBackupConfig, + err: backup.ErrInvalidConfig, }, { name: "invalid backup provider name", args: []string{"install", "--wait=false", "--backup-provider=alibaba-cloud-blob"}, - err: errInvalidBackupConfig, + err: backup.ErrInvalidConfig, + }, + { + name: "dry run", + args: []string{"install", "--dry-run", "--local"}, + dryRunAssertions: func(t *testutil.T, out *bytes.Buffer) { + // Assert correct number of k8s objs are serialized to yaml + docs := strings.Split(out.String(), "---\n") + assert.Equal(t, 11, len(docs)) + }, }, } for _, tt := range tests { @@ -131,7 +135,7 @@ func TestInstall(t *testing.T) { buf := new(bytes.Buffer) f := &cmdutil.Factory{ - IOStreams: cmdutil.IOStreams{Out: os.Stdout}, + IOStreams: cmdutil.IOStreams{Out: buf}, RuntimeClientCreator: NewFakeClientCreator(convertObjs(tt.objs...)...), } @@ -157,6 +161,12 @@ func TestInstall(t *testing.T) { return } + // Perform dry run assertions and skip k8s tests + if tt.dryRunAssertions != nil { + tt.dryRunAssertions(t, buf) + return + } + // get runtime client now that it's been created client := opts.RuntimeClient @@ -217,30 +227,6 @@ func TestInstallWait(t *testing.T) { } } -func TestInstallDryRun(t *testing.T) { - testutil.Run(t, "default", func(t *testutil.T) { - // When retrieve local paths to YAML files, it's assumed the user's pwd - // is the repo root - t.Chdir("../../") - - out := new(bytes.Buffer) - opts := &installOptions{ - Client: &etokclient.Client{ - RuntimeClient: fake.NewFakeClientWithScheme(scheme.Scheme), - }, - Factory: &cmdutil.Factory{ - IOStreams: cmdutil.IOStreams{Out: out}, - }, - dryRun: true, - local: true, - } - require.NoError(t, opts.install(context.Background())) - - docs := strings.Split(out.String(), "---\n") - assert.Equal(t, 11, len(docs)) - }) -} - // Convert []client.Object to []runtime.Object (the CR real client works with // the former, whereas the CR fake client works with the latter) func convertObjs(objs ...runtimeclient.Object) (converted []runtime.Object) { diff --git a/cmd/manager/manager.go b/cmd/manager/manager.go index 3063cebe..ac1e4853 100644 --- a/cmd/manager/manager.go +++ b/cmd/manager/manager.go @@ -9,9 +9,9 @@ import ( "k8s.io/klog/v2" + "github.com/leg100/etok/cmd/backup" "github.com/leg100/etok/cmd/flags" cmdutil "github.com/leg100/etok/cmd/util" - "github.com/leg100/etok/pkg/backup" "github.com/leg100/etok/pkg/controllers" "github.com/leg100/etok/pkg/scheme" "github.com/leg100/etok/pkg/version" @@ -42,15 +42,15 @@ type ManagerOptions struct { args []string - // Toggle state backups - backupProviderName string - - // GCS backup bucket - gcsBucket string + // State backup configuration + backupCfg *backup.Config } func ManagerCmd(f *cmdutil.Factory) *cobra.Command { - o := &ManagerOptions{Factory: f} + o := &ManagerOptions{ + backupCfg: backup.NewConfig(), + Factory: f, + } cmd := &cobra.Command{ Use: "operator", Short: "Run the etok operator", @@ -60,6 +60,11 @@ func ManagerCmd(f *cmdutil.Factory) *cobra.Command { printVersion() + // Validate backup flags + if err := o.backupCfg.Validate(cmd.Flags()); err != nil { + return err + } + client, err := f.Create(o.KubeContext) if err != nil { return err @@ -89,15 +94,13 @@ func ManagerCmd(f *cmdutil.Factory) *cobra.Command { } } - var backupProvider backup.Provider - if o.backupProviderName != "" { - switch o.backupProviderName { - case "gcs": - backupProvider, err = backup.NewGCSProvider(cmd.Context(), o.gcsBucket, nil) - if err != nil { - return err - } - } + // Select backup provider based on parsed flags + backupProvider, err := o.backupCfg.CreateSelectedProvider(cmd.Context()) + if err != nil { + return err + } + if backupProvider != nil { + klog.V(0).Infof("Created backup provider: %s", o.backupCfg.Selected) } // Setup workspace ctrl with mgr @@ -127,6 +130,8 @@ func ManagerCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().AddGoFlagSet(flag.CommandLine) + o.backupCfg.AddToFlagSet(cmd.Flags()) + flags.AddKubeContextFlag(cmd, &o.KubeContext) cmd.Flags().StringVar(&o.MetricsAddress, "metrics-addr", ":8080", "The address the metric endpoint binds to.") @@ -135,9 +140,5 @@ func ManagerCmd(f *cmdutil.Factory) *cobra.Command { "Enabling this will ensure there is only one active controller manager.") cmd.Flags().StringVar(&o.Image, "image", version.Image, "Docker image used for both the operator and the runner") - cmd.Flags().StringVar(&o.backupProviderName, "backup-provider", "", "Enable backups specifying a provider") - - cmd.Flags().StringVar(&o.gcsBucket, "gcs-bucket", "", "Specify GCS bucket for terraform state backups") - return cmd } diff --git a/go.mod b/go.mod index a14c826a..49f14479 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.15 require ( cloud.google.com/go/storage v1.12.0 + github.com/aws/aws-sdk-go v1.37.3 github.com/creack/pty v1.1.9 github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c // indirect github.com/fatih/color v1.7.0 @@ -11,6 +12,7 @@ require ( github.com/google/go-cmp v0.5.4 github.com/google/goexpect v0.0.0-20200816234442-b5b77125c2c5 github.com/hashicorp/terraform-config-inspect v0.0.0-20201102131242-0c45ba392e51 + github.com/johannesboyne/gofakes3 v0.0.0-20210124080349-901cf567bf01 github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.4 // indirect github.com/mattn/go-isatty v0.0.12 // indirect diff --git a/go.sum b/go.sum index b83dd3c2..eb453972 100644 --- a/go.sum +++ b/go.sum @@ -87,12 +87,16 @@ github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/aws/aws-sdk-go v1.17.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/aws/aws-sdk-go v1.37.3 h1:1f0groABc4AuapskpHf6EBRaG2tqw0Sx3ebCMwfp1Ys= +github.com/aws/aws-sdk-go v1.37.3/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -330,6 +334,13 @@ github.com/imdario/mergo v0.3.10 h1:6q5mVkdH/vYmqngx7kZQTjJ5HRsx+ImorDIEQ+beJgc= github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/johannesboyne/gofakes3 v0.0.0-20210124080349-901cf567bf01 h1:OgeS46YxpBWMY1AB5asiZyTP2kRlH/Wlm94YwuusRak= +github.com/johannesboyne/gofakes3 v0.0.0-20210124080349-901cf567bf01/go.mod h1:fNiSoOiEI5KlkWXn26OwKnNe58ilTIkpBlgOrt7Olu8= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -448,9 +459,13 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= +github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63 h1:J6qvD6rbmOil46orKqJaRPG+zTpoGlBTUdyv8ki63L0= +github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63/go.mod h1:n+VKSARF5y/tS9XFSP7vWDfS+GUC5vs/YT7M5XDTUEM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= @@ -461,6 +476,7 @@ github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= @@ -585,6 +601,7 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190310074541-c10a0554eabf/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -618,6 +635,8 @@ golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102 h1:42cLlJJdEh+ySyeUUbEQ5bsTiq8voBeTuweGVkY6Puw= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -660,6 +679,7 @@ golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190308174544-00c44ba9c14f/go.mod h1:25r3+/G6/xytQM8iWZKq3Hn0kr0rgFKPUNVEL/dr3z4= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= @@ -812,6 +832,7 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= diff --git a/pkg/backup/gcs.go b/pkg/backup/gcs.go index 994b1a4b..24ebc21a 100644 --- a/pkg/backup/gcs.go +++ b/pkg/backup/gcs.go @@ -29,6 +29,9 @@ func NewGCSProvider(ctx context.Context, bucket string, client *storage.Client) // Check bucket exists bh := client.Bucket(bucket) _, err := bh.Attrs(ctx) + if err == storage.ErrBucketNotExist { + return nil, fmt.Errorf("%w: %s", ErrBucketNotFound, bucket) + } if err != nil { return nil, err } @@ -109,7 +112,3 @@ func (p *gcsProvider) Restore(ctx context.Context, key client.ObjectKey) (*corev return &secret, nil } - -func path(key client.ObjectKey) string { - return fmt.Sprintf("%s.yaml", key) -} diff --git a/pkg/backup/gcs_test.go b/pkg/backup/gcs_test.go index c00b8859..dcfe9034 100644 --- a/pkg/backup/gcs_test.go +++ b/pkg/backup/gcs_test.go @@ -2,53 +2,33 @@ package backup import ( "context" - "testing" "github.com/fsouza/fake-gcs-server/fakestorage" - "github.com/leg100/etok/pkg/testobj" "github.com/leg100/etok/pkg/testutil" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - "sigs.k8s.io/controller-runtime/pkg/client" ) -func TestGCSProvider(t *testing.T) { - tests := []struct { - name string - bucket string - bucketObjs []fakestorage.Object - secret *corev1.Secret - }{ - { - name: "Backup", - bucket: "backups", - bucketObjs: []fakestorage.Object{ - { - BucketName: "backups", - }, - }, - secret: testobj.Secret("default", "tfstate-default-workspace-1", testobj.WithCompressedDataFromFile("tfstate", "testdata/tfstate.json")), - }, - } - for _, tt := range tests { - testutil.Run(t, tt.name, func(t *testutil.T) { - // Setup up new fake GCS server for each test - server, err := fakestorage.NewServerWithOptions(fakestorage.Options{ - InitialObjects: tt.bucketObjs, - Host: "127.0.0.1", - Port: 8081, - }) - require.NoError(t, err) - defer server.Stop() - - p, err := NewGCSProvider(context.Background(), tt.bucket, server.Client()) - require.NoError(t, err) +type gcsTestProvider struct { + *gcsProvider +} - require.NoError(t, p.Backup(context.Background(), tt.secret)) - secret, err := p.Restore(context.Background(), client.ObjectKeyFromObject(tt.secret)) +func (p *gcsTestProvider) createProviderWithBuckets(t *testutil.T, providerBucket string, createBuckets ...string) (Provider, error) { + var initialObjects []fakestorage.Object - assert.Equal(t, tt.secret, secret) + for _, b := range createBuckets { + initialObjects = append(initialObjects, fakestorage.Object{ + BucketName: b, }) } + + // Setup up fake GCS server + server, err := fakestorage.NewServerWithOptions(fakestorage.Options{ + InitialObjects: initialObjects, + Host: "127.0.0.1", + Port: 8081, + }) + require.NoError(t, err) + t.Cleanup(server.Stop) + + return NewGCSProvider(context.Background(), providerBucket, server.Client()) } diff --git a/pkg/backup/provider_test.go b/pkg/backup/provider_test.go new file mode 100644 index 00000000..6da9e694 --- /dev/null +++ b/pkg/backup/provider_test.go @@ -0,0 +1,90 @@ +package backup + +import ( + "context" + "errors" + "testing" + + "github.com/leg100/etok/pkg/testobj" + "github.com/leg100/etok/pkg/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// testProvider is a provider plus the ability to create provider as well create +// buckets +type testProvider interface { + Provider + createProviderWithBuckets(*testutil.T, string, ...string) (Provider, error) +} + +var ( + // testProviders is a collection of implementations of testProvider to be + // tested + testProviders = map[string]testProvider{ + "gcs": &gcsTestProvider{}, + "s3": &s3TestProvider{}, + } +) + +func TestProviders(t *testing.T) { + tests := []struct { + name string + providerBucket string + createBuckets []string + backup bool + restore bool + wantSecret *corev1.Secret + err error + }{ + { + name: "backup-restore", + backup: true, + restore: true, + wantSecret: testobj.Secret("default", "tfstate-default-workspace-1"), + providerBucket: "backups-bucket", + createBuckets: []string{"backups-bucket"}, + }, + { + name: "nothing to restore", + restore: true, + providerBucket: "backups-bucket", + createBuckets: []string{"backups-bucket"}, + }, + { + name: "missing bucket", + providerBucket: "backups-bucket", + err: ErrBucketNotFound, + }, + } + + for _, tt := range tests { + for providerName, tp := range testProviders { + testutil.Run(t, tt.name+"/"+providerName, func(t *testutil.T) { + p, err := tp.createProviderWithBuckets(t, tt.providerBucket, tt.createBuckets...) + if !assert.True(t, errors.Is(err, tt.err)) { + t.Errorf("no error in %v's chain matches %v", err, tt.err) + } + if err != nil { + return + } + + secret := testobj.Secret("default", "tfstate-default-workspace-1") + + // Assert that backed up secret matches restored secret + if tt.backup { + require.NoError(t, p.Backup(context.Background(), secret)) + } + + if tt.restore { + restored, err := p.Restore(context.Background(), client.ObjectKeyFromObject(secret)) + require.NoError(t, err) + assert.Equal(t, tt.wantSecret, restored) + } + }) + } + } + +} diff --git a/pkg/backup/s3.go b/pkg/backup/s3.go new file mode 100644 index 00000000..cf26ed00 --- /dev/null +++ b/pkg/backup/s3.go @@ -0,0 +1,96 @@ +package backup + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" +) + +type s3Provider struct { + bucket string + client *s3.S3 +} + +func NewS3Provider(ctx context.Context, bucket string, cfg *aws.Config) (Provider, error) { + sess, err := session.NewSession(cfg) + if err != nil { + return nil, err + } + + svc := s3.New(sess) + + // Check bucket exists + if _, err := svc.GetBucketAclWithContext(ctx, &s3.GetBucketAclInput{Bucket: &bucket}); err != nil { + if aerr, ok := err.(awserr.Error); ok { + if aerr.Code() == s3.ErrCodeNoSuchBucket { + return nil, fmt.Errorf("%w: %s", ErrBucketNotFound, bucket) + } + } + return nil, err + } + + return &s3Provider{ + bucket: bucket, + client: svc, + }, nil +} + +func (p *s3Provider) Backup(ctx context.Context, secret *corev1.Secret) error { + // Marshal state file first to json then to yaml + y, err := yaml.Marshal(secret) + if err != nil { + return err + } + + _, err = p.client.PutObject(&s3.PutObjectInput{ + Body: bytes.NewReader(y), + Bucket: aws.String(p.bucket), + Key: aws.String(path(client.ObjectKeyFromObject(secret))), + }) + + if err != nil { + return err + } + + return nil +} + +func (p *s3Provider) Restore(ctx context.Context, key client.ObjectKey) (*corev1.Secret, error) { + var secret corev1.Secret + + resp, err := p.client.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(p.bucket), + Key: aws.String(path(key)), + }) + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + if aerr.Code() == s3.ErrCodeNoSuchKey { + // No backup to restore + return nil, nil + } + } + return nil, err + } + defer resp.Body.Close() + + buf, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // Unmarshal state file into secret obj + if err := yaml.Unmarshal(buf, &secret); err != nil { + return nil, err + } + + return &secret, nil +} diff --git a/pkg/backup/s3_test.go b/pkg/backup/s3_test.go new file mode 100644 index 00000000..86dcc7cc --- /dev/null +++ b/pkg/backup/s3_test.go @@ -0,0 +1,49 @@ +package backup + +import ( + "context" + "net/http/httptest" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/johannesboyne/gofakes3" + "github.com/johannesboyne/gofakes3/backend/s3mem" + "github.com/leg100/etok/pkg/testutil" + "github.com/stretchr/testify/require" +) + +type s3TestProvider struct { + *s3Provider +} + +func (p *s3TestProvider) createProviderWithBuckets(t *testutil.T, providerBucket string, createBuckets ...string) (Provider, error) { + // fake s3 + backend := s3mem.New() + faker := gofakes3.New(backend) + ts := httptest.NewServer(faker.Server()) + t.Cleanup(ts.Close) + + // configure S3 client + cfg := &aws.Config{ + Credentials: credentials.NewStaticCredentials("YOUR-ACCESSKEYID", "YOUR-SECRETACCESSKEY", ""), + Endpoint: aws.String(ts.URL), + Region: aws.String("eu-central-1"), + DisableSSL: aws.Bool(true), + S3ForcePathStyle: aws.Bool(true), + } + sess, err := session.NewSession(cfg) + require.NoError(t, err) + + client := s3.New(sess) + + for _, b := range createBuckets { + _, err = client.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(b), + }) + require.NoError(t, err) + } + + return NewS3Provider(context.Background(), providerBucket, cfg) +} diff --git a/pkg/backup/utils.go b/pkg/backup/utils.go new file mode 100644 index 00000000..e5ed874e --- /dev/null +++ b/pkg/backup/utils.go @@ -0,0 +1,16 @@ +package backup + +import ( + "errors" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + ErrBucketNotFound = errors.New("backup bucket could not be found") +) + +func path(key client.ObjectKey) string { + return fmt.Sprintf("%s.yaml", key) +} diff --git a/pkg/controllers/workspace_controller.go b/pkg/controllers/workspace_controller.go index 8a66b06b..18ef79db 100644 --- a/pkg/controllers/workspace_controller.go +++ b/pkg/controllers/workspace_controller.go @@ -381,7 +381,7 @@ func (r *WorkspaceReconciler) restore(ctx context.Context, ws *v1alpha1.Workspac secret.OwnerReferences = nil if err := r.Create(ctx, secret); err != nil { - return r.sendWarningEvent(err, ws, "RestoreError") + return nil, err } // Parse state file