cloud/b/b/reflection: add single-line HumanValue rendering, Index

This is in preparation for printing fields in bmcli.

Change-Id: I1aa178da1a50e8dd0c572a238f92daa536f9fcd9
Reviewed-on: https://review.monogon.dev/c/monogon/+/2170
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/cloud/bmaas/bmdb/reflection/BUILD.bazel b/cloud/bmaas/bmdb/reflection/BUILD.bazel
index a324dab..0922542 100644
--- a/cloud/bmaas/bmdb/reflection/BUILD.bazel
+++ b/cloud/bmaas/bmdb/reflection/BUILD.bazel
@@ -15,6 +15,8 @@
         "@io_k8s_klog_v2//:klog",
         "@org_golang_google_protobuf//encoding/prototext",
         "@org_golang_google_protobuf//proto",
+        "@org_golang_google_protobuf//reflect/protopath",
+        "@org_golang_google_protobuf//reflect/protorange",
         "@org_golang_google_protobuf//reflect/protoreflect",
     ],
 )
diff --git a/cloud/bmaas/bmdb/reflection/reflection.go b/cloud/bmaas/bmdb/reflection/reflection.go
index 5942a30..b9f6d11 100644
--- a/cloud/bmaas/bmdb/reflection/reflection.go
+++ b/cloud/bmaas/bmdb/reflection/reflection.go
@@ -22,6 +22,8 @@
 	"github.com/google/uuid"
 	"google.golang.org/protobuf/encoding/prototext"
 	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/reflect/protopath"
+	"google.golang.org/protobuf/reflect/protorange"
 )
 
 // GetMachinesOpts influences the behaviour of GetMachines.
@@ -318,6 +320,24 @@
 	return nil
 }
 
+// DisplayOption is an opaque argument used to influence the display style of a
+// tag value when returned from HumanValue.
+type DisplayOption string
+
+const (
+	// DisplaySingleLine limits display to a single line (i.e. don't try to
+	// pretty-print long values by inserting newlines and indents).
+	DisplaySingleLine DisplayOption = "single-line"
+)
+
+func (r *Tag) HumanValue(opts ...DisplayOption) string {
+	var kvs []string
+	for _, field := range r.Fields {
+		kvs = append(kvs, fmt.Sprintf("%s: %s", field.Type.NativeName, field.HumanValue(opts...)))
+	}
+	return strings.Join(kvs, ", ")
+}
+
 // TagField value which is part of a Tag set on a Machine.
 type TagField struct {
 	// Type describing this field.
@@ -331,14 +351,19 @@
 
 // HumanValue returns a human-readable (best effort) representation of the field
 // value.
-func (r *TagField) HumanValue() string {
+func (r *TagField) HumanValue(opts ...DisplayOption) string {
 	switch {
 	case r.proto != nil:
-		opts := prototext.MarshalOptions{
+		mopts := prototext.MarshalOptions{
 			Multiline: true,
 			Indent:    "\t",
 		}
-		return opts.Format(r.proto)
+		for _, opt := range opts {
+			if opt == DisplaySingleLine {
+				mopts.Multiline = false
+			}
+		}
+		return mopts.Format(r.proto)
 	case r.text != nil:
 		return *r.text
 	case r.bytes != nil:
@@ -350,6 +375,39 @@
 	}
 }
 
+// Index attempts to index into a structured tag field (currently only protobuf
+// fields) by a 'field.subfield.subsubfield' selector.
+//
+// The selector for Protobuf fields follows the convention from 'protorange',
+// which is a semi-standardized format used in the Protobuf ecosystem. See
+// https://pkg.go.dev/google.golang.org/protobuf/reflect/protorange for more
+// details.
+//
+// An error will be returned if the TagField is not a protobuf field or if the
+// given selector does not point to a known message field.
+func (r *TagField) Index(k string) (string, error) {
+	if r.Type.ProtoType == nil {
+		return "", fmt.Errorf("can only index proto fields")
+	}
+	k = fmt.Sprintf("(%s).%s", r.Type.ProtoType.Descriptor().FullName(), k)
+
+	var res string
+	var found bool
+	ref := r.proto.ProtoReflect()
+	protorange.Range(ref, func(values protopath.Values) error {
+		if values.Path.String() == k {
+			res = values.Index(-1).Value.String()
+			found = true
+		}
+		return nil
+	})
+
+	if !found {
+		return "", fmt.Errorf("protobuf field not found")
+	}
+	return res, nil
+}
+
 // Backoff on a Machine.
 type Backoff struct {
 	// Process which established Backoff.
diff --git a/cloud/bmaas/bmdb/reflection_test.go b/cloud/bmaas/bmdb/reflection_test.go
index 6b8b506..38118cb 100644
--- a/cloud/bmaas/bmdb/reflection_test.go
+++ b/cloud/bmaas/bmdb/reflection_test.go
@@ -285,5 +285,13 @@
 		if !strings.Contains(v, "manufacturer:") {
 			t.Errorf("Invalid serialized prototext: %s", v)
 		}
+		fv, err := machine.Tags["HardwareReport"].Field("hardware_report_raw").Index("report.cpu[0].cores")
+		if err != nil {
+			t.Errorf("Could not get report.cpu[0].cores from hardware_report_raw: %v", err)
+		} else {
+			if want, got := "1", fv; want != got {
+				t.Errorf("report.cpu[0].cores should be %q, got %q", want, got)
+			}
+		}
 	}
 }