Skip to content

Commit

Permalink
add wildcard properties to satellite spec
Browse files Browse the repository at this point in the history
When setting properties inherited from the Kubernetes Node, it might be useful
to copy all labels/annotations matching a specific prefix. To implement this,
extend the existing "nodeFieldRef" syntax by allowing the key to contain a
"*" character.

When a "*" character is used, it acts like in a glob pattern, matching all keys
on the referenced object. The property name has to contain a "$1", which gets
replaced by whatever was matched by the "*".

Signed-off-by: Moritz Wanzenböck <[email protected]>
  • Loading branch information
WanzenBug committed Aug 27, 2024
1 parent 9785ba0 commit 26134a4
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 51 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Support wildcard patterns when referencing property values from node labels and annotations.

## [v2.5.2] - 2024-07-17

### Added
Expand Down
27 changes: 27 additions & 0 deletions api/v1/linstorsatelliteconfiguration_webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,31 @@ var _ = Describe("LinstorSatelliteConfiguration webhook", func() {
Expect(warningHandler).To(HaveLen(1))
Expect(warningHandler[0].text).To(ContainSubstring("consider targeting the DaemonSet 'linstor-satellite'"))
})

It("should reject wildcard properties without replacement target", func(ctx context.Context) {
satelliteConfig := &piraeusv1.LinstorSatelliteConfiguration{
TypeMeta: typeMeta,
ObjectMeta: metav1.ObjectMeta{Name: "wildcard-properties"},
Spec: piraeusv1.LinstorSatelliteConfigurationSpec{
Properties: []piraeusv1.LinstorNodeProperty{
{
Name: "missing-target-name",
ValueFrom: &piraeusv1.LinstorNodePropertyValueFrom{NodeFieldRef: "metadata.annotations['example.com/*']"},
},
{
Name: "invalid-reference",
ValueFrom: &piraeusv1.LinstorNodePropertyValueFrom{NodeFieldRef: "something random"},
},
},
},
}
err := k8sClient.Patch(ctx, satelliteConfig, client.Apply, client.FieldOwner("test"), client.ForceOwnership)
Expect(err).To(HaveOccurred())
statusErr := err.(*errors.StatusError)
Expect(statusErr).NotTo(BeNil())
Expect(statusErr.ErrStatus.Details).NotTo(BeNil())
Expect(statusErr.ErrStatus.Details.Causes).To(HaveLen(2))
Expect(statusErr.ErrStatus.Details.Causes[0].Field).To(Equal("spec.properties.0.name"))
Expect(statusErr.ErrStatus.Details.Causes[1].Field).To(Equal("spec.properties.1.valueFrom.nodeFieldRef"))
})
})
16 changes: 16 additions & 0 deletions api/v1/properties.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package v1

import (
"fmt"
"strconv"
"strings"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/validation/field"

"github.com/piraeusdatastore/piraeus-operator/v2/pkg/utils/fieldpath"
)

type LinstorControllerProperty struct {
Expand Down Expand Up @@ -53,6 +58,17 @@ func ValidateNodeProperties(props []LinstorNodeProperty, path *field.Path) field
if valSet == fromSet {
result = append(result, field.Invalid(path.Child(strconv.Itoa(i)), p, "Expected exactly one of 'value' and 'valueFrom' to be set"))
}

if fromSet {
_, keys, err := fieldpath.ExtractFieldPath(&corev1.Node{}, p.ValueFrom.NodeFieldRef)
if err != nil {
result = append(result, field.Invalid(path.Child(strconv.Itoa(i), "valueFrom", "nodeFieldRef"), p.ValueFrom.NodeFieldRef, fmt.Sprintf("Invalid reference format: %s", err)))
}

if keys != nil && !strings.Contains(p.Name, "$1") {
result = append(result, field.Invalid(path.Child(strconv.Itoa(i), "name"), p.Name, "Wildcard property requires replacement target `$1` in name"))
}
}
}

return result
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/linstorsatelliteconfiguration.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ The property value can either be set directly using `value`, or inherited from t
`valueFrom`. Metadata fields are specified using the same syntax as the [Downward API](https://kubernetes.io/docs/concepts/workloads/pods/downward-api)
for Pods.

A special case is using `valueFrom` with a reference path ending in a `*` character. This copies all keys and values
that match the given pattern. The property name must contain the special `$1` string, which gets replaced by the part of
the key matching the `*` character.

In addition, setting `optional` to true means the property is only applied if the value is not empty. This is useful
in case the property value should be inherited from the node's metadata

Expand All @@ -74,6 +78,8 @@ This examples sets three Properties on every satellite:
* `AutoplaceTarget` (if set to `no`, will exclude the node from LINSTOR's Autoplacer) takes the value from the
`piraeus.io/autoplace` annotation of the Kubernetes Node. If a node has no `piraeus.io/autoplace` annotation, the
property will not be set.
* `Aux/role/$1` copies all "node-role.kubernetes.io/*" label keys and values. For example, a worker node with the
`node-role.kubernetes.io/worker: "true"` label will have the `Aux/role/worker` set to `"true"`.

```yaml
apiVersion: piraeus.io/v1
Expand All @@ -91,6 +97,9 @@ spec:
valueFrom:
nodeFieldRef: metadata.annotations['piraeus.io/autoplace']
optional: yes
- name: Aux/role/$1
valueFrom:
nodeFieldRef: metadata.labels['node-role.kubernetes.io/*']
```

### `.spec.storagePools`
Expand Down
101 changes: 69 additions & 32 deletions pkg/utils/fieldpath/fieldpath.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,66 +20,103 @@ import (
"fmt"
"strings"

"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation"
)

// FormatMap formats map[string]string to a string.
func FormatMap(m map[string]string) (fmtStr string) {
// output with keys in sorted order to provide stable output
keys := sets.NewString()
for key := range m {
keys.Insert(key)
}
for _, key := range keys.List() {
fmtStr += fmt.Sprintf("%v=%q\n", key, m[key])
}
fmtStr = strings.TrimSuffix(fmtStr, "\n")

return
}

// ExtractFieldPathAsString extracts the field from the given object
// and returns it as a string. The object must be a pointer to an
// API type.
func ExtractFieldPathAsString(obj interface{}, fieldPath string) (string, error) {
// ExtractFieldPath extracts the field(s) from the given object
// and returns the value as string, along with the expanded value of any
// wildcard values.
//
// If a wildcard path was given, keys is not nil.
func ExtractFieldPath(obj interface{}, fieldPath string) ([]string, []string, error) {
accessor, err := meta.Accessor(obj)
if err != nil {
return "", err
return nil, nil, err
}

if path, subscript, ok := SplitMaybeSubscriptedPath(fieldPath); ok {
switch path {
case "metadata.annotations":
if strings.HasSuffix(subscript, "*") {
keys, vals := mapToSlices(filterPrefix(accessor.GetAnnotations(), subscript[:len(subscript)-1]))
return vals, keys, nil
}

if errs := validation.IsQualifiedName(strings.ToLower(subscript)); len(errs) != 0 {
return "", fmt.Errorf("invalid key subscript in %s: %s", fieldPath, strings.Join(errs, ";"))
return nil, nil, fmt.Errorf("invalid key subscript in %s: %s", fieldPath, strings.Join(errs, ";"))
}

val, ok := accessor.GetAnnotations()[subscript]
if ok {
return []string{val}, nil, nil
}
return accessor.GetAnnotations()[subscript], nil

return nil, nil, nil
case "metadata.labels":
if strings.HasSuffix(subscript, "*") {
keys, vals := mapToSlices(filterPrefix(accessor.GetLabels(), subscript[:len(subscript)-1]))
return vals, keys, nil
}

if errs := validation.IsQualifiedName(subscript); len(errs) != 0 {
return "", fmt.Errorf("invalid key subscript in %s: %s", fieldPath, strings.Join(errs, ";"))
return nil, nil, fmt.Errorf("invalid key subscript in %s: %s", fieldPath, strings.Join(errs, ";"))
}
return accessor.GetLabels()[subscript], nil

val, ok := accessor.GetLabels()[subscript]
if ok {
return []string{val}, nil, nil
}

return nil, nil, nil
default:
return "", fmt.Errorf("fieldPath %q does not support subscript", fieldPath)
return nil, nil, fmt.Errorf("fieldPath %q does not support subscript", fieldPath)
}
}

switch fieldPath {
case "metadata.annotations":
return FormatMap(accessor.GetAnnotations()), nil
keys, values := mapToSlices(accessor.GetAnnotations())
return values, keys, nil
case "metadata.labels":
return FormatMap(accessor.GetLabels()), nil
keys, values := mapToSlices(accessor.GetLabels())
return values, keys, nil
case "metadata.name":
return accessor.GetName(), nil
return []string{accessor.GetName()}, nil, nil
case "metadata.namespace":
return accessor.GetNamespace(), nil
return []string{accessor.GetNamespace()}, nil, nil
case "metadata.uid":
return string(accessor.GetUID()), nil
return []string{string(accessor.GetUID())}, nil, nil
}

return nil, nil, fmt.Errorf("unsupported fieldPath: %v", fieldPath)
}

// filterPrefix returns all key-values where the key has the prefix.
// The new key will be the rest of the key.
func filterPrefix(m map[string]string, prefix string) map[string]string {
result := map[string]string{}
for k, v := range m {
if strings.HasPrefix(k, prefix) {
result[k[len(prefix):]] = v
}
}

return result
}

func mapToSlices(m map[string]string) ([]string, []string) {
keys := maps.Keys(m)
slices.Sort(keys)

values := make([]string, 0, len(keys))
for _, key := range keys {
values = append(values, m[key])
}

return "", fmt.Errorf("unsupported fieldPath: %v", fieldPath)
return keys, values
}

// SplitMaybeSubscriptedPath checks whether the specified fieldPath is
Expand Down
Loading

0 comments on commit 26134a4

Please sign in to comment.