Files
cozystack/internal/backupcontroller/default_objects_gate.go
T
Alexey Artamonov bd2b6c4b49 fix(backups): leave the credentials Secret alone when BucketInfo has no endpoint
A BucketInfo without an endpoint differs from every credentials Secret
that has one, so the gate forced the bucket release on every throttle
window. That render cannot converge: an empty endpoint would be written
into the Secret every driver reads, and with the key absent the
template's trimPrefix fails and parks the release in Failed.

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

705 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. A
// BucketInfo without an endpoint is not a target either: rendering from it
// writes an empty endpoint, or fails the release when the key is absent.
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 want == "" || 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
}