Skip to content

Commit

Permalink
feat: add support for overriding environment label (#824) (#975)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
DeanBruntThirdfort and julienduchesne authored Nov 6, 2024
1 parent 5e0eb01 commit b44bdc9
Show file tree
Hide file tree
Showing 6 changed files with 285 additions and 12 deletions.
7 changes: 6 additions & 1 deletion pkg/kubernetes/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions pkg/process/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion pkg/process/process_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
49 changes: 43 additions & 6 deletions pkg/spec/v1alpha1/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
Expand All @@ -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"`
Expand All @@ -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"`
Expand Down
131 changes: 131 additions & 0 deletions pkg/spec/v1alpha1/environment_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
89 changes: 89 additions & 0 deletions pkg/spec/v1alpha1/reflect_utils.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit b44bdc9

Please sign in to comment.