mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-29 08:07:33 +09:00
resolveMongoDBBackupSource folded two failure classes into one error return:
a transient API read error on the live PerconaServerMongoDBBackup CR (cache
not yet synced, apiserver hiccup) and the terminal no-restorable-destination
case. The RestoreJob caller turned every error into markRestoreJobFailed, so a
retryable read error failed a restore that would have succeeded on the next
reconcile. The backup path already treats the symmetric live-CR read error as
transient (return ctrl.Result{}, err).
Wrap the transient read error in a new errTransientRestoreSource sentinel and
requeue on it at the caller; only a genuinely unresolvable source stays
terminal. Adds a non-vacuous interceptor-driven test.
Addresses the MINOR robustness note from PR review.
Signed-off-by: Andrey Kolkov <androndo@gmail.com>
1090 lines
46 KiB
Go
1090 lines
46 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
package backupcontroller
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
apimeta "k8s.io/apimachinery/pkg/api/meta"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
|
|
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
|
|
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
|
"github.com/cozystack/cozystack/internal/backupcontroller/mongodbapp"
|
|
"github.com/cozystack/cozystack/internal/backupcontroller/psmdbtypes"
|
|
"github.com/cozystack/cozystack/internal/template"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
// mongodbAppKind / mongodbAppPrefix map a cozystack MongoDB application
|
|
// instance to its operator-side psmdb.percona.com/PerconaServerMongoDB CR.
|
|
// The mongodb-rd ApplicationDefinition renders the HelmRelease with
|
|
// releaseName = "mongodb-" + appName (release.prefix in
|
|
// packages/system/mongodb-rd/cozyrds/mongodb.yaml), and the chart's
|
|
// templates/mongodb.yaml sets the PerconaServerMongoDB metadata.name to
|
|
// .Release.Name — so the driver looks up by the prefixed name. Mirrors the
|
|
// MariaDB driver's mariadbAppPrefix.
|
|
mongodbAppKind = "MongoDB"
|
|
mongodbAppPrefix = "mongodb-"
|
|
|
|
// psmdbDefaultStorageName is the storage the mongodb chart declares in the
|
|
// PerconaServerMongoDB CR's spec.backup.storages when backup.enabled=true
|
|
// (see packages/apps/mongodb/templates/mongodb.yaml). The strategy names a
|
|
// storage; when it leaves StorageName empty the driver falls back to this.
|
|
psmdbDefaultStorageName = "s3-storage"
|
|
|
|
// Driver-metadata keys persisted on Cozystack Backup artifacts. The
|
|
// restore path reads Destination (+ the S3 snapshot) to drive the operator
|
|
// Restore CR's backupSource, so a restore-to-differently-named instance
|
|
// works without the source PerconaServerMongoDBBackup CR still existing.
|
|
psmdbBackupNameKey = "psmdb.percona.com/backup-name"
|
|
psmdbBackupNamespaceKey = "psmdb.percona.com/backup-namespace"
|
|
psmdbDestinationKey = "psmdb.percona.com/destination"
|
|
|
|
// Polling cadence for the operator Backup/Restore lifecycle.
|
|
psmdbPollInterval = 5 * time.Second
|
|
|
|
// Wall-clock cap on a BackupJob waiting for the operator Backup to reach
|
|
// state=ready. A permanently-stuck backup (e.g. the cluster never brings
|
|
// up its pbm agents) must not pin the BackupJob in Running and wedge the
|
|
// Plan-controller queue. Mirrors the MariaDB/CNPG deadline.
|
|
psmdbDefaultBackupDeadline = 30 * time.Minute
|
|
|
|
// Default deadline on a RestoreJob waiting for the operator Restore to
|
|
// terminate. Tenants override via spec.options.restoreTimeoutSeconds.
|
|
psmdbDefaultRestoreDeadline = 30 * time.Minute
|
|
|
|
// psmdbBackupSnapshotKind is the Kind stamped onto the snapshot persisted
|
|
// in Backup.status.underlyingResources. It carries the S3 storage
|
|
// descriptor (by reference — bucket/endpoint/credentialsSecret NAME, never
|
|
// a raw credential) and the backup destination read back from the operator
|
|
// Backup status, so the restore path can rebuild backupSource even after
|
|
// the operator-side Backup CR has been reaped.
|
|
psmdbBackupSnapshotKind = "MongoDBBackupSnapshot"
|
|
)
|
|
|
|
// psmdbBackupSnapshotAPIVersion is the apiVersion stamped onto the snapshot.
|
|
// Borrows the Cozystack backups group so the field is self-typed within the
|
|
// existing API surface. Mirrors the MariaDB driver.
|
|
var psmdbBackupSnapshotAPIVersion = backupsv1alpha1.GroupVersion.String()
|
|
|
|
// mongodbNameForApp returns the psmdb.percona.com/PerconaServerMongoDB CR name
|
|
// for a cozystack MongoDB application instance.
|
|
func mongodbNameForApp(appName string) string {
|
|
return mongodbAppPrefix + appName
|
|
}
|
|
|
|
// validateMongoDBApplicationRef rejects ApplicationRefs that name a
|
|
// Kind/APIGroup the MongoDB driver does not own. Empty APIGroup is accepted and
|
|
// treated as the default (apps.cozystack.io), matching the BackupClass
|
|
// resolution helpers and the Plan/BackupJob CRD docs.
|
|
func validateMongoDBApplicationRef(ref corev1.TypedLocalObjectReference) error {
|
|
if ref.Kind != mongodbAppKind {
|
|
return fmt.Errorf("MongoDB strategy supports applicationRef.kind=%q, got %q", mongodbAppKind, ref.Kind)
|
|
}
|
|
apiGroup := ""
|
|
if ref.APIGroup != nil {
|
|
apiGroup = *ref.APIGroup
|
|
}
|
|
if apiGroup != "" && apiGroup != mongodbapp.GroupName {
|
|
return fmt.Errorf("MongoDB strategy supports applicationRef.apiGroup=%q, got %q", mongodbapp.GroupName, apiGroup)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// psmdbBackupTypeOrDefault returns the rendered backup type, defaulting to
|
|
// logical. Only logical is supported (the CRD enum on MongoDBTemplate.Type
|
|
// enforces this at admission; this keeps a pre-admission strategy well-defined).
|
|
func psmdbBackupTypeOrDefault(t string) string {
|
|
if t == "" {
|
|
return psmdbtypes.BackupTypeLogical
|
|
}
|
|
return t
|
|
}
|
|
|
|
// psmdbStorageNameOrDefault resolves the storage name the operator Backup CR
|
|
// references, defaulting to the chart's storage.
|
|
func psmdbStorageNameOrDefault(name string) string {
|
|
if name == "" {
|
|
return psmdbDefaultStorageName
|
|
}
|
|
return name
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BackupJob path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (r *BackupJobReconciler) reconcileMongoDB(ctx context.Context, j *backupsv1alpha1.BackupJob, resolved *ResolvedBackupConfig) (ctrl.Result, error) {
|
|
logger := getLogger(ctx)
|
|
logger.Debug("reconciling MongoDB strategy", "backupjob", j.Name, "phase", j.Status.Phase)
|
|
|
|
if j.Status.Phase == backupsv1alpha1.BackupJobPhaseSucceeded ||
|
|
j.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
|
|
return ctrl.Result{}, nil
|
|
}
|
|
|
|
if err := validateMongoDBApplicationRef(j.Spec.ApplicationRef); err != nil {
|
|
return r.markBackupJobFailed(ctx, j, err.Error())
|
|
}
|
|
|
|
if j.Status.StartedAt == nil {
|
|
// Refetch the latest persisted state before writing StartedAt: a stale
|
|
// informer cache that returns StartedAt==nil after we already persisted
|
|
// it would otherwise let the deadline gate slide forward on every poll.
|
|
// Same idempotency pattern as the MariaDB/CNPG drivers.
|
|
fresh := &backupsv1alpha1.BackupJob{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: j.Namespace, Name: j.Name}, fresh); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
if fresh.Status.StartedAt != nil {
|
|
j.Status.StartedAt = fresh.Status.StartedAt
|
|
} else {
|
|
base := fresh.DeepCopy()
|
|
now := metav1.Now()
|
|
fresh.Status.StartedAt = &now
|
|
if err := r.Status().Patch(ctx, fresh, client.MergeFrom(base)); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{RequeueAfter: psmdbPollInterval}, nil
|
|
}
|
|
}
|
|
|
|
strategy := &strategyv1alpha1.MongoDB{}
|
|
if err := r.Get(ctx, client.ObjectKey{Name: resolved.StrategyRef.Name}, strategy); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return r.requeueStrategyNotReady(ctx, j, resolved.StrategyRef.Name)
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
app, err := r.getMongoDBApp(ctx, j.Namespace, j.Spec.ApplicationRef.Name)
|
|
if err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
// A GitOps apply that lands the MongoDB app and the BackupJob in one
|
|
// commit can reconcile the BackupJob before the app CR is visible in
|
|
// the cache. Tolerate a not-yet-present app with the same bounded
|
|
// grace the psmdb-CR existence gate below uses, instead of failing
|
|
// terminally on a transient ordering race. StartedAt was persisted
|
|
// above, so the deadline clock is already running.
|
|
if psmdbBackupDeadlineExceeded(j.Status.StartedAt) {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf(
|
|
"MongoDB application %s/%s not found within %s",
|
|
j.Namespace, j.Spec.ApplicationRef.Name, psmdbDefaultBackupDeadline))
|
|
}
|
|
return r.requeueMongoDBBackupWaiting(ctx, j, "MongoDBApplicationNotReady",
|
|
fmt.Sprintf("waiting for MongoDB application %s/%s to exist", j.Namespace, j.Spec.ApplicationRef.Name))
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
rendered, err := renderMongoDBTemplate(strategy.Spec.Template, app, resolved.Parameters)
|
|
if err != nil {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to template MongoDB strategy: %v", err))
|
|
}
|
|
storageName := psmdbStorageNameOrDefault(rendered.StorageName)
|
|
|
|
// The operator-side PerconaServerMongoDB CR carries the prefixed release
|
|
// name. Verify it exists and has backups wired up before we ask the
|
|
// operator to snapshot it. psmdb only services PerconaServerMongoDBBackup
|
|
// CRs when spec.backup.enabled=true and the named storage is declared;
|
|
// without that the Backup would sit in waiting/error forever, so surface a
|
|
// precise precondition instead. Bounded by psmdbDefaultBackupDeadline —
|
|
// StartedAt was persisted above, so the clock is already running.
|
|
psmdbName := mongodbNameForApp(j.Spec.ApplicationRef.Name)
|
|
cluster := &psmdbtypes.PerconaServerMongoDB{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: j.Namespace, Name: psmdbName}, cluster); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
if psmdbBackupDeadlineExceeded(j.Status.StartedAt) {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf(
|
|
"psmdb.percona.com/PerconaServerMongoDB %s/%s never reached existence within %s",
|
|
j.Namespace, psmdbName, psmdbDefaultBackupDeadline))
|
|
}
|
|
return r.requeueMongoDBBackupWaiting(ctx, j, "PerconaServerMongoDBNotReady",
|
|
fmt.Sprintf("waiting for psmdb.percona.com/PerconaServerMongoDB %s/%s to exist", j.Namespace, psmdbName))
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
if msg := psmdbBackupPrecondition(cluster, storageName); msg != "" {
|
|
if psmdbBackupDeadlineExceeded(j.Status.StartedAt) {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf(
|
|
"psmdb.percona.com/PerconaServerMongoDB %s/%s not ready for backups within %s: %s (set backup.enabled=true on the MongoDB application)",
|
|
j.Namespace, psmdbName, psmdbDefaultBackupDeadline, msg))
|
|
}
|
|
return r.requeueMongoDBBackupWaiting(ctx, j, "PerconaServerMongoDBBackupsDisabled", msg)
|
|
}
|
|
|
|
mdbBackup, err := r.ensureMongoDBBackup(ctx, j, psmdbName, storageName, rendered)
|
|
if err != nil {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to ensure psmdb.percona.com/PerconaServerMongoDBBackup: %v", err))
|
|
}
|
|
|
|
if j.Status.Phase != backupsv1alpha1.BackupJobPhaseRunning {
|
|
j.Status.Phase = backupsv1alpha1.BackupJobPhaseRunning
|
|
if err := r.Status().Update(ctx, j); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
}
|
|
|
|
switch state := mdbBackup.Status.State; {
|
|
case state == psmdbtypes.StateReady:
|
|
// Backup completed successfully. Materialise the Cozystack artifact and
|
|
// finalise the BackupJob. Idempotent: reuse the existing artifact if a
|
|
// previous reconcile created it and then raced on the status update.
|
|
if j.Status.BackupRef != nil {
|
|
return ctrl.Result{}, nil
|
|
}
|
|
artifact, err := r.createMongoDBBackupArtifact(ctx, j, resolved, mdbBackup, rendered, storageName)
|
|
if err != nil {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to create Backup artifact: %v", err))
|
|
}
|
|
now := metav1.Now()
|
|
j.Status.BackupRef = &corev1.LocalObjectReference{Name: artifact.Name}
|
|
j.Status.CompletedAt = &now
|
|
j.Status.Phase = backupsv1alpha1.BackupJobPhaseSucceeded
|
|
apimeta.SetStatusCondition(&j.Status.Conditions, metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionTrue,
|
|
Reason: "BackupCompleted",
|
|
Message: "psmdb.percona.com PerconaServerMongoDBBackup completed",
|
|
})
|
|
if err := r.Status().Update(ctx, j); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{}, nil
|
|
|
|
case state == psmdbtypes.StateError || state == psmdbtypes.StateRejected:
|
|
// Terminal failure. The operator sets state=error/rejected once the pbm
|
|
// backup fails or is refused, so fail the BackupJob immediately rather
|
|
// than waiting for the driver-side deadline.
|
|
message := mdbBackup.Status.Error
|
|
if message == "" {
|
|
message = fmt.Sprintf("psmdb.percona.com PerconaServerMongoDBBackup reported state=%s", state)
|
|
}
|
|
return r.markBackupJobFailed(ctx, j, message)
|
|
|
|
default:
|
|
// Still in progress ("", requested, running, waiting). Apply a
|
|
// wall-clock deadline so a permanently-stuck backup eventually fails
|
|
// the BackupJob instead of pinning it Running forever.
|
|
if psmdbBackupDeadlineExceeded(j.Status.StartedAt) {
|
|
detail := "no state observed"
|
|
if state != "" {
|
|
detail = fmt.Sprintf("state=%s", state)
|
|
}
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf(
|
|
"psmdb.percona.com PerconaServerMongoDBBackup did not complete within %s (%s)", psmdbDefaultBackupDeadline, detail))
|
|
}
|
|
return ctrl.Result{RequeueAfter: psmdbPollInterval}, nil
|
|
}
|
|
}
|
|
|
|
// requeueMongoDBBackupWaiting records a transient Ready=False condition on the
|
|
// BackupJob and requeues. Factored out because the backup path has two
|
|
// waiting-for-precondition branches (cluster absent, backups disabled) that
|
|
// share the same shape.
|
|
func (r *BackupJobReconciler) requeueMongoDBBackupWaiting(ctx context.Context, j *backupsv1alpha1.BackupJob, reason, message string) (ctrl.Result, error) {
|
|
apimeta.SetStatusCondition(&j.Status.Conditions, metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionFalse,
|
|
Reason: reason,
|
|
Message: message,
|
|
})
|
|
if err := r.Status().Update(ctx, j); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{RequeueAfter: psmdbPollInterval}, nil
|
|
}
|
|
|
|
// psmdbBackupPrecondition reports why a cluster is not ready to service an
|
|
// on-demand backup, or "" when it is. The operator only runs pbm agents (and
|
|
// therefore only completes PerconaServerMongoDBBackup CRs) when backups are
|
|
// enabled and the named storage is declared.
|
|
func psmdbBackupPrecondition(cluster *psmdbtypes.PerconaServerMongoDB, storageName string) string {
|
|
if !cluster.Spec.Backup.Enabled {
|
|
return "spec.backup.enabled is false; on-demand backups need the percona-backup-mongodb agents running"
|
|
}
|
|
if _, ok := cluster.Spec.Backup.Storages[storageName]; !ok {
|
|
return fmt.Sprintf("spec.backup.storages does not declare storage %q", storageName)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// psmdbTargetCredentialsSecret returns the s3 credentialsSecret name the target
|
|
// cluster declares for its backup storage, so a restore authenticates with the
|
|
// TARGET's own credentials rather than the (possibly deleted) source's. It
|
|
// prefers the storage whose name the source backup used, then the chart's
|
|
// default storage, then — only when the cluster declares exactly one storage —
|
|
// that sole storage. Returns "" when nothing is resolvable, in which case the
|
|
// caller keeps the source reference. spec.backup.storages is decoded on demand
|
|
// from runtime.RawExtension (the partial type keeps only the key set for the
|
|
// precondition gate), so no upstream storage shape is mirrored here.
|
|
func psmdbTargetCredentialsSecret(cluster *psmdbtypes.PerconaServerMongoDB, preferredStorage string) string {
|
|
storages := cluster.Spec.Backup.Storages
|
|
if len(storages) == 0 {
|
|
return ""
|
|
}
|
|
if preferredStorage != "" {
|
|
if cred := psmdbStorageS3CredentialsSecret(storages[preferredStorage]); cred != "" {
|
|
return cred
|
|
}
|
|
}
|
|
if cred := psmdbStorageS3CredentialsSecret(storages[psmdbDefaultStorageName]); cred != "" {
|
|
return cred
|
|
}
|
|
if len(storages) == 1 {
|
|
for _, raw := range storages {
|
|
return psmdbStorageS3CredentialsSecret(raw)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// psmdbStorageS3CredentialsSecret extracts .s3.credentialsSecret from one
|
|
// psmdb spec.backup.storages entry. Returns "" for an empty/non-object entry,
|
|
// a non-s3 storage, or an entry that names no secret.
|
|
func psmdbStorageS3CredentialsSecret(raw runtime.RawExtension) string {
|
|
if len(raw.Raw) == 0 {
|
|
return ""
|
|
}
|
|
var st struct {
|
|
S3 struct {
|
|
CredentialsSecret string `json:"credentialsSecret"`
|
|
} `json:"s3"`
|
|
}
|
|
if err := json.Unmarshal(raw.Raw, &st); err != nil {
|
|
return ""
|
|
}
|
|
return st.S3.CredentialsSecret
|
|
}
|
|
|
|
// psmdbBackupDeadlineExceeded reports whether enough wall-clock time elapsed
|
|
// since the BackupJob started that we should give up on a stuck operator
|
|
// backup. Returns false when StartedAt is nil so the first reconcile does not
|
|
// trip the gate.
|
|
func psmdbBackupDeadlineExceeded(startedAt *metav1.Time) bool {
|
|
if startedAt == nil {
|
|
return false
|
|
}
|
|
return time.Since(startedAt.Time) > psmdbDefaultBackupDeadline
|
|
}
|
|
|
|
// ensureMongoDBBackup creates a one-shot PerconaServerMongoDBBackup CR labelled
|
|
// with the BackupJob, or returns the existing one if a previous reconcile
|
|
// already created it. Idempotency relies on the OwningJob labels.
|
|
func (r *BackupJobReconciler) ensureMongoDBBackup(ctx context.Context, j *backupsv1alpha1.BackupJob, clusterName, storageName string, rendered *strategyv1alpha1.MongoDBTemplate) (*psmdbtypes.PerconaServerMongoDBBackup, error) {
|
|
existing, err := r.findMongoDBBackupForJob(ctx, j)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existing != nil {
|
|
return existing, nil
|
|
}
|
|
|
|
obj := &psmdbtypes.PerconaServerMongoDBBackup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Namespace: j.Namespace,
|
|
GenerateName: fmt.Sprintf("%s-", j.Name),
|
|
Labels: map[string]string{
|
|
backupsv1alpha1.OwningJobNameLabel: j.Name,
|
|
backupsv1alpha1.OwningJobNamespaceLabel: j.Namespace,
|
|
},
|
|
},
|
|
Spec: psmdbtypes.PerconaServerMongoDBBackupSpec{
|
|
ClusterName: clusterName,
|
|
StorageName: storageName,
|
|
Type: psmdbBackupTypeOrDefault(rendered.Type),
|
|
CompressionType: rendered.CompressionType,
|
|
},
|
|
}
|
|
if rendered.CompressionLevel != nil {
|
|
lvl := *rendered.CompressionLevel
|
|
obj.Spec.CompressionLevel = &lvl
|
|
}
|
|
|
|
if err := r.Create(ctx, obj); err != nil {
|
|
return nil, err
|
|
}
|
|
return obj, nil
|
|
}
|
|
|
|
// findMongoDBBackupForJob returns the PerconaServerMongoDBBackup labelled with
|
|
// the BackupJob's OwningJob{Name,Namespace}, if any. Returns (nil, nil) when no
|
|
// match is found. See the MariaDB driver's findMariaDBBackupForJob for the
|
|
// duplicate-observation rationale (list race across replicas → pick [0]
|
|
// deterministically with a breadcrumb).
|
|
func (r *BackupJobReconciler) findMongoDBBackupForJob(ctx context.Context, j *backupsv1alpha1.BackupJob) (*psmdbtypes.PerconaServerMongoDBBackup, error) {
|
|
list := &psmdbtypes.PerconaServerMongoDBBackupList{}
|
|
if err := r.List(ctx, list,
|
|
client.InNamespace(j.Namespace),
|
|
client.MatchingLabels{
|
|
backupsv1alpha1.OwningJobNameLabel: j.Name,
|
|
backupsv1alpha1.OwningJobNamespaceLabel: j.Namespace,
|
|
},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(list.Items) == 0 {
|
|
return nil, nil
|
|
}
|
|
if len(list.Items) > 1 {
|
|
names := make([]string, 0, len(list.Items))
|
|
for i := range list.Items {
|
|
names = append(names, list.Items[i].Name)
|
|
}
|
|
getLogger(ctx).Debug("multiple PerconaServerMongoDBBackup CRs match BackupJob OwningJob labels; reusing first",
|
|
"backupjob", j.Name, "namespace", j.Namespace, "matches", names, "picked", names[0])
|
|
}
|
|
return &list.Items[0], nil
|
|
}
|
|
|
|
// createMongoDBBackupArtifact materialises a Cozystack Backup resource carrying
|
|
// the metadata callers need to drive a future restore: the operator backup
|
|
// name/namespace, the S3 destination, and a snapshot of the storage descriptor
|
|
// (persisted in status.underlyingResources) so a restore can rebuild
|
|
// backupSource even once the operator-side Backup CR is reaped.
|
|
func (r *BackupJobReconciler) createMongoDBBackupArtifact(
|
|
ctx context.Context,
|
|
j *backupsv1alpha1.BackupJob,
|
|
resolved *ResolvedBackupConfig,
|
|
mdbBackup *psmdbtypes.PerconaServerMongoDBBackup,
|
|
rendered *strategyv1alpha1.MongoDBTemplate,
|
|
storageName string,
|
|
) (*backupsv1alpha1.Backup, error) {
|
|
takenAt := metav1.Now()
|
|
if mdbBackup.Status.Completed != nil && !mdbBackup.Status.Completed.IsZero() {
|
|
takenAt = *mdbBackup.Status.Completed
|
|
}
|
|
|
|
driverMD := map[string]string{
|
|
psmdbBackupNameKey: mdbBackup.Name,
|
|
psmdbBackupNamespaceKey: mdbBackup.Namespace,
|
|
}
|
|
if mdbBackup.Status.Destination != "" {
|
|
driverMD[psmdbDestinationKey] = mdbBackup.Status.Destination
|
|
}
|
|
|
|
underlyingResources, err := marshalMongoDBBackupSnapshot(mdbBackup, rendered, storageName, resolved.Parameters)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode source snapshot for Backup.status.underlyingResources: %w", err)
|
|
}
|
|
|
|
status := backupsv1alpha1.BackupStatus{
|
|
Phase: backupsv1alpha1.BackupPhaseReady,
|
|
UnderlyingResources: underlyingResources,
|
|
}
|
|
if mdbBackup.Status.Destination != "" {
|
|
status.Artifact = &backupsv1alpha1.BackupArtifact{URI: mdbBackup.Status.Destination}
|
|
}
|
|
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: j.Name,
|
|
Namespace: j.Namespace,
|
|
},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: j.Spec.ApplicationRef,
|
|
StrategyRef: resolved.StrategyRef,
|
|
TakenAt: takenAt,
|
|
DriverMetadata: driverMD,
|
|
},
|
|
Status: status,
|
|
}
|
|
if j.Spec.PlanRef != nil {
|
|
backup.Spec.PlanRef = j.Spec.PlanRef
|
|
}
|
|
if err := r.Create(ctx, backup); err != nil {
|
|
if !apierrors.IsAlreadyExists(err) {
|
|
return nil, err
|
|
}
|
|
existing := &backupsv1alpha1.Backup{}
|
|
if getErr := r.Get(ctx, types.NamespacedName{Namespace: backup.Namespace, Name: backup.Name}, existing); getErr != nil {
|
|
return nil, getErr
|
|
}
|
|
return existing, nil
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RestoreJob path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (r *RestoreJobReconciler) reconcileMongoDBRestore(ctx context.Context, restoreJob *backupsv1alpha1.RestoreJob, backup *backupsv1alpha1.Backup) (ctrl.Result, error) {
|
|
logger := getLogger(ctx)
|
|
logger.Debug("reconciling MongoDB restore", "restorejob", restoreJob.Name, "backup", backup.Name)
|
|
|
|
if restoreJob.Status.Phase == backupsv1alpha1.RestoreJobPhaseSucceeded ||
|
|
restoreJob.Status.Phase == backupsv1alpha1.RestoreJobPhaseFailed {
|
|
return ctrl.Result{}, nil
|
|
}
|
|
|
|
if err := validateMongoDBApplicationRef(backup.Spec.ApplicationRef); err != nil {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, err.Error())
|
|
}
|
|
|
|
// Validate the resolved target shape before any apiserver call.
|
|
target := r.resolveMongoDBRestoreTarget(restoreJob, backup)
|
|
if target.Kind != mongodbAppKind {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"target applicationRef.kind=%q is not supported by the MongoDB driver", target.Kind))
|
|
}
|
|
if target.APIGroup != "" && target.APIGroup != mongodbapp.GroupName {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"target applicationRef.apiGroup=%q is not supported by the MongoDB driver", target.APIGroup))
|
|
}
|
|
|
|
if restoreJob.Status.StartedAt == nil {
|
|
fresh := &backupsv1alpha1.RestoreJob{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: restoreJob.Namespace, Name: restoreJob.Name}, fresh); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
if fresh.Status.StartedAt != nil {
|
|
restoreJob.Status.StartedAt = fresh.Status.StartedAt
|
|
if fresh.Status.Phase != "" {
|
|
restoreJob.Status.Phase = fresh.Status.Phase
|
|
}
|
|
} else {
|
|
base := fresh.DeepCopy()
|
|
now := metav1.Now()
|
|
fresh.Status.StartedAt = &now
|
|
fresh.Status.Phase = backupsv1alpha1.RestoreJobPhaseRunning
|
|
if err := r.Status().Patch(ctx, fresh, client.MergeFrom(base)); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
restoreJob.Status.StartedAt = fresh.Status.StartedAt
|
|
restoreJob.Status.Phase = fresh.Status.Phase
|
|
return ctrl.Result{RequeueAfter: psmdbPollInterval}, nil
|
|
}
|
|
}
|
|
|
|
// parseMongoDBRestoreOptions is intentionally permissive (event + log,
|
|
// proceed with defaults) for a malformed blob, but a malformed recoveryTime
|
|
// is load-bearing — restoring to the wrong point silently would be worse
|
|
// than failing — so a bad recoveryTime fails terminally below.
|
|
options, unknownKeys, err := parseMongoDBRestoreOptions(restoreJob.Spec.Options)
|
|
if err != nil {
|
|
// Falling back to defaults is safe for most malformed options, but not
|
|
// when the blob carries a recoveryTime key: silently degrading a PITR
|
|
// request to a plain snapshot restore is exactly the wrong-point restore
|
|
// pitrSpec fails terminally to prevent. spec.options is free-form
|
|
// runtime.RawExtension, so a non-string recoveryTime (e.g. {"recoveryTime":
|
|
// 20260805}) passes admission yet fails the typed decode here — fail
|
|
// terminally rather than restore to the snapshot and report Succeeded.
|
|
if restoreOptionsCarryRecoveryTimeKey(restoreJob.Spec.Options) {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"restoreJob.spec.options carries a recoveryTime but is malformed (%v); refusing to fall back to a plain snapshot restore, which would silently ignore the requested point-in-time", err))
|
|
}
|
|
logger.Info("malformed restoreJob.spec.options; falling back to defaults", "error", err)
|
|
r.Recorder.Eventf(restoreJob, corev1.EventTypeWarning, "MalformedOptions",
|
|
"spec.options is not valid JSON; falling back to defaults: %v", err)
|
|
}
|
|
// Warn (don't fail) on keys the MongoDB driver doesn't recognise: a typo
|
|
// like "recoverytime" would otherwise be silently ignored and the restore
|
|
// would run to the snapshot instead of the intended point. A Warning event
|
|
// gives the tenant a breadcrumb without rejecting an otherwise-valid restore.
|
|
for _, k := range unknownKeys {
|
|
logger.Info("ignoring unknown restoreJob.spec.options key", "key", k, "known", mongodbKnownRestoreOptionKeys)
|
|
r.Recorder.Eventf(restoreJob, corev1.EventTypeWarning, "UnknownRestoreOption",
|
|
"spec.options.%s is not a recognised MongoDB restore option and was ignored (known: %s)",
|
|
k, strings.Join(mongodbKnownRestoreOptionKeys, ", "))
|
|
}
|
|
pitr, perr := options.pitrSpec()
|
|
if perr != nil {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf("invalid restoreJob.spec.options.recoveryTime: %v", perr))
|
|
}
|
|
|
|
// Resolve the backup source (destination + S3 config). Prefer the live
|
|
// operator Backup CR's status (freshest), fall back to the snapshot
|
|
// persisted on the Cozystack Backup so a reaped operator CR still restores.
|
|
source, err := r.resolveMongoDBBackupSource(ctx, backup)
|
|
if err != nil {
|
|
// A read error on the live operator Backup CR is retryable (cache not
|
|
// yet synced, apiserver hiccup); requeue rather than fail the
|
|
// RestoreJob, matching the backup path's handling of the symmetric
|
|
// read error. Only a genuinely unresolvable source (no destination)
|
|
// is terminal.
|
|
if errors.Is(err, errTransientRestoreSource) {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return r.markRestoreJobFailed(ctx, restoreJob, err.Error())
|
|
}
|
|
|
|
// The operator Restore replays into a live cluster, so the target
|
|
// PerconaServerMongoDB must exist (and, for the pbm agents to run the
|
|
// restore, have backups enabled). Mirror the backup path's transient
|
|
// handling and let the deadline guard a target that never shows up.
|
|
targetPSMDBName := mongodbNameForApp(target.AppName)
|
|
targetCluster := &psmdbtypes.PerconaServerMongoDB{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: target.Namespace, Name: targetPSMDBName}, targetCluster); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
deadline := options.effectiveRestoreDeadline()
|
|
if restoreJob.Status.StartedAt != nil && time.Since(restoreJob.Status.StartedAt.Time) > deadline {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"target psmdb.percona.com/PerconaServerMongoDB %s/%s not found within %s (deploy the target MongoDB application with backup.enabled=true before requesting the restore; override via spec.options.restoreTimeoutSeconds)",
|
|
target.Namespace, targetPSMDBName, deadline))
|
|
}
|
|
return r.requeueMongoDBRestoreWaiting(ctx, restoreJob, "TargetPerconaServerMongoDBNotReady",
|
|
fmt.Sprintf("waiting for target psmdb.percona.com/PerconaServerMongoDB %s/%s to exist", target.Namespace, targetPSMDBName))
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
if !targetCluster.Spec.Backup.Enabled {
|
|
deadline := options.effectiveRestoreDeadline()
|
|
if restoreJob.Status.StartedAt != nil && time.Since(restoreJob.Status.StartedAt.Time) > deadline {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"target psmdb.percona.com/PerconaServerMongoDB %s/%s has spec.backup.enabled=false within %s; the percona-backup-mongodb agents must run to restore (set backup.enabled=true on the target MongoDB application)",
|
|
target.Namespace, targetPSMDBName, deadline))
|
|
}
|
|
return r.requeueMongoDBRestoreWaiting(ctx, restoreJob, "TargetPerconaServerMongoDBBackupsDisabled",
|
|
fmt.Sprintf("waiting for target psmdb.percona.com/PerconaServerMongoDB %s/%s to enable backups", target.Namespace, targetPSMDBName))
|
|
}
|
|
|
|
// Re-point the restore at the TARGET cluster's own S3 credentials. The
|
|
// backupSource carries the SOURCE cluster's credentialsSecret (read back from
|
|
// the source backup status / snapshot), which names the source release's
|
|
// -s3-creds Secret. That Secret is deleted together with the source app —
|
|
// exactly the disaster-recovery case backups exist for — so a restore into a
|
|
// fresh, differently-named target must not depend on it. The restore replays
|
|
// into the target, whose own -s3-creds Secret is guaranteed present
|
|
// (backup.enabled was gated just above) and grants access to the backup's
|
|
// bucket. The backupSource's bucket/prefix/endpoint still come from the
|
|
// source (that is where the archive lives); only the credential reference is
|
|
// swapped. Falls back to the source reference when the target declares no
|
|
// discoverable s3 credentials (in-place restore into the still-present source
|
|
// resolves to the same Secret, so this is a no-op there).
|
|
if source.S3 != nil {
|
|
if cred := psmdbTargetCredentialsSecret(targetCluster, source.StorageName); cred != "" && cred != source.S3.CredentialsSecret {
|
|
logger.Debug("re-pointing MongoDB restore credentials at target cluster storage",
|
|
"restorejob", restoreJob.Name, "from", source.S3.CredentialsSecret, "to", cred)
|
|
source.S3.CredentialsSecret = cred
|
|
}
|
|
}
|
|
|
|
mdbRestore, err := r.ensureMongoDBRestore(ctx, restoreJob, targetPSMDBName, source, pitr)
|
|
if err != nil {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf("failed to ensure psmdb.percona.com/PerconaServerMongoDBRestore: %v", err))
|
|
}
|
|
|
|
switch state := mdbRestore.Status.State; {
|
|
case state == psmdbtypes.StateReady:
|
|
now := metav1.Now()
|
|
restoreJob.Status.CompletedAt = &now
|
|
restoreJob.Status.Phase = backupsv1alpha1.RestoreJobPhaseSucceeded
|
|
apimeta.SetStatusCondition(&restoreJob.Status.Conditions, metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionTrue,
|
|
Reason: "RestoreCompleted",
|
|
Message: fmt.Sprintf("psmdb.percona.com/PerconaServerMongoDBRestore %s/%s completed", mdbRestore.Namespace, mdbRestore.Name),
|
|
})
|
|
if err := r.Status().Update(ctx, restoreJob); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{}, nil
|
|
|
|
case state == psmdbtypes.StateError || state == psmdbtypes.StateRejected:
|
|
message := mdbRestore.Status.Error
|
|
if message == "" {
|
|
message = fmt.Sprintf("psmdb.percona.com/PerconaServerMongoDBRestore %s/%s reported state=%s", mdbRestore.Namespace, mdbRestore.Name, state)
|
|
}
|
|
return r.markRestoreJobFailed(ctx, restoreJob, message)
|
|
|
|
default:
|
|
deadline := options.effectiveRestoreDeadline()
|
|
if restoreJob.Status.StartedAt != nil && time.Since(restoreJob.Status.StartedAt.Time) > deadline {
|
|
detail := "no state observed"
|
|
if state != "" {
|
|
detail = fmt.Sprintf("state=%s", state)
|
|
}
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"psmdb.percona.com/PerconaServerMongoDBRestore did not complete within %s (%s; override via spec.options.restoreTimeoutSeconds)",
|
|
deadline, detail))
|
|
}
|
|
return ctrl.Result{RequeueAfter: psmdbPollInterval}, nil
|
|
}
|
|
}
|
|
|
|
// requeueMongoDBRestoreWaiting records a transient Ready=False condition on the
|
|
// RestoreJob and requeues.
|
|
func (r *RestoreJobReconciler) requeueMongoDBRestoreWaiting(ctx context.Context, rj *backupsv1alpha1.RestoreJob, reason, message string) (ctrl.Result, error) {
|
|
apimeta.SetStatusCondition(&rj.Status.Conditions, metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionFalse,
|
|
Reason: reason,
|
|
Message: message,
|
|
})
|
|
if err := r.Status().Update(ctx, rj); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{RequeueAfter: psmdbPollInterval}, nil
|
|
}
|
|
|
|
// errTransientRestoreSource marks a backup-source resolution failure that a
|
|
// retry can clear — an API read error (cache not yet synced, apiserver hiccup)
|
|
// on the live operator Backup CR — as opposed to a terminal one (no restorable
|
|
// destination). The RestoreJob reconciler requeues on it rather than failing
|
|
// the job, mirroring the backup path, which requeues the symmetric live-CR read
|
|
// error (return ctrl.Result{}, err) instead of failing the BackupJob.
|
|
var errTransientRestoreSource = errors.New("transient backup-source resolution error")
|
|
|
|
// resolveMongoDBBackupSource builds the restore backupSource from the freshest
|
|
// available source: the live operator Backup CR's status when it still exists,
|
|
// otherwise the snapshot persisted on the Cozystack Backup at backup time.
|
|
// Fails terminally when neither yields a destination — without one there is
|
|
// nothing to restore from. A transient read error on the live Backup CR is
|
|
// wrapped in errTransientRestoreSource so the caller can requeue instead.
|
|
func (r *RestoreJobReconciler) resolveMongoDBBackupSource(ctx context.Context, backup *backupsv1alpha1.Backup) (*psmdbtypes.BackupSource, error) {
|
|
// Live operator Backup CR (same namespace as the Cozystack Backup).
|
|
sourceBackupName := backup.Spec.DriverMetadata[psmdbBackupNameKey]
|
|
if sourceBackupName != "" {
|
|
live := &psmdbtypes.PerconaServerMongoDBBackup{}
|
|
err := r.Get(ctx, types.NamespacedName{Namespace: backup.Namespace, Name: sourceBackupName}, live)
|
|
if err == nil {
|
|
if live.Status.Destination != "" {
|
|
return &psmdbtypes.BackupSource{
|
|
Type: psmdbBackupTypeOrDefault(live.Status.Type),
|
|
Destination: live.Status.Destination,
|
|
StorageName: live.Status.StorageName,
|
|
S3: live.Status.S3.DeepCopy(),
|
|
}, nil
|
|
}
|
|
} else if !apierrors.IsNotFound(err) {
|
|
return nil, fmt.Errorf("%w: get source PerconaServerMongoDBBackup %s/%s: %v", errTransientRestoreSource, backup.Namespace, sourceBackupName, err)
|
|
}
|
|
}
|
|
|
|
// Snapshot fallback.
|
|
snap, err := unmarshalMongoDBBackupSnapshot(backup.Status.UnderlyingResources)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode Backup snapshot: %v", err)
|
|
}
|
|
destination := backup.Spec.DriverMetadata[psmdbDestinationKey]
|
|
if snap != nil && snap.Destination != "" {
|
|
destination = snap.Destination
|
|
}
|
|
if destination == "" {
|
|
return nil, fmt.Errorf(
|
|
"Backup has no restorable destination (the operator PerconaServerMongoDBBackup was reaped and no snapshot destination is persisted); re-run the BackupJob")
|
|
}
|
|
src := &psmdbtypes.BackupSource{
|
|
Type: psmdbtypes.BackupTypeLogical,
|
|
Destination: destination,
|
|
}
|
|
if snap != nil {
|
|
if snap.Type != "" {
|
|
src.Type = snap.Type
|
|
}
|
|
src.StorageName = snap.StorageName
|
|
src.S3 = snap.S3.DeepCopy()
|
|
}
|
|
return src, nil
|
|
}
|
|
|
|
// ensureMongoDBRestore creates a PerconaServerMongoDBRestore CR labelled with
|
|
// the RestoreJob, or returns the existing one if a previous reconcile already
|
|
// created it.
|
|
func (r *RestoreJobReconciler) ensureMongoDBRestore(ctx context.Context, rj *backupsv1alpha1.RestoreJob, targetClusterName string, source *psmdbtypes.BackupSource, pitr *psmdbtypes.PITRSpec) (*psmdbtypes.PerconaServerMongoDBRestore, error) {
|
|
list := &psmdbtypes.PerconaServerMongoDBRestoreList{}
|
|
if err := r.List(ctx, list,
|
|
client.InNamespace(rj.Namespace),
|
|
client.MatchingLabels{
|
|
backupsv1alpha1.OwningJobNameLabel: rj.Name,
|
|
backupsv1alpha1.OwningJobNamespaceLabel: rj.Namespace,
|
|
},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(list.Items) > 0 {
|
|
return &list.Items[0], nil
|
|
}
|
|
|
|
obj := &psmdbtypes.PerconaServerMongoDBRestore{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Namespace: rj.Namespace,
|
|
GenerateName: fmt.Sprintf("%s-", rj.Name),
|
|
Labels: map[string]string{
|
|
backupsv1alpha1.OwningJobNameLabel: rj.Name,
|
|
backupsv1alpha1.OwningJobNamespaceLabel: rj.Namespace,
|
|
},
|
|
},
|
|
Spec: psmdbtypes.PerconaServerMongoDBRestoreSpec{
|
|
ClusterName: targetClusterName,
|
|
BackupSource: source,
|
|
PITR: pitr,
|
|
},
|
|
}
|
|
if err := r.Create(ctx, obj); err != nil {
|
|
return nil, err
|
|
}
|
|
return obj, nil
|
|
}
|
|
|
|
// mongodbRestoreTarget captures the resolved target for a MongoDB restore.
|
|
// In-place and to-copy are treated identically: both create a
|
|
// PerconaServerMongoDBRestore CR pointing (via clusterName) at the named target
|
|
// cluster. Callers infer the mode from AppName != backup.Spec.ApplicationRef.Name.
|
|
type mongodbRestoreTarget struct {
|
|
Namespace string
|
|
AppName string
|
|
Kind string
|
|
APIGroup string
|
|
}
|
|
|
|
func (r *RestoreJobReconciler) resolveMongoDBRestoreTarget(restoreJob *backupsv1alpha1.RestoreJob, backup *backupsv1alpha1.Backup) mongodbRestoreTarget {
|
|
t := mongodbRestoreTarget{
|
|
Namespace: backup.Namespace,
|
|
AppName: backup.Spec.ApplicationRef.Name,
|
|
Kind: backup.Spec.ApplicationRef.Kind,
|
|
}
|
|
if backup.Spec.ApplicationRef.APIGroup != nil {
|
|
t.APIGroup = *backup.Spec.ApplicationRef.APIGroup
|
|
}
|
|
if restoreJob.Spec.TargetApplicationRef != nil {
|
|
if restoreJob.Spec.TargetApplicationRef.Name != "" {
|
|
t.AppName = restoreJob.Spec.TargetApplicationRef.Name
|
|
}
|
|
if restoreJob.Spec.TargetApplicationRef.Kind != "" {
|
|
t.Kind = restoreJob.Spec.TargetApplicationRef.Kind
|
|
}
|
|
if restoreJob.Spec.TargetApplicationRef.APIGroup != nil {
|
|
t.APIGroup = *restoreJob.Spec.TargetApplicationRef.APIGroup
|
|
}
|
|
}
|
|
return t
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Snapshot persisted on Cozystack Backup.status.underlyingResources
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// mongodbBackupSnapshot is the MongoDB-specific payload persisted in
|
|
// Backup.status.underlyingResources at backup time. It carries the S3
|
|
// destination and storage descriptor read back from the operator Backup status
|
|
// so the restore path can rebuild backupSource even after the operator-side
|
|
// PerconaServerMongoDBBackup CR is reaped.
|
|
//
|
|
// SECRET-HANDLING CONTRACT (plaintext-readable to anyone with read access to
|
|
// backups.cozystack.io/Backups):
|
|
//
|
|
// - S3 carries only a Secret NAME (CredentialsSecret) plus non-secret
|
|
// coordinates (bucket, endpoint, prefix, region) — the psmdb storage shape
|
|
// has no field for a raw credential, so the snapshot can never contain an
|
|
// access key. The operator dereferences CredentialsSecret at restore time
|
|
// against the apiserver's Secret cache in the restore namespace.
|
|
// - Parameters lands here verbatim from BackupClassStrategy.parameters.
|
|
// Callers MUST NOT put credentials in Parameters.
|
|
type mongodbBackupSnapshot struct {
|
|
Kind string `json:"kind"`
|
|
APIVersion string `json:"apiVersion"`
|
|
Destination string `json:"destination,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
StorageName string `json:"storageName,omitempty"`
|
|
S3 *psmdbtypes.BackupStorageS3 `json:"s3,omitempty"`
|
|
Parameters map[string]string `json:"parameters,omitempty"`
|
|
}
|
|
|
|
func marshalMongoDBBackupSnapshot(mdbBackup *psmdbtypes.PerconaServerMongoDBBackup, rendered *strategyv1alpha1.MongoDBTemplate, storageName string, parameters map[string]string) (*runtime.RawExtension, error) {
|
|
snap := mongodbBackupSnapshot{
|
|
Kind: psmdbBackupSnapshotKind,
|
|
APIVersion: psmdbBackupSnapshotAPIVersion,
|
|
Destination: mdbBackup.Status.Destination,
|
|
Type: psmdbBackupTypeOrDefault(rendered.Type),
|
|
StorageName: storageName,
|
|
S3: mdbBackup.Status.S3.DeepCopy(),
|
|
Parameters: parameters,
|
|
}
|
|
raw, err := json.Marshal(snap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &runtime.RawExtension{Raw: raw}, nil
|
|
}
|
|
|
|
func unmarshalMongoDBBackupSnapshot(raw *runtime.RawExtension) (*mongodbBackupSnapshot, error) {
|
|
if raw == nil || len(raw.Raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
var snap mongodbBackupSnapshot
|
|
if err := json.Unmarshal(raw.Raw, &snap); err != nil {
|
|
return nil, err
|
|
}
|
|
return &snap, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// renderMongoDBTemplate templates the strategy template against a context
|
|
// containing the live application object and the BackupClass parameters.
|
|
func renderMongoDBTemplate(t strategyv1alpha1.MongoDBTemplate, app *mongodbapp.MongoDB, parameters map[string]string) (*strategyv1alpha1.MongoDBTemplate, error) {
|
|
appAsMap, err := toJSONMapMongoDB(app)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode application for templating: %w", err)
|
|
}
|
|
templateContext := map[string]interface{}{
|
|
"Application": appAsMap,
|
|
"Parameters": parameters,
|
|
}
|
|
return template.Template(&t, templateContext)
|
|
}
|
|
|
|
// toJSONMapMongoDB converts a typed object to a generic map via JSON tags so
|
|
// user-authored go-templates address fields by their JSON names (e.g.
|
|
// .Application.metadata.name). Mirrors the MariaDB controller's helper; scoped
|
|
// here to avoid cross-strategy import.
|
|
func toJSONMapMongoDB(obj interface{}) (map[string]interface{}, error) {
|
|
raw, err := json.Marshal(obj)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]interface{}{}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// getMongoDBApp fetches the apps.cozystack.io MongoDB instance via the shared
|
|
// typed client. The MongoDB scheme is registered in main.go so the
|
|
// controller-runtime cache serves it directly.
|
|
func (r *BackupJobReconciler) getMongoDBApp(ctx context.Context, namespace, name string) (*mongodbapp.MongoDB, error) {
|
|
app := &mongodbapp.MongoDB{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, app); err != nil {
|
|
return nil, err
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// MongoDBRestoreOptions is the typed shape of RestoreJob.Spec.Options for the
|
|
// MongoDB driver. The option surface is kept uniform with the CNPG strategy
|
|
// (CNPGRestoreOptions): point-in-time recovery is expressed as a single
|
|
// RFC3339 recoveryTime string, and restoreTimeoutSeconds caps the wait.
|
|
type MongoDBRestoreOptions struct {
|
|
// RecoveryTime is an optional RFC3339 timestamp for point-in-time recovery,
|
|
// mirroring the CNPG strategy's spec.options.recoveryTime. When set, the
|
|
// driver maps it onto the psmdb Restore's pitr {type: date, date} (converted
|
|
// to the operator's "YYYY-MM-DD HH:MM:SS" UTC format). Empty restores the
|
|
// backup snapshot as taken (no oplog replay) — note this differs from CNPG,
|
|
// whose empty recoveryTime replays WAL to the latest archived point; a psmdb
|
|
// logical backup is already a consistent snapshot, so "restore this backup"
|
|
// is the safe, always-available default here.
|
|
// +optional
|
|
RecoveryTime string `json:"recoveryTime,omitempty"`
|
|
|
|
// RestoreTimeoutSeconds caps the time the driver waits for the
|
|
// PerconaServerMongoDBRestore to terminate before it marks the RestoreJob
|
|
// Failed. Zero or unset falls back to psmdbDefaultRestoreDeadline. Same knob
|
|
// and semantics as the CNPG strategy.
|
|
// +optional
|
|
RestoreTimeoutSeconds int64 `json:"restoreTimeoutSeconds,omitempty"`
|
|
}
|
|
|
|
// mongodbKnownRestoreOptionKeys enumerates the JSON keys parseMongoDBRestoreOptions
|
|
// recognises. Any other key in spec.options is surfaced as a Warning event
|
|
// (see reconcileMongoDBRestore) so a typo like "recoverytime" is not silently
|
|
// dropped. Keep in sync with the MongoDBRestoreOptions json tags.
|
|
var mongodbKnownRestoreOptionKeys = []string{"recoveryTime", "restoreTimeoutSeconds"}
|
|
|
|
// parseMongoDBRestoreOptions decodes RestoreJob.Spec.Options into the typed
|
|
// shape and additionally reports any keys the driver does not recognise. The
|
|
// typed decode stays permissive (unknown keys are ignored, not rejected), so
|
|
// the returned options are always usable; the unknownKeys slice lets the caller
|
|
// warn without failing. A malformed blob returns the zero options + a decode
|
|
// error the caller surfaces as an event. Mirrors parseCNPGRestoreOptions, plus
|
|
// the unknown-key detection.
|
|
func parseMongoDBRestoreOptions(opts *runtime.RawExtension) (MongoDBRestoreOptions, []string, error) {
|
|
var out MongoDBRestoreOptions
|
|
if opts == nil || len(opts.Raw) == 0 {
|
|
return out, nil, nil
|
|
}
|
|
if err := json.Unmarshal(opts.Raw, &out); err != nil {
|
|
return MongoDBRestoreOptions{}, nil, fmt.Errorf("decode restoreJob.spec.options: %w", err)
|
|
}
|
|
// Second pass into a generic map to diff keys against the known set. Done
|
|
// separately from the typed decode so the options stay usable even when an
|
|
// unknown key is present.
|
|
unknown := unknownJSONKeys(opts.Raw, mongodbKnownRestoreOptionKeys)
|
|
return out, unknown, nil
|
|
}
|
|
|
|
// restoreOptionsCarryRecoveryTimeKey reports whether spec.options parses as a
|
|
// JSON object with a "recoveryTime" key present, regardless of that value's
|
|
// type. The caller uses it to decide whether a typed-decode failure is
|
|
// load-bearing (a malformed recoveryTime must fail terminally) or benign (a
|
|
// malformed unrelated field can fall back to defaults). A blob that is not a
|
|
// JSON object at all returns false — there is no recoveryTime to honour, so the
|
|
// permissive fall-back applies.
|
|
func restoreOptionsCarryRecoveryTimeKey(opts *runtime.RawExtension) bool {
|
|
if opts == nil || len(opts.Raw) == 0 {
|
|
return false
|
|
}
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal(opts.Raw, &obj); err != nil {
|
|
return false
|
|
}
|
|
_, ok := obj["recoveryTime"]
|
|
return ok
|
|
}
|
|
|
|
// unknownJSONKeys returns the top-level object keys in raw that are not in
|
|
// known. Returns nil when raw is not a JSON object (a malformed blob is handled
|
|
// by the typed decode's error path, not here).
|
|
func unknownJSONKeys(raw []byte, known []string) []string {
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
|
return nil
|
|
}
|
|
knownSet := make(map[string]struct{}, len(known))
|
|
for _, k := range known {
|
|
knownSet[k] = struct{}{}
|
|
}
|
|
var out []string
|
|
for k := range obj {
|
|
if _, ok := knownSet[k]; !ok {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func (o MongoDBRestoreOptions) effectiveRestoreDeadline() time.Duration {
|
|
if o.RestoreTimeoutSeconds > 0 {
|
|
return time.Duration(o.RestoreTimeoutSeconds) * time.Second
|
|
}
|
|
return psmdbDefaultRestoreDeadline
|
|
}
|
|
|
|
// pitrSpec translates the RFC3339 recoveryTime into the operator restore's
|
|
// pitr {type: date, date} shape, converting to psmdb's "YYYY-MM-DD HH:MM:SS"
|
|
// UTC format (the operator CRD's XValidation regex). Returns (nil, nil) when no
|
|
// recoveryTime is requested (plain snapshot restore). A recoveryTime that does
|
|
// not parse as RFC3339 is a terminal error — a silent wrong-point restore is
|
|
// worse than failing.
|
|
func (o MongoDBRestoreOptions) pitrSpec() (*psmdbtypes.PITRSpec, error) {
|
|
if o.RecoveryTime == "" {
|
|
return nil, nil
|
|
}
|
|
t, err := time.Parse(time.RFC3339, o.RecoveryTime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recoveryTime %q is not a valid RFC3339 timestamp: %w", o.RecoveryTime, err)
|
|
}
|
|
return &psmdbtypes.PITRSpec{Type: "date", Date: t.UTC().Format("2006-01-02 15:04:05")}, nil
|
|
}
|