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>
1340 lines
54 KiB
Go
1340 lines
54 KiB
Go
package backupcontroller
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
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"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
dynamicfake "k8s.io/client-go/dynamic/fake"
|
|
k8stesting "k8s.io/client-go/testing"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
|
|
|
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
|
)
|
|
|
|
// gateGVKs are the kinds the gate touches in these tests: the
|
|
// strategy.backups.cozystack.io CRs cozy-default routes to, the Velero BSL,
|
|
// and the HelmRelease it patches. The RESTMapper and the dynamic fake's
|
|
// resource registry are both derived from this one list so the plural the
|
|
// gate resolves and the plural the fake serves cannot drift apart.
|
|
var gateGVKs = []schema.GroupVersionKind{
|
|
{Group: strategyAPIGroup, Version: "v1alpha1", Kind: "CNPG"},
|
|
{Group: strategyAPIGroup, Version: "v1alpha1", Kind: "MariaDB"},
|
|
{Group: strategyAPIGroup, Version: "v1alpha1", Kind: "Etcd"},
|
|
{Group: strategyAPIGroup, Version: "v1alpha1", Kind: "Altinity"},
|
|
{Group: strategyAPIGroup, Version: "v1alpha1", Kind: "Velero"},
|
|
{Group: "velero.io", Version: "v1", Kind: "BackupStorageLocation"},
|
|
{Group: "helm.toolkit.fluxcd.io", Version: "v2", Kind: "HelmRelease"},
|
|
}
|
|
|
|
func gateScheme() *runtime.Scheme {
|
|
scheme := runtime.NewScheme()
|
|
_ = corev1.AddToScheme(scheme)
|
|
_ = backupsv1alpha1.AddToScheme(scheme)
|
|
return scheme
|
|
}
|
|
|
|
func gateListKinds() map[schema.GroupVersionResource]string {
|
|
out := map[schema.GroupVersionResource]string{}
|
|
for _, gvk := range gateGVKs {
|
|
gvr, _ := meta.UnsafeGuessKindToResource(gvk)
|
|
out[gvr] = gvk.Kind + "List"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// gateRESTMapper resolves group+kind (the only coordinates a
|
|
// BackupClass strategyRef carries) to a resource, which is what the gate
|
|
// does in production through the dynamic RESTMapper. The group versions must
|
|
// be passed as defaults: RESTMapping is called without a version.
|
|
func gateRESTMapper() meta.RESTMapper {
|
|
groupVersions := map[schema.GroupVersion]struct{}{}
|
|
for _, gvk := range gateGVKs {
|
|
groupVersions[gvk.GroupVersion()] = struct{}{}
|
|
}
|
|
var gvs []schema.GroupVersion
|
|
for gv := range groupVersions {
|
|
gvs = append(gvs, gv)
|
|
}
|
|
m := meta.NewDefaultRESTMapper(gvs)
|
|
for _, gvk := range gateGVKs {
|
|
scope := meta.RESTScopeRoot
|
|
if gvk.Kind == "BackupStorageLocation" || gvk.Kind == "HelmRelease" {
|
|
scope = meta.RESTScopeNamespace
|
|
}
|
|
m.Add(gvk, scope)
|
|
}
|
|
return m
|
|
}
|
|
|
|
// cozyDefaultBackupClass mirrors the routes the chart's
|
|
// backupclass-default.yaml renders unconditionally: it is the manifest of
|
|
// what must exist, which is exactly why the gate reads it instead of
|
|
// hard-coding a list of Strategy names.
|
|
func cozyDefaultBackupClass() *backupsv1alpha1.BackupClass {
|
|
ref := func(kind, name string) backupsv1alpha1.BackupClassStrategy {
|
|
return backupsv1alpha1.BackupClassStrategy{
|
|
Application: backupsv1alpha1.ApplicationSelector{
|
|
APIGroup: new("apps.cozystack.io"),
|
|
Kind: kind + "App",
|
|
},
|
|
StrategyRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new(strategyAPIGroup),
|
|
Kind: kind,
|
|
Name: name,
|
|
},
|
|
}
|
|
}
|
|
return &backupsv1alpha1.BackupClass{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "cozy-default"},
|
|
Spec: backupsv1alpha1.BackupClassSpec{
|
|
Strategies: []backupsv1alpha1.BackupClassStrategy{
|
|
ref("CNPG", "cozy-default-cnpg"),
|
|
ref("MariaDB", "cozy-default-mariadb"),
|
|
ref("Etcd", "cozy-default-etcd"),
|
|
ref("Altinity", "cozy-default-altinity"),
|
|
ref("Velero", "cozy-default-velero-vminstance"),
|
|
ref("Velero", "cozy-default-velero-vmdisk"),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// renderedEndpoint is what the chart stamps on every default object for the
|
|
// endpoint sourceSecret carries: the steady state after a render that saw it.
|
|
const renderedEndpoint = "https://s3.example.com"
|
|
|
|
func strategyObject(kind, name string) *unstructured.Unstructured {
|
|
u := &unstructured.Unstructured{}
|
|
u.SetAPIVersion(strategyAPIGroup + "/v1alpha1")
|
|
u.SetKind(kind)
|
|
u.SetName(name)
|
|
u.SetAnnotations(map[string]string{endpointAnnotation: renderedEndpoint})
|
|
return u
|
|
}
|
|
|
|
func withEndpoint(u *unstructured.Unstructured, endpoint string) *unstructured.Unstructured {
|
|
u.SetAnnotations(map[string]string{endpointAnnotation: endpoint})
|
|
return u
|
|
}
|
|
|
|
// unannotated is an object rendered by a chart that predates the endpoint
|
|
// annotation.
|
|
func unannotated(u *unstructured.Unstructured) *unstructured.Unstructured {
|
|
u.SetAnnotations(nil)
|
|
return u
|
|
}
|
|
|
|
// allDefaultObjects is every object cozy-default routes to, plus the BSL, as
|
|
// the chart renders them for sourceSecret's endpoint.
|
|
func allDefaultObjects() []runtime.Object {
|
|
return []runtime.Object{
|
|
strategyObject("CNPG", "cozy-default-cnpg"),
|
|
strategyObject("MariaDB", "cozy-default-mariadb"),
|
|
strategyObject("Etcd", "cozy-default-etcd"),
|
|
strategyObject("Altinity", "cozy-default-altinity"),
|
|
strategyObject("Velero", "cozy-default-velero-vminstance"),
|
|
strategyObject("Velero", "cozy-default-velero-vmdisk"),
|
|
bslObject(),
|
|
}
|
|
}
|
|
|
|
// cosiSecret is the Secret the COSI sidecar writes for the platform bucket's
|
|
// BucketAccess, which user-credentials.yaml renders the credentials Secret
|
|
// from. The BucketInfo document is one read off a live cluster, with the keys
|
|
// and the host replaced.
|
|
func cosiSecret(endpoint string) *corev1.Secret {
|
|
bucketInfo := `{"metadata": {"name": "bc-b8e30337-1821-418f-a4dd-163e8016aaee", "creationTimestamp": null}, ` +
|
|
`"spec": {"bucketName": "bucket-1a2b", "authenticationType": "KEY", ` +
|
|
`"secretS3": {"endpoint": "` + endpoint + `", "region": "", "accessKeyID": "AK", "accessSecretKey": "SK"}, ` +
|
|
`"secretAzure": null, "protocols": ["s3"]}}`
|
|
return &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bucket-cozy-backups-system"},
|
|
Data: map[string][]byte{"BucketInfo": []byte(bucketInfo)},
|
|
}
|
|
}
|
|
|
|
func helmReleaseObject() *unstructured.Unstructured {
|
|
u := &unstructured.Unstructured{}
|
|
u.SetAPIVersion("helm.toolkit.fluxcd.io/v2")
|
|
u.SetKind("HelmRelease")
|
|
u.SetNamespace("cozy-backup-controller")
|
|
u.SetName("backupstrategy-controller")
|
|
return u
|
|
}
|
|
|
|
// credentialsHelmReleaseObject is the platform bucket's <bucket>-system
|
|
// release: the one that renders the user-credentials Secret the gate reads
|
|
// to resolve the bucket name, behind its own install-time lookup.
|
|
func credentialsHelmReleaseObject() *unstructured.Unstructured {
|
|
u := &unstructured.Unstructured{}
|
|
u.SetAPIVersion("helm.toolkit.fluxcd.io/v2")
|
|
u.SetKind("HelmRelease")
|
|
u.SetNamespace("tenant-root")
|
|
u.SetName("bucket-cozy-backups-system")
|
|
return u
|
|
}
|
|
|
|
func suspended(u *unstructured.Unstructured) *unstructured.Unstructured {
|
|
_ = unstructured.SetNestedField(u.Object, true, "spec", "suspend")
|
|
return u
|
|
}
|
|
|
|
func newGate(t *testing.T, ctrlObjs []client.Object, dynObjs ...runtime.Object) (*DefaultObjectsGate, *dynamicfake.FakeDynamicClient) {
|
|
t.Helper()
|
|
dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), gateListKinds(), dynObjs...)
|
|
g := &DefaultObjectsGate{
|
|
Client: fake.NewClientBuilder().WithScheme(gateScheme()).WithObjects(ctrlObjs...).Build(),
|
|
Config: BackupCredentialsConfig{
|
|
SourceNamespace: "tenant-root",
|
|
SourceSecretName: "bucket-cozy-backups-system-credentials",
|
|
TargetSecretName: "cozy-backups-creds",
|
|
},
|
|
BackupClassName: "cozy-default",
|
|
HelmRelease: types.NamespacedName{
|
|
Namespace: "cozy-backup-controller",
|
|
Name: "backupstrategy-controller",
|
|
},
|
|
CredentialsHelmRelease: types.NamespacedName{
|
|
Namespace: "tenant-root",
|
|
Name: "bucket-cozy-backups-system",
|
|
},
|
|
CredentialsCOSISecretName: "bucket-cozy-backups-system",
|
|
VeleroNamespace: "cozy-velero",
|
|
MinForceInterval: 5 * time.Minute,
|
|
now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
|
|
}
|
|
g.Interface = dyn
|
|
g.RESTMapper = gateRESTMapper()
|
|
return g, dyn
|
|
}
|
|
|
|
func sourceSecret(bucket string) *corev1.Secret {
|
|
data := map[string][]byte{
|
|
"accessKey": []byte("AK"),
|
|
"secretKey": []byte("SK"),
|
|
"endpoint": []byte("s3.example.com"),
|
|
}
|
|
if bucket != "" {
|
|
data["bucketName"] = []byte(bucket)
|
|
}
|
|
return &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-root", Name: "bucket-cozy-backups-system-credentials"},
|
|
Data: data,
|
|
}
|
|
}
|
|
|
|
func bslObject() *unstructured.Unstructured {
|
|
u := &unstructured.Unstructured{}
|
|
u.SetAPIVersion("velero.io/v1")
|
|
u.SetKind("BackupStorageLocation")
|
|
u.SetNamespace("cozy-velero")
|
|
u.SetName(defaultBackupStorageLocationName)
|
|
u.SetAnnotations(map[string]string{endpointAnnotation: renderedEndpoint})
|
|
return u
|
|
}
|
|
|
|
func forceAnnotations(t *testing.T, dyn *dynamicfake.FakeDynamicClient) map[string]string {
|
|
t.Helper()
|
|
return annotationsOf(t, dyn, "cozy-backup-controller", "backupstrategy-controller")
|
|
}
|
|
|
|
func credentialsForceAnnotations(t *testing.T, dyn *dynamicfake.FakeDynamicClient) map[string]string {
|
|
t.Helper()
|
|
return annotationsOf(t, dyn, "tenant-root", "bucket-cozy-backups-system")
|
|
}
|
|
|
|
func annotationsOf(t *testing.T, dyn *dynamicfake.FakeDynamicClient, namespace, name string) map[string]string {
|
|
t.Helper()
|
|
hr, err := dyn.Resource(helmReleaseGVR).Namespace(namespace).
|
|
Get(context.Background(), name, metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("get HelmRelease %s/%s: %v", namespace, name, err)
|
|
}
|
|
return hr.GetAnnotations()
|
|
}
|
|
|
|
// TestCheckForcesWhenObjectsMissing is the bug this gate exists for: the
|
|
// bucket name resolves, so a Helm re-render WOULD produce the gated
|
|
// templates, but the objects are absent because the install-time lookup was
|
|
// empty and helm-controller never re-rendered. The gate must force a real
|
|
// Helm upgrade — BOTH annotations, because requestedAt alone is a no-op for
|
|
// an unchanged release.
|
|
func TestCheckForcesWhenObjectsMissing(t *testing.T) {
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if !forced {
|
|
t.Fatal("expected a forced Helm upgrade when default objects are missing")
|
|
}
|
|
// 6 strategyRefs (Velero twice under different names) + the BSL.
|
|
if len(missing) != 7 {
|
|
t.Fatalf("missing = %v, want 7 entries", missing)
|
|
}
|
|
ann := forceAnnotations(t, dyn)
|
|
forceAt, ok := ann["reconcile.fluxcd.io/forceAt"]
|
|
if !ok || forceAt == "" {
|
|
t.Errorf("reconcile.fluxcd.io/forceAt not set: %v", ann)
|
|
}
|
|
requestedAt, ok := ann["reconcile.fluxcd.io/requestedAt"]
|
|
if !ok || requestedAt == "" {
|
|
t.Errorf("reconcile.fluxcd.io/requestedAt not set: %v", ann)
|
|
}
|
|
if forceAt != requestedAt {
|
|
t.Errorf("forceAt (%q) and requestedAt (%q) must carry the same stamp", forceAt, requestedAt)
|
|
}
|
|
}
|
|
|
|
// TestCheckNoopWhenAllPresent pins the steady state: no forced upgrades
|
|
// once the objects exist, otherwise the gate would rewrite the annotation
|
|
// forever and re-run a Helm upgrade every MinForceInterval for the life of
|
|
// the cluster.
|
|
func TestCheckNoopWhenAllPresent(t *testing.T) {
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cosiSecret("https://s3.example.com"), cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
strategyObject("CNPG", "cozy-default-cnpg"),
|
|
strategyObject("MariaDB", "cozy-default-mariadb"),
|
|
strategyObject("Etcd", "cozy-default-etcd"),
|
|
strategyObject("Altinity", "cozy-default-altinity"),
|
|
strategyObject("Velero", "cozy-default-velero-vminstance"),
|
|
strategyObject("Velero", "cozy-default-velero-vmdisk"),
|
|
bslObject(),
|
|
)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if len(missing) != 0 || forced {
|
|
t.Fatalf("missing = %v, forced = %v, want none", missing, forced)
|
|
}
|
|
if ann := forceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("HelmRelease annotated in the steady state: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestCheckForcesCredentialsReleaseWhenSourceSecretAbsent is the second half
|
|
// of the bug. The Secret the gate reads to resolve the bucket name is itself
|
|
// rendered behind an install-time lookup, by the bucket's <bucket>-system
|
|
// release — so it is subject to the identical permanent skip, one release
|
|
// earlier. While it is absent nothing downstream can resolve: no projected
|
|
// credentials, no Strategy CRs, no Velero, and migration 50 has no snapshot
|
|
// target. Forcing THIS chart's release would be useless (its own lookups
|
|
// depend on that Secret); the bucket release is the one to force.
|
|
func TestCheckForcesCredentialsReleaseWhenSourceSecretAbsent(t *testing.T) {
|
|
g, dyn := newGate(t,
|
|
[]client.Object{cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
credentialsHelmReleaseObject(),
|
|
)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if !forced {
|
|
t.Fatal("expected the bucket release to be forced while the credentials Secret is absent")
|
|
}
|
|
if len(missing) != 1 || missing[0] != "Secret/bucket-cozy-backups-system-credentials" {
|
|
t.Fatalf("missing = %v, want the credentials Secret", missing)
|
|
}
|
|
ann := credentialsForceAnnotations(t, dyn)
|
|
if ann["reconcile.fluxcd.io/forceAt"] == "" || ann["reconcile.fluxcd.io/requestedAt"] == "" {
|
|
t.Errorf("bucket release not forced with both annotations: %v", ann)
|
|
}
|
|
// This chart's own release must be left alone: its templates gate on the
|
|
// same unresolved bucket, so forcing it would burn a Helm upgrade that
|
|
// re-runs the same empty lookup.
|
|
if ann := forceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("own HelmRelease forced before the bucket resolved: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestCheckForcesCredentialsReleaseWhenBucketNameEmpty pins the same
|
|
// treatment one state later: the Secret exists but carries no bucket name (a
|
|
// partial render, or a hand-written Secret missing the key). Only a
|
|
// re-render of the producing release can complete it.
|
|
func TestCheckForcesCredentialsReleaseWhenBucketNameEmpty(t *testing.T) {
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret(""), cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
credentialsHelmReleaseObject(),
|
|
)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if !forced || len(missing) != 1 {
|
|
t.Fatalf("missing = %v, forced = %v; want the credentials Secret forced", missing, forced)
|
|
}
|
|
if ann := credentialsForceAnnotations(t, dyn); ann["reconcile.fluxcd.io/forceAt"] == "" {
|
|
t.Errorf("bucket release not forced: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestCheckReportsCredentialsSecretWithoutBucketRelease covers external S3
|
|
// (backupStorage.provisionBucket=false): the Secret is admin-managed and no
|
|
// release renders it, so there is nothing to force — but it must still be
|
|
// reported missing, because that is the state an operator has to alert on.
|
|
func TestCheckReportsCredentialsSecretWithoutBucketRelease(t *testing.T) {
|
|
g, dyn := newGate(t,
|
|
[]client.Object{cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
credentialsHelmReleaseObject(),
|
|
)
|
|
g.CredentialsHelmRelease = types.NamespacedName{}
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if forced {
|
|
t.Error("forced a release on the external-S3 path, where none renders the Secret")
|
|
}
|
|
if len(missing) != 1 {
|
|
t.Fatalf("missing = %v, want the credentials Secret reported", missing)
|
|
}
|
|
if ann := credentialsForceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("bucket release annotated with no coordinates configured: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestCheckThrottlesTheTwoReleasesIndependently pins that forcing the bucket
|
|
// release does not eat the objects release's throttle budget. The two happen
|
|
// in sequence on a real bootstrap — credentials first, then the objects the
|
|
// resolved bucket unblocks — so a shared timestamp would delay the second
|
|
// force by a full MinForceInterval for no reason.
|
|
func TestCheckThrottlesTheTwoReleasesIndependently(t *testing.T) {
|
|
base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
g, dyn := newGate(t,
|
|
[]client.Object{cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
credentialsHelmReleaseObject(),
|
|
)
|
|
g.now = func() time.Time { return base }
|
|
|
|
// No source Secret yet: the bucket release is forced.
|
|
if _, forced, err := g.Check(context.Background()); err != nil || !forced {
|
|
t.Fatalf("first Check: forced = %v, err = %v; want the bucket release forced", forced, err)
|
|
}
|
|
|
|
// A minute later the Secret lands. The objects are still missing, and
|
|
// this force must go through despite being well inside MinForceInterval
|
|
// of the previous one.
|
|
g.now = func() time.Time { return base.Add(time.Minute) }
|
|
if err := g.Client.Create(context.Background(), sourceSecret("bucket-1a2b")); err != nil {
|
|
t.Fatalf("create source Secret: %v", err)
|
|
}
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("second Check: %v", err)
|
|
}
|
|
if !forced {
|
|
t.Fatal("objects release throttled by the earlier credentials force")
|
|
}
|
|
if len(missing) != 7 {
|
|
t.Fatalf("missing = %v, want the 6 strategies + the BSL", missing)
|
|
}
|
|
if ann := forceAnnotations(t, dyn); ann["reconcile.fluxcd.io/forceAt"] == "" {
|
|
t.Errorf("own HelmRelease not forced: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestForceSkipsSuspendedHelmRelease pins the suspend guard. helm-controller
|
|
// ignores forceAt/requestedAt while spec.suspend is true (`cozyhr suspend` is
|
|
// a standard dev workflow), so patching anyway would re-stamp every
|
|
// MinForceInterval for the whole suspension and climb
|
|
// cozystack_backup_default_objects_force_reconciles_total — which the runbook
|
|
// reads as "the forced render is not producing the objects", the wrong
|
|
// diagnosis. The objects must still be reported missing.
|
|
func TestForceSkipsSuspendedHelmRelease(t *testing.T) {
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cozyDefaultBackupClass()},
|
|
suspended(helmReleaseObject()),
|
|
)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if forced {
|
|
t.Error("forced reported true for a suspended HelmRelease that ignores the annotations")
|
|
}
|
|
if len(missing) != 7 {
|
|
t.Fatalf("missing = %v, want the objects still reported", missing)
|
|
}
|
|
if ann := forceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("suspended HelmRelease annotated: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestForceSkipsSuspendedCredentialsHelmRelease pins the same guard on the
|
|
// bucket release, which an operator is far more likely to have suspended:
|
|
// it lives in a tenant namespace and is not part of this chart.
|
|
func TestForceSkipsSuspendedCredentialsHelmRelease(t *testing.T) {
|
|
g, dyn := newGate(t,
|
|
[]client.Object{cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
suspended(credentialsHelmReleaseObject()),
|
|
)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if forced {
|
|
t.Error("forced reported true for a suspended bucket HelmRelease")
|
|
}
|
|
if len(missing) != 1 {
|
|
t.Fatalf("missing = %v, want the credentials Secret still reported", missing)
|
|
}
|
|
if ann := credentialsForceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("suspended bucket HelmRelease annotated: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestCheckTimeoutStaysBelowPeriod pins the bound on a single check. The ticker
|
|
// loop is sequential and the manager context is only cancelled at shutdown, so
|
|
// an unbounded check that hangs on a stalled API call stops every later check
|
|
// for the life of the pod.
|
|
func TestCheckTimeoutStaysBelowPeriod(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
period time.Duration
|
|
want time.Duration
|
|
}{
|
|
{"unset falls back to the cap", 0, 30 * time.Second},
|
|
{"default period halves", time.Minute, 30 * time.Second},
|
|
{"short period halves", 10 * time.Second, 5 * time.Second},
|
|
{"long period is capped", time.Hour, 30 * time.Second},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
g := &DefaultObjectsGate{Period: tc.period}
|
|
got := g.checkTimeout()
|
|
if got != tc.want {
|
|
t.Fatalf("checkTimeout() = %v, want %v", got, tc.want)
|
|
}
|
|
if tc.period > 0 && got >= tc.period {
|
|
t.Fatalf("checkTimeout() = %v, must stay below Period %v", got, tc.period)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckThrottlesRepeatedForces pins the throttle. A condition a
|
|
// re-render cannot fix (missing CRD, unreachable bucket) must not turn into
|
|
// a hot loop of Helm upgrades against helm-controller.
|
|
func TestCheckThrottlesRepeatedForces(t *testing.T) {
|
|
g, _ := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
)
|
|
base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
g.now = func() time.Time { return base }
|
|
|
|
if _, forced, err := g.Check(context.Background()); err != nil || !forced {
|
|
t.Fatalf("first Check: forced = %v, err = %v; want forced", forced, err)
|
|
}
|
|
|
|
// Well inside MinForceInterval: still missing, but no second upgrade.
|
|
g.now = func() time.Time { return base.Add(time.Minute) }
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("second Check: %v", err)
|
|
}
|
|
if forced {
|
|
t.Error("second Check forced inside MinForceInterval")
|
|
}
|
|
if len(missing) == 0 {
|
|
t.Error("second Check should still report the objects as missing")
|
|
}
|
|
|
|
// Past MinForceInterval: force again, because the objects are still gone.
|
|
g.now = func() time.Time { return base.Add(6 * time.Minute) }
|
|
if _, forced, err := g.Check(context.Background()); err != nil || !forced {
|
|
t.Fatalf("third Check: forced = %v, err = %v; want forced", forced, err)
|
|
}
|
|
}
|
|
|
|
// TestMissingObjectsIgnoresUnmappedKinds pins that a strategyRef whose CRD
|
|
// is not installed is not counted as missing. No Helm re-render can create
|
|
// such an object, so counting it would force upgrades forever.
|
|
func TestMissingObjectsIgnoresUnmappedKinds(t *testing.T) {
|
|
bc := cozyDefaultBackupClass()
|
|
bc.Spec.Strategies = append(bc.Spec.Strategies, backupsv1alpha1.BackupClassStrategy{
|
|
Application: backupsv1alpha1.ApplicationSelector{Kind: "Nonexistent"},
|
|
StrategyRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: new(strategyAPIGroup),
|
|
Kind: "NotInstalled",
|
|
Name: "cozy-default-notinstalled",
|
|
},
|
|
})
|
|
g, _ := newGate(t, []client.Object{sourceSecret("b"), bc},
|
|
helmReleaseObject(),
|
|
strategyObject("CNPG", "cozy-default-cnpg"),
|
|
strategyObject("MariaDB", "cozy-default-mariadb"),
|
|
strategyObject("Etcd", "cozy-default-etcd"),
|
|
strategyObject("Altinity", "cozy-default-altinity"),
|
|
strategyObject("Velero", "cozy-default-velero-vminstance"),
|
|
strategyObject("Velero", "cozy-default-velero-vmdisk"),
|
|
bslObject(),
|
|
)
|
|
|
|
state, err := g.inspectObjects(context.Background(), bc, "")
|
|
if err != nil {
|
|
t.Fatalf("inspectObjects: %v", err)
|
|
}
|
|
missing := state.missing
|
|
if len(missing) != 0 {
|
|
t.Fatalf("missing = %v, want none (unmapped kind must be ignored)", missing)
|
|
}
|
|
}
|
|
|
|
// TestMissingObjectsSkipsVeleroWhenNamespaceEmpty covers
|
|
// velero.bslEnabled=false: the chart gates BOTH the BSL and the Velero
|
|
// Strategy CRs off the same flag, so neither is rendered and their absence
|
|
// is not a defect. The BackupClass still routes to the Velero strategies
|
|
// unconditionally, which is the trap: without the skip the gate would count
|
|
// the never-rendered Velero CRs as missing and force a Helm upgrade every
|
|
// MinForceInterval forever. The Velero objects are deliberately NOT
|
|
// pre-created here, because with the BSL disabled the chart never renders
|
|
// them on a real cluster.
|
|
func TestMissingObjectsSkipsVeleroWhenNamespaceEmpty(t *testing.T) {
|
|
bc := cozyDefaultBackupClass()
|
|
g, _ := newGate(t, []client.Object{sourceSecret("b"), bc},
|
|
helmReleaseObject(),
|
|
strategyObject("CNPG", "cozy-default-cnpg"),
|
|
strategyObject("MariaDB", "cozy-default-mariadb"),
|
|
strategyObject("Etcd", "cozy-default-etcd"),
|
|
strategyObject("Altinity", "cozy-default-altinity"),
|
|
)
|
|
g.VeleroNamespace = ""
|
|
|
|
state, err := g.inspectObjects(context.Background(), bc, "")
|
|
if err != nil {
|
|
t.Fatalf("inspectObjects: %v", err)
|
|
}
|
|
missing := state.missing
|
|
if len(missing) != 0 {
|
|
t.Fatalf("missing = %v, want none when Velero is disabled", missing)
|
|
}
|
|
}
|
|
|
|
// TestMissingObjectsSkipsBSLWhenVeleroAPIAbsent covers Velero being removed
|
|
// after bootstrap while bslEnabled=true: the velero.io API is no longer
|
|
// served, so the BSL cannot be re-rendered and must not be counted as
|
|
// missing. Resolving through the RESTMapper turns that into a NoMatch skip;
|
|
// the previous hardcoded-GVR Get returned a NotFound that would have looped
|
|
// the gate forever.
|
|
func TestMissingObjectsSkipsBSLWhenVeleroAPIAbsent(t *testing.T) {
|
|
bc := cozyDefaultBackupClass()
|
|
g, _ := newGate(t, []client.Object{sourceSecret("b"), bc},
|
|
helmReleaseObject(),
|
|
strategyObject("CNPG", "cozy-default-cnpg"),
|
|
strategyObject("MariaDB", "cozy-default-mariadb"),
|
|
strategyObject("Etcd", "cozy-default-etcd"),
|
|
strategyObject("Altinity", "cozy-default-altinity"),
|
|
strategyObject("Velero", "cozy-default-velero-vminstance"),
|
|
strategyObject("Velero", "cozy-default-velero-vmdisk"),
|
|
)
|
|
// Rebuild the RESTMapper without BackupStorageLocation: the velero.io API
|
|
// is not served on this cluster.
|
|
gvset := map[schema.GroupVersion]struct{}{}
|
|
var kept []schema.GroupVersionKind
|
|
for _, gvk := range gateGVKs {
|
|
if gvk.Kind == "BackupStorageLocation" {
|
|
continue
|
|
}
|
|
gvset[gvk.GroupVersion()] = struct{}{}
|
|
kept = append(kept, gvk)
|
|
}
|
|
var gvs []schema.GroupVersion
|
|
for gv := range gvset {
|
|
gvs = append(gvs, gv)
|
|
}
|
|
m := meta.NewDefaultRESTMapper(gvs)
|
|
for _, gvk := range kept {
|
|
scope := meta.RESTScopeRoot
|
|
if gvk.Kind == "HelmRelease" {
|
|
scope = meta.RESTScopeNamespace
|
|
}
|
|
m.Add(gvk, scope)
|
|
}
|
|
g.RESTMapper = m
|
|
|
|
state, err := g.inspectObjects(context.Background(), bc, "")
|
|
if err != nil {
|
|
t.Fatalf("inspectObjects: %v", err)
|
|
}
|
|
missing := state.missing
|
|
if len(missing) != 0 {
|
|
t.Fatalf("missing = %v, want none when the Velero API is absent", missing)
|
|
}
|
|
}
|
|
|
|
// TestCheckSurfacesPatchFailure pins that a failed force is reported rather
|
|
// than silently recorded as done — and that lastForce is NOT advanced, so
|
|
// the next tick retries instead of waiting out MinForceInterval.
|
|
func TestCheckSurfacesPatchFailure(t *testing.T) {
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
)
|
|
dyn.PrependReactor("patch", "helmreleases", func(k8stesting.Action) (bool, runtime.Object, error) {
|
|
return true, nil, apierrors.NewForbidden(schema.GroupResource{Group: "helm.toolkit.fluxcd.io", Resource: "helmreleases"}, "backupstrategy-controller", nil)
|
|
})
|
|
|
|
if _, forced, err := g.Check(context.Background()); err == nil || forced {
|
|
t.Fatalf("forced = %v, err = %v; want an error and forced=false", forced, err)
|
|
}
|
|
if !g.lastForce.IsZero() {
|
|
t.Error("lastForce advanced despite the patch failing; the next tick would be throttled")
|
|
}
|
|
}
|
|
|
|
// TestMissingGaugeCoversTheUnresolvedPath pins the alerting contract. The
|
|
// gauge used to be written only after the bucket resolved, so the state the
|
|
// gate exists to catch — no credentials Secret, therefore no strategies —
|
|
// reported 0 or nothing at all, and an alert on it never fired. Deleting the
|
|
// source Secret later (credential rotation) had the same effect: the gauge
|
|
// froze at its last value.
|
|
func TestMissingGaugeCoversTheUnresolvedPath(t *testing.T) {
|
|
defaultObjectsMissing.Reset()
|
|
g, _ := newGate(t,
|
|
[]client.Object{cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
credentialsHelmReleaseObject(),
|
|
)
|
|
|
|
if _, _, err := g.Check(context.Background()); err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if got := testutil.ToFloat64(defaultObjectsMissing.WithLabelValues("cozy-default")); got != 1 {
|
|
t.Fatalf("cozystack_backup_default_objects_missing = %v, want 1 while the credentials Secret is absent", got)
|
|
}
|
|
}
|
|
|
|
// TestCheckErrorsCounterMarksTheGaugeStale pins the other half of that
|
|
// contract. On an API error the gauge deliberately keeps its last value
|
|
// rather than flapping the alert, which means the gauge alone cannot tell
|
|
// "healthy" from "not evaluated". The errors counter is what closes that
|
|
// gap, and the runbook alerts on both.
|
|
func TestCheckErrorsCounterMarksTheGaugeStale(t *testing.T) {
|
|
defaultObjectsMissing.Reset()
|
|
defaultObjectsCheckErrors.Reset()
|
|
// No BackupClass: the check cannot enumerate what must exist.
|
|
g, _ := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b")},
|
|
helmReleaseObject(),
|
|
)
|
|
|
|
if _, _, err := g.Check(context.Background()); err == nil {
|
|
t.Fatal("Check succeeded with no BackupClass, want an error")
|
|
}
|
|
if got := testutil.ToFloat64(defaultObjectsCheckErrors.WithLabelValues("cozy-default")); got != 1 {
|
|
t.Fatalf("cozystack_backup_default_objects_check_errors_total = %v, want 1", got)
|
|
}
|
|
if got := testutil.ToFloat64(defaultObjectsMissing.WithLabelValues("cozy-default")); got != 0 {
|
|
t.Fatalf("gauge = %v, want it left untouched by a failed check", got)
|
|
}
|
|
}
|
|
|
|
// TestStartDisabledWithoutHelmRelease pins the opt-out: without release
|
|
// coordinates the runnable must return immediately instead of ticking with
|
|
// a nil target.
|
|
func TestStartDisabledWithoutHelmRelease(t *testing.T) {
|
|
g, _ := newGate(t, []client.Object{sourceSecret("b"), cozyDefaultBackupClass()}, helmReleaseObject())
|
|
g.HelmRelease = types.NamespacedName{}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
done := make(chan error, 1)
|
|
go func() { done <- g.Start(ctx) }()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("Start: %v", err)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("Start did not return immediately when disabled")
|
|
}
|
|
}
|
|
|
|
// TestCheckToleratesAbsentHelmRelease covers a plain `helm install` (local
|
|
// development): there is no HelmRelease to force, which is not an error to
|
|
// report every tick.
|
|
func TestCheckToleratesAbsentHelmRelease(t *testing.T) {
|
|
g, _ := newGate(t, []client.Object{sourceSecret("bucket-1a2b"), cozyDefaultBackupClass()})
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if forced {
|
|
t.Error("forced reported true with no HelmRelease to patch")
|
|
}
|
|
if len(missing) == 0 {
|
|
t.Error("objects should still be reported as missing")
|
|
}
|
|
}
|
|
|
|
// TestCheckForcesCredentialsReleaseWhenEndpointStale covers the credentials
|
|
// Secret outliving the endpoint it was rendered from. The COSI sidecar writes
|
|
// BucketInfo once per BucketAccess, so a re-granted BucketAccess (after a
|
|
// publishing.host change) carries the new endpoint while the credentials
|
|
// Secret <bucket>-system rendered earlier keeps the old one: the Secret
|
|
// exists, so the missing-Secret path never fires, and every consumer keeps
|
|
// the dead host.
|
|
func TestCheckForcesCredentialsReleaseWhenEndpointStale(t *testing.T) {
|
|
objs := append([]runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}, allDefaultObjects()...)
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cosiSecret("https://s3.new.example.com"), cozyDefaultBackupClass()},
|
|
objs...,
|
|
)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if !forced {
|
|
t.Fatal("expected the bucket release forced while its credentials Secret carries a stale endpoint")
|
|
}
|
|
if len(missing) != 1 || !strings.HasPrefix(missing[0], "Secret/bucket-cozy-backups-system-credentials") {
|
|
t.Fatalf("missing = %v, want the credentials Secret", missing)
|
|
}
|
|
if ann := credentialsForceAnnotations(t, dyn); ann["reconcile.fluxcd.io/forceAt"] == "" {
|
|
t.Errorf("bucket release not forced: %v", ann)
|
|
}
|
|
// The objects were rendered from the stale Secret and agree with it;
|
|
// re-rendering them before the Secret is fixed would bake the old
|
|
// endpoint in again.
|
|
if ann := forceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("own HelmRelease forced while the credentials Secret is stale: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestCheckCredentialsEndpointComparison pins the comparison against what
|
|
// user-credentials.yaml writes for a given BucketInfo endpoint (the scheme
|
|
// stripped, nothing else), so a Secret the chart rendered from the current
|
|
// BucketInfo never counts as stale and the bucket release is not forced on
|
|
// every throttle window.
|
|
func TestCheckCredentialsEndpointComparison(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
cosi string
|
|
wantForce bool
|
|
}{
|
|
{"https scheme stripped", "https://s3.example.com", false},
|
|
{"http scheme stripped", "http://s3.example.com", false},
|
|
{"bare host", "s3.example.com", false},
|
|
{"other host", "https://s3.new.example.com", true},
|
|
{"other port", "https://s3.example.com:8443", true},
|
|
// A forced render from a BucketInfo with no endpoint would write an
|
|
// empty endpoint into the Secret every driver reads (or, with the key
|
|
// absent, fail the bucket release's render), never a working one.
|
|
{"no endpoint in BucketInfo", "", false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
objs := append([]runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}, allDefaultObjects()...)
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cosiSecret(tc.cosi), cozyDefaultBackupClass()},
|
|
objs...,
|
|
)
|
|
if _, _, err := g.Check(context.Background()); err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
forced := credentialsForceAnnotations(t, dyn)["reconcile.fluxcd.io/forceAt"] != ""
|
|
if forced != tc.wantForce {
|
|
t.Fatalf("bucket release forced = %v, want %v", forced, tc.wantForce)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckSkipsCredentialsComparisonWithoutCOSISecret covers the window in
|
|
// which the BucketAccess is being re-granted: the COSI Secret is gone and
|
|
// there is nothing to compare against, which is not evidence of staleness.
|
|
func TestCheckSkipsCredentialsComparisonWithoutCOSISecret(t *testing.T) {
|
|
objs := append([]runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}, allDefaultObjects()...)
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cozyDefaultBackupClass()},
|
|
objs...,
|
|
)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil || forced || len(missing) != 0 {
|
|
t.Fatalf("missing = %v, forced = %v, err = %v; want a no-op", missing, forced, err)
|
|
}
|
|
if ann := credentialsForceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("bucket release forced with no COSI Secret to compare against: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestCheckSkipsCredentialsComparisonWhenUnconfigured pins both switches that
|
|
// turn the comparison off: no COSI Secret name, and no bucket release (external
|
|
// S3, where the Secret is admin-managed and no BucketInfo backs it).
|
|
func TestCheckSkipsCredentialsComparisonWhenUnconfigured(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
apply func(g *DefaultObjectsGate)
|
|
}{
|
|
{"no COSI Secret name", func(g *DefaultObjectsGate) { g.CredentialsCOSISecretName = "" }},
|
|
{"no bucket release", func(g *DefaultObjectsGate) { g.CredentialsHelmRelease = types.NamespacedName{} }},
|
|
{"no bucket release name", func(g *DefaultObjectsGate) { g.CredentialsHelmRelease.Name = "" }},
|
|
{"no bucket release namespace", func(g *DefaultObjectsGate) { g.CredentialsHelmRelease.Namespace = "" }},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
objs := append([]runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}, allDefaultObjects()...)
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cosiSecret("https://s3.new.example.com"), cozyDefaultBackupClass()},
|
|
objs...,
|
|
)
|
|
tc.apply(g)
|
|
// The real API server rejects a Get with an empty name; the fake
|
|
// answers NotFound, which would hide a lookup of an unset name.
|
|
g.Client = interceptor.NewClient(g.Client.(client.WithWatch), interceptor.Funcs{
|
|
Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
|
|
if key.Name == "" {
|
|
return errors.New("resource name may not be empty")
|
|
}
|
|
return c.Get(ctx, key, obj, opts...)
|
|
},
|
|
})
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil || forced || len(missing) != 0 {
|
|
t.Fatalf("missing = %v, forced = %v, err = %v; want a no-op", missing, forced, err)
|
|
}
|
|
if ann := credentialsForceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("bucket release forced with the comparison disabled: %v", ann)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckReportsMalformedCOSISecret pins that a COSI Secret the gate cannot
|
|
// read is an error the check counts, not a silent "not stale".
|
|
func TestCheckReportsMalformedCOSISecret(t *testing.T) {
|
|
defaultObjectsCheckErrors.Reset()
|
|
bad := cosiSecret("")
|
|
bad.Data["BucketInfo"] = []byte("{not json")
|
|
objs := append([]runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}, allDefaultObjects()...)
|
|
g, _ := newGate(t, []client.Object{sourceSecret("bucket-1a2b"), bad, cozyDefaultBackupClass()}, objs...)
|
|
|
|
if _, forced, err := g.Check(context.Background()); err == nil || forced {
|
|
t.Fatalf("forced = %v, err = %v; want an error", forced, err)
|
|
}
|
|
if got := testutil.ToFloat64(defaultObjectsCheckErrors.WithLabelValues("cozy-default")); got != 1 {
|
|
t.Fatalf("check errors = %v, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestCheckForcesOwnReleaseWhenObjectEndpointStale is the second half of a
|
|
// publishing.host change: the credentials Secret now carries the new
|
|
// endpoint, but the BSL and the default Strategies baked the old one at the
|
|
// last render of this chart and nothing re-renders them, so every backup
|
|
// keeps going to the dead host.
|
|
func TestCheckForcesOwnReleaseWhenObjectEndpointStale(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
stale *unstructured.Unstructured
|
|
key string
|
|
}{
|
|
{"strategy", strategyObject("CNPG", "cozy-default-cnpg"), "CNPG/cozy-default-cnpg"},
|
|
{"BSL", bslObject(), "BackupStorageLocation/cozy-default"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var objs []runtime.Object
|
|
for _, o := range allDefaultObjects() {
|
|
u := o.(*unstructured.Unstructured)
|
|
if u.GetKind() == tc.stale.GetKind() && u.GetName() == tc.stale.GetName() {
|
|
withEndpoint(u, "https://s3.old.example.com")
|
|
}
|
|
objs = append(objs, u)
|
|
}
|
|
objs = append(objs, helmReleaseObject(), credentialsHelmReleaseObject())
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cosiSecret("https://s3.example.com"), cozyDefaultBackupClass()},
|
|
objs...,
|
|
)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if !forced {
|
|
t.Fatal("expected this chart's release forced while an object carries a stale endpoint")
|
|
}
|
|
if len(missing) != 1 || !strings.HasPrefix(missing[0], tc.key+" ") {
|
|
t.Fatalf("missing = %v, want only %s", missing, tc.key)
|
|
}
|
|
if ann := forceAnnotations(t, dyn); ann["reconcile.fluxcd.io/forceAt"] == "" {
|
|
t.Errorf("own HelmRelease not forced: %v", ann)
|
|
}
|
|
if ann := credentialsForceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("bucket release forced for a stale object: %v", ann)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckIgnoresObjectEndpointOnExternalS3 pins that the comparison is off
|
|
// when no bucket release is configured: with provisionBucket=false the chart
|
|
// renders backupStorage.endpoint, not the Secret's, so the two legitimately
|
|
// differ and forcing would loop every MinForceInterval.
|
|
func TestCheckIgnoresObjectEndpointOnExternalS3(t *testing.T) {
|
|
objs := []runtime.Object{helmReleaseObject()}
|
|
for _, o := range allDefaultObjects() {
|
|
objs = append(objs, withEndpoint(o.(*unstructured.Unstructured), "https://minio.example.internal:9000"))
|
|
}
|
|
g, dyn := newGate(t, []client.Object{sourceSecret("bucket-1a2b"), cozyDefaultBackupClass()}, objs...)
|
|
g.CredentialsHelmRelease = types.NamespacedName{}
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil || forced || len(missing) != 0 {
|
|
t.Fatalf("missing = %v, forced = %v, err = %v; want a no-op", missing, forced, err)
|
|
}
|
|
if ann := forceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("own HelmRelease forced on external S3: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestCheckIgnoresObjectEndpointWithoutSecretEndpoint covers a credentials
|
|
// Secret with no endpoint key: the chart then renders its values fallback,
|
|
// which the gate cannot reproduce, so there is nothing to compare against.
|
|
func TestCheckIgnoresObjectEndpointWithoutSecretEndpoint(t *testing.T) {
|
|
src := sourceSecret("bucket-1a2b")
|
|
delete(src.Data, "endpoint")
|
|
objs := append([]runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}, allDefaultObjects()...)
|
|
g, dyn := newGate(t, []client.Object{src, cozyDefaultBackupClass()}, objs...)
|
|
|
|
if _, forced, err := g.Check(context.Background()); err != nil || forced {
|
|
t.Fatalf("forced = %v, err = %v; want a no-op", forced, err)
|
|
}
|
|
if ann := forceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("own HelmRelease forced with no endpoint to compare against: %v", ann)
|
|
}
|
|
}
|
|
|
|
// TestCheckForcesOnceForUnannotatedObjects covers objects rendered by a chart
|
|
// that predates the endpoint annotation. Their endpoint is unknown, so they
|
|
// are treated as stale until one forced render has gone through, which stamps
|
|
// the annotation. A force that did not go through (throttled) does not count,
|
|
// and once one has, an object still without the annotation (one the chart
|
|
// does not render) must not force again every MinForceInterval.
|
|
func TestCheckForcesOnceForUnannotatedObjects(t *testing.T) {
|
|
base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
objs := []runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}
|
|
for _, o := range allDefaultObjects() {
|
|
objs = append(objs, unannotated(o.(*unstructured.Unstructured)))
|
|
}
|
|
g, dyn := newGate(t, []client.Object{sourceSecret("bucket-1a2b"), cozyDefaultBackupClass()}, objs...)
|
|
g.lastForce = base.Add(-time.Minute)
|
|
g.now = func() time.Time { return base }
|
|
|
|
if missing, forced, err := g.Check(context.Background()); err != nil || forced || len(missing) != 7 {
|
|
t.Fatalf("throttled Check: missing = %v, forced = %v, err = %v; want 7 reported, none forced", missing, forced, err)
|
|
}
|
|
|
|
g.now = func() time.Time { return base.Add(5 * time.Minute) }
|
|
if _, forced, err := g.Check(context.Background()); err != nil || !forced {
|
|
t.Fatalf("first unthrottled Check: forced = %v, err = %v; want forced", forced, err)
|
|
}
|
|
first := forceAnnotations(t, dyn)["reconcile.fluxcd.io/forceAt"]
|
|
|
|
g.now = func() time.Time { return base.Add(time.Hour) }
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil || forced || len(missing) != 0 {
|
|
t.Fatalf("after the forced render: missing = %v, forced = %v, err = %v; want a no-op", missing, forced, err)
|
|
}
|
|
if again := forceAnnotations(t, dyn)["reconcile.fluxcd.io/forceAt"]; again != first {
|
|
t.Errorf("forced again for unannotated objects: %q, then %q", first, again)
|
|
}
|
|
}
|
|
|
|
// TestCheckStillForcesStaleAfterUnannotatedForce pins that the once-only
|
|
// treatment covers the missing annotation alone: an annotation that names the
|
|
// wrong endpoint still forces after that.
|
|
func TestCheckStillForcesStaleAfterUnannotatedForce(t *testing.T) {
|
|
objs := []runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}
|
|
for _, o := range allDefaultObjects() {
|
|
objs = append(objs, withEndpoint(o.(*unstructured.Unstructured), "https://s3.old.example.com"))
|
|
}
|
|
g, _ := newGate(t, []client.Object{sourceSecret("bucket-1a2b"), cozyDefaultBackupClass()}, objs...)
|
|
g.renderForced = true
|
|
|
|
if _, forced, err := g.Check(context.Background()); err != nil || !forced {
|
|
t.Fatalf("forced = %v, err = %v; want forced", forced, err)
|
|
}
|
|
}
|
|
|
|
// TestRenderedEndpointMatchesTheChart pins renderedEndpointFor to the
|
|
// "backupstrategy-controller.endpoint" helper, which the chart stamps as the
|
|
// annotation: trim, strip a scheme, force https://. Any divergence makes the
|
|
// gate see every object as stale and force this release forever.
|
|
func TestRenderedEndpointMatchesTheChart(t *testing.T) {
|
|
for _, tc := range []struct{ in, want string }{
|
|
{"s3.example.com", "https://s3.example.com"},
|
|
{"s3.example.com\n", "https://s3.example.com"},
|
|
{" s3.example.com ", "https://s3.example.com"},
|
|
{"https://s3.example.com", "https://s3.example.com"},
|
|
{"http://s3.example.com:8333", "https://s3.example.com:8333"},
|
|
{"s3.example.com/prefix/", "https://s3.example.com/prefix/"},
|
|
// No endpoint in the Secret: the chart renders its values fallback,
|
|
// which the gate cannot reproduce, so it compares nothing.
|
|
{"", ""},
|
|
// Present but blank: the helper's lookup sees a non-empty value and
|
|
// renders the bare scheme.
|
|
{"\n", "https://"},
|
|
} {
|
|
if got := renderedEndpointFor([]byte(tc.in)); got != tc.want {
|
|
t.Errorf("renderedEndpointFor(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestForcedReleasesForNamesEachRenderingRelease pins the releases the log
|
|
// line names: the credentials Secret, missing or stale, is repaired by the
|
|
// bucket release, everything else by this chart's own, and both are named when
|
|
// one check forced both.
|
|
func TestForcedReleasesForNamesEachRenderingRelease(t *testing.T) {
|
|
g, _ := newGate(t, nil)
|
|
creds, own := g.CredentialsHelmRelease.String(), g.HelmRelease.String()
|
|
for _, tc := range []struct {
|
|
missing []string
|
|
want []string
|
|
}{
|
|
{[]string{"Secret/bucket-cozy-backups-system-credentials"}, []string{creds}},
|
|
{[]string{`Secret/bucket-cozy-backups-system-credentials (endpoint "s3.old", COSI Secret has "s3.new")`}, []string{creds}},
|
|
{[]string{"Secret/bucket-cozy-backups-system-credentials-other"}, []string{own}},
|
|
{[]string{`CNPG/cozy-default-cnpg (endpoint "https://s3.old", want "https://s3.new")`, "Etcd/cozy-default-etcd"}, []string{own}},
|
|
{[]string{`Secret/bucket-cozy-backups-system-credentials (endpoint "s3.old", COSI Secret has "s3.new")`, "CNPG/cozy-default-cnpg"}, []string{creds, own}},
|
|
} {
|
|
if got := g.forcedReleasesFor(tc.missing); !slices.Equal(got, tc.want) {
|
|
t.Errorf("forcedReleasesFor(%v) = %v, want %v", tc.missing, got, tc.want)
|
|
}
|
|
}
|
|
// External S3: no bucket release, so nothing but this chart's own.
|
|
g.CredentialsHelmRelease = types.NamespacedName{}
|
|
if got := g.forcedReleasesFor([]string{"Secret/bucket-cozy-backups-system-credentials"}); !slices.Equal(got, []string{own}) {
|
|
t.Errorf("forcedReleasesFor without a bucket release = %v, want [%s]", got, own)
|
|
}
|
|
}
|
|
|
|
// TestCheckSkipsCredentialsComparisonWithoutDerivedSecret covers a
|
|
// systemSecretName the bucket chart does not render: the same name as the COSI
|
|
// Secret (the projector also reads a raw COSI Secret) or a Secret without the
|
|
// endpoint key. Comparing it would report it stale on every tick, or fail the
|
|
// check, and the missing objects would never be repaired.
|
|
func TestCheckSkipsCredentialsComparisonWithoutDerivedSecret(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
apply func(g *DefaultObjectsGate, src *corev1.Secret)
|
|
}{
|
|
{"COSI Secret is the source", func(g *DefaultObjectsGate, src *corev1.Secret) {
|
|
g.CredentialsCOSISecretName = g.Config.SourceSecretName
|
|
src.Data["BucketInfo"] = cosiSecret("https://s3.new.example.com").Data["BucketInfo"]
|
|
}},
|
|
{"source without endpoint", func(_ *DefaultObjectsGate, src *corev1.Secret) {
|
|
delete(src.Data, "endpoint")
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
src := sourceSecret("bucket-1a2b")
|
|
var objs []runtime.Object
|
|
for _, o := range allDefaultObjects() {
|
|
if u := o.(*unstructured.Unstructured); u.GetName() != "cozy-default-cnpg" {
|
|
objs = append(objs, u)
|
|
}
|
|
}
|
|
objs = append(objs, helmReleaseObject(), credentialsHelmReleaseObject())
|
|
g, dyn := newGate(t, nil, objs...)
|
|
tc.apply(g, src)
|
|
g.Client = fake.NewClientBuilder().WithScheme(gateScheme()).
|
|
WithObjects(src, cosiSecret("https://s3.new.example.com"), cozyDefaultBackupClass()).Build()
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Check: %v", err)
|
|
}
|
|
if !forced || len(missing) != 1 || missing[0] != "CNPG/cozy-default-cnpg" {
|
|
t.Fatalf("missing = %v, forced = %v; want the missing strategy repaired", missing, forced)
|
|
}
|
|
if ann := credentialsForceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("bucket release forced: %v", ann)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckRepairsMissingObjectsWhileCredentialsUnresolved pins that a stale
|
|
// or unreadable credentials comparison does not cost the repair of missing
|
|
// objects the gate made before it existed. The comparison only withholds the
|
|
// endpoint check, whose answer would be rendered from the same stale Secret.
|
|
func TestCheckRepairsMissingObjectsWhileCredentialsUnresolved(t *testing.T) {
|
|
malformed := cosiSecret("")
|
|
malformed.Data["BucketInfo"] = []byte("{not json")
|
|
for _, tc := range []struct {
|
|
name string
|
|
cosi *corev1.Secret
|
|
credsHR *unstructured.Unstructured
|
|
wantErr bool
|
|
wantCredsHRAt bool
|
|
}{
|
|
{"stale credentials", cosiSecret("https://s3.new.example.com"), credentialsHelmReleaseObject(), false, true},
|
|
{"stale credentials, bucket release suspended", cosiSecret("https://s3.new.example.com"), suspended(credentialsHelmReleaseObject()), false, false},
|
|
{"malformed COSI Secret", malformed, credentialsHelmReleaseObject(), true, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defaultObjectsCheckErrors.Reset()
|
|
defaultObjectsMissing.Reset()
|
|
var objs []runtime.Object
|
|
for _, o := range allDefaultObjects() {
|
|
if u := o.(*unstructured.Unstructured); u.GetName() != "cozy-default-cnpg" {
|
|
objs = append(objs, u)
|
|
}
|
|
}
|
|
objs = append(objs, helmReleaseObject(), tc.credsHR)
|
|
g, dyn := newGate(t, []client.Object{sourceSecret("bucket-1a2b"), tc.cosi, cozyDefaultBackupClass()}, objs...)
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if (err != nil) != tc.wantErr {
|
|
t.Fatalf("err = %v, want error %v", err, tc.wantErr)
|
|
}
|
|
if !forced {
|
|
t.Error("forced = false, want this chart's release forced for the missing strategy")
|
|
}
|
|
if ann := forceAnnotations(t, dyn); ann["reconcile.fluxcd.io/forceAt"] == "" {
|
|
t.Errorf("own HelmRelease not forced for a missing strategy: %v", ann)
|
|
}
|
|
if got := credentialsForceAnnotations(t, dyn)["reconcile.fluxcd.io/forceAt"] != ""; got != tc.wantCredsHRAt {
|
|
t.Errorf("bucket release forced = %v, want %v", got, tc.wantCredsHRAt)
|
|
}
|
|
found := false
|
|
for _, m := range missing {
|
|
found = found || m == "CNPG/cozy-default-cnpg"
|
|
}
|
|
if !found {
|
|
t.Errorf("missing = %v, want CNPG/cozy-default-cnpg reported", missing)
|
|
}
|
|
if got := testutil.ToFloat64(defaultObjectsMissing.WithLabelValues("cozy-default")); got != 1 {
|
|
t.Errorf("missing gauge = %v, want 1", got)
|
|
}
|
|
wantErrs := 0.0
|
|
if tc.wantErr {
|
|
wantErrs = 1
|
|
}
|
|
if got := testutil.ToFloat64(defaultObjectsCheckErrors.WithLabelValues("cozy-default")); got != wantErrs {
|
|
t.Errorf("check errors = %v, want %v", got, wantErrs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCheckCountsOneErrorPerCheck pins that a check failing in two places is
|
|
// still one check that did not reach a conclusion.
|
|
func TestCheckCountsOneErrorPerCheck(t *testing.T) {
|
|
defaultObjectsCheckErrors.Reset()
|
|
bad := cosiSecret("")
|
|
bad.Data["BucketInfo"] = []byte("{not json")
|
|
g, _ := newGate(t, []client.Object{sourceSecret("bucket-1a2b"), bad}, helmReleaseObject(), credentialsHelmReleaseObject())
|
|
|
|
if _, _, err := g.Check(context.Background()); err == nil {
|
|
t.Fatal("Check succeeded with a malformed COSI Secret and no BackupClass")
|
|
}
|
|
if got := testutil.ToFloat64(defaultObjectsCheckErrors.WithLabelValues("cozy-default")); got != 1 {
|
|
t.Fatalf("check errors = %v, want 1", got)
|
|
}
|
|
}
|
|
|
|
// TestCheckSurfacesCredentialsPatchFailure pins that a failed force of the
|
|
// bucket release is reported, not recorded as done.
|
|
func TestCheckSurfacesCredentialsPatchFailure(t *testing.T) {
|
|
objs := append([]runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}, allDefaultObjects()...)
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret("bucket-1a2b"), cosiSecret("https://s3.new.example.com"), cozyDefaultBackupClass()},
|
|
objs...,
|
|
)
|
|
dyn.PrependReactor("patch", "helmreleases", func(k8stesting.Action) (bool, runtime.Object, error) {
|
|
return true, nil, apierrors.NewForbidden(schema.GroupResource{Group: "helm.toolkit.fluxcd.io", Resource: "helmreleases"}, "bucket-cozy-backups-system", nil)
|
|
})
|
|
|
|
if _, forced, err := g.Check(context.Background()); err == nil || forced {
|
|
t.Fatalf("forced = %v, err = %v; want an error and forced=false", forced, err)
|
|
}
|
|
if !g.lastCredentialsForce.IsZero() {
|
|
t.Error("lastCredentialsForce advanced despite the patch failing")
|
|
}
|
|
}
|
|
|
|
// TestCheckForcesStaleCredentialsWhenObjectsUnreadable pins that the
|
|
// credentials comparison needs only the two Secrets: a BackupClass or a routed
|
|
// object the gate cannot read must not keep the stale endpoint in the
|
|
// credentials Secret, which every other driver reads through the projector.
|
|
func TestCheckForcesStaleCredentialsWhenObjectsUnreadable(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
backupClass bool
|
|
forbidCNPG bool
|
|
}{
|
|
{"BackupClass absent", false, false},
|
|
{"routed Strategy unreadable", true, true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
defaultObjectsCheckErrors.Reset()
|
|
ctrlObjs := []client.Object{sourceSecret("bucket-1a2b"), cosiSecret("https://s3.new.example.com")}
|
|
if tc.backupClass {
|
|
ctrlObjs = append(ctrlObjs, cozyDefaultBackupClass())
|
|
}
|
|
objs := append([]runtime.Object{helmReleaseObject(), credentialsHelmReleaseObject()}, allDefaultObjects()...)
|
|
g, dyn := newGate(t, ctrlObjs, objs...)
|
|
if tc.forbidCNPG {
|
|
dyn.PrependReactor("get", "cnpgs", func(k8stesting.Action) (bool, runtime.Object, error) {
|
|
return true, nil, apierrors.NewForbidden(schema.GroupResource{Group: strategyAPIGroup, Resource: "cnpgs"}, "cozy-default-cnpg", nil)
|
|
})
|
|
}
|
|
|
|
missing, forced, err := g.Check(context.Background())
|
|
if err == nil {
|
|
t.Fatal("Check succeeded although the routed objects could not be evaluated")
|
|
}
|
|
if !forced {
|
|
t.Error("forced = false, want the bucket release forced for its stale credentials Secret")
|
|
}
|
|
if ann := credentialsForceAnnotations(t, dyn); ann["reconcile.fluxcd.io/forceAt"] == "" {
|
|
t.Errorf("bucket release not forced: %v", ann)
|
|
}
|
|
if ann := forceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("own HelmRelease forced without a conclusive object check: %v", ann)
|
|
}
|
|
if len(missing) != 1 || !strings.HasPrefix(missing[0], "Secret/bucket-cozy-backups-system-credentials") {
|
|
t.Errorf("missing = %v, want the credentials Secret", missing)
|
|
}
|
|
if got := testutil.ToFloat64(defaultObjectsCheckErrors.WithLabelValues("cozy-default")); got != 1 {
|
|
t.Errorf("check errors = %v, want 1", got)
|
|
}
|
|
})
|
|
}
|
|
}
|