mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-10 06:24:09 +09:00
fix(backups): report sizeBytes for MariaDB backup artifacts
Backup.status.artifact.sizeBytes was only ever set by the etcd strategy, so MariaDB backups always arrived without a size. mariadb-operator writes each logical dump as <prefix>/backup.<RFC3339>[.<compression>].sql under a prefix shared by every backup of the application, and the URI the driver records is synthetic, so there is no single object to HEAD. On completion the driver now lists the prefix with the tenant credentials the operator Backup was rendered with and takes the size of the one dump whose timestamp falls inside the operator Backup's lifetime. When the bucket cannot be read or the window does not match exactly one dump, the field stays unset. The API doc now states that an absent sizeBytes means "unknown", and the Velero strategy documents why it leaves the field unset: kopia stores data content-addressed across backups, so there is no per-backup stored size. Refs #4316 Assisted-by: LLM Signed-off-by: Andrey Kolkov <androndo@gmail.com>
This commit is contained in:
1 parent
4087218a02
commit
d4ad5612d3
9 files changed
+473
-5
No files matched your search
@@ -37,7 +37,9 @@ type BackupArtifact struct {
|
||||
// For example: s3://bucket/prefix/file.tar.gz
|
||||
URI string `json:"uri"`
|
||||
|
||||
// SizeBytes is the size of the artifact in bytes, if known.
|
||||
// SizeBytes is the size of the artifact in bytes, if known. Drivers
|
||||
// leave it unset rather than writing 0 when they cannot measure the
|
||||
// stored size, so an absent value means "unknown", not "empty".
|
||||
// +optional
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ require (
|
||||
github.com/go-logr/logr v1.4.4
|
||||
github.com/go-logr/zapr v1.3.0
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
github.com/onsi/ginkgo/v2 v2.33.0
|
||||
github.com/onsi/gomega v1.43.1
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
@@ -63,6 +64,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.4 // indirect
|
||||
github.com/gkampitakis/go-snaps v0.5.23 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.1 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.2 // indirect
|
||||
@@ -93,18 +95,25 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/moby/spdystream v0.5.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.3 // indirect
|
||||
github.com/prometheus/common v0.71.0 // indirect
|
||||
github.com/prometheus/procfs v0.22.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.16.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sirupsen/logrus v1.10.2 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.etcd.io/etcd/api/v3 v3.7.0 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.7.0 // indirect
|
||||
|
||||
@@ -61,6 +61,8 @@ github.com/gkampitakis/ciinfo v0.3.4 h1:5eBSibVuSMbb/H6Elc0IIEFbkzCJi3lm94n0+U7Z
|
||||
github.com/gkampitakis/ciinfo v0.3.4/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||
github.com/gkampitakis/go-snaps v0.5.23 h1:okh5QR48zpUjpWtu65AtqxdCY8huJq+dEDuUzd1PuKg=
|
||||
github.com/gkampitakis/go-snaps v0.5.23/go.mod h1:uy3lVzCCRRsAwYqSocyw5fY8xRLCYEfqoOJNxr8HonM=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -141,6 +143,11 @@ 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/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -151,6 +158,12 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg=
|
||||
github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y=
|
||||
github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -165,6 +178,8 @@ github.com/onsi/ginkgo/v2 v2.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8
|
||||
github.com/onsi/ginkgo/v2 v2.33.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||
github.com/onsi/gomega v1.43.1 h1:vGIPFuYrIO6/0Z09s0I0QQQgFchiX4+tb1re3MScJYo=
|
||||
github.com/onsi/gomega v1.43.1/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -180,6 +195,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
|
||||
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
@@ -206,6 +223,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
|
||||
github.com/vmware-tanzu/velero v1.18.3 h1:9uwN5yxTWQl9PsF5ljX4xiG+7VIq3I9mxOQ0DyyZ31A=
|
||||
|
||||
@@ -39,6 +39,10 @@ type BackupJobReconciler struct {
|
||||
// re-reads the BackupJob before dispatching to a driver. Wired in
|
||||
// SetupWithManager.
|
||||
APIReader client.Reader
|
||||
|
||||
// newS3ObjectLister overrides the S3 client used to measure artifact
|
||||
// sizes; nil means a real minio client. Set by unit tests.
|
||||
newS3ObjectLister func(ctx context.Context, namespace string, s3 *strategyv1alpha1.MariaDBS3Template) (s3ObjectLister, error)
|
||||
}
|
||||
|
||||
func (r *BackupJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
package backupcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
|
||||
"github.com/cozystack/cozystack/internal/backupcontroller/mariadbtypes"
|
||||
)
|
||||
|
||||
const (
|
||||
mariadbSizeProbeTimeout = 30 * time.Second
|
||||
|
||||
// Slack around the [created, completed] window of the operator Backup
|
||||
// when matching the timestamp mariadb-operator embeds in the dump file
|
||||
// name: that timestamp comes from the backup Job pod's clock, not the
|
||||
// apiserver's, and is truncated to seconds.
|
||||
mariadbSizeProbeClockSkew = time.Minute
|
||||
)
|
||||
|
||||
type s3ObjectInfo struct {
|
||||
Key string
|
||||
Size int64
|
||||
}
|
||||
|
||||
type s3ObjectLister interface {
|
||||
ListObjects(ctx context.Context, bucket, prefix string) ([]s3ObjectInfo, error)
|
||||
}
|
||||
|
||||
// mariadbArtifactSizeBytes measures the dump mariadb-operator uploaded for
|
||||
// mdbBackup. The strategy prefix is shared by every backup of the
|
||||
// application, so the dump is identified by the timestamp in its file name
|
||||
// falling inside the operator Backup's lifetime. Returns 0 - leaving
|
||||
// Artifact.SizeBytes absent - when the object store cannot be read or the
|
||||
// window does not match exactly one dump: an unknown size must not be
|
||||
// reported as a measured one.
|
||||
func (r *BackupJobReconciler) mariadbArtifactSizeBytes(
|
||||
ctx context.Context,
|
||||
namespace string,
|
||||
s3 *strategyv1alpha1.MariaDBS3Template,
|
||||
mdbBackup *mariadbtypes.Backup,
|
||||
completedAt time.Time,
|
||||
) int64 {
|
||||
logger := getLogger(ctx)
|
||||
ctx, cancel := context.WithTimeout(ctx, mariadbSizeProbeTimeout)
|
||||
defer cancel()
|
||||
|
||||
newLister := r.newS3ObjectLister
|
||||
if newLister == nil {
|
||||
newLister = r.newMariaDBMinioLister
|
||||
}
|
||||
lister, err := newLister(ctx, namespace, s3)
|
||||
if err != nil {
|
||||
logger.Debug("cannot build S3 client to measure MariaDB backup size", "backup", mdbBackup.Name, "error", err)
|
||||
return 0
|
||||
}
|
||||
prefix := mariadbOperatorS3Prefix(s3.Prefix)
|
||||
objects, err := lister.ListObjects(ctx, s3.Bucket, prefix)
|
||||
if err != nil {
|
||||
logger.Debug("cannot list MariaDB backup objects to measure size", "backup", mdbBackup.Name, "error", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
lower := mdbBackup.CreationTimestamp.Add(-mariadbSizeProbeClockSkew)
|
||||
upper := completedAt.Add(mariadbSizeProbeClockSkew)
|
||||
var matched []s3ObjectInfo
|
||||
for _, obj := range objects {
|
||||
ts, ok := parseMariaDBLogicalBackupFile(strings.TrimPrefix(obj.Key, prefix))
|
||||
if !ok || ts.Before(lower) || ts.After(upper) {
|
||||
continue
|
||||
}
|
||||
matched = append(matched, obj)
|
||||
}
|
||||
if len(matched) != 1 {
|
||||
logger.Debug("MariaDB backup dump not attributable to a single object; leaving size unset",
|
||||
"backup", mdbBackup.Name, "candidates", len(matched))
|
||||
return 0
|
||||
}
|
||||
return matched[0].Size
|
||||
}
|
||||
|
||||
// mariadbOperatorS3Prefix mirrors how mariadb-operator normalises
|
||||
// spec.storage.s3.prefix before prepending it to object keys.
|
||||
func mariadbOperatorS3Prefix(prefix string) string {
|
||||
if prefix == "" || prefix == "/" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
return prefix + "/"
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
|
||||
// parseMariaDBLogicalBackupFile accepts the logical-backup file names
|
||||
// mariadb-operator writes, backup.<RFC3339>.sql and
|
||||
// backup.<RFC3339>.<compression>.sql, and returns the embedded timestamp.
|
||||
func parseMariaDBLogicalBackupFile(name string) (time.Time, bool) {
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
if parts[0] != "backup" || parts[len(parts)-1] != "sql" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ts, err := time.Parse(time.RFC3339, parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return ts, true
|
||||
}
|
||||
|
||||
type minioObjectLister struct {
|
||||
client *minio.Client
|
||||
}
|
||||
|
||||
func (l *minioObjectLister) ListObjects(ctx context.Context, bucket, prefix string) ([]s3ObjectInfo, error) {
|
||||
var out []s3ObjectInfo
|
||||
for obj := range l.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix}) {
|
||||
if obj.Err != nil {
|
||||
return nil, obj.Err
|
||||
}
|
||||
out = append(out, s3ObjectInfo{Key: obj.Key, Size: obj.Size})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newMariaDBMinioLister builds an S3 client from the same Secrets and TLS
|
||||
// settings the operator Backup was rendered with, so the controller reads
|
||||
// the bucket with the tenant's credentials rather than its own.
|
||||
func (r *BackupJobReconciler) newMariaDBMinioLister(ctx context.Context, namespace string, s3 *strategyv1alpha1.MariaDBS3Template) (s3ObjectLister, error) {
|
||||
accessKey, err := r.mariadbSecretValue(ctx, namespace, s3.AccessKeyIDSecretKeyRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secretKey, err := r.mariadbSecretValue(ctx, namespace, s3.SecretAccessKeySecretKeyRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sessionToken string
|
||||
if s3.SessionTokenSecretKeyRef != nil {
|
||||
if sessionToken, err = r.mariadbSecretValue(ctx, namespace, *s3.SessionTokenSecretKeyRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
opts := &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, sessionToken),
|
||||
Region: s3.Region,
|
||||
Secure: s3.TLS != nil && s3.TLS.Enabled,
|
||||
}
|
||||
if opts.Secure && s3.TLS.CASecretKeyRef != nil {
|
||||
caPEM, err := r.mariadbSecretValue(ctx, namespace, *s3.TLS.CASecretKeyRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM([]byte(caPEM)) {
|
||||
return nil, fmt.Errorf("no PEM certificates in Secret %s/%s key %q", namespace, s3.TLS.CASecretKeyRef.Name, s3.TLS.CASecretKeyRef.Key)
|
||||
}
|
||||
opts.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}
|
||||
}
|
||||
client, err := minio.New(s3.Endpoint, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &minioObjectLister{client: client}, nil
|
||||
}
|
||||
|
||||
func (r *BackupJobReconciler) mariadbSecretValue(ctx context.Context, namespace string, ref strategyv1alpha1.MariaDBSecretKeySelector) (string, error) {
|
||||
secret := &corev1.Secret{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: ref.Name}, secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
v, ok := secret.Data[ref.Key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("Secret %s/%s has no key %q", namespace, ref.Name, ref.Key)
|
||||
}
|
||||
return string(v), nil
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
package backupcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
"github.com/cozystack/cozystack/internal/backupcontroller/mariadbapp"
|
||||
"github.com/cozystack/cozystack/internal/backupcontroller/mariadbtypes"
|
||||
)
|
||||
|
||||
type fakeS3Lister struct {
|
||||
objects []s3ObjectInfo
|
||||
err error
|
||||
|
||||
bucket, prefix string
|
||||
}
|
||||
|
||||
func (f *fakeS3Lister) ListObjects(_ context.Context, bucket, prefix string) ([]s3ObjectInfo, error) {
|
||||
f.bucket, f.prefix = bucket, prefix
|
||||
return f.objects, f.err
|
||||
}
|
||||
|
||||
func reconcilerWithLister(l *fakeS3Lister) *BackupJobReconciler {
|
||||
return &BackupJobReconciler{
|
||||
newS3ObjectLister: func(context.Context, string, *strategyv1alpha1.MariaDBS3Template) (s3ObjectLister, error) {
|
||||
return l, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func dumpKey(prefix string, ts time.Time, compression string) string {
|
||||
if compression == "" {
|
||||
return fmt.Sprintf("%sbackup.%s.sql", prefix, ts.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return fmt.Sprintf("%sbackup.%s.%s.sql", prefix, ts.UTC().Format(time.RFC3339), compression)
|
||||
}
|
||||
|
||||
func TestParseMariaDBLogicalBackupFile(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"backup.2026-10-21T10:00:00Z.sql": true,
|
||||
"backup.2026-10-21T10:00:00Z.gzip.sql": true,
|
||||
"backup.2026-10-21T10:00:00+04:00.sql": true,
|
||||
"backup.2026-10-21T10:00:00Z.sql.tmp": false,
|
||||
"physicalbackup-20261021100000.xb": false,
|
||||
"backup.not-a-date.sql": false,
|
||||
"sub/backup.2026-10-21T10:00:00Z.sql": false,
|
||||
"backup.2026-10-21T10:00:00Z.a.gzip.sql": false,
|
||||
"backups.2026-10-21T10:00:00Z.gzip.sql": false,
|
||||
"backup.2026-10-21T10:00:00Z.gzip.dump": false,
|
||||
"": false,
|
||||
}
|
||||
for name, want := range cases {
|
||||
if _, got := parseMariaDBLogicalBackupFile(name); got != want {
|
||||
t.Errorf("parseMariaDBLogicalBackupFile(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMariaDBOperatorS3Prefix(t *testing.T) {
|
||||
cases := map[string]string{"": "", "/": "", "ns/app": "ns/app/", "ns/app/": "ns/app/"}
|
||||
for in, want := range cases {
|
||||
if got := mariadbOperatorS3Prefix(in); got != want {
|
||||
t.Errorf("mariadbOperatorS3Prefix(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMariaDBArtifactSizeBytes(t *testing.T) {
|
||||
created := time.Date(2026, 10, 21, 10, 0, 0, 0, time.UTC)
|
||||
completed := created.Add(3 * time.Minute)
|
||||
mdbBackup := &mariadbtypes.Backup{ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "tenant", Name: "op-bk", CreationTimestamp: metav1.NewTime(created),
|
||||
}}
|
||||
s3 := newRenderedMariaDBTemplate().Storage.S3
|
||||
s3.Prefix = "tenant/db"
|
||||
const prefix = "tenant/db/"
|
||||
|
||||
t.Run("single dump inside the backup window", func(t *testing.T) {
|
||||
l := &fakeS3Lister{objects: []s3ObjectInfo{
|
||||
{Key: dumpKey(prefix, created.Add(-24*time.Hour), "gzip"), Size: 111},
|
||||
{Key: dumpKey(prefix, created.Add(time.Minute), "gzip"), Size: 4242},
|
||||
{Key: prefix + "0-backup-target.txt", Size: 7},
|
||||
}}
|
||||
got := reconcilerWithLister(l).mariadbArtifactSizeBytes(context.Background(), "tenant", s3, mdbBackup, completed)
|
||||
if got != 4242 {
|
||||
t.Errorf("size = %d, want 4242", got)
|
||||
}
|
||||
if l.bucket != "bkt" || l.prefix != prefix {
|
||||
t.Errorf("listed %q/%q, want bkt/%q", l.bucket, l.prefix, prefix)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pod clock skew within tolerance", func(t *testing.T) {
|
||||
l := &fakeS3Lister{objects: []s3ObjectInfo{
|
||||
{Key: dumpKey(prefix, created.Add(-30*time.Second), ""), Size: 9},
|
||||
}}
|
||||
if got := reconcilerWithLister(l).mariadbArtifactSizeBytes(context.Background(), "tenant", s3, mdbBackup, completed); got != 9 {
|
||||
t.Errorf("size = %d, want 9", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two dumps in the window leave size unset", func(t *testing.T) {
|
||||
l := &fakeS3Lister{objects: []s3ObjectInfo{
|
||||
{Key: dumpKey(prefix, created.Add(time.Minute), "gzip"), Size: 1},
|
||||
{Key: dumpKey(prefix, created.Add(2*time.Minute), "gzip"), Size: 2},
|
||||
}}
|
||||
if got := reconcilerWithLister(l).mariadbArtifactSizeBytes(context.Background(), "tenant", s3, mdbBackup, completed); got != 0 {
|
||||
t.Errorf("size = %d, want 0 (ambiguous)", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no dump in the window leaves size unset", func(t *testing.T) {
|
||||
l := &fakeS3Lister{objects: []s3ObjectInfo{
|
||||
{Key: dumpKey(prefix, completed.Add(time.Hour), "gzip"), Size: 1},
|
||||
}}
|
||||
if got := reconcilerWithLister(l).mariadbArtifactSizeBytes(context.Background(), "tenant", s3, mdbBackup, completed); got != 0 {
|
||||
t.Errorf("size = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list error leaves size unset", func(t *testing.T) {
|
||||
l := &fakeS3Lister{err: errors.New("AccessDenied")}
|
||||
if got := reconcilerWithLister(l).mariadbArtifactSizeBytes(context.Background(), "tenant", s3, mdbBackup, completed); got != 0 {
|
||||
t.Errorf("size = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing credentials Secret leaves size unset", func(t *testing.T) {
|
||||
c := newMariaDBStrategyTestClient(t)
|
||||
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
|
||||
if got := r.mariadbArtifactSizeBytes(context.Background(), "tenant", s3, mdbBackup, completed); got != 0 {
|
||||
t.Errorf("size = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMariaDBArtifactSizeBytes_MinioClient drives the real minio client
|
||||
// against a stub ListObjectsV2 endpoint, so the Secret wiring, prefix and
|
||||
// response parsing are exercised together.
|
||||
func TestMariaDBArtifactSizeBytes_MinioClient(t *testing.T) {
|
||||
created := time.Date(2026, 10, 21, 10, 0, 0, 0, time.UTC)
|
||||
key := dumpKey("tenant/db/", created.Add(time.Minute), "gzip")
|
||||
|
||||
var gotPath, gotPrefix, gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
gotPath, gotPrefix, gotAuth = req.URL.Path, req.URL.Query().Get("prefix"), req.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Name>bkt</Name><Prefix>tenant/db/</Prefix><KeyCount>1</KeyCount><MaxKeys>1000</MaxKeys><IsTruncated>false</IsTruncated>
|
||||
<Contents><Key>%s</Key><Size>31337</Size><ETag>"x"</ETag><LastModified>2026-10-21T10:01:00.000Z</LastModified></Contents>
|
||||
</ListBucketResult>`, key)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
creds := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "creds"},
|
||||
Data: map[string][]byte{
|
||||
"AWS_ACCESS_KEY_ID": []byte("AKID"),
|
||||
"AWS_SECRET_ACCESS_KEY": []byte("secret"),
|
||||
},
|
||||
}
|
||||
c := newMariaDBStrategyTestClient(t, creds)
|
||||
r := &BackupJobReconciler{Client: c, Scheme: c.Scheme()}
|
||||
|
||||
s3 := newRenderedMariaDBTemplate().Storage.S3
|
||||
s3.Endpoint = strings.TrimPrefix(srv.URL, "http://")
|
||||
s3.Prefix = "tenant/db/"
|
||||
s3.Region = "us-east-1"
|
||||
mdbBackup := &mariadbtypes.Backup{ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "tenant", Name: "op-bk", CreationTimestamp: metav1.NewTime(created),
|
||||
}}
|
||||
|
||||
if got := r.mariadbArtifactSizeBytes(context.Background(), "tenant", s3, mdbBackup, created.Add(3*time.Minute)); got != 31337 {
|
||||
t.Fatalf("size = %d, want 31337", got)
|
||||
}
|
||||
if gotPath != "/bkt/" || gotPrefix != "tenant/db/" {
|
||||
t.Errorf("request path=%q prefix=%q, want /bkt/ and tenant/db/", gotPath, gotPrefix)
|
||||
}
|
||||
if !strings.Contains(gotAuth, "Credential=AKID/") {
|
||||
t.Errorf("request not signed with the Secret's access key: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMariaDBBackupArtifact_SizeBytes(t *testing.T) {
|
||||
apps := mariadbapp.GroupName
|
||||
completed := metav1.NewTime(time.Date(2026, 10, 21, 10, 3, 0, 0, time.UTC))
|
||||
mdbBackup := &mariadbtypes.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "tenant", Name: "src-op-bk",
|
||||
CreationTimestamp: metav1.NewTime(completed.Add(-3 * time.Minute)),
|
||||
},
|
||||
Status: mariadbtypes.BackupStatus{Conditions: []metav1.Condition{{
|
||||
Type: mariadbtypes.ConditionTypeComplete, Status: metav1.ConditionTrue, LastTransitionTime: completed,
|
||||
}}},
|
||||
}
|
||||
resolved := &ResolvedBackupConfig{
|
||||
StrategyRef: corev1.TypedLocalObjectReference{Kind: "MariaDB", Name: "mariadb-strategy-default"},
|
||||
Parameters: map[string]string{},
|
||||
}
|
||||
job := &backupsv1alpha1.BackupJob{
|
||||
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant", Name: "bj-size"},
|
||||
Spec: backupsv1alpha1.BackupJobSpec{
|
||||
ApplicationRef: corev1.TypedLocalObjectReference{Kind: "MariaDB", Name: "src", APIGroup: &apps},
|
||||
},
|
||||
}
|
||||
c := newMariaDBStrategyTestClient(t, job)
|
||||
r := reconcilerWithLister(&fakeS3Lister{objects: []s3ObjectInfo{
|
||||
{Key: dumpKey("src/", completed.Add(-time.Minute), "gzip"), Size: 2048},
|
||||
}})
|
||||
r.Client, r.Scheme = c, c.Scheme()
|
||||
rendered := newRenderedMariaDBTemplate()
|
||||
rendered.Storage.S3.Prefix = "src/"
|
||||
|
||||
artefact, err := r.createMariaDBBackupArtifact(context.Background(), job, resolved, mdbBackup, rendered)
|
||||
if err != nil {
|
||||
t.Fatalf("createMariaDBBackupArtifact: %v", err)
|
||||
}
|
||||
if artefact.Status.Artifact == nil || artefact.Status.Artifact.SizeBytes != 2048 {
|
||||
t.Fatalf("Artifact = %#v, want SizeBytes 2048", artefact.Status.Artifact)
|
||||
}
|
||||
}
|
||||
@@ -392,7 +392,10 @@ func (r *BackupJobReconciler) createMariaDBBackupArtifact(
|
||||
// operator's PVC ID), so an Artifact{URI: ""} is a worse signal than
|
||||
// no Artifact at all - leave it nil instead.
|
||||
if uri := driverMD[mariadbStorageURIKey]; uri != "" {
|
||||
status.Artifact = &backupsv1alpha1.BackupArtifact{URI: uri}
|
||||
status.Artifact = &backupsv1alpha1.BackupArtifact{
|
||||
URI: uri,
|
||||
SizeBytes: r.mariadbArtifactSizeBytes(ctx, j.Namespace, rendered.Storage.S3, mdbBackup, takenAt.Time),
|
||||
}
|
||||
}
|
||||
backup := &backupsv1alpha1.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
|
||||
@@ -558,7 +558,9 @@ func (r *BackupJobReconciler) createBackupResource(ctx context.Context, backupJo
|
||||
veleroBackupNamespaceMetadataKey: veleroBackup.Namespace,
|
||||
}
|
||||
|
||||
// Create a basic artifact referencing the Velero backup
|
||||
// SizeBytes stays unset: data lands in a content-addressed kopia
|
||||
// repository shared across backups, so there is no per-backup stored
|
||||
// size, and DataUpload totalBytes counts bytes read, not bytes stored.
|
||||
artifact := &backupsv1alpha1.BackupArtifact{
|
||||
URI: fmt.Sprintf("velero://%s/%s", veleroBackup.Namespace, veleroBackup.Name),
|
||||
}
|
||||
|
||||
+4
-2
@@ -131,8 +131,10 @@ spec:
|
||||
For example: "sha256:<hex>".
|
||||
type: string
|
||||
sizeBytes:
|
||||
description: SizeBytes is the size of the artifact in bytes, if
|
||||
known.
|
||||
description: |-
|
||||
SizeBytes is the size of the artifact in bytes, if known. Drivers
|
||||
leave it unset rather than writing 0 when they cannot measure the
|
||||
stored size, so an absent value means "unknown", not "empty".
|
||||
format: int64
|
||||
type: integer
|
||||
uri:
|
||||
|
||||
Reference in new issue
Block a user