diff --git a/api/v1/clusterobjectdeployment_types.go b/api/v1/clusterobjectdeployment_types.go
index 64bfb4df6b..8d242ba50a 100644
--- a/api/v1/clusterobjectdeployment_types.go
+++ b/api/v1/clusterobjectdeployment_types.go
@@ -32,6 +32,7 @@ const (
ClusterObjectDeploymentReasonNewClusterObjectSetProgressed = "NewClusterObjectSetProgressed"
ClusterObjectDeploymentReasonNewClusterObjectSetProgressing = "NewClusterObjectSetProgressing"
ClusterObjectDeploymentReasonProgressDeadlineExceeded = ReasonProgressDeadlineExceeded
+ ClusterObjectDeploymentReasonInvalidSpecification = "InvalidSpecification"
// Available condition reasons.
ClusterObjectDeploymentReasonAvailable = "Available"
@@ -65,12 +66,37 @@ type ClusterObjectDeploymentSpec struct {
// ClusterObjectDeploymentTemplate describes the caller-controlled part of a revision.
type ClusterObjectDeploymentTemplate struct {
+ // metadata contains labels and annotations propagated to each ClusterObjectSet
+ // created from this template, except the reserved
+ // olm.operatorframework.io/template-hash label, whose value is always set by the
+ // controller. Changes to metadata change template identity and therefore create
+ // a new revision.
+ // +optional
+ Metadata ClusterObjectDeploymentTemplateMetadata `json:"metadata,omitzero"`
+
// spec contains the desired revision content. Group, revision, lifecycle state,
// and ownership are assigned by the controller and cannot be set here.
// +required
Spec ClusterObjectDeploymentTemplateSpec `json:"spec,omitzero"`
}
+// ClusterObjectDeploymentTemplateMetadata contains labels and annotations
+// propagated to each ClusterObjectSet revision created from the template.
+type ClusterObjectDeploymentTemplateMetadata struct {
+ // labels is a set of key/value pairs propagated to each revision's metadata.
+ // +optional
+ // +kubebuilder:validation:MaxProperties=32
+ Labels map[string]string `json:"labels,omitempty"`
+
+ // annotations is a set of key/value pairs propagated to each revision's
+ // metadata. The controller validates them against the Kubernetes annotation
+ // rules (key format and total size) before creating a revision; invalid
+ // annotations are reported on the deployment's Progressing condition.
+ // +optional
+ // +kubebuilder:validation:MaxProperties=32
+ Annotations map[string]string `json:"annotations,omitempty"`
+}
+
// ClusterObjectDeploymentTemplateSpec contains mutable ClusterObjectSet content.
type ClusterObjectDeploymentTemplateSpec struct {
// phases is an ordered list of 1 to 20 uniquely named phases. Each phase contains
@@ -119,6 +145,9 @@ type ClusterObjectDeploymentStatus struct {
// completion. Completion remains recorded even if the completed revision later
// becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when
// the latest revision does not roll out within its spec.progressDeadlineMinutes.
+ // Progressing is False with reason InvalidSpecification when spec.template.metadata
+ // contains labels or annotations that are not valid for the resulting
+ // ClusterObjectSet; no new revision is created until the metadata is corrected.
//
// Available is True with reason Available when at least one active (non-archived)
// ClusterObjectSet revision is Ready, so the deployment remains available across
diff --git a/api/v1/clusterobjectdeployment_types_test.go b/api/v1/clusterobjectdeployment_types_test.go
index e08a6ecc2d..da4e06c02b 100644
--- a/api/v1/clusterobjectdeployment_types_test.go
+++ b/api/v1/clusterobjectdeployment_types_test.go
@@ -283,14 +283,10 @@ func TestClusterObjectDeploymentDeferredFieldsArePruned(t *testing.T) {
raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cod)
require.NoError(t, err)
require.NoError(t, unstructured.SetNestedField(raw, int64(10), "spec", "progressDeadlineMinutes"))
- require.NoError(t, unstructured.SetNestedField(raw, map[string]interface{}{
- "labels": map[string]interface{}{"app": "example"},
- "annotations": map[string]interface{}{"note": "deferred"},
- }, "spec", "template", "metadata"))
obj := &unstructured.Unstructured{Object: raw}
require.NoError(t, c.Create(t.Context(), obj))
require.NoError(t, c.Get(t.Context(), client.ObjectKeyFromObject(obj), obj))
- for _, path := range [][]string{{"spec", "progressDeadlineMinutes"}, {"spec", "template", "metadata"}} {
+ for _, path := range [][]string{{"spec", "progressDeadlineMinutes"}} {
_, found, err := unstructured.NestedFieldNoCopy(obj.Object, path...)
require.NoError(t, err)
require.False(t, found, "deployment must not expose %s", strings.Join(path, "."))
@@ -311,6 +307,30 @@ func TestClusterObjectDeploymentDeferredFieldsArePruned(t *testing.T) {
}
}
+func TestClusterObjectDeploymentTemplateMetadataIsRetained(t *testing.T) {
+ c := newClient(t)
+ cod := newTestClusterObjectDeployment("cod-template-metadata")
+ raw, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cod)
+ require.NoError(t, err)
+ require.NoError(t, unstructured.SetNestedField(raw, map[string]interface{}{
+ "labels": map[string]interface{}{"team": "platform"},
+ "annotations": map[string]interface{}{"note": "hello"},
+ }, "spec", "template", "metadata"))
+ obj := &unstructured.Unstructured{Object: raw}
+ require.NoError(t, c.Create(t.Context(), obj))
+ require.NoError(t, c.Get(t.Context(), client.ObjectKeyFromObject(obj), obj))
+
+ label, found, err := unstructured.NestedString(obj.Object, "spec", "template", "metadata", "labels", "team")
+ require.NoError(t, err)
+ require.True(t, found, "template must expose metadata.labels")
+ require.Equal(t, "platform", label)
+
+ annotation, found, err := unstructured.NestedString(obj.Object, "spec", "template", "metadata", "annotations", "note")
+ require.NoError(t, err)
+ require.True(t, found, "template must expose metadata.annotations")
+ require.Equal(t, "hello", annotation)
+}
+
func getTestClusterObjectDeployment(t *testing.T, c client.Client, name string) *ClusterObjectDeployment {
t.Helper()
cod := &ClusterObjectDeployment{}
diff --git a/api/v1/validation_test.go b/api/v1/validation_test.go
index cb9386c790..5450679abc 100644
--- a/api/v1/validation_test.go
+++ b/api/v1/validation_test.go
@@ -20,6 +20,9 @@ func TestValidate(t *testing.T) {
}
type want struct {
valid bool
+ // errContains is the substring expected in the invalid-create error.
+ // Required for every case with valid: false.
+ errContains string
}
type testCase struct {
args args
@@ -42,6 +45,13 @@ func TestValidate(t *testing.T) {
}
return s
}
+ manyEntries := func(n int) map[string]string {
+ m := make(map[string]string, n)
+ for j := 0; j < n; j++ {
+ m[fmt.Sprintf("k%d", j)] = "v"
+ }
+ return m
+ }
c := newClient(t)
i := 0
@@ -52,7 +62,7 @@ func TestValidate(t *testing.T) {
ProgressDeadlineMinutes: 9,
},
},
- want: want{valid: false},
+ want: want{valid: false, errContains: "spec.progressDeadlineMinutes"},
},
"ClusterExtension: valid progress deadline = 10": {
args: args{
@@ -84,7 +94,7 @@ func TestValidate(t *testing.T) {
ProgressDeadlineMinutes: 721,
},
},
- want: want{valid: false},
+ want: want{valid: false, errContains: "spec.progressDeadlineMinutes"},
},
"ClusterExtension: no progress deadline set": {
args: args{
@@ -102,7 +112,7 @@ func TestValidate(t *testing.T) {
ProgressDeadlineMinutes: 9,
},
},
- want: want{valid: false},
+ want: want{valid: false, errContains: "spec.progressDeadlineMinutes"},
},
"ClusterObjectSet: valid progress deadline = 10": {
args: args{
@@ -150,7 +160,7 @@ func TestValidate(t *testing.T) {
ProgressDeadlineMinutes: 721,
},
},
- want: want{valid: false},
+ want: want{valid: false, errContains: "spec.progressDeadlineMinutes"},
},
"ClusterObjectSet: no progress deadline set": {
args: args{
@@ -171,7 +181,7 @@ func TestValidate(t *testing.T) {
},
},
},
- want: want{valid: false},
+ want: want{valid: false, errContains: "spec.progressDeadlineMinutes"},
},
"ClusterObjectDeployment: valid progress deadline = 10": {
args: args{
@@ -201,7 +211,7 @@ func TestValidate(t *testing.T) {
},
},
},
- want: want{valid: false},
+ want: want{valid: false, errContains: "spec.progressDeadlineMinutes"},
},
"ClusterObjectDeployment: no progress deadline set": {
args: args{
@@ -209,6 +219,31 @@ func TestValidate(t *testing.T) {
},
want: want{valid: true},
},
+ "ClusterObjectDeployment: valid template metadata": {
+ args: args{
+ object: ClusterObjectDeploymentSpec{
+ Template: ClusterObjectDeploymentTemplate{
+ Metadata: ClusterObjectDeploymentTemplateMetadata{
+ Labels: map[string]string{"team": "platform"},
+ Annotations: map[string]string{"note": "hello"},
+ },
+ },
+ },
+ },
+ want: want{valid: true},
+ },
+ "ClusterObjectDeployment: too many template metadata labels": {
+ args: args{
+ object: ClusterObjectDeploymentSpec{
+ Template: ClusterObjectDeploymentTemplate{
+ Metadata: ClusterObjectDeploymentTemplateMetadata{
+ Labels: manyEntries(33),
+ },
+ },
+ },
+ },
+ want: want{valid: false, errContains: "spec.template.metadata.labels"},
+ },
} {
t.Run(name, func(t *testing.T) {
var obj client.Object
@@ -253,7 +288,8 @@ func TestValidate(t *testing.T) {
}
if !tc.want.valid {
require.True(t, errors.IsInvalid(err), "%v", err)
- require.ErrorContains(t, err, "spec.progressDeadlineMinutes")
+ require.NotEmpty(t, tc.want.errContains, "invalid cases must specify want.errContains")
+ require.ErrorContains(t, err, tc.want.errContains)
}
})
}
diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go
index 6d9a7ad6f6..82d05f7a49 100644
--- a/api/v1/zz_generated.deepcopy.go
+++ b/api/v1/zz_generated.deepcopy.go
@@ -521,6 +521,7 @@ func (in *ClusterObjectDeploymentStatus) DeepCopy() *ClusterObjectDeploymentStat
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ClusterObjectDeploymentTemplate) DeepCopyInto(out *ClusterObjectDeploymentTemplate) {
*out = *in
+ in.Metadata.DeepCopyInto(&out.Metadata)
in.Spec.DeepCopyInto(&out.Spec)
}
@@ -534,6 +535,35 @@ func (in *ClusterObjectDeploymentTemplate) DeepCopy() *ClusterObjectDeploymentTe
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ClusterObjectDeploymentTemplateMetadata) DeepCopyInto(out *ClusterObjectDeploymentTemplateMetadata) {
+ *out = *in
+ if in.Labels != nil {
+ in, out := &in.Labels, &out.Labels
+ *out = make(map[string]string, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val
+ }
+ }
+ if in.Annotations != nil {
+ in, out := &in.Annotations, &out.Annotations
+ *out = make(map[string]string, len(*in))
+ for key, val := range *in {
+ (*out)[key] = val
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterObjectDeploymentTemplateMetadata.
+func (in *ClusterObjectDeploymentTemplateMetadata) DeepCopy() *ClusterObjectDeploymentTemplateMetadata {
+ if in == nil {
+ return nil
+ }
+ out := new(ClusterObjectDeploymentTemplateMetadata)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ClusterObjectDeploymentTemplateSpec) DeepCopyInto(out *ClusterObjectDeploymentTemplateSpec) {
*out = *in
diff --git a/applyconfigurations/api/v1/clusterobjectdeploymentstatus.go b/applyconfigurations/api/v1/clusterobjectdeploymentstatus.go
index f1f70d8da7..effebcc2b5 100644
--- a/applyconfigurations/api/v1/clusterobjectdeploymentstatus.go
+++ b/applyconfigurations/api/v1/clusterobjectdeploymentstatus.go
@@ -34,6 +34,9 @@ type ClusterObjectDeploymentStatusApplyConfiguration struct {
// completion. Completion remains recorded even if the completed revision later
// becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when
// the latest revision does not roll out within its spec.progressDeadlineMinutes.
+ // Progressing is False with reason InvalidSpecification when spec.template.metadata
+ // contains labels or annotations that are not valid for the resulting
+ // ClusterObjectSet; no new revision is created until the metadata is corrected.
//
// Available is True with reason Available when at least one active (non-archived)
// ClusterObjectSet revision is Ready, so the deployment remains available across
diff --git a/applyconfigurations/api/v1/clusterobjectdeploymenttemplate.go b/applyconfigurations/api/v1/clusterobjectdeploymenttemplate.go
index c9dd55352c..964850afee 100644
--- a/applyconfigurations/api/v1/clusterobjectdeploymenttemplate.go
+++ b/applyconfigurations/api/v1/clusterobjectdeploymenttemplate.go
@@ -23,6 +23,12 @@ package v1
//
// ClusterObjectDeploymentTemplate describes the caller-controlled part of a revision.
type ClusterObjectDeploymentTemplateApplyConfiguration struct {
+ // metadata contains labels and annotations propagated to each ClusterObjectSet
+ // created from this template, except the reserved
+ // olm.operatorframework.io/template-hash label, whose value is always set by the
+ // controller. Changes to metadata change template identity and therefore create
+ // a new revision.
+ Metadata *ClusterObjectDeploymentTemplateMetadataApplyConfiguration `json:"metadata,omitempty"`
// spec contains the desired revision content. Group, revision, lifecycle state,
// and ownership are assigned by the controller and cannot be set here.
Spec *ClusterObjectDeploymentTemplateSpecApplyConfiguration `json:"spec,omitempty"`
@@ -34,6 +40,14 @@ func ClusterObjectDeploymentTemplate() *ClusterObjectDeploymentTemplateApplyConf
return &ClusterObjectDeploymentTemplateApplyConfiguration{}
}
+// WithMetadata sets the Metadata field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Metadata field is set to the value of the last call.
+func (b *ClusterObjectDeploymentTemplateApplyConfiguration) WithMetadata(value *ClusterObjectDeploymentTemplateMetadataApplyConfiguration) *ClusterObjectDeploymentTemplateApplyConfiguration {
+ b.Metadata = value
+ return b
+}
+
// WithSpec sets the Spec field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Spec field is set to the value of the last call.
diff --git a/applyconfigurations/api/v1/clusterobjectdeploymenttemplatemetadata.go b/applyconfigurations/api/v1/clusterobjectdeploymenttemplatemetadata.go
new file mode 100644
index 0000000000..bf3c712fd5
--- /dev/null
+++ b/applyconfigurations/api/v1/clusterobjectdeploymenttemplatemetadata.go
@@ -0,0 +1,68 @@
+/*
+Copyright 2026.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by controller-gen-v0.21. DO NOT EDIT.
+
+package v1
+
+// ClusterObjectDeploymentTemplateMetadataApplyConfiguration represents a declarative configuration of the ClusterObjectDeploymentTemplateMetadata type for use
+// with apply.
+//
+// ClusterObjectDeploymentTemplateMetadata contains labels and annotations
+// propagated to each ClusterObjectSet revision created from the template.
+type ClusterObjectDeploymentTemplateMetadataApplyConfiguration struct {
+ // labels is a set of key/value pairs propagated to each revision's metadata.
+ Labels map[string]string `json:"labels,omitempty"`
+ // annotations is a set of key/value pairs propagated to each revision's
+ // metadata. The controller validates them against the Kubernetes annotation
+ // rules (key format and total size) before creating a revision; invalid
+ // annotations are reported on the deployment's Progressing condition.
+ Annotations map[string]string `json:"annotations,omitempty"`
+}
+
+// ClusterObjectDeploymentTemplateMetadataApplyConfiguration constructs a declarative configuration of the ClusterObjectDeploymentTemplateMetadata type for use with
+// apply.
+func ClusterObjectDeploymentTemplateMetadata() *ClusterObjectDeploymentTemplateMetadataApplyConfiguration {
+ return &ClusterObjectDeploymentTemplateMetadataApplyConfiguration{}
+}
+
+// WithLabels puts the entries into the Labels field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, the entries provided by each call will be put on the Labels field,
+// overwriting an existing map entries in Labels field with the same key.
+func (b *ClusterObjectDeploymentTemplateMetadataApplyConfiguration) WithLabels(entries map[string]string) *ClusterObjectDeploymentTemplateMetadataApplyConfiguration {
+ if b.Labels == nil && len(entries) > 0 {
+ b.Labels = make(map[string]string, len(entries))
+ }
+ for k, v := range entries {
+ b.Labels[k] = v
+ }
+ return b
+}
+
+// WithAnnotations puts the entries into the Annotations field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, the entries provided by each call will be put on the Annotations field,
+// overwriting an existing map entries in Annotations field with the same key.
+func (b *ClusterObjectDeploymentTemplateMetadataApplyConfiguration) WithAnnotations(entries map[string]string) *ClusterObjectDeploymentTemplateMetadataApplyConfiguration {
+ if b.Annotations == nil && len(entries) > 0 {
+ b.Annotations = make(map[string]string, len(entries))
+ }
+ for k, v := range entries {
+ b.Annotations[k] = v
+ }
+ return b
+}
diff --git a/applyconfigurations/internal/internal.go b/applyconfigurations/internal/internal.go
index b82ad37e53..de613a38ac 100644
--- a/applyconfigurations/internal/internal.go
+++ b/applyconfigurations/internal/internal.go
@@ -283,9 +283,25 @@ var schemaYAML = typed.YAMLObject(`types:
- name: com.github.operator-framework.operator-controller.api.v1.ClusterObjectDeploymentTemplate
map:
fields:
+ - name: metadata
+ type:
+ namedType: com.github.operator-framework.operator-controller.api.v1.ClusterObjectDeploymentTemplateMetadata
- name: spec
type:
namedType: com.github.operator-framework.operator-controller.api.v1.ClusterObjectDeploymentTemplateSpec
+- name: com.github.operator-framework.operator-controller.api.v1.ClusterObjectDeploymentTemplateMetadata
+ map:
+ fields:
+ - name: annotations
+ type:
+ map:
+ elementType:
+ scalar: string
+ - name: labels
+ type:
+ map:
+ elementType:
+ scalar: string
- name: com.github.operator-framework.operator-controller.api.v1.ClusterObjectDeploymentTemplateSpec
map:
fields:
diff --git a/applyconfigurations/utils.go b/applyconfigurations/utils.go
index f5fb6b393d..7f3fc9f1d4 100644
--- a/applyconfigurations/utils.go
+++ b/applyconfigurations/utils.go
@@ -68,6 +68,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
return &apiv1.ClusterObjectDeploymentStatusApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("ClusterObjectDeploymentTemplate"):
return &apiv1.ClusterObjectDeploymentTemplateApplyConfiguration{}
+ case v1.SchemeGroupVersion.WithKind("ClusterObjectDeploymentTemplateMetadata"):
+ return &apiv1.ClusterObjectDeploymentTemplateMetadataApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("ClusterObjectDeploymentTemplateSpec"):
return &apiv1.ClusterObjectDeploymentTemplateSpecApplyConfiguration{}
case v1.SchemeGroupVersion.WithKind("ClusterObjectSet"):
diff --git a/docs/api-reference/olmv1-api-reference.md b/docs/api-reference/olmv1-api-reference.md
index 3f25668dff..8fa0349426 100644
--- a/docs/api-reference/olmv1-api-reference.md
+++ b/docs/api-reference/olmv1-api-reference.md
@@ -458,7 +458,7 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
-| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | conditions describes rollout progress and availability for the observed generation.
Progressing is True with reason NewClusterObjectSetProgressing while the desired
rollout is incomplete, or False with reason NewClusterObjectSetProgressed after
completion. Completion remains recorded even if the completed revision later
becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when
the latest revision does not roll out within its spec.progressDeadlineMinutes.
Available is True with reason Available when at least one active (non-archived)
ClusterObjectSet revision is Ready, so the deployment remains available across
rollouts while a previous revision is still Ready. It is False with reason
Unavailable when active revisions exist but none are Ready yet, or with reason
NoActiveRevision when no active revision exists. | | Optional: \{\}
|
+| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#condition-v1-meta) array_ | conditions describes rollout progress and availability for the observed generation.
Progressing is True with reason NewClusterObjectSetProgressing while the desired
rollout is incomplete, or False with reason NewClusterObjectSetProgressed after
completion. Completion remains recorded even if the completed revision later
becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when
the latest revision does not roll out within its spec.progressDeadlineMinutes.
Progressing is False with reason InvalidSpecification when spec.template.metadata
contains labels or annotations that are not valid for the resulting
ClusterObjectSet; no new revision is created until the metadata is corrected.
Available is True with reason Available when at least one active (non-archived)
ClusterObjectSet revision is Ready, so the deployment remains available across
rollouts while a previous revision is still Ready. It is False with reason
Unavailable when active revisions exist but none are Ready yet, or with reason
NoActiveRevision when no active revision exists. | | Optional: \{\}
|
#### ClusterObjectDeploymentTemplate
@@ -474,9 +474,28 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
+| `metadata` _[ClusterObjectDeploymentTemplateMetadata](#clusterobjectdeploymenttemplatemetadata)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | Optional: \{\}
|
| `spec` _[ClusterObjectDeploymentTemplateSpec](#clusterobjectdeploymenttemplatespec)_ | spec contains the desired revision content. Group, revision, lifecycle state,
and ownership are assigned by the controller and cannot be set here. | | Required: \{\}
|
+#### ClusterObjectDeploymentTemplateMetadata
+
+
+
+ClusterObjectDeploymentTemplateMetadata contains labels and annotations
+propagated to each ClusterObjectSet revision created from the template.
+
+
+
+_Appears in:_
+- [ClusterObjectDeploymentTemplate](#clusterobjectdeploymenttemplate)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `labels` _object (keys:string, values:string)_ | labels is a set of key/value pairs propagated to each revision's metadata. | | MaxProperties: 32
Optional: \{\}
|
+| `annotations` _object (keys:string, values:string)_ | annotations is a set of key/value pairs propagated to each revision's
metadata. The controller validates them against the Kubernetes annotation
rules (key format and total size) before creating a revision; invalid
annotations are reported on the deployment's Progressing condition. | | MaxProperties: 32
Optional: \{\}
|
+
+
#### ClusterObjectDeploymentTemplateSpec
diff --git a/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectdeployments.yaml b/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectdeployments.yaml
index 72fd7cab93..1012ca6122 100644
--- a/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectdeployments.yaml
+++ b/helm/olmv1/base/object-controller/crd/experimental/olm.operatorframework.io_clusterobjectdeployments.yaml
@@ -75,6 +75,32 @@ spec:
template is the desired content of newly created ClusterObjectSets.
Changes to template create a new revision; existing revisions remain immutable.
properties:
+ metadata:
+ description: |-
+ metadata contains labels and annotations propagated to each ClusterObjectSet
+ created from this template, except the reserved
+ olm.operatorframework.io/template-hash label, whose value is always set by the
+ controller. Changes to metadata change template identity and therefore create
+ a new revision.
+ properties:
+ annotations:
+ additionalProperties:
+ type: string
+ description: |-
+ annotations is a set of key/value pairs propagated to each revision's
+ metadata. The controller validates them against the Kubernetes annotation
+ rules (key format and total size) before creating a revision; invalid
+ annotations are reported on the deployment's Progressing condition.
+ maxProperties: 32
+ type: object
+ labels:
+ additionalProperties:
+ type: string
+ description: labels is a set of key/value pairs propagated
+ to each revision's metadata.
+ maxProperties: 32
+ type: object
+ type: object
spec:
description: |-
spec contains the desired revision content. Group, revision, lifecycle state,
@@ -520,6 +546,9 @@ spec:
completion. Completion remains recorded even if the completed revision later
becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when
the latest revision does not roll out within its spec.progressDeadlineMinutes.
+ Progressing is False with reason InvalidSpecification when spec.template.metadata
+ contains labels or annotations that are not valid for the resulting
+ ClusterObjectSet; no new revision is created until the metadata is corrected.
Available is True with reason Available when at least one active (non-archived)
ClusterObjectSet revision is Ready, so the deployment remains available across
diff --git a/internal/object-controller/controllers/clusterobjectdeployment_controller.go b/internal/object-controller/controllers/clusterobjectdeployment_controller.go
index 81e73d6d38..b5da2ac791 100644
--- a/internal/object-controller/controllers/clusterobjectdeployment_controller.go
+++ b/internal/object-controller/controllers/clusterobjectdeployment_controller.go
@@ -7,14 +7,19 @@ import (
"context"
"errors"
"fmt"
+ "maps"
"slices"
+ "unicode/utf8"
"k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
+ apivalidation "k8s.io/apimachinery/pkg/api/validation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ metav1validation "k8s.io/apimachinery/pkg/apis/meta/v1/validation"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/apimachinery/pkg/util/validation/field"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -102,6 +107,15 @@ func (c *ClusterObjectDeploymentReconciler) reconcileRollout(ctx context.Context
setAvailable(cod, revisions)
+ // The template metadata is propagated verbatim onto each revision's
+ // ClusterObjectSet. Invalid labels or annotations would make that revision
+ // rejected by the API server and stall the rollout with the real cause buried
+ // in logs, so validate here and surface it on the Progressing condition instead.
+ if msg := validateTemplateMetadata(cod.Spec.Template.Metadata); msg != "" {
+ setProgressing(cod, metav1.ConditionFalse, ocv1.ClusterObjectDeploymentReasonInvalidSpecification, msg)
+ return nil
+ }
+
latest := latestActiveRevision(revisions)
if latest == nil || latest.Labels[labels.TemplateHashKey] != hash {
if err := c.createRevision(ctx, cod, revisions, hash); err != nil {
@@ -262,12 +276,14 @@ func setProgressing(cod *ocv1.ClusterObjectDeployment, status metav1.ConditionSt
}
// setCondition sets a condition on the deployment status with the observed generation.
+// The message is capped (see maxConditionMessageBytes) so Status().Update is never
+// rejected for an oversized message, which would drop the condition entirely.
func setCondition(cod *ocv1.ClusterObjectDeployment, condType string, status metav1.ConditionStatus, reason, message string) {
apimeta.SetStatusCondition(&cod.Status.Conditions, metav1.Condition{
Type: condType,
Status: status,
Reason: reason,
- Message: message,
+ Message: capBytes(message, maxConditionMessageBytes),
ObservedGeneration: cod.Generation,
})
}
@@ -348,19 +364,86 @@ func latestActiveRevision(revisions []ocv1.ClusterObjectSet) *ocv1.ClusterObject
return latest
}
+// maxConditionMessageBytes caps condition messages written by this controller. The API
+// server rejects a condition message longer than 32768 bytes (metav1.Condition.message
+// carries +kubebuilder:validation:MaxLength=32768), which would drop the condition
+// entirely; 1024 bytes is ample for a human-readable diagnostic and stays well clear of
+// that limit.
+const maxConditionMessageBytes = 1024
+
+// maxBadValueBytes is the largest rejected label/annotation value echoed in a
+// diagnostic. Longer values are omitted so a single value cannot bloat the message.
+const maxBadValueBytes = 64
+
+// validateTemplateMetadata validates the labels and annotations the deployment
+// would propagate to each revision, using the same rules the API server applies
+// to object metadata (key/value format for labels, key format and total size for
+// annotations). It returns a user-facing message describing the problem, or an
+// empty string if the metadata is valid.
+func validateTemplateMetadata(md ocv1.ClusterObjectDeploymentTemplateMetadata) string {
+ base := field.NewPath("spec", "template", "metadata")
+ var errs field.ErrorList
+ errs = append(errs, metav1validation.ValidateLabels(md.Labels, base.Child("labels"))...)
+ errs = append(errs, apivalidation.ValidateAnnotations(md.Annotations, base.Child("annotations"))...)
+ if len(errs) == 0 {
+ return ""
+ }
+ // Field errors echo the rejected value, and a label value is not length-bounded by
+ // the template schema. Omit oversized values rather than echoing them; the aggregated
+ // message is additionally capped when written to a condition (see setCondition).
+ for _, err := range errs {
+ if v, ok := err.BadValue.(string); ok && len(v) > maxBadValueBytes {
+ err.BadValue = fmt.Sprintf("<%d bytes omitted>", len(v))
+ }
+ }
+ return fmt.Sprintf("template metadata is invalid: %v", errs.ToAggregate())
+}
+
+// capBytes truncates s to at most maxBytes bytes, appending a marker when it does so.
+func capBytes(s string, maxBytes int) string {
+ if len(s) <= maxBytes {
+ return s
+ }
+ const marker = "...[truncated]"
+ return truncateBytes(s, maxBytes-len(marker)) + marker
+}
+
+// truncateBytes returns the longest prefix of s that is at most maxBytes long without
+// splitting a multi-byte UTF-8 rune.
+func truncateBytes(s string, maxBytes int) string {
+ if maxBytes <= 0 {
+ return ""
+ }
+ if len(s) <= maxBytes {
+ return s
+ }
+ b := s[:maxBytes]
+ for len(b) > 0 && !utf8.ValidString(b) {
+ b = b[:len(b)-1]
+ }
+ return b
+}
+
// buildClusterObjectSet constructs the ClusterObjectSet for a revision from the
// deployment template. It does not set the owner reference; the caller does.
func buildClusterObjectSet(cod *ocv1.ClusterObjectDeployment, revision int64, hash string) *ocv1.ClusterObjectSet {
+ // Propagate the template's metadata labels and annotations verbatim to the
+ // revision. The controller-managed TemplateHashKey label is set last so it
+ // always wins over any caller-supplied value; it records which template produced
+ // this revision so the controller can detect template changes. Ownership is
+ // expressed through the controller owner reference and the revision's spec.group,
+ // so no owner-kind/owner-name labels are set.
+ revLabels := maps.Clone(cod.Spec.Template.Metadata.Labels)
+ if revLabels == nil {
+ revLabels = map[string]string{}
+ }
+ revLabels[labels.TemplateHashKey] = hash
+
return &ocv1.ClusterObjectSet{
ObjectMeta: metav1.ObjectMeta{
- Name: fmt.Sprintf("%s-%d", cod.Name, revision),
- Labels: map[string]string{
- // TemplateHashKey records which template produced this revision so the
- // controller can detect template changes. Ownership is expressed through
- // the controller owner reference and the revision's spec.group, so no
- // owner-kind/owner-name labels are set.
- labels.TemplateHashKey: hash,
- },
+ Name: fmt.Sprintf("%s-%d", cod.Name, revision),
+ Labels: revLabels,
+ Annotations: maps.Clone(cod.Spec.Template.Metadata.Annotations),
},
Spec: ocv1.ClusterObjectSetSpec{
Group: cod.Name,
diff --git a/internal/object-controller/controllers/clusterobjectdeployment_controller_internal_test.go b/internal/object-controller/controllers/clusterobjectdeployment_controller_internal_test.go
index a0c79797bf..a47a384b9f 100644
--- a/internal/object-controller/controllers/clusterobjectdeployment_controller_internal_test.go
+++ b/internal/object-controller/controllers/clusterobjectdeployment_controller_internal_test.go
@@ -3,10 +3,12 @@
package controllers
import (
+ "strings"
"testing"
"time"
"github.com/stretchr/testify/require"
+ apimeta "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/types"
@@ -118,6 +120,65 @@ func Test_buildClusterObjectSet(t *testing.T) {
require.Equal(t, ocv1.CollisionProtectionNone, cos.Spec.CollisionProtection, "template collisionProtection must be copied through")
}
+func Test_validateTemplateMetadata(t *testing.T) {
+ // A label value is not length-bounded by the template schema (only MaxProperties is
+ // enforced on labels), so an oversized value must be omitted from the diagnostic
+ // rather than echoed verbatim. Otherwise it bloats the message that is written to
+ // the Progressing condition.
+ oversizedValue := strings.Repeat("a", 40000)
+
+ for _, tc := range []struct {
+ name string
+ md ocv1.ClusterObjectDeploymentTemplateMetadata
+ wantDiag bool // whether a non-empty diagnostic is expected
+ omitsOversize bool // whether the oversized value must be omitted from the diagnostic
+ }{
+ {
+ name: "valid metadata returns no diagnostic",
+ md: ocv1.ClusterObjectDeploymentTemplateMetadata{
+ Labels: map[string]string{"team": "platform"},
+ Annotations: map[string]string{"note": "hello"},
+ },
+ wantDiag: false,
+ },
+ {
+ name: "invalid label key is reported",
+ md: ocv1.ClusterObjectDeploymentTemplateMetadata{Labels: map[string]string{"not a valid key": "v"}},
+ wantDiag: true,
+ },
+ {
+ name: "oversized label value is omitted",
+ md: ocv1.ClusterObjectDeploymentTemplateMetadata{Labels: map[string]string{"team": oversizedValue}},
+ wantDiag: true,
+ omitsOversize: true,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ msg := validateTemplateMetadata(tc.md)
+ if !tc.wantDiag {
+ require.Empty(t, msg)
+ return
+ }
+ require.NotEmpty(t, msg)
+ if tc.omitsOversize {
+ require.NotContains(t, msg, oversizedValue, "the oversized rejected value must not be echoed verbatim")
+ require.Contains(t, msg, "bytes omitted", "the diagnostic should note that the value was omitted")
+ }
+ })
+ }
+}
+
+func Test_setCondition_capsMessage(t *testing.T) {
+ // setCondition must bound the message so Status().Update is never rejected for an
+ // oversized condition message (the API server caps it at 32768 bytes).
+ cod := &ocv1.ClusterObjectDeployment{}
+ setCondition(cod, ocv1.ClusterObjectDeploymentTypeProgressing, metav1.ConditionFalse, "Reason", strings.Repeat("x", 5000))
+
+ cond := apimeta.FindStatusCondition(cod.Status.Conditions, ocv1.ClusterObjectDeploymentTypeProgressing)
+ require.NotNil(t, cond)
+ require.LessOrEqual(t, len(cond.Message), maxConditionMessageBytes)
+}
+
func Test_mapClusterObjectSetToClusterObjectDeployment(t *testing.T) {
for _, tc := range []struct {
name string
diff --git a/internal/object-controller/controllers/clusterobjectdeployment_controller_test.go b/internal/object-controller/controllers/clusterobjectdeployment_controller_test.go
index a6b6df7cef..a80fd2758f 100644
--- a/internal/object-controller/controllers/clusterobjectdeployment_controller_test.go
+++ b/internal/object-controller/controllers/clusterobjectdeployment_controller_test.go
@@ -4,8 +4,10 @@ package controllers_test
import (
"context"
+ "encoding/json"
"fmt"
"math"
+ "strings"
"testing"
"github.com/stretchr/testify/require"
@@ -247,6 +249,141 @@ func Test_ClusterObjectDeploymentReconciler_CreatesFirstRevision(t *testing.T) {
require.Equal(t, ocv1.ClusterObjectDeploymentReasonNewClusterObjectSetProgressing, cond.Reason)
}
+func Test_ClusterObjectDeploymentReconciler_PropagatesTemplateMetadata(t *testing.T) {
+ cod := codFixture()
+ cod.Spec.Template.Metadata = ocv1.ClusterObjectDeploymentTemplateMetadata{
+ Labels: map[string]string{"team": "platform"},
+ Annotations: map[string]string{"note": "hello"},
+ }
+ c := newCODClient(t, cod)
+
+ _, err := reconcileCOD(t, c)
+ require.NoError(t, err)
+
+ cos, ok := getCOS(t, c, clusterObjectDeploymentName+"-1")
+ require.True(t, ok, "revision 1 should be created")
+ require.Equal(t, "platform", cos.Labels["team"], "template.metadata.labels should propagate to the revision")
+ require.Equal(t, "hello", cos.Annotations["note"], "template.metadata.annotations should propagate to the revision")
+ require.NotEmpty(t, cos.Labels[labels.TemplateHashKey], "controller-managed template-hash label should still be set")
+}
+
+func Test_ClusterObjectDeploymentReconciler_NoTemplateMetadata(t *testing.T) {
+ // codFixture sets no template.metadata, so the revision must carry only the
+ // controller-managed template-hash label and no annotations.
+ c := newCODClient(t, codFixture())
+
+ _, err := reconcileCOD(t, c)
+ require.NoError(t, err)
+
+ cos, ok := getCOS(t, c, clusterObjectDeploymentName+"-1")
+ require.True(t, ok, "revision 1 should be created")
+ require.Len(t, cos.Labels, 1, "only the controller-managed template-hash label should be set")
+ require.NotEmpty(t, cos.Labels[labels.TemplateHashKey])
+ require.Empty(t, cos.Annotations, "no annotations should be set when template.metadata has none")
+}
+
+func Test_ClusterObjectDeploymentReconciler_InvalidLabelMetadataSetsInvalidSpecification(t *testing.T) {
+ cod := codFixture()
+ cod.Spec.Template.Metadata = ocv1.ClusterObjectDeploymentTemplateMetadata{
+ Labels: map[string]string{"not a valid key": "v"},
+ }
+ c := newCODClient(t, cod)
+
+ _, err := reconcileCOD(t, c)
+ require.NoError(t, err)
+
+ cond := progressing(t, c)
+ require.NotNil(t, cond)
+ require.Equal(t, metav1.ConditionFalse, cond.Status)
+ require.Equal(t, ocv1.ClusterObjectDeploymentReasonInvalidSpecification, cond.Reason)
+ require.NotEmpty(t, cond.Message)
+
+ _, ok := getCOS(t, c, clusterObjectDeploymentName+"-1")
+ require.False(t, ok, "no revision should be created while the template metadata is invalid")
+}
+
+func Test_ClusterObjectDeploymentReconciler_InvalidAnnotationMetadataSetsInvalidSpecification(t *testing.T) {
+ cod := codFixture()
+ cod.Spec.Template.Metadata = ocv1.ClusterObjectDeploymentTemplateMetadata{
+ Annotations: map[string]string{"not a valid key": "v"},
+ }
+ c := newCODClient(t, cod)
+
+ _, err := reconcileCOD(t, c)
+ require.NoError(t, err)
+
+ cond := progressing(t, c)
+ require.NotNil(t, cond)
+ require.Equal(t, metav1.ConditionFalse, cond.Status)
+ require.Equal(t, ocv1.ClusterObjectDeploymentReasonInvalidSpecification, cond.Reason)
+
+ _, ok := getCOS(t, c, clusterObjectDeploymentName+"-1")
+ require.False(t, ok, "no revision should be created while the template metadata is invalid")
+}
+
+func Test_ClusterObjectDeploymentReconciler_OversizedAnnotationsSetInvalidSpecification(t *testing.T) {
+ // Each value is individually under the 256 KiB limit, but their combined size
+ // (keys + values) exceeds the Kubernetes total annotation limit, which only the
+ // aggregate check catches.
+ big := strings.Repeat("a", 150*1024)
+ cod := codFixture()
+ cod.Spec.Template.Metadata = ocv1.ClusterObjectDeploymentTemplateMetadata{
+ Annotations: map[string]string{"a": big, "b": big},
+ }
+ c := newCODClient(t, cod)
+
+ _, err := reconcileCOD(t, c)
+ require.NoError(t, err)
+
+ cond := progressing(t, c)
+ require.NotNil(t, cond)
+ require.Equal(t, metav1.ConditionFalse, cond.Status)
+ require.Equal(t, ocv1.ClusterObjectDeploymentReasonInvalidSpecification, cond.Reason)
+
+ _, ok := getCOS(t, c, clusterObjectDeploymentName+"-1")
+ require.False(t, ok, "no revision should be created when annotations exceed the total size limit")
+}
+
+func Test_ClusterObjectDeploymentReconciler_TemplateHashLabelOverridesCaller(t *testing.T) {
+ cod := codFixture()
+ cod.Spec.Template.Metadata = ocv1.ClusterObjectDeploymentTemplateMetadata{
+ Labels: map[string]string{labels.TemplateHashKey: "caller-wins-NOT"},
+ }
+ c := newCODClient(t, cod)
+
+ _, err := reconcileCOD(t, c)
+ require.NoError(t, err)
+
+ cos, ok := getCOS(t, c, clusterObjectDeploymentName+"-1")
+ require.True(t, ok, "revision 1 should be created")
+ require.Equal(t, hashutil.DeepHashObject(cod.Spec.Template), cos.Labels[labels.TemplateHashKey],
+ "controller-managed template-hash must win over a caller-supplied value")
+ require.NotEqual(t, "caller-wins-NOT", cos.Labels[labels.TemplateHashKey])
+}
+
+func Test_TemplateHash_MetadataParticipates(t *testing.T) {
+ base := codFixture().Spec.Template
+
+ withLabelA := base
+ withLabelA.Metadata = ocv1.ClusterObjectDeploymentTemplateMetadata{Labels: map[string]string{"app": "a"}}
+ withLabelB := base
+ withLabelB.Metadata = ocv1.ClusterObjectDeploymentTemplateMetadata{Labels: map[string]string{"app": "b"}}
+ require.NotEqual(t, hashutil.DeepHashObject(withLabelA), hashutil.DeepHashObject(withLabelB),
+ "metadata must participate in the template hash")
+
+ // No-churn guarantee: a template that sets no metadata must serialize without a
+ // "metadata" key, so its hash is identical to the pre-field serialization and
+ // existing CODs are not re-rolled out on upgrade. This asserts the omitzero
+ // mechanism directly; it fails if omitzero is ever dropped from the field.
+ raw, err := json.Marshal(base)
+ require.NoError(t, err)
+ var top map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(raw, &top))
+ _, hasMetadata := top["metadata"]
+ require.False(t, hasMetadata,
+ "zero-value template metadata must be omitted (omitzero) so metadata-less CODs keep the same hash")
+}
+
func Test_ClusterObjectDeploymentReconciler_NoNewRevisionWhenTemplateUnchanged(t *testing.T) {
for _, tc := range []struct {
name string
diff --git a/manifests/experimental-e2e.yaml b/manifests/experimental-e2e.yaml
index 06a4464d2b..bd269df650 100644
--- a/manifests/experimental-e2e.yaml
+++ b/manifests/experimental-e2e.yaml
@@ -1404,6 +1404,32 @@ spec:
template is the desired content of newly created ClusterObjectSets.
Changes to template create a new revision; existing revisions remain immutable.
properties:
+ metadata:
+ description: |-
+ metadata contains labels and annotations propagated to each ClusterObjectSet
+ created from this template, except the reserved
+ olm.operatorframework.io/template-hash label, whose value is always set by the
+ controller. Changes to metadata change template identity and therefore create
+ a new revision.
+ properties:
+ annotations:
+ additionalProperties:
+ type: string
+ description: |-
+ annotations is a set of key/value pairs propagated to each revision's
+ metadata. The controller validates them against the Kubernetes annotation
+ rules (key format and total size) before creating a revision; invalid
+ annotations are reported on the deployment's Progressing condition.
+ maxProperties: 32
+ type: object
+ labels:
+ additionalProperties:
+ type: string
+ description: labels is a set of key/value pairs propagated
+ to each revision's metadata.
+ maxProperties: 32
+ type: object
+ type: object
spec:
description: |-
spec contains the desired revision content. Group, revision, lifecycle state,
@@ -1849,6 +1875,9 @@ spec:
completion. Completion remains recorded even if the completed revision later
becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when
the latest revision does not roll out within its spec.progressDeadlineMinutes.
+ Progressing is False with reason InvalidSpecification when spec.template.metadata
+ contains labels or annotations that are not valid for the resulting
+ ClusterObjectSet; no new revision is created until the metadata is corrected.
Available is True with reason Available when at least one active (non-archived)
ClusterObjectSet revision is Ready, so the deployment remains available across
diff --git a/manifests/experimental.yaml b/manifests/experimental.yaml
index 1bd3d1e382..5272f9d9a8 100644
--- a/manifests/experimental.yaml
+++ b/manifests/experimental.yaml
@@ -1365,6 +1365,32 @@ spec:
template is the desired content of newly created ClusterObjectSets.
Changes to template create a new revision; existing revisions remain immutable.
properties:
+ metadata:
+ description: |-
+ metadata contains labels and annotations propagated to each ClusterObjectSet
+ created from this template, except the reserved
+ olm.operatorframework.io/template-hash label, whose value is always set by the
+ controller. Changes to metadata change template identity and therefore create
+ a new revision.
+ properties:
+ annotations:
+ additionalProperties:
+ type: string
+ description: |-
+ annotations is a set of key/value pairs propagated to each revision's
+ metadata. The controller validates them against the Kubernetes annotation
+ rules (key format and total size) before creating a revision; invalid
+ annotations are reported on the deployment's Progressing condition.
+ maxProperties: 32
+ type: object
+ labels:
+ additionalProperties:
+ type: string
+ description: labels is a set of key/value pairs propagated
+ to each revision's metadata.
+ maxProperties: 32
+ type: object
+ type: object
spec:
description: |-
spec contains the desired revision content. Group, revision, lifecycle state,
@@ -1810,6 +1836,9 @@ spec:
completion. Completion remains recorded even if the completed revision later
becomes unhealthy. Progressing is False with reason ProgressDeadlineExceeded when
the latest revision does not roll out within its spec.progressDeadlineMinutes.
+ Progressing is False with reason InvalidSpecification when spec.template.metadata
+ contains labels or annotations that are not valid for the resulting
+ ClusterObjectSet; no new revision is created until the metadata is corrected.
Available is True with reason Available when at least one active (non-archived)
ClusterObjectSet revision is Ready, so the deployment remains available across
diff --git a/test/e2e/features/cod_template_metadata.feature b/test/e2e/features/cod_template_metadata.feature
new file mode 100644
index 0000000000..d274b9378c
--- /dev/null
+++ b/test/e2e/features/cod_template_metadata.feature
@@ -0,0 +1,78 @@
+@BoxcutterRuntime
+Feature: ClusterObjectDeployment template metadata propagation
+
+ As an OLM user applying a ClusterObjectDeployment, I want the labels and
+ annotations in spec.template.metadata to be propagated verbatim onto each
+ ClusterObjectSet revision the controller creates, and invalid metadata to be
+ reported on the deployment rather than silently stalling the rollout.
+
+ Background:
+ Given OLM is available
+
+ Scenario: Template metadata is propagated to the revision
+ Given namespace "${TEST_NAMESPACE}" is available
+ When resource is applied
+ """
+ apiVersion: olm.operatorframework.io/v1
+ kind: ClusterObjectDeployment
+ metadata:
+ name: ${NAME}
+ spec:
+ template:
+ metadata:
+ labels:
+ team: platform
+ annotations:
+ note: hello
+ spec:
+ collisionProtection: Prevent
+ phases:
+ - name: resources
+ objects:
+ - object:
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: cod-cm
+ namespace: ${TEST_NAMESPACE}
+ data:
+ k: v
+ """
+ Then ClusterObjectSet "${NAME}-1" has group "${NAME}"
+ And ClusterObjectSet "${NAME}-1" has label "team" with value "platform"
+ And ClusterObjectSet "${NAME}-1" contains annotation "note" with value
+ """
+ hello
+ """
+
+ Scenario: Invalid template metadata is reported as InvalidSpecification
+ Given namespace "${TEST_NAMESPACE}" is available
+ When resource is applied
+ """
+ apiVersion: olm.operatorframework.io/v1
+ kind: ClusterObjectDeployment
+ metadata:
+ name: ${NAME}
+ spec:
+ template:
+ metadata:
+ labels:
+ "not a valid key": value
+ spec:
+ collisionProtection: Prevent
+ phases:
+ - name: resources
+ objects:
+ - object:
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: cod-cm
+ namespace: ${TEST_NAMESPACE}
+ data:
+ k: v
+ """
+ Then ClusterObjectDeployment "${NAME}" reports Progressing as False with Reason InvalidSpecification and Message includes:
+ """
+ template metadata is invalid: spec.template.metadata.labels: Invalid value: "not a valid key": name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character
+ """
diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go
index 9c3073cb43..12d5dafa16 100644
--- a/test/e2e/steps/steps.go
+++ b/test/e2e/steps/steps.go
@@ -128,6 +128,7 @@ func RegisterSteps(sc *godog.ScenarioContext) {
sc.Step(`^(?i)ClusterObjectSet "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message includes:$`, ClusterObjectSetReportsConditionWithMessageFragment)
sc.Step(`^(?i)ClusterObjectDeployment "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterObjectDeploymentReportsConditionWithoutMsg)
+ sc.Step(`^(?i)ClusterObjectDeployment "([^"]+)" reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+) and Message includes:$`, ClusterObjectDeploymentReportsConditionWithMessageFragment)
sc.Step(`^(?i)ClusterExtension reports ([[:alnum:]]+) transition between (\d+) and (\d+) minutes since its creation$`, ClusterExtensionReportsConditionTransitionTime)
sc.Step(`^(?i)ClusterObjectSet is applied(?:\s+.*)?$`, ResourceIsApplied)
sc.Step(`^(?i)ClusterObjectSet with Namespace owner "([^"]*)" is applied$`, ClusterObjectSetWithNamespaceOwnerApplied)
@@ -990,6 +991,12 @@ func ClusterObjectDeploymentReportsConditionWithoutMsg(ctx context.Context, name
return waitForCondition(ctx, "clusterobjectdeployment", substituteScenarioVars(name, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, nil)
}
+// ClusterObjectDeploymentReportsConditionWithMessageFragment waits for the named ClusterObjectDeployment to have a
+// condition matching type, status, reason, with a message containing the specified fragment. Polls with timeout.
+func ClusterObjectDeploymentReportsConditionWithMessageFragment(ctx context.Context, name, conditionType, conditionStatus, conditionReason string, msgFragment *godog.DocString) error {
+ return waitForCondition(ctx, "clusterobjectdeployment", substituteScenarioVars(name, scenarioCtx(ctx)), conditionType, conditionStatus, &conditionReason, messageFragmentComparison(ctx, msgFragment))
+}
+
// TriggerClusterObjectSetReconciliation annotates the named ClusterObjectSet
// to trigger a new reconciliation cycle.
func TriggerClusterObjectSetReconciliation(ctx context.Context, cosName string) error {