From 7ad5a64ee134fee664ba5803e66e76734013a2c0 Mon Sep 17 00:00:00 2001 From: Valentin Gerlach Date: Thu, 14 Aug 2025 15:57:25 +0200 Subject: [PATCH 1/2] feat: new hash function for k8s resource names --- pkg/controller/hash.go | 72 +++++++++++++++++++ pkg/controller/hash_test.go | 134 ++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 pkg/controller/hash.go create mode 100644 pkg/controller/hash_test.go diff --git a/pkg/controller/hash.go b/pkg/controller/hash.go new file mode 100644 index 0000000..18d874d --- /dev/null +++ b/pkg/controller/hash.go @@ -0,0 +1,72 @@ +package controller + +import ( + "crypto/sha3" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + ErrInvalidNames = errors.New("list of names must not be empty and contain at least one non-empty string") +) + +// Version8UUID creates a new UUID (version 8) from a byte slice. Returns an error if the slice does not have a length of 16. The bytes are copied from the slice. +// The bits 48-51 and 64-65 are modified to make the output recognizable as a version 8 UUID, so only 122 out of 128 bits from the input data will be kept. +func Version8UUID(data []byte) (uuid.UUID, error) { + if len(data) != 16 { + return uuid.Nil, fmt.Errorf("invalid data (got %d bytes)", len(data)) + } + + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + + // Set 4-bit version field (ver = 0b1000) + dataCopy[6] &= 0b00001111 + dataCopy[6] |= 0b10000000 + + // Set 2-bit variant field (var = 0b10) + dataCopy[8] &= 0b00111111 + dataCopy[8] |= 0b10000000 + + return uuid.FromBytes(dataCopy) +} + +// K8sNameUUID takes any number of string arguments and computes a hash out of it, which is then formatted as a version 8 UUID. +// The arguments are joined with '/' before being hashed. +// Returns an error if the list of ids is empty or contains only empty strings. +func K8sNameUUID(names ...string) (string, error) { + if err := validateIDs(names); err != nil { + return "", err + } + + name := strings.Join(names, "/") + hash := sha3.SumSHAKE128([]byte(name), 16) + u, err := Version8UUID(hash) + + return u.String(), err +} + +func validateIDs(names []string) error { + for _, name := range names { + // at least one non-empty string found + if name != "" { + return nil + } + } + return ErrInvalidNames +} + +// K8sObjectUUID takes a client object and computes a hash out of the namespace and name, which is then formatted as a version 8 UUID. +// An empty namespace will be replaced by "default". +func K8sObjectUUID(obj client.Object) (string, error) { + name, namespace := obj.GetName(), obj.GetNamespace() + if namespace == "" { + namespace = corev1.NamespaceDefault + } + return K8sNameUUID(namespace, name) +} diff --git a/pkg/controller/hash_test.go b/pkg/controller/hash_test.go new file mode 100644 index 0000000..a183388 --- /dev/null +++ b/pkg/controller/hash_test.go @@ -0,0 +1,134 @@ +package controller + +import ( + "crypto/sha3" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func Test_Version8UUID(t *testing.T) { + testCases := []struct { + desc string + data []byte + expectedErr *string + }{ + { + desc: "Text1", + data: sha3.SumSHAKE128([]byte("hello world"), 16), + }, + { + desc: "Text2", + data: sha3.SumSHAKE128([]byte("The quick brown fox jumps over the lazy dog"), 16), + }, + { + desc: "Text3", + data: sha3.SumSHAKE128([]byte("Lorem ipsum dolor sit amet"), 16), + }, + { + desc: "TooShort", + data: sha3.SumSHAKE128([]byte("Lorem ipsum dolor sit amet"), 15), + expectedErr: ptr.To("invalid data (got 15 bytes)"), + }, + { + desc: "TooLong", + data: sha3.SumSHAKE128([]byte("Lorem ipsum dolor sit amet"), 17), + expectedErr: ptr.To("invalid data (got 17 bytes)"), + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + u, err := Version8UUID(tC.data) + if tC.expectedErr == nil { + assert.NoError(t, err) + assert.Equal(t, uuid.Version(8), u.Version(), "unexpected version") + assert.Equal(t, uuid.RFC4122, u.Variant(), "unexpected variant") + } else { + assert.EqualError(t, err, *tC.expectedErr) + assert.Equal(t, uuid.Nil, u) + } + }) + } +} + +func Test_K8sNameUUID(t *testing.T) { + testCases := []struct { + desc string + input []string + expectedUUID string + expectedErr error + }{ + { + desc: "should generate ID from one name", + input: []string{"example"}, + expectedUUID: "23cc2129-a257-8644-95e0-289d55c69704", + }, + { + desc: "should generate ID from two names", + input: []string{corev1.NamespaceDefault, "example"}, + expectedUUID: "2bcf790e-815e-8ea9-857b-15be429583a5", + }, + { + desc: "should fail because of empty slice", + input: []string{}, + expectedErr: ErrInvalidNames, + }, + { + desc: "should fail because of slice with empty string", + input: []string{""}, + expectedErr: ErrInvalidNames, + }, + { + desc: "should fail because of slice with empty strings", + input: []string{"", ""}, + expectedErr: ErrInvalidNames, + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + actual, err := K8sNameUUID(tC.input...) + assert.Equal(t, tC.expectedUUID, actual) + assert.Equal(t, tC.expectedErr, err) + }) + } +} + +func Test_K8sObjectUUID(t *testing.T) { + testCases := []struct { + desc string + obj client.Object + expectedUUID string + }{ + { + desc: "should work with config map", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "default", + }, + }, + expectedUUID: "2bcf790e-815e-8ea9-857b-15be429583a5", // same as in Test_K8sNameUUID + }, + { + desc: "should work with config map and empty namespace", + obj: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + }, + }, + expectedUUID: "2bcf790e-815e-8ea9-857b-15be429583a5", // same as above + }, + } + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + actual, err := K8sObjectUUID(tC.obj) + assert.NoError(t, err) + assert.Equal(t, tC.expectedUUID, actual) + }) + } +} From 24bb6e9e39a173513d21beb924bfee4282c01092 Mon Sep 17 00:00:00 2001 From: Valentin Gerlach Date: Thu, 14 Aug 2025 16:07:23 +0200 Subject: [PATCH 2/2] chore: go mod tidy --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 0d448fa..c5357e5 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 github.com/go-logr/logr v1.4.3 github.com/go-logr/zapr v1.3.0 + github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.38.0 github.com/openmcp-project/controller-utils/api v0.17.0 @@ -44,7 +45,6 @@ require ( github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.9.0 // indirect