Skip to content

Commit e970625

Browse files
committed
OCPBUGS-123707: prevent ClusterCatalog catalog rollbacks
Record a positive catalog publication version from the OCI image config label alongside cached catalog content and in ClusterCatalog status. Reject a newly resolved digest when the currently accepted catalog is versioned and the new catalog has no version or a non-increasing version. Preserve the last accepted version when content is unavailable so subsequent reconciles continue to reject the rollback. Add controller, cache, and puller coverage for version parsing, metadata persistence and garbage collection, and rollback rejection. Validation: make test-unit Signed-off-by: Todd Short <tshort@redhat.com>
1 parent 18dfd75 commit e970625

17 files changed

Lines changed: 542 additions & 21 deletions

File tree

‎api/v1/clustercatalog_types.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,13 @@ type ResolvedImageSource struct {
265265
// +kubebuilder:validation:XValidation:rule="self.find('(@.*:)') != \"\" ? self.find(':.*$').substring(1).size() >= 32 : true",message="digest is not valid. the encoded string must be at least 32 characters"
266266
// +kubebuilder:validation:XValidation:rule="self.find('(@.*:)') != \"\" ? self.find(':.*$').matches(':[0-9A-Fa-f]*$') : true",message="digest is not valid. the encoded string must only contain hex characters (A-F, a-f, 0-9)"
267267
Ref string `json:"ref"`
268+
// catalogVersion is the monotonic version assigned to the catalog publication by the image publisher.
269+
// It is read from the OCI image config label "olm.operatorframework.io/catalog-version".
270+
// Older images without this label have no version. After a version is recorded, a different digest must have a
271+
// greater version before its content can replace the currently served catalog.
272+
// +kubebuilder:validation:Minimum:=1
273+
// +optional
274+
CatalogVersion int64 `json:"catalogVersion,omitempty"`
268275
}
269276

270277
// ImageSource enables users to define the information required for sourcing a Catalog from an OCI image

‎applyconfigurations/api/v1/resolvedimagesource.go‎

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎applyconfigurations/internal/internal.go‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/api-reference/olmv1-api-reference.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,7 @@ _Appears in:_
545545
| Field | Description | Default | Validation |
546546
| --- | --- | --- | --- |
547547
| `ref` _string_ | ref contains the resolved image digest-based reference.<br />The digest format allows you to use other tooling to fetch the exact OCI manifests<br />that were used to extract the catalog contents. | | MaxLength: 1000 <br />Required: \{\} <br /> |
548+
| `catalogVersion` _integer_ | catalogVersion is the monotonic version assigned to the catalog publication by the image publisher.<br />It is read from the OCI image config label "olm.operatorframework.io/catalog-version".<br />Older images without this label have no version. After a version is recorded, a different digest must have a<br />greater version before its content can replace the currently served catalog. | | Minimum: 1 <br />Optional: \{\} <br /> |
548549

549550

550551
#### RevisionStatus

‎helm/olmv1/base/catalogd/crd/experimental/olm.operatorframework.io_clustercatalogs.yaml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
33
kind: CustomResourceDefinition
44
metadata:
55
annotations:
6-
controller-gen.kubebuilder.io/version: v0.20.1
6+
controller-gen.kubebuilder.io/version: v0.21.0
77
olm.operatorframework.io/generator: experimental
88
name: clustercatalogs.olm.operatorframework.io
99
spec:
@@ -332,6 +332,15 @@ spec:
332332
image contains resolution information for a catalog sourced from an image.
333333
It must be set when type is Image, and forbidden otherwise.
334334
properties:
335+
catalogVersion:
336+
description: |-
337+
catalogVersion is the monotonic version assigned to the catalog publication by the image publisher.
338+
It is read from the OCI image config label "olm.operatorframework.io/catalog-version".
339+
Older images without this label have no version. After a version is recorded, a different digest must have a
340+
greater version before its content can replace the currently served catalog.
341+
format: int64
342+
minimum: 1
343+
type: integer
335344
ref:
336345
description: |-
337346
ref contains the resolved image digest-based reference.

‎helm/olmv1/base/catalogd/crd/standard/olm.operatorframework.io_clustercatalogs.yaml‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1
33
kind: CustomResourceDefinition
44
metadata:
55
annotations:
6-
controller-gen.kubebuilder.io/version: v0.20.1
6+
controller-gen.kubebuilder.io/version: v0.21.0
77
olm.operatorframework.io/generator: standard
88
name: clustercatalogs.olm.operatorframework.io
99
spec:
@@ -332,6 +332,15 @@ spec:
332332
image contains resolution information for a catalog sourced from an image.
333333
It must be set when type is Image, and forbidden otherwise.
334334
properties:
335+
catalogVersion:
336+
description: |-
337+
catalogVersion is the monotonic version assigned to the catalog publication by the image publisher.
338+
It is read from the OCI image config label "olm.operatorframework.io/catalog-version".
339+
Older images without this label have no version. After a version is recorded, a different digest must have a
340+
greater version before its content can replace the currently served catalog.
341+
format: int64
342+
minimum: 1
343+
type: integer
335344
ref:
336345
description: |-
337346
ref contains the resolved image digest-based reference.

‎internal/catalogd/controllers/core/clustercatalog_controller.go‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ type ClusterCatalogReconciler struct {
7373

7474
type storedCatalogData struct {
7575
ref reference.Canonical
76+
catalogVersion int64
7677
lastUnpack time.Time
7778
lastSuccessfulPoll time.Time
7879
observedGeneration int64
@@ -253,13 +254,61 @@ func (r *ClusterCatalogReconciler) reconcile(ctx context.Context, catalog *ocv1.
253254
return ctrl.Result{}, err
254255
}
255256

256-
fsys, canonicalRef, unpackTime, err := r.ImagePuller.Pull(ctx, catalog.Name, catalog.Spec.Source.Image.Ref, r.ImageCache)
257+
catalogPuller, ok := r.ImagePuller.(imageutil.CatalogPuller)
258+
if !ok {
259+
err := fmt.Errorf("catalog image puller does not provide publication metadata")
260+
updateStatusProgressing(&catalog.Status, catalog.GetGeneration(), err)
261+
return ctrl.Result{}, err
262+
}
263+
fsys, canonicalRef, unpackTime, catalogVersion, err := catalogPuller.PullCatalog(ctx, catalog.Name, catalog.Spec.Source.Image.Ref, r.ImageCache)
257264
if err != nil {
258265
unpackErr := fmt.Errorf("source catalog content: %w", err)
259266
updateStatusProgressing(&catalog.Status, catalog.GetGeneration(), unpackErr)
260267
return ctrl.Result{}, unpackErr
261268
}
262269

270+
currentImage := (*ocv1.ResolvedImageSource)(nil)
271+
if catalog.Status.ResolvedSource != nil {
272+
currentImage = catalog.Status.ResolvedSource.Image
273+
}
274+
sameDigest := false
275+
if currentImage != nil {
276+
currentRef, err := reference.Parse(currentImage.Ref)
277+
if err != nil {
278+
statusErr := fmt.Errorf("invalid currently resolved catalog image reference %q: %w", currentImage.Ref, err)
279+
updateStatusProgressing(&catalog.Status, catalog.GetGeneration(), statusErr)
280+
return ctrl.Result{}, statusErr
281+
}
282+
currentCanonicalRef, ok := currentRef.(reference.Canonical)
283+
if !ok {
284+
statusErr := fmt.Errorf("currently resolved catalog image reference %q is not digest-based", currentImage.Ref)
285+
updateStatusProgressing(&catalog.Status, catalog.GetGeneration(), statusErr)
286+
return ctrl.Result{}, statusErr
287+
}
288+
sameDigest = canonicalRef.Digest() == currentCanonicalRef.Digest()
289+
}
290+
if currentImage != nil &&
291+
!sameDigest &&
292+
currentImage.CatalogVersion > 0 &&
293+
catalogVersion <= currentImage.CatalogVersion {
294+
rollbackErr := fmt.Errorf(
295+
"rejecting catalog image %s with catalog version %d: currently served image %s has version %d; a different digest must have a greater catalog version",
296+
canonicalRef,
297+
catalogVersion,
298+
currentImage.Ref,
299+
currentImage.CatalogVersion,
300+
)
301+
updateStatusProgressing(&catalog.Status, catalog.GetGeneration(), rollbackErr)
302+
if !r.Storage.ContentExists(catalog.Name) {
303+
updateStatusNotServingWithResolvedSource(&catalog.Status, catalog.GetGeneration())
304+
}
305+
return ctrl.Result{}, rollbackErr
306+
}
307+
if sameDigest && currentImage.CatalogVersion > catalogVersion {
308+
// Preserve the accepted version for the same immutable image if older cache data lacks the metadata sidecar.
309+
catalogVersion = currentImage.CatalogVersion
310+
}
311+
263312
// TODO: We should check to see if the unpacked result has the same content
264313
// as the already unpacked content. If it does, we should skip this rest
265314
// of the unpacking steps.
@@ -271,12 +320,13 @@ func (r *ClusterCatalogReconciler) reconcile(ctx context.Context, catalog *ocv1.
271320
baseURL := r.Storage.BaseURL(catalog.Name)
272321

273322
updateStatusProgressing(&catalog.Status, catalog.GetGeneration(), nil)
274-
updateStatusServing(&catalog.Status, canonicalRef, unpackTime, baseURL, catalog.GetGeneration())
323+
updateStatusServing(&catalog.Status, canonicalRef, catalogVersion, unpackTime, baseURL, catalog.GetGeneration())
275324

276325
lastSuccessfulPoll := time.Now()
277326
r.storedCatalogsMu.Lock()
278327
r.storedCatalogs[catalog.Name] = storedCatalogData{
279328
ref: canonicalRef,
329+
catalogVersion: catalogVersion,
280330
lastUnpack: unpackTime,
281331
lastSuccessfulPoll: lastSuccessfulPoll,
282332
observedGeneration: catalog.GetGeneration(),
@@ -295,7 +345,7 @@ func (r *ClusterCatalogReconciler) getCurrentState(catalog *ocv1.ClusterCatalog)
295345
// Set expected status based on what we see in the stored catalog
296346
clearUnknownConditions(expectedStatus)
297347
if hasStoredCatalog && r.Storage.ContentExists(catalog.Name) {
298-
updateStatusServing(expectedStatus, storedCatalog.ref, storedCatalog.lastUnpack, r.Storage.BaseURL(catalog.Name), storedCatalog.observedGeneration)
348+
updateStatusServing(expectedStatus, storedCatalog.ref, storedCatalog.catalogVersion, storedCatalog.lastUnpack, r.Storage.BaseURL(catalog.Name), storedCatalog.observedGeneration)
299349
updateStatusProgressing(expectedStatus, storedCatalog.observedGeneration, nil)
300350
}
301351

@@ -348,11 +398,12 @@ func updateStatusProgressing(status *ocv1.ClusterCatalogStatus, generation int64
348398
meta.SetStatusCondition(&status.Conditions, progressingCond)
349399
}
350400

351-
func updateStatusServing(status *ocv1.ClusterCatalogStatus, ref reference.Canonical, modTime time.Time, baseURL string, generation int64) {
401+
func updateStatusServing(status *ocv1.ClusterCatalogStatus, ref reference.Canonical, catalogVersion int64, modTime time.Time, baseURL string, generation int64) {
352402
status.ResolvedSource = &ocv1.ResolvedCatalogSource{
353403
Type: ocv1.SourceTypeImage,
354404
Image: &ocv1.ResolvedImageSource{
355-
Ref: ref.String(),
405+
Ref: ref.String(),
406+
CatalogVersion: catalogVersion,
356407
},
357408
}
358409
status.URLs = &ocv1.ClusterCatalogURLs{
@@ -398,6 +449,13 @@ func updateStatusProgressingUserSpecifiedUnavailable(status *ocv1.ClusterCatalog
398449

399450
func updateStatusNotServing(status *ocv1.ClusterCatalogStatus, generation int64) {
400451
status.ResolvedSource = nil
452+
updateStatusNotServingWithResolvedSource(status, generation)
453+
}
454+
455+
// updateStatusNotServingWithResolvedSource marks content unavailable while retaining the last accepted
456+
// source. In particular, this preserves a catalog publication version so a rejected rollback cannot
457+
// become acceptable on the next reconcile merely because served content is missing.
458+
func updateStatusNotServingWithResolvedSource(status *ocv1.ClusterCatalogStatus, generation int64) {
401459
status.URLs = nil
402460
status.LastUnpacked = nil
403461
meta.SetStatusCondition(&status.Conditions, metav1.Condition{

‎internal/catalogd/controllers/core/clustercatalog_controller_test.go‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,138 @@ func TestPollingReconcilerUnpack(t *testing.T) {
11321132
}
11331133
}
11341134

1135+
func TestCatalogVersionRollbackGuard(t *testing.T) {
1136+
oldRef := mustRef(t, "registry.example.com/catalog@sha256:a5d4f4467250074216eb1ba1c36e06a3ab797d81c431427fc2aca97ecaf4e9d8")
1137+
newRef := mustRef(t, "registry.example.com/catalog@sha256:f42337e7b85a46d83c94694638e2312e10ca16a03542399a65ba783c94a32b63")
1138+
sameDigestDifferentRepository := mustRef(t, "mirror.example.com/catalog@sha256:a5d4f4467250074216eb1ba1c36e06a3ab797d81c431427fc2aca97ecaf4e9d8")
1139+
1140+
newCatalog := func(current *ocv1.ResolvedImageSource) *ocv1.ClusterCatalog {
1141+
catalog := &ocv1.ClusterCatalog{
1142+
ObjectMeta: metav1.ObjectMeta{Name: "catalog", Finalizers: []string{fbcDeletionFinalizer}},
1143+
Spec: ocv1.ClusterCatalogSpec{Source: ocv1.CatalogSource{
1144+
Type: ocv1.SourceTypeImage,
1145+
Image: &ocv1.ImageSource{Ref: "registry.example.com/catalog:latest"},
1146+
}},
1147+
}
1148+
if current != nil {
1149+
catalog.Status.ResolvedSource = &ocv1.ResolvedCatalogSource{Type: ocv1.SourceTypeImage, Image: current}
1150+
}
1151+
return catalog
1152+
}
1153+
1154+
for name, tc := range map[string]struct {
1155+
current *ocv1.ResolvedImageSource
1156+
pulledRef reference.Canonical
1157+
pulledVersion int64
1158+
contentExists bool
1159+
wantErr string
1160+
wantRef reference.Canonical
1161+
wantVersion int64
1162+
wantNotServing bool
1163+
secondAttempt bool
1164+
}{
1165+
"accepts first versioned catalog": {
1166+
pulledRef: oldRef,
1167+
pulledVersion: 2,
1168+
contentExists: true,
1169+
wantRef: oldRef,
1170+
wantVersion: 2,
1171+
},
1172+
"accepts higher version for a different digest": {
1173+
current: &ocv1.ResolvedImageSource{Ref: oldRef.String(), CatalogVersion: 2},
1174+
pulledRef: newRef,
1175+
pulledVersion: 3,
1176+
contentExists: true,
1177+
wantRef: newRef,
1178+
wantVersion: 3,
1179+
},
1180+
"rejects equal version for a different digest": {
1181+
current: &ocv1.ResolvedImageSource{Ref: oldRef.String(), CatalogVersion: 2},
1182+
pulledRef: newRef,
1183+
pulledVersion: 2,
1184+
contentExists: true,
1185+
wantErr: "different digest must have a greater catalog version",
1186+
wantRef: oldRef,
1187+
wantVersion: 2,
1188+
},
1189+
"rejects lower version for a different digest": {
1190+
current: &ocv1.ResolvedImageSource{Ref: oldRef.String(), CatalogVersion: 2},
1191+
pulledRef: newRef,
1192+
pulledVersion: 1,
1193+
contentExists: true,
1194+
wantErr: "different digest must have a greater catalog version",
1195+
wantRef: oldRef,
1196+
wantVersion: 2,
1197+
},
1198+
"rejects unversioned catalog after a versioned catalog": {
1199+
current: &ocv1.ResolvedImageSource{Ref: oldRef.String(), CatalogVersion: 2},
1200+
pulledRef: newRef,
1201+
contentExists: true,
1202+
wantErr: "catalog version 0",
1203+
wantRef: oldRef,
1204+
wantVersion: 2,
1205+
},
1206+
"preserves version for the same digest from a different repository": {
1207+
current: &ocv1.ResolvedImageSource{Ref: oldRef.String(), CatalogVersion: 2},
1208+
pulledRef: sameDigestDifferentRepository,
1209+
contentExists: true,
1210+
wantRef: sameDigestDifferentRepository,
1211+
wantVersion: 2,
1212+
},
1213+
"rejected catalog remains rejected when content is absent": {
1214+
current: &ocv1.ResolvedImageSource{Ref: oldRef.String(), CatalogVersion: 2},
1215+
pulledRef: newRef,
1216+
pulledVersion: 1,
1217+
contentExists: false,
1218+
wantErr: "different digest must have a greater catalog version",
1219+
wantRef: oldRef,
1220+
wantVersion: 2,
1221+
wantNotServing: true,
1222+
secondAttempt: true,
1223+
},
1224+
} {
1225+
t.Run(name, func(t *testing.T) {
1226+
mockCtrl := gomock.NewController(t)
1227+
store := mockstorage.NewMockInstance(mockCtrl)
1228+
store.EXPECT().ContentExists("catalog").Return(tc.contentExists).AnyTimes()
1229+
store.EXPECT().BaseURL("catalog").Return("URL").AnyTimes()
1230+
if tc.wantErr == "" {
1231+
store.EXPECT().Store(gomock.Any(), "catalog", gomock.Any()).Return(nil)
1232+
}
1233+
reconciler := &ClusterCatalogReconciler{
1234+
ImagePuller: &imageutil.FakePuller{ImageFS: fstest.MapFS{}, Ref: tc.pulledRef, CatalogVersion: tc.pulledVersion},
1235+
ImageCache: &imageutil.FakeCache{},
1236+
Storage: store,
1237+
storedCatalogs: map[string]storedCatalogData{},
1238+
}
1239+
require.NoError(t, reconciler.setupFinalizers())
1240+
catalog := newCatalog(tc.current)
1241+
1242+
_, err := reconciler.reconcile(context.Background(), catalog)
1243+
if tc.wantErr == "" {
1244+
require.NoError(t, err)
1245+
} else {
1246+
require.ErrorContains(t, err, tc.wantErr)
1247+
}
1248+
require.NotNil(t, catalog.Status.ResolvedSource)
1249+
require.NotNil(t, catalog.Status.ResolvedSource.Image)
1250+
assert.Equal(t, tc.wantRef.String(), catalog.Status.ResolvedSource.Image.Ref)
1251+
assert.Equal(t, tc.wantVersion, catalog.Status.ResolvedSource.Image.CatalogVersion)
1252+
if tc.wantNotServing {
1253+
assert.Nil(t, catalog.Status.URLs)
1254+
assert.Nil(t, catalog.Status.LastUnpacked)
1255+
assert.Equal(t, metav1.ConditionFalse, meta.FindStatusCondition(catalog.Status.Conditions, ocv1.TypeServing).Status)
1256+
}
1257+
if tc.secondAttempt {
1258+
_, err = reconciler.reconcile(context.Background(), catalog)
1259+
require.ErrorContains(t, err, tc.wantErr)
1260+
assert.Equal(t, tc.wantRef.String(), catalog.Status.ResolvedSource.Image.Ref)
1261+
assert.Equal(t, tc.wantVersion, catalog.Status.ResolvedSource.Image.CatalogVersion)
1262+
}
1263+
})
1264+
}
1265+
}
1266+
11351267
func mustRef(t *testing.T, ref string) reference.Canonical {
11361268
t.Helper()
11371269
p, err := reference.Parse(ref)

0 commit comments

Comments
 (0)