From e4970f9214d4245104a2eb84ed7fc584c5b49547 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Mon, 24 Aug 2015 12:07:55 +0000 Subject: [PATCH] Give mapping functions the ability to return multiple nodes. --- render/mapping.go | 103 ++++++++++++++-------------- render/mapping_test.go | 2 +- render/render.go | 148 ++++++++++++++++++++++------------------- render/render_test.go | 22 +++--- 4 files changed, 142 insertions(+), 133 deletions(-) diff --git a/render/mapping.go b/render/mapping.go index 0c8030687..153c33743 100644 --- a/render/mapping.go +++ b/render/mapping.go @@ -31,10 +31,7 @@ const ( // // A single NodeMetadata can yield arbitrary many representations, including // representations that reduce the cardinality of the set of nodes. -// -// If the final output parameter is false, the node shall be omitted from the -// rendered topology. -type LeafMapFunc func(report.NodeMetadata) (RenderableNode, bool) +type LeafMapFunc func(report.NodeMetadata) RenderableNodes // PseudoFunc creates RenderableNode representing pseudo nodes given the // srcNodeID. dstNodeID is the node id of one of the nodes this node has an @@ -48,20 +45,20 @@ type PseudoFunc func(srcNodeID, dstNodeID string, srcIsClient bool, local report // // As with LeafMapFunc, if the final output parameter is false, the node // shall be omitted from the rendered topology. -type MapFunc func(RenderableNode) (RenderableNode, bool) +type MapFunc func(RenderableNode) RenderableNodes // MapEndpointIdentity maps an endpoint topology node to an endpoint // renderable node. As it is only ever run on endpoint topology nodes, we // expect that certain keys are present. -func MapEndpointIdentity(m report.NodeMetadata) (RenderableNode, bool) { +func MapEndpointIdentity(m report.NodeMetadata) RenderableNodes { addr, ok := m.Metadata[endpoint.Addr] if !ok { - return RenderableNode{}, false + return RenderableNodes{} } port, ok := m.Metadata[endpoint.Port] if !ok { - return RenderableNode{}, false + return RenderableNodes{} } var ( @@ -75,16 +72,16 @@ func MapEndpointIdentity(m report.NodeMetadata) (RenderableNode, bool) { minor = fmt.Sprintf("%s (%s)", minor, pid) } - return NewRenderableNode(id, major, minor, rank, m), true + return RenderableNodes{id: NewRenderableNode(id, major, minor, rank, m)} } // MapProcessIdentity maps a process topology node to a process renderable // node. As it is only ever run on process topology nodes, we expect that // certain keys are present. -func MapProcessIdentity(m report.NodeMetadata) (RenderableNode, bool) { +func MapProcessIdentity(m report.NodeMetadata) RenderableNodes { pid, ok := m.Metadata[process.PID] if !ok { - return RenderableNode{}, false + return RenderableNodes{} } var ( @@ -94,16 +91,16 @@ func MapProcessIdentity(m report.NodeMetadata) (RenderableNode, bool) { rank = m.Metadata["comm"] ) - return NewRenderableNode(id, major, minor, rank, m), true + return RenderableNodes{id: NewRenderableNode(id, major, minor, rank, m)} } // MapContainerIdentity maps a container topology node to a container // renderable node. As it is only ever run on container topology nodes, we // expect that certain keys are present. -func MapContainerIdentity(m report.NodeMetadata) (RenderableNode, bool) { +func MapContainerIdentity(m report.NodeMetadata) RenderableNodes { id, ok := m.Metadata[docker.ContainerID] if !ok { - return RenderableNode{}, false + return RenderableNodes{} } var ( @@ -112,16 +109,16 @@ func MapContainerIdentity(m report.NodeMetadata) (RenderableNode, bool) { rank = m.Metadata[docker.ImageID] ) - return NewRenderableNode(id, major, minor, rank, m), true + return RenderableNodes{id: NewRenderableNode(id, major, minor, rank, m)} } // MapContainerImageIdentity maps a container image topology node to container // image renderable node. As it is only ever run on container image topology // nodes, we expect that certain keys are present. -func MapContainerImageIdentity(m report.NodeMetadata) (RenderableNode, bool) { +func MapContainerImageIdentity(m report.NodeMetadata) RenderableNodes { id, ok := m.Metadata[docker.ImageID] if !ok { - return RenderableNode{}, false + return RenderableNodes{} } var ( @@ -129,16 +126,16 @@ func MapContainerImageIdentity(m report.NodeMetadata) (RenderableNode, bool) { rank = m.Metadata[docker.ImageID] ) - return NewRenderableNode(id, major, "", rank, m), true + return RenderableNodes{id: NewRenderableNode(id, major, "", rank, m)} } // MapAddressIdentity maps an address topology node to an address renderable // node. As it is only ever run on address topology nodes, we expect that // certain keys are present. -func MapAddressIdentity(m report.NodeMetadata) (RenderableNode, bool) { +func MapAddressIdentity(m report.NodeMetadata) RenderableNodes { addr, ok := m.Metadata[endpoint.Addr] if !ok { - return RenderableNode{}, false + return RenderableNodes{} } var ( @@ -148,13 +145,13 @@ func MapAddressIdentity(m report.NodeMetadata) (RenderableNode, bool) { rank = major ) - return NewRenderableNode(id, major, minor, rank, m), true + return RenderableNodes{id: NewRenderableNode(id, major, minor, rank, m)} } // MapHostIdentity maps a host topology node to a host renderable node. As it // is only ever run on host topology nodes, we expect that certain keys are // present. -func MapHostIdentity(m report.NodeMetadata) (RenderableNode, bool) { +func MapHostIdentity(m report.NodeMetadata) RenderableNodes { var ( id = MakeHostID(report.ExtractHostID(m)) hostname = m.Metadata[host.HostName] @@ -168,7 +165,7 @@ func MapHostIdentity(m report.NodeMetadata) (RenderableNode, bool) { major = hostname } - return NewRenderableNode(id, major, minor, rank, m), true + return RenderableNodes{id: NewRenderableNode(id, major, minor, rank, m)} } // MapEndpoint2Process maps endpoint RenderableNodes to process @@ -182,18 +179,18 @@ func MapHostIdentity(m report.NodeMetadata) (RenderableNode, bool) { // format for a process, but without any Major or Minor labels. // It does not have enough info to do that, and the resulting graph // must be merged with a process graph to get that info. -func MapEndpoint2Process(n RenderableNode) (RenderableNode, bool) { +func MapEndpoint2Process(n RenderableNode) RenderableNodes { if n.Pseudo { - return n, true + return RenderableNodes{n.ID: n} } pid, ok := n.NodeMetadata.Metadata[process.PID] if !ok { - return RenderableNode{}, false + return RenderableNodes{} } id := MakeProcessID(report.ExtractHostID(n.NodeMetadata), pid) - return newDerivedNode(id, n), true + return RenderableNodes{id: newDerivedNode(id, n)} } // MapProcess2Container maps process RenderableNodes to container @@ -207,15 +204,15 @@ func MapEndpoint2Process(n RenderableNode) (RenderableNode, bool) { // format for a container, but without any Major or Minor labels. // It does not have enough info to do that, and the resulting graph // must be merged with a container graph to get that info. -func MapProcess2Container(n RenderableNode) (RenderableNode, bool) { +func MapProcess2Container(n RenderableNode) RenderableNodes { // Propogate the internet pseudo node if n.ID == TheInternetID { - return n, true + return RenderableNodes{n.ID: n} } // Don't propogate non-internet pseudo nodes if n.Pseudo { - return n, false + return RenderableNodes{} } // Otherwise, if the process is not in a container, group it @@ -228,10 +225,10 @@ func MapProcess2Container(n RenderableNode) (RenderableNode, bool) { id = MakePseudoNodeID(UncontainedID, hostID) node := newDerivedPseudoNode(id, UncontainedMajor, n) node.LabelMinor = hostID - return node, true + return RenderableNodes{id: node} } - return newDerivedNode(id, n), true + return RenderableNodes{id: newDerivedNode(id, n)} } // MapProcess2Name maps process RenderableNodes to RenderableNodes @@ -240,29 +237,29 @@ func MapProcess2Container(n RenderableNode) (RenderableNode, bool) { // This mapper is unlike the other foo2bar mappers as the intention // is not to join the information with another topology. Therefore // it outputs a properly-formed node with labels etc. -func MapProcess2Name(n RenderableNode) (RenderableNode, bool) { +func MapProcess2Name(n RenderableNode) RenderableNodes { if n.Pseudo { - return n, true + return RenderableNodes{n.ID: n} } name, ok := n.NodeMetadata.Metadata["comm"] if !ok { - return RenderableNode{}, false + return RenderableNodes{} } node := newDerivedNode(name, n) node.LabelMajor = name node.Rank = name node.NodeMetadata.Counters[processesKey] = 1 - return node, true + return RenderableNodes{name: node} } // MapCountProcessName maps 1:1 process name nodes, counting // the number of processes grouped together and putting // that info in the minor label. -func MapCountProcessName(n RenderableNode) (RenderableNode, bool) { +func MapCountProcessName(n RenderableNode) RenderableNodes { if n.Pseudo { - return n, true + return RenderableNodes{n.ID: n} } processes := n.NodeMetadata.Counters[processesKey] @@ -271,7 +268,7 @@ func MapCountProcessName(n RenderableNode) (RenderableNode, bool) { } else { n.LabelMinor = fmt.Sprintf("%d processes", processes) } - return n, true + return RenderableNodes{n.ID: n} } // MapContainer2ContainerImage maps container RenderableNodes to container @@ -285,23 +282,23 @@ func MapCountProcessName(n RenderableNode) (RenderableNode, bool) { // format for a container, but without any Major or Minor labels. // It does not have enough info to do that, and the resulting graph // must be merged with a container graph to get that info. -func MapContainer2ContainerImage(n RenderableNode) (RenderableNode, bool) { +func MapContainer2ContainerImage(n RenderableNode) RenderableNodes { // Propogate all pseudo nodes if n.Pseudo { - return n, true + return RenderableNodes{n.ID: n} } // Otherwise, if some some reason the container doesn't have a image_id // (maybe slightly out of sync reports), just drop it id, ok := n.NodeMetadata.Metadata[docker.ImageID] if !ok { - return n, false + return RenderableNodes{} } // Add container- key to NMD, which will later be counted to produce the minor label result := newDerivedNode(id, n) result.NodeMetadata.Counters[containersKey] = 1 - return result, true + return RenderableNodes{id: result} } // MapContainerImage2Name maps container images RenderableNodes to @@ -310,14 +307,14 @@ func MapContainer2ContainerImage(n RenderableNode) (RenderableNode, bool) { // This mapper is unlike the other foo2bar mappers as the intention // is not to join the information with another topology. Therefore // it outputs a properly-formed node with labels etc. -func MapContainerImage2Name(n RenderableNode) (RenderableNode, bool) { +func MapContainerImage2Name(n RenderableNode) RenderableNodes { if n.Pseudo { - return n, true + return RenderableNodes{n.ID: n} } name, ok := n.NodeMetadata.Metadata[docker.ImageName] if !ok { - return RenderableNode{}, false + return RenderableNodes{} } parts := strings.SplitN(name, ":", 2) @@ -329,15 +326,15 @@ func MapContainerImage2Name(n RenderableNode) (RenderableNode, bool) { node.LabelMajor = name node.Rank = name node.NodeMetadata = n.NodeMetadata.Copy() // Propagate NMD for container counting. - return node, true + return RenderableNodes{name: node} } // MapCountContainers maps 1:1 container image nodes, counting // the number of containers grouped together and putting // that info in the minor label. -func MapCountContainers(n RenderableNode) (RenderableNode, bool) { +func MapCountContainers(n RenderableNode) RenderableNodes { if n.Pseudo { - return n, true + return RenderableNodes{n.ID: n} } containers := n.NodeMetadata.Counters[containersKey] @@ -346,19 +343,19 @@ func MapCountContainers(n RenderableNode) (RenderableNode, bool) { } else { n.LabelMinor = fmt.Sprintf("%d containers", containers) } - return n, true + return RenderableNodes{n.ID: n} } // MapAddress2Host maps address RenderableNodes to host RenderableNodes. // // Otherthan pseudo nodes, we can assume all nodes have a HostID -func MapAddress2Host(n RenderableNode) (RenderableNode, bool) { +func MapAddress2Host(n RenderableNode) RenderableNodes { if n.Pseudo { - return n, true + return RenderableNodes{n.ID: n} } id := MakeHostID(report.ExtractHostID(n.NodeMetadata)) - return newDerivedNode(id, n), true + return RenderableNodes{id: newDerivedNode(id, n)} } // GenericPseudoNode makes a PseudoFunc given an addresser. The returned diff --git a/render/mapping_test.go b/render/mapping_test.go index 6efbf98d8..35f746ebe 100644 --- a/render/mapping_test.go +++ b/render/mapping_test.go @@ -72,7 +72,7 @@ type testcase struct { } func testMap(t *testing.T, f render.LeafMapFunc, input testcase) { - if _, have := f(input.md); input.ok != have { + if have := f(input.md); input.ok != (len(have) > 0) { t.Errorf("%v: want %v, have %v", input.md, input.ok, have) } } diff --git a/render/render.go b/render/render.go index bc551ef52..e0f5a5b1f 100644 --- a/render/render.go +++ b/render/render.go @@ -53,26 +53,24 @@ func (m Map) Render(rpt report.Report) RenderableNodes { return output } -func (m Map) render(rpt report.Report) (RenderableNodes, map[string]string) { +func (m Map) render(rpt report.Report) (RenderableNodes, map[string]report.IDList) { input := m.Renderer.Render(rpt) output := RenderableNodes{} - mapped := map[string]string{} // input node ID -> output node ID + mapped := map[string]report.IDList{} // input node ID -> output node IDs adjacencies := map[string]report.IDList{} // output node ID -> input node Adjacencies for _, inRenderable := range input { - outRenderable, ok := m.MapFunc(inRenderable) - if !ok { - continue - } + outRenderables := m.MapFunc(inRenderable) + for _, outRenderable := range outRenderables { + existing, ok := output[outRenderable.ID] + if ok { + outRenderable.Merge(existing) + } - existing, ok := output[outRenderable.ID] - if ok { - outRenderable.Merge(existing) + output[outRenderable.ID] = outRenderable + mapped[inRenderable.ID] = mapped[inRenderable.ID].Add(outRenderable.ID) + adjacencies[outRenderable.ID] = adjacencies[outRenderable.ID].Merge(inRenderable.Adjacency) } - - output[outRenderable.ID] = outRenderable - mapped[inRenderable.ID] = outRenderable.ID - adjacencies[outRenderable.ID] = adjacencies[outRenderable.ID].Merge(inRenderable.Adjacency) } // Rewrite Adjacency for new node IDs. @@ -82,7 +80,7 @@ func (m Map) render(rpt report.Report) (RenderableNodes, map[string]string) { for outNodeID, inAdjacency := range adjacencies { outAdjacency := report.MakeIDList() for _, inAdjacent := range inAdjacency { - if outAdjacent, ok := mapped[inAdjacent]; ok { + for _, outAdjacent := range mapped[inAdjacent] { outAdjacency = outAdjacency.Add(outAdjacent) } } @@ -102,10 +100,12 @@ func (m Map) EdgeMetadata(rpt report.Report, srcRenderableID, dstRenderableID st // First we need to map the ids in this layer into the ids in the underlying layer _, mapped := m.render(rpt) // this maps from old -> new inverted := map[string][]string{} // this maps from new -> old(s) - for k, v := range mapped { - existing := inverted[v] - existing = append(existing, k) - inverted[v] = existing + for k, vs := range mapped { + for _, v := range vs { + existing := inverted[v] + existing = append(existing, k) + inverted[v] = existing + } } // Now work out a slice of edges this edge is constructed from @@ -148,34 +148,31 @@ func (m LeafMap) Render(rpt report.Report) RenderableNodes { // Build a set of RenderableNodes for all non-pseudo probes, and an // addressID to nodeID lookup map. Multiple addressIDs can map to the same // RenderableNodes. - source2mapped := map[string]string{} // source node ID -> mapped node ID + source2mapped := map[string]report.IDList{} // source node ID -> mapped node IDs for nodeID, metadata := range t.NodeMetadatas { - mapped, ok := m.Mapper(metadata) - if !ok { - continue + for _, mapped := range m.Mapper(metadata) { + // mapped.ID needs not be unique over all addressIDs. If not, we merge with + // the existing data, on the assumption that the MapFunc returns the same + // data. + existing, ok := nodes[mapped.ID] + if ok { + mapped.Merge(existing) + } + + origins := mapped.Origins + origins = origins.Add(nodeID) + origins = origins.Add(metadata.Metadata[report.HostNodeID]) + mapped.Origins = origins + + nodes[mapped.ID] = mapped + source2mapped[nodeID] = source2mapped[nodeID].Add(mapped.ID) } - - // mapped.ID needs not be unique over all addressIDs. If not, we merge with - // the existing data, on the assumption that the MapFunc returns the same - // data. - existing, ok := nodes[mapped.ID] - if ok { - mapped.Merge(existing) - } - - origins := mapped.Origins - origins = origins.Add(nodeID) - origins = origins.Add(metadata.Metadata[report.HostNodeID]) - mapped.Origins = origins - - nodes[mapped.ID] = mapped - source2mapped[nodeID] = mapped.ID } - mkPseudoNode := func(srcNodeID, dstNodeID string, srcIsClient bool) (string, bool) { + mkPseudoNode := func(srcNodeID, dstNodeID string, srcIsClient bool) report.IDList { pseudoNode, ok := m.Pseudo(srcNodeID, dstNodeID, srcIsClient, localNetworks) if !ok { - return "", false + return report.MakeIDList() } pseudoNode.Origins = pseudoNode.Origins.Add(srcNodeID) existing, ok := nodes[pseudoNode.ID] @@ -184,8 +181,8 @@ func (m LeafMap) Render(rpt report.Report) RenderableNodes { } nodes[pseudoNode.ID] = pseudoNode - source2mapped[pseudoNode.ID] = srcNodeID - return pseudoNode.ID, true + source2mapped[pseudoNode.ID] = source2mapped[pseudoNode.ID].Add(srcNodeID) + return report.MakeIDList(pseudoNode.ID) } // Walk the graph and make connections. @@ -196,7 +193,7 @@ func (m LeafMap) Render(rpt report.Report) RenderableNodes { continue } - srcRenderableID, ok := source2mapped[srcNodeID] + srcRenderableIDs, ok := source2mapped[srcNodeID] if !ok { // One of the entries in dsts must be a non-pseudo node var existingDstNodeID string @@ -207,40 +204,52 @@ func (m LeafMap) Render(rpt report.Report) RenderableNodes { } } - srcRenderableID, ok = mkPseudoNode(srcNodeID, existingDstNodeID, true) - if !ok { - continue - } + srcRenderableIDs = mkPseudoNode(srcNodeID, existingDstNodeID, true) + } + if len(srcRenderableIDs) == 0 { + continue } - srcRenderableNode := nodes[srcRenderableID] - for _, dstNodeID := range dsts { - dstRenderableID, ok := source2mapped[dstNodeID] - if !ok { - dstRenderableID, ok = mkPseudoNode(dstNodeID, srcNodeID, false) + for _, srcRenderableID := range srcRenderableIDs { + srcRenderableNode := nodes[srcRenderableID] + + for _, dstNodeID := range dsts { + dstRenderableIDs, ok := source2mapped[dstNodeID] if !ok { + dstRenderableIDs = mkPseudoNode(dstNodeID, srcNodeID, false) + } + if len(dstRenderableIDs) == 0 { continue } - } - dstRenderableNode := nodes[dstRenderableID] + for _, dstRenderableID := range dstRenderableIDs { + dstRenderableNode := nodes[dstRenderableID] + srcRenderableNode.Adjacency = srcRenderableNode.Adjacency.Add(dstRenderableID) - srcRenderableNode.Adjacency = srcRenderableNode.Adjacency.Add(dstRenderableID) - - // We propagate edge metadata to nodes on both ends of the edges. - // TODO we should 'reverse' one end of the edge meta data - ingress -> egress etc. - if md, ok := t.EdgeMetadatas[report.MakeEdgeID(srcNodeID, dstNodeID)]; ok { - srcRenderableNode.EdgeMetadata = srcRenderableNode.EdgeMetadata.Merge(md) - dstRenderableNode.EdgeMetadata = dstRenderableNode.EdgeMetadata.Merge(md) - nodes[dstRenderableID] = dstRenderableNode + // We propagate edge metadata to nodes on both ends of the edges. + // TODO we should 'reverse' one end of the edge meta data - ingress -> egress etc. + if md, ok := t.EdgeMetadatas[report.MakeEdgeID(srcNodeID, dstNodeID)]; ok { + srcRenderableNode.EdgeMetadata = srcRenderableNode.EdgeMetadata.Merge(md) + dstRenderableNode.EdgeMetadata = dstRenderableNode.EdgeMetadata.Merge(md) + nodes[dstRenderableID] = dstRenderableNode + } + } } + + nodes[srcRenderableID] = srcRenderableNode } - - nodes[srcRenderableID] = srcRenderableNode } return nodes } +func ids(nodes RenderableNodes) report.IDList { + result := report.MakeIDList() + for id := range nodes { + result = result.Add(id) + } + return result +} + // EdgeMetadata gives the metadata of an edge from the perspective of the // srcRenderableID. Since an edgeID can have multiple edges on the address // level, it uses the supplied mapping function to translate address IDs to @@ -254,15 +263,16 @@ func (m LeafMap) EdgeMetadata(rpt report.Report, srcRenderableID, dstRenderableI log.Printf("bad edge ID %q", edgeID) continue } + srcs, dsts := report.MakeIDList(src), report.MakeIDList(dst) if src != report.TheInternet { - mapped, _ := m.Mapper(t.NodeMetadatas[src]) - src = mapped.ID + mapped := m.Mapper(t.NodeMetadatas[src]) + srcs = ids(mapped) } if dst != report.TheInternet { - mapped, _ := m.Mapper(t.NodeMetadatas[dst]) - dst = mapped.ID + mapped := m.Mapper(t.NodeMetadatas[dst]) + dsts = ids(mapped) } - if src == srcRenderableID && dst == dstRenderableID { + if srcs.Contains(srcRenderableID) && dsts.Contains(dstRenderableID) { metadata = metadata.Flatten(edgeMeta) } } diff --git a/render/render_test.go b/render/render_test.go index bc69c90ff..a9f0a5fd4 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -52,8 +52,8 @@ func TestReduceEdge(t *testing.T) { func TestMapRender1(t *testing.T) { // 1. Check when we return false, the node gets filtered out mapper := render.Map{ - MapFunc: func(nodes render.RenderableNode) (render.RenderableNode, bool) { - return render.RenderableNode{}, false + MapFunc: func(nodes render.RenderableNode) render.RenderableNodes { + return render.RenderableNodes{} }, Renderer: mockRenderer{RenderableNodes: render.RenderableNodes{ "foo": {ID: "foo"}, @@ -69,8 +69,8 @@ func TestMapRender1(t *testing.T) { func TestMapRender2(t *testing.T) { // 2. Check we can remap two nodes into one mapper := render.Map{ - MapFunc: func(nodes render.RenderableNode) (render.RenderableNode, bool) { - return render.RenderableNode{ID: "bar"}, true + MapFunc: func(nodes render.RenderableNode) render.RenderableNodes { + return render.RenderableNodes{"bar": render.RenderableNode{ID: "bar"}} }, Renderer: mockRenderer{RenderableNodes: render.RenderableNodes{ "foo": {ID: "foo"}, @@ -89,8 +89,9 @@ func TestMapRender2(t *testing.T) { func TestMapRender3(t *testing.T) { // 3. Check we can remap adjacencies mapper := render.Map{ - MapFunc: func(nodes render.RenderableNode) (render.RenderableNode, bool) { - return render.RenderableNode{ID: "_" + nodes.ID}, true + MapFunc: func(nodes render.RenderableNode) render.RenderableNodes { + id := "_" + nodes.ID + return render.RenderableNodes{id: render.RenderableNode{ID: id}} }, Renderer: mockRenderer{RenderableNodes: render.RenderableNodes{ "foo": {ID: "foo", Adjacency: report.MakeIDList("baz")}, @@ -125,13 +126,14 @@ func TestMapEdge(t *testing.T) { } } - identity := func(nmd report.NodeMetadata) (render.RenderableNode, bool) { - return render.NewRenderableNode(nmd.Metadata["id"], "", "", "", nmd), true + identity := func(nmd report.NodeMetadata) render.RenderableNodes { + return render.RenderableNodes{nmd.Metadata["id"]: render.NewRenderableNode(nmd.Metadata["id"], "", "", "", nmd)} } mapper := render.Map{ - MapFunc: func(n render.RenderableNode) (render.RenderableNode, bool) { - return render.RenderableNode{ID: "_" + n.ID}, true + MapFunc: func(nodes render.RenderableNode) render.RenderableNodes { + id := "_" + nodes.ID + return render.RenderableNodes{id: render.RenderableNode{ID: id}} }, Renderer: render.LeafMap{ Selector: selector,