mirror of
https://github.com/open-cluster-management-io/ocm.git
synced 2026-02-14 18:09:57 +00:00
Add duplicate manifest detection in ManifestWork webhook validation (#1310)
This commit adds validation to detect and reject duplicate manifests in ManifestWork resources. A manifest is considered duplicate when it has the same apiVersion, kind, namespace, and name as another manifest in the same ManifestWork. This prevents issues where duplicate manifests with different specs can cause state inconsistency, as the Work Agent applies manifests sequentially and later entries would overwrite earlier ones. The validation returns a clear error message indicating the duplicate manifest's index and the index of its first occurrence. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: xuezhaojun <zxue@redhat.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
|
||||
workv1 "open-cluster-management.io/api/work/v1"
|
||||
)
|
||||
@@ -33,11 +36,25 @@ func (m *Validator) ValidateManifests(manifests []workv1.Manifest) error {
|
||||
return fmt.Errorf("the size of manifests is %v bytes which exceeds the %v limit", totalSize, m.limit)
|
||||
}
|
||||
|
||||
for _, manifest := range manifests {
|
||||
// Track seen manifests to detect duplicates
|
||||
seen := sets.New[string]()
|
||||
|
||||
for i, manifest := range manifests {
|
||||
err := validateManifest(manifest.Raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for duplicate manifests
|
||||
info, err := extractManifestInfo(manifest.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract metadata from manifest at index %d: %w", i, err)
|
||||
}
|
||||
|
||||
if seen.Has(info.key) {
|
||||
return fmt.Errorf("duplicate manifest for resource %s/%s with resource type %s", info.namespace, info.name, info.gvk)
|
||||
}
|
||||
seen.Insert(info.key)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -62,3 +79,26 @@ func validateManifest(manifest []byte) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// manifestInfo contains the metadata needed for duplicate detection and error messages.
|
||||
type manifestInfo struct {
|
||||
key string // unique key for duplicate detection: apiVersion/kind/namespace/name
|
||||
name string
|
||||
namespace string
|
||||
gvk string // apiVersion.kind format for error messages
|
||||
}
|
||||
|
||||
// extractManifestInfo extracts metadata from a manifest for duplicate detection.
|
||||
func extractManifestInfo(manifest []byte) (*manifestInfo, error) {
|
||||
meta := &metav1.PartialObjectMetadata{}
|
||||
if err := json.Unmarshal(manifest, meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &manifestInfo{
|
||||
key: fmt.Sprintf("%s/%s/%s/%s", meta.APIVersion, meta.Kind, meta.Namespace, meta.Name),
|
||||
name: meta.Name,
|
||||
namespace: meta.Namespace,
|
||||
gvk: fmt.Sprintf("%s.%s", meta.APIVersion, meta.Kind),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -32,6 +32,40 @@ func newManifest(size int) workv1.Manifest {
|
||||
manifest.Raw = objectStr
|
||||
return manifest
|
||||
}
|
||||
|
||||
func newManifestWithNameAndNamespace(name, namespace string) workv1.Manifest {
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ConfigMap",
|
||||
"metadata": map[string]interface{}{
|
||||
"namespace": namespace,
|
||||
"name": name,
|
||||
},
|
||||
},
|
||||
}
|
||||
objectStr, _ := obj.MarshalJSON()
|
||||
manifest := workv1.Manifest{}
|
||||
manifest.Raw = objectStr
|
||||
return manifest
|
||||
}
|
||||
|
||||
func newManifestWithKind(name, namespace, kind string) workv1.Manifest {
|
||||
obj := &unstructured.Unstructured{
|
||||
Object: map[string]interface{}{
|
||||
"apiVersion": "v1",
|
||||
"kind": kind,
|
||||
"metadata": map[string]interface{}{
|
||||
"namespace": namespace,
|
||||
"name": name,
|
||||
},
|
||||
},
|
||||
}
|
||||
objectStr, _ := obj.MarshalJSON()
|
||||
manifest := workv1.Manifest{}
|
||||
manifest.Raw = objectStr
|
||||
return manifest
|
||||
}
|
||||
func Test_Validator(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -39,15 +73,49 @@ func Test_Validator(t *testing.T) {
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "not exceed the limit",
|
||||
name: "duplicate manifests from newManifest helper",
|
||||
manifests: []workv1.Manifest{newManifest(100 * 1024), newManifest(100 * 1024)},
|
||||
expectedError: nil,
|
||||
expectedError: fmt.Errorf("duplicate manifest for resource test/test with resource type v1.Secret"),
|
||||
},
|
||||
{
|
||||
name: "exceed the limit",
|
||||
manifests: []workv1.Manifest{newManifest(300 * 1024), newManifest(200 * 1024)},
|
||||
expectedError: fmt.Errorf("the size of manifests is 512192 bytes which exceeds the 512000 limit"),
|
||||
},
|
||||
{
|
||||
name: "duplicate manifests",
|
||||
manifests: []workv1.Manifest{
|
||||
newManifestWithNameAndNamespace("test1", "default"),
|
||||
newManifestWithNameAndNamespace("test2", "default"),
|
||||
newManifestWithNameAndNamespace("test1", "default"),
|
||||
},
|
||||
expectedError: fmt.Errorf("duplicate manifest for resource default/test1 with resource type v1.ConfigMap"),
|
||||
},
|
||||
{
|
||||
name: "same name different namespace",
|
||||
manifests: []workv1.Manifest{
|
||||
newManifestWithNameAndNamespace("test", "ns1"),
|
||||
newManifestWithNameAndNamespace("test", "ns2"),
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "same name different kind",
|
||||
manifests: []workv1.Manifest{
|
||||
newManifestWithKind("test", "default", "ConfigMap"),
|
||||
newManifestWithKind("test", "default", "Secret"),
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "unique manifests",
|
||||
manifests: []workv1.Manifest{
|
||||
newManifestWithNameAndNamespace("cm1", "default"),
|
||||
newManifestWithNameAndNamespace("cm2", "default"),
|
||||
newManifestWithNameAndNamespace("cm3", "default"),
|
||||
},
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -51,6 +51,16 @@ var _ = ginkgo.Describe("ManifestWork admission webhook", ginkgo.Label("validati
|
||||
gomega.Expect(errors.IsBadRequest(err)).Should(gomega.BeTrue())
|
||||
})
|
||||
|
||||
ginkgo.It("Should respond bad request when creating a manifestwork with duplicate manifests", func() {
|
||||
work := newManifestWork(universalClusterName, workName, []runtime.Object{
|
||||
util.NewConfigmap("default", "cm1", nil, nil),
|
||||
util.NewConfigmap("default", "cm1", nil, nil), // duplicate
|
||||
}...)
|
||||
_, err := hub.WorkClient.WorkV1().ManifestWorks(universalClusterName).Create(context.Background(), work, metav1.CreateOptions{})
|
||||
gomega.Expect(err).To(gomega.HaveOccurred())
|
||||
gomega.Expect(errors.IsBadRequest(err)).Should(gomega.BeTrue())
|
||||
})
|
||||
|
||||
ginkgo.Context("executor", func() {
|
||||
var hubUser string
|
||||
var roleName string
|
||||
|
||||
Reference in New Issue
Block a user