diff --git a/cmd/operator/main.go b/cmd/operator/main.go index 7d9e0f0..becd92d 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -73,6 +73,13 @@ func main() { os.Exit(1) } + // Advisory only: warn (never exit) if the installed CRDs are missing or + // predate this operator's schema, which otherwise surfaces only as opaque + // strict-decode failures during reconcile. + crdCtx, crdCancel := context.WithTimeout(context.Background(), 15*time.Second) + controller.CheckCRDVersions(crdCtx, mgr.GetConfig()) + crdCancel() + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { logger.Error(err, "unable to set up health check") os.Exit(1) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index e705659..730177f 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -16,6 +16,13 @@ rules: - patch - update - watch +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list - apiGroups: - ceph.unkin.net resources: diff --git a/go.mod b/go.mod index 0a7e64d..ae763cc 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/smithy-go v1.27.4 github.com/ceph/go-ceph v0.40.0 k8s.io/api v0.34.4 + k8s.io/apiextensions-apiserver v0.34.1 k8s.io/apimachinery v0.34.4 k8s.io/client-go v0.34.4 sigs.k8s.io/controller-runtime v0.22.4 @@ -70,7 +71,6 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect diff --git a/internal/controller/crdcheck.go b/internal/controller/crdcheck.go new file mode 100644 index 0000000..6f6d689 --- /dev/null +++ b/internal/controller/crdcheck.go @@ -0,0 +1,89 @@ +package controller + +import ( + "context" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" +) + +// The CRD version check reads CustomResourceDefinitions to compare the +// installed schema against what this operator expects. +// +// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list + +// crdSentinel names a CRD and a spec property that only exists in the schema +// version shipped alongside this operator build. If the installed CRD lacks the +// sentinel, its schema predates this operator and strict decoding of new spec +// fields will silently fail. Keep this list in one place so new fields are easy +// to register as sentinels. +type crdSentinel struct { + // crd is the metadata.name of the CustomResourceDefinition. + crd string + // specProperty is a key expected under + // .spec.versions[].schema.openAPIV3Schema.properties.spec.properties. + specProperty string +} + +// crdSentinels is the authoritative list checked at startup. Extend it whenever +// a new spec field is added that older CRDs would reject. +var crdSentinels = []crdSentinel{ + {crd: "buckets.ceph.unkin.net", specProperty: "managePolicy"}, + {crd: "objectstoreusers.ceph.unkin.net", specProperty: "retainOnDelete"}, + {crd: "bucketaccesses.ceph.unkin.net", specProperty: "rawStatements"}, +} + +// CheckCRDVersions verifies that every CRD this operator owns is installed and +// carries the schema fields this build expects. It is advisory only: it logs a +// distinct WARNING per problem and never returns an error or exits, so a stale +// or missing CRD cannot block startup. +func CheckCRDVersions(ctx context.Context, cfg *rest.Config) { + log := ctrl.Log.WithName("crd-version-check") + + client, err := apiextensionsclient.NewForConfig(cfg) + if err != nil { + log.Error(err, "unable to build apiextensions client; skipping CRD version check") + return + } + + for _, s := range crdSentinels { + crd, err := client.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, s.crd, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + log.Info("WARNING: CRD is not installed — apply the CRDs matching this operator version", + "crd", s.crd) + continue + } + if err != nil { + log.Error(err, "unable to read CRD; cannot verify it matches this operator version", + "crd", s.crd) + continue + } + if !crdHasSpecProperty(crd, s.specProperty) { + log.Info("WARNING: CRD is out of date — apply the CRDs matching this operator version", + "crd", s.crd, "missingField", "spec."+s.specProperty) + } + } +} + +// crdHasSpecProperty reports whether any served/stored version of the CRD +// declares the given property under spec. +func crdHasSpecProperty(crd *apiextensionsv1.CustomResourceDefinition, property string) bool { + for _, v := range crd.Spec.Versions { + schema := v.Schema + if schema == nil || schema.OpenAPIV3Schema == nil { + continue + } + specSchema, ok := schema.OpenAPIV3Schema.Properties["spec"] + if !ok { + continue + } + if _, ok := specSchema.Properties[property]; ok { + return true + } + } + return false +}