diff --git a/app/api_topologies.go b/app/api_topologies.go index 29e6b64ca..e4c6eafaf 100644 --- a/app/api_topologies.go +++ b/app/api_topologies.go @@ -29,8 +29,7 @@ const ( containersByImageID = "containers-by-image" podsID = "pods" replicaSetsID = "replica-sets" - deploymentsID = "deployments" - daemonsetsID = "daemonsets" + kubeControllersID = "kube-controllers" servicesID = "services" hostsID = "hosts" weaveID = "weave" @@ -120,7 +119,7 @@ func updateKubeFilters(rpt report.Report, topologies []APITopologyDesc) []APITop sort.Strings(ns) topologies = append([]APITopologyDesc{}, topologies...) // Make a copy so we can make changes safely for i, t := range topologies { - if t.id == containersID || t.id == podsID || t.id == servicesID || t.id == deploymentsID || t.id == replicaSetsID || t.id == daemonsetsID { + if t.id == containersID || t.id == podsID || t.id == servicesID || t.id == replicaSetsID || t.id == kubeControllersID { topologies[i] = mergeTopologyFilters(t, []APITopologyOptionGroup{ namespaceFilters(ns, "All Namespaces"), }) @@ -196,6 +195,17 @@ func MakeRegistry() *Registry { }, } + k8sControllersTypeFilter := APITopologyOptionGroup{ + ID: "grouptype", + Default: "", + SelectType: "union", + NoneLabel: "All Types", + Options: []APITopologyOption{ + {Value: report.Deployment, Label: "Deployments", filter: render.IsTopology(report.Deployment), filterPseudo: false}, + {Value: report.DaemonSet, Label: "Daemonsets", filter: render.IsTopology(report.DaemonSet), filterPseudo: false}, + }, + } + // Topology option labels should tell the current state. The first item must // be the verb to get to that state registry.Add( @@ -253,19 +263,11 @@ func MakeRegistry() *Registry { HideIfEmpty: true, }, APITopologyDesc{ - id: deploymentsID, + id: kubeControllersID, parent: podsID, - renderer: render.DeploymentRenderer, - Name: "deployments", - Options: []APITopologyOptionGroup{unmanagedFilter}, - HideIfEmpty: true, - }, - APITopologyDesc{ - id: daemonsetsID, - parent: podsID, - renderer: render.DaemonSetRenderer, - Name: "daemonsets", - Options: []APITopologyOptionGroup{unmanagedFilter}, + renderer: render.KubeControllerRenderer, + Name: "controllers", + Options: []APITopologyOptionGroup{unmanagedFilter, k8sControllersTypeFilter}, HideIfEmpty: true, }, APITopologyDesc{ diff --git a/client/app/scripts/charts/node-shapes.js b/client/app/scripts/charts/node-shapes.js index 3c421ab7c..f73545d86 100644 --- a/client/app/scripts/charts/node-shapes.js +++ b/client/app/scripts/charts/node-shapes.js @@ -11,11 +11,14 @@ import { pathElement, circleElement, rectangleElement, - cloudShapeProps, circleShapeProps, + triangleShapeProps, squareShapeProps, + pentagonShapeProps, hexagonShapeProps, heptagonShapeProps, + octagonShapeProps, + cloudShapeProps, } from '../utils/node-shape-utils'; @@ -69,8 +72,11 @@ function NodeShape(shapeType, shapeElement, shapeProps, { id, highlighted, color ); } -export const NodeShapeCloud = props => NodeShape('cloud', pathElement, cloudShapeProps, props); export const NodeShapeCircle = props => NodeShape('circle', circleElement, circleShapeProps, props); +export const NodeShapeTriangle = props => NodeShape('triangle', pathElement, triangleShapeProps, props); +export const NodeShapeSquare = props => NodeShape('square', rectangleElement, squareShapeProps, props); +export const NodeShapePentagon = props => NodeShape('pentagon', pathElement, pentagonShapeProps, props); export const NodeShapeHexagon = props => NodeShape('hexagon', pathElement, hexagonShapeProps, props); export const NodeShapeHeptagon = props => NodeShape('heptagon', pathElement, heptagonShapeProps, props); -export const NodeShapeSquare = props => NodeShape('square', rectangleElement, squareShapeProps, props); +export const NodeShapeOctagon = props => NodeShape('octagon', pathElement, octagonShapeProps, props); +export const NodeShapeCloud = props => NodeShape('cloud', pathElement, cloudShapeProps, props); diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index f5882cb5e..0b5cfb868 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -14,20 +14,26 @@ import { NODE_BASE_SIZE } from '../constants/styles'; import NodeShapeStack from './node-shape-stack'; import NodeNetworksOverlay from './node-networks-overlay'; import { - NodeShapeCloud, NodeShapeCircle, + NodeShapeTriangle, NodeShapeSquare, + NodeShapePentagon, NodeShapeHexagon, NodeShapeHeptagon, + NodeShapeOctagon, + NodeShapeCloud, } from './node-shapes'; const labelWidth = 1.2 * NODE_BASE_SIZE; const nodeShapes = { circle: NodeShapeCircle, + triangle: NodeShapeTriangle, + square: NodeShapeSquare, + pentagon: NodeShapePentagon, hexagon: NodeShapeHexagon, heptagon: NodeShapeHeptagon, - square: NodeShapeSquare, + octagon: NodeShapeOctagon, cloud: NodeShapeCloud, }; diff --git a/client/app/scripts/utils/node-shape-utils.js b/client/app/scripts/utils/node-shape-utils.js index 20b59f755..1f3f4c255 100644 --- a/client/app/scripts/utils/node-shape-utils.js +++ b/client/app/scripts/utils/node-shape-utils.js @@ -20,8 +20,11 @@ function curvedUnitPolygonPath(n) { ])); } -export const squareShapeProps = { width: 1.8, height: 1.8, rx: 0.4, ry: 0.4, x: -0.9, y: -0.9 }; -export const heptagonShapeProps = { d: curvedUnitPolygonPath(7) }; -export const hexagonShapeProps = { d: curvedUnitPolygonPath(6) }; -export const cloudShapeProps = { d: UNIT_CLOUD_PATH }; export const circleShapeProps = { r: 1 }; +export const triangleShapeProps = { d: curvedUnitPolygonPath(3) }; +export const squareShapeProps = { width: 1.8, height: 1.8, rx: 0.4, ry: 0.4, x: -0.9, y: -0.9 }; +export const pentagonShapeProps = { d: curvedUnitPolygonPath(5) }; +export const hexagonShapeProps = { d: curvedUnitPolygonPath(6) }; +export const heptagonShapeProps = { d: curvedUnitPolygonPath(7) }; +export const octagonShapeProps = { d: curvedUnitPolygonPath(8) }; +export const cloudShapeProps = { d: UNIT_CLOUD_PATH }; diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index f92c15088..e67287de6 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -12,8 +12,8 @@ import { shownNodesSelector, shownResourceTopologyIdsSelector } from '../selecto const TOPOLOGY_DISPLAY_PRIORITY = [ 'ecs-services', 'ecs-tasks', + 'kube-controllers', 'services', - 'deployments', 'replica-sets', 'pods', 'containers', diff --git a/probe/kubernetes/daemonset.go b/probe/kubernetes/daemonset.go index 4e93e7a8b..3460895b3 100644 --- a/probe/kubernetes/daemonset.go +++ b/probe/kubernetes/daemonset.go @@ -48,5 +48,6 @@ func (d *daemonSet) GetNode() report.Node { DesiredReplicas: fmt.Sprint(d.Status.DesiredNumberScheduled), Replicas: fmt.Sprint(d.Status.CurrentNumberScheduled), MisscheduledReplicas: fmt.Sprint(d.Status.NumberMisscheduled), + NodeType: "Daemon Set", }) } diff --git a/probe/kubernetes/deployment.go b/probe/kubernetes/deployment.go index f67553b70..16211f7cd 100644 --- a/probe/kubernetes/deployment.go +++ b/probe/kubernetes/deployment.go @@ -54,5 +54,6 @@ func (d *deployment) GetNode(probeID string) report.Node { UnavailableReplicas: fmt.Sprint(d.Status.UnavailableReplicas), Strategy: string(d.Spec.Strategy.Type), report.ControlProbeID: probeID, + NodeType: "Deployment", }).WithLatestActiveControls(ScaleUp, ScaleDown) } diff --git a/probe/kubernetes/reporter.go b/probe/kubernetes/reporter.go index 4016e334b..a0db79273 100644 --- a/probe/kubernetes/reporter.go +++ b/probe/kubernetes/reporter.go @@ -21,6 +21,7 @@ const ( ObservedGeneration = "kubernetes_observed_generation" Replicas = "kubernetes_replicas" DesiredReplicas = "kubernetes_desired_replicas" + NodeType = "kubernetes_node_type" ) // Exposed for testing @@ -46,6 +47,7 @@ var ( ServiceMetricTemplates = PodMetricTemplates DeploymentMetadataTemplates = report.MetadataTemplates{ + NodeType: {ID: NodeType, Label: "Type", From: report.FromLatest, Priority: 1}, Namespace: {ID: Namespace, Label: "Namespace", From: report.FromLatest, Priority: 2}, Created: {ID: Created, Label: "Created", From: report.FromLatest, Datatype: "datetime", Priority: 3}, ObservedGeneration: {ID: ObservedGeneration, Label: "Observed Gen.", From: report.FromLatest, Datatype: "number", Priority: 4}, @@ -67,6 +69,7 @@ var ( ReplicaSetMetricTemplates = PodMetricTemplates DaemonSetMetadataTemplates = report.MetadataTemplates{ + NodeType: {ID: NodeType, Label: "Type", From: report.FromLatest, Priority: 1}, Namespace: {ID: Namespace, Label: "Namespace", From: report.FromLatest, Priority: 2}, Created: {ID: Created, Label: "Created", From: report.FromLatest, Datatype: "datetime", Priority: 3}, DesiredReplicas: {ID: DesiredReplicas, Label: "Desired Replicas", From: report.FromLatest, Datatype: "number", Priority: 4}, diff --git a/render/detailed/summary.go b/render/detailed/summary.go index 773039a26..b3d058b15 100644 --- a/render/detailed/summary.go +++ b/render/detailed/summary.go @@ -90,8 +90,8 @@ var primaryAPITopology = map[string]string{ report.ContainerImage: "containers-by-image", report.Pod: "pods", report.ReplicaSet: "replica-sets", - report.Deployment: "deployments", - report.DaemonSet: "daemonsets", + report.Deployment: "kube-controllers", + report.DaemonSet: "kube-controllers", report.Service: "services", report.ECSTask: "ecs-tasks", report.ECSService: "ecs-services", @@ -257,13 +257,23 @@ func podNodeSummary(base NodeSummary, n report.Node) (NodeSummary, bool) { return base, true } +var podGroupNodeTypeName = map[string]string{ + report.Deployment: "Deployment", + report.DaemonSet: "Daemon Set", +} + func podGroupNodeSummary(base NodeSummary, n report.Node) (NodeSummary, bool) { base = addKubernetesLabelAndRank(base, n) base.Stack = true // NB: pods are the highest aggregation level for which we display // counts. - base.LabelMinor = pluralize(n.Counters, report.Pod, "pod", "pods") + count := pluralize(n.Counters, report.Pod, "pod", "pods") + if typeName, ok := podGroupNodeTypeName[n.Topology]; ok { + base.LabelMinor = fmt.Sprintf("%s of %s", typeName, count) + } else { + base.LabelMinor = count + } return base, true } diff --git a/render/ecs.go b/render/ecs.go index 50eef7226..81b11a700 100644 --- a/render/ecs.go +++ b/render/ecs.go @@ -6,32 +6,20 @@ import ( // ECSTaskRenderer is a Renderer for Amazon ECS tasks. var ECSTaskRenderer = ConditionalRenderer(renderECSTopologies, - MakeMap( - PropagateSingleMetrics(report.Container), - MakeReduce( - MakeMap( - Map2Parent(report.ECSTask, UnmanagedID, nil), - MakeFilter( - IsRunning, - ContainerWithImageNameRenderer, - ), - ), - SelectECSTask, + renderParents( + report.Container, []string{report.ECSTask}, NoParentsPseudo, UnmanagedID, nil, + MakeFilter( + IsRunning, + ContainerWithImageNameRenderer, ), ), ) // ECSServiceRenderer is a Renderer for Amazon ECS services. var ECSServiceRenderer = ConditionalRenderer(renderECSTopologies, - MakeMap( - PropagateSingleMetrics(report.ECSTask), - MakeReduce( - MakeMap( - Map2Parent(report.ECSService, "", nil), - ECSTaskRenderer, - ), - SelectECSService, - ), + renderParents( + report.ECSTask, []string{report.ECSService}, NoParentsDrop, "", nil, + ECSTaskRenderer, ), ) diff --git a/render/filters.go b/render/filters.go index 7199e23a0..8cf384618 100644 --- a/render/filters.go +++ b/render/filters.go @@ -311,12 +311,6 @@ func IsNotPseudo(n report.Node) bool { return n.Topology != Pseudo || strings.HasSuffix(n.ID, TheInternetID) || strings.HasPrefix(n.ID, ServiceNodeIDPrefix) } -// IsPseudoTopology returns true if the node is in a pseudo topology, -// mimicing the check performed by MakeFilter() instead of the more complex check in IsNotPseudo() -func IsPseudoTopology(n report.Node) bool { - return n.Topology == Pseudo -} - // IsNamespace checks if the node is a pod/service in the specified namespace func IsNamespace(namespace string) FilterFunc { return func(n report.Node) bool { @@ -336,6 +330,17 @@ func IsNamespace(namespace string) FilterFunc { } } +// IsTopology checks if the node is from a particular report topology +func IsTopology(topology string) FilterFunc { + return func(n report.Node) bool { + return n.Topology == topology + } +} + +// IsPseudoTopology returns true if the node is in a pseudo topology, +// mimicing the check performed by MakeFilter() instead of the more complex check in IsNotPseudo() +var IsPseudoTopology = IsTopology(Pseudo) + var systemContainerNames = map[string]struct{}{ "weavescope": {}, "weavedns": {}, diff --git a/render/pod.go b/render/pod.go index fcd553c47..ccdb0dc1a 100644 --- a/render/pod.go +++ b/render/pod.go @@ -26,6 +26,15 @@ func isPauseContainer(n report.Node) bool { return ok && kubernetes.IsPauseImageName(image) } +type noParentsActionEnum int + +// Constants for specifying noParentsAction in Map2Parent +const ( + NoParentsPseudo noParentsActionEnum = iota + NoParentsDrop + NoParentsKeep +) + // PodRenderer is a Renderer which produces a renderable kubernetes // graph by merging the container graph and the pods topology. var PodRenderer = ConditionalRenderer(renderKubernetesTopologies, @@ -34,22 +43,18 @@ var PodRenderer = ConditionalRenderer(renderKubernetesTopologies, state, ok := n.Latest.Lookup(kubernetes.State) return (!ok || state != kubernetes.StateDeleted) }, - MakeMap( - PropagateSingleMetrics(report.Container), - MakeReduce( - MakeMap( - Map2Parent(report.Pod, UnmanagedID, nil), - MakeFilter( - ComposeFilterFuncs( - IsRunning, - Complement(isPauseContainer), - ), - ContainerWithImageNameRenderer, + MakeReduce( + renderParents( + report.Container, []string{report.Pod}, NoParentsPseudo, UnmanagedID, nil, + MakeFilter( + ComposeFilterFuncs( + IsRunning, + Complement(isPauseContainer), ), + ContainerWithImageNameRenderer, ), - ConnectionJoin(SelectPod, MapPod2IP), - SelectPod, ), + ConnectionJoin(SelectPod, MapPod2IP), ), ), ) @@ -57,62 +62,63 @@ var PodRenderer = ConditionalRenderer(renderKubernetesTopologies, // PodServiceRenderer is a Renderer which produces a renderable kubernetes services // graph by merging the pods graph and the services topology. var PodServiceRenderer = ConditionalRenderer(renderKubernetesTopologies, - MakeMap( - PropagateSingleMetrics(report.Pod), - MakeReduce( - MakeMap( - Map2Parent(report.Service, "", nil), - PodRenderer, - ), - SelectService, - ), - ), -) - -// DeploymentRenderer is a Renderer which produces a renderable kubernetes deployments -// graph by merging the pods graph and the deployments topology. -var DeploymentRenderer = ConditionalRenderer(renderKubernetesTopologies, - MakeMap( - PropagateSingleMetrics(report.ReplicaSet), - MakeReduce( - MakeMap( - Map2Parent(report.Deployment, "", mapPodCounts), - ReplicaSetRenderer, - ), - SelectDeployment, - ), + renderParents( + report.Pod, []string{report.Service}, NoParentsDrop, "", nil, + PodRenderer, ), ) // ReplicaSetRenderer is a Renderer which produces a renderable kubernetes replica sets // graph by merging the pods graph and the replica sets topology. var ReplicaSetRenderer = ConditionalRenderer(renderKubernetesTopologies, - MakeMap( - PropagateSingleMetrics(report.Pod), - MakeReduce( - MakeMap( - Map2Parent(report.ReplicaSet, "", nil), + renderParents( + report.Pod, []string{report.ReplicaSet}, NoParentsDrop, "", nil, + PodRenderer, + ), +) + +// KubeControllerRenderer is a Renderer which combines all the 'controller' topologies. +// We first map pods to daemonsets and replica sets, then to deployments since replica sets +// can map to those (the rest are passed through unchanged). +// Pods with no controller are mapped to 'Unmanaged' +// We can't simply combine the rendered graphs of the high level objects as they would never +// have connections to each other. +var KubeControllerRenderer = ConditionalRenderer(renderKubernetesTopologies, + MakeFilter( + // Filter out any remaining unmatched replica sets + Complement(IsTopology(report.ReplicaSet)), + renderParents( + report.ReplicaSet, []string{report.Deployment}, NoParentsKeep, "", mapPodCounts, + renderParents( + report.Pod, []string{report.ReplicaSet, report.DaemonSet}, + NoParentsPseudo, UnmanagedID, nil, PodRenderer, ), - SelectReplicaSet, ), ), ) -// DaemonSetRenderer is a Renderer which produces a renderable kubernetes daemonsets -// graph by merging the pods graph and the daemonsets topology. -var DaemonSetRenderer = ConditionalRenderer(renderKubernetesTopologies, - MakeMap( - PropagateSingleMetrics(report.Pod), - MakeReduce( +// renderParents produces a 'standard' renderer for mapping from some child topology to some parent topologies, +// by taking a child renderer, mapping to parents, propagating single metrics, and joining with full parent topology. +// Most options are as per Map2Parent. +func renderParents(childTopology string, parentTopologies []string, noParentsAction noParentsActionEnum, + noParentsPseudoID string, modifyMappedNode func(parent, original report.Node) report.Node, + childRenderer Renderer) Renderer { + selectors := make([]Renderer, len(parentTopologies)) + for i, topology := range parentTopologies { + selectors[i] = TopologySelector(topology) + } + return MakeReduce(append( + selectors, + MakeMap( + PropagateSingleMetrics(childTopology), MakeMap( - Map2Parent(report.DaemonSet, "", nil), - PodRenderer, + Map2Parent(parentTopologies, noParentsAction, noParentsPseudoID, modifyMappedNode), + childRenderer, ), - SelectDaemonSet, ), - ), -) + )...) +} func mapPodCounts(parent, original report.Node) report.Node { // When mapping ReplicaSets to Deployments, we want to propagate the Pods counter @@ -141,18 +147,23 @@ func MapPod2IP(m report.Node) []string { // Map2Parent returns a MapFunc which maps Nodes to some parent grouping. func Map2Parent( - // The topology ID of the parents - topology string, - // Either the ID prefix of the pseudo node to use for nodes without - // any parents in the group, eg. UnmanagedID, or "" to drop nodes without any parents. + // The topology IDs to look for parents in + topologies []string, + // Choose what to do in the case of nodes with no parents. One of: + // NoParentsPseudo: Map them to a common pseudo node id with prefix noParentsPseudoID + // NoParentsDrop: Map them to no node. + // NoParentsKeep: Map them to themselves, preserving them in the new graph. + noParentsAction noParentsActionEnum, + // The ID prefix of the pseudo node to use for nodes without any parents in the group + // if noParentsAction == Pseudo, eg. UnmanagedID noParentsPseudoID string, // Optional (can be nil) function to modify any parent nodes, // eg. to copy over details from the original node. modifyMappedNode func(parent, original report.Node) report.Node, ) MapFunc { return func(n report.Node, _ report.Networks) report.Nodes { - // Uncontained becomes Unmanaged/whatever if noParentsPseudoID is set - if noParentsPseudoID != "" && strings.HasPrefix(n.ID, UncontainedIDPrefix) { + // Uncontained becomes Unmanaged/whatever if noParentsAction == Pseudo + if noParentsAction == NoParentsPseudo && strings.HasPrefix(n.ID, UncontainedIDPrefix) { id := MakePseudoNodeID(noParentsPseudoID, report.ExtractHostID(n)) node := NewDerivedPseudoNode(id, n) return report.Nodes{id: node} @@ -163,28 +174,36 @@ func Map2Parent( return report.Nodes{n.ID: n} } - // If some some reason the node doesn't have any of these ids - // (maybe slightly out of sync reports, or its not in this group), - // either drop it or put it in Uncontained/Unmanaged/whatever if one was given - groupIDs, ok := n.Parents.Lookup(topology) - if !ok || len(groupIDs) == 0 { - if noParentsPseudoID == "" { - return report.Nodes{} + // For each topology, map to any parents we can find + result := report.Nodes{} + for _, topology := range topologies { + if groupIDs, ok := n.Parents.Lookup(topology); ok { + for _, id := range groupIDs { + node := NewDerivedNode(id, n).WithTopology(topology) + node.Counters = node.Counters.Add(n.Topology, 1) + if modifyMappedNode != nil { + node = modifyMappedNode(node, n) + } + result[id] = node + } } - id := MakePseudoNodeID(UnmanagedID, report.ExtractHostID(n)) - node := NewDerivedPseudoNode(id, n) - return report.Nodes{id: node} } - result := report.Nodes{} - for _, id := range groupIDs { - node := NewDerivedNode(id, n).WithTopology(topology) - node.Counters = node.Counters.Add(n.Topology, 1) - if modifyMappedNode != nil { - node = modifyMappedNode(node, n) + if len(result) == 0 { + switch noParentsAction { + case NoParentsPseudo: + // Map to pseudo node + id := MakePseudoNodeID(UnmanagedID, report.ExtractHostID(n)) + node := NewDerivedPseudoNode(id, n) + result[id] = node + case NoParentsKeep: + // Pass n to output unmodified + result[n.ID] = n + case NoParentsDrop: + // Do nothing, we will return an empty result } - result[id] = node } + return result } } diff --git a/render/swarm.go b/render/swarm.go index b77177a5e..4aecced10 100644 --- a/render/swarm.go +++ b/render/swarm.go @@ -6,17 +6,11 @@ import ( // SwarmServiceRenderer is a Renderer for Docker Swarm services var SwarmServiceRenderer = ConditionalRenderer(renderSwarmTopologies, - MakeMap( - PropagateSingleMetrics(report.Container), - MakeReduce( - MakeMap( - Map2Parent(report.SwarmService, UnmanagedID, nil), - MakeFilter( - IsRunning, - ContainerWithImageNameRenderer, - ), - ), - SelectSwarmService, + renderParents( + report.Container, []string{report.SwarmService}, NoParentsPseudo, UnmanagedID, nil, + MakeFilter( + IsRunning, + ContainerWithImageNameRenderer, ), ), ) diff --git a/report/report.go b/report/report.go index f1f790a42..7fb95ec95 100644 --- a/report/report.go +++ b/report/report.go @@ -29,9 +29,12 @@ const ( // Shapes used for different nodes Circle = "circle" + Triangle = "triangle" Square = "square" - Heptagon = "heptagon" + Pentagon = "pentagon" Hexagon = "hexagon" + Heptagon = "heptagon" + Octagon = "octagon" Cloud = "cloud" // Used when counting the number of containers @@ -163,15 +166,15 @@ func MakeReport() Report { WithLabel("service", "services"), Deployment: MakeTopology(). - WithShape(Heptagon). + WithShape(Octagon). WithLabel("deployment", "deployments"), ReplicaSet: MakeTopology(). - WithShape(Heptagon). + WithShape(Triangle). WithLabel("replica set", "replica sets"), DaemonSet: MakeTopology(). - WithShape(Heptagon). + WithShape(Pentagon). WithLabel("daemonset", "daemonsets"), Overlay: MakeTopology().