Skip to content

Commit 6f96a8f

Browse files
authored
Add logic for automated hashing of CSM Mesh name (#42)
1 parent 57a5b7b commit 6f96a8f

File tree

6 files changed

+471
-12
lines changed

6 files changed

+471
-12
lines changed

csmnamer/hash.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright 2023 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// DO NOT EDIT: This is a sync of services_platform/thetis/common/gke_net/naming.go
16+
// and should not be modified to maintain functional consistency.
17+
18+
package csmnamer
19+
20+
import (
21+
"crypto/sha256"
22+
"strconv"
23+
)
24+
25+
// lookup table to maintain entropy when converting bytes to string.
26+
var table []string
27+
28+
func init() {
29+
for i := 0; i < 10; i++ {
30+
table = append(table, strconv.Itoa(i))
31+
}
32+
for i := 0; i < 26; i++ {
33+
table = append(table, string('a'+rune(i)))
34+
}
35+
}
36+
37+
// Hash creates a content hash string of length n of s utilizing sha256.
38+
// Note that 256 is not evenly divisible by 36, so the first four elements
39+
// will be slightly more likely (3.125% chance) than the rest (2.734375% chance).
40+
// This results in a per-character chance of collision of
41+
// (4 * ((8/256)^2) + (36-4) * ((7/256)^2)) instead of (1 / 36).
42+
// For an 8 character hash string (used for cluster UID and suffix hash), this
43+
// comes out to 3.600e-13 instead of 3.545e-13, which is a negligibly larger
44+
// chance of collision.
45+
func Hash(s string, n int) string {
46+
var h string
47+
bytes := sha256.Sum256(([]byte)(s))
48+
for i := 0; i < n && i < len(bytes); i++ {
49+
idx := int(bytes[i]) % len(table)
50+
h += table[idx]
51+
}
52+
return h
53+
}
54+
55+
// TrimFieldsEvenly trims the fields evenly and keeps the total length <= max.
56+
// Truncation is spread in ratio with their original length, meaning smaller
57+
// fields will be truncated less than longer ones.
58+
func TrimFieldsEvenly(max int, fields ...string) []string {
59+
if max <= 0 {
60+
return fields
61+
}
62+
total := 0
63+
for _, s := range fields {
64+
total += len(s)
65+
}
66+
if total <= max {
67+
return fields
68+
}
69+
70+
// Distribute truncation evenly among the fields.
71+
excess := total - max
72+
remaining := max
73+
var lengths []int
74+
for _, s := range fields {
75+
// Scale truncation to shorten longer fields more than ones that are already
76+
// short.
77+
l := len(s) - len(s)*excess/total - 1
78+
lengths = append(lengths, l)
79+
remaining -= l
80+
}
81+
// Add fractional space that was rounded down.
82+
for i := 0; i < remaining; i++ {
83+
lengths[i]++
84+
}
85+
86+
var ret []string
87+
for i, l := range lengths {
88+
ret = append(ret, fields[i][:l])
89+
}
90+
91+
return ret
92+
}

csmnamer/hasher_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright 2023 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// DO NOT EDIT: This is a sync of services_platform/thetis/common/gke_net/naming_test.go
16+
// and should not be modified to maintain functional consistency.
17+
18+
package csmnamer
19+
20+
import "testing"
21+
22+
func TestTrimFieldsEvenly(t *testing.T) {
23+
longString := "01234567890123456789012345678901234567890123456789"
24+
cases := []struct {
25+
desc string
26+
fields []string
27+
want []string
28+
max int
29+
}{
30+
{
31+
desc: "no-change",
32+
fields: []string{longString},
33+
want: []string{longString},
34+
max: 100,
35+
},
36+
{
37+
desc: "equal-to-max-and-no-change",
38+
fields: []string{longString, longString},
39+
want: []string{longString, longString},
40+
max: 100,
41+
},
42+
{
43+
desc: "equally-trimmed-to-half",
44+
fields: []string{longString, longString},
45+
want: []string{longString[:25], longString[:25]},
46+
max: 50,
47+
},
48+
{
49+
desc: "trimmed-to-only-10",
50+
fields: []string{longString, longString, longString},
51+
want: []string{longString[:4], longString[:3], longString[:3]},
52+
max: 10,
53+
},
54+
{
55+
desc: "trimmed-to-only-3",
56+
fields: []string{longString, longString, longString},
57+
want: []string{longString[:1], longString[:1], longString[:1]},
58+
max: 3,
59+
},
60+
{
61+
desc: "one-long-field-with-one-short-field",
62+
fields: []string{longString, longString[:10]},
63+
want: []string{"01234567890123456", "012"},
64+
max: 20,
65+
},
66+
{
67+
desc: "one-long-field-with-one-short-field-and-trimmed-to-1",
68+
fields: []string{longString, longString[:1]},
69+
want: []string{longString[:1], ""},
70+
max: 1,
71+
},
72+
{
73+
desc: "one-long-field-with-one-short-field-and-trimmed-to-5",
74+
fields: []string{longString, longString[:1]},
75+
want: []string{longString[:5], ""},
76+
max: 5,
77+
},
78+
}
79+
80+
for _, tc := range cases {
81+
t.Run(tc.desc, func(t *testing.T) {
82+
got := TrimFieldsEvenly(tc.max, tc.fields...)
83+
if len(got) != len(tc.want) {
84+
t.Fatalf("TrimFieldsEvenly(): got length %d, want %d", len(got), len(tc.want))
85+
}
86+
87+
totalLen := 0
88+
for i := range got {
89+
totalLen += len(got[i])
90+
if got[i] != tc.want[i] {
91+
t.Errorf("TrimFieldsEvenly(): got the %d field to be %q, want %q", i, got[i], tc.want[i])
92+
}
93+
}
94+
95+
if tc.max < totalLen {
96+
t.Errorf("TrimFieldsEvenly(): got total length %d, want less than %d", totalLen, tc.max)
97+
}
98+
})
99+
}
100+
}

csmnamer/namer.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright 2023 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// DO NOT EDIT: This code is a subset of services_platform/thetis/gateway/core/v1alpha2/common/appnettranslator/gsm/namer.go
16+
// and should not be modified to maintain functional consistency.
17+
18+
package csmnamer
19+
20+
import (
21+
"fmt"
22+
"strings"
23+
"unicode"
24+
)
25+
26+
const (
27+
// Length limit for hash created from fields that uniquely identify a GCE resource and
28+
// appended as a suffix to the resource name
29+
nHashLen = 12
30+
// max length of a GCE resource name.
31+
resourceNameMaxLen = 63
32+
// clusterUIDLen is the length of cluster UID, computed as a hash of ClusterName
33+
// prefix used for GCE resource names created by GAMMA mesh.
34+
clusterUIDLen = 4
35+
// csmMeshPrefix is the prefix override used in the CSMMesh use cases.
36+
csmMeshPrefix = "gsmmesh"
37+
)
38+
39+
type MeshNamer struct {
40+
ClusterName string
41+
Location string
42+
}
43+
44+
func (m *MeshNamer) GenerateMeshId() string {
45+
return readableResourceName(m.ClusterName, m.Location)
46+
}
47+
48+
// Returns a readable resource name in the following format
49+
// {prefix}-{component#0}-{component#1}...-{hash}
50+
// The length of the returned resource name is guarantee to be within
51+
// resourceNameLen which is the maximum length of a GCE resource. A component
52+
// will only be included explicitly in the resource name if it doesn't have an
53+
// invalid character (any character that is not a letter, digit or '-').
54+
// Components in the resource name maybe trimmed to fit the maximum length
55+
// requirement. {hash} uniquely identifies the component set.
56+
func readableResourceName(components ...string) string {
57+
// clusterHash enforces uniqueness of resources of different clusters in
58+
// the same project.
59+
clusterHash := Hash(strings.Join(components, ";"), clusterUIDLen)
60+
prefix := csmMeshPrefix + "-" + clusterHash
61+
// resourceHash enforces uniqueness of resources of the same cluster.
62+
resourceHash := Hash(strings.Join(components, ";"), nHashLen)
63+
// Ideally we explicitly include all components in the GCP resource name, so
64+
// it's easier to be related to the corresponding k8s resource(s). However,
65+
// only certain characters are allowed in a GCP resource name(e.g. a common
66+
// character '.' in hostnames is not allowed in GCP resource name).
67+
var explicitComponents []string
68+
for _, c := range components {
69+
// Only explicitly include a component in GCP resource name if all
70+
// characters in it are allowed. Omitting a component here is okay since
71+
// the resourceHash already represents the full component set.
72+
if allCharAllowedInResourceName(c) {
73+
explicitComponents = append(explicitComponents, c)
74+
}
75+
}
76+
// The maximum total length of components is determined by subtracting length
77+
// of the following substring from the maximum length of resource name:
78+
// * prefix
79+
// * separators "-". There will be len(explicitComponents) + 1 of them.
80+
// * hash
81+
componentsMaxLen := resourceNameMaxLen - len(prefix) - (len(explicitComponents) + 1) - len(resourceHash)
82+
// Drop components from the resource name if the allowed maximum total length
83+
// of them is less them the total number of components. (This happens when
84+
// there are too many components)
85+
if componentsMaxLen < len(explicitComponents) {
86+
return fmt.Sprintf("%s-%s", prefix, resourceHash)
87+
}
88+
// Trim components to fit the allowed maximum total length.
89+
trimmed := TrimFieldsEvenly(componentsMaxLen, explicitComponents...)
90+
return fmt.Sprintf("%s-%s-%s", prefix, strings.Join(trimmed, "-"), resourceHash)
91+
}
92+
93+
func allCharAllowedInResourceName(s string) bool {
94+
if len(s) == 0 {
95+
return false
96+
}
97+
for _, r := range s {
98+
if !(unicode.IsDigit(r) || unicode.IsLetter(r) || r == '-') {
99+
return false
100+
}
101+
}
102+
return true
103+
}

0 commit comments

Comments
 (0)