Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions pkg/controller/hash.go
Original file line number Diff line number Diff line change
@@ -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)
}
134 changes: 134 additions & 0 deletions pkg/controller/hash_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}