m/p/api: use protobuf.Duration in Management.Node

This switches Management.Node message's time_since_heartbeat backing
type from int64 to google.protobuf.Duration in order to enable duration
based predicates in Management.GetNodes filter expressions.

Change-Id: Ia2663475d1b9ee535dc5578f16d53b70c6686b7c
Reviewed-on: https://review.monogon.dev/c/monogon/+/776
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/metropolis/node/core/curator/BUILD.bazel b/metropolis/node/core/curator/BUILD.bazel
index b8ff16a..57ed4ec 100644
--- a/metropolis/node/core/curator/BUILD.bazel
+++ b/metropolis/node/core/curator/BUILD.bazel
@@ -44,6 +44,7 @@
         "@org_golang_google_grpc//codes",
         "@org_golang_google_grpc//status",
         "@org_golang_google_protobuf//proto",
+        "@org_golang_google_protobuf//types/known/durationpb",
     ],
 )
 
diff --git a/metropolis/node/core/curator/impl_leader_management.go b/metropolis/node/core/curator/impl_leader_management.go
index fd593c3..0c7bc21 100644
--- a/metropolis/node/core/curator/impl_leader_management.go
+++ b/metropolis/node/core/curator/impl_leader_management.go
@@ -9,6 +9,7 @@
 
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
+	dpb "google.golang.org/protobuf/types/known/durationpb"
 
 	"source.monogon.dev/metropolis/node/core/identity"
 	"source.monogon.dev/metropolis/node/core/rpc"
@@ -215,10 +216,7 @@
 			State:  node.state,
 			Status: node.status,
 			Roles:  roles,
-			// TODO(mateusz@monogon.tech): update the API to use protobuf Duration
-			// message, in order to facilitate filter expressions like
-			// 'node.HeartbeatTimestamp > duration("30s")'.
-			TimeSinceHeartbeat: lhb.Nanoseconds(),
+			TimeSinceHeartbeat: dpb.New(lhb),
 			Health:             health,
 		}
 
diff --git a/metropolis/node/core/curator/impl_leader_test.go b/metropolis/node/core/curator/impl_leader_test.go
index 28d5fcf..cc90ca6 100644
--- a/metropolis/node/core/curator/impl_leader_test.go
+++ b/metropolis/node/core/curator/impl_leader_test.go
@@ -1006,6 +1006,28 @@
 	if exists(in, wnr) {
 		t.Fatalf("management.GetNodes returned a node which isn't a Kubernetes worker.")
 	}
+
+	// Exercise duration-based filtering. Start with setting up node and
+	// leadership timestamps much like in TestClusterHeartbeat.
+	tsn := putNode(t, ctx, cl.l, func(n *Node) { n.state = cpb.NodeState_NODE_STATE_UP })
+	nid := identity.NodeID(tsn.pubkey)
+	// Last of node's tsn heartbeats were received 5 seconds ago,
+	cl.l.ls.heartbeatTimestamps.Store(nid, time.Now().Add(-5*time.Second))
+	// ...while the current leader's tenure started 15 seconds ago.
+	cl.l.ls.startTs = time.Now().Add(-15 * time.Second)
+
+	// Get all nodes that sent their last heartbeat between 4 and 6 seconds ago.
+	// Node tsn should be among the results.
+	tsr := getNodes(t, ctx, mgmt, "node.time_since_heartbeat < duration('6s') && node.time_since_heartbeat > duration('4s')")
+	if !exists(tsn, tsr) {
+		t.Fatalf("node was filtered out where it shouldn't be")
+	}
+	// Now, get all nodes that sent their last heartbeat more than 7 seconds ago.
+	// In this case, node tsn should be filtered out.
+	tsr = getNodes(t, ctx, mgmt, "node.time_since_heartbeat > duration('7s')")
+	if exists(tsn, tsr) {
+		t.Fatalf("node wasn't filtered out where it should be")
+	}
 }
 
 // TestUpdateNodeRoles exercises management.UpdateNodeRoles by running it
diff --git a/metropolis/proto/api/BUILD.bazel b/metropolis/proto/api/BUILD.bazel
index b732f18..5bc5640 100644
--- a/metropolis/proto/api/BUILD.bazel
+++ b/metropolis/proto/api/BUILD.bazel
@@ -14,6 +14,7 @@
     deps = [
         "//metropolis/proto/common:common_proto",
         "//metropolis/proto/ext:ext_proto",
+        "@com_google_protobuf//:duration_proto",
     ],
 )
 
diff --git a/metropolis/proto/api/management.proto b/metropolis/proto/api/management.proto
index b48e2b1..d481339 100644
--- a/metropolis/proto/api/management.proto
+++ b/metropolis/proto/api/management.proto
@@ -2,6 +2,8 @@
 package metropolis.proto.api;
 option go_package = "source.monogon.dev/metropolis/proto/api";
 
+import "google/protobuf/duration.proto";
+
 import "metropolis/proto/common/common.proto";
 import "metropolis/proto/ext/authorization.proto";
 
@@ -140,7 +142,7 @@
     // time_since_heartbeat is the duration since the last of the node's
     // heartbeats was received, expressed in nanoseconds. It is only valid with
     // the health status of either HEALTHY or HEARTBEAT_TIMEOUT.
-    int64 time_since_heartbeat = 6;
+    google.protobuf.Duration time_since_heartbeat = 6;
 }
 
 message ApproveNodeRequest {