mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-29 16:17:20 +09:00
1432 lines
62 KiB
Go
1432 lines
62 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/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"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/etcdapp"
|
|
"github.com/cozystack/cozystack/internal/backupcontroller/etcdtypes"
|
|
"github.com/cozystack/cozystack/internal/template"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const (
|
|
// etcdAppKind is the apps.cozystack.io Kind the driver claims.
|
|
etcdAppKind = "Etcd"
|
|
|
|
// etcdClusterName is the singleton etcd-operator.cozystack.io/EtcdCluster name the
|
|
// chart renders (templates/etcd-cluster.yaml sets metadata.name: etcd
|
|
// regardless of the application name). The chart additionally pins the
|
|
// Helm release name to "etcd" via templates/check-release-name.yaml -
|
|
// so per namespace there is exactly one EtcdCluster named "etcd" that
|
|
// the driver operates on.
|
|
etcdClusterName = "etcd"
|
|
|
|
// Driver-metadata keys persisted on Cozystack Backup artefacts. The
|
|
// restore path reads these to feed
|
|
// EtcdCluster.spec.bootstrap.restore.source.s3 on the re-created
|
|
// EtcdCluster.
|
|
//
|
|
// These are opaque cozystack-internal label keys on the Backup CR, NOT
|
|
// operator API. They intentionally keep the legacy "etcd.aenix.io/"
|
|
// prefix across the v1alpha2 migration so Backup artefacts taken before
|
|
// the migration still decode on the restore path; renaming them would
|
|
// silently strand pre-migration backups.
|
|
etcdBackupBucketKey = "etcd.aenix.io/bucket"
|
|
etcdBackupEndpointKey = "etcd.aenix.io/endpoint"
|
|
etcdBackupKeyKey = "etcd.aenix.io/key"
|
|
etcdBackupRegionKey = "etcd.aenix.io/region"
|
|
etcdBackupForcePathStyleKey = "etcd.aenix.io/force-path-style"
|
|
etcdBackupCredsSecretKey = "etcd.aenix.io/credentials-secret-name"
|
|
etcdBackupNameKey = "etcd.aenix.io/backup-name"
|
|
|
|
// Polling cadence for both the EtcdSnapshot status loop and the restore
|
|
// purge/Ready loop. Matches the upstream operator's reconcile beat.
|
|
etcdPollInterval = 10 * time.Second
|
|
|
|
// Wall-clock cap on a BackupJob waiting for the operator EtcdSnapshot
|
|
// to reach phase=Complete. A single etcd snapshot rarely takes more
|
|
// than a minute, but the Job pod may sit Pending behind image pulls
|
|
// and PVC provisioning. CNPG/MariaDB use 30m; etcd is faster so 20m
|
|
// is plenty without making operators tail logs forever before a
|
|
// terminal Failed surfaces.
|
|
etcdDefaultBackupDeadline = 20 * time.Minute
|
|
|
|
// Wall-clock cap on a RestoreJob waiting for the new EtcdCluster to
|
|
// become Available. Includes EtcdCluster deletion drain, PVC garbage
|
|
// collection, fresh PVC provisioning, snapshot download, etcd boot,
|
|
// peer/client TLS handshake, and member election. 30m matches the
|
|
// CNPG/Velero pattern and gives slow CSI provisioners headroom.
|
|
etcdDefaultRestoreDeadline = 30 * time.Minute
|
|
|
|
// etcdBackupSnapshotKind / etcdBackupSnapshotAPIVersion tag the
|
|
// payload persisted on Cozystack Backup.status.underlyingResources.
|
|
// Schema v1; bump the apiVersion the moment the on-disk shape changes
|
|
// in a way that breaks older readers - the restore path's
|
|
// errSnapshotUnrecognised gate refuses unfamiliar snapshots rather
|
|
// than silently falling 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).
|
|
etcdBackupSnapshotKind = "EtcdBackupSnapshot"
|
|
|
|
// Restore-state condition surfaced on the RestoreJob so a controller
|
|
// crash mid-purge can repair on the next reconcile without
|
|
// re-deleting an already-restored cluster.
|
|
etcdRestoreCondTargetPurged = "TargetPurged"
|
|
|
|
// etcdRestoreCapturedSpecMaxBytes caps the marshaled chart-rendered
|
|
// EtcdCluster spec the driver stuffs into the EtcdClusterSpecCaptured
|
|
// condition message. The Kubernetes Condition schema caps message at
|
|
// 32 KiB (maxLength: 32768); writing past that triggers a CRD
|
|
// validation rejection on r.Status().Update, which - if the
|
|
// rejection lands after the spec snapshot was taken but before
|
|
// TargetPurged is set - would leave the RestoreJob mid-purge with
|
|
// the cluster already deleted and the spec irrecoverable. Fail fast
|
|
// with a clear terminal message BEFORE any destructive step instead;
|
|
// 24 KiB leaves ~25% headroom for the condition wrapper (type,
|
|
// reason, timestamps) the API server adds on top of message.
|
|
etcdRestoreCapturedSpecMaxBytes = 24 * 1024
|
|
)
|
|
|
|
var etcdBackupSnapshotAPIVersion = backupsv1alpha1.GroupVersion.String()
|
|
|
|
// etcdClusterGVR is the GroupVersionResource for the operator-side
|
|
// EtcdCluster CR. Used by the dynamic client on the restore path so the
|
|
// driver captures and re-creates the FULL chart-rendered spec (replicas,
|
|
// storage, security, options, podTemplate, ...) rather than the
|
|
// 1-field-typed subset etcdtypes.EtcdClusterSpec carries. The typed
|
|
// client is still used for reads that only need status.conditions.
|
|
var etcdClusterGVR = schema.GroupVersionResource{
|
|
Group: etcdtypes.GroupName,
|
|
Version: etcdtypes.Version,
|
|
Resource: "etcdclusters",
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// validateEtcdApplicationRef rejects ApplicationRefs that name a
|
|
// Kind/APIGroup the Etcd driver does not own. Mirrors the FoundationDB
|
|
// driver's defence: without this gate a ref like other.example.com/Etcd
|
|
// would be accepted by the Kind check alone and then reconciled against
|
|
// the wrong CRD via the typed client.
|
|
func validateEtcdApplicationRef(ref corev1.TypedLocalObjectReference) error {
|
|
if ref.Kind != etcdAppKind {
|
|
return fmt.Errorf("Etcd strategy supports applicationRef.kind=%q, got %q", etcdAppKind, ref.Kind)
|
|
}
|
|
apiGroup := ""
|
|
if ref.APIGroup != nil {
|
|
apiGroup = *ref.APIGroup
|
|
}
|
|
if apiGroup != "" && apiGroup != etcdapp.GroupName {
|
|
return fmt.Errorf("Etcd strategy supports applicationRef.apiGroup=%q, got %q", etcdapp.GroupName, apiGroup)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BackupJob path
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (r *BackupJobReconciler) reconcileEtcd(ctx context.Context, j *backupsv1alpha1.BackupJob, resolved *ResolvedBackupConfig) (ctrl.Result, error) {
|
|
logger := getLogger(ctx)
|
|
logger.Debug("reconciling Etcd 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 := validateEtcdApplicationRef(j.Spec.ApplicationRef); err != nil {
|
|
return r.markBackupJobFailed(ctx, j, err.Error())
|
|
}
|
|
|
|
if j.Status.StartedAt == nil {
|
|
// Refetch 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 FDB driver.
|
|
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: etcdPollInterval}, nil
|
|
}
|
|
}
|
|
|
|
strategy := &strategyv1alpha1.Etcd{}
|
|
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.getEtcdApp(ctx, j.Namespace, j.Spec.ApplicationRef.Name)
|
|
if err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("Etcd application not found: %s/%s", j.Namespace, j.Spec.ApplicationRef.Name))
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
rendered, err := renderEtcdTemplate(strategy.Spec.Template, app, resolved.Parameters)
|
|
if err != nil {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to template Etcd strategy: %v", err))
|
|
}
|
|
if err := validateRenderedEtcdDestination(rendered.Destination); err != nil {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("rendered Etcd destination is invalid: %v", err))
|
|
}
|
|
|
|
// EtcdCluster is the chart-rendered, singleton-named CR. Defer the
|
|
// EtcdSnapshot creation until the cluster reports Available - the operator
|
|
// otherwise materialises a Job that fails immediately because there
|
|
// are no etcd members to snapshot.
|
|
//
|
|
// Wait-budget: the cluster-Available wait shares the same wall-clock cap
|
|
// (etcdDefaultBackupDeadline) as the operator-side EtcdSnapshot wait.
|
|
// Without this gate, a BackupJob against a never-Available EtcdCluster
|
|
// (broken etcd, deleted source app, stuck PVC provisioner) requeues
|
|
// forever and the tenant gets no terminal signal. Mirror the same
|
|
// deadline check the post-EtcdSnapshot-created path uses (line ~286)
|
|
// so the failure mode collapses to a clean phase=Failed.
|
|
cluster := &etcdtypes.EtcdCluster{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: j.Namespace, Name: etcdClusterName}, cluster); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
if j.Status.StartedAt != nil && time.Since(j.Status.StartedAt.Time) > etcdDefaultBackupDeadline {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf(
|
|
"etcd-operator.cozystack.io/EtcdCluster %s/%s did not appear within %s (deploy the source Etcd application before requesting the backup)",
|
|
j.Namespace, etcdClusterName, etcdDefaultBackupDeadline))
|
|
}
|
|
return r.requeueBackupJobWithReason(ctx, j, "EtcdClusterNotAvailable",
|
|
fmt.Sprintf("waiting for etcd-operator.cozystack.io/EtcdCluster %s/%s to exist", j.Namespace, etcdClusterName))
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
if !etcdClusterReady(cluster) {
|
|
if j.Status.StartedAt != nil && time.Since(j.Status.StartedAt.Time) > etcdDefaultBackupDeadline {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf(
|
|
"etcd-operator.cozystack.io/EtcdCluster %s/%s did not become Available within %s (current Available condition: %s)",
|
|
j.Namespace, etcdClusterName, etcdDefaultBackupDeadline, etcdClusterReadyReason(cluster)))
|
|
}
|
|
return r.requeueBackupJobWithReason(ctx, j, "EtcdClusterNotAvailable",
|
|
fmt.Sprintf("waiting for etcd-operator.cozystack.io/EtcdCluster %s/%s to become Available", j.Namespace, etcdClusterName))
|
|
}
|
|
|
|
// Intentionally skip a pre-flight Secret existence check for the
|
|
// rendered S3 credentialsSecretRef. The shared backupstrategy-
|
|
// controller RBAC (packages/system/backupstrategy-controller/templates/
|
|
// rbac.yaml) does NOT grant cluster-scoped Secret list/watch -
|
|
// reading the Secret through r.Get would force controller-runtime
|
|
// to start a Secret informer cluster-wide, the watch is denied, and
|
|
// the typed Get blocks indefinitely (the single BackupJob worker
|
|
// stalls and no further reconciles happen). The FoundationDB
|
|
// driver's analogous check gates on a non-empty backupDeployment-
|
|
// PodTemplateSpec, which is unset for tenants that don't customise
|
|
// the backup_agent pod - so the FDB path never exercises the broken
|
|
// informer in practice. The etcd path would always hit it.
|
|
//
|
|
// Missing-Secret diagnostics still surface, just one step later:
|
|
// the operator-side EtcdSnapshot Job mounts the Secret by name and
|
|
// fails fast at pod start with a CreateContainerConfigError. The
|
|
// BackupJob deadline (etcdDefaultBackupDeadline = 20m) catches the
|
|
// pathological stuck case and flips the job Failed with a clear
|
|
// message.
|
|
|
|
eb, err := r.ensureEtcdSnapshot(ctx, j, rendered)
|
|
if err != nil {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to ensure etcd-operator.cozystack.io/EtcdSnapshot: %v", err))
|
|
}
|
|
|
|
if j.Status.Phase != backupsv1alpha1.BackupJobPhaseRunning {
|
|
j.Status.Phase = backupsv1alpha1.BackupJobPhaseRunning
|
|
if err := r.Status().Update(ctx, j); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
}
|
|
|
|
switch eb.Status.Phase {
|
|
case etcdtypes.BackupPhaseComplete:
|
|
if j.Status.BackupRef != nil {
|
|
return ctrl.Result{}, nil
|
|
}
|
|
artifact, err := r.createEtcdBackupArtifact(ctx, j, resolved, eb, rendered)
|
|
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: "etcd-operator.cozystack.io/EtcdSnapshot reached phase=Complete",
|
|
})
|
|
if err := r.Status().Update(ctx, j); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{}, nil
|
|
|
|
case etcdtypes.BackupPhaseFailed:
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf(
|
|
"etcd-operator.cozystack.io/EtcdSnapshot %s/%s reached phase=Failed (%s)",
|
|
eb.Namespace, eb.Name, latestEtcdSnapshotConditionMessage(eb)))
|
|
|
|
default:
|
|
// Pending / Started / empty: still waiting on the operator.
|
|
if j.Status.StartedAt != nil && time.Since(j.Status.StartedAt.Time) > etcdDefaultBackupDeadline {
|
|
return r.markBackupJobFailed(ctx, j, fmt.Sprintf(
|
|
"etcd-operator.cozystack.io/EtcdSnapshot %s/%s did not reach phase=Complete within %s (current phase=%q)",
|
|
eb.Namespace, eb.Name, etcdDefaultBackupDeadline, eb.Status.Phase))
|
|
}
|
|
return r.requeueBackupJobWithReason(ctx, j, "EtcdBackupRunning",
|
|
fmt.Sprintf("etcd-operator.cozystack.io/EtcdSnapshot %s/%s phase=%q", eb.Namespace, eb.Name, eb.Status.Phase))
|
|
}
|
|
}
|
|
|
|
// requeueBackupJobWithReason stamps a transient Ready=False/<reason>
|
|
// condition and requeues after etcdPollInterval. Mirrors the FDB driver's
|
|
// transient-error helper but without the patch-on-Conflict retry loop
|
|
// because EtcdSnapshot status updates by the operator are far slower than
|
|
// FoundationDBBackup, so the 409 race the FDB driver works around does
|
|
// not surface in practice for this driver.
|
|
//
|
|
// Also flips phase to Running on first observable iteration so tenants
|
|
// tailing BackupJob.status.phase see activity instead of an empty string
|
|
// while the cluster boots. The Phase stays Running across subsequent
|
|
// requeues; only the terminal Succeeded/Failed transitions flip it.
|
|
func (r *BackupJobReconciler) requeueBackupJobWithReason(ctx context.Context, j *backupsv1alpha1.BackupJob, reason, message string) (ctrl.Result, error) {
|
|
if j.Status.Phase != backupsv1alpha1.BackupJobPhaseRunning {
|
|
j.Status.Phase = backupsv1alpha1.BackupJobPhaseRunning
|
|
}
|
|
apimeta.SetStatusCondition(&j.Status.Conditions, metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionFalse,
|
|
Reason: reason,
|
|
Message: message,
|
|
})
|
|
if err := r.Status().Update(ctx, j); err != nil {
|
|
if apierrors.IsConflict(err) {
|
|
// Best-effort: let the next reconcile pick up the conflict.
|
|
return ctrl.Result{RequeueAfter: etcdPollInterval}, nil
|
|
}
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{RequeueAfter: etcdPollInterval}, nil
|
|
}
|
|
|
|
// etcdClusterReady checks the operator-side Available condition.
|
|
func etcdClusterReady(c *etcdtypes.EtcdCluster) bool {
|
|
if c == nil {
|
|
return false
|
|
}
|
|
cond := apimeta.FindStatusCondition(c.Status.Conditions, etcdtypes.ClusterConditionAvailable)
|
|
return cond != nil && cond.Status == metav1.ConditionTrue
|
|
}
|
|
|
|
// etcdClusterReadyReason returns a short human-readable summary of the
|
|
// operator-side Available condition for inclusion in BackupJob terminal
|
|
// failure messages. Falls back to a sentinel when no Available condition
|
|
// has been emitted yet.
|
|
func etcdClusterReadyReason(c *etcdtypes.EtcdCluster) string {
|
|
if c == nil {
|
|
return "cluster=nil"
|
|
}
|
|
cond := apimeta.FindStatusCondition(c.Status.Conditions, etcdtypes.ClusterConditionAvailable)
|
|
if cond == nil {
|
|
return "no Available condition emitted yet"
|
|
}
|
|
if cond.Message != "" {
|
|
return fmt.Sprintf("%s=%s/%s", cond.Type, cond.Status, cond.Message)
|
|
}
|
|
return fmt.Sprintf("%s=%s/%s", cond.Type, cond.Status, cond.Reason)
|
|
}
|
|
|
|
// latestEtcdSnapshotConditionMessage returns the message most likely to
|
|
// describe a terminal failure. The upstream operator's reconcile may
|
|
// stamp a housekeeping condition (Started, Uploading, ...) AFTER the
|
|
// failure landed, which would shadow the actual cause if we picked the
|
|
// latest by LastTransitionTime alone. Prefer a Failed-typed condition
|
|
// (or a Ready=False entry) so tenants see the upstream reason, falling
|
|
// back to the latest transition only when no failure-shaped condition
|
|
// exists.
|
|
func latestEtcdSnapshotConditionMessage(eb *etcdtypes.EtcdSnapshot) string {
|
|
if eb == nil || len(eb.Status.Conditions) == 0 {
|
|
return ""
|
|
}
|
|
var (
|
|
latestFailure *metav1.Condition
|
|
latestAny *metav1.Condition
|
|
)
|
|
for i := range eb.Status.Conditions {
|
|
c := &eb.Status.Conditions[i]
|
|
if latestAny == nil || c.LastTransitionTime.After(latestAny.LastTransitionTime.Time) {
|
|
latestAny = c
|
|
}
|
|
// Treat any condition whose Type spells failure, or whose
|
|
// Reason starts with "Failed", or a Ready=False entry as a
|
|
// failure-shaped condition. Belt-and-braces because the upstream
|
|
// operator's exact spelling is not pinned by the typed wrapper.
|
|
isFailure := strings.EqualFold(c.Type, "Failed") ||
|
|
strings.HasPrefix(c.Reason, "Failed") ||
|
|
(c.Type == etcdtypes.ClusterConditionAvailable && c.Status == metav1.ConditionFalse)
|
|
if !isFailure {
|
|
continue
|
|
}
|
|
if latestFailure == nil || c.LastTransitionTime.After(latestFailure.LastTransitionTime.Time) {
|
|
latestFailure = c
|
|
}
|
|
}
|
|
if latestFailure != nil {
|
|
return latestFailure.Message
|
|
}
|
|
return latestAny.Message
|
|
}
|
|
|
|
// ensureEtcdSnapshot materialises a per-BackupJob EtcdSnapshot CR, or returns
|
|
// the existing one if a previous reconcile already created it.
|
|
// Idempotency relies on the OwningJob labels.
|
|
//
|
|
// IMPORTANT: the EtcdSnapshot CR is intentionally NOT linked back to the
|
|
// BackupJob via metav1.OwnerReference. Same reasoning as the FDB driver:
|
|
// a fresh BackupJob with the same name (e.g. tenant `kubectl delete &&
|
|
// kubectl apply`) must find the prior operator CR by its OwningJob label
|
|
// and reuse it instead of leaking a duplicate Job. Adding an OwnerRef
|
|
// would make Kubernetes GC reap the operator CR with the parent
|
|
// BackupJob, defeating the reuse contract.
|
|
func (r *BackupJobReconciler) ensureEtcdSnapshot(
|
|
ctx context.Context,
|
|
j *backupsv1alpha1.BackupJob,
|
|
rendered *strategyv1alpha1.EtcdTemplate,
|
|
) (*etcdtypes.EtcdSnapshot, error) {
|
|
existing, err := r.findEtcdSnapshotForJob(ctx, j)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if existing != nil {
|
|
return existing, nil
|
|
}
|
|
|
|
dest, err := strategyToEtcdBackupDestination(rendered.Destination)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
obj := &etcdtypes.EtcdSnapshot{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Namespace: j.Namespace,
|
|
GenerateName: fmt.Sprintf("%s-", j.Name),
|
|
Labels: map[string]string{
|
|
backupsv1alpha1.OwningJobNameLabel: j.Name,
|
|
backupsv1alpha1.OwningJobNamespaceLabel: j.Namespace,
|
|
},
|
|
},
|
|
Spec: etcdtypes.EtcdSnapshotSpec{
|
|
ClusterRef: etcdtypes.EtcdLocalObjectReference{Name: etcdClusterName},
|
|
Destination: dest,
|
|
},
|
|
}
|
|
if err := r.Create(ctx, obj); err != nil {
|
|
return nil, err
|
|
}
|
|
return obj, nil
|
|
}
|
|
|
|
// findEtcdSnapshotForJob returns the EtcdSnapshot labelled with the
|
|
// BackupJob's OwningJob{Name,Namespace}, if any.
|
|
func (r *BackupJobReconciler) findEtcdSnapshotForJob(ctx context.Context, j *backupsv1alpha1.BackupJob) (*etcdtypes.EtcdSnapshot, error) {
|
|
list := &etcdtypes.EtcdSnapshotList{}
|
|
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 etcd-operator.cozystack.io/EtcdSnapshot CRs match BackupJob OwningJob labels; reusing first",
|
|
"backupjob", j.Name, "namespace", j.Namespace, "matches", names, "picked", names[0])
|
|
}
|
|
return &list.Items[0], nil
|
|
}
|
|
|
|
// createEtcdBackupArtifact materialises a Cozystack Backup resource
|
|
// carrying the metadata callers need to drive a future restore. The
|
|
// rendered S3/PVC coordinates are persisted in
|
|
// Backup.status.underlyingResources so restore-time templating produces
|
|
// the same values the backup ran with even after the operator-side
|
|
// EtcdSnapshot CR has been pruned.
|
|
func (r *BackupJobReconciler) createEtcdBackupArtifact(
|
|
ctx context.Context,
|
|
j *backupsv1alpha1.BackupJob,
|
|
resolved *ResolvedBackupConfig,
|
|
eb *etcdtypes.EtcdSnapshot,
|
|
rendered *strategyv1alpha1.EtcdTemplate,
|
|
) (*backupsv1alpha1.Backup, error) {
|
|
takenAt := metav1.Now()
|
|
|
|
driverMD := map[string]string{etcdBackupNameKey: eb.Name}
|
|
if s := rendered.Destination.S3; s != nil {
|
|
driverMD[etcdBackupBucketKey] = s.Bucket
|
|
driverMD[etcdBackupEndpointKey] = s.Endpoint
|
|
if s.Key != "" {
|
|
driverMD[etcdBackupKeyKey] = s.Key
|
|
}
|
|
if s.Region != "" {
|
|
driverMD[etcdBackupRegionKey] = s.Region
|
|
}
|
|
if s.ForcePathStyle != nil {
|
|
driverMD[etcdBackupForcePathStyleKey] = fmt.Sprintf("%t", *s.ForcePathStyle)
|
|
}
|
|
driverMD[etcdBackupCredsSecretKey] = s.CredentialsSecretRef.Name
|
|
}
|
|
|
|
underlyingResources, err := marshalEtcdBackupSnapshot(rendered, resolved.Parameters)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode source snapshot for Backup.status.underlyingResources: %w", err)
|
|
}
|
|
|
|
status := backupsv1alpha1.BackupStatus{
|
|
Phase: backupsv1alpha1.BackupPhaseReady,
|
|
UnderlyingResources: underlyingResources,
|
|
}
|
|
// Pass through the upstream operator's reported snapshot location
|
|
// + integrity hash. The backup-agent emits the final URI in a
|
|
// terminal pod-log marker that the operator (v0.4.4+) parses into
|
|
// EtcdSnapshot.status.artifact — see the upstream etcd-operator
|
|
// EtcdSnapshot reconciler. The spec
|
|
// destination alone is just the prefix; the agent appends the
|
|
// backup-name (and any rev/timestamp suffix), so status.snapshot
|
|
// is the authoritative final URI for human inspection.
|
|
//
|
|
// Defensive nil-check: pre-Complete reconciles and a forensic
|
|
// downgrade to v0.4.3 (no snapshot field) leave Snapshot nil. In
|
|
// that case we deliberately leave Backup.status.artifact unset
|
|
// rather than synthesise a URI from the spec destination — the
|
|
// spec destination is the prefix, not the final key, and emitting
|
|
// a URI that points at a non-existent S3 object is worse than
|
|
// emitting nothing. The buildEtcdRestoreS3Key helper handles
|
|
// reconstruction at restore time for both cases.
|
|
if s := eb.Status.Artifact; s != nil && s.URI != "" {
|
|
status.Artifact = &backupsv1alpha1.BackupArtifact{
|
|
URI: s.URI,
|
|
SizeBytes: s.SizeBytes,
|
|
Checksum: s.Checksum,
|
|
}
|
|
}
|
|
backup := &backupsv1alpha1.Backup{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: j.Name,
|
|
Namespace: j.Namespace,
|
|
},
|
|
Spec: backupsv1alpha1.BackupSpec{
|
|
ApplicationRef: j.Spec.ApplicationRef,
|
|
StrategyRef: resolved.StrategyRef,
|
|
TakenAt: takenAt,
|
|
DriverMetadata: driverMD,
|
|
},
|
|
Status: status,
|
|
}
|
|
if j.Spec.PlanRef != nil {
|
|
backup.Spec.PlanRef = j.Spec.PlanRef
|
|
}
|
|
if err := r.Create(ctx, backup); err != nil {
|
|
if !apierrors.IsAlreadyExists(err) {
|
|
return nil, err
|
|
}
|
|
existing := &backupsv1alpha1.Backup{}
|
|
if getErr := r.Get(ctx, types.NamespacedName{Namespace: backup.Namespace, Name: backup.Name}, existing); getErr != nil {
|
|
return nil, getErr
|
|
}
|
|
return existing, nil
|
|
}
|
|
return backup, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RestoreJob path - in-place only (R2: suspend HR, snapshot live spec,
|
|
// delete + recreate EtcdCluster with bootstrap.restore, resume HR)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (r *RestoreJobReconciler) reconcileEtcdRestore(ctx context.Context, restoreJob *backupsv1alpha1.RestoreJob, backup *backupsv1alpha1.Backup) (ctrl.Result, error) {
|
|
logger := getLogger(ctx)
|
|
logger.Debug("reconciling Etcd restore", "restorejob", restoreJob.Name, "backup", backup.Name)
|
|
|
|
if err := validateEtcdApplicationRef(backup.Spec.ApplicationRef); err != nil {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, err.Error())
|
|
}
|
|
|
|
// To-copy isn't expressible for etcd. The PRIMARY constraint is at
|
|
// the core API level: RestoreJob.spec.targetApplicationRef is a
|
|
// TypedLocalObjectReference, which has no namespace field - the
|
|
// restore target is always the SAME namespace as the source Backup,
|
|
// so there is no API surface for a cross-namespace "fresh cluster
|
|
// with the snapshot's data" workflow. The chart-level pin (Helm
|
|
// release name fixed to "etcd" via
|
|
// templates/check-release-name.yaml) is a SECONDARY constraint that
|
|
// compounds the first: even with a hypothetical cross-namespace
|
|
// TargetApplicationRef API, two Etcd apps could not coexist in one
|
|
// namespace. Reject any RestoreJob that names a target different
|
|
// from the Backup's source so tenants get a pointed message instead
|
|
// of a silent fall-through into in-place.
|
|
if t := restoreJob.Spec.TargetApplicationRef; t != nil {
|
|
if t.Name != "" && t.Name != backup.Spec.ApplicationRef.Name {
|
|
return r.markRestoreJobFailed(ctx, restoreJob,
|
|
"Etcd driver does not support to-copy restore: RestoreJob.spec.targetApplicationRef is a same-namespace local reference, so a different target name in the same namespace cannot exist (and the chart pins release.name=\"etcd\", precluding two Etcd apps in one namespace regardless). Restore in-place by leaving spec.targetApplicationRef unset.")
|
|
}
|
|
if t.Kind != "" && t.Kind != etcdAppKind {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"target applicationRef.kind=%q is not supported by the Etcd driver", t.Kind))
|
|
}
|
|
// Mirror validateEtcdApplicationRef's defence-in-depth on the
|
|
// TARGET ref: without this gate a tenant could submit
|
|
// {Kind: "Etcd", Name: <source>, APIGroup: "other.example.com"}
|
|
// — the Name+Kind checks above accept (name matches the source,
|
|
// Kind matches "Etcd"), resolveEtcdRestoreTarget then silently
|
|
// drops the foreign APIGroup, and the restore runs against the
|
|
// Cozystack Etcd app instead of whatever the tenant intended.
|
|
if t.APIGroup != nil && *t.APIGroup != "" && *t.APIGroup != etcdapp.GroupName {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"target applicationRef.apiGroup=%q is not supported by the Etcd driver (want %q)", *t.APIGroup, etcdapp.GroupName))
|
|
}
|
|
}
|
|
|
|
target := r.resolveEtcdRestoreTarget(restoreJob, backup)
|
|
|
|
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: etcdPollInterval}, nil
|
|
}
|
|
}
|
|
|
|
options, err := parseEtcdRestoreOptions(restoreJob.Spec.Options)
|
|
if err != nil {
|
|
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 EtcdRestoreOptions JSON object)", err))
|
|
}
|
|
|
|
// Resolve the snapshot destination from the persisted underlyingResources
|
|
// (the authoritative source - durable beyond the operator EtcdSnapshot
|
|
// CR's lifetime). Fall back to driverMetadata on decode failure unless
|
|
// the snapshot identifies an unrecognised schema, in which case the
|
|
// restore terminates - same contract as the FoundationDB driver.
|
|
snap, err := decodeEtcdBackupSnapshot(backup.Status.UnderlyingResources)
|
|
if err != nil {
|
|
if errors.Is(err, errEtcdSnapshotUnrecognised) {
|
|
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 Etcd snapshot; falling back to driverMetadata: %v", err)
|
|
snap = nil
|
|
}
|
|
dest, destOK := resolveEtcdRestoreDestination(backup, snap)
|
|
if !destOK {
|
|
return r.markRestoreJobFailed(ctx, restoreJob,
|
|
"Backup driverMetadata/snapshot is missing the Etcd backup destination (re-take the backup with a controller version that persists it)")
|
|
}
|
|
|
|
// The driver must NOT race the source Etcd HelmRelease while the
|
|
// purge/recreate window is open: helm-controller observing the live
|
|
// EtcdCluster disappear would re-render the chart's bootstrap-less
|
|
// EtcdCluster on the next sync and the operator would start a fresh
|
|
// empty cluster, defeating the restore. Suspend the HR, hold it
|
|
// suspended until the new EtcdCluster is Available, then resume.
|
|
hrName := target.AppName
|
|
|
|
// First reconcile in the destructive window: snapshot the live spec
|
|
// (via the dynamic client so all chart-rendered fields - replicas,
|
|
// storage, security, options, podTemplate - are preserved instead of
|
|
// being dropped by the typed-client subset projection), suspend HR,
|
|
// delete the EtcdCluster.
|
|
purged := apimeta.IsStatusConditionTrue(restoreJob.Status.Conditions, etcdRestoreCondTargetPurged)
|
|
specCaptured := apimeta.IsStatusConditionTrue(restoreJob.Status.Conditions, etcdRestoreCondClusterSpecCaptured)
|
|
if !purged {
|
|
live, err := r.Resource(etcdClusterGVR).Namespace(target.Namespace).Get(ctx, etcdClusterName, metav1.GetOptions{})
|
|
if err != nil {
|
|
if !apierrors.IsNotFound(err) {
|
|
return ctrl.Result{}, err
|
|
}
|
|
// NotFound disambiguation:
|
|
// - SpecCaptured already set: a previous iteration in this
|
|
// reconcile loop already deleted the EtcdCluster but
|
|
// the controller crashed (or got requeued) before
|
|
// stamping TargetPurged. Treat as "already deleted",
|
|
// advance the state machine.
|
|
// - SpecCaptured NOT set: the tenant never had a cluster
|
|
// to back up. Terminate the RestoreJob loudly.
|
|
if specCaptured {
|
|
apimeta.SetStatusCondition(&restoreJob.Status.Conditions, metav1.Condition{
|
|
Type: etcdRestoreCondTargetPurged,
|
|
Status: metav1.ConditionTrue,
|
|
Reason: "ClusterPurgedRecovered",
|
|
Message: fmt.Sprintf("etcd-operator.cozystack.io/EtcdCluster %s/%s already gone on this iteration; advancing to recreate phase", target.Namespace, etcdClusterName),
|
|
})
|
|
if err := r.Status().Update(ctx, restoreJob); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{RequeueAfter: etcdPollInterval}, nil
|
|
}
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"etcd-operator.cozystack.io/EtcdCluster %s/%s not found; cannot derive the spec to re-create with bootstrap.restore (deploy the source Etcd application before requesting the restore)",
|
|
target.Namespace, etcdClusterName))
|
|
}
|
|
|
|
// Persist the live spec on a status condition so a controller
|
|
// crash between "snapshot taken" and "cluster recreated" can
|
|
// recover without losing the chart-rendered spec to the empty
|
|
// post-delete state. Skip when already persisted.
|
|
if !specCaptured {
|
|
liveSpec, _, _ := unstructured.NestedMap(live.Object, "spec")
|
|
if len(liveSpec) == 0 {
|
|
return r.markRestoreJobFailed(ctx, restoreJob,
|
|
"live etcd-operator.cozystack.io/EtcdCluster has empty spec; refusing to restore against an unconfigured cluster")
|
|
}
|
|
specPayload, mErr := json.Marshal(liveSpec)
|
|
if mErr != nil {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"encode live EtcdCluster spec: %v", mErr))
|
|
}
|
|
// Fail fast BEFORE any destructive step if the spec would
|
|
// overflow the Condition.Message cap. Hitting the cap after
|
|
// the cluster is deleted would leave the RestoreJob stuck
|
|
// with the spec lost.
|
|
if len(specPayload) > etcdRestoreCapturedSpecMaxBytes {
|
|
return r.markRestoreJobFailed(ctx, restoreJob, fmt.Sprintf(
|
|
"live etcd-operator.cozystack.io/EtcdCluster spec is %d bytes which exceeds the %d-byte limit the driver can durably capture before the destructive purge; trim podTemplate / topologySpreadConstraints customisation on the source app or open an issue for a ConfigMap-backed capture path",
|
|
len(specPayload), etcdRestoreCapturedSpecMaxBytes))
|
|
}
|
|
apimeta.SetStatusCondition(&restoreJob.Status.Conditions, metav1.Condition{
|
|
Type: etcdRestoreCondClusterSpecCaptured,
|
|
Status: metav1.ConditionTrue,
|
|
Reason: "SpecCaptured",
|
|
Message: string(specPayload),
|
|
})
|
|
if err := r.Status().Update(ctx, restoreJob); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
// Requeue so the next pass sees the persisted condition and
|
|
// can proceed with the destructive flow on a stable basis.
|
|
return ctrl.Result{RequeueAfter: etcdPollInterval}, nil
|
|
}
|
|
|
|
if err := r.setEtcdRestoreHRSuspended(ctx, target.Namespace, hrName, true); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
if err := r.Resource(etcdClusterGVR).Namespace(target.Namespace).Delete(ctx, etcdClusterName, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) {
|
|
return r.markEtcdRestoreFailedAndResumeHR(ctx, restoreJob, target.Namespace, hrName, fmt.Sprintf(
|
|
"delete etcd-operator.cozystack.io/EtcdCluster %s/%s: %v", target.Namespace, etcdClusterName, err))
|
|
}
|
|
|
|
gone, err := r.etcdClusterFullyGone(ctx, target.Namespace)
|
|
if err != nil {
|
|
// Transient apiserver errors here would loop forever with HR
|
|
// suspended (helm-controller frozen, no manual recovery
|
|
// possible). Resume the HR before bubbling the err - the next
|
|
// reconcile re-suspends if needed.
|
|
_ = r.setEtcdRestoreHRSuspended(ctx, target.Namespace, hrName, false)
|
|
return ctrl.Result{}, err
|
|
}
|
|
if !gone {
|
|
return ctrl.Result{RequeueAfter: etcdPollInterval}, nil
|
|
}
|
|
|
|
apimeta.SetStatusCondition(&restoreJob.Status.Conditions, metav1.Condition{
|
|
Type: etcdRestoreCondTargetPurged,
|
|
Status: metav1.ConditionTrue,
|
|
Reason: "ClusterPurged",
|
|
Message: fmt.Sprintf("etcd-operator.cozystack.io/EtcdCluster %s/%s and member PVCs are gone; recreating with bootstrap.restore", target.Namespace, etcdClusterName),
|
|
})
|
|
if err := r.Status().Update(ctx, restoreJob); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{RequeueAfter: etcdPollInterval}, nil
|
|
}
|
|
|
|
// Recreate phase: ensure a fresh EtcdCluster with bootstrap.restore
|
|
// pointing at the snapshot. Idempotent: if a prior reconcile already
|
|
// created it (or the operator already started bootstrapping), the
|
|
// Get returns the live CR and we fall through to the Ready poll.
|
|
if _, err := r.Resource(etcdClusterGVR).Namespace(target.Namespace).Get(ctx, etcdClusterName, metav1.GetOptions{}); err != nil {
|
|
if !apierrors.IsNotFound(err) {
|
|
return ctrl.Result{}, err
|
|
}
|
|
specMap, specErr := readCapturedEtcdClusterSpec(restoreJob)
|
|
if specErr != nil {
|
|
return r.markEtcdRestoreFailedAndResumeHR(ctx, restoreJob, target.Namespace, hrName, fmt.Sprintf(
|
|
"recover captured EtcdCluster spec: %v", specErr))
|
|
}
|
|
// Inject bootstrap.restore.source on top of the captured spec.
|
|
bootstrap := map[string]interface{}{
|
|
"restore": map[string]interface{}{
|
|
"source": etcdBackupDestinationToUnstructured(dest),
|
|
},
|
|
}
|
|
specMap["bootstrap"] = bootstrap
|
|
|
|
obj := &unstructured.Unstructured{}
|
|
obj.SetAPIVersion(etcdtypes.GroupVersion.String())
|
|
obj.SetKind("EtcdCluster")
|
|
obj.SetNamespace(target.Namespace)
|
|
obj.SetName(etcdClusterName)
|
|
if err := unstructured.SetNestedMap(obj.Object, specMap, "spec"); err != nil {
|
|
return r.markEtcdRestoreFailedAndResumeHR(ctx, restoreJob, target.Namespace, hrName, fmt.Sprintf(
|
|
"assemble bootstrap.restore spec: %v", err))
|
|
}
|
|
if _, err := r.Resource(etcdClusterGVR).Namespace(target.Namespace).Create(ctx, obj, metav1.CreateOptions{}); err != nil && !apierrors.IsAlreadyExists(err) {
|
|
return ctrl.Result{}, fmt.Errorf("create EtcdCluster with bootstrap.restore: %w", err)
|
|
}
|
|
return ctrl.Result{RequeueAfter: etcdPollInterval}, nil
|
|
}
|
|
|
|
// For the Ready poll, the typed client is enough: we only read
|
|
// status.conditions, which etcdtypes.EtcdClusterStatus exposes.
|
|
live := &etcdtypes.EtcdCluster{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: target.Namespace, Name: etcdClusterName}, live); err != nil {
|
|
if !apierrors.IsNotFound(err) {
|
|
return ctrl.Result{}, err
|
|
}
|
|
// Just-deleted race; requeue and let the next reconcile re-create.
|
|
return ctrl.Result{RequeueAfter: etcdPollInterval}, nil
|
|
}
|
|
if !etcdClusterReady(live) {
|
|
deadline := options.effectiveRestoreDeadline()
|
|
if restoreJob.Status.StartedAt != nil && time.Since(restoreJob.Status.StartedAt.Time) > deadline {
|
|
// This is the most likely production failure point - the
|
|
// recreated EtcdCluster never becomes Available within deadline
|
|
// (e.g. snapshot download stuck, S3 creds wrong, PVC
|
|
// provisioner slow). Resume the HR before terminal-failing
|
|
// so helm-controller is not left frozen on the tenant's
|
|
// Etcd app.
|
|
return r.markEtcdRestoreFailedAndResumeHR(ctx, restoreJob, target.Namespace, hrName, fmt.Sprintf(
|
|
"etcd-operator.cozystack.io/EtcdCluster %s/%s did not become Available within %s after recreation with bootstrap.restore (override via spec.options.restoreTimeoutSeconds)",
|
|
target.Namespace, etcdClusterName, deadline))
|
|
}
|
|
apimeta.SetStatusCondition(&restoreJob.Status.Conditions, metav1.Condition{
|
|
Type: "Ready",
|
|
Status: metav1.ConditionFalse,
|
|
Reason: "EtcdClusterBootstrapping",
|
|
Message: fmt.Sprintf("waiting for etcd-operator.cozystack.io/EtcdCluster %s/%s to become Available after bootstrap.restore", target.Namespace, etcdClusterName),
|
|
})
|
|
if err := r.Status().Update(ctx, restoreJob); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{RequeueAfter: etcdPollInterval}, nil
|
|
}
|
|
|
|
// Cluster is Available - resume the HR. Idempotent: a NotFound on the HR
|
|
// (deleted between suspend and resume) is treated as success.
|
|
if err := r.setEtcdRestoreHRSuspended(ctx, target.Namespace, hrName, false); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
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("etcd-operator.cozystack.io/EtcdCluster %s/%s became Available after bootstrap.restore", target.Namespace, etcdClusterName),
|
|
})
|
|
if err := r.Status().Update(ctx, restoreJob); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
return ctrl.Result{}, nil
|
|
}
|
|
|
|
const (
|
|
// etcdRestoreCondClusterSpecCaptured carries the JSON-encoded live
|
|
// EtcdCluster spec as its Message, so a controller crash between
|
|
// "snapshot taken" and "cluster recreated" recovers without losing
|
|
// the chart-rendered spec to the empty post-delete state. Mirrors
|
|
// the CNPG driver's TargetPurged pattern; reusing a Condition for
|
|
// the payload sidesteps the need for a new CRD field on RestoreJob.
|
|
// The size cap (etcdRestoreCapturedSpecMaxBytes) is enforced BEFORE
|
|
// the destructive purge so a spec that wouldn't survive the
|
|
// 32 KiB Condition.Message limit terminates the RestoreJob loudly
|
|
// instead of getting stuck mid-purge with the cluster gone.
|
|
etcdRestoreCondClusterSpecCaptured = "EtcdClusterSpecCaptured"
|
|
)
|
|
|
|
// readCapturedEtcdClusterSpec returns the EtcdCluster spec persisted on
|
|
// the EtcdClusterSpecCaptured condition by the destructive-window step
|
|
// as an unstructured map. Errors when the condition is missing/empty or
|
|
// the payload doesn't round-trip; a missing capture is itself a
|
|
// programming error - the caller only reaches this path after a
|
|
// TargetPurged condition was set, which requires the capture step to
|
|
// have written the message.
|
|
//
|
|
// Map-based instead of typed because etcdtypes.EtcdClusterSpec carries
|
|
// only the fields the driver actively mutates (currently Bootstrap), so
|
|
// a typed unmarshal would drop everything the chart populated (replicas,
|
|
// storage, security, options, podTemplate, ...) and the recreated
|
|
// EtcdCluster would be an empty shell.
|
|
func readCapturedEtcdClusterSpec(restoreJob *backupsv1alpha1.RestoreJob) (map[string]interface{}, error) {
|
|
cond := apimeta.FindStatusCondition(restoreJob.Status.Conditions, etcdRestoreCondClusterSpecCaptured)
|
|
if cond == nil || cond.Message == "" {
|
|
return nil, errors.New("EtcdClusterSpecCaptured condition is missing or empty; cannot rebuild EtcdCluster spec")
|
|
}
|
|
specMap := map[string]interface{}{}
|
|
if err := json.Unmarshal([]byte(cond.Message), &specMap); err != nil {
|
|
return nil, fmt.Errorf("decode captured spec: %w", err)
|
|
}
|
|
if len(specMap) == 0 {
|
|
return nil, errors.New("captured EtcdCluster spec is empty; refusing to recreate against an unconfigured cluster")
|
|
}
|
|
return specMap, nil
|
|
}
|
|
|
|
// s3KeyFromArtifactURI extracts the object key from an
|
|
// `s3://<bucket>/<key>` artifact URI when the bucket matches the
|
|
// expected destination bucket. The operator's backup-agent emits the
|
|
// FULL final key in this URI, including BACKUP_INCLUDE_REVISION /
|
|
// BACKUP_TIMESTAMP suffixes the agent appended at write time. When
|
|
// the URI is parseable and addresses the same bucket, the returned
|
|
// key is what the restore-agent needs in spec.bootstrap.restore.source
|
|
// .s3.key.
|
|
//
|
|
// Returns ok=false when the artifact is nil, the URI has a non-s3
|
|
// scheme (PVC destinations or future agents emitting "file://"), the
|
|
// host (bucket) doesn't match `expectedBucket`, or the path is empty.
|
|
// The bucket-match check is a safety belt: a future agent that emits
|
|
// a URI with the wrong bucket should not silently divert the restore.
|
|
func s3KeyFromArtifactURI(artifact *backupsv1alpha1.BackupArtifact, expectedBucket string) (string, bool) {
|
|
if artifact == nil || artifact.URI == "" {
|
|
return "", false
|
|
}
|
|
const scheme = "s3://"
|
|
if !strings.HasPrefix(artifact.URI, scheme) {
|
|
return "", false
|
|
}
|
|
rest := artifact.URI[len(scheme):]
|
|
slash := strings.Index(rest, "/")
|
|
if slash <= 0 || slash == len(rest)-1 {
|
|
// No bucket/key separator, or trailing slash with empty key.
|
|
return "", false
|
|
}
|
|
bucket := rest[:slash]
|
|
key := rest[slash+1:]
|
|
if bucket != expectedBucket {
|
|
return "", false
|
|
}
|
|
return key, true
|
|
}
|
|
|
|
// buildEtcdRestoreS3Key mirrors the operator's backup-side filename
|
|
// convention (internal/controller/factory/backup_job.go) so the
|
|
// restore-agent's S3_KEY env points at the exact object the backup-
|
|
// agent wrote:
|
|
//
|
|
// key == "" => "<backupName>.db"
|
|
// key trailing-slash => "<prefix>/<backupName>.db"
|
|
// otherwise => "<key>/<backupName>.db"
|
|
//
|
|
// When backupName is empty (old artefact missing the driverMetadata
|
|
// label) we return the raw prefix verbatim - the restore will fail with
|
|
// a clear "downloaded snapshot is empty" message and the tenant knows
|
|
// to re-take the backup with the current controller version.
|
|
func buildEtcdRestoreS3Key(keyPrefix, backupName string) string {
|
|
if backupName == "" {
|
|
return keyPrefix
|
|
}
|
|
trimmed := strings.TrimRight(keyPrefix, "/")
|
|
if trimmed == "" {
|
|
return backupName + ".db"
|
|
}
|
|
return trimmed + "/" + backupName + ".db"
|
|
}
|
|
|
|
// etcdBackupDestinationToUnstructured renders an EtcdBackupDestination
|
|
// into the operator-side EtcdCluster.spec.bootstrap.restore.source shape
|
|
// as an unstructured map. The two CRDs share the same JSON shape for
|
|
// destinations, so this is a 1:1 field mapping with empty fields elided
|
|
// (the operator's MinLength validators reject zero-length bucket /
|
|
// endpoint / credentialsSecretRef.name; emitting them explicitly would
|
|
// turn a partial-config into a noisier admission error than the
|
|
// driver's own validateRenderedEtcdDestination already surfaces).
|
|
func etcdBackupDestinationToUnstructured(d etcdtypes.EtcdBackupDestination) map[string]interface{} {
|
|
out := map[string]interface{}{}
|
|
if s := d.S3; s != nil {
|
|
s3 := map[string]interface{}{
|
|
"bucket": s.Bucket,
|
|
"endpoint": s.Endpoint,
|
|
"credentialsSecretRef": map[string]interface{}{
|
|
"name": s.CredentialsSecretRef.Name,
|
|
},
|
|
}
|
|
if s.Key != "" {
|
|
s3["key"] = s.Key
|
|
}
|
|
if s.Region != "" {
|
|
s3["region"] = s.Region
|
|
}
|
|
if s.ForcePathStyle != nil {
|
|
s3["forcePathStyle"] = *s.ForcePathStyle
|
|
}
|
|
out["s3"] = s3
|
|
}
|
|
// PVC is intentionally not handled here: the strategy CR rejects
|
|
// PVC destinations at admission time (see EtcdDestinationTemplate's
|
|
// XValidation rule) because the operator's PVC backup/restore
|
|
// paths are asymmetric and the resulting restore is unreachable.
|
|
// d.PVC stays in the etcdtypes mirror for upstream-shape parity
|
|
// but is unreachable from this driver.
|
|
return out
|
|
}
|
|
|
|
// resolveEtcdRestoreDestination picks the destination block to stamp onto
|
|
// EtcdCluster.spec.bootstrap.restore.source. Resolution order:
|
|
//
|
|
// 1. Backup.status.artifact.uri (v0.4.4+) - the AUTHORITATIVE final S3
|
|
// key written by the backup-agent, including any
|
|
// BACKUP_INCLUDE_REVISION / BACKUP_TIMESTAMP suffix the agent
|
|
// appended at write time. This is the only path that round-trips
|
|
// correctly when the operator is configured to inject filename
|
|
// suffixes.
|
|
// 2. Persisted snapshot (Backup.status.underlyingResources) - same
|
|
// fields as the operator's destination, but the Key is the
|
|
// prefix-only fragment the strategy rendered. Reconstruct the
|
|
// filename via buildEtcdRestoreS3Key, which mirrors the operator's
|
|
// default (suffix-less) convention. Used when the artifact URI is
|
|
// unavailable (forensic downgrade, pre-Complete reconcile).
|
|
// 3. driverMetadata (oldest artifacts) - same suffix-less
|
|
// reconstruction.
|
|
//
|
|
// For S3, the restore-agent reads S3_KEY verbatim from
|
|
// EtcdCluster.spec.bootstrap.restore.source.s3.key, so the value MUST
|
|
// be the full object key. Passing the prefix would 404 against the
|
|
// agent-written object; passing the agent's documented filename when
|
|
// the agent actually wrote a -rev<N>-suffixed file would also 404. The
|
|
// artifact-URI path sidesteps both by trusting the agent's emitted
|
|
// marker.
|
|
func resolveEtcdRestoreDestination(backup *backupsv1alpha1.Backup, snap *etcdBackupSnapshot) (etcdtypes.EtcdBackupDestination, bool) {
|
|
if snap != nil && snap.Destination.S3 != nil {
|
|
s := snap.Destination.S3
|
|
fps := s.ForcePathStyle
|
|
// Prefer the operator-reported artifact URI when present: it
|
|
// carries the FULL final key including BACKUP_INCLUDE_REVISION
|
|
// / BACKUP_TIMESTAMP suffixes. buildEtcdRestoreS3Key only
|
|
// reproduces the suffix-less default; if the operator was
|
|
// configured with BACKUP_INCLUDE_REVISION=true (as in v0.4.4
|
|
// default), the reconstructed key would 404.
|
|
key, ok := s3KeyFromArtifactURI(backup.Status.Artifact, s.Bucket)
|
|
if !ok {
|
|
key = buildEtcdRestoreS3Key(s.Key, backup.Spec.DriverMetadata[etcdBackupNameKey])
|
|
}
|
|
dest := etcdtypes.EtcdBackupDestination{
|
|
S3: &etcdtypes.EtcdBackupS3{
|
|
Bucket: s.Bucket,
|
|
Endpoint: s.Endpoint,
|
|
Key: key,
|
|
Region: s.Region,
|
|
ForcePathStyle: fps,
|
|
CredentialsSecretRef: etcdtypes.EtcdLocalObjectReference{Name: s.CredentialsSecretRef.Name},
|
|
},
|
|
}
|
|
return dest, true
|
|
}
|
|
// PVC destinations are intentionally not supported by this driver
|
|
// - see EtcdDestinationTemplate's XValidation rule in etcd_types.go.
|
|
// The upstream operator's PVC backup-side writes a path the
|
|
// restore-side cannot read; until that's fixed upstream, a PVC
|
|
// backup taken with this strategy would be unrestoreable. The CRD
|
|
// admission gate ensures snap.Destination.PVC stays nil here.
|
|
md := backup.Spec.DriverMetadata
|
|
bucket := md[etcdBackupBucketKey]
|
|
endpoint := md[etcdBackupEndpointKey]
|
|
credsName := md[etcdBackupCredsSecretKey]
|
|
if bucket == "" || endpoint == "" || credsName == "" {
|
|
return etcdtypes.EtcdBackupDestination{}, false
|
|
}
|
|
var fps *bool
|
|
if v, ok := md[etcdBackupForcePathStyleKey]; ok {
|
|
b := v == "true"
|
|
fps = &b
|
|
}
|
|
// Same artifact-URI preference as the snapshot path - so a backup
|
|
// taken with BACKUP_INCLUDE_REVISION=true is restorable even when
|
|
// the snapshot field has been wiped from Backup.status but the
|
|
// artifact URI was preserved.
|
|
key, ok := s3KeyFromArtifactURI(backup.Status.Artifact, bucket)
|
|
if !ok {
|
|
key = buildEtcdRestoreS3Key(md[etcdBackupKeyKey], md[etcdBackupNameKey])
|
|
}
|
|
dest := etcdtypes.EtcdBackupDestination{
|
|
S3: &etcdtypes.EtcdBackupS3{
|
|
Bucket: bucket,
|
|
Endpoint: endpoint,
|
|
Key: key,
|
|
Region: md[etcdBackupRegionKey],
|
|
ForcePathStyle: fps,
|
|
CredentialsSecretRef: etcdtypes.EtcdLocalObjectReference{Name: credsName},
|
|
},
|
|
}
|
|
return dest, true
|
|
}
|
|
|
|
// markEtcdRestoreFailedAndResumeHR is the terminal-failure exit used by
|
|
// every code path that runs AFTER setEtcdRestoreHRSuspended(true). It
|
|
// resumes the HelmRelease best-effort before marking the RestoreJob
|
|
// Failed, so a terminal failure during the destructive window does not
|
|
// leave the tenant's Etcd app with helm-controller frozen (no manual
|
|
// `kubectl apply` would reconcile until the HR is resumed by hand).
|
|
//
|
|
// For etcd we cannot do CNPG's "early resume after purge" pattern: the
|
|
// chart-rendered EtcdCluster has no bootstrap block, so resuming the
|
|
// HR before our recreate stamps bootstrap.restore would let
|
|
// helm-controller race a bootstrap-less render onto the purged
|
|
// namespace and the operator would start an empty cluster. The HR
|
|
// MUST stay suspended for the full destructive window; the only safe
|
|
// way to recover from a terminal failure mid-window is to resume the
|
|
// HR exactly here, on the way out.
|
|
//
|
|
// The resume err is intentionally swallowed: the RestoreJob is
|
|
// already failing, and any HR-side err is surfaced through
|
|
// helm-controller's own status, not the RestoreJob's. Idempotent: if
|
|
// the HR is already resumed the call is a no-op inside
|
|
// setEtcdRestoreHRSuspended.
|
|
func (r *RestoreJobReconciler) markEtcdRestoreFailedAndResumeHR(
|
|
ctx context.Context,
|
|
restoreJob *backupsv1alpha1.RestoreJob,
|
|
namespace, hrName, message string,
|
|
) (ctrl.Result, error) {
|
|
_ = r.setEtcdRestoreHRSuspended(ctx, namespace, hrName, false)
|
|
return r.markRestoreJobFailed(ctx, restoreJob, message)
|
|
}
|
|
|
|
// setEtcdRestoreHRSuspended toggles spec.suspend on the target Etcd
|
|
// application's HelmRelease. The destructive flow needs the HR suspended
|
|
// before the driver deletes the live EtcdCluster - otherwise helm-
|
|
// controller observes the disappearance and re-renders the chart's
|
|
// bootstrap-less EtcdCluster on its next sync, defeating the restore.
|
|
// Resume only after the new EtcdCluster reaches Ready. Idempotent.
|
|
//
|
|
// NotFound is treated as success: a tenant who deletes the Etcd app
|
|
// while a RestoreJob is mid-flight has bigger problems than the
|
|
// finalizer; the controller should not get stuck on it.
|
|
func (r *RestoreJobReconciler) setEtcdRestoreHRSuspended(ctx context.Context, namespace, name string, suspend bool) error {
|
|
hr, err := r.Resource(helmReleaseGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
|
if err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("get HelmRelease %s/%s: %w", namespace, name, err)
|
|
}
|
|
current, _, _ := unstructured.NestedBool(hr.Object, "spec", "suspend")
|
|
if current == suspend {
|
|
return nil
|
|
}
|
|
if err := unstructured.SetNestedField(hr.Object, suspend, "spec", "suspend"); err != nil {
|
|
return fmt.Errorf("set spec.suspend on HelmRelease %s/%s: %w", namespace, name, err)
|
|
}
|
|
if _, err := r.Resource(helmReleaseGVR).Namespace(namespace).Update(ctx, hr, metav1.UpdateOptions{}); err != nil {
|
|
return fmt.Errorf("update HelmRelease %s/%s: %w", namespace, name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// etcdClusterFullyGone returns true when the chart-rendered EtcdCluster
|
|
// AND every PVC the etcd-operator generated for it have actually
|
|
// disappeared from the API. r.Delete returns when DeletionTimestamp
|
|
// lands but the operator's finalizers + storage drain take additional
|
|
// time - returning before that drain finishes lets the recreate step
|
|
// race the still-terminating cluster, and the new EtcdCluster Create
|
|
// fails with AlreadyExists or worse races on PVC name collision.
|
|
//
|
|
// The PVC label match mirrors the etcd-operator's StatefulSet-style
|
|
// volumeClaimTemplate behaviour: PVCs are named etcd-data-etcd-<i>
|
|
// and carry an app.kubernetes.io/instance=etcd label set by the
|
|
// operator.
|
|
func (r *RestoreJobReconciler) etcdClusterFullyGone(ctx context.Context, namespace string) (bool, error) {
|
|
cluster := &etcdtypes.EtcdCluster{}
|
|
switch err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: etcdClusterName}, cluster); {
|
|
case err == nil:
|
|
return false, nil
|
|
case !apierrors.IsNotFound(err):
|
|
return false, fmt.Errorf("get etcd-operator.cozystack.io/EtcdCluster %s/%s: %w", namespace, etcdClusterName, err)
|
|
}
|
|
pvcList := &corev1.PersistentVolumeClaimList{}
|
|
if err := r.List(ctx, pvcList,
|
|
client.InNamespace(namespace),
|
|
client.MatchingLabels{"app.kubernetes.io/instance": etcdClusterName},
|
|
); err != nil {
|
|
return false, fmt.Errorf("list PVCs for cluster %s/%s: %w", namespace, etcdClusterName, err)
|
|
}
|
|
return len(pvcList.Items) == 0, nil
|
|
}
|
|
|
|
// etcdRestoreTarget captures the resolved target for an Etcd restore.
|
|
// In-place only - the to-copy path is rejected up-front in
|
|
// reconcileEtcdRestore because the chart's check-release-name.yaml
|
|
// pins exactly one Etcd release per namespace.
|
|
type etcdRestoreTarget struct {
|
|
Namespace string
|
|
AppName string
|
|
}
|
|
|
|
func (r *RestoreJobReconciler) resolveEtcdRestoreTarget(restoreJob *backupsv1alpha1.RestoreJob, backup *backupsv1alpha1.Backup) etcdRestoreTarget {
|
|
return etcdRestoreTarget{
|
|
Namespace: backup.Namespace,
|
|
AppName: backup.Spec.ApplicationRef.Name,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Snapshot persisted on Cozystack Backup.status.underlyingResources
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// etcdBackupSnapshot is the Etcd-specific payload persisted in
|
|
// Backup.status.underlyingResources at backup time. Carries the rendered
|
|
// destination and BackupClass parameters so a future RestoreJob can
|
|
// reproduce them exactly when the operator-side EtcdSnapshot CR has been
|
|
// pruned. The JSON tag for Destination matches the strategy CR's
|
|
// spec.template.destination so the on-disk shape is self-describing
|
|
// when an operator inspects the Backup artefact.
|
|
type etcdBackupSnapshot struct {
|
|
Kind string `json:"kind"`
|
|
APIVersion string `json:"apiVersion"`
|
|
Destination strategyv1alpha1.EtcdDestinationTemplate `json:"destination"`
|
|
Parameters map[string]string `json:"parameters,omitempty"`
|
|
}
|
|
|
|
func marshalEtcdBackupSnapshot(
|
|
rendered *strategyv1alpha1.EtcdTemplate,
|
|
parameters map[string]string,
|
|
) (*runtime.RawExtension, error) {
|
|
dest := rendered.Destination.DeepCopy()
|
|
snap := etcdBackupSnapshot{
|
|
Kind: etcdBackupSnapshotKind,
|
|
APIVersion: etcdBackupSnapshotAPIVersion,
|
|
Destination: *dest,
|
|
Parameters: parameters,
|
|
}
|
|
raw, err := json.Marshal(snap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &runtime.RawExtension{Raw: raw}, nil
|
|
}
|
|
|
|
// errEtcdSnapshotUnrecognised is returned 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. Same contract as the FoundationDB driver:
|
|
// future readers refuse such snapshots rather than silently fall back to
|
|
// driverMetadata, which on a real version-bump would corrupt the restore.
|
|
var errEtcdSnapshotUnrecognised = errors.New("Backup.status.underlyingResources carries a snapshot of an unrecognised kind/apiVersion")
|
|
|
|
func decodeEtcdBackupSnapshot(raw *runtime.RawExtension) (*etcdBackupSnapshot, error) {
|
|
if raw == nil || len(raw.Raw) == 0 {
|
|
return nil, nil
|
|
}
|
|
snap := &etcdBackupSnapshot{}
|
|
if err := json.Unmarshal(raw.Raw, snap); err != nil {
|
|
return nil, fmt.Errorf("decode Backup.status.underlyingResources: %w", err)
|
|
}
|
|
if snap.Kind != etcdBackupSnapshotKind {
|
|
return nil, fmt.Errorf("%w: kind=%q (want %q)",
|
|
errEtcdSnapshotUnrecognised, snap.Kind, etcdBackupSnapshotKind)
|
|
}
|
|
if snap.APIVersion != etcdBackupSnapshotAPIVersion {
|
|
return nil, fmt.Errorf("%w: apiVersion=%q (want %q)",
|
|
errEtcdSnapshotUnrecognised, snap.APIVersion, etcdBackupSnapshotAPIVersion)
|
|
}
|
|
return snap, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// renderEtcdTemplate 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 / FoundationDB strategies.
|
|
func renderEtcdTemplate(t strategyv1alpha1.EtcdTemplate, app *etcdapp.Etcd, parameters map[string]string) (*strategyv1alpha1.EtcdTemplate, error) {
|
|
appAsMap, err := toJSONMapEtcd(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)
|
|
}
|
|
|
|
// toJSONMapEtcd 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 toJSONMapEtcd(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
|
|
}
|
|
|
|
// getEtcdApp fetches the apps.cozystack.io Etcd instance via the shared
|
|
// typed client. The Etcd scheme is registered in main.go so the
|
|
// controller-runtime cache serves it directly.
|
|
func (r *BackupJobReconciler) getEtcdApp(ctx context.Context, namespace, name string) (*etcdapp.Etcd, error) {
|
|
app := &etcdapp.Etcd{}
|
|
if err := r.Get(ctx, types.NamespacedName{Namespace: namespace, Name: name}, app); err != nil {
|
|
return nil, err
|
|
}
|
|
return app, nil
|
|
}
|
|
|
|
// validateRenderedEtcdDestination rejects destinations that the operator
|
|
// itself would reject at apply time, surfacing the failure as a clean
|
|
// terminal BackupJob status instead of letting the operator log a stack
|
|
// trace and the BackupJob spin until deadline.
|
|
//
|
|
// PVC destinations are not supported by this strategy (see the XValidation
|
|
// rule on EtcdDestinationTemplate in api/backups/strategy/v1alpha1/
|
|
// etcd_types.go): the upstream PVC backup-write path and PVC restore-read
|
|
// path use different filenames, so a PVC backup taken with this strategy
|
|
// would be unrestoreable. The CRD admission gate enforces "s3 is set" so
|
|
// this runtime path only ever validates the S3 fields. The defensive
|
|
// check on a nil S3 still lives here in case a tenant somehow bypasses
|
|
// admission (e.g. via an apiserver bug or an outdated CRD).
|
|
func validateRenderedEtcdDestination(d strategyv1alpha1.EtcdDestinationTemplate) error {
|
|
if d.S3 == nil {
|
|
return errors.New("destination.s3 is required (PVC destinations are not supported by this strategy)")
|
|
}
|
|
if d.S3.Bucket == "" {
|
|
return errors.New("destination.s3.bucket is empty after templating")
|
|
}
|
|
if d.S3.Endpoint == "" {
|
|
return errors.New("destination.s3.endpoint is empty after templating")
|
|
}
|
|
if d.S3.CredentialsSecretRef.Name == "" {
|
|
return errors.New("destination.s3.credentialsSecretRef.name is empty after templating")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// strategyToEtcdBackupDestination converts the validated strategy-side
|
|
// destination template into the operator-side destination shape. The
|
|
// two types share JSON fields verbatim for S3, so this is a pure
|
|
// shape-cast. PVC is not produced - see validateRenderedEtcdDestination
|
|
// and the strategy CR's XValidation rule.
|
|
func strategyToEtcdBackupDestination(d strategyv1alpha1.EtcdDestinationTemplate) (etcdtypes.EtcdBackupDestination, error) {
|
|
if err := validateRenderedEtcdDestination(d); err != nil {
|
|
return etcdtypes.EtcdBackupDestination{}, err
|
|
}
|
|
var fps *bool
|
|
if d.S3.ForcePathStyle != nil {
|
|
v := *d.S3.ForcePathStyle
|
|
fps = &v
|
|
}
|
|
return etcdtypes.EtcdBackupDestination{
|
|
S3: &etcdtypes.EtcdBackupS3{
|
|
Bucket: d.S3.Bucket,
|
|
Endpoint: d.S3.Endpoint,
|
|
Key: d.S3.Key,
|
|
Region: d.S3.Region,
|
|
ForcePathStyle: fps,
|
|
CredentialsSecretRef: etcdtypes.EtcdLocalObjectReference{Name: d.S3.CredentialsSecretRef.Name},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// EtcdRestoreOptions is the typed shape of RestoreJob.Spec.Options for
|
|
// the Etcd driver. Mirrors the FDB driver's RestoreOptions pattern so
|
|
// the boundary parses lazily and keeps behaviour permissive.
|
|
type EtcdRestoreOptions struct {
|
|
// RestoreTimeoutSeconds caps the time the driver waits for the
|
|
// re-created EtcdCluster to become Available before it marks the
|
|
// RestoreJob Failed. Zero or unset falls back to
|
|
// etcdDefaultRestoreDeadline.
|
|
// +optional
|
|
RestoreTimeoutSeconds int64 `json:"restoreTimeoutSeconds,omitempty"`
|
|
}
|
|
|
|
func parseEtcdRestoreOptions(opts *runtime.RawExtension) (EtcdRestoreOptions, error) {
|
|
var out EtcdRestoreOptions
|
|
if opts == nil || len(opts.Raw) == 0 {
|
|
return out, nil
|
|
}
|
|
if err := json.Unmarshal(opts.Raw, &out); err != nil {
|
|
return EtcdRestoreOptions{}, fmt.Errorf("decode restoreJob.spec.options: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (o EtcdRestoreOptions) effectiveRestoreDeadline() time.Duration {
|
|
if o.RestoreTimeoutSeconds <= 0 {
|
|
return etcdDefaultRestoreDeadline
|
|
}
|
|
return time.Duration(o.RestoreTimeoutSeconds) * time.Second
|
|
}
|