Skip to content

Commit dbb0487

Browse files
committed
gcp: refactor label key and value sanitization
The previous logic did not properly sanitize the label values, as it only ensured length was within the 64 char limit. However, there are additional restrictions on labels and they are similar to the key restrictions. The language from the GCP docs is: > Each resource can have up to 64 labels. > Each label must be a key-value pair. > Keys have a minimum length of 1 character and a maximum length of 63 characters, and cannot be empty. Values can be empty, and have a maximum length of 63 characters. > Keys and values can contain only lowercase letters, numeric characters, underscores, and dashes. All characters must use UTF-8 encoding, and international characters are allowed. Keys must start with a lowercase letter or international character. The new logic attempts to follow all of these rules. The test cases have been expanded to reflect this as well.
1 parent dd41f5f commit dbb0487

File tree

2 files changed

+344
-75
lines changed

2 files changed

+344
-75
lines changed

gcp.go

+107-26
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,28 @@ import (
66
"maps"
77
"strings"
88
"time"
9+
"unicode"
910

1011
"github.com/prometheus/client_golang/prometheus"
1112
log "github.com/sirupsen/logrus"
1213
"google.golang.org/api/compute/v1"
1314
"k8s.io/apimachinery/pkg/util/wait"
1415
)
1516

17+
var gcpLabelCharReplacer = strings.NewReplacer(
18+
// slash and dot are common, use different replacement chars:
19+
"/", "_", // replace slashes with underscores
20+
".", "-", // replace dots with dashes
21+
22+
// less common characters, replace with dashes:
23+
" ", "-", // replace spaces with dashes
24+
":", "-", // replace colons with dashes
25+
",", "-", // replace commas with dashes
26+
";", "-", // replace semi-colons with dashes
27+
"=", "-", // replace equals with dashes
28+
"+", "-", // replace plus with dashes
29+
)
30+
1631
type GCPClient interface {
1732
GetDisk(project, zone, name string) (*compute.Disk, error)
1833
SetDiskLabels(project, zone, name string, labelReq *compute.ZoneSetLabelsRequest) (*compute.Operation, error)
@@ -93,7 +108,6 @@ func addPDVolumeLabels(c GCPClient, volumeID string, labels map[string]string, s
93108
false,
94109
waitForCompletion); err != nil {
95110
log.Errorf("set label operation failed: %s", err)
96-
promActionsTotal.With(prometheus.Labels{"status": "error", "storageclass": storageclass}).Inc()
97111
return
98112
}
99113

@@ -145,7 +159,7 @@ func deletePDVolumeLabels(c GCPClient, volumeID string, keys []string, storagecl
145159
waitForCompletion := func(_ context.Context) (bool, error) {
146160
resp, err := c.GetGCEOp(project, location, op.Name)
147161
if err != nil {
148-
return false, fmt.Errorf("failed retrieve status of label update operation: %s", err)
162+
return false, fmt.Errorf("failed to delete labels from PD %s: %s", disk.Name, err)
149163
}
150164
return resp.Status == "DONE", nil
151165
}
@@ -154,7 +168,6 @@ func deletePDVolumeLabels(c GCPClient, volumeID string, keys []string, storagecl
154168
time.Minute,
155169
false,
156170
waitForCompletion); err != nil {
157-
promActionsTotal.With(prometheus.Labels{"status": "error", "storageclass": storageclass}).Inc()
158171
log.Errorf("delete label operation failed: %s", err)
159172
return
160173
}
@@ -174,38 +187,106 @@ func parseVolumeID(id string) (string, string, string, error) {
174187
return project, location, name, nil
175188
}
176189

177-
func sanitizeLabelsForGCP(labels map[string]string) map[string]string {
178-
newLabels := make(map[string]string, len(labels))
179-
for k, v := range labels {
180-
newLabels[sanitizeKeyForGCP(k)] = sanitizeValueForGCP(v)
181-
}
182-
return newLabels
190+
// isValidGCPChar returns true if the rune is valid for GCP labels:
191+
// lowercase letters, numbers, dash, or underscore. International characters are
192+
// allowed.
193+
func isValidGCPChar(r rune) bool {
194+
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_'
183195
}
184196

185-
func sanitizeKeysForGCP(keys []string) []string {
186-
newKeys := make([]string, len(keys))
187-
for i, k := range keys {
188-
newKeys[i] = sanitizeKeyForGCP(k)
197+
// sanitizeGCPLabelComponent handles the common sanitization logic for both keys
198+
// and values
199+
func sanitizeGCPLabelComponent(s string, isKey bool) string {
200+
// Convert to lowercase
201+
s = strings.ToLower(s)
202+
203+
// Replace invalid characters with dashes
204+
s = gcpLabelCharReplacer.Replace(s)
205+
206+
// Filter to only valid characters
207+
var b strings.Builder
208+
for _, r := range s {
209+
if isValidGCPChar(r) {
210+
b.WriteRune(r)
211+
}
212+
}
213+
s = b.String()
214+
215+
// For keys, ensure they start with a letter
216+
if isKey && len(s) > 0 && !unicode.IsLetter(rune(s[0])) {
217+
s = "k" + s
218+
}
219+
220+
// Remove consecutive dashes/underscores
221+
for strings.Contains(s, "--") || strings.Contains(s, "__") {
222+
s = strings.ReplaceAll(s, "--", "-")
223+
s = strings.ReplaceAll(s, "__", "_")
189224
}
190-
return newKeys
225+
226+
// Remove any trailing dashes or underscores
227+
s = strings.TrimRight(s, "-_")
228+
229+
// Truncate to maximum length
230+
if len(s) > 63 {
231+
s = s[:63]
232+
s = strings.TrimRight(s, "-_")
233+
}
234+
235+
return s
191236
}
192237

193-
// sanitizeKeyForGCP sanitizes a Kubernetes label key to fit GCP's label key constraints
238+
// sanitizeKeyForGCP sanitizes a Kubernetes label key to fit GCP's label key constraints:
239+
// - Must start with a lowercase letter or international character
240+
// - Can only contain lowercase letters, numbers, dashes and underscores
241+
// - Must be between 1 and 63 characters long
242+
// - Must use UTF-8 encoding
194243
func sanitizeKeyForGCP(key string) string {
195-
key = strings.ToLower(key)
196-
key = strings.NewReplacer("/", "_", ".", "-").Replace(key) // Replace disallowed characters
197-
key = strings.TrimRight(key, "-_") // Ensure it does not end with '-' or '_'
244+
return sanitizeGCPLabelComponent(key, true)
245+
}
246+
247+
// sanitizeValueForGCP sanitizes a Kubernetes label value to fit GCP's label value constraints:
248+
// - Can be empty
249+
// - Maximum length of 63 characters
250+
// - Can only contain lowercase letters, numbers, dashes and underscores
251+
// - Must use UTF-8 encoding
252+
func sanitizeValueForGCP(value string) string {
253+
return sanitizeGCPLabelComponent(value, false)
254+
}
255+
256+
// sanitizeLabelsForGCP sanitizes a map of Kubernetes labels to fit GCP's constraints.
257+
// Empty keys after sanitization are dropped from the result.
258+
func sanitizeLabelsForGCP(labels map[string]string) map[string]string {
259+
if len(labels) > 64 {
260+
// If we have more than 64 labels, only take the first 64
261+
truncatedLabels := make(map[string]string, 64)
262+
i := 0
263+
for k, v := range labels {
264+
if i >= 64 {
265+
break
266+
}
267+
truncatedLabels[k] = v
268+
i++
269+
}
270+
labels = truncatedLabels
271+
}
198272

199-
if len(key) > 63 {
200-
key = key[:63]
273+
result := make(map[string]string, len(labels))
274+
for k, v := range labels {
275+
if sanitizedKey := sanitizeKeyForGCP(k); sanitizedKey != "" {
276+
result[sanitizedKey] = sanitizeValueForGCP(v)
277+
}
201278
}
202-
return key
279+
return result
203280
}
204281

205-
// sanitizeKeyForGCP sanitizes a Kubernetes label value to fit GCP's label value constraints
206-
func sanitizeValueForGCP(value string) string {
207-
if len(value) > 63 {
208-
value = value[:63]
282+
// sanitizeKeysForGCP sanitizes a slice of label keys to fit GCP's constraints.
283+
// Empty keys after sanitization are dropped from the result.
284+
func sanitizeKeysForGCP(keys []string) []string {
285+
result := make([]string, 0, len(keys))
286+
for _, k := range keys {
287+
if sanitized := sanitizeKeyForGCP(k); sanitized != "" {
288+
result = append(result, sanitized)
289+
}
209290
}
210-
return value
291+
return result
211292
}

0 commit comments

Comments
 (0)