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 +}