mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-06-05 22:43:51 +00:00
75f7c18f3c
M7: extractUnstructuredContent only hashed 'spec' when present, dropping all other top-level content fields. Resources with both spec and data (or any non-spec content) silently drifted until the next 10m resync. Now hashes every non-Kubernetes-managed top-level field, matching the fields updateUnstructuredMirror copies. M6: when a source has a transform annotation, also hash the source's labels and annotations (filtered of kubemirror.raczylo.com/* keys to avoid the controller's own bookkeeping churning the hash). Templates read these via TransformContext; without this a label change wouldn't re-render the transformed mirror. H3: text/template.Execute is not context-aware, so applyTemplateRule's timeout cancels the select but leaks the executor goroutine. Added a process-wide semaphore (cap 64) so a runaway template can't spawn an unbounded number of stuck goroutines on every reconcile. M4: zap dev mode (DPanic-on-error, console output, stacktraces on warning) was hardcoded on. Defaulted to production; --zap-devel flag remains for opt-in. M5: WaitForInitialDiscovery was anchored on context.Background() with its own WithTimeout, so SIGTERM during startup couldn't abort the wait. Now anchors on signalCtx.
717 lines
18 KiB
Go
717 lines
18 KiB
Go
package hash
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/lukaszraczylo/kubemirror/pkg/constants"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
)
|
|
|
|
func TestComputeContentHash_Secret(t *testing.T) {
|
|
tests := []struct {
|
|
secret1 *corev1.Secret
|
|
secret2 *corev1.Secret
|
|
name string
|
|
wantSame bool
|
|
wantError bool
|
|
}{
|
|
{
|
|
name: "identical secrets produce same hash",
|
|
secret1: &corev1.Secret{
|
|
Data: map[string][]byte{
|
|
"password": []byte("secret123"),
|
|
},
|
|
Type: corev1.SecretTypeOpaque,
|
|
},
|
|
secret2: &corev1.Secret{
|
|
Data: map[string][]byte{
|
|
"password": []byte("secret123"),
|
|
},
|
|
Type: corev1.SecretTypeOpaque,
|
|
},
|
|
wantSame: true,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "different data produces different hash",
|
|
secret1: &corev1.Secret{
|
|
Data: map[string][]byte{
|
|
"password": []byte("secret123"),
|
|
},
|
|
},
|
|
secret2: &corev1.Secret{
|
|
Data: map[string][]byte{
|
|
"password": []byte("different"),
|
|
},
|
|
},
|
|
wantSame: false,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "different type produces different hash",
|
|
secret1: &corev1.Secret{
|
|
Data: map[string][]byte{"key": []byte("value")},
|
|
Type: corev1.SecretTypeOpaque,
|
|
},
|
|
secret2: &corev1.Secret{
|
|
Data: map[string][]byte{"key": []byte("value")},
|
|
Type: corev1.SecretTypeTLS,
|
|
},
|
|
wantSame: false,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "metadata changes don't affect hash",
|
|
secret1: &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "secret1",
|
|
Namespace: "default",
|
|
ResourceVersion: "100",
|
|
Generation: 1,
|
|
},
|
|
Data: map[string][]byte{"key": []byte("value")},
|
|
},
|
|
secret2: &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "secret2",
|
|
Namespace: "different",
|
|
ResourceVersion: "200",
|
|
Generation: 2,
|
|
},
|
|
Data: map[string][]byte{"key": []byte("value")},
|
|
},
|
|
wantSame: true,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "stringData included in hash",
|
|
secret1: &corev1.Secret{
|
|
StringData: map[string]string{"key": "value"},
|
|
},
|
|
secret2: &corev1.Secret{
|
|
StringData: map[string]string{"key": "different"},
|
|
},
|
|
wantSame: false,
|
|
wantError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hash1, err1 := ComputeContentHash(tt.secret1)
|
|
hash2, err2 := ComputeContentHash(tt.secret2)
|
|
|
|
if tt.wantError {
|
|
require.Error(t, err1)
|
|
require.Error(t, err2)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err1)
|
|
require.NoError(t, err2)
|
|
assert.NotEmpty(t, hash1)
|
|
assert.NotEmpty(t, hash2)
|
|
|
|
if tt.wantSame {
|
|
assert.Equal(t, hash1, hash2, "hashes should be identical")
|
|
} else {
|
|
assert.NotEqual(t, hash1, hash2, "hashes should be different")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestComputeContentHash_ConfigMap(t *testing.T) {
|
|
tests := []struct {
|
|
cm1 *corev1.ConfigMap
|
|
cm2 *corev1.ConfigMap
|
|
name string
|
|
wantSame bool
|
|
wantError bool
|
|
}{
|
|
{
|
|
name: "identical configmaps produce same hash",
|
|
cm1: &corev1.ConfigMap{
|
|
Data: map[string]string{
|
|
"config.yaml": "setting: value",
|
|
},
|
|
},
|
|
cm2: &corev1.ConfigMap{
|
|
Data: map[string]string{
|
|
"config.yaml": "setting: value",
|
|
},
|
|
},
|
|
wantSame: true,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "different data produces different hash",
|
|
cm1: &corev1.ConfigMap{
|
|
Data: map[string]string{
|
|
"key": "value1",
|
|
},
|
|
},
|
|
cm2: &corev1.ConfigMap{
|
|
Data: map[string]string{
|
|
"key": "value2",
|
|
},
|
|
},
|
|
wantSame: false,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "binaryData included in hash",
|
|
cm1: &corev1.ConfigMap{
|
|
BinaryData: map[string][]byte{
|
|
"file": {0x00, 0x01, 0x02},
|
|
},
|
|
},
|
|
cm2: &corev1.ConfigMap{
|
|
BinaryData: map[string][]byte{
|
|
"file": {0x00, 0x01, 0xFF},
|
|
},
|
|
},
|
|
wantSame: false,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "metadata changes don't affect hash",
|
|
cm1: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
ResourceVersion: "100",
|
|
Generation: 1,
|
|
},
|
|
Data: map[string]string{"key": "value"},
|
|
},
|
|
cm2: &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
ResourceVersion: "200",
|
|
Generation: 5,
|
|
},
|
|
Data: map[string]string{"key": "value"},
|
|
},
|
|
wantSame: true,
|
|
wantError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hash1, err1 := ComputeContentHash(tt.cm1)
|
|
hash2, err2 := ComputeContentHash(tt.cm2)
|
|
|
|
if tt.wantError {
|
|
require.Error(t, err1)
|
|
require.Error(t, err2)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err1)
|
|
require.NoError(t, err2)
|
|
assert.NotEmpty(t, hash1)
|
|
assert.NotEmpty(t, hash2)
|
|
|
|
if tt.wantSame {
|
|
assert.Equal(t, hash1, hash2)
|
|
} else {
|
|
assert.NotEqual(t, hash1, hash2)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestComputeContentHash_Unstructured(t *testing.T) {
|
|
tests := []struct {
|
|
obj1 *unstructured.Unstructured
|
|
obj2 *unstructured.Unstructured
|
|
name string
|
|
wantSame bool
|
|
wantError bool
|
|
}{
|
|
{
|
|
name: "identical specs produce same hash",
|
|
obj1: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Custom",
|
|
"spec": map[string]interface{}{
|
|
"field": "value",
|
|
},
|
|
},
|
|
},
|
|
obj2: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Custom",
|
|
"spec": map[string]interface{}{
|
|
"field": "value",
|
|
},
|
|
},
|
|
},
|
|
wantSame: true,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "different specs produce different hash",
|
|
obj1: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"spec": map[string]interface{}{
|
|
"field": "value1",
|
|
},
|
|
},
|
|
},
|
|
obj2: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"spec": map[string]interface{}{
|
|
"field": "value2",
|
|
},
|
|
},
|
|
},
|
|
wantSame: false,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "metadata excluded from hash",
|
|
obj1: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"resourceVersion": "100",
|
|
},
|
|
"spec": map[string]interface{}{
|
|
"field": "value",
|
|
},
|
|
},
|
|
},
|
|
obj2: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"resourceVersion": "200",
|
|
},
|
|
"spec": map[string]interface{}{
|
|
"field": "value",
|
|
},
|
|
},
|
|
},
|
|
wantSame: true,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "status excluded from hash",
|
|
obj1: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"spec": map[string]interface{}{
|
|
"field": "value",
|
|
},
|
|
"status": map[string]interface{}{
|
|
"condition": "Ready",
|
|
},
|
|
},
|
|
},
|
|
obj2: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"spec": map[string]interface{}{
|
|
"field": "value",
|
|
},
|
|
"status": map[string]interface{}{
|
|
"condition": "NotReady",
|
|
},
|
|
},
|
|
},
|
|
wantSame: true,
|
|
wantError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
hash1, err1 := ComputeContentHash(tt.obj1)
|
|
hash2, err2 := ComputeContentHash(tt.obj2)
|
|
|
|
if tt.wantError {
|
|
require.Error(t, err1)
|
|
require.Error(t, err2)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err1)
|
|
require.NoError(t, err2)
|
|
assert.NotEmpty(t, hash1)
|
|
assert.NotEmpty(t, hash2)
|
|
|
|
if tt.wantSame {
|
|
assert.Equal(t, hash1, hash2)
|
|
} else {
|
|
assert.NotEqual(t, hash1, hash2)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNeedsSync(t *testing.T) {
|
|
tests := []struct {
|
|
source runtime.Object
|
|
target runtime.Object
|
|
targetAnnotations map[string]string
|
|
name string
|
|
want bool
|
|
wantError bool
|
|
}{
|
|
{
|
|
name: "needs sync when generation changed",
|
|
source: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"generation": int64(5),
|
|
},
|
|
"spec": map[string]interface{}{
|
|
"field": "value",
|
|
},
|
|
},
|
|
},
|
|
target: &unstructured.Unstructured{},
|
|
targetAnnotations: map[string]string{
|
|
constants.AnnotationSourceGeneration: "3",
|
|
constants.AnnotationSourceContentHash: "abc123",
|
|
},
|
|
want: true,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "doesn't need sync when generation same and hash same",
|
|
source: &corev1.Secret{
|
|
Data: map[string][]byte{"key": []byte("value")},
|
|
},
|
|
target: &corev1.Secret{},
|
|
targetAnnotations: map[string]string{
|
|
constants.AnnotationSourceGeneration: "0",
|
|
constants.AnnotationSourceContentHash: mustComputeHash(t, &corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}),
|
|
},
|
|
want: false,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "needs sync when content hash changed",
|
|
source: &corev1.ConfigMap{
|
|
Data: map[string]string{"key": "newvalue"},
|
|
},
|
|
target: &corev1.ConfigMap{},
|
|
targetAnnotations: map[string]string{
|
|
constants.AnnotationSourceContentHash: "oldhash",
|
|
},
|
|
want: true,
|
|
wantError: false,
|
|
},
|
|
{
|
|
name: "needs sync when no previous hash",
|
|
source: &corev1.Secret{
|
|
Data: map[string][]byte{"key": []byte("value")},
|
|
},
|
|
target: &corev1.Secret{},
|
|
targetAnnotations: map[string]string{},
|
|
want: true,
|
|
wantError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := NeedsSync(tt.source, tt.target, tt.targetAnnotations)
|
|
|
|
if tt.wantError {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetGeneration(t *testing.T) {
|
|
tests := []struct {
|
|
obj runtime.Object
|
|
name string
|
|
want int64
|
|
}{
|
|
{
|
|
name: "returns generation for resource with generation",
|
|
obj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"metadata": map[string]interface{}{
|
|
"generation": int64(42),
|
|
},
|
|
},
|
|
},
|
|
want: 42,
|
|
},
|
|
{
|
|
name: "returns 0 for resource without generation",
|
|
obj: &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test",
|
|
},
|
|
},
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "returns 0 for nil metadata",
|
|
obj: &unstructured.Unstructured{
|
|
Object: map[string]interface{}{},
|
|
},
|
|
want: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := getGeneration(tt.obj)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Helper function to compute hash for test setup
|
|
func mustComputeHash(t *testing.T, obj runtime.Object) string {
|
|
t.Helper()
|
|
hash, err := ComputeContentHash(obj)
|
|
require.NoError(t, err)
|
|
return hash
|
|
}
|
|
|
|
// TestComputeContentHash_NoMutation verifies that hash computation doesn't mutate the input object.
|
|
// This is critical because NestedMap can modify the underlying map.
|
|
func TestComputeContentHash_NoMutation(t *testing.T) {
|
|
t.Run("unstructured object is not mutated", func(t *testing.T) {
|
|
// Create an unstructured object with nested spec
|
|
original := &unstructured.Unstructured{
|
|
Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Custom",
|
|
"metadata": map[string]interface{}{
|
|
"name": "test-resource",
|
|
"namespace": "default",
|
|
"annotations": map[string]interface{}{
|
|
constants.AnnotationTransform: `{"rules":[{"field":"spec.value","action":"base64encode"}]}`,
|
|
},
|
|
},
|
|
"spec": map[string]interface{}{
|
|
"field1": "value1",
|
|
"nested": map[string]interface{}{
|
|
"deep": "data",
|
|
},
|
|
},
|
|
"status": map[string]interface{}{
|
|
"condition": "Ready",
|
|
},
|
|
},
|
|
}
|
|
|
|
// Deep copy the original to compare after hash computation
|
|
expectedCopy := original.DeepCopy()
|
|
|
|
// Compute hash multiple times
|
|
hash1, err := ComputeContentHash(original)
|
|
require.NoError(t, err)
|
|
|
|
hash2, err := ComputeContentHash(original)
|
|
require.NoError(t, err)
|
|
|
|
// Hashes should be consistent (object wasn't modified)
|
|
assert.Equal(t, hash1, hash2, "hash should be consistent across calls")
|
|
|
|
// Original object should be unchanged
|
|
assert.Equal(t, expectedCopy.Object, original.Object, "original object should not be mutated")
|
|
})
|
|
|
|
t.Run("secret is not mutated", func(t *testing.T) {
|
|
secret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-secret",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
constants.AnnotationTransform: `{"rules":[]}`,
|
|
},
|
|
},
|
|
Data: map[string][]byte{
|
|
"password": []byte("secret123"),
|
|
},
|
|
Type: corev1.SecretTypeOpaque,
|
|
}
|
|
|
|
// Copy for comparison
|
|
originalData := make(map[string][]byte)
|
|
for k, v := range secret.Data {
|
|
originalData[k] = append([]byte(nil), v...)
|
|
}
|
|
originalAnnotations := make(map[string]string)
|
|
for k, v := range secret.Annotations {
|
|
originalAnnotations[k] = v
|
|
}
|
|
|
|
// Compute hash
|
|
_, err := ComputeContentHash(secret)
|
|
require.NoError(t, err)
|
|
|
|
// Verify no mutation
|
|
assert.Equal(t, originalData, secret.Data, "secret data should not be mutated")
|
|
assert.Equal(t, originalAnnotations, secret.Annotations, "secret annotations should not be mutated")
|
|
})
|
|
|
|
t.Run("configmap is not mutated", func(t *testing.T) {
|
|
cm := &corev1.ConfigMap{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-cm",
|
|
Namespace: "default",
|
|
},
|
|
Data: map[string]string{
|
|
"config.yaml": "key: value",
|
|
},
|
|
BinaryData: map[string][]byte{
|
|
"binary": {0x00, 0x01, 0x02},
|
|
},
|
|
}
|
|
|
|
// Copy for comparison
|
|
originalData := make(map[string]string)
|
|
for k, v := range cm.Data {
|
|
originalData[k] = v
|
|
}
|
|
originalBinaryData := make(map[string][]byte)
|
|
for k, v := range cm.BinaryData {
|
|
originalBinaryData[k] = append([]byte(nil), v...)
|
|
}
|
|
|
|
// Compute hash
|
|
_, err := ComputeContentHash(cm)
|
|
require.NoError(t, err)
|
|
|
|
// Verify no mutation
|
|
assert.Equal(t, originalData, cm.Data, "configmap data should not be mutated")
|
|
assert.Equal(t, originalBinaryData, cm.BinaryData, "configmap binary data should not be mutated")
|
|
})
|
|
}
|
|
|
|
// Benchmark tests
|
|
func BenchmarkComputeContentHash_Secret(b *testing.B) {
|
|
secret := &corev1.Secret{
|
|
Data: map[string][]byte{
|
|
"password": []byte("secret123"),
|
|
"username": []byte("admin"),
|
|
},
|
|
Type: corev1.SecretTypeOpaque,
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, _ = ComputeContentHash(secret)
|
|
}
|
|
}
|
|
|
|
func BenchmarkComputeContentHash_ConfigMap(b *testing.B) {
|
|
cm := &corev1.ConfigMap{
|
|
Data: map[string]string{
|
|
"config.yaml": "setting: value\nother: data",
|
|
},
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, _ = ComputeContentHash(cm)
|
|
}
|
|
}
|
|
|
|
func BenchmarkNeedsSync(b *testing.B) {
|
|
source := &corev1.Secret{
|
|
Data: map[string][]byte{"key": []byte("value")},
|
|
}
|
|
target := &corev1.Secret{}
|
|
hash, _ := ComputeContentHash(source)
|
|
annotations := map[string]string{
|
|
"source-content-hash": hash,
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, _ = NeedsSync(source, target, annotations)
|
|
}
|
|
}
|
|
func TestComputeContentHash_Unstructured_HashesAllNonMetaFields(t *testing.T) {
|
|
// Regression (M7): the previous implementation only hashed `spec` when it
|
|
// was present, dropping any other top-level content (data, type, custom
|
|
// CRD fields). Drift to those fields was invisible until the next resync.
|
|
objSpecOnly := &unstructured.Unstructured{Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Custom",
|
|
"spec": map[string]interface{}{"field": "v1"},
|
|
"data": map[string]interface{}{"k": "v1"},
|
|
}}
|
|
objSpecAndDifferentData := &unstructured.Unstructured{Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "Custom",
|
|
"spec": map[string]interface{}{"field": "v1"},
|
|
"data": map[string]interface{}{"k": "v2"}, // only data differs
|
|
}}
|
|
|
|
h1, err := ComputeContentHash(objSpecOnly)
|
|
require.NoError(t, err)
|
|
h2, err := ComputeContentHash(objSpecAndDifferentData)
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, h1, h2, "data field must contribute to hash even when spec exists")
|
|
}
|
|
|
|
func TestComputeContentHash_Unstructured_TransformIncludesLabelsAndAnnotations(t *testing.T) {
|
|
// Regression (M6): templates can read source labels/annotations via
|
|
// TransformContext. When a transform annotation is present, label /
|
|
// annotation changes must therefore re-hash so NeedsSync re-renders.
|
|
make := func(label, annot string) *unstructured.Unstructured {
|
|
return &unstructured.Unstructured{Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "ConfigMap",
|
|
"metadata": map[string]interface{}{
|
|
"labels": map[string]interface{}{"app": label},
|
|
"annotations": map[string]interface{}{constants.AnnotationTransform: "rules: []", "tier": annot},
|
|
},
|
|
"data": map[string]interface{}{"k": "v"},
|
|
}}
|
|
}
|
|
|
|
base, err := ComputeContentHash(make("v1", "prod"))
|
|
require.NoError(t, err)
|
|
|
|
labelChanged, err := ComputeContentHash(make("v2", "prod"))
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, base, labelChanged, "label change must re-hash when transform is present")
|
|
|
|
annotChanged, err := ComputeContentHash(make("v1", "stage"))
|
|
require.NoError(t, err)
|
|
assert.NotEqual(t, base, annotChanged, "annotation change must re-hash when transform is present")
|
|
}
|
|
|
|
func TestComputeContentHash_Unstructured_LabelChangesIgnoredWithoutTransform(t *testing.T) {
|
|
// Counterpart to the above: when there is NO transform annotation, label
|
|
// changes must NOT churn the hash — that would cause unnecessary mirror
|
|
// re-writes for plain (non-transformed) mirrors.
|
|
make := func(label string) *unstructured.Unstructured {
|
|
return &unstructured.Unstructured{Object: map[string]interface{}{
|
|
"apiVersion": "v1",
|
|
"kind": "ConfigMap",
|
|
"metadata": map[string]interface{}{
|
|
"labels": map[string]interface{}{"app": label},
|
|
},
|
|
"data": map[string]interface{}{"k": "v"},
|
|
}}
|
|
}
|
|
|
|
h1, err := ComputeContentHash(make("v1"))
|
|
require.NoError(t, err)
|
|
h2, err := ComputeContentHash(make("v2"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, h1, h2, "label changes must not re-hash without a transform annotation")
|
|
}
|