Files
cozystack/internal/backupcontroller/default_objects_gate.go
T
Alexey Artamonov 399ccd3f4a fix(backups): refresh stale credentials even when the routed objects are unreadable
The credentials endpoint comparison needs only the credentials Secret and
the COSI Secret it is rendered from, yet its forced render was issued only
after the BackupClass and every routed object had been read. A missing
BackupClass or one unreadable Strategy therefore kept the old endpoint in
the credentials Secret, which the projector hands to every driver, not
just the one whose object failed.

Assisted-by: LLM
Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-11-08 20:18:29 +03:00

703 lines
31 KiB
Go

package backupcontroller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/go-logr/logr"
"github.com/prometheus/client_golang/prometheus"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"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/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/metrics"
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
)
// defaultObjectsMissing reports how many of the objects the platform default
// backups depend on are absent from the cluster: the bucket credentials
// Secret the projector reads, the Strategy CRs the BackupClass routes to,
// and the Velero BSL. It is the signal an operator alerts on: a non-zero
// value that does not return to zero means the platform default backups are
// not usable, whatever the HelmRelease's Ready condition says.
//
// It is only written when a check reached a conclusion. An API error mid-check
// leaves the previous value in place rather than flapping the alert, so the
// gauge alone cannot distinguish "healthy" from "not evaluated" — that is what
// defaultObjectsCheckErrors is for, and the runbook pairs the two.
var defaultObjectsMissing = prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "cozystack_backup_default_objects_missing",
Help: "Number of objects the platform default backups depend on (bucket credentials Secret, BackupClass strategy CRs, Velero BSL) that do not exist in the cluster.",
},
[]string{"backupclass"},
)
// defaultObjectsForceReconciles counts the forced Helm upgrades the gate
// issued to materialise those objects or to refresh their S3 endpoint. A
// counter that keeps climbing means the forced render is not producing the
// objects (e.g. a CRD is missing) or not changing their endpoint,
// which is a different failure than the install-time race this gate closes.
// A suspended release is NOT counted here: it is skipped before the patch,
// so a paused release cannot masquerade as a render that keeps failing.
var defaultObjectsForceReconciles = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "cozystack_backup_default_objects_force_reconciles_total",
Help: "Number of forced HelmRelease reconciles issued to create missing platform backup objects or refresh their S3 endpoint.",
},
[]string{"namespace", "name"},
)
// defaultObjectsCheckErrors counts the checks that could not reach a
// conclusion (API error reading the source Secret, the BackupClass, or one
// of the routed objects). While this climbs, defaultObjectsMissing is stale:
// alerting on the gauge alone would silently keep reporting the last known
// state, including a 0 that is no longer true.
var defaultObjectsCheckErrors = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "cozystack_backup_default_objects_check_errors_total",
Help: "Number of default backup object checks that failed before reaching a conclusion, leaving cozystack_backup_default_objects_missing stale.",
},
[]string{"backupclass"},
)
func init() {
metrics.Registry.MustRegister(defaultObjectsMissing, defaultObjectsForceReconciles, defaultObjectsCheckErrors)
}
const (
// strategyAPIGroup is the API group of the strategy.backups.cozystack.io
// CRs a BackupClass routes to. BackupClassStrategy.StrategyRef is a
// TypedLocalObjectReference and carries no version, so the group+kind
// are mapped to a resource through the RESTMapper.
strategyAPIGroup = "strategy.backups.cozystack.io"
// defaultBackupStorageLocationName is the Velero BSL the cozy-default
// Velero strategies reference via storageLocation. It is rendered by the
// same lookup-gated template as the Strategy CRs, so it belongs to the
// same existence check.
defaultBackupStorageLocationName = "cozy-default"
// endpointAnnotation carries the S3 endpoint the chart rendered into the
// object. The endpoint itself sits at a different path in every kind,
// and in some only as a bare host, so the gate compares this instead.
endpointAnnotation = "backups.cozystack.io/endpoint"
)
var backupStorageLocationGVR = schema.GroupVersionResource{
Group: "velero.io",
Version: "v1",
Resource: "backupstoragelocations",
}
// DefaultObjectsGate closes a race the chart's templates cannot close by
// themselves.
//
// The default Strategy CRs and the Velero BackupStorageLocation are
// Helm-templated behind a `lookup` of the BucketClaim the SAME chart
// creates, because the S3 bucket name is assigned by the COSI driver
// (`bucket-<claim-UID>`) and is therefore unknowable at render time. On a
// fresh install the lookup is empty and those templates render nothing.
// helm-controller does not re-render a release that succeeded and whose
// chart and values did not change (drift detection is off on
// operator-generated HelmReleases), so the skip is PERMANENT: the objects
// are never created, and the only recovery is a forced Helm upgrade
// (reconcile.fluxcd.io/forceAt + requestedAt — a plain reconcile request
// does not re-render).
//
// This runnable performs that recovery automatically. Once the bucket name
// is resolvable — read from the same platform credentials Secret the
// projector already consumes, so no new dependency and no `lookup` — it
// checks that every object the platform BackupClass routes to actually
// exists, and forces one Helm upgrade when any is missing or carries a stale
// S3 endpoint. A forced upgrade re-runs the lookups, so the gated templates
// materialise with the current endpoint.
//
// It deliberately does not create the objects itself: their bodies are
// values-driven (endpoint, region, forcePathStyle, the Altinity strategy's
// whole PodTemplateSpec) and Helm remains their single owner. The gate only
// makes sure the render that produces them actually happens.
type DefaultObjectsGate struct {
// Client reads the BackupClass (cached) and the source credentials
// Secret (uncached — the manager disables the Secret cache).
Client client.Client
// Interface is a dynamic client used for the per-object existence
// checks and the HelmRelease annotation patch. Going through the
// dynamic client keeps these reads off the controller-runtime cache,
// so they need only `get` RBAC and start no cluster-wide informers.
dynamic.Interface
meta.RESTMapper
// Config supplies the source credentials Secret coordinates. Reused
// verbatim from the credentials projector.
Config BackupCredentialsConfig
// BackupClassName is the platform BackupClass whose strategyRefs
// enumerate the objects that must exist. Empty disables the gate.
BackupClassName string
// HelmRelease is the release that renders those objects and is forced
// when they are missing. Empty name disables the gate.
HelmRelease types.NamespacedName
// CredentialsHelmRelease is the platform bucket's <bucket>-system
// release, which renders the user-credentials Secret named by
// Config.SourceSecretName behind a `lookup` of the COSI Secret — the
// same permanent-skip trap as the Strategy CRs, one release earlier.
// Forcing it is what unblocks everything downstream, since the gate
// itself cannot resolve the bucket name without that Secret.
//
// Empty when the platform bucket is not provisioned by Cozystack
// (backupStorage.provisionBucket=false, external S3): the Secret is
// then admin-managed and no release renders it, so there is nothing
// to force.
CredentialsHelmRelease types.NamespacedName
// CredentialsCOSISecretName is the Secret the COSI sidecar writes for the
// platform bucket's BucketAccess, in Config.SourceNamespace, from which
// CredentialsHelmRelease renders the credentials Secret. The sidecar
// writes it once per BucketAccess, so after a publishing.host change a
// re-granted BucketAccess carries the new endpoint while the credentials
// Secret keeps the old one; the gate compares the two. Empty disables
// the comparison.
CredentialsCOSISecretName string
// VeleroNamespace is where the cozy-default BackupStorageLocation
// lives. Empty skips the BSL check (velero.bslEnabled=false).
VeleroNamespace string
// Period is the check interval. Defaults to 1 minute.
Period time.Duration
// MinForceInterval throttles forced upgrades so a condition the forced
// render cannot fix (missing CRD, wrong bucket) does not turn into a
// hot loop against helm-controller. Defaults to 5 minutes.
MinForceInterval time.Duration
// now is a test seam for the throttle clock.
now func() time.Time
// lastForce and lastCredentialsForce throttle the two releases
// independently: the credentials release is forced during the window in
// which the objects release cannot even be evaluated, so sharing one
// timestamp would make the first force delay the second by
// MinForceInterval for no reason.
lastForce time.Time
lastCredentialsForce time.Time
// renderForced records that a forced render of HelmRelease was requested
// (the annotations were patched; whether the upgrade succeeds is not
// observed). That render stamps endpointAnnotation on every object the
// chart renders, so from then on an object without it (one rendered by a
// chart that predates the annotation) is not counted as stale again.
renderForced bool
}
// errHelmReleaseSuspended reports that the target release has
// spec.suspend: true. helm-controller ignores reconcile.fluxcd.io/forceAt on
// a suspended release, so patching it would be a no-op repeated every
// MinForceInterval for as long as the suspension lasts.
var errHelmReleaseSuspended = errors.New("HelmRelease is suspended")
var (
_ manager.Runnable = (*DefaultObjectsGate)(nil)
_ manager.LeaderElectionRunnable = (*DefaultObjectsGate)(nil)
)
// NeedLeaderElection keeps a single replica forcing the release. Two
// replicas racing the same annotation patch would double the Helm upgrades
// for no benefit.
func (g *DefaultObjectsGate) NeedLeaderElection() bool { return true }
// SetupWithManager wires the dynamic client and the RESTMapper from the
// manager's rest.Config, mirroring RestoreJobReconciler, and registers the
// gate as a manager Runnable.
func (g *DefaultObjectsGate) SetupWithManager(mgr ctrl.Manager) error {
cfg := mgr.GetConfig()
var err error
if g.Interface, err = dynamic.NewForConfig(cfg); err != nil {
return err
}
var h *http.Client
if h, err = rest.HTTPClientFor(cfg); err != nil {
return err
}
if g.RESTMapper, err = apiutil.NewDynamicRESTMapper(cfg, h); err != nil {
return err
}
return mgr.Add(g)
}
func (g *DefaultObjectsGate) Start(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("default-objects-gate")
if g.BackupClassName == "" || g.HelmRelease.Name == "" || g.HelmRelease.Namespace == "" || !g.Config.IsEnabled() {
logger.V(1).Info("default backup objects gate disabled",
"backupClass", g.BackupClassName,
"helmRelease", g.HelmRelease.String(),
"credentialsConfigured", g.Config.IsEnabled())
return nil
}
if g.Period == 0 {
g.Period = time.Minute
}
if g.MinForceInterval == 0 {
g.MinForceInterval = 5 * time.Minute
}
if g.now == nil {
g.now = time.Now
}
// force() logs the suspended-release skip from deep in the call stack;
// without this it would land on a bare logger with no name.
ctx = log.IntoContext(ctx, logger)
tick := time.NewTicker(g.Period)
defer tick.Stop()
g.checkAndLog(ctx, logger)
for {
select {
case <-ctx.Done():
return nil
case <-tick.C:
g.checkAndLog(ctx, logger)
}
}
}
// checkTimeout bounds a single check. It stays below Period so a slow check
// cannot overlap the next tick, and is capped so a long Period does not mean a
// correspondingly long stall.
func (g *DefaultObjectsGate) checkTimeout() time.Duration {
const maxTimeout = 30 * time.Second
if g.Period <= 0 {
return maxTimeout
}
if half := g.Period / 2; half < maxTimeout {
return half
}
return maxTimeout
}
func (g *DefaultObjectsGate) checkAndLog(ctx context.Context, logger logr.Logger) {
// Bound every tick. Check makes several sequential API calls (Secret get,
// BackupClass get, one Get per routed strategy, HelmRelease patch) and the
// manager context is only cancelled at shutdown, so an API server that
// stalls on any one of them would block here indefinitely. The loop that
// drives this is sequential, so that single stuck call would stop all
// later checks for the life of the pod: the recovery this gate exists to
// provide would go silent, with a stale gauge and no further log line, and
// only a restart would bring it back.
checkCtx, cancel := context.WithTimeout(ctx, g.checkTimeout())
defer cancel()
missing, forced, err := g.Check(checkCtx)
switch {
case err != nil:
// Info, not Error: every branch here is either transient (the
// bucket is still being provisioned) or already exposed as a
// metric, and this runs every minute for the life of the cluster.
logger.Info("default backup objects check failed", "error", err.Error(),
"missing", missing, "forced", forced)
case len(missing) > 0 && forced:
// The releases named here are the ones that render what is missing:
// the credentials Secret comes from the bucket's <bucket>-system
// release, everything else from this chart's own.
logger.Info("forced a Helm upgrade to create missing or stale default backup objects",
"helmReleases", g.forcedReleasesFor(missing), "missing", missing)
case len(missing) > 0:
logger.Info("default backup objects still missing or stale, no force issued on this tick",
"helmReleases", g.forcedReleasesFor(missing), "missing", missing,
"minForceInterval", g.MinForceInterval.String())
default:
logger.V(1).Info("all default backup objects present")
}
}
// forcedReleasesFor reports which releases render the given entries, for
// logging only: the credentials Secret comes from CredentialsHelmRelease,
// everything else from HelmRelease.
func (g *DefaultObjectsGate) forcedReleasesFor(missing []string) []string {
credsKey := fmt.Sprintf("Secret/%s", g.Config.SourceSecretName)
var out []string
seen := map[string]bool{}
for _, m := range missing {
release := g.HelmRelease.String()
if (m == credsKey || strings.HasPrefix(m, credsKey+" ")) && g.CredentialsHelmRelease.Name != "" {
release = g.CredentialsHelmRelease.String()
}
if !seen[release] {
seen[release] = true
out = append(out, release)
}
}
return out
}
// Check resolves the platform bucket name, verifies that every object the
// BackupClass routes to exists and carries the current S3 endpoint, and
// forces one Helm upgrade when any is missing or stale. It returns those
// objects (formatted for logs) and whether a forced upgrade was issued on this
// call.
//
// While the source Secret is absent or carries no bucket name, the gate
// cannot evaluate the routed objects — their templates gate on the same
// unresolved bucket — so it instead forces the release that renders that
// Secret (see reconcileCredentials). The bucket name is read from the
// projector's source Secret rather than from the BucketClaim, so the gate
// needs no objectstorage RBAC and works for the external-S3 path too.
func (g *DefaultObjectsGate) Check(ctx context.Context) ([]string, bool, error) {
src := &corev1.Secret{}
if err := g.Client.Get(ctx, types.NamespacedName{Namespace: g.Config.SourceNamespace, Name: g.Config.SourceSecretName}, src); err != nil {
if apierrors.IsNotFound(err) {
// The Secret the projector reads does not exist. On a fresh
// install that is the bootstrap window; months later it is the
// permanent skip this gate exists to repair, and the two are
// indistinguishable from here — so treat both the same way and
// let the throttle bound the cost.
return g.reconcileCredentials(ctx)
}
defaultObjectsCheckErrors.WithLabelValues(g.BackupClassName).Inc()
return nil, false, fmt.Errorf("get source credentials Secret %s/%s: %w", g.Config.SourceNamespace, g.Config.SourceSecretName, err)
}
creds, err := parseSourceSecret(src)
if err != nil {
defaultObjectsCheckErrors.WithLabelValues(g.BackupClassName).Inc()
return nil, false, err
}
if creds.bucket == "" {
// The Secret exists but carries no bucket name: a partially rendered
// or hand-written Secret. Same treatment — a re-render of the
// producing release is the only thing that can complete it.
return g.reconcileCredentials(ctx)
}
// The credentials comparison needs only the two Secrets, so its force goes
// out before the object reads: an unreadable BackupClass or Strategy must
// not keep the stale endpoint in the Secret every driver reads.
var needed []string
var forced bool
staleCreds, credsErr := g.staleCredentials(ctx, src)
errs := []error{credsErr}
if credsErr != nil {
defaultObjectsCheckErrors.WithLabelValues(g.BackupClassName).Inc()
}
if staleCreds != "" {
_, credsForced, err := g.force(ctx, g.CredentialsHelmRelease, &g.lastCredentialsForce, []string{staleCreds})
needed = append(needed, staleCreds)
forced = credsForced
errs = append(errs, err)
}
backupClass := &backupsv1alpha1.BackupClass{}
if err := g.Client.Get(ctx, client.ObjectKey{Name: g.BackupClassName}, backupClass); err != nil {
if credsErr == nil {
defaultObjectsCheckErrors.WithLabelValues(g.BackupClassName).Inc()
}
return needed, forced, errors.Join(append(errs, fmt.Errorf("get BackupClass %s: %w", g.BackupClassName, err))...)
}
// With provisionBucket=false (no bucket release) the chart renders
// backupStorage.endpoint rather than the Secret's, so the two may
// legitimately differ and there is nothing to compare.
want := ""
if g.CredentialsHelmRelease.Name != "" {
want = renderedEndpointFor(src.Data["endpoint"])
}
state, err := g.inspectObjects(ctx, backupClass, want)
if err != nil {
if credsErr == nil {
defaultObjectsCheckErrors.WithLabelValues(g.BackupClassName).Inc()
}
return needed, forced, errors.Join(append(errs, err)...)
}
defaultObjectsMissing.WithLabelValues(g.BackupClassName).Set(float64(len(state.missing)))
if objects := append(state.missing, state.stale...); len(objects) > 0 {
sort.Strings(objects)
_, objectsForced, err := g.force(ctx, g.HelmRelease, &g.lastForce, objects)
needed = append(needed, objects...)
if objectsForced {
forced = true
g.renderForced = true
}
errs = append(errs, err)
}
return needed, forced, errors.Join(errs...)
}
// staleCredentials reports the credentials Secret, formatted for logs, when
// its endpoint differs from the one in the COSI Secret it is rendered from,
// comparing against what packages/system/bucket/templates/user-credentials.yaml
// writes for it. The COSI Secret being absent (the BucketAccess is being
// re-granted) leaves nothing to compare against and is not staleness, and
// neither is a source Secret the bucket chart does not render: the COSI Secret
// itself (the projector accepts one) or one without the endpoint key.
func (g *DefaultObjectsGate) staleCredentials(ctx context.Context, src *corev1.Secret) (string, error) {
if g.CredentialsCOSISecretName == "" || g.CredentialsHelmRelease.Name == "" || g.CredentialsHelmRelease.Namespace == "" {
return "", nil
}
if _, ok := src.Data["endpoint"]; !ok || g.CredentialsCOSISecretName == g.Config.SourceSecretName {
return "", nil
}
cosi := &corev1.Secret{}
key := types.NamespacedName{Namespace: g.Config.SourceNamespace, Name: g.CredentialsCOSISecretName}
if err := g.Client.Get(ctx, key, cosi); err != nil {
if apierrors.IsNotFound(err) {
return "", nil
}
return "", fmt.Errorf("get COSI Secret %s: %w", key, err)
}
var info struct {
Spec struct {
SecretS3 struct {
Endpoint string `json:"endpoint"`
} `json:"secretS3"`
} `json:"spec"`
}
if err := json.Unmarshal(cosi.Data["BucketInfo"], &info); err != nil {
return "", fmt.Errorf("parse BucketInfo of COSI Secret %s: %w", key, err)
}
want := strings.TrimPrefix(strings.TrimPrefix(info.Spec.SecretS3.Endpoint, "https://"), "http://")
have := string(src.Data["endpoint"])
if have == want {
return "", nil
}
return fmt.Sprintf("Secret/%s (endpoint %q, COSI Secret has %q)", g.Config.SourceSecretName, have, want), nil
}
// renderedEndpointFor mirrors the "backupstrategy-controller.endpoint" helper
// for a provisioned bucket: the credentials Secret's endpoint, trimmed, with
// any scheme replaced by https://. It is the value the chart stamps as
// endpointAnnotation. An absent or empty endpoint makes the helper fall back
// to backupStorage.endpoint, which the gate cannot see, so it returns "".
func renderedEndpointFor(raw []byte) string {
if len(raw) == 0 {
return ""
}
host := strings.TrimSpace(string(raw))
return "https://" + strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://")
}
// reconcileCredentials handles the one object the gate cannot check the same
// way as the others: the bucket user-credentials Secret it reads to resolve
// the bucket name in the first place.
//
// That Secret is rendered by the platform bucket's <bucket>-system release
// behind a `lookup` of the COSI Secret, so it is subject to the identical
// permanent-skip trap — and when it is missing, nothing downstream can
// resolve: the projector has no source, every Strategy CR and the Velero BSL
// stay gated off, and migration 50 cannot find its snapshot target.
//
// The chart cannot repair this itself. Making the render `fail` instead of
// skip would abort the whole <bucket>-system release — the other users'
// Secrets and the bucket UI Deployment/Service/Ingress with it — and one
// user whose BucketAccess never provisions would park the release in Failed,
// blocking every later upgrade of that bucket with no per-bucket way out. So
// the repair belongs here, where it is per-object and costs a throttled
// annotation patch: exactly the mechanism the gate already applies to the
// Strategy CRs, one release earlier in the chain.
//
// Forcing that release cannot deadlock its own precondition: the BucketClaim
// and the BucketAccess whose COSI Secret the lookup reads are rendered
// unconditionally by the PARENT release (packages/apps/bucket/templates/
// bucketclaim.yaml), not by the one being forced.
func (g *DefaultObjectsGate) reconcileCredentials(ctx context.Context) ([]string, bool, error) {
missing := []string{fmt.Sprintf("Secret/%s", g.Config.SourceSecretName)}
defaultObjectsMissing.WithLabelValues(g.BackupClassName).Set(float64(len(missing)))
if g.CredentialsHelmRelease.Name == "" || g.CredentialsHelmRelease.Namespace == "" {
// External S3: the Secret is admin-managed and no release renders
// it. Report it missing, force nothing.
return missing, false, nil
}
return g.force(ctx, g.CredentialsHelmRelease, &g.lastCredentialsForce, missing)
}
// force issues at most one throttled forced Helm upgrade of target and
// reports whether it went through. last is the caller's throttle timestamp,
// advanced on every outcome that must not be retried immediately.
func (g *DefaultObjectsGate) force(ctx context.Context, target types.NamespacedName, last *time.Time, missing []string) ([]string, bool, error) {
now := g.now()
if !last.IsZero() && now.Sub(*last) < g.MinForceInterval {
return missing, false, nil
}
if err := g.forceHelmRelease(ctx, target, now); err != nil {
switch {
case apierrors.IsNotFound(err):
// Not a Flux-managed install (a plain `helm install` for local
// development). There is nothing to force; stay quiet rather
// than logging an error every tick.
*last = now
return missing, false, nil
case errors.Is(err, errHelmReleaseSuspended):
// Deliberate operator state (cozyhr suspend, maintenance). The
// annotations would be ignored, so skip the patch entirely and
// leave the force counter alone — a climbing counter must keep
// meaning "the forced render is not producing the objects".
log.FromContext(ctx).Info("skipped forcing a suspended HelmRelease; missing or stale default backup objects will not be repaired until it is resumed",
"helmRelease", target.String(), "missing", missing)
*last = now
return missing, false, nil
}
return missing, false, fmt.Errorf("force HelmRelease %s: %w", target.String(), err)
}
*last = now
defaultObjectsForceReconciles.WithLabelValues(target.Namespace, target.Name).Inc()
return missing, true, nil
}
// defaultObjectsState is what inspectObjects found: the objects that do not
// exist, and the existing ones rendered with an endpoint other than the one
// the chart would render now.
type defaultObjectsState struct {
missing []string
stale []string
}
// staleEndpoint reports an existing object rendered with another endpoint
// than want, formatted for logs. An empty want disables the comparison.
func (g *DefaultObjectsGate) staleEndpoint(state *defaultObjectsState, key string, obj *unstructured.Unstructured, want string) {
if want == "" {
return
}
have, ok := obj.GetAnnotations()[endpointAnnotation]
switch {
case !ok:
if !g.renderForced {
state.stale = append(state.stale, key+" (no endpoint annotation)")
}
case have != want:
state.stale = append(state.stale, fmt.Sprintf("%s (endpoint %q, want %q)", key, have, want))
}
}
// inspectObjects returns a sorted, de-duplicated list of the
// "<kind>/<name>" objects the BackupClass references that do not exist,
// plus the Velero BSL when a Velero namespace is configured, and, unsorted,
// the existing ones whose endpointAnnotation differs from want.
//
// An unmappable group/kind (the CRD is not installed) is NOT reported as
// missing: no Helm re-render can create an object whose CRD is absent, and
// counting it would keep the gate forcing upgrades forever.
func (g *DefaultObjectsGate) inspectObjects(ctx context.Context, backupClass *backupsv1alpha1.BackupClass, want string) (defaultObjectsState, error) {
seen := map[string]struct{}{}
var state defaultObjectsState
for _, strategy := range backupClass.Spec.Strategies {
name := strategy.StrategyRef.Name
kind := strategy.StrategyRef.Kind
if name == "" || kind == "" {
continue
}
group := strategyAPIGroup
if strategy.StrategyRef.APIGroup != nil && *strategy.StrategyRef.APIGroup != "" {
group = *strategy.StrategyRef.APIGroup
}
// When the Velero BSL is disabled (velero.bslEnabled=false, surfaced
// here as an empty VeleroNamespace) the chart gates the Velero
// Strategy CRs off the SAME flag, so they are never rendered. The
// BackupClass still routes to them unconditionally, so counting them
// as missing would force a Helm upgrade every MinForceInterval
// forever against a render that can never produce them.
if g.VeleroNamespace == "" && group == strategyAPIGroup && kind == "Velero" {
continue
}
key := fmt.Sprintf("%s/%s", kind, name)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
mapping, err := g.RESTMapping(schema.GroupKind{Group: group, Kind: kind})
if err != nil {
if meta.IsNoMatchError(err) {
continue
}
return defaultObjectsState{}, fmt.Errorf("map %s/%s: %w", group, kind, err)
}
// Strategy CRs are cluster-scoped; Namespace("") is correct for
// both scopes on the dynamic client.
obj, err := g.Resource(mapping.Resource).Get(ctx, name, metav1.GetOptions{})
switch {
case err == nil:
g.staleEndpoint(&state, key, obj, want)
case apierrors.IsNotFound(err):
state.missing = append(state.missing, key)
default:
return defaultObjectsState{}, fmt.Errorf("get %s %s: %w", kind, name, err)
}
}
if g.VeleroNamespace != "" {
// Resolve through the RESTMapper, like the strategy loop above, so a
// genuinely absent Velero API is a NoMatch we skip rather than a
// NotFound we count. The dynamic client's Get bypasses the mapper and
// returns a plain 404 for an unserved group, which IsNotFound would
// catch first, making the "CRDs absent" branch dead code and looping
// the gate forever if Velero were removed while bslEnabled=true.
mapping, err := g.RESTMapping(schema.GroupKind{Group: backupStorageLocationGVR.Group, Kind: "BackupStorageLocation"})
switch {
case err == nil:
key := fmt.Sprintf("BackupStorageLocation/%s", defaultBackupStorageLocationName)
obj, getErr := g.Resource(mapping.Resource).Namespace(g.VeleroNamespace).Get(ctx, defaultBackupStorageLocationName, metav1.GetOptions{})
switch {
case getErr == nil:
g.staleEndpoint(&state, key, obj, want)
case apierrors.IsNotFound(getErr):
state.missing = append(state.missing, key)
default:
return defaultObjectsState{}, fmt.Errorf("get BackupStorageLocation %s: %w", defaultBackupStorageLocationName, getErr)
}
case meta.IsNoMatchError(err):
// Velero API not served (e.g. Velero uninstalled after bootstrap):
// no Helm re-render can create the BSL, so it is not "missing".
default:
return defaultObjectsState{}, fmt.Errorf("map BackupStorageLocation: %w", err)
}
}
sort.Strings(state.missing)
return state, nil
}
// forceHelmRelease stamps BOTH reconcile.fluxcd.io/forceAt and
// reconcile.fluxcd.io/requestedAt. requestedAt alone only asks
// helm-controller to reconcile, which is a no-op for a release whose chart
// and values are unchanged — it is forceAt that makes it run a real Helm
// upgrade, and forceAt is only honoured together with requestedAt.
//
// A point Get precedes the patch so a suspended release is skipped rather
// than re-stamped forever: helm-controller ignores both annotations while
// spec.suspend is true, and `cozyhr suspend` sets exactly that. Without the
// check the gate would patch every MinForceInterval for the whole suspension
// and inflate the force counter, which the runbook reads as a render that
// keeps failing — the wrong diagnosis. The Get needs no RBAC beyond the
// `get` on helmreleases the chart already grants.
func (g *DefaultObjectsGate) forceHelmRelease(ctx context.Context, target types.NamespacedName, now time.Time) error {
hr, err := g.Resource(helmReleaseGVR).Namespace(target.Namespace).Get(ctx, target.Name, metav1.GetOptions{})
if err != nil {
return err
}
// A malformed spec.suspend (wrong type) is reported by NestedBool as an
// error; treat it as not suspended and let the patch proceed, rather
// than silently disabling the repair on a field we could not parse.
if suspended, found, err := unstructured.NestedBool(hr.Object, "spec", "suspend"); err == nil && found && suspended {
return errHelmReleaseSuspended
}
stamp := now.UTC().Format(time.RFC3339Nano)
patch := fmt.Sprintf(
`{"metadata":{"annotations":{"reconcile.fluxcd.io/forceAt":%q,"reconcile.fluxcd.io/requestedAt":%q}}}`,
stamp, stamp,
)
_, err = g.Resource(helmReleaseGVR).Namespace(target.Namespace).
Patch(ctx, target.Name, types.MergePatchType, []byte(patch), metav1.PatchOptions{})
return err
}