1
16
17 package get
18
19 import (
20 "encoding/json"
21 "reflect"
22 "strings"
23 "testing"
24
25 "github.com/google/go-cmp/cmp"
26 corev1 "k8s.io/api/core/v1"
27 "k8s.io/apimachinery/pkg/api/meta"
28 "k8s.io/apimachinery/pkg/api/resource"
29 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
30 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
31 metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
32 "k8s.io/apimachinery/pkg/runtime"
33 "k8s.io/kubectl/pkg/scheme"
34 )
35
36 func toUnstructuredOrDie(data []byte) *unstructured.Unstructured {
37 unstrBody := map[string]interface{}{}
38 err := json.Unmarshal(data, &unstrBody)
39 if err != nil {
40 panic(err)
41 }
42 return &unstructured.Unstructured{Object: unstrBody}
43 }
44 func encodeOrDie(obj runtime.Object) []byte {
45 data, err := runtime.Encode(scheme.Codecs.LegacyCodec(corev1.SchemeGroupVersion), obj)
46 if err != nil {
47 panic(err.Error())
48 }
49 return data
50 }
51 func createPodSpecResource(t *testing.T, memReq, memLimit, cpuReq, cpuLimit string) corev1.PodSpec {
52 t.Helper()
53 podSpec := corev1.PodSpec{
54 Containers: []corev1.Container{
55 {
56 Resources: corev1.ResourceRequirements{
57 Requests: corev1.ResourceList{},
58 Limits: corev1.ResourceList{},
59 },
60 },
61 },
62 }
63
64 req := podSpec.Containers[0].Resources.Requests
65 if memReq != "" {
66 memReq, err := resource.ParseQuantity(memReq)
67 if err != nil {
68 t.Errorf("memory request string is not a valid quantity")
69 }
70 req["memory"] = memReq
71 }
72 if cpuReq != "" {
73 cpuReq, err := resource.ParseQuantity(cpuReq)
74 if err != nil {
75 t.Errorf("cpu request string is not a valid quantity")
76 }
77 req["cpu"] = cpuReq
78 }
79 limit := podSpec.Containers[0].Resources.Limits
80 if memLimit != "" {
81 memLimit, err := resource.ParseQuantity(memLimit)
82 if err != nil {
83 t.Errorf("memory limit string is not a valid quantity")
84 }
85 limit["memory"] = memLimit
86 }
87 if cpuLimit != "" {
88 cpuLimit, err := resource.ParseQuantity(cpuLimit)
89 if err != nil {
90 t.Errorf("cpu limit string is not a valid quantity")
91 }
92 limit["cpu"] = cpuLimit
93 }
94
95 return podSpec
96 }
97 func createUnstructuredPodResource(t *testing.T, memReq, memLimit, cpuReq, cpuLimit string) unstructured.Unstructured {
98 t.Helper()
99 pod := &corev1.Pod{
100 Spec: createPodSpecResource(t, memReq, memLimit, cpuReq, cpuLimit),
101 }
102 return *toUnstructuredOrDie(encodeOrDie(pod))
103 }
104
105 func TestSortingPrinter(t *testing.T) {
106 intPtr := func(val int32) *int32 { return &val }
107
108 a := &corev1.Pod{
109 ObjectMeta: metav1.ObjectMeta{
110 Name: "a",
111 },
112 }
113
114 b := &corev1.Pod{
115 ObjectMeta: metav1.ObjectMeta{
116 Name: "b",
117 },
118 }
119
120 c := &corev1.Pod{
121 ObjectMeta: metav1.ObjectMeta{
122 Name: "c",
123 },
124 }
125
126 tests := []struct {
127 obj runtime.Object
128 sort runtime.Object
129 field string
130 name string
131 expectedErr string
132 }{
133 {
134 name: "empty",
135 obj: &corev1.PodList{
136 Items: []corev1.Pod{},
137 },
138 sort: &corev1.PodList{
139 Items: []corev1.Pod{},
140 },
141 field: "{.metadata.name}",
142 },
143 {
144 name: "in-order-already",
145 obj: &corev1.PodList{
146 Items: []corev1.Pod{
147 {
148 ObjectMeta: metav1.ObjectMeta{
149 Name: "a",
150 },
151 },
152 {
153 ObjectMeta: metav1.ObjectMeta{
154 Name: "b",
155 },
156 },
157 {
158 ObjectMeta: metav1.ObjectMeta{
159 Name: "c",
160 },
161 },
162 },
163 },
164 sort: &corev1.PodList{
165 Items: []corev1.Pod{
166 {
167 ObjectMeta: metav1.ObjectMeta{
168 Name: "a",
169 },
170 },
171 {
172 ObjectMeta: metav1.ObjectMeta{
173 Name: "b",
174 },
175 },
176 {
177 ObjectMeta: metav1.ObjectMeta{
178 Name: "c",
179 },
180 },
181 },
182 },
183 field: "{.metadata.name}",
184 },
185 {
186 name: "reverse-order",
187 obj: &corev1.PodList{
188 Items: []corev1.Pod{
189 {
190 ObjectMeta: metav1.ObjectMeta{
191 Name: "b",
192 },
193 },
194 {
195 ObjectMeta: metav1.ObjectMeta{
196 Name: "c",
197 },
198 },
199 {
200 ObjectMeta: metav1.ObjectMeta{
201 Name: "a",
202 },
203 },
204 },
205 },
206 sort: &corev1.PodList{
207 Items: []corev1.Pod{
208 {
209 ObjectMeta: metav1.ObjectMeta{
210 Name: "a",
211 },
212 },
213 {
214 ObjectMeta: metav1.ObjectMeta{
215 Name: "b",
216 },
217 },
218 {
219 ObjectMeta: metav1.ObjectMeta{
220 Name: "c",
221 },
222 },
223 },
224 },
225 field: "{.metadata.name}",
226 },
227 {
228 name: "random-order-timestamp",
229 obj: &corev1.PodList{
230 Items: []corev1.Pod{
231 {
232 ObjectMeta: metav1.ObjectMeta{
233 CreationTimestamp: metav1.Unix(300, 0),
234 },
235 },
236 {
237 ObjectMeta: metav1.ObjectMeta{
238 CreationTimestamp: metav1.Unix(100, 0),
239 },
240 },
241 {
242 ObjectMeta: metav1.ObjectMeta{
243 CreationTimestamp: metav1.Unix(200, 0),
244 },
245 },
246 },
247 },
248 sort: &corev1.PodList{
249 Items: []corev1.Pod{
250 {
251 ObjectMeta: metav1.ObjectMeta{
252 CreationTimestamp: metav1.Unix(100, 0),
253 },
254 },
255 {
256 ObjectMeta: metav1.ObjectMeta{
257 CreationTimestamp: metav1.Unix(200, 0),
258 },
259 },
260 {
261 ObjectMeta: metav1.ObjectMeta{
262 CreationTimestamp: metav1.Unix(300, 0),
263 },
264 },
265 },
266 },
267 field: "{.metadata.creationTimestamp}",
268 },
269 {
270 name: "random-order-numbers",
271 obj: &corev1.ReplicationControllerList{
272 Items: []corev1.ReplicationController{
273 {
274 Spec: corev1.ReplicationControllerSpec{
275 Replicas: intPtr(5),
276 },
277 },
278 {
279 Spec: corev1.ReplicationControllerSpec{
280 Replicas: intPtr(1),
281 },
282 },
283 {
284 Spec: corev1.ReplicationControllerSpec{
285 Replicas: intPtr(9),
286 },
287 },
288 },
289 },
290 sort: &corev1.ReplicationControllerList{
291 Items: []corev1.ReplicationController{
292 {
293 Spec: corev1.ReplicationControllerSpec{
294 Replicas: intPtr(1),
295 },
296 },
297 {
298 Spec: corev1.ReplicationControllerSpec{
299 Replicas: intPtr(5),
300 },
301 },
302 {
303 Spec: corev1.ReplicationControllerSpec{
304 Replicas: intPtr(9),
305 },
306 },
307 },
308 },
309 field: "{.spec.replicas}",
310 },
311 {
312 name: "v1.List in order",
313 obj: &corev1.List{
314 Items: []runtime.RawExtension{
315 {Object: a, Raw: encodeOrDie(a)},
316 {Object: b, Raw: encodeOrDie(b)},
317 {Object: c, Raw: encodeOrDie(c)},
318 },
319 },
320 sort: &corev1.List{
321 Items: []runtime.RawExtension{
322 {Object: a, Raw: encodeOrDie(a)},
323 {Object: b, Raw: encodeOrDie(b)},
324 {Object: c, Raw: encodeOrDie(c)},
325 },
326 },
327 field: "{.metadata.name}",
328 },
329 {
330 name: "v1.List in reverse",
331 obj: &corev1.List{
332 Items: []runtime.RawExtension{
333 {Object: c, Raw: encodeOrDie(c)},
334 {Object: b, Raw: encodeOrDie(b)},
335 {Object: a, Raw: encodeOrDie(a)},
336 },
337 },
338 sort: &corev1.List{
339 Items: []runtime.RawExtension{
340 {Object: a, Raw: encodeOrDie(a)},
341 {Object: b, Raw: encodeOrDie(b)},
342 {Object: c, Raw: encodeOrDie(c)},
343 },
344 },
345 field: "{.metadata.name}",
346 },
347 {
348 name: "some-missing-fields",
349 obj: &unstructured.UnstructuredList{
350 Object: map[string]interface{}{
351 "kind": "List",
352 "apiVersion": "v1",
353 },
354 Items: []unstructured.Unstructured{
355 {
356 Object: map[string]interface{}{
357 "kind": "ReplicationController",
358 "apiVersion": "v1",
359 "status": map[string]interface{}{
360 "availableReplicas": 2,
361 },
362 },
363 },
364 {
365 Object: map[string]interface{}{
366 "kind": "ReplicationController",
367 "apiVersion": "v1",
368 "status": map[string]interface{}{},
369 },
370 },
371 {
372 Object: map[string]interface{}{
373 "kind": "ReplicationController",
374 "apiVersion": "v1",
375 "status": map[string]interface{}{
376 "availableReplicas": 1,
377 },
378 },
379 },
380 },
381 },
382 sort: &unstructured.UnstructuredList{
383 Object: map[string]interface{}{
384 "kind": "List",
385 "apiVersion": "v1",
386 },
387 Items: []unstructured.Unstructured{
388 {
389 Object: map[string]interface{}{
390 "kind": "ReplicationController",
391 "apiVersion": "v1",
392 "status": map[string]interface{}{},
393 },
394 },
395 {
396 Object: map[string]interface{}{
397 "kind": "ReplicationController",
398 "apiVersion": "v1",
399 "status": map[string]interface{}{
400 "availableReplicas": 1,
401 },
402 },
403 },
404 {
405 Object: map[string]interface{}{
406 "kind": "ReplicationController",
407 "apiVersion": "v1",
408 "status": map[string]interface{}{
409 "availableReplicas": 2,
410 },
411 },
412 },
413 },
414 },
415 field: "{.status.availableReplicas}",
416 },
417 {
418 name: "all-missing-fields",
419 obj: &unstructured.UnstructuredList{
420 Object: map[string]interface{}{
421 "kind": "List",
422 "apiVersion": "v1",
423 },
424 Items: []unstructured.Unstructured{
425 {
426 Object: map[string]interface{}{
427 "kind": "ReplicationController",
428 "apiVersion": "v1",
429 "status": map[string]interface{}{
430 "replicas": 0,
431 },
432 },
433 },
434 {
435 Object: map[string]interface{}{
436 "kind": "ReplicationController",
437 "apiVersion": "v1",
438 "status": map[string]interface{}{
439 "replicas": 0,
440 },
441 },
442 },
443 },
444 },
445 field: "{.status.availableReplicas}",
446 expectedErr: "couldn't find any field with path \"{.status.availableReplicas}\" in the list of objects",
447 },
448 {
449 name: "model-invalid-fields",
450 obj: &corev1.ReplicationControllerList{
451 Items: []corev1.ReplicationController{
452 {
453 Status: corev1.ReplicationControllerStatus{},
454 },
455 {
456 Status: corev1.ReplicationControllerStatus{},
457 },
458 {
459 Status: corev1.ReplicationControllerStatus{},
460 },
461 },
462 },
463 field: "{.invalid}",
464 expectedErr: "couldn't find any field with path \"{.invalid}\" in the list of objects",
465 },
466 {
467 name: "empty fields",
468 obj: &corev1.EventList{
469 Items: []corev1.Event{
470 {
471 ObjectMeta: metav1.ObjectMeta{CreationTimestamp: metav1.Unix(300, 0)},
472 LastTimestamp: metav1.Unix(300, 0),
473 },
474 {
475 ObjectMeta: metav1.ObjectMeta{CreationTimestamp: metav1.Unix(200, 0)},
476 },
477 },
478 },
479 sort: &corev1.EventList{
480 Items: []corev1.Event{
481 {
482 ObjectMeta: metav1.ObjectMeta{CreationTimestamp: metav1.Unix(200, 0)},
483 },
484 {
485 ObjectMeta: metav1.ObjectMeta{CreationTimestamp: metav1.Unix(300, 0)},
486 LastTimestamp: metav1.Unix(300, 0),
487 },
488 },
489 },
490 field: "{.lastTimestamp}",
491 },
492 {
493 name: "pod-resources-cpu-random-order-with-missing-fields",
494 obj: &corev1.PodList{
495 Items: []corev1.Pod{
496 {
497 Spec: createPodSpecResource(t, "", "", "0.5", ""),
498 },
499 {
500 Spec: createPodSpecResource(t, "", "", "10", ""),
501 },
502 {
503 Spec: createPodSpecResource(t, "", "", "100m", ""),
504 },
505 {
506 Spec: createPodSpecResource(t, "", "", "", ""),
507 },
508 },
509 },
510 sort: &corev1.PodList{
511 Items: []corev1.Pod{
512 {
513 Spec: createPodSpecResource(t, "", "", "", ""),
514 },
515 {
516 Spec: createPodSpecResource(t, "", "", "100m", ""),
517 },
518 {
519 Spec: createPodSpecResource(t, "", "", "0.5", ""),
520 },
521 {
522 Spec: createPodSpecResource(t, "", "", "10", ""),
523 },
524 },
525 },
526 field: "{.spec.containers[].resources.requests.cpu}",
527 },
528 {
529 name: "pod-resources-memory-random-order-with-missing-fields",
530 obj: &corev1.PodList{
531 Items: []corev1.Pod{
532 {
533 Spec: createPodSpecResource(t, "128Mi", "", "", ""),
534 },
535 {
536 Spec: createPodSpecResource(t, "10Ei", "", "", ""),
537 },
538 {
539 Spec: createPodSpecResource(t, "8Ti", "", "", ""),
540 },
541 {
542 Spec: createPodSpecResource(t, "64Gi", "", "", ""),
543 },
544 {
545 Spec: createPodSpecResource(t, "55Pi", "", "", ""),
546 },
547 {
548 Spec: createPodSpecResource(t, "2Ki", "", "", ""),
549 },
550 {
551 Spec: createPodSpecResource(t, "", "", "", ""),
552 },
553 },
554 },
555 sort: &corev1.PodList{
556 Items: []corev1.Pod{
557 {
558 Spec: createPodSpecResource(t, "", "", "", ""),
559 },
560 {
561 Spec: createPodSpecResource(t, "2Ki", "", "", ""),
562 },
563 {
564 Spec: createPodSpecResource(t, "128Mi", "", "", ""),
565 },
566 {
567 Spec: createPodSpecResource(t, "64Gi", "", "", ""),
568 },
569 {
570 Spec: createPodSpecResource(t, "8Ti", "", "", ""),
571 },
572 {
573 Spec: createPodSpecResource(t, "55Pi", "", "", ""),
574 },
575 {
576 Spec: createPodSpecResource(t, "10Ei", "", "", ""),
577 },
578 },
579 },
580 field: "{.spec.containers[].resources.requests.memory}",
581 },
582 {
583 name: "pod-unstructured-resources-cpu-random-order-with-missing-fields",
584 obj: &unstructured.UnstructuredList{
585 Object: map[string]interface{}{
586 "kind": "List",
587 "apiVersion": "v1",
588 },
589 Items: []unstructured.Unstructured{
590 createUnstructuredPodResource(t, "", "", "0.5", ""),
591 createUnstructuredPodResource(t, "", "", "10", ""),
592 createUnstructuredPodResource(t, "", "", "100m", ""),
593 createUnstructuredPodResource(t, "", "", "", ""),
594 },
595 },
596 sort: &unstructured.UnstructuredList{
597 Object: map[string]interface{}{
598 "kind": "List",
599 "apiVersion": "v1",
600 },
601 Items: []unstructured.Unstructured{
602 createUnstructuredPodResource(t, "", "", "", ""),
603 createUnstructuredPodResource(t, "", "", "100m", ""),
604 createUnstructuredPodResource(t, "", "", "0.5", ""),
605 createUnstructuredPodResource(t, "", "", "10", ""),
606 },
607 },
608 field: "{.spec.containers[].resources.requests.cpu}",
609 },
610 {
611 name: "pod-unstructured-resources-memory-random-order-with-missing-fields",
612 obj: &unstructured.UnstructuredList{
613 Object: map[string]interface{}{
614 "kind": "List",
615 "apiVersion": "v1",
616 },
617 Items: []unstructured.Unstructured{
618 createUnstructuredPodResource(t, "128Mi", "", "", ""),
619 createUnstructuredPodResource(t, "10Ei", "", "", ""),
620 createUnstructuredPodResource(t, "8Ti", "", "", ""),
621 createUnstructuredPodResource(t, "64Gi", "", "", ""),
622 createUnstructuredPodResource(t, "55Pi", "", "", ""),
623 createUnstructuredPodResource(t, "2Ki", "", "", ""),
624 createUnstructuredPodResource(t, "", "", "", ""),
625 },
626 },
627 sort: &unstructured.UnstructuredList{
628 Object: map[string]interface{}{
629 "kind": "List",
630 "apiVersion": "v1",
631 },
632 Items: []unstructured.Unstructured{
633 createUnstructuredPodResource(t, "", "", "", ""),
634 createUnstructuredPodResource(t, "2Ki", "", "", ""),
635 createUnstructuredPodResource(t, "128Mi", "", "", ""),
636 createUnstructuredPodResource(t, "64Gi", "", "", ""),
637 createUnstructuredPodResource(t, "8Ti", "", "", ""),
638 createUnstructuredPodResource(t, "55Pi", "", "", ""),
639 createUnstructuredPodResource(t, "10Ei", "", "", ""),
640 },
641 },
642 field: "{.spec.containers[].resources.requests.memory}",
643 },
644 }
645 for _, tt := range tests {
646 t.Run(tt.name+" table", func(t *testing.T) {
647 table := &metav1beta1.Table{}
648 meta.EachListItem(tt.obj, func(item runtime.Object) error {
649 table.Rows = append(table.Rows, metav1beta1.TableRow{
650 Object: runtime.RawExtension{Object: toUnstructuredOrDie(encodeOrDie(item))},
651 })
652 return nil
653 })
654
655 expectedTable := &metav1beta1.Table{}
656 meta.EachListItem(tt.sort, func(item runtime.Object) error {
657 expectedTable.Rows = append(expectedTable.Rows, metav1beta1.TableRow{
658 Object: runtime.RawExtension{Object: toUnstructuredOrDie(encodeOrDie(item))},
659 })
660 return nil
661 })
662
663 sorter, err := NewTableSorter(table, tt.field)
664 if err == nil {
665 err = sorter.Sort()
666 }
667 if err != nil {
668 if len(tt.expectedErr) > 0 {
669 if strings.Contains(err.Error(), tt.expectedErr) {
670 return
671 }
672 t.Fatalf("%s: expected error containing: %q, got: \"%v\"", tt.name, tt.expectedErr, err)
673 }
674 t.Fatalf("%s: unexpected error: %v", tt.name, err)
675 }
676 if len(tt.expectedErr) > 0 {
677 t.Fatalf("%s: expected error containing: %q, got none", tt.name, tt.expectedErr)
678 }
679 if !reflect.DeepEqual(table, expectedTable) {
680 t.Errorf("[%s]\nexpected/saw:\n%s", tt.name, cmp.Diff(expectedTable, table))
681 }
682 })
683 t.Run(tt.name, func(t *testing.T) {
684 sort := &SortingPrinter{SortField: tt.field, Decoder: scheme.Codecs.UniversalDecoder()}
685 err := sort.sortObj(tt.obj)
686 if err != nil {
687 if len(tt.expectedErr) > 0 {
688 if strings.Contains(err.Error(), tt.expectedErr) {
689 return
690 }
691 t.Fatalf("%s: expected error containing: %q, got: \"%v\"", tt.name, tt.expectedErr, err)
692 }
693 t.Fatalf("%s: unexpected error: %v", tt.name, err)
694 }
695 if len(tt.expectedErr) > 0 {
696 t.Fatalf("%s: expected error containing: %q, got none", tt.name, tt.expectedErr)
697 }
698 if !reflect.DeepEqual(tt.obj, tt.sort) {
699 t.Errorf("[%s]\nexpected:\n%v\nsaw:\n%v", tt.name, tt.sort, tt.obj)
700 }
701 })
702 }
703 }
704
705 func TestRuntimeSortLess(t *testing.T) {
706 var testobj runtime.Object
707
708 testobj = &corev1.PodList{
709 Items: []corev1.Pod{
710 {
711 ObjectMeta: metav1.ObjectMeta{
712 Name: "b",
713 },
714 Spec: createPodSpecResource(t, "0.5", "", "1Gi", ""),
715 },
716 {
717 ObjectMeta: metav1.ObjectMeta{
718 Name: "c",
719 },
720 Spec: createPodSpecResource(t, "2", "", "1Ti", ""),
721 },
722 {
723 ObjectMeta: metav1.ObjectMeta{
724 Name: "a",
725 },
726 Spec: createPodSpecResource(t, "10m", "", "1Ki", ""),
727 },
728 },
729 }
730
731 testobjs, err := meta.ExtractList(testobj)
732 if err != nil {
733 t.Fatalf("ExtractList testobj got unexpected error: %v", err)
734 }
735
736 testfieldName := "{.metadata.name}"
737 testruntimeSortName := NewRuntimeSort(testfieldName, testobjs)
738
739 testfieldCPU := "{.spec.containers[].resources.requests.cpu}"
740 testruntimeSortCPU := NewRuntimeSort(testfieldCPU, testobjs)
741
742 testfieldMemory := "{.spec.containers[].resources.requests.memory}"
743 testruntimeSortMemory := NewRuntimeSort(testfieldMemory, testobjs)
744
745 tests := []struct {
746 name string
747 runtimeSort *RuntimeSort
748 i int
749 j int
750 expectResult bool
751 expectErr bool
752 }{
753 {
754 name: "test name b c less true",
755 runtimeSort: testruntimeSortName,
756 i: 0,
757 j: 1,
758 expectResult: true,
759 },
760 {
761 name: "test name c a less false",
762 runtimeSort: testruntimeSortName,
763 i: 1,
764 j: 2,
765 expectResult: false,
766 },
767 {
768 name: "test name b a less false",
769 runtimeSort: testruntimeSortName,
770 i: 0,
771 j: 2,
772 expectResult: false,
773 },
774 {
775 name: "test cpu 0.5 2 less true",
776 runtimeSort: testruntimeSortCPU,
777 i: 0,
778 j: 1,
779 expectResult: true,
780 },
781 {
782 name: "test cpu 2 10mi less false",
783 runtimeSort: testruntimeSortCPU,
784 i: 1,
785 j: 2,
786 expectResult: false,
787 },
788 {
789 name: "test cpu 0.5 10mi less false",
790 runtimeSort: testruntimeSortCPU,
791 i: 0,
792 j: 2,
793 expectResult: false,
794 },
795 {
796 name: "test memory 1Gi 1Ti less true",
797 runtimeSort: testruntimeSortMemory,
798 i: 0,
799 j: 1,
800 expectResult: true,
801 },
802 {
803 name: "test memory 1Ti 1Ki less false",
804 runtimeSort: testruntimeSortMemory,
805 i: 1,
806 j: 2,
807 expectResult: false,
808 },
809 {
810 name: "test memory 1Gi 1Ki less false",
811 runtimeSort: testruntimeSortMemory,
812 i: 0,
813 j: 2,
814 expectResult: false,
815 },
816 }
817
818 for i, test := range tests {
819 t.Run(test.name, func(t *testing.T) {
820 result := test.runtimeSort.Less(test.i, test.j)
821 if result != test.expectResult {
822 t.Errorf("case[%d]:%s Expected result: %v, Got result: %v", i, test.name, test.expectResult, result)
823 }
824 })
825 }
826 }
827
View as plain text