Repository: mariadb-operator/mariadb-operator

Issue:
[Feature] Compress backup files
**Is your feature request related to a problem? Please describe.**
When using mariadb-dump/mysqldump, you can pipe its output to a compression program, for instance, gzip or 7z, and compress the resulting dump file on a single step.
As far as I know, this is currently not possible with the mariadb-operator `Backup` resource, and if you want to compress the dump you have to do so manually.

**Describe the solution you'd like**
Add a `spec.compress` field to the `Backup` resource, which can be a boolean, or a string that indicates the compression type (gzip, 7z, zstd, etc.). If compression is enabled, the backup job would compress the file using the selected compression program, and then save it.

**Describe alternatives you've considered**
N/A

**Environment details**:
- Kubernetes version: 1.27.5
- Kubernetes distribution: k3s
- mariadb-operator version: 0.24.0
- Install method: Helm
- Install flavor: Recommended

**Additional context**
As for now, what we're doing to save compressed dumps is using the Backup resource to generate a dump, save it to an NFS volume mapped in Kubernetes, and then we have a cron job on the server that reads the file, compresses it using gzip, and uploads it to S3 using the AWS CLI.
Now that there's support for directly uploading dumps to S3 in mariadb-operator (which I've been testing and works well for our use case), it would be very useful to have the option of compressing the dumps before uploading them to S3, so we can eliminate the manual steps.


Hints from issue comments:
@mmontes11: Hola @TheNozomi ! 100% this makes total sense to me.

Not only we should support it in the `Backups`, but also we should make the `Restore` resource compatible with it. As you said we should introduce a new field in the `Backup` resource indicating the compression algorithm:

```yaml
apiVersion: mariadb.mmontes.io/v1alpha1
kind: Backup
metadata:
  name: backup
spec:
   compression: gzip
   ....
```
We don't need to change anything in the `Restore` API, as we already have a reference to the `Backup`:
```yaml
apiVersion: mariadb.mmontes.io/v1alpha1
kind: Restore
metadata:
  name: restore
spec:
  backupRef:
    name: backup
  ...
```

Regarding the implementation, I would support this in our new [backup](https://github.com/mariadb-operator/mariadb-operator/blob/main/cmd/backup/main.go) and [restore](https://github.com/mariadb-operator/mariadb-operator/blob/main/cmd/backup/restore.go) subcommands:
- The `backup` subcommand  is responsible for process the backup files AFTER the `mariadb-backup` command is executed. This responsability includes pushing the files to S3, and now we could potentially COMPRESS the backups before doing so. Here:
https://github.com/mariadb-operator/mariadb-operator/blob/d679c20bc64cf099353fbcd063edf51960c9b35a/cmd/backup/main.go#L88

- The `restore` subcommand is responsible for processing the backup files BEFORE THE `mariadb` command is executed to restore them. This responsability includes pulling them from S3, and now we could potentially DECOMPRESS the backup before doing so. Here:
https://github.com/mariadb-operator/mariadb-operator/blob/d679c20bc64cf099353fbcd063edf51960c9b35a/cmd/backup/restore.go#L59

There are multiple reasons to do it in Go in these subcommands:
- Our images can stay minimal without depending on compression libraries
- Easier to test and maintain
- In general, Go based solutions tend to be more reliable and robust than bash scripts

In order to achieve this, and to support multiple compression strategies, we would need to be able to swap compression algoritms that implement [io.Reader](https://pkg.go.dev/io#Reader) (decompress) and [io.Writer](https://pkg.go.dev/io#Writer) (compress). We could leverage the compress package for this purpose:
- https://pkg.go.dev/compress

Initially I would support `gzip` and `zlib`, which seem to be the more popular among the supported in the `compress` package but I am open to suggestions here. And of course, the `spec.compression` field should be optional, which would mean that the `Backup`/ `Restore` resources work as they do currently.

Thoughts? Let me know what you think.

Contributions are welcome! 
@eloo: Hi there,

i have just discovered this operator while looking for a better solution of spinning up databases and backups separately and was pretty impressed by the feature set of this operator.

But compression is really a must have in my use case so its awesome that this topic is already being worked on. 
As i have seen the discussion about the compression algorithm maybe the following project could be a good reference:
https://github.com/tiredofit/docker-db-backup

I use this docker backup solution since a few years and its working pretty stable. 
Maybe it could give some inspiration about common backup options..

Further it would be great if backup encryption could be implemented together with compression.
Thats always a good option when you store your backup offsite ;) 

Thanks for this great operator!
@TheNozomi: Hola @mmontes11! Thank you for the response. Yes, it's exactly what I've been thinking, and I agree that a go based solution would be a better approach than just invoking a binary. I just wasn't aware of the `compress` package (to be honest I'm pretty much new to the go ecosystem, that's the reason I didn't try to implement the feature myself). And, yes, I think having `gzip` and `zlib` support would be enough for the majority of use cases.

Also, thanks @eloo for mentioning `docker-db-backup`! Seems like I could look into using that in the meantime this feature gets implemented.@majordhote: That would be a great feature to reduce backup storage needs !@RegisHubelia: Hi, Thank you for the good work.  Not sure if this is the right channel or if I should add a new feature request - as compression would fix my immediate issue where the backup file is bigger than the allowed 5gb in a single put operation.  So the backup fails.  I have the option to separate the database backup operations, but uploading to s3 in part is also quite important so we don't hit this issue of upload limit in a single put operation...  Backup Encryption should also be on the pipeline to make it more production-ready as stated above.@mmontes11: Hey there @RegisHubelia ! We really appreciate this feedback, thank you

Compression first, and then encryption is definetly in our radar. After having prioritized [PITR](https://github.com/mariadb-operator/mariadb-operator/issues/507) in our roadmap, which makes use of S3, having S3 compression makes even more sense.@phanimullapudi: qpress is also commonly used compression algorithm @mmontes11: This was kindly contributed by @vixns via https://github.com/mariadb-operator/mariadb-operator/pull/836! We've decided to initially support the `gzip` (faster) and `bzip2` (better compression ratio) algorithms. Stay tuned for the next release! @mohag: zstd would be nice - it tend to outperform gzip and bzip2 by a LOT... (It would probably require exposing a level setting as well though...)

(The default settings takes 1/6th as long as gzip and compress somewhere between gzip and bzip2. Its best setting is a bit slower than bzip2 and compresses better (similar to xz's highest setting, in 1/5th the time)) ([Some benchmarks here](https://linuxreviews.org/Comparison_of_Compression_Algorithms))

Solution patch:
diff --git a/api/v1alpha1/backup_types.go b/api/v1alpha1/backup_types.go
index 7eecfd38..f74668ff 100644
--- a/api/v1alpha1/backup_types.go
+++ b/api/v1alpha1/backup_types.go
@@ -43,6 +43,27 @@ func (b *BackupStorage) Validate() error {
 	return nil
 }
 
+// CompressAlgorithm defines the compression algorithm for a Backup resource.
+type CompressAlgorithm string
+
+const (
+	// No compression
+	CompressNone CompressAlgorithm = "none"
+	// Bzip2 compression
+	CompressBzip2 CompressAlgorithm = "bzip2"
+	// Gzip compression
+	CompressGzip CompressAlgorithm = "gzip"
+)
+
+func (c CompressAlgorithm) Validate() error {
+	switch c {
+	case CompressAlgorithm(""), CompressNone, CompressBzip2, CompressGzip:
+		return nil
+	default:
+		return fmt.Errorf("invalid compression: %v, supported agorithms: [%v|%v|%v]", c, CompressNone, CompressBzip2, CompressGzip)
+	}
+}
+
 // BackupSpec defines the desired state of Backup
 type BackupSpec struct {
 	// JobContainerTemplate defines templates to configure Container objects.
@@ -58,6 +79,11 @@ type BackupSpec struct {
 	// +kubebuilder:validation:Required
 	// +operator-sdk:csv:customresourcedefinitions:type=spec
 	MariaDBRef MariaDBRef `json:"mariaDbRef" webhook:"inmutable"`
+	// Compression algorithm to be used in the Backup.
+	// +optional
+	// +kubebuilder:validation:Enum=none;bzip2;gzip
+	// +operator-sdk:csv:customresourcedefinitions:type=spec
+	Compression CompressAlgorithm `json:"compression,omitempty"`
 	// Storage to be used in the Backup.
 	// +kubebuilder:validation:Required
 	// +operator-sdk:csv:customresourcedefinitions:type=spec
@@ -148,10 +174,16 @@ func (b *Backup) Validate() error {
 	if err := b.Spec.Storage.Validate(); err != nil {
 		return fmt.Errorf("invalid Storage: %v", err)
 	}
+	if err := b.Spec.Compression.Validate(); err != nil {
+		return fmt.Errorf("invalid Compression: %v", err)
+	}
 	return nil
 }
 
 func (b *Backup) SetDefaults(mariadb *MariaDB) {
+	if b.Spec.Compression == CompressAlgorithm("") {
+		b.Spec.Compression = CompressNone
+	}
 	if b.Spec.MaxRetention == (metav1.Duration{}) {
 		b.Spec.MaxRetention = metav1.Duration{Duration: 30 * 24 * time.Hour}
 	}
diff --git a/cmd/backup/main.go b/cmd/backup/main.go
index 642237e9..1ad3b776 100644
--- a/cmd/backup/main.go
+++ b/cmd/backup/main.go
@@ -1,13 +1,19 @@
 package backup
 
 import (
+	"compress/gzip"
 	"context"
+	"errors"
 	"fmt"
+	"io"
 	"os"
 	"os/signal"
+	"path/filepath"
 	"syscall"
 	"time"
 
+	"github.com/dsnet/compress/bzip2"
+	mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1"
 	"github.com/mariadb-operator/mariadb-operator/pkg/backup"
 	"github.com/mariadb-operator/mariadb-operator/pkg/log"
 	"github.com/spf13/cobra"
@@ -26,6 +32,7 @@ var (
 	s3CACertPath   string
 	s3Prefix       string
 	maxRetention   time.Duration
+	compression    string
 )
 
 func init() {
@@ -49,6 +56,15 @@ func init() {
 	RootCmd.PersistentFlags().StringVar(&s3CACertPath, "s3-ca-cert-path", "", "Path to the CA to be trusted when connecting to S3.")
 	RootCmd.PersistentFlags().StringVar(&s3Prefix, "s3-prefix", "", "S3 bucket prefix name to use.")
 
+	RootCmd.PersistentFlags().StringVar(&compression, "compression", string(mariadbv1alpha1.CompressNone),
+		"Compression algorithm, none, gzip or bzip2.")
+
+	err := mariadbv1alpha1.CompressAlgorithm(compression).Validate()
+	if err != nil {
+		fmt.Printf("compression algorithm not supported: %v", err)
+		os.Exit(1)
+	}
+
 	RootCmd.Flags().DurationVar(&maxRetention, "max-retention", 30*24*time.Hour,
 		"Defines the retention policy for backups. Older backups will be deleted.")
 
@@ -84,6 +100,15 @@ var RootCmd = &cobra.Command{
 		}
 		logger.Info("obtained target backup", "file", backupTargetFile)
 
+		if compression != string(mariadbv1alpha1.CompressNone) {
+			logger.Info("compressing target backup", "file", backupTargetFile)
+			err := compressFile(filepath.Join(path, backupTargetFile), mariadbv1alpha1.CompressAlgorithm(compression))
+			if err != nil {
+				logger.Error(err, "error compressing file", "path", backupTargetFile, "error", err)
+				os.Exit(1)
+			}
+		}
+
 		logger.Info("pushing target backup", "file", backupTargetFile, "prefix", s3Prefix)
 		if err := backupStorage.Push(ctx, backupTargetFile); err != nil {
 			logger.Error(err, "error pushing target backup", "file", backupTargetFile, "prefix", s3Prefix)
@@ -155,3 +180,62 @@ func readTargetFile() (string, error) {
 	}
 	return string(bytes), nil
 }
+
+func compressFile(f string, compression mariadbv1alpha1.CompressAlgorithm) error {
+
+	originalFile, err := os.Open(f)
+	if err != nil {
+		return err
+	}
+	defer originalFile.Close()
+
+	tmpf := f + ".tmp"
+	compressedFile, err := os.Create(tmpf)
+	if err != nil {
+		return err
+	}
+	defer compressedFile.Close()
+
+	switch compression {
+
+	case mariadbv1alpha1.CompressGzip:
+		writer := gzip.NewWriter(compressedFile)
+		defer writer.Close()
+		_, err = io.Copy(writer, originalFile)
+		if err != nil {
+			return (err)
+		}
+		writer.Flush()
+
+	case mariadbv1alpha1.CompressBzip2:
+		writer, err := bzip2.NewWriter(compressedFile,
+			&bzip2.WriterConfig{Level: bzip2.DefaultCompression})
+		defer writer.Close()
+		if err != nil {
+			return (err)
+		}
+		_, err = io.Copy(writer, originalFile)
+		if err != nil {
+			return (err)
+		}
+
+	default:
+		err = os.Remove(tmpf)
+		if err != nil {
+			return (err)
+		}
+		return errors.New("unknown compression algorithm")
+	}
+
+	err = os.Remove(f)
+	if err != nil {
+		return (err)
+	}
+
+	err = os.Rename(tmpf, f)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/cmd/backup/restore.go b/cmd/backup/restore.go
index fef3e546..627a9482 100644
--- a/cmd/backup/restore.go
+++ b/cmd/backup/restore.go
@@ -1,10 +1,16 @@
 package backup
 
 import (
+	"compress/gzip"
 	"fmt"
+	"io"
 	"os"
+	"path/filepath"
+	"strings"
 	"time"
 
+	"github.com/dsnet/compress/bzip2"
+	mariadbv1alpha1 "github.com/mariadb-operator/mariadb-operator/api/v1alpha1"
 	"github.com/mariadb-operator/mariadb-operator/pkg/backup"
 	"github.com/mariadb-operator/mariadb-operator/pkg/log"
 	"github.com/spf13/cobra"
@@ -15,6 +21,7 @@ var targetTimeRaw string
 func init() {
 	restoreCommand.Flags().StringVar(&targetTimeRaw, "target-time", "",
 		"RFC3339 (1970-01-01T00:00:00Z) date and time that defines the backup target time.")
+	restoreCommand.Flags().StringVar(&path, "path", "/backup", "Directory path where the backup files are located.")
 }
 
 var restoreCommand = &cobra.Command{
@@ -63,8 +70,15 @@ var restoreCommand = &cobra.Command{
 			os.Exit(1)
 		}
 
+		logger.Info("uncompressing file", "path", backupTargetFile)
+		backupFile, err := uncompressFile(path, backupTargetFile)
+		if err != nil {
+			logger.Error(err, "error uncompressing file", "path", backupTargetFile)
+			os.Exit(1)
+		}
+
 		logger.Info("writing target file", "path", targetFilePath)
-		if err := writeTargetFile(backupTargetFile); err != nil {
+		if err := writeTargetFile(backupFile); err != nil {
 			logger.Error(err, "error writing target file", "path", targetFilePath)
 			os.Exit(1)
 		}
@@ -81,3 +95,68 @@ func getTargetTime() (time.Time, error) {
 func writeTargetFile(backupTargetFilePath string) error {
 	return os.WriteFile(targetFilePath, []byte(backupTargetFilePath), 0777)
 }
+
+func uncompressFile(path string, f string) (string, error) {
+
+	parts := strings.Split(f, ".")
+	if len(parts) == 3 {
+		// uncompressed file, do nothing
+		return f, nil
+	}
+
+	if len(parts) != 4 {
+		return "", fmt.Errorf("Invalid filename: %s", f)
+	}
+
+	calg := mariadbv1alpha1.CompressAlgorithm(parts[2])
+
+	err := calg.Validate()
+	if err != nil {
+		return "", err
+	}
+
+	if calg == mariadbv1alpha1.CompressNone {
+		// uncompressed file, do nothing
+		return f, nil
+	}
+
+	compressedFile, err := os.Open(filepath.Join(path, f))
+	if err != nil {
+		return "", err
+	}
+	defer compressedFile.Close()
+
+	plainFileName := fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[3])
+
+	plainFile, err := os.Create(filepath.Join(path, plainFileName))
+	if err != nil {
+		return "", err
+	}
+	defer plainFile.Close()
+
+	switch calg {
+	case mariadbv1alpha1.CompressGzip:
+		reader, err := gzip.NewReader(compressedFile)
+		if err != nil {
+			return "", err
+		}
+		defer reader.Close()
+		_, err = io.Copy(plainFile, reader)
+		if err != nil {
+			return "", err
+		}
+	case mariadbv1alpha1.CompressBzip2:
+		reader, err := bzip2.NewReader(compressedFile,
+			&bzip2.ReaderConfig{})
+		if err != nil {
+			return "", err
+		}
+		defer reader.Close()
+		_, err = io.Copy(plainFile, reader)
+		if err != nil {
+			return "", err
+		}
+	}
+
+	return plainFileName, nil
+}
diff --git a/config/crd/bases/k8s.mariadb.com_backups.yaml b/config/crd/bases/k8s.mariadb.com_backups.yaml
index c80fe1c9..2dcbd297 100644
--- a/config/crd/bases/k8s.mariadb.com_backups.yaml
+++ b/config/crd/bases/k8s.mariadb.com_backups.yaml
@@ -209,6 +209,13 @@ spec:
                   successfully take a Backup.
                 format: int32
                 type: integer
+              compression:
+                description: Compression algorithm to be used in the Backup.
+                enum:
+                - none
+                - bzip2
+                - gzip
+                type: string
               databases:
                 description: Databases defines the logical databases to be backed
                   up. If not provided, all databases are backed up.
diff --git a/config/manifests/bases/mariadb-operator-enterprise.clusterserviceversion.yaml b/config/manifests/bases/mariadb-operator-enterprise.clusterserviceversion.yaml
index 424a5e58..d189d78b 100644
--- a/config/manifests/bases/mariadb-operator-enterprise.clusterserviceversion.yaml
+++ b/config/manifests/bases/mariadb-operator-enterprise.clusterserviceversion.yaml
@@ -305,6 +305,9 @@ spec:
         x-descriptors:
         - urn:alm:descriptor:com.tectonic.ui:number
         - urn:alm:descriptor:com.tectonic.ui:advanced
+      - description: Compression algorithm to be used in the Backup.
+        displayName: Compression
+        path: compression
       - description: Databases defines the logical databases to be backed up. If not
           provided, all databases are backed up.
         displayName: Databases
diff --git a/deploy/charts/mariadb-operator-crds/templates/crds.yaml b/deploy/charts/mariadb-operator-crds/templates/crds.yaml
index 4ac14556..35e78fc6 100644
--- a/deploy/charts/mariadb-operator-crds/templates/crds.yaml
+++ b/deploy/charts/mariadb-operator-crds/templates/crds.yaml
@@ -208,6 +208,13 @@ spec:
                   successfully take a Backup.
                 format: int32
                 type: integer
+              compression:
+                description: Compression algorithm to be used in the Backup.
+                enum:
+                - none
+                - bzip2
+                - gzip
+                type: string
               databases:
                 description: Databases defines the logical databases to be backed
                   up. If not provided, all databases are backed up.
diff --git a/deploy/crds/crds.yaml b/deploy/crds/crds.yaml
index 4ac14556..35e78fc6 100644
--- a/deploy/crds/crds.yaml
+++ b/deploy/crds/crds.yaml
@@ -208,6 +208,13 @@ spec:
                   successfully take a Backup.
                 format: int32
                 type: integer
+              compression:
+                description: Compression algorithm to be used in the Backup.
+                enum:
+                - none
+                - bzip2
+                - gzip
+                type: string
               databases:
                 description: Databases defines the logical databases to be backed
                   up. If not provided, all databases are backed up.
diff --git a/deploy/olm/manifests/k8s.mariadb.com_backups.yaml b/deploy/olm/manifests/k8s.mariadb.com_backups.yaml
index 8fb85e0b..7862ceb4 100644
--- a/deploy/olm/manifests/k8s.mariadb.com_backups.yaml
+++ b/deploy/olm/manifests/k8s.mariadb.com_backups.yaml
@@ -209,6 +209,9 @@ spec:
                   successfully take a Backup.
                 format: int32
                 type: integer
+              compression:
+                description: Compression algorithm to be used in the Backup.
+                type: string
               databases:
                 description: Databases defines the logical databases to be backed
                   up. If not provided, all databases are backed up.
diff --git a/deploy/olm/manifests/mariadb-operator-enterprise.clusterserviceversion.yaml b/deploy/olm/manifests/mariadb-operator-enterprise.clusterserviceversion.yaml
index 11be2e8c..5eccf536 100644
--- a/deploy/olm/manifests/mariadb-operator-enterprise.clusterserviceversion.yaml
+++ b/deploy/olm/manifests/mariadb-operator-enterprise.clusterserviceversion.yaml
@@ -399,6 +399,9 @@ spec:
             x-descriptors:
               - urn:alm:descriptor:com.tectonic.ui:number
               - urn:alm:descriptor:com.tectonic.ui:advanced
+          - description: Compression algorithm to be used in the Backup.
+            displayName: Compression
+            path: compression
           - description: Databases defines the logical databases to be backed up. If not provided, all databases are backed up.
             displayName: Databases
             path: databases
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
index 4419d6c9..4c4aeb93 100644
--- a/docs/API_REFERENCE.md
+++ b/docs/API_REFERENCE.md
@@ -111,6 +111,7 @@ _Appears in:_
 | `failedJobsHistoryLimit` _integer_ | FailedJobsHistoryLimit defines the maximum number of failed Jobs to be displayed. |  | Minimum: 0 <br /> |
 | `timeZone` _string_ | TimeZone defines the timezone associated with the cron expression. |  |  |
 | `mariaDbRef` _[MariaDBRef](#mariadbref)_ | MariaDBRef is a reference to a MariaDB object. |  | Required: \{\} <br /> |
+| `compression` _[CompressAlgorithm](#compressalgorithm)_ | Compression algorithm to be used in the Backup. |  | Enum: [none bzip2 gzip] <br /> |
 | `storage` _[BackupStorage](#backupstorage)_ | Storage to be used in the Backup. |  | Required: \{\} <br /> |
 | `schedule` _[Schedule](#schedule)_ | Schedule defines when the Backup will be taken. |  |  |
 | `maxRetention` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#duration-v1-meta)_ | MaxRetention defines the retention policy for backups. Old backups will be cleaned up by the Backup Job.<br />It defaults to 30 days. |  |  |
@@ -198,6 +199,24 @@ _Appears in:_
 | `Delete` | CleanupPolicyDelete indicates that the resource will be deleted from the database after the CR is deleted.<br /> |
 
 
+#### CompressAlgorithm
+
+_Underlying type:_ _string_
+
+CompressAlgorithm defines the compression algorithm for a Backup resource.
+
+
+
+_Appears in:_
+- [BackupSpec](#backupspec)
+
+| Field | Description |
+| --- | --- |
+| `none` | No compression<br /> |
+| `bzip2` | Bzip2 compression<br /> |
+| `gzip` | Gzip compression<br /> |
+
+
 #### ConfigMapKeySelector
 
 
diff --git a/go.mod b/go.mod
index 8be112d8..3d35df15 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.23.1
 
 require (
 	github.com/distribution/reference v0.6.0
+	github.com/dsnet/compress v0.0.1
 	github.com/go-chi/chi/v5 v5.1.0
 	github.com/go-chi/httprate v0.14.1
 	github.com/go-logr/logr v1.4.2
diff --git a/go.sum b/go.sum
index 0550d343..973618cb 100644
--- a/go.sum
+++ b/go.sum
@@ -23,6 +23,9 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q=
+github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo=
+github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
@@ -124,8 +127,10 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
 github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
 github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
 github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
 github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -229,6 +234,7 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U=
 github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8=
+github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8=
 github.com/urfave/cli v1.22.2 h1:gsqYFH8bb9ekPA12kRo0hfjngWQjkJPlN9R0N78BoUo=
 github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 github.com/virtuald/go-ordered-json v0.0.0-20170621173500-b18e6e673d74 h1:JwtAtbp7r/7QSyGz8mKUbYJBg2+6Cd7OjM8o/GNOcVo=
diff --git a/pkg/backup/backup.go b/pkg/backup/backup.go
index abb42329..17a645b0 100644
--- a/pkg/backup/backup.go
+++ b/pkg/backup/backup.go
@@ -90,7 +90,7 @@ func ParseBackupDate(timeRaw string) (time.Time, error) {
 
 func parseDateInBackupFile(fileName string) (time.Time, error) {
 	parts := strings.Split(fileName, ".")
-	if len(parts) != 3 {
+	if len(parts) != 3 && len(parts) != 4 {
 		return time.Time{}, fmt.Errorf("invalid backup file name: %s", fileName)
 	}
 	return ParseBackupDate(parts[1])
diff --git a/pkg/builder/batch_builder.go b/pkg/builder/batch_builder.go
index 79769c4e..e3b8e0b7 100644
--- a/pkg/builder/batch_builder.go
+++ b/pkg/builder/batch_builder.go
@@ -54,6 +54,7 @@ func (b *Builder) BuildBackupJob(key types.NamespacedName, backup *mariadbv1alph
 			batchBackupTargetFilePath,
 		),
 		command.WithBackupMaxRetention(backup.Spec.MaxRetention.Duration),
+		command.WithBackupCompression(backup.Spec.Compression),
 		command.WithBackupUserEnv(batchUserEnv),
 		command.WithBackupPasswordEnv(batchPasswordEnv),
 		command.WithBackupLogLevel(backup.Spec.LogLevel),
diff --git a/pkg/command/backup.go b/pkg/command/backup.go
index 2121bafe..fab7a2d1 100644
--- a/pkg/command/backup.go
+++ b/pkg/command/backup.go
@@ -18,6 +18,7 @@ type BackupOpts struct {
 	TargetFilePath       string
 	MaxRetentionDuration time.Duration
 	TargetTime           time.Time
+	Compression          mariadbv1alpha1.CompressAlgorithm
 	S3                   bool
 	S3Bucket             string
 	S3Endpoint           string
@@ -50,6 +51,12 @@ func WithBackupTargetTime(t time.Time) BackupOpt {
 	}
 }
 
+func WithBackupCompression(c mariadbv1alpha1.CompressAlgorithm) BackupOpt {
+	return func(bo *BackupOpts) {
+		bo.Compression = c
+	}
+}
+
 func WithS3(bucket, endpoint, region, prefix string) BackupOpt {
 	return func(bo *BackupOpts) {
 		bo.S3 = true
@@ -173,6 +180,8 @@ func (b *BackupCommand) MariadbOperatorBackup() *Command {
 		b.TargetFilePath,
 		"--max-retention",
 		b.MaxRetentionDuration.String(),
+		"--compression",
+		string(b.Compression),
 		"--log-level",
 		b.LogLevel,
 	}
@@ -217,9 +226,17 @@ func (b *BackupCommand) MariadbRestore(restore *mariadbv1alpha1.Restore,
 }
 
 func (b *BackupCommand) newBackupFile() string {
+	var calg string
+	switch b.Compression {
+	case mariadbv1alpha1.CompressNone:
+		calg = ""
+	default:
+		calg = fmt.Sprintf(".%v", b.Compression)
+	}
 	return fmt.Sprintf(
-		"backup.$(date -u +'%s').sql",
+		"backup.$(date -u +'%s')%s.sql",
 		"%Y-%m-%dT%H:%M:%SZ",
+		calg,
 	)
 }
 



Test patch:
diff --git a/api/v1alpha1/backup_types_test.go b/api/v1alpha1/backup_types_test.go
index 73c9b384..abbcc710 100644
--- a/api/v1alpha1/backup_types_test.go
+++ b/api/v1alpha1/backup_types_test.go
@@ -40,6 +40,7 @@ var _ = Describe("Backup types", func() {
 						JobPodTemplate: JobPodTemplate{
 							ServiceAccountName: &objMeta.Name,
 						},
+						Compression:      CompressNone,
 						MaxRetention:     metav1.Duration{Duration: 30 * 24 * time.Hour},
 						IgnoreGlobalPriv: ptr.To(false),
 						BackoffLimit:     5,
@@ -65,6 +66,7 @@ var _ = Describe("Backup types", func() {
 						JobPodTemplate: JobPodTemplate{
 							ServiceAccountName: &objMeta.Name,
 						},
+						Compression:      CompressNone,
 						MaxRetention:     metav1.Duration{Duration: 30 * 24 * time.Hour},
 						IgnoreGlobalPriv: ptr.To(true),
 						BackoffLimit:     5,
@@ -115,6 +117,7 @@ var _ = Describe("Backup types", func() {
 								},
 							},
 						},
+						Compression:      CompressNone,
 						MaxRetention:     metav1.Duration{Duration: 30 * 24 * time.Hour},
 						IgnoreGlobalPriv: ptr.To(false),
 						BackoffLimit:     5,
@@ -132,6 +135,7 @@ var _ = Describe("Backup types", func() {
 								AntiAffinityEnabled: ptr.To(true),
 							},
 						},
+						Compression:      CompressNone,
 						MaxRetention:     metav1.Duration{Duration: 10 * 24 * time.Hour},
 						IgnoreGlobalPriv: ptr.To(false),
 						BackoffLimit:     3,
@@ -174,6 +178,7 @@ var _ = Describe("Backup types", func() {
 								},
 							},
 						},
+						Compression:      CompressNone,
 						MaxRetention:     metav1.Duration{Duration: 10 * 24 * time.Hour},
 						IgnoreGlobalPriv: ptr.To(false),
 						BackoffLimit:     3,
diff --git a/internal/controller/backup_controller_test.go b/internal/controller/backup_controller_test.go
index 643a6a89..f5107bf0 100644
--- a/internal/controller/backup_controller_test.go
+++ b/internal/controller/backup_controller_test.go
@@ -43,14 +43,33 @@ var _ = Describe("Backup", func() {
 			getBackupWithVolumeStorage,
 			testBackup,
 		),
+		Entry("should reconcile a Job with Volume storage and gzip compression",
+			"backup-volume-gzip-test",
+			applyDecoratorChain[*mariadbv1alpha1.Backup](
+				getBackupWithVolumeStorage,
+				decorateBackupWithGzipCompression,
+			),
+			testBackup,
+		),
 		Entry("should reconcile a Job with S3 storage",
 			"backup-s3-test",
 			buildBackupWithS3Storage("test-backup", ""),
 			testS3Backup,
 		),
+		Entry("should reconcile a Job with S3 storage and bzip2 compression",
+			"backup-s3-zlib-test",
+			applyDecoratorChain[*mariadbv1alpha1.Backup](
+				buildBackupWithS3Storage("test-backup", ""),
+				decorateBackupWithBzip2Compression,
+			),
+			testS3Backup,
+		),
 		Entry("should reconcile a Job with S3 storage with prefix",
 			"backup-s3-test-prefix",
-			buildBackupWithS3Storage("test-backup", "mariadb"),
+			applyDecoratorChain[*mariadbv1alpha1.Backup](
+				buildBackupWithS3Storage("test-backup", "mariadb"),
+				decorateBackupWithNoneCompression,
+			),
 			testS3Backup,
 		),
 		Entry("should reconcile a CronJob with PVC storage",
@@ -296,6 +315,21 @@ func decorateBackupWithTimeZone(backup *mariadbv1alpha1.Backup) *mariadbv1alpha1
 	return backup
 }
 
+func decorateBackupWithNoneCompression(backup *mariadbv1alpha1.Backup) *mariadbv1alpha1.Backup {
+	backup.Spec.Compression = mariadbv1alpha1.CompressNone
+	return backup
+}
+
+func decorateBackupWithGzipCompression(backup *mariadbv1alpha1.Backup) *mariadbv1alpha1.Backup {
+	backup.Spec.Compression = mariadbv1alpha1.CompressGzip
+	return backup
+}
+
+func decorateBackupWithBzip2Compression(backup *mariadbv1alpha1.Backup) *mariadbv1alpha1.Backup {
+	backup.Spec.Compression = mariadbv1alpha1.CompressBzip2
+	return backup
+}
+
 func buildBackupWithS3Storage(bucket, prefix string) func(key types.NamespacedName) *mariadbv1alpha1.Backup {
 	return func(key types.NamespacedName) *mariadbv1alpha1.Backup {
 		return getBackupWithS3Storage(key, bucket, prefix)
diff --git a/internal/controller/restore_controller_test.go b/internal/controller/restore_controller_test.go
index 3e101c03..ee085721 100644
--- a/internal/controller/restore_controller_test.go
+++ b/internal/controller/restore_controller_test.go
@@ -191,6 +191,126 @@ var _ = Describe("Restore", func() {
 		}, testTimeout, testInterval).Should(BeTrue())
 	})
 
+	It("should reconcile a Job with Volume storage and gzip compression", func() {
+		By("Creating Backup")
+		key := types.NamespacedName{
+			Name:      "restore-volume-test",
+			Namespace: testNamespace,
+		}
+		backupKey := types.NamespacedName{
+			Name:      fmt.Sprintf("%s-%s", key.Name, "backup"),
+			Namespace: testNamespace,
+		}
+		backup := decorateBackupWithGzipCompression(getBackupWithPVCStorage(backupKey))
+		Expect(k8sClient.Create(testCtx, backup)).To(Succeed())
+		DeferCleanup(func() {
+			Expect(k8sClient.Delete(testCtx, backup)).To(Succeed())
+		})
+
+		By("Expecting Backup to complete eventually")
+		Eventually(func() bool {
+			if err := k8sClient.Get(testCtx, backupKey, backup); err != nil {
+				return false
+			}
+			return backup.IsComplete()
+		}, testTimeout, testInterval).Should(BeTrue())
+
+		By("Creating Restore")
+		restore := &mariadbv1alpha1.Restore{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      key.Name,
+				Namespace: key.Namespace,
+			},
+			Spec: mariadbv1alpha1.RestoreSpec{
+				MariaDBRef: mariadbv1alpha1.MariaDBRef{
+					ObjectReference: mariadbv1alpha1.ObjectReference{
+						Name: testMdbkey.Name,
+					},
+					WaitForIt: true,
+				},
+				RestoreSource: mariadbv1alpha1.RestoreSource{
+					Volume: &mariadbv1alpha1.VolumeSource{
+						PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
+							ClaimName: backupKey.Name,
+						},
+					},
+				},
+			},
+		}
+		Expect(k8sClient.Create(testCtx, restore)).To(Succeed())
+		DeferCleanup(func() {
+			Expect(k8sClient.Delete(testCtx, restore)).To(Succeed())
+		})
+
+		By("Expecting Restore to complete eventually")
+		Eventually(func() bool {
+			if err := k8sClient.Get(testCtx, key, restore); err != nil {
+				return false
+			}
+			return restore.IsComplete()
+		}, testTimeout, testInterval).Should(BeTrue())
+	})
+
+	It("should reconcile a Job with Volume storage and bzip2 compression", func() {
+		By("Creating Backup")
+		key := types.NamespacedName{
+			Name:      "restore-volume-test",
+			Namespace: testNamespace,
+		}
+		backupKey := types.NamespacedName{
+			Name:      fmt.Sprintf("%s-%s", key.Name, "backup"),
+			Namespace: testNamespace,
+		}
+		backup := decorateBackupWithBzip2Compression(getBackupWithPVCStorage(backupKey))
+		Expect(k8sClient.Create(testCtx, backup)).To(Succeed())
+		DeferCleanup(func() {
+			Expect(k8sClient.Delete(testCtx, backup)).To(Succeed())
+		})
+
+		By("Expecting Backup to complete eventually")
+		Eventually(func() bool {
+			if err := k8sClient.Get(testCtx, backupKey, backup); err != nil {
+				return false
+			}
+			return backup.IsComplete()
+		}, testTimeout, testInterval).Should(BeTrue())
+
+		By("Creating Restore")
+		restore := &mariadbv1alpha1.Restore{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      key.Name,
+				Namespace: key.Namespace,
+			},
+			Spec: mariadbv1alpha1.RestoreSpec{
+				MariaDBRef: mariadbv1alpha1.MariaDBRef{
+					ObjectReference: mariadbv1alpha1.ObjectReference{
+						Name: testMdbkey.Name,
+					},
+					WaitForIt: true,
+				},
+				RestoreSource: mariadbv1alpha1.RestoreSource{
+					Volume: &mariadbv1alpha1.VolumeSource{
+						PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
+							ClaimName: backupKey.Name,
+						},
+					},
+				},
+			},
+		}
+		Expect(k8sClient.Create(testCtx, restore)).To(Succeed())
+		DeferCleanup(func() {
+			Expect(k8sClient.Delete(testCtx, restore)).To(Succeed())
+		})
+
+		By("Expecting Restore to complete eventually")
+		Eventually(func() bool {
+			if err := k8sClient.Get(testCtx, key, restore); err != nil {
+				return false
+			}
+			return restore.IsComplete()
+		}, testTimeout, testInterval).Should(BeTrue())
+	})
+
 	It("should reconcile a Job with S3 storage", func() {
 		key := types.NamespacedName{
 			Name:      "restore-s3-test",