mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 14:02:56 +09:00
The default Strategy CRs and the Velero BackupStorageLocation are
Helm-templated behind a `lookup` of the BucketClaim the same chart
creates, and the <bucket>-<user>-credentials Secret behind a `lookup` of
the COSI Secret. When those lookups are empty at install time the objects
were silently skipped, and helm-controller does not re-render a release
whose chart and values did not change (drift detection is off on
operator-generated HelmReleases), so the skip was permanent: clusters ran
for months with only BackupClass cozy-default and no Strategy CRs at all.
That also fail-closes the pre-adoption snapshot in the v1.6.0 etcd
migration, which reads the projected credentials Secret.
Add a DefaultObjectsGate runnable to backupstrategy-controller. Once the
bucket name is resolvable from the projector's source Secret, it checks
that every object cozy-default routes to exists and forces one real Helm
upgrade (reconcile.fluxcd.io/forceAt + requestedAt) when any is missing.
Helm remains the objects' only author; the gate only makes sure the render
that produces them actually happens. It cannot be a render-time `fail` in
this chart: the chart is the producer of the Bucket its own lookup reads,
so a failed render would deadlock the condition.
The bucket chart's user-credentials template CAN fail safely — the
BucketAccess that produces the COSI Secret belongs to the parent release
and that release retries forever — so make it fail loudly instead of
skipping, with requireUserCredentials=false as the offline-render escape.
Also correct the chart and doc comments that claimed Flux re-renders on
its interval, and document the two-release manual recovery for clusters
already affected.
Signed-off-by: Mattia Eleuteri <mattia@hidora.io>
(cherry picked from commit 51ad226706)
417 lines
14 KiB
Go
417 lines
14 KiB
Go
package backupcontroller
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
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"
|
|
|
|
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: "Etcd"},
|
|
{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
|
|
}
|
|
|
|
func apiGroup(s string) *string { return &s }
|
|
|
|
// 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: apiGroup("apps.cozystack.io"),
|
|
Kind: kind + "App",
|
|
},
|
|
StrategyRef: corev1.TypedLocalObjectReference{
|
|
APIGroup: apiGroup(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("Etcd", "cozy-default-etcd"),
|
|
ref("Velero", "cozy-default-velero-vminstance"),
|
|
ref("Velero", "cozy-default-velero-vmdisk"),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func strategyObject(kind, name string) *unstructured.Unstructured {
|
|
u := &unstructured.Unstructured{}
|
|
u.SetAPIVersion(strategyAPIGroup + "/v1alpha1")
|
|
u.SetKind(kind)
|
|
u.SetName(name)
|
|
return u
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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",
|
|
},
|
|
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)
|
|
return u
|
|
}
|
|
|
|
func forceAnnotations(t *testing.T, dyn *dynamicfake.FakeDynamicClient) map[string]string {
|
|
t.Helper()
|
|
hr, err := dyn.Resource(helmReleaseGVR).Namespace("cozy-backup-controller").
|
|
Get(context.Background(), "backupstrategy-controller", metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatalf("get HelmRelease: %v", 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")
|
|
}
|
|
// 4 strategyRefs (Velero twice under different names) + the BSL.
|
|
if len(missing) != 5 {
|
|
t.Fatalf("missing = %v, want 5 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"), cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
strategyObject("CNPG", "cozy-default-cnpg"),
|
|
strategyObject("Etcd", "cozy-default-etcd"),
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestCheckSkipsWhileBucketUnresolved pins the precondition. Forcing a
|
|
// re-render before the COSI driver has published a bucket name would just
|
|
// re-run the same empty lookup and burn a Helm upgrade, so the gate waits.
|
|
func TestCheckSkipsWhileBucketUnresolved(t *testing.T) {
|
|
g, dyn := newGate(t,
|
|
[]client.Object{sourceSecret(""), cozyDefaultBackupClass()},
|
|
helmReleaseObject(),
|
|
)
|
|
|
|
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 while the bucket is unresolved", missing, forced)
|
|
}
|
|
if ann := forceAnnotations(t, dyn); len(ann) != 0 {
|
|
t.Errorf("HelmRelease forced before the bucket resolved: %v", ann)
|
|
}
|
|
}
|
|
|
|
// 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: apiGroup(strategyAPIGroup),
|
|
Kind: "NotInstalled",
|
|
Name: "cozy-default-notinstalled",
|
|
},
|
|
})
|
|
g, _ := newGate(t, []client.Object{sourceSecret("b"), bc},
|
|
helmReleaseObject(),
|
|
strategyObject("CNPG", "cozy-default-cnpg"),
|
|
strategyObject("Etcd", "cozy-default-etcd"),
|
|
strategyObject("Velero", "cozy-default-velero-vminstance"),
|
|
strategyObject("Velero", "cozy-default-velero-vmdisk"),
|
|
bslObject(),
|
|
)
|
|
|
|
missing, err := g.missingObjects(context.Background(), bc)
|
|
if err != nil {
|
|
t.Fatalf("missingObjects: %v", err)
|
|
}
|
|
if len(missing) != 0 {
|
|
t.Fatalf("missing = %v, want none (unmapped kind must be ignored)", missing)
|
|
}
|
|
}
|
|
|
|
// TestMissingObjectsSkipsBSLWhenVeleroNamespaceEmpty covers
|
|
// velero.bslEnabled=false: the chart renders no BSL, so its absence is not
|
|
// a defect and must not drive forced upgrades.
|
|
func TestMissingObjectsSkipsBSLWhenVeleroNamespaceEmpty(t *testing.T) {
|
|
bc := cozyDefaultBackupClass()
|
|
g, _ := newGate(t, []client.Object{sourceSecret("b"), bc},
|
|
helmReleaseObject(),
|
|
strategyObject("CNPG", "cozy-default-cnpg"),
|
|
strategyObject("Etcd", "cozy-default-etcd"),
|
|
strategyObject("Velero", "cozy-default-velero-vminstance"),
|
|
strategyObject("Velero", "cozy-default-velero-vmdisk"),
|
|
)
|
|
g.VeleroNamespace = ""
|
|
|
|
missing, err := g.missingObjects(context.Background(), bc)
|
|
if err != nil {
|
|
t.Fatalf("missingObjects: %v", err)
|
|
}
|
|
if len(missing) != 0 {
|
|
t.Fatalf("missing = %v, want none when the BSL is disabled", 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")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|