add prototype sorting for table rows

pull/8/head
juanvallejo 2018-05-30 15:24:55 -04:00
parent 113872798d
commit c32d10f3cf
No known key found for this signature in database
GPG Key ID: 7D2C958002D6448D
9 changed files with 370 additions and 33 deletions

View File

@ -2,9 +2,10 @@ apiVersion: v1
kind: Pod kind: Pod
metadata: metadata:
name: sorted-pod1 name: sorted-pod1
creationTimestamp: 2018-08-30T14:10:58Z
labels: labels:
name: sorted-pod3-label name: sorted-pod3-label
spec: spec:
containers: containers:
- name: kubernetes-pause - name: kubernetes-pause2
image: k8s.gcr.io/pause:2.0 image: k8s.gcr.io/pause:2.0

View File

@ -2,9 +2,10 @@ apiVersion: v1
kind: Pod kind: Pod
metadata: metadata:
name: sorted-pod2 name: sorted-pod2
creationTimestamp: 2018-08-30T14:10:55Z
labels: labels:
name: sorted-pod2-label name: sorted-pod2-label
spec: spec:
containers: containers:
- name: kubernetes-pause - name: kubernetes-pause1
image: k8s.gcr.io/pause:2.0 image: k8s.gcr.io/pause:2.0

View File

@ -2,9 +2,10 @@ apiVersion: v1
kind: Pod kind: Pod
metadata: metadata:
name: sorted-pod3 name: sorted-pod3
creationTimestamp: 2018-08-30T14:10:53Z
labels: labels:
name: sorted-pod1-label name: sorted-pod1-label
spec: spec:
containers: containers:
- name: kubernetes-pause - name: kubernetes-pause3
image: k8s.gcr.io/pause:2.0 image: k8s.gcr.io/pause:2.0

View File

@ -144,6 +144,7 @@ go_library(
"//staging/src/k8s.io/apimachinery/pkg/api/resource:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/api/resource:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1beta1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/labels:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/labels:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/runtime/schema:go_default_library",

View File

@ -81,6 +81,7 @@ go_test(
"//staging/src/k8s.io/api/core/v1:go_default_library", "//staging/src/k8s.io/api/core/v1:go_default_library",
"//staging/src/k8s.io/api/extensions/v1beta1:go_default_library", "//staging/src/k8s.io/api/extensions/v1beta1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1beta1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/runtime/schema:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/runtime/serializer/json:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/runtime/serializer/json:go_default_library",

View File

@ -207,10 +207,10 @@ func (o *GetOptions) Complete(f cmdutil.Factory, cmd *cobra.Command, args []stri
o.NoHeaders = cmdutil.GetFlagBool(cmd, "no-headers") o.NoHeaders = cmdutil.GetFlagBool(cmd, "no-headers")
// TODO (soltysh): currently we don't support sorting and custom columns // TODO (soltysh): currently we don't support custom columns
// with server side print. So in these cases force the old behavior. // with server side print. So in these cases force the old behavior.
outputOption := cmd.Flags().Lookup("output").Value.String() outputOption := cmd.Flags().Lookup("output").Value.String()
if o.Sort && outputOption == "custom-columns" { if outputOption == "custom-columns" {
o.ServerPrint = false o.ServerPrint = false
} }
@ -296,6 +296,96 @@ func (o *GetOptions) Validate(cmd *cobra.Command) error {
return nil return nil
} }
type OriginalPositioner interface {
OriginalPosition(int) int
}
type NopPositioner struct{}
func (t *NopPositioner) OriginalPosition(ix int) int {
return ix
}
type RuntimeSorter struct {
field string
decoder runtime.Decoder
objects []runtime.Object
positioner OriginalPositioner
}
func (r *RuntimeSorter) Sort() error {
if len(r.objects) <= 1 {
// a list is only considered "sorted" if there are 0 or 1 items in it
// AND (if 1 item) the item is not a Table object
_, isTable := r.objects[0].(*metav1beta1.Table)
if len(r.objects) == 0 || !isTable {
return nil
}
}
includesTable := false
includesRuntimeObjs := false
for _, obj := range r.objects {
switch t := obj.(type) {
case *metav1beta1.Table:
includesTable = true
if err := kubectl.NewTableSorter(t, r.field).Sort(); err != nil {
continue
}
default:
includesRuntimeObjs = true
}
}
// we use a NopPositioner when dealing with Table objects
// because the objects themselves are not swapped, but rather
// the rows in each object are swapped / sorted.
r.positioner = &NopPositioner{}
if includesRuntimeObjs && includesTable {
return fmt.Errorf("sorting is not supported on mixed Table and non-Table object lists")
}
if includesTable {
return nil
}
// if not dealing with a Table response from the server, assume
// all objects are runtime.Object as usual, and sort using old method.
var err error
if r.positioner, err = kubectl.SortObjects(r.decoder, r.objects, r.field); err != nil {
return err
}
return nil
}
func (r *RuntimeSorter) OriginalPosition(ix int) int {
if r.positioner == nil {
return 0
}
return r.positioner.OriginalPosition(ix)
}
// allows custom decoder to be set for testing
func (r *RuntimeSorter) WithDecoder(decoder runtime.Decoder) *RuntimeSorter {
r.decoder = decoder
return r
}
func NewRuntimeSorter(objects []runtime.Object, sortBy string) *RuntimeSorter {
parsedField, err := printers.RelaxedJSONPathExpression(sortBy)
if err != nil {
parsedField = sortBy
}
return &RuntimeSorter{
field: parsedField,
decoder: cmdutil.InternalVersionDecoder(),
objects: objects,
}
}
// Run performs the get operation. // Run performs the get operation.
// TODO: remove the need to pass these arguments, like other commands. // TODO: remove the need to pass these arguments, like other commands.
func (o *GetOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) error { func (o *GetOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) error {
@ -311,6 +401,13 @@ func (o *GetOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) e
fmt.Fprintf(o.IOStreams.ErrOut, "warning: --%s requested, --%s will be ignored\n", useOpenAPIPrintColumnFlagLabel, useServerPrintColumns) fmt.Fprintf(o.IOStreams.ErrOut, "warning: --%s requested, --%s will be ignored\n", useOpenAPIPrintColumnFlagLabel, useServerPrintColumns)
} }
chunkSize := o.ChunkSize
if o.Sort {
// TODO(juanvallejo): in the future, we could have the client use chunking
// to gather all results, then sort them all at the end to reduce server load.
chunkSize = 0
}
r := f.NewBuilder(). r := f.NewBuilder().
Unstructured(). Unstructured().
NamespaceParam(o.Namespace).DefaultNamespace().AllNamespaces(o.AllNamespaces). NamespaceParam(o.Namespace).DefaultNamespace().AllNamespaces(o.AllNamespaces).
@ -318,7 +415,7 @@ func (o *GetOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) e
LabelSelectorParam(o.LabelSelector). LabelSelectorParam(o.LabelSelector).
FieldSelectorParam(o.FieldSelector). FieldSelectorParam(o.FieldSelector).
ExportParam(o.Export). ExportParam(o.Export).
RequestChunksOf(o.ChunkSize). RequestChunksOf(chunkSize).
IncludeUninitialized(o.IncludeUninitialized). IncludeUninitialized(o.IncludeUninitialized).
ResourceTypeOrNameArgs(true, args...). ResourceTypeOrNameArgs(true, args...).
ContinueOnError(). ContinueOnError().
@ -329,12 +426,19 @@ func (o *GetOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) e
if o.PrintWithOpenAPICols { if o.PrintWithOpenAPICols {
return return
} }
if o.ServerPrint && o.IsHumanReadablePrinter && !o.Sort { if !o.ServerPrint || !o.IsHumanReadablePrinter {
group := metav1beta1.GroupName return
version := metav1beta1.SchemeGroupVersion.Version }
tableParam := fmt.Sprintf("application/json;as=Table;v=%s;g=%s, application/json", version, group) group := metav1beta1.GroupName
req.SetHeader("Accept", tableParam) version := metav1beta1.SchemeGroupVersion.Version
tableParam := fmt.Sprintf("application/json;as=Table;v=%s;g=%s, application/json", version, group)
req.SetHeader("Accept", tableParam)
// if sorting, ensure we receive the full object in order to introspect its fields via jsonpath
if o.Sort {
req.Param("includeObject", "Object")
} }
}). }).
Do() Do()
@ -378,12 +482,14 @@ func (o *GetOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) e
if err != nil { if err != nil {
return err return err
} }
var sorter *kubectl.RuntimeSort
if o.Sort && len(objs) > 1 { var positioner OriginalPositioner
// TODO: questionable if o.Sort {
if sorter, err = kubectl.SortObjects(cmdutil.InternalVersionDecoder(), objs, sorting); err != nil { sorter := NewRuntimeSorter(objs, sorting)
if err := sorter.Sort(); err != nil {
return err return err
} }
positioner = sorter
} }
var printer printers.ResourcePrinter var printer printers.ResourcePrinter
@ -393,8 +499,8 @@ func (o *GetOptions) Run(f cmdutil.Factory, cmd *cobra.Command, args []string) e
for ix := range objs { for ix := range objs {
var mapping *meta.RESTMapping var mapping *meta.RESTMapping
var info *resource.Info var info *resource.Info
if sorter != nil { if positioner != nil {
info = infos[sorter.OriginalPosition(ix)] info = infos[positioner.OriginalPosition(ix)]
mapping = info.Mapping mapping = info.Mapping
} else { } else {
info = infos[ix] info = infos[ix]

View File

@ -19,6 +19,7 @@ package get
import ( import (
"bytes" "bytes"
encjson "encoding/json" encjson "encoding/json"
"fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
@ -34,6 +35,7 @@ import (
api "k8s.io/api/core/v1" api "k8s.io/api/core/v1"
apiextensionsv1beta1 "k8s.io/api/extensions/v1beta1" apiextensionsv1beta1 "k8s.io/api/extensions/v1beta1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer/json" "k8s.io/apimachinery/pkg/runtime/serializer/json"
@ -526,6 +528,150 @@ c 0/0 0 <unknown>
} }
} }
func sortTestData() []runtime.Object {
return []runtime.Object{
&api.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "test", ResourceVersion: "10"},
Spec: apitesting.V1DeepEqualSafePodSpec(),
},
&api.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "test", ResourceVersion: "11"},
Spec: apitesting.V1DeepEqualSafePodSpec(),
},
&api.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "test", ResourceVersion: "9"},
Spec: apitesting.V1DeepEqualSafePodSpec(),
},
}
}
func sortTestTableData() []runtime.Object {
return []runtime.Object{
&metav1beta1.Table{
TypeMeta: metav1.TypeMeta{Kind: "Table"},
Rows: []metav1beta1.TableRow{
{
Object: runtime.RawExtension{
Object: &api.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "c", Namespace: "test", ResourceVersion: "10"},
Spec: apitesting.V1DeepEqualSafePodSpec(),
},
},
},
{
Object: runtime.RawExtension{
Object: &api.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "b", Namespace: "test", ResourceVersion: "11"},
Spec: apitesting.V1DeepEqualSafePodSpec(),
},
},
},
{
Object: runtime.RawExtension{
Object: &api.Pod{
ObjectMeta: metav1.ObjectMeta{Name: "a", Namespace: "test", ResourceVersion: "9"},
Spec: apitesting.V1DeepEqualSafePodSpec(),
},
},
},
},
},
}
}
func TestRuntimeSorter(t *testing.T) {
tests := []struct {
name string
field string
objs []runtime.Object
op func(sorter *RuntimeSorter, objs []runtime.Object, out io.Writer) error
expect string
expectError string
}{
{
name: "ensure sorter returns original position",
field: "metadata.name",
objs: sortTestData(),
op: func(sorter *RuntimeSorter, objs []runtime.Object, out io.Writer) error {
for idx := range objs {
p := sorter.OriginalPosition(idx)
fmt.Fprintf(out, "%v,", p)
}
return nil
},
expect: "2,1,0,",
},
{
name: "ensure sorter handles table object position",
field: "metadata.name",
objs: sortTestTableData(),
op: func(sorter *RuntimeSorter, objs []runtime.Object, out io.Writer) error {
for idx := range objs {
p := sorter.OriginalPosition(idx)
fmt.Fprintf(out, "%v,", p)
}
return nil
},
expect: "0,",
},
{
name: "ensure sorter sorts table objects",
field: "metadata.name",
objs: sortTestData(),
op: func(sorter *RuntimeSorter, objs []runtime.Object, out io.Writer) error {
for _, o := range objs {
fmt.Fprintf(out, "%s,", o.(*api.Pod).Name)
}
return nil
},
expect: "a,b,c,",
},
{
name: "ensure sorter rejects mixed Table + non-Table object lists",
field: "metadata.name",
objs: append(sortTestData(), sortTestTableData()...),
op: func(sorter *RuntimeSorter, objs []runtime.Object, out io.Writer) error { return nil },
expectError: "sorting is not supported on mixed Table",
},
{
name: "ensure sorter errors out on invalid jsonpath",
field: "metadata.unknown",
objs: sortTestData(),
op: func(sorter *RuntimeSorter, objs []runtime.Object, out io.Writer) error { return nil },
expectError: "couldn't find any field with path",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
sorter := NewRuntimeSorter(tc.objs, tc.field)
if err := sorter.Sort(); err != nil {
if len(tc.expectError) > 0 && strings.Contains(err.Error(), tc.expectError) {
return
}
if len(tc.expectError) > 0 {
t.Fatalf("unexpected error: expecting %s, but got %s", tc.expectError, err)
}
t.Fatalf("unexpected error: %v", err)
}
out := bytes.NewBuffer([]byte{})
err := tc.op(sorter, tc.objs, out)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tc.expect != out.String() {
t.Fatalf("unexpected output: expecting %s, but got %s", tc.expect, out.String())
}
})
}
}
func TestGetObjectsIdentifiedByFile(t *testing.T) { func TestGetObjectsIdentifiedByFile(t *testing.T) {
pods, _, _ := testData() pods, _, _ := testData()

View File

@ -28,6 +28,7 @@ import (
"k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/util/integer" "k8s.io/client-go/util/integer"
"k8s.io/client-go/util/jsonpath" "k8s.io/client-go/util/jsonpath"
@ -111,12 +112,7 @@ func SortObjects(decoder runtime.Decoder, objs []runtime.Object, fieldInput stri
// Note that this requires empty fields to be considered later, when sorting. // Note that this requires empty fields to be considered later, when sorting.
var fieldFoundOnce bool var fieldFoundOnce bool
for _, obj := range objs { for _, obj := range objs {
var values [][]reflect.Value values, err := findJSONPathResults(parser, obj)
if unstructured, ok := obj.(*unstructured.Unstructured); ok {
values, err = parser.FindResults(unstructured.Object)
} else {
values, err = parser.FindResults(reflect.ValueOf(obj).Elem().Interface())
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -274,20 +270,12 @@ func (r *RuntimeSort) Less(i, j int) bool {
panic(err) panic(err)
} }
if unstructured, ok := iObj.(*unstructured.Unstructured); ok { iValues, err = findJSONPathResults(parser, iObj)
iValues, err = parser.FindResults(unstructured.Object)
} else {
iValues, err = parser.FindResults(reflect.ValueOf(iObj).Elem().Interface())
}
if err != nil { if err != nil {
glog.Fatalf("Failed to get i values for %#v using %s (%#v)", iObj, r.field, err) glog.Fatalf("Failed to get i values for %#v using %s (%#v)", iObj, r.field, err)
} }
if unstructured, ok := jObj.(*unstructured.Unstructured); ok { jValues, err = findJSONPathResults(parser, jObj)
jValues, err = parser.FindResults(unstructured.Object)
} else {
jValues, err = parser.FindResults(reflect.ValueOf(jObj).Elem().Interface())
}
if err != nil { if err != nil {
glog.Fatalf("Failed to get j values for %#v using %s (%v)", jObj, r.field, err) glog.Fatalf("Failed to get j values for %#v using %s (%v)", jObj, r.field, err)
} }
@ -316,3 +304,77 @@ func (r *RuntimeSort) OriginalPosition(ix int) int {
} }
return r.origPosition[ix] return r.origPosition[ix]
} }
type TableSorter struct {
field string
obj *metav1beta1.Table
}
func (t *TableSorter) Len() int {
return len(t.obj.Rows)
}
func (t *TableSorter) Swap(i, j int) {
t.obj.Rows[i], t.obj.Rows[j] = t.obj.Rows[j], t.obj.Rows[i]
}
func (t *TableSorter) Less(i, j int) bool {
iObj := t.obj.Rows[i].Object.Object
jObj := t.obj.Rows[j].Object.Object
var iValues [][]reflect.Value
var jValues [][]reflect.Value
var err error
parser := jsonpath.New("sorting").AllowMissingKeys(true)
err = parser.Parse(t.field)
if err != nil {
glog.Fatalf("sorting error: %v\n", err)
}
// TODO(juanvallejo): this is expensive for very large sets.
// To improve runtime complexity, build an array which contains all
// resolved fields, and sort that instead.
iValues, err = findJSONPathResults(parser, iObj)
if err != nil {
glog.Fatalf("Failed to get i values for %#v using %s (%#v)", iObj, t.field, err)
}
jValues, err = findJSONPathResults(parser, jObj)
if err != nil {
glog.Fatalf("Failed to get j values for %#v using %s (%v)", jObj, t.field, err)
}
if len(iValues) == 0 || len(iValues[0]) == 0 || len(jValues) == 0 || len(jValues[0]) == 0 {
glog.Fatalf("couldn't find any field with path %q in the list of objects", t.field)
}
iField := iValues[0][0]
jField := jValues[0][0]
less, err := isLess(iField, jField)
if err != nil {
glog.Fatalf("Field %s in %T is an unsortable type: %s, err: %v", t.field, iObj, iField.Kind().String(), err)
}
return less
}
func (t *TableSorter) Sort() error {
sort.Sort(t)
return nil
}
func NewTableSorter(table *metav1beta1.Table, field string) *TableSorter {
return &TableSorter{
obj: table,
field: field,
}
}
func findJSONPathResults(parser *jsonpath.JSONPath, from runtime.Object) ([][]reflect.Value, error) {
if unstructuredObj, ok := from.(*unstructured.Unstructured); ok {
return parser.FindResults(unstructuredObj.Object)
}
return parser.FindResults(reflect.ValueOf(from).Elem().Interface())
}

View File

@ -240,6 +240,11 @@ run_kubectl_sort_by_tests() {
# Check output of sort-by # Check output of sort-by
output_message=$(kubectl get pods --sort-by="{metadata.name}") output_message=$(kubectl get pods --sort-by="{metadata.name}")
kube::test::if_has_string "${output_message}" "valid-pod" kube::test::if_has_string "${output_message}" "valid-pod"
# ensure sort-by receivers objects as Table
output_message=$(kubectl get pods --v=8 --sort-by="{metadata.name}" 2>&1)
kube::test::if_has_string "${output_message}" "as=Table"
# ensure sort-by requests the full object
kube::test::if_has_string "${output_message}" "includeObject=Object"
### Clean up ### Clean up
# Pre-condition: valid-pod exists # Pre-condition: valid-pod exists
kube::test::get_object_assert pods "{{range.items}}{{$id_field}}:{{end}}" 'valid-pod:' kube::test::get_object_assert pods "{{range.items}}{{$id_field}}:{{end}}" 'valid-pod:'
@ -273,6 +278,19 @@ run_kubectl_sort_by_tests() {
output_message=$(kubectl get pods --sort-by="{metadata.labels.name}") output_message=$(kubectl get pods --sort-by="{metadata.labels.name}")
kube::test::if_sort_by_has_correct_order "${output_message}" "sorted-pod3:sorted-pod2:sorted-pod1:" kube::test::if_sort_by_has_correct_order "${output_message}" "sorted-pod3:sorted-pod2:sorted-pod1:"
# if sorting, we should be able to use any field in our objects
output_message=$(kubectl get pods --sort-by="{spec.containers[0].name}")
kube::test::if_sort_by_has_correct_order "${output_message}" "sorted-pod2:sorted-pod1:sorted-pod3:"
# ensure sorting by creation timestamps works
output_message=$(kubectl get pods --sort-by="{metadata.creationTimestamp}")
kube::test::if_sort_by_has_correct_order "${output_message}" "sorted-pod1:sorted-pod2:sorted-pod3:"
# ensure sorting using fallback codepath still works
output_message=$(kubectl get pods --sort-by="{spec.containers[0].name}" --server-print=false --v=8 2>&1)
kube::test::if_sort_by_has_correct_order "${output_message}" "sorted-pod2:sorted-pod1:sorted-pod3:"
kube::test::if_has_not_string "${output_message}" "Table"
### Clean up ### Clean up
# Pre-condition: valid-pod exists # Pre-condition: valid-pod exists
kube::test::get_object_assert pods "{{range.items}}{{$id_field}}:{{end}}" 'sorted-pod1:sorted-pod2:sorted-pod3:' kube::test::get_object_assert pods "{{range.items}}{{$id_field}}:{{end}}" 'sorted-pod1:sorted-pod2:sorted-pod3:'