diff --git a/pkg/graph/builder.go b/pkg/graph/builder.go index 5ca21756..21a41bab 100644 --- a/pkg/graph/builder.go +++ b/pkg/graph/builder.go @@ -142,12 +142,17 @@ func (b *Builder) NewResourceGraphDefinition(originalCR *v1alpha1.ResourceGraphD // we'll also store the resources in a map for easy access later. resources := make(map[string]*Resource) - for _, rgResource := range rgd.Spec.Resources { - r, err := b.buildRGResource(rgResource, namespacedResources) + for i, rgResource := range rgd.Spec.Resources { + id := rgResource.ID + order := i + r, err := b.buildRGResource(rgResource, namespacedResources, order) if err != nil { - return nil, fmt.Errorf("failed to build resource '%v': %v", rgResource.ID, err) + return nil, fmt.Errorf("failed to build resource %q: %w", id, err) } - resources[rgResource.ID] = r + if resources[id] != nil { + return nil, fmt.Errorf("found resources with duplicate id %q", id) + } + resources[id] = r } // At this stage we have a superficial understanding of the resources that are @@ -246,7 +251,7 @@ func (b *Builder) NewResourceGraphDefinition(originalCR *v1alpha1.ResourceGraphD // It provides a high-level understanding of the resource, by extracting the // OpenAPI schema, emualting the resource and extracting the cel expressions // from the schema. -func (b *Builder) buildRGResource(rgResource *v1alpha1.Resource, namespacedResources map[k8sschema.GroupKind]bool) (*Resource, error) { +func (b *Builder) buildRGResource(rgResource *v1alpha1.Resource, namespacedResources map[k8sschema.GroupKind]bool, order int) (*Resource, error) { // 1. We need to unmashal the resource into a map[string]interface{} to // make it easier to work with. resourceObject := map[string]interface{}{} @@ -334,6 +339,7 @@ func (b *Builder) buildRGResource(rgResource *v1alpha1.Resource, namespacedResou readyWhenExpressions: readyWhen, includeWhenExpressions: includeWhen, namespaced: isNamespaced, + order: order, }, nil } @@ -364,13 +370,13 @@ func (b *Builder) buildDependencyGraph( directedAcyclicGraph := dag.NewDirectedAcyclicGraph() // Set the vertices of the graph to be the resources defined in the resource graph definition. - for resourceName := range resources { - if err := directedAcyclicGraph.AddVertex(resourceName); err != nil { + for _, resource := range resources { + if err := directedAcyclicGraph.AddVertex(resource.id, resource.order); err != nil { return nil, fmt.Errorf("failed to add vertex to graph: %w", err) } } - for resourceName, resource := range resources { + for _, resource := range resources { for _, resourceVariable := range resource.variables { for _, expression := range resourceVariable.Expressions { // We need to inspect the expression to understand how it relates to the @@ -397,10 +403,8 @@ func (b *Builder) buildDependencyGraph( resource.addDependencies(resourceDependencies...) resourceVariable.AddDependencies(resourceDependencies...) // We need to add the dependencies to the graph. - for _, dependency := range resourceDependencies { - if err := directedAcyclicGraph.AddEdge(resourceName, dependency); err != nil { - return nil, err - } + if err := directedAcyclicGraph.AddDependencies(resource.id, resourceDependencies); err != nil { + return nil, err } } } diff --git a/pkg/graph/builder_test.go b/pkg/graph/builder_test.go index 0cad2c56..83ba6c28 100644 --- a/pkg/graph/builder_test.go +++ b/pkg/graph/builder_test.go @@ -509,7 +509,7 @@ func TestGraphBuilder_DependencyValidation(t *testing.T) { assert.Contains(t, clusterDeps, "subnet2") // Validate topological order - assert.Equal(t, []string{"clusterpolicy", "clusterrole", "vpc", "subnet1", "subnet2", "cluster"}, g.TopologicalOrder) + assert.Equal(t, []string{"vpc", "clusterpolicy", "clusterrole", "subnet1", "subnet2", "cluster"}, g.TopologicalOrder) }, }, { @@ -571,7 +571,7 @@ func TestGraphBuilder_DependencyValidation(t *testing.T) { }, nil, nil), }, wantErr: true, - errMsg: "This would create a cycle", + errMsg: "graph contains a cycle", }, { name: "independent pods", @@ -726,7 +726,7 @@ func TestGraphBuilder_DependencyValidation(t *testing.T) { }, nil, nil), }, wantErr: true, - errMsg: "This would create a cycle", + errMsg: "graph contains a cycle", }, { name: "shared infrastructure dependencies", @@ -919,17 +919,17 @@ func TestGraphBuilder_DependencyValidation(t *testing.T) { // Validate topological order assert.Equal(t, []string{ - "policy", - "role", "vpc", "subnet1", "subnet2", "subnet3", + "secgroup", + "policy", + "role", "cluster1", "cluster2", "cluster3", "monitor", - "secgroup", }, g.TopologicalOrder) }, }, diff --git a/pkg/graph/dag/dag.go b/pkg/graph/dag/dag.go index 0b9d9508..8c1fffc1 100644 --- a/pkg/graph/dag/dag.go +++ b/pkg/graph/dag/dag.go @@ -14,6 +14,7 @@ package dag import ( + "errors" "fmt" "sort" "strings" @@ -23,9 +24,22 @@ import ( type Vertex struct { // ID is a unique identifier for the node ID string - // Edges stores the IDs of the nodes that this node has an outgoing edge to. - // In kro, this would be the children of a resource. - Edges map[string]struct{} + // Order records the original order, and is used to preserve the original user-provided ordering as far as posible. + Order int + // DependsOn stores the IDs of the nodes that this node depends on. + // If we depend on another vertex, we must appear after that vertex in the topological sort. + DependsOn map[string]struct{} +} + +func (v Vertex) String() string { + var dependsOn strings.Builder + for id := range v.DependsOn { + if dependsOn.Len() != 0 { + dependsOn.WriteString(",") + } + dependsOn.WriteString(id) + } + return fmt.Sprintf("Vertex[ID: %s, Order: %d, DependsOn: %s]", v.ID, v.Order, dependsOn.String()) } // DirectedAcyclicGraph represents a directed acyclic graph @@ -42,54 +56,66 @@ func NewDirectedAcyclicGraph() *DirectedAcyclicGraph { } // AddVertex adds a new node to the graph. -func (d *DirectedAcyclicGraph) AddVertex(id string) error { +func (d *DirectedAcyclicGraph) AddVertex(id string, order int) error { if _, exists := d.Vertices[id]; exists { return fmt.Errorf("node %s already exists", id) } d.Vertices[id] = &Vertex{ - ID: id, - Edges: make(map[string]struct{}), + ID: id, + Order: order, + DependsOn: make(map[string]struct{}), } return nil } type CycleError struct { - From, to string - Cycle []string + Cycle []string } func (e *CycleError) Error() string { - return fmt.Sprintf("Cannot add edge from %s to %s. This would create a cycle: %s", e.From, e.to, formatCycle(e.Cycle)) + return fmt.Sprintf("graph contains a cycle: %s", formatCycle(e.Cycle)) } func formatCycle(cycle []string) string { return strings.Join(cycle, " -> ") } -// AddEdge adds a directed edge from one node to another. -func (d *DirectedAcyclicGraph) AddEdge(from, to string) error { +// AsCycleError returns the (potentially wrapped) CycleError, or nil if it is not a CycleError. +func AsCycleError(err error) *CycleError { + cycleError := &CycleError{} + if errors.As(err, &cycleError) { + return cycleError + } + return nil +} + +// AddDependencies adds a set of dependencies to the "from" vertex. +// This indicates that all the vertexes in "dependencies" must occur before "from". +func (d *DirectedAcyclicGraph) AddDependencies(from string, dependencies []string) error { fromNode, fromExists := d.Vertices[from] - _, toExists := d.Vertices[to] if !fromExists { return fmt.Errorf("node %s does not exist", from) } - if !toExists { - return fmt.Errorf("node %s does not exist", to) - } - if from == to { - return fmt.Errorf("self references are not allowed") - } - fromNode.Edges[to] = struct{}{} + for _, dependency := range dependencies { + _, toExists := d.Vertices[dependency] + if !toExists { + return fmt.Errorf("node %s does not exist", dependency) + } + if from == dependency { + return fmt.Errorf("self references are not allowed") + } + fromNode.DependsOn[dependency] = struct{}{} + } // Check if the graph is still a DAG - hasCycle, cycle := d.HasCycle() + hasCycle, cycle := d.hasCycle() if hasCycle { // Ehmmm, we have a cycle, let's remove the edge we just added - delete(fromNode.Edges, to) + for _, dependency := range dependencies { + delete(fromNode.DependsOn, dependency) + } return &CycleError{ - From: from, - to: to, Cycle: cycle, } } @@ -97,79 +123,61 @@ func (d *DirectedAcyclicGraph) AddEdge(from, to string) error { return nil } +// TopologicalSort returns the vertexes of the graph, respecting topological ordering first, +// and preserving order of nodes within each "depth" of the topological ordering. func (d *DirectedAcyclicGraph) TopologicalSort() ([]string, error) { - if cyclic, _ := d.HasCycle(); cyclic { - return nil, fmt.Errorf("graph has a cycle") - } - visited := make(map[string]bool) var order []string - // Get a sorted list of all vertices - vertices := d.GetVertices() + // Make a list of vertices, sorted by Order + vertices := make([]*Vertex, 0, len(d.Vertices)) + for _, vertex := range d.Vertices { + vertices = append(vertices, vertex) + } + sort.Slice(vertices, func(i, j int) bool { + return vertices[i].Order < vertices[j].Order + }) - var dfs func(string) - dfs = func(node string) { - visited[node] = true + for len(visited) < len(vertices) { + progress := false - // Sort the neighbors to ensure deterministic order - neighbors := make([]string, 0, len(d.Vertices[node].Edges)) - for neighbor := range d.Vertices[node].Edges { - neighbors = append(neighbors, neighbor) - } - sort.Strings(neighbors) + for _, vertex := range vertices { + if visited[vertex.ID] { + continue + } - for _, neighbor := range neighbors { - if !visited[neighbor] { - dfs(neighbor) + allDependenciesReady := true + for dep := range vertex.DependsOn { + if !visited[dep] { + allDependenciesReady = false + break + } + } + if !allDependenciesReady { + continue } + + order = append(order, vertex.ID) + visited[vertex.ID] = true + progress = true } - order = append(order, node) - } - // Visit nodes in a deterministic order - for _, node := range vertices { - if !visited[node] { - dfs(node) + if !progress { + hasCycle, cycle := d.hasCycle() + if !hasCycle { + // Unexpected! + return nil, &CycleError{} + } + return nil, &CycleError{ + Cycle: cycle, + } } } return order, nil } -// GetVertices returns the nodes in the graph in sorted alphabetical -// order. -func (d *DirectedAcyclicGraph) GetVertices() []string { - nodes := make([]string, 0, len(d.Vertices)) - for node := range d.Vertices { - nodes = append(nodes, node) - } - - // Ensure deterministic order. This is important for TopologicalSort - // to return a deterministic result. - sort.Strings(nodes) - return nodes -} - -// GetEdges returns the edges in the graph in sorted order... -func (d *DirectedAcyclicGraph) GetEdges() [][2]string { - var edges [][2]string - for from, node := range d.Vertices { - for to := range node.Edges { - edges = append(edges, [2]string{from, to}) - } - } - sort.Slice(edges, func(i, j int) bool { - // Sort by from node first - if edges[i][0] == edges[j][0] { - return edges[i][1] < edges[j][1] - } - return edges[i][0] < edges[j][0] - }) - return edges -} - -func (d *DirectedAcyclicGraph) HasCycle() (bool, []string) { +func (d *DirectedAcyclicGraph) hasCycle() (bool, []string) { visited := make(map[string]bool) recStack := make(map[string]bool) var cyclePath []string @@ -180,14 +188,14 @@ func (d *DirectedAcyclicGraph) HasCycle() (bool, []string) { recStack[node] = true cyclePath = append(cyclePath, node) - for neighbor := range d.Vertices[node].Edges { - if !visited[neighbor] { - if dfs(neighbor) { + for dependency := range d.Vertices[node].DependsOn { + if !visited[dependency] { + if dfs(dependency) { return true } - } else if recStack[neighbor] { + } else if recStack[dependency] { // Found a cycle, add the closing node to complete the cycle - cyclePath = append(cyclePath, neighbor) + cyclePath = append(cyclePath, dependency) return true } } diff --git a/pkg/graph/dag/dag_test.go b/pkg/graph/dag/dag_test.go index a3451f01..a7167540 100644 --- a/pkg/graph/dag/dag_test.go +++ b/pkg/graph/dag/dag_test.go @@ -14,18 +14,20 @@ package dag import ( + "fmt" "reflect" + "strings" "testing" ) func TestDAGAddNode(t *testing.T) { d := NewDirectedAcyclicGraph() - if err := d.AddVertex("A"); err != nil { + if err := d.AddVertex("A", 1); err != nil { t.Errorf("Failed to add node: %v", err) } - if err := d.AddVertex("A"); err == nil { + if err := d.AddVertex("A", 1); err == nil { t.Error("Expected error when adding duplicate node, but got nil") } @@ -36,117 +38,153 @@ func TestDAGAddNode(t *testing.T) { func TestDAGAddEdge(t *testing.T) { d := NewDirectedAcyclicGraph() - d.AddVertex("A") - d.AddVertex("B") + if err := d.AddVertex("A", 1); err != nil { + t.Fatalf("error from AddVertex(A, 1): %v", err) + } + if err := d.AddVertex("B", 2); err != nil { + t.Fatalf("error from AddVertex(B, 2): %v", err) + } - if err := d.AddEdge("A", "B"); err != nil { + if err := d.AddDependencies("A", []string{"B"}); err != nil { t.Errorf("Failed to add edge: %v", err) } - if err := d.AddEdge("A", "C"); err == nil { + if err := d.AddDependencies("A", []string{"C"}); err == nil { t.Error("Expected error when adding edge to non-existent node, but got nil") } - if err := d.AddEdge("A", "A"); err == nil { - t.Error("Expected error when adding self refernce, but got nil") + if err := d.AddDependencies("A", []string{"A"}); err == nil { + t.Error("Expected error when adding self reference, but got nil") } } func TestDAGHasCycle(t *testing.T) { d := NewDirectedAcyclicGraph() - d.AddVertex("A") - d.AddVertex("B") - d.AddVertex("C") + if err := d.AddVertex("A", 1); err != nil { + t.Fatalf("error from AddVertex(A, 1): %v", err) + } + if err := d.AddVertex("B", 2); err != nil { + t.Fatalf("error from AddVertex(B, 2): %v", err) + } + if err := d.AddVertex("C", 3); err != nil { + t.Fatalf("error from AddVertex(C, 3): %v", err) + } - d.AddEdge("A", "B") - d.AddEdge("B", "C") + if err := d.AddDependencies("A", []string{"B"}); err != nil { + t.Fatalf("adding dependencies: %v", err) + } + if err := d.AddDependencies("B", []string{"C"}); err != nil { + t.Fatalf("adding dependencies: %v", err) + } - if cyclic, _ := d.HasCycle(); cyclic { + if cyclic, _ := d.hasCycle(); cyclic { t.Error("DAG incorrectly reported a cycle") } - if err := d.AddEdge("C", "A"); err == nil { + if err := d.AddDependencies("C", []string{"A"}); err == nil { t.Error("Expected error when creating a cycle, but got nil") } // pointless to test for the cycle here, so we need to emulate one // by artificially adding a cycle. - d.Vertices["C"].Edges["A"] = struct{}{} - if cyclic, _ := d.HasCycle(); !cyclic { + d.Vertices["C"].DependsOn["A"] = struct{}{} + if cyclic, _ := d.hasCycle(); !cyclic { t.Error("DAG failed to detect cycle") } -} -func TestDAGTopologicalSort(t *testing.T) { - d := NewDirectedAcyclicGraph() - d.AddVertex("A") - d.AddVertex("B") - d.AddVertex("C") - d.AddVertex("D") - d.AddVertex("E") - d.AddVertex("F") - - d.AddEdge("A", "B") - d.AddEdge("A", "C") - d.AddEdge("B", "D") - d.AddEdge("C", "D") - d.AddEdge("E", "A") - d.AddEdge("E", "F") - // [D B C A F E] - - order, err := d.TopologicalSort() - if err != nil { - t.Errorf("topological sort failed: %v", err) - } - - // slices.Reverse(order) - - if !isValidTopologicalOrder(d, order) { - t.Errorf("invalid topological order: %v", order) + if _, err := d.TopologicalSort(); err == nil { + t.Errorf("TopologicalSort failed to detect cycle") + } else if AsCycleError(err) == nil { + t.Errorf("TopologicalSort returned unexpected error: %T %v", err, err) } } -func TestDAGGetNodes(t *testing.T) { - d := NewDirectedAcyclicGraph() - d.AddVertex("A") - d.AddVertex("B") - d.AddVertex("C") +func TestDAGTopologicalSort(t *testing.T) { + grid := []struct { + Nodes string + Edges string + Want string + }{ + {Nodes: "A,B", Want: "A,B"}, + {Nodes: "A,B", Edges: "A->B", Want: "A,B"}, + {Nodes: "A,B", Edges: "B->A", Want: "B,A"}, + {Nodes: "A,B,C,D,E,F", Want: "A,B,C,D,E,F"}, + {Nodes: "A,B,C,D,E,F", Edges: "C->D", Want: "A,B,C,D,E,F"}, + {Nodes: "A,B,C,D,E,F", Edges: "D->C", Want: "A,B,D,E,F,C"}, + {Nodes: "A,B,C,D,E,F", Edges: "F->A,F->B,B->A", Want: "C,D,E,F,B,A"}, + {Nodes: "A,B,C,D,E,F", Edges: "B->A,C->A,D->B,D->C,F->E,A->E", Want: "D,F,B,C,A,E"}, + } - nodes := d.GetVertices() - expected := []string{"A", "B", "C"} + for i, g := range grid { + t.Run(fmt.Sprintf("[%d] nodes=%s,edges=%s", i, g.Nodes, g.Edges), func(t *testing.T) { + d := NewDirectedAcyclicGraph() + for i, node := range strings.Split(g.Nodes, ",") { + if err := d.AddVertex(node, i); err != nil { + t.Fatalf("adding vertex: %v", err) + } + } - if !reflect.DeepEqual(nodes, expected) { - t.Errorf("GetNodes() = %v, want %v", nodes, expected) - } -} + if g.Edges != "" { + for _, edge := range strings.Split(g.Edges, ",") { + tokens := strings.SplitN(edge, "->", 2) + if err := d.AddDependencies(tokens[1], []string{tokens[0]}); err != nil { + t.Fatalf("adding edge %q: %v", edge, err) + } + } + } -func TestDAGGetEdges(t *testing.T) { - d := NewDirectedAcyclicGraph() - d.AddVertex("A") - d.AddVertex("B") - d.AddVertex("C") - d.AddEdge("A", "B") - d.AddEdge("B", "C") + order, err := d.TopologicalSort() + if err != nil { + t.Errorf("topological sort failed: %v", err) + } - edges := d.GetEdges() - expected := [][2]string{{"A", "B"}, {"B", "C"}} + got := strings.Join(order, ",") + want := g.Want + if !reflect.DeepEqual(got, want) { + t.Errorf("unexpected result from TopologicalSort for nodes=%q edges=%q, got %q, want %q", g.Nodes, g.Edges, got, want) + } - if !reflect.DeepEqual(edges, expected) { - t.Errorf("GetEdges() = %v, want %v", edges, expected) + checkValidTopologicalOrder(t, d, order) + }) } } -func isValidTopologicalOrder(d *DirectedAcyclicGraph, order []string) bool { +func checkValidTopologicalOrder(t *testing.T, d *DirectedAcyclicGraph, order []string) { pos := make(map[string]int) for i, node := range order { pos[node] = i } + + // Verify that we obey the dependencies for _, node := range order { - for successor := range d.Vertices[node].Edges { + for successor := range d.Vertices[node].DependsOn { if pos[node] < pos[successor] { - return false + t.Errorf("invalid topological order: %v", order) } } } - return true + + // Verify that we also obey the ordering, unless we cannot + for i, nodeKey := range order { + if i == 0 { + continue + } + node := d.Vertices[nodeKey] + previousNode := d.Vertices[order[i-1]] + if previousNode.Order <= node.Order { + continue // these two nodes are in order + } + + // These two nodes are out of order, there should be a dependency on one of the previous nodes + hasDep := false + for j := 0; j < i; j++ { + if _, found := node.DependsOn[order[j]]; found { + hasDep = true + break + } + } + if !hasDep { + t.Errorf("invalid topological order %q; node %v appears before %v", order, previousNode, node) + } + } } diff --git a/pkg/graph/resource.go b/pkg/graph/resource.go index 26b298d7..d725f76b 100644 --- a/pkg/graph/resource.go +++ b/pkg/graph/resource.go @@ -70,6 +70,9 @@ type Resource struct { // This is useful when initiating the dynamic client to interact with the // resource. namespaced bool + // order reflects the original order in which the resources were specified, + // and lets us keep the client-specified ordering where the dependencies allow. + order int } // GetDependencies returns the dependencies of the resource. @@ -106,6 +109,11 @@ func (r *Resource) GetID() string { return r.id } +// GetOrder returns the original Order of the resource. +func (r *Resource) GetOrder() int { + return r.order +} + // GetGroupVersionKind returns the GVK of the resource. func (r *Resource) GetGroupVersionResource() schema.GroupVersionResource { return r.gvr @@ -160,6 +168,7 @@ func (r *Resource) IsNamespaced() bool { func (r *Resource) DeepCopy() *Resource { return &Resource{ id: r.id, + order: r.order, gvr: r.gvr, schema: r.schema, originalObject: r.originalObject.DeepCopy(), diff --git a/test/integration/suites/ackekscluster/suite_test.go b/test/integration/suites/ackekscluster/suite_test.go index e21f9e94..35716aa9 100644 --- a/test/integration/suites/ackekscluster/suite_test.go +++ b/test/integration/suites/ackekscluster/suite_test.go @@ -89,17 +89,17 @@ var _ = Describe("EKSCluster", func() { g.Expect(createdRGD.Spec.Resources).To(HaveLen(12)) // All resources from the generator g.Expect(createdRGD.Status.TopologicalOrder).To(Equal([]string{ - "clusterRole", "clusterVPC", + "clusterElasticIPAddress", "clusterInternetGateway", "clusterRouteTable", "clusterSubnetA", "clusterSubnetB", - "cluster", - "clusterAdminRole", - "clusterElasticIPAddress", "clusterNATGateway", + "clusterRole", "clusterNodeRole", + "clusterAdminRole", + "cluster", "clusterNodeGroup", })) diff --git a/test/integration/suites/core/conditional_test.go b/test/integration/suites/core/conditional_test.go index 4c1206a0..0c61e204 100644 --- a/test/integration/suites/core/conditional_test.go +++ b/test/integration/suites/core/conditional_test.go @@ -213,8 +213,8 @@ var _ = Describe("Conditions", func() { g.Expect(createdRGD.Status.TopologicalOrder).To(Equal([]string{ "deploymentA", "serviceAccountA", - "deploymentB", "serviceA", + "deploymentB", "serviceAccountB", "serviceB", })) diff --git a/test/integration/suites/core/topology_test.go b/test/integration/suites/core/topology_test.go index eef5e32e..f33a72f3 100644 --- a/test/integration/suites/core/topology_test.go +++ b/test/integration/suites/core/topology_test.go @@ -221,7 +221,7 @@ var _ = Describe("Topology", func() { } g.Expect(graphCondition).ToNot(BeNil()) g.Expect(graphCondition.Status).To(Equal(metav1.ConditionFalse)) - g.Expect(*graphCondition.Reason).To(ContainSubstring("This would create a cycle")) + g.Expect(*graphCondition.Reason).To(ContainSubstring("graph contains a cycle")) g.Expect(rgd.Status.State).To(Equal(krov1alpha1.ResourceGraphDefinitionStateInactive)) }, 10*time.Second, time.Second).Should(Succeed()) }) diff --git a/test/integration/suites/networkingstack/suite_test.go b/test/integration/suites/networkingstack/suite_test.go index 32185f8b..5080aa51 100644 --- a/test/integration/suites/networkingstack/suite_test.go +++ b/test/integration/suites/networkingstack/suite_test.go @@ -90,10 +90,10 @@ var _ = Describe("NetworkingStack", func() { g.Expect(createdRGD.Status.TopologicalOrder).To(HaveLen(5)) g.Expect(createdRGD.Status.TopologicalOrder).To(Equal([]string{ "vpc", - "securityGroup", "subnetAZA", "subnetAZB", "subnetAZC", + "securityGroup", })) g.Expect(createdRGD.Status.Conditions).To(HaveLen(3)) g.Expect(createdRGD.Status.Conditions[0].Type).To(Equal(