Skip to content

Commit cb3340b

Browse files
authored
fix(operator): encode apply hashes as valid Kubernetes labels (#867)
Use unpadded Base32 for SHA-256 apply hashes so their endpoints are always alphanumeric. Validate diverse payloads with Kubernetes label validation and preserve hash reuse, payload sensitivity, and error coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 177a4051-b686-4605-960c-48d24eb96e39
1 parent b653f05 commit cb3340b

2 files changed

Lines changed: 58 additions & 6 deletions

File tree

‎internal/operator/component/env.go‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
"bytes"
88
"context"
99
"crypto/sha256"
10-
"encoding/base64"
10+
"encoding/base32"
1111
"encoding/json"
1212
"errors"
1313
"fmt"
@@ -402,7 +402,8 @@ func DesiredFieldsMatch(desired, current any) bool {
402402
}
403403

404404
// AppliedPayloadHash returns the label-safe digest used to identify an exact
405-
// desired SSA payload.
405+
// desired SSA payload. Unpadded Base32 preserves all 256 bits in 52 alphanumeric
406+
// characters, including the first and last characters required by label values.
406407
func AppliedPayloadHash(obj *unstructured.Unstructured) (string, error) {
407408
payload := obj.DeepCopy()
408409
labels := payload.GetLabels()
@@ -416,7 +417,7 @@ func AppliedPayloadHash(obj *unstructured.Unstructured) (string, error) {
416417

417418
sum := sha256.Sum256(data)
418419

419-
return base64.RawURLEncoding.EncodeToString(sum[:]), nil
420+
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:]), nil
420421
}
421422

422423
// ListSites returns every Site in the cluster.

‎internal/operator/component/env_test.go‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ package component
55

66
import (
77
"context"
8-
"regexp"
8+
"fmt"
9+
"math"
910
"testing"
1011

1112
appsv1 "k8s.io/api/apps/v1"
@@ -15,6 +16,7 @@ import (
1516
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1617
"k8s.io/apimachinery/pkg/runtime"
1718
"k8s.io/apimachinery/pkg/runtime/schema"
19+
"k8s.io/apimachinery/pkg/util/validation"
1820
"k8s.io/client-go/util/workqueue"
1921
"sigs.k8s.io/controller-runtime/pkg/client"
2022
"sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -57,7 +59,6 @@ func TestSetPodSpecImages(t *testing.T) {
5759
"containers": []any{map[string]any{"name": "run", "image": "old:run"}},
5860
}}},
5961
}}
60-
6162
if err := SetPodSpecImages(obj, "registry.example.com/azure/component:v1"); err != nil {
6263
t.Fatalf("SetPodSpecImages: %v", err)
6364
}
@@ -151,6 +152,56 @@ func TestConfigMapPayloadHashIncludesDataAndBinaryData(t *testing.T) {
151152
}
152153
}
153154

155+
func TestAppliedPayloadHashLabelValue(t *testing.T) {
156+
for i := range 256 {
157+
obj := &unstructured.Unstructured{Object: map[string]any{
158+
"apiVersion": "v1",
159+
"kind": "ConfigMap",
160+
"metadata": map[string]any{"name": fmt.Sprintf("payload-%d", i)},
161+
"data": map[string]any{"value": fmt.Sprint(i)},
162+
}}
163+
obj.SetLabels(map[string]string{"app": "test"})
164+
165+
hash, err := AppliedPayloadHash(obj)
166+
if err != nil {
167+
t.Fatal(err)
168+
}
169+
170+
if len(hash) != 52 {
171+
t.Fatalf("payload hash length = %d, want 52", len(hash))
172+
}
173+
174+
if errors := validation.IsValidLabelValue(hash); len(errors) != 0 {
175+
t.Fatalf("payload %d hash %q is not a valid label value: %v", i, hash, errors)
176+
}
177+
178+
obj.SetLabels(map[string]string{"app": "test", AppliedHashLabel: "previous-hash"})
179+
180+
repeated, err := AppliedPayloadHash(obj)
181+
if err != nil || repeated != hash {
182+
t.Fatalf("existing applied hash changed payload hash: %q, %v", repeated, err)
183+
}
184+
185+
if obj.GetLabels()[AppliedHashLabel] != "previous-hash" {
186+
t.Fatal("hashing mutated input labels")
187+
}
188+
189+
obj.Object["data"] = map[string]any{"value": "changed"}
190+
191+
changed, err := AppliedPayloadHash(obj)
192+
if err != nil || changed == hash {
193+
t.Fatalf("changed payload hash: %q, %v", changed, err)
194+
}
195+
}
196+
}
197+
198+
func TestAppliedPayloadHashInvalidPayload(t *testing.T) {
199+
obj := &unstructured.Unstructured{Object: map[string]any{"value": math.Inf(1)}}
200+
if _, err := AppliedPayloadHash(obj); err == nil {
201+
t.Fatal("unsupported JSON value did not return an error")
202+
}
203+
}
204+
154205
func TestRetargetNamespaceRewritesToCustomNamespace(t *testing.T) {
155206
env := &Env{Namespace: "custom-ns"}
156207

@@ -271,7 +322,7 @@ func TestApplyObjectSkipsMatchingPayload(t *testing.T) {
271322
t.Fatalf("appliedPayloadHash: %v", err)
272323
}
273324

274-
if len(hash) > 63 || !regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(hash) {
325+
if errors := validation.IsValidLabelValue(hash); len(errors) != 0 {
275326
t.Fatalf("applied payload hash %q is not a valid label value", hash)
276327
}
277328

0 commit comments

Comments
 (0)