Files
cozystack/internal/backupcontroller/foundationdbstrategy_controller.go
Myasnikov DaniilandClaude Opus 4.8 c82b55c621 docs(agents): align E2E guide and references with the Chainsaw migration
Rewrite docs/agents/e2e-testing.md for the Chainsaw suite: declarative
asserts replace the until-get/wait backstops, Chainsaw cleanup replaces
test-level EXIT traps, catch blocks replace the `|| { ...; false; }` idiom,
and the TIA/diagnostics sections describe suites + `make test-chainsaw
CHAINSAW_SUITES`. Move the BATS->Chainsaw bullet out of 'in-flight' since it
is now the standard.

Fix references the migration left stale: hack/e2e-apps/ -> hack/e2e-chainsaw/
in the gemini styleguide and the nats/etcd backup READMEs. The
backup-postgres.bats / backup-foundationdb.bats files cited by the postgres
examples, the postgres README, and the foundationdb controller comment never
existed -- drop the false 'CI e2e drives this' claims and describe those
backup flows as the manual reference flows they are.

Assisted-By: Claude <noreply@anthropic.com>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-06-25 13:43:50 +05:00

1265 lines
52 KiB
Go

// 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/<reason> 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 := 0; attempt < foundationdbStopConflictRetries; attempt++ {
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 := 0; attempt < foundationdbStopConflictRetries; attempt++ {
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 "<api_key>@<host:port>" 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]interface{}{
"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 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
}
// 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
}