mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 22:13:18 +09:00
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>
705 lines
31 KiB
Go
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
|
|
}
|