From b44bdc9a210e4b9c734afa89e9a049c4112576ae Mon Sep 17 00:00:00 2001 From: DeanBruntThirdfort <122882392+DeanBruntThirdfort@users.noreply.github.com> Date: Wed, 6 Nov 2024 14:04:31 +0000 Subject: [PATCH] feat: add support for overriding environment label (#824) (#975) * feat: Add support for overriding environment label This change adds support for overriding the environment label using fields on the environment as per #824. To achieve this, the NameLabel() function that generates this needed to be lifted to the Environment struct (from Metadata) to ensure it can access the fields it needs. Additionally, its signature now returns an error as there are ways errors could occur during this generation now that should be surfaced neatly to the user. * Fix misspellings --------- Co-authored-by: Julien Duchesne --- pkg/kubernetes/apply.go | 7 +- pkg/process/process.go | 16 +++- pkg/process/process_test.go | 5 +- pkg/spec/v1alpha1/environment.go | 49 ++++++++-- pkg/spec/v1alpha1/environment_test.go | 131 ++++++++++++++++++++++++++ pkg/spec/v1alpha1/reflect_utils.go | 89 +++++++++++++++++ 6 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 pkg/spec/v1alpha1/environment_test.go create mode 100644 pkg/spec/v1alpha1/reflect_utils.go diff --git a/pkg/kubernetes/apply.go b/pkg/kubernetes/apply.go index 1da8f41b7..5ca312bd2 100644 --- a/pkg/kubernetes/apply.go +++ b/pkg/kubernetes/apply.go @@ -63,8 +63,13 @@ See https://tanka.dev/garbage-collection for more details`) // get all resources matching our label start = time.Now() log.Info().Msg("fetching resources previously created by this env") + + nameLabel, err := k.Env.NameLabel() + if err != nil { + return nil, err + } matched, err := k.ctl.GetByLabels("", kinds, map[string]string{ - process.LabelEnvironment: k.Env.Metadata.NameLabel(), + process.LabelEnvironment: nameLabel, }) if err != nil { return nil, err diff --git a/pkg/process/process.go b/pkg/process/process.go index 5d725e5f2..ec1dfbb6c 100644 --- a/pkg/process/process.go +++ b/pkg/process/process.go @@ -45,7 +45,10 @@ func Process(cfg v1alpha1.Environment, exprs Matchers) (manifest.List, error) { out = Namespace(out, cfg.Spec.Namespace) // tanka.dev/** labels - out = Label(out, cfg) + out, err = Label(out, cfg) + if err != nil { + return nil, err + } // arbitrary labels and annotations from spec out = ResourceDefaults(out, cfg) @@ -62,16 +65,21 @@ func Process(cfg v1alpha1.Environment, exprs Matchers) (manifest.List, error) { } // Label conditionally adds tanka.dev/** labels to each manifest in the List -func Label(list manifest.List, cfg v1alpha1.Environment) manifest.List { +func Label(list manifest.List, cfg v1alpha1.Environment) (manifest.List, error) { for i, m := range list { // inject tanka.dev/environment label if cfg.Spec.InjectLabels { - m.Metadata().Labels()[LabelEnvironment] = cfg.Metadata.NameLabel() + label, err := cfg.NameLabel() + if err != nil { + return nil, fmt.Errorf("failed to get name label: %w", err) + } + + m.Metadata().Labels()[LabelEnvironment] = label } list[i] = m } - return list + return list, nil } func ResourceDefaults(list manifest.List, cfg v1alpha1.Environment) manifest.List { diff --git a/pkg/process/process_test.go b/pkg/process/process_test.go index 9b1a58cae..926a89066 100644 --- a/pkg/process/process_test.go +++ b/pkg/process/process_test.go @@ -116,7 +116,10 @@ func TestProcess(t *testing.T) { if env.Spec.InjectLabels { for i, m := range c.flat { - m.Metadata().Labels()[LabelEnvironment] = env.Metadata.NameLabel() + nameLabel, err := env.NameLabel() + require.NoError(t, err) + + m.Metadata().Labels()[LabelEnvironment] = nameLabel c.flat[i] = m } } diff --git a/pkg/spec/v1alpha1/environment.go b/pkg/spec/v1alpha1/environment.go index 0a8ce4329..033bb615d 100644 --- a/pkg/spec/v1alpha1/environment.go +++ b/pkg/spec/v1alpha1/environment.go @@ -3,7 +3,9 @@ package v1alpha1 import ( "crypto/sha256" "encoding/hex" + "errors" "fmt" + "strings" ) // New creates a new Environment object with internal values already set @@ -31,6 +33,46 @@ type Environment struct { Data interface{} `json:"data,omitempty"` } +func (e Environment) NameLabel() (string, error) { + envLabelFields := e.Spec.TankaEnvLabelFromFields + if len(envLabelFields) == 0 { + envLabelFields = []string{ + ".metadata.name", + ".metadata.namespace", + } + } + + envLabelFieldValues, err := e.getFieldValuesByLabel(envLabelFields) + if err != nil { + return "", fmt.Errorf("failed to retrieve field values for label: %w", err) + } + + labelParts := strings.Join(envLabelFieldValues, ":") + partsHash := sha256.Sum256([]byte(labelParts)) + chars := []rune(hex.EncodeToString(partsHash[:])) + return string(chars[:48]), nil +} + +func (e Environment) getFieldValuesByLabel(labels []string) ([]string, error) { + if len(labels) == 0 { + return nil, errors.New("labels must be set") + } + + fieldValues := make([]string, len(labels)) + for idx, label := range labels { + keyPath := strings.Split(strings.TrimPrefix(label, "."), ".") + + labelValue, err := getDeepFieldAsString(e, keyPath) + if err != nil { + return nil, fmt.Errorf("could not get struct value at path: %w", err) + } + + fieldValues[idx] = labelValue + } + + return fieldValues, nil +} + // Metadata is meant for humans and not parsed type Metadata struct { Name string `json:"name,omitempty"` @@ -49,12 +91,6 @@ func (m Metadata) Get(label string) (value string) { return m.Labels[label] } -func (m Metadata) NameLabel() string { - partsHash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s", m.Name, m.Namespace))) - chars := []rune(hex.EncodeToString(partsHash[:])) - return string(chars[:48]) -} - // Spec defines Kubernetes properties type Spec struct { APIServer string `json:"apiServer,omitempty"` @@ -63,6 +99,7 @@ type Spec struct { DiffStrategy string `json:"diffStrategy,omitempty"` ApplyStrategy string `json:"applyStrategy,omitempty"` InjectLabels bool `json:"injectLabels,omitempty"` + TankaEnvLabelFromFields []string `json:"tankaEnvLabelFromFields,omitempty"` ResourceDefaults ResourceDefaults `json:"resourceDefaults"` ExpectVersions ExpectVersions `json:"expectVersions"` ExportJsonnetImplementation string `json:"exportJsonnetImplementation,omitempty"` diff --git a/pkg/spec/v1alpha1/environment_test.go b/pkg/spec/v1alpha1/environment_test.go new file mode 100644 index 000000000..abaf3d77f --- /dev/null +++ b/pkg/spec/v1alpha1/environment_test.go @@ -0,0 +1,131 @@ +package v1alpha1 + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEnvironmentNameLabel(t *testing.T) { + type testCase struct { + name string + inputEnvironment *Environment + expectedLabelPreHash string + expectError bool + } + + testCases := []testCase{ + { + name: "Default environment label hash", + inputEnvironment: &Environment{ + Spec: Spec{ + Namespace: "default", + }, + Metadata: Metadata{ + Name: "environments/a-nice-go-test", + Namespace: "main.jsonnet", + }, + }, + expectedLabelPreHash: "environments/a-nice-go-test:main.jsonnet", + }, + { + name: "Overridden single nested field", + inputEnvironment: &Environment{ + Spec: Spec{ + Namespace: "default", + TankaEnvLabelFromFields: []string{ + ".metadata.name", + }, + }, + Metadata: Metadata{ + Name: "environments/another-nice-go-test", + }, + }, + expectedLabelPreHash: "environments/another-nice-go-test", + }, + { + name: "Overridden multiple nested field", + inputEnvironment: &Environment{ + Spec: Spec{ + Namespace: "default", + TankaEnvLabelFromFields: []string{ + ".metadata.name", + ".spec.namespace", + }, + }, + Metadata: Metadata{ + Name: "environments/another-nice-go-test", + }, + }, + expectedLabelPreHash: "environments/another-nice-go-test:default", + }, + { + name: "Override field of map type", + inputEnvironment: &Environment{ + Spec: Spec{ + TankaEnvLabelFromFields: []string{ + ".metadata.labels.project", + }, + }, + Metadata: Metadata{ + Name: "environments/another-nice-go-test", + Labels: map[string]string{ + "project": "an-equally-nice-project", + }, + }, + }, + expectedLabelPreHash: "an-equally-nice-project", + }, + { + name: "Label value not primitive type", + inputEnvironment: &Environment{ + Spec: Spec{ + TankaEnvLabelFromFields: []string{ + ".metadata", + }, + }, + Metadata: Metadata{ + Name: "environments/another-nice-go-test", + }, + }, + expectError: true, + }, + { + name: "Attempted descent past non-object like type", + inputEnvironment: &Environment{ + Spec: Spec{ + TankaEnvLabelFromFields: []string{ + ".metadata.name.nonExistent", + }, + }, + Metadata: Metadata{ + Name: "environments/not-an-object", + }, + }, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + expectedLabelHashParts := sha256.Sum256([]byte(tc.expectedLabelPreHash)) + expectedLabelHashChars := []rune(hex.EncodeToString(expectedLabelHashParts[:])) + expectedLabelHash := string(expectedLabelHashChars[:48]) + actualLabelHash, err := tc.inputEnvironment.NameLabel() + + if tc.expectedLabelPreHash != "" { + assert.Equal(t, expectedLabelHash, actualLabelHash) + } else { + assert.Equal(t, "", actualLabelHash) + } + + if tc.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/spec/v1alpha1/reflect_utils.go b/pkg/spec/v1alpha1/reflect_utils.go new file mode 100644 index 000000000..9142d91fd --- /dev/null +++ b/pkg/spec/v1alpha1/reflect_utils.go @@ -0,0 +1,89 @@ +package v1alpha1 + +import ( + "errors" + "reflect" + "strconv" + "strings" +) + +func getDeepFieldAsString(obj interface{}, keyPath []string) (string, error) { + if !isSupportedType(obj, []reflect.Kind{reflect.Struct, reflect.Pointer, reflect.Map}) { + return "", errors.New("intermediary objects must be object types") + } + + objValue := reflectValue(obj) + objType := objValue.Type() + + var nextFieldValue reflect.Value + + switch objType.Kind() { + case reflect.Struct, reflect.Pointer: + fieldsCount := objType.NumField() + + for i := 0; i < fieldsCount; i++ { + candidateType := objType.Field(i) + candidateValue := objValue.Field(i) + jsonTag := candidateType.Tag.Get("json") + + if strings.Split(jsonTag, ",")[0] == keyPath[0] { + nextFieldValue = candidateValue + break + } + } + + case reflect.Map: + for _, key := range objValue.MapKeys() { + nextFieldValue = objValue.MapIndex(key) + } + } + + if len(keyPath) == 1 { + return getReflectValueAsString(nextFieldValue) + } + + if nextFieldValue.Type().Kind() == reflect.Pointer { + nextFieldValue = nextFieldValue.Elem() + } + + return getDeepFieldAsString(nextFieldValue.Interface(), keyPath[1:]) +} + +func getReflectValueAsString(val reflect.Value) (string, error) { + switch val.Type().Kind() { + case reflect.String: + return val.String(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(val.Int(), 10), nil + case reflect.Float32: + return strconv.FormatFloat(val.Float(), 'f', -1, 32), nil + case reflect.Float64: + return strconv.FormatFloat(val.Float(), 'f', -1, 64), nil + case reflect.Bool: + return strconv.FormatBool(val.Bool()), nil + default: + return "", errors.New("unsupported value type") + } +} + +func reflectValue(obj interface{}) reflect.Value { + var val reflect.Value + + if reflect.TypeOf(obj).Kind() == reflect.Pointer { + val = reflect.ValueOf(obj).Elem() + } else { + val = reflect.ValueOf(obj) + } + + return val +} + +func isSupportedType(obj interface{}, types []reflect.Kind) bool { + for _, t := range types { + if reflect.TypeOf(obj).Kind() == t { + return true + } + } + + return false +}