// SPDX-License-Identifier: Apache-2.0 package backupcontroller import ( "context" "encoding/json" "errors" "fmt" "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/foundationdbapp" "github.com/cozystack/cozystack/internal/backupcontroller/foundationdbtypes" "github.com/cozystack/cozystack/internal/template" ) // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const ( // foundationdbAppKind / foundationdbAppPrefix mirror the // foundationdb ApplicationDefinition (release.prefix=foundationdb-). // The cozystack foundationdb-rd renders the HelmRelease with // releaseName = "foundationdb-" + appName, and the app chart's // templates/cluster.yaml materialises the operator-side // apps.foundationdb.org/FoundationDBCluster CR with // metadata.name set to .Release.Name. So the driver must look up // the operator-side cluster by the prefixed name. foundationdbAppKind = "FoundationDB" foundationdbAppPrefix = "foundationdb-" // Driver-metadata keys persisted on Cozystack Backup artefacts. // Restore path reads these to drive the operator FoundationDBRestore CR. foundationdbBackupNameKey = "apps.foundationdb.org/backup-name" foundationdbBackupNamespaceKey = "apps.foundationdb.org/backup-namespace" foundationdbAccountNameKey = "apps.foundationdb.org/account-name" foundationdbBucketKey = "apps.foundationdb.org/bucket" foundationdbBlobBackupNameKey = "apps.foundationdb.org/blob-backup-name" foundationdbStorageURIKey = "apps.foundationdb.org/storage-uri" // Polling cadence for the operator FoundationDBBackup/Restore lifecycle. foundationdbPollInterval = 10 * time.Second // Wall-clock cap on a BackupJob waiting for the operator // FoundationDBBackup to land its first restorable snapshot. The // agent has to start, contact the cluster, and write a full // snapshot to blob storage; a permanently-stuck backup must not // pin the BackupJob in Running and wedge the Plan controller's // queue. CNPG/MariaDB use 30m for the same reason; FDB needs a // touch more headroom because the first snapshot for an empty // cluster still has to do range-file rotation through the // backup_agent. foundationdbDefaultBackupDeadline = 45 * time.Minute // Default deadline on a RestoreJob waiting for the operator // FoundationDBRestore to terminate. Tenants override via // spec.options.restoreTimeoutSeconds. foundationdbDefaultRestoreDeadline = 30 * time.Minute // foundationdbBackupSnapshotKind is the Kind stamped onto the snapshot // persisted in Backup.status.underlyingResources. Carries the rendered // strategy parameters and the per-run BackupName so restore-time // re-rendering produces deterministic values even after the operator-side // FoundationDBBackup has been reaped. // // Snapshot schema v1. Bump the apiVersion (or introduce a Version field // on foundationdbBackupSnapshot) the moment the on-disk shape changes // in a way that breaks older readers - decoders deployed against a // mismatched apiVersion must be able to detect the mismatch and refuse // to use the snapshot rather than silently mis-interpret it. foundationdbBackupSnapshotKind = "FoundationDBBackupSnapshot" ) // foundationdbBackupSnapshotAPIVersion is the apiVersion stamped onto the // snapshot. Borrows the Cozystack backups group so the field is self-typed // within the existing API surface. See foundationdbBackupSnapshotKind for // the schema-versioning contract. var foundationdbBackupSnapshotAPIVersion = backupsv1alpha1.GroupVersion.String() // foundationdbClusterNameForApp returns the // apps.foundationdb.org/FoundationDBCluster CR name for a cozystack // FoundationDB application instance. The mapping mirrors the foundationdb // ApplicationDefinition (release.prefix=foundationdb-). func foundationdbClusterNameForApp(appName string) string { return foundationdbAppPrefix + appName } // validateFoundationDBApplicationRef rejects ApplicationRefs that name a // Kind/APIGroup the FoundationDB driver does not own. The driver assumes // apps.cozystack.io/FoundationDB; without this gate a ref like // other.example.com/FoundationDB would be accepted by the Kind check alone // and then reconciled against the wrong CRD via the typed client. func validateFoundationDBApplicationRef(ref corev1.TypedLocalObjectReference) error { if ref.Kind != foundationdbAppKind { return fmt.Errorf("FoundationDB strategy supports applicationRef.kind=%q, got %q", foundationdbAppKind, ref.Kind) } apiGroup := "" if ref.APIGroup != nil { apiGroup = *ref.APIGroup } if apiGroup != "" && apiGroup != foundationdbapp.GroupName { return fmt.Errorf("FoundationDB strategy supports applicationRef.apiGroup=%q, got %q", foundationdbapp.GroupName, apiGroup) } return nil } // --------------------------------------------------------------------------- // BackupJob path // --------------------------------------------------------------------------- func (r *BackupJobReconciler) reconcileFoundationDB(ctx context.Context, j *backupsv1alpha1.BackupJob, resolved *ResolvedBackupConfig) (ctrl.Result, error) { logger := getLogger(ctx) logger.Debug("reconciling FoundationDB 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 := validateFoundationDBApplicationRef(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 CNPG/MariaDB // 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: foundationdbPollInterval}, nil } } strategy := &strategyv1alpha1.FoundationDB{} 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.getFoundationDBApp(ctx, j.Namespace, j.Spec.ApplicationRef.Name) if err != nil { if apierrors.IsNotFound(err) { return r.markBackupJobFailed(ctx, j, fmt.Sprintf("FoundationDB application not found: %s/%s", j.Namespace, j.Spec.ApplicationRef.Name)) } return ctrl.Result{}, err } rendered, err := renderFoundationDBTemplate(strategy.Spec.Template, app, resolved.Parameters) if err != nil { return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to template FoundationDB strategy: %v", err)) } // Operator-side cluster carries the prefixed release name (see // foundationdbClusterNameForApp); verify it exists and is healthy // before we ask the operator to back it up. Returning a transient // "NotReady" surfaces a transient condition rather than a terminal // failure: the chart may still be rendering on a fresh app. clusterName := foundationdbClusterNameForApp(j.Spec.ApplicationRef.Name) cluster := &foundationdbtypes.FoundationDBCluster{} if err := r.Get(ctx, types.NamespacedName{Namespace: j.Namespace, Name: clusterName}, cluster); err != nil { if apierrors.IsNotFound(err) { return r.requeueWithReason(ctx, j, "FoundationDBClusterNotReady", fmt.Sprintf("waiting for apps.foundationdb.org/FoundationDBCluster %s/%s to exist", j.Namespace, clusterName)) } return ctrl.Result{}, err } if !cluster.Status.Health.Available { return r.requeueWithReason(ctx, j, "FoundationDBClusterNotAvailable", fmt.Sprintf("waiting for apps.foundationdb.org/FoundationDBCluster %s/%s to become available", j.Namespace, clusterName)) } // Per BackupJob the driver materialises a discrete FoundationDBBackup // with backupName = j.Name; the operator only permits one running // backup directory per cluster, so any prior running backups for the // same cluster must be stopped first. if cluster.Spec.Version == "" { return r.requeueWithReason(ctx, j, "FoundationDBClusterVersionUnknown", fmt.Sprintf("apps.foundationdb.org/FoundationDBCluster %s/%s does not yet report spec.version", j.Namespace, clusterName)) } // Fast-fail when a chart-rendered FoundationDBBackup is still // Running against the same cluster (legacy `backup.enabled=true` // flow). The operator only permits one running backup directory // per cluster, and the chart-rendered CR carries no driver labels // for stopOtherFoundationDBBackups to find, so a silent migration // would split the backup stream and corrupt both. Diagnostic is // terminal because letting the BackupJob spin for 45 minutes would // mask the chart-side cleanup tenants need to do. if conflicting, err := r.findConflictingInChartBackup(ctx, j.Namespace, clusterName); err != nil { return ctrl.Result{}, err } else if conflicting != "" { return r.markBackupJobFailed(ctx, j, fmt.Sprintf( "conflicting in-chart FoundationDBBackup %s/%s is Running against cluster %q; disable the chart's backup.enabled and delete that CR before using the BackupClass flow", j.Namespace, conflicting, clusterName)) } // Resolve every Secret the rendered PodTemplateSpec references before // the backup_agent Deployment starts, so a tenant who forgot to // pre-create the per-app blob-credentials Secret fails fast with a // diagnostic instead of spinning until the 45-minute deadline. if missing := podTemplateSpecMissingSecrets(rendered.BackupDeploymentPodTemplateSpec); len(missing) > 0 { for _, name := range missing { err := r.Get(ctx, types.NamespacedName{Namespace: j.Namespace, Name: name}, &corev1.Secret{}) if err == nil { continue } if !apierrors.IsNotFound(err) { return ctrl.Result{}, err } return r.requeueWithReason(ctx, j, "MissingBlobCredentialsSecret", fmt.Sprintf("Secret %s/%s referenced by the FoundationDB strategy's backupDeploymentPodTemplateSpec does not exist; create it before submitting the BackupJob", j.Namespace, name)) } } fdbBackup, err := r.ensureFoundationDBBackup(ctx, j, clusterName, cluster.Spec.Version, rendered) if err != nil { return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to ensure apps.foundationdb.org/FoundationDBBackup: %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 } } // Ready when the operator has reconciled the spec, the deployment is // configured, and the backup_agent has written at least one full // snapshot (SnapshotTime > 0). if !foundationdbBackupReady(fdbBackup) { if foundationdbBackupDeadlineExceeded(j.Status.StartedAt) { detail := foundationdbBackupNotReadyDetail(fdbBackup) // Stop the operator-side CR before marking the job // Failed. Otherwise the backup_agent Deployment keeps // retrying against a broken S3 endpoint indefinitely // (one Deployment leaked per failed BackupJob) and the // FDB operator continues reconciling the running CR // for no reason. Best-effort: if the Stop patch fails, // the BackupJob still terminates so the deadline gate // stays trustworthy. if stopErr := r.stopFoundationDBBackupForJob(ctx, fdbBackup); stopErr != nil { getLogger(ctx).Debug("failed to stop FoundationDBBackup on deadline expiry; continuing with BackupJob failure", "backupjob", j.Name, "fdbBackup", fdbBackup.Name, "error", stopErr.Error()) } return r.markBackupJobFailed(ctx, j, fmt.Sprintf( "apps.foundationdb.org/FoundationDBBackup did not reach a restorable snapshot within %s (%s)", foundationdbDefaultBackupDeadline, detail)) } return r.requeueWithReason(ctx, j, "FoundationDBBackupRunning", foundationdbBackupNotReadyDetail(fdbBackup)) } if j.Status.BackupRef != nil { // Already finalised; nothing more to do. return ctrl.Result{}, nil } artifact, err := r.createFoundationDBBackupArtifact(ctx, j, resolved, fdbBackup, rendered, app) 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: "apps.foundationdb.org/FoundationDBBackup reached a restorable snapshot", }) if err := r.Status().Update(ctx, j); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil } // foundationdbBackupReady reports whether the operator-side // FoundationDBBackup has reconciled its spec and the backup_agent has // written at least one full snapshot (i.e. is restorable). func foundationdbBackupReady(b *foundationdbtypes.FoundationDBBackup) bool { if b == nil || b.Status.BackupDetails == nil { return false } if !b.Status.BackupDetails.Running { return false } if b.Status.BackupDetails.SnapshotTime <= 0 { return false } if b.Status.Generations != nil && b.Status.Generations.Reconciled < b.Generation { return false } return true } // foundationdbBackupNotReadyDetail returns a human-readable reason string // for the not-yet-ready FoundationDBBackup. func foundationdbBackupNotReadyDetail(b *foundationdbtypes.FoundationDBBackup) string { if b == nil { return "no FoundationDBBackup observed" } if b.Status.BackupDetails == nil { return "no backupDetails observed" } if b.Status.Generations != nil && b.Status.Generations.Reconciled < b.Generation { return fmt.Sprintf("operator has not reconciled the spec yet (reconciled=%d, generation=%d)", b.Status.Generations.Reconciled, b.Generation) } if !b.Status.BackupDetails.Running { return "backup_agent has not reported running=true" } if b.Status.BackupDetails.SnapshotTime <= 0 { return "waiting for first full snapshot (snapshotTime is 0)" } return "waiting" } // foundationdbBackupDeadlineExceeded reports whether enough wall-clock time // has elapsed since the BackupJob started that we should give up on a stuck // operator backup. Returns false when StartedAt is nil so the very first // reconcile (which sets StartedAt) does not trip the gate. func foundationdbBackupDeadlineExceeded(startedAt *metav1.Time) bool { if startedAt == nil { return false } return time.Since(startedAt.Time) > foundationdbDefaultBackupDeadline } // requeueWithReason stamps a transient Ready=False/ condition and // requeues after foundationdbPollInterval. Mirrors the MariaDB driver's // transient-error helper. // // Retries on 409 Conflict: most call sites fire on the cold-start // reconcile path where the informer cache is most likely to be stale, // so a bare propagation of the 409 to controller-runtime can pin a // busy-spin for the duration of the resync. patchConflictRetries-1 // retries plus the initial attempt is enough to absorb a stale-RV // race without burning the reconcile budget. If the Conflict persists // past the budget the bare error propagates - the parent reconciler // will pick up the next change anyway. func (r *BackupJobReconciler) requeueWithReason(ctx context.Context, j *backupsv1alpha1.BackupJob, reason, message string) (ctrl.Result, error) { cond := metav1.Condition{ Type: "Ready", Status: metav1.ConditionFalse, Reason: reason, Message: message, } apimeta.SetStatusCondition(&j.Status.Conditions, cond) for attempt := range foundationdbStopConflictRetries { err := r.Status().Update(ctx, j) if err == nil { return ctrl.Result{RequeueAfter: foundationdbPollInterval}, nil } if !apierrors.IsConflict(err) { return ctrl.Result{}, err } if attempt == foundationdbStopConflictRetries-1 { return ctrl.Result{}, err } // Refetch and re-apply the condition on the fresh handle. fresh := &backupsv1alpha1.BackupJob{} if getErr := r.Get(ctx, types.NamespacedName{Namespace: j.Namespace, Name: j.Name}, fresh); getErr != nil { if apierrors.IsNotFound(getErr) { return ctrl.Result{}, nil } return ctrl.Result{}, getErr } // Carry over any prior status fields the caller already set // (Phase, StartedAt) so the retry doesn't accidentally clobber // them while the in-memory j is being mutated by the caller. fresh.Status.Phase = j.Status.Phase if j.Status.StartedAt != nil { fresh.Status.StartedAt = j.Status.StartedAt } if j.Status.BackupRef != nil { fresh.Status.BackupRef = j.Status.BackupRef } apimeta.SetStatusCondition(&fresh.Status.Conditions, cond) *j = *fresh } return ctrl.Result{RequeueAfter: foundationdbPollInterval}, nil } // ensureFoundationDBBackup materialises a per-BackupJob FoundationDBBackup // CR, or returns the existing one if a previous reconcile already created // it. Stops any prior Running FoundationDBBackup attached to the same // FDB cluster so the operator's "one running backup per cluster" invariant // stays satisfied. Idempotency relies on the OwningJob labels. // // IMPORTANT: the FoundationDBBackup CR is intentionally NOT linked back to // the BackupJob via metav1.OwnerReference. That deliberate omission is // what makes the ensure-by-label semantics work across BackupJob // recreates with the same name (e.g. a tenant runs `kubectl delete && // kubectl apply` to retry): a fresh BackupJob with the same name would // find the prior operator CR by its OwningJob label and reuse it // instead of leaking a duplicate Deployment. Adding an OwnerReference // would make Kubernetes GC reap the operator CR when the parent // BackupJob is deleted, defeating the reuse contract. // // Cleanup of the operator-side CR is therefore the framework's // responsibility, not Kubernetes ownerRef GC. See: // - examples/backups/foundationdb/cleanup.sh (label-based reap), // - stopFoundationDBBackupForJob (deadline-expiry cleanup in this file). // // The `TestEnsureFoundationDBBackup_DoesNotSetOwnerReference` test pins // the contract so a future patch that adds an OwnerReference "for // safety" trips the test before silently breaking the reuse path. func (r *BackupJobReconciler) ensureFoundationDBBackup(ctx context.Context, j *backupsv1alpha1.BackupJob, clusterName, clusterVersion string, rendered *strategyv1alpha1.FoundationDBTemplate) (*foundationdbtypes.FoundationDBBackup, error) { existing, err := r.findFoundationDBBackupForJob(ctx, j) if err != nil { return nil, err } if existing != nil { return existing, nil } if err := r.stopOtherFoundationDBBackups(ctx, j, clusterName); err != nil { return nil, err } blobName := rendered.BlobStoreConfiguration.BackupName if blobName == "" { blobName = j.Name } obj := &foundationdbtypes.FoundationDBBackup{ ObjectMeta: metav1.ObjectMeta{ Namespace: j.Namespace, GenerateName: fmt.Sprintf("%s-", j.Name), Labels: map[string]string{ backupsv1alpha1.OwningJobNameLabel: j.Name, backupsv1alpha1.OwningJobNamespaceLabel: j.Namespace, foundationdbClusterLabel: clusterName, }, }, Spec: foundationdbtypes.FoundationDBBackupSpec{ ClusterName: clusterName, Version: clusterVersion, BackupState: foundationdbtypes.BackupStateRunning, BlobStoreConfiguration: foundationdbtypes.BlobStoreConfiguration{ AccountName: rendered.BlobStoreConfiguration.AccountName, Bucket: rendered.BlobStoreConfiguration.Bucket, BackupName: blobName, URLParameters: append([]string(nil), rendered.BlobStoreConfiguration.URLParameters...), }, AgentCount: int32PtrCopy(rendered.AgentCount), SnapshotPeriodSeconds: int32PtrCopy(rendered.SnapshotPeriodSeconds), CustomParameters: append([]string(nil), rendered.CustomParameters...), EncryptionKeyPath: rendered.EncryptionKeyPath, }, } if rendered.BackupDeploymentPodTemplateSpec != nil { obj.Spec.BackupDeploymentSpec = &foundationdbtypes.BackupDeploymentSpec{ PodTemplateSpec: rendered.BackupDeploymentPodTemplateSpec.DeepCopy(), } } if err := r.Create(ctx, obj); err != nil { return nil, err } return obj, nil } // stopOtherFoundationDBBackups flips any FoundationDBBackup CRs in the // BackupJob namespace that target the same FDB cluster to backupState=Stopped. // The operator only permits one running backup directory per cluster, so a // prior BackupJob's CR has to stop before the new one can start streaming. // // The operator reconciles spec.backupState concurrently, so the patch can // race with an operator update and surface as a 409 Conflict on a stale // resourceVersion. Retry-on-Conflict by refetching the CR and re-applying // the Stop intent; if the refetch shows the CR is already Stopped, we're // done. func (r *BackupJobReconciler) stopOtherFoundationDBBackups(ctx context.Context, j *backupsv1alpha1.BackupJob, clusterName string) error { list := &foundationdbtypes.FoundationDBBackupList{} if err := r.List(ctx, list, client.InNamespace(j.Namespace), client.MatchingLabels{foundationdbClusterLabel: clusterName}, ); err != nil { return err } for i := range list.Items { b := &list.Items[i] if b.Spec.BackupState == foundationdbtypes.BackupStateStopped { continue } // Don't stop the BackupJob's own backup (shouldn't be in the list at // this point - ensureFoundationDBBackup checks for it first - but be // defensive against a race between two reconciles). if b.Labels[backupsv1alpha1.OwningJobNameLabel] == j.Name && b.Labels[backupsv1alpha1.OwningJobNamespaceLabel] == j.Namespace { continue } if err := r.patchStopWithRetry(ctx, b); err != nil { return fmt.Errorf("stop prior FoundationDBBackup %s/%s: %w", b.Namespace, b.Name, err) } } return nil } // stopFoundationDBBackupForJob is the single-CR variant of // stopOtherFoundationDBBackups, used on terminal BackupJob failure to keep // the operator-side backup_agent Deployment from leaking past the // BackupJob's lifetime. Same Conflict-retry semantics. func (r *BackupJobReconciler) stopFoundationDBBackupForJob(ctx context.Context, b *foundationdbtypes.FoundationDBBackup) error { if b == nil { return nil } if b.Spec.BackupState == foundationdbtypes.BackupStateStopped { return nil } if err := r.patchStopWithRetry(ctx, b); err != nil { return fmt.Errorf("stop FoundationDBBackup %s/%s: %w", b.Namespace, b.Name, err) } return nil } // foundationdbStopConflictRetries caps the patch-retry budget so a // pathologically-fast concurrent writer (operator reconciling against the // same CR) cannot pin a single BackupJob reconcile forever. const foundationdbStopConflictRetries = 5 // patchStopWithRetry sets spec.backupState=Stopped on the given CR, // retrying on 409 Conflict by refetching and re-applying the change. 404 // during refetch is treated as success (the CR is gone — Stopped is // reached). The function mutates *b to the final observed state on // success. func (r *BackupJobReconciler) patchStopWithRetry(ctx context.Context, b *foundationdbtypes.FoundationDBBackup) error { for attempt := range foundationdbStopConflictRetries { base := b.DeepCopy() b.Spec.BackupState = foundationdbtypes.BackupStateStopped err := r.Patch(ctx, b, client.MergeFrom(base)) if err == nil { return nil } if apierrors.IsNotFound(err) { return nil } if !apierrors.IsConflict(err) { return err } // Refetch and try again with the latest resourceVersion. A // Conflict on the last attempt falls through to the caller so // the surrounding reconcile can retry the whole sequence // rather than burn the entire budget here. if attempt == foundationdbStopConflictRetries-1 { return err } if getErr := r.Get(ctx, client.ObjectKeyFromObject(b), b); getErr != nil { if apierrors.IsNotFound(getErr) { return nil } return fmt.Errorf("refetch on conflict: %w", getErr) } if b.Spec.BackupState == foundationdbtypes.BackupStateStopped { return nil } } return nil } // foundationdbClusterLabel groups FoundationDBBackup CRs by the FDB // cluster they target so the driver can list them in O(1). const foundationdbClusterLabel = "backups.cozystack.io/foundationdb-cluster" // findConflictingInChartBackup returns the name of any FoundationDBBackup // in the given namespace that targets the same cluster, is currently // Running, and does NOT carry the driver's cluster label - i.e. a // chart-rendered CR from the legacy `backup.enabled=true` flow. Returns // "" when no conflict is found. // // The FDB operator only permits one running backup directory per cluster. // Silently stopping the chart-rendered CR would split the in-chart user's // backup stream and break their tenancy contract; a terminal failure // surfaces the migration step they need to take instead. func (r *BackupJobReconciler) findConflictingInChartBackup(ctx context.Context, namespace, clusterName string) (string, error) { list := &foundationdbtypes.FoundationDBBackupList{} if err := r.List(ctx, list, client.InNamespace(namespace)); err != nil { return "", err } for i := range list.Items { b := &list.Items[i] if b.Spec.ClusterName != clusterName { continue } if b.Spec.BackupState != foundationdbtypes.BackupStateRunning { continue } if _, ok := b.Labels[foundationdbClusterLabel]; ok { // Driver-managed CR. The dispatch path // (stopOtherFoundationDBBackups + // ensureFoundationDBBackup) handles transitions // between driver-managed CRs; only chart-rendered // strangers need the loud diagnostic. continue } return b.Name, nil } return "", nil } // podTemplateSpecMissingSecrets returns the de-duplicated names of all // Secrets referenced by the PodTemplateSpec's volumes/envFrom/env that // the driver must verify exist in the BackupJob namespace before the // backup_agent Deployment starts. The list is "potentially missing" // because the caller does the actual Get against the apiserver; the // helper is purely template traversal so it stays testable without a // fake client. An optional reference (e.g. volumes[].secret.optional=true, // env[].valueFrom.secretKeyRef.optional=true) is skipped - the operator // is supposed to tolerate its absence. func podTemplateSpecMissingSecrets(tmpl *corev1.PodTemplateSpec) []string { if tmpl == nil { return nil } seen := map[string]struct{}{} out := []string{} add := func(name string) { if name == "" { return } if _, ok := seen[name]; ok { return } seen[name] = struct{}{} out = append(out, name) } for _, v := range tmpl.Spec.Volumes { if v.Secret == nil || v.Secret.SecretName == "" { continue } if v.Secret.Optional != nil && *v.Secret.Optional { continue } add(v.Secret.SecretName) } for _, allContainers := range [][]corev1.Container{tmpl.Spec.InitContainers, tmpl.Spec.Containers} { for _, ctr := range allContainers { for _, ef := range ctr.EnvFrom { if ef.SecretRef == nil { continue } if ef.SecretRef.Optional != nil && *ef.SecretRef.Optional { continue } add(ef.SecretRef.Name) } for _, e := range ctr.Env { if e.ValueFrom == nil || e.ValueFrom.SecretKeyRef == nil { continue } if e.ValueFrom.SecretKeyRef.Optional != nil && *e.ValueFrom.SecretKeyRef.Optional { continue } add(e.ValueFrom.SecretKeyRef.Name) } } } return out } // findFoundationDBBackupForJob returns the FoundationDBBackup labelled // with the BackupJob's OwningJob{Name,Namespace}, if any. Returns // (nil, nil) when no match is found. Mirrors the MariaDB driver's // idempotent ensure-by-label pattern. func (r *BackupJobReconciler) findFoundationDBBackupForJob(ctx context.Context, j *backupsv1alpha1.BackupJob) (*foundationdbtypes.FoundationDBBackup, error) { list := &foundationdbtypes.FoundationDBBackupList{} 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 apps.foundationdb.org/FoundationDBBackup CRs match BackupJob OwningJob labels; reusing first", "backupjob", j.Name, "namespace", j.Namespace, "matches", names, "picked", names[0]) } return &list.Items[0], nil } // createFoundationDBBackupArtifact materialises a Cozystack Backup resource // carrying the metadata callers need to drive a future restore. The source // app's spec snapshot plus the rendered strategy parameters are persisted // in status.underlyingResources so restore-time templating produces the // same values the backup ran with. func (r *BackupJobReconciler) createFoundationDBBackupArtifact( ctx context.Context, j *backupsv1alpha1.BackupJob, resolved *ResolvedBackupConfig, fdbBackup *foundationdbtypes.FoundationDBBackup, rendered *strategyv1alpha1.FoundationDBTemplate, sourceApp *foundationdbapp.FoundationDB, ) (*backupsv1alpha1.Backup, error) { // NOTE: FoundationDBBackup.status.backupDetails.snapshotTime is the FDB // read-version at snapshot time (an FDB-internal integer counter, not a // wall-clock timestamp), so it cannot be plugged into Backup.spec.takenAt // directly. We observed the snapshot ready in the BackupJob reconcile // that called this function, so wall-clock "now" is close enough for // human consumption. takenAt := metav1.Now() driverMD := map[string]string{ foundationdbBackupNameKey: fdbBackup.Name, foundationdbBackupNamespaceKey: fdbBackup.Namespace, foundationdbAccountNameKey: fdbBackup.Spec.BlobStoreConfiguration.AccountName, foundationdbBucketKey: fdbBackup.Spec.BlobStoreConfiguration.Bucket, foundationdbBlobBackupNameKey: fdbBackup.Spec.BlobStoreConfiguration.BackupName, } if uri := foundationdbBackupURI(fdbBackup); uri != "" { driverMD[foundationdbStorageURIKey] = uri } underlyingResources, err := marshalFoundationDBBackupSnapshot(sourceApp, rendered, resolved.Parameters, fdbBackup) 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 uri := driverMD[foundationdbStorageURIKey]; uri != "" { status.Artifact = &backupsv1alpha1.BackupArtifact{URI: uri} } 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 } // foundationdbBackupURI synthesises a human-readable URI for the Cozystack // Backup artifact. The URI is informational (Backup.status.driverMetadata // carries the load-bearing coordinates); when bucket is unset we return "" // rather than guess a path that uses AccountName, since AccountName looks // like "@" and grafting it into a bucket slot produces // a misleading URL that doesn't match anything the operator would write. func foundationdbBackupURI(fdbBackup *foundationdbtypes.FoundationDBBackup) string { if fdbBackup == nil { return "" } // Prefer the operator-reported URL when present: it's the canonical // path the agent is actually writing to. if fdbBackup.Status.BackupDetails != nil && fdbBackup.Status.BackupDetails.URL != "" { return fdbBackup.Status.BackupDetails.URL } cfg := fdbBackup.Spec.BlobStoreConfiguration if cfg.Bucket == "" || cfg.BackupName == "" { return "" } return fmt.Sprintf("blobstore://%s/%s", cfg.Bucket, cfg.BackupName) } // --------------------------------------------------------------------------- // RestoreJob path // --------------------------------------------------------------------------- func (r *RestoreJobReconciler) reconcileFoundationDBRestore(ctx context.Context, restoreJob *backupsv1alpha1.RestoreJob, backup *backupsv1alpha1.Backup) (ctrl.Result, error) { logger := getLogger(ctx) logger.Debug("reconciling FoundationDB restore", "restorejob", restoreJob.Name, "backup", backup.Name) if err := validateFoundationDBApplicationRef(backup.Spec.ApplicationRef); err != nil { return r.markRestoreJobFailed(ctx, restoreJob, err.Error()) } // Validate the resolved target shape before any apiserver call. target := r.resolveFoundationDBRestoreTarget(restoreJob, backup) if target.Kind != foundationdbAppKind { return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf( "target applicationRef.kind=%q is not supported by the FoundationDB driver", target.Kind)) } if target.APIGroup != "" && target.APIGroup != foundationdbapp.GroupName { return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf( "target applicationRef.apiGroup=%q is not supported by the FoundationDB 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: foundationdbPollInterval}, nil } } options, err := parseFoundationDBRestoreOptions(restoreJob.Spec.Options) if err != nil { // Treat malformed spec.options as a terminal failure. The // previous behaviour silently fell back to defaults, which is // hostile: a tenant who set `restoreTimeoutSeconds: "5h"` // (string instead of int64) would see the default 30-minute // deadline take effect with no Ready=False signal on the // RestoreJob, and the misconfiguration would persist until a // restore ran longer than 30 minutes. Other strategies treat // option-parse errors as terminal; do the same here. r.Recorder.Eventf(restoreJob, corev1.EventTypeWarning, "MalformedOptions", "spec.options is not valid JSON: %v", err) return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf( "malformed restoreJob.spec.options: %v (clear the field or supply a valid FoundationDBRestoreOptions JSON object)", err)) } // Restore needs the blob-store address the backup ran with. Prefer // the persisted snapshot on the Cozystack Backup // (status.underlyingResources) - it is durable beyond the operator // Backup CR's lifetime - and fall back to driverMetadata when the // snapshot has been dropped from an old artefact. // // Decode-error handling honours the contract documented next to // foundationdbBackupSnapshotKind: // - errSnapshotUnrecognised (kind/apiVersion mismatch): terminal // failure. A future v2 snapshot with extra fields the v1 reader // cannot interpret must not be silently overridden by // driverMetadata, because the snapshot is the authoritative // source for fields driverMetadata doesn't carry // (CustomParameters / EncryptionKeyPath). // - any other decode failure (malformed JSON): log + emit a // MalformedSnapshot event, then fall through to driverMetadata. // The error is recoverable - driverMetadata carries the load- // bearing blob coordinates - and the breadcrumb gives operators // something to grep for. snap, err := decodeFoundationDBBackupSnapshot(backup.Status.UnderlyingResources) if err != nil { if errors.Is(err, errSnapshotUnrecognised) { return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf( "%v (re-take the backup with a controller version compatible with this snapshot schema, or clear Backup.status.underlyingResources to opt into the driverMetadata fallback)", err)) } logger.Info("malformed Backup.status.underlyingResources; falling back to driverMetadata", "error", err) r.Recorder.Eventf(restoreJob, corev1.EventTypeWarning, "MalformedSnapshot", "Backup.status.underlyingResources is not a valid FoundationDB snapshot; falling back to driverMetadata: %v", err) snap = nil } blobCfg, blobOK := resolveFoundationDBRestoreBlob(backup, snap) if !blobOK { return r.markRestoreJobFailed(ctx, restoreJob, "Backup driverMetadata/snapshot is missing FoundationDB blob-store configuration (re-take the backup with a controller version that persists it)") } // Verify the operator-side destination cluster exists. NotFound is // transient: a tenant who fires a RestoreJob seconds before their // target HelmRelease materialises the cluster shouldn't have to // recreate the RestoreJob - mirror the backup path's transient // handling and let restoreTimeoutSeconds guard against a target // that never shows up. targetClusterName := foundationdbClusterNameForApp(target.AppName) if err := r.Get(ctx, types.NamespacedName{Namespace: target.Namespace, Name: targetClusterName}, &foundationdbtypes.FoundationDBCluster{}); 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 apps.foundationdb.org/FoundationDBCluster %s/%s not found within %s (deploy the target FoundationDB application before requesting the restore; override via spec.options.restoreTimeoutSeconds)", target.Namespace, targetClusterName, deadline)) } apimeta.SetStatusCondition(&restoreJob.Status.Conditions, metav1.Condition{ Type: "Ready", Status: metav1.ConditionFalse, Reason: "TargetFoundationDBClusterNotReady", Message: fmt.Sprintf("waiting for target apps.foundationdb.org/FoundationDBCluster %s/%s to exist", target.Namespace, targetClusterName), }) if err := r.Status().Update(ctx, restoreJob); err != nil { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: foundationdbPollInterval}, nil } return ctrl.Result{}, err } customParams := []string(nil) if snap != nil { customParams = append(customParams, snap.CustomParameters...) } fdbRestore, err := r.ensureFoundationDBRestore(ctx, restoreJob, targetClusterName, blobCfg, customParams, snap) if err != nil { return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf("failed to ensure apps.foundationdb.org/FoundationDBRestore: %v", err)) } switch { case strings.EqualFold(fdbRestore.Status.State, foundationdbtypes.RestoreStateCompleted): 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("apps.foundationdb.org/FoundationDBRestore %s/%s completed", fdbRestore.Namespace, fdbRestore.Name), }) if err := r.Status().Update(ctx, restoreJob); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil default: deadline := options.effectiveRestoreDeadline() if restoreJob.Status.StartedAt != nil && time.Since(restoreJob.Status.StartedAt.Time) > deadline { detail := fmt.Sprintf("state=%q running=%t", fdbRestore.Status.State, fdbRestore.Status.Running) return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf( "apps.foundationdb.org/FoundationDBRestore did not complete within %s (%s; override via spec.options.restoreTimeoutSeconds)", deadline, detail)) } apimeta.SetStatusCondition(&restoreJob.Status.Conditions, metav1.Condition{ Type: "Ready", Status: metav1.ConditionFalse, Reason: "FoundationDBRestoreRunning", Message: fmt.Sprintf("apps.foundationdb.org/FoundationDBRestore %s/%s state=%q running=%t", fdbRestore.Namespace, fdbRestore.Name, fdbRestore.Status.State, fdbRestore.Status.Running), }) if err := r.Status().Update(ctx, restoreJob); err != nil { return ctrl.Result{}, err } return ctrl.Result{RequeueAfter: foundationdbPollInterval}, nil } } // resolveFoundationDBRestoreBlob picks the blob-store config to feed into // FoundationDBRestore. The persisted snapshot // (backup.status.underlyingResources) is the authoritative source; the // driverMetadata-only fallback covers older artefacts that pre-date the // snapshot field. func resolveFoundationDBRestoreBlob(backup *backupsv1alpha1.Backup, snap *foundationdbBackupSnapshot) (foundationdbtypes.BlobStoreConfiguration, bool) { if snap != nil && (snap.Storage.AccountName != "" || snap.Storage.Bucket != "" || snap.Storage.BackupName != "") { return foundationdbtypes.BlobStoreConfiguration{ AccountName: snap.Storage.AccountName, Bucket: snap.Storage.Bucket, BackupName: snap.Storage.BackupName, URLParameters: append([]string(nil), snap.Storage.URLParameters...), }, true } md := backup.Spec.DriverMetadata cfg := foundationdbtypes.BlobStoreConfiguration{ AccountName: md[foundationdbAccountNameKey], Bucket: md[foundationdbBucketKey], BackupName: md[foundationdbBlobBackupNameKey], } if cfg.AccountName == "" && cfg.Bucket == "" && cfg.BackupName == "" { return cfg, false } return cfg, true } // ensureFoundationDBRestore creates a FoundationDBRestore CR labelled with // the RestoreJob, or returns the existing one if a previous reconcile // already created it. func (r *RestoreJobReconciler) ensureFoundationDBRestore( ctx context.Context, rj *backupsv1alpha1.RestoreJob, targetClusterName string, blob foundationdbtypes.BlobStoreConfiguration, customParameters []string, snap *foundationdbBackupSnapshot, ) (*foundationdbtypes.FoundationDBRestore, error) { list := &foundationdbtypes.FoundationDBRestoreList{} 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 } encryptionKeyPath := "" if snap != nil { encryptionKeyPath = snap.EncryptionKeyPath } obj := &foundationdbtypes.FoundationDBRestore{ 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: foundationdbtypes.FoundationDBRestoreSpec{ DestinationClusterName: targetClusterName, BlobStoreConfiguration: blob, CustomParameters: append([]string(nil), customParameters...), EncryptionKeyPath: encryptionKeyPath, }, } if err := r.Create(ctx, obj); err != nil { return nil, err } return obj, nil } // foundationdbRestoreTarget captures the resolved target for a FoundationDB // restore. Both in-place and to-copy create a FoundationDBRestore against // a named target cluster; callers infer the mode from // AppName != backup.Spec.ApplicationRef.Name. type foundationdbRestoreTarget struct { Namespace string AppName string Kind string APIGroup string } func (r *RestoreJobReconciler) resolveFoundationDBRestoreTarget(restoreJob *backupsv1alpha1.RestoreJob, backup *backupsv1alpha1.Backup) foundationdbRestoreTarget { t := foundationdbRestoreTarget{ 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 // --------------------------------------------------------------------------- // foundationdbBackupSnapshot is the FDB-specific payload persisted in // Backup.status.underlyingResources at backup time. Carries the rendered // blob-store target, custom parameters, and BackupClass parameters so a // future RestoreJob can reproduce them exactly when the operator-side // FoundationDBBackup CR has been pruned. type foundationdbBackupSnapshot struct { Kind string `json:"kind"` APIVersion string `json:"apiVersion"` Storage strategyv1alpha1.FoundationDBBlobStoreTemplate `json:"storage"` CustomParameters []string `json:"customParameters,omitempty"` EncryptionKeyPath string `json:"encryptionKeyPath,omitempty"` Parameters map[string]string `json:"parameters,omitempty"` } func marshalFoundationDBBackupSnapshot( _ *foundationdbapp.FoundationDB, rendered *strategyv1alpha1.FoundationDBTemplate, parameters map[string]string, fdbBackup *foundationdbtypes.FoundationDBBackup, ) (*runtime.RawExtension, error) { storage := rendered.BlobStoreConfiguration.DeepCopy() // Persist the actual per-run BackupName the operator received, even // when the strategy template left it empty (ensureFoundationDBBackup // fills it with the BackupJob name). if storage.BackupName == "" && fdbBackup != nil { storage.BackupName = fdbBackup.Spec.BlobStoreConfiguration.BackupName } snap := foundationdbBackupSnapshot{ Kind: foundationdbBackupSnapshotKind, APIVersion: foundationdbBackupSnapshotAPIVersion, Storage: *storage, CustomParameters: append([]string(nil), rendered.CustomParameters...), EncryptionKeyPath: rendered.EncryptionKeyPath, Parameters: parameters, } raw, err := json.Marshal(snap) if err != nil { return nil, err } return &runtime.RawExtension{Raw: raw}, nil } // errSnapshotUnrecognised is returned by decodeFoundationDBBackupSnapshot // when the payload at Backup.status.underlyingResources is parseable JSON // but its self-typing fields (Kind, APIVersion) do not match the schema // this driver understands. The contract block on // foundationdbBackupSnapshotKind requires future readers to refuse such // snapshots rather than silently fall back to driverMetadata, which on a // real version-bump would corrupt the restore (a v2 snapshot may carry // fields whose absence the v1 reader would interpret incorrectly). // // Call sites use errors.Is(err, errSnapshotUnrecognised) to distinguish // "no snapshot present" (raw==nil, decoder returns nil/nil — fall back to // driverMetadata) from "snapshot present but unrecognised" (decoder // returns this error — restore must fail terminally). var errSnapshotUnrecognised = errors.New("Backup.status.underlyingResources carries a snapshot of an unrecognised kind/apiVersion") func decodeFoundationDBBackupSnapshot(raw *runtime.RawExtension) (*foundationdbBackupSnapshot, error) { if raw == nil || len(raw.Raw) == 0 { return nil, nil } snap := &foundationdbBackupSnapshot{} if err := json.Unmarshal(raw.Raw, snap); err != nil { return nil, fmt.Errorf("decode Backup.status.underlyingResources: %w", err) } if snap.Kind != foundationdbBackupSnapshotKind { return nil, fmt.Errorf("%w: kind=%q (want %q)", errSnapshotUnrecognised, snap.Kind, foundationdbBackupSnapshotKind) } if snap.APIVersion != foundationdbBackupSnapshotAPIVersion { return nil, fmt.Errorf("%w: apiVersion=%q (want %q)", errSnapshotUnrecognised, snap.APIVersion, foundationdbBackupSnapshotAPIVersion) } return snap, nil } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // renderFoundationDBTemplate templates the strategy template against a // context containing the live application object and the BackupClass // parameters. Reuses the same templating helper as the CNPG / MariaDB / // Velero strategies. func renderFoundationDBTemplate(t strategyv1alpha1.FoundationDBTemplate, app *foundationdbapp.FoundationDB, parameters map[string]string) (*strategyv1alpha1.FoundationDBTemplate, error) { appAsMap, err := toJSONMapFoundationDB(app) if err != nil { return nil, fmt.Errorf("encode application for templating: %w", err) } templateContext := map[string]any{ "Application": appAsMap, "Parameters": parameters, } return template.Template(&t, templateContext) } // toJSONMapFoundationDB 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). func toJSONMapFoundationDB(obj any) (map[string]any, error) { raw, err := json.Marshal(obj) if err != nil { return nil, err } out := map[string]any{} if err := json.Unmarshal(raw, &out); err != nil { return nil, err } return out, nil } // getFoundationDBApp fetches the apps.cozystack.io FoundationDB instance // via the shared typed client. The FoundationDB scheme is registered in // main.go so the controller-runtime cache serves it directly. func (r *BackupJobReconciler) getFoundationDBApp(ctx context.Context, namespace, name string) (*foundationdbapp.FoundationDB, error) { app := &foundationdbapp.FoundationDB{} if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, app); err != nil { return nil, err } return app, nil } // int32PtrCopy returns a deep copy of an optional int32 pointer. func int32PtrCopy(in *int32) *int32 { if in == nil { return nil } out := *in return &out } // FoundationDBRestoreOptions is the typed shape of RestoreJob.Spec.Options // for the FoundationDB driver. Mirrors the MariaDB strategy's // RestoreOptions pattern so the boundary parses lazily and keeps behaviour // permissive. type FoundationDBRestoreOptions struct { // RestoreTimeoutSeconds caps the time the driver waits for the // FoundationDBRestore to terminate before it marks the RestoreJob // Failed. Zero or unset falls back to foundationdbDefaultRestoreDeadline. // +optional RestoreTimeoutSeconds int64 `json:"restoreTimeoutSeconds,omitempty"` } func parseFoundationDBRestoreOptions(opts *runtime.RawExtension) (FoundationDBRestoreOptions, error) { var out FoundationDBRestoreOptions if opts == nil || len(opts.Raw) == 0 { return out, nil } if err := json.Unmarshal(opts.Raw, &out); err != nil { return FoundationDBRestoreOptions{}, fmt.Errorf("decode restoreJob.spec.options: %w", err) } return out, nil } func (o FoundationDBRestoreOptions) effectiveRestoreDeadline() time.Duration { if o.RestoreTimeoutSeconds > 0 { return time.Duration(o.RestoreTimeoutSeconds) * time.Second } return foundationdbDefaultRestoreDeadline }