Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
K
k3s
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Jacklull
k3s
Commits
2003a0db
Commit
2003a0db
authored
Jul 11, 2018
by
Silvery Fu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Rework image locality with spread-based scoring
parent
c3f111f7
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
236 additions
and
49 deletions
+236
-49
image_locality.go
pkg/scheduler/algorithm/priorities/image_locality.go
+39
-27
image_locality_test.go
pkg/scheduler/algorithm/priorities/image_locality_test.go
+20
-20
metadata.go
pkg/scheduler/algorithm/priorities/metadata.go
+2
-0
util.go
pkg/scheduler/cache/util.go
+41
-2
util_test.go
pkg/scheduler/cache/util_test.go
+134
-0
No files found.
pkg/scheduler/algorithm/priorities/image_locality.go
View file @
2003a0db
...
@@ -26,11 +26,12 @@ import (
...
@@ -26,11 +26,12 @@ import (
"k8s.io/kubernetes/pkg/util/parsers"
"k8s.io/kubernetes/pkg/util/parsers"
)
)
// This is a reasonable size range of all container images. 90%ile of images on dockerhub drops into this range.
// The two thresholds are used as bounds for the image score range. They correspond to a reasonable size range for
// container images compressed and stored in registries; 90%ile of images on dockerhub drops into this range.
const
(
const
(
mb
int64
=
1024
*
1024
mb
int64
=
1024
*
1024
min
ImgSize
int64
=
23
*
mb
min
Threshold
int64
=
23
*
mb
max
ImgSize
int64
=
1000
*
mb
max
Threshold
int64
=
1000
*
mb
)
)
// ImageLocalityPriorityMap is a priority function that favors nodes that already have requested pod container's images.
// ImageLocalityPriorityMap is a priority function that favors nodes that already have requested pod container's images.
...
@@ -44,44 +45,55 @@ func ImageLocalityPriorityMap(pod *v1.Pod, meta interface{}, nodeInfo *scheduler
...
@@ -44,44 +45,55 @@ func ImageLocalityPriorityMap(pod *v1.Pod, meta interface{}, nodeInfo *scheduler
return
schedulerapi
.
HostPriority
{},
fmt
.
Errorf
(
"node not found"
)
return
schedulerapi
.
HostPriority
{},
fmt
.
Errorf
(
"node not found"
)
}
}
sumSize
:=
totalImageSize
(
nodeInfo
,
pod
.
Spec
.
Containers
)
var
score
int
if
priorityMeta
,
ok
:=
meta
.
(
*
priorityMetadata
);
ok
{
score
=
calculatePriority
(
sumImageScores
(
nodeInfo
,
pod
.
Spec
.
Containers
,
priorityMeta
.
totalNumNodes
))
}
else
{
// if we are not able to parse priority meta data, skip this priority
score
=
0
}
return
schedulerapi
.
HostPriority
{
return
schedulerapi
.
HostPriority
{
Host
:
node
.
Name
,
Host
:
node
.
Name
,
Score
:
calculateScoreFromSize
(
sumSize
)
,
Score
:
score
,
},
nil
},
nil
}
}
// calculateScoreFromSize calculates the priority of a node. sumSize is sum size of requested images on this node.
// calculatePriority returns the priority of a node. Given the sumScores of requested images on the node, the node's
// 1. Split image size range into 10 buckets.
// priority is obtained by scaling the maximum priority value with a ratio proportional to the sumScores.
// 2. Decide the priority of a given sumSize based on which bucket it belongs to.
func
calculatePriority
(
sumScores
int64
)
int
{
func
calculateScoreFromSize
(
sumSize
int64
)
int
{
if
sumScores
<
minThreshold
{
switch
{
sumScores
=
minThreshold
case
sumSize
==
0
||
sumSize
<
minImgSize
:
}
else
if
sumScores
>
maxThreshold
{
// 0 means none of the images required by this pod are present on this
sumScores
=
maxThreshold
// node or the total size of the images present are too small to be taken into further consideration.
return
0
case
sumSize
>=
maxImgSize
:
// If existing images' total size is larger than max, just make it highest priority.
return
schedulerapi
.
MaxPriority
}
}
return
int
(
(
int64
(
schedulerapi
.
MaxPriority
)
*
(
sumSize
-
minImgSize
)
/
(
maxImgSize
-
minImgSize
))
+
1
)
return
int
(
int64
(
schedulerapi
.
MaxPriority
)
*
(
sumScores
-
minThreshold
)
/
(
maxThreshold
-
minThreshold
)
)
}
}
// totalImageSize returns the total image size of all the containers that are already on the node.
// sumImageScores returns the sum of image scores of all the containers that are already on the node.
func
totalImageSize
(
nodeInfo
*
schedulercache
.
NodeInfo
,
containers
[]
v1
.
Container
)
int64
{
// Each image receives a raw score of its size, scaled by scaledImageScore. The raw scores are later used to calculate
var
total
int64
// the final score. Note that the init containers are not considered for it's rare for users to deploy huge init containers.
func
sumImageScores
(
nodeInfo
*
schedulercache
.
NodeInfo
,
containers
[]
v1
.
Container
,
totalNumNodes
int
)
int64
{
var
sum
int64
imageStates
:=
nodeInfo
.
ImageStates
()
imageSizes
:=
nodeInfo
.
ImageSizes
()
for
_
,
container
:=
range
containers
{
for
_
,
container
:=
range
containers
{
if
s
ize
,
ok
:=
imageSiz
es
[
normalizedImageName
(
container
.
Image
)];
ok
{
if
s
tate
,
ok
:=
imageStat
es
[
normalizedImageName
(
container
.
Image
)];
ok
{
total
+=
size
sum
+=
scaledImageScore
(
state
,
totalNumNodes
)
}
}
}
}
return
total
return
sum
}
// scaledImageScore returns an adaptively scaled score for the given state of an image.
// The size of the image is used as the base score, scaled by a factor which considers how much nodes the image has "spread" to.
// This heuristic aims to mitigate the undesirable "node heating problem", i.e., pods get assigned to the same or
// a few nodes due to image locality.
func
scaledImageScore
(
imageState
*
schedulercache
.
ImageStateSummary
,
totalNumNodes
int
)
int64
{
spread
:=
float64
(
imageState
.
NumNodes
)
/
float64
(
totalNumNodes
)
return
int64
(
float64
(
imageState
.
Size
)
*
spread
)
}
}
// normalizedImageName returns the CRI compliant name for a given image.
// normalizedImageName returns the CRI compliant name for a given image.
...
...
pkg/scheduler/algorithm/priorities/image_locality_test.go
View file @
2003a0db
...
@@ -42,13 +42,13 @@ func TestImageLocalityPriority(t *testing.T) {
...
@@ -42,13 +42,13 @@ func TestImageLocalityPriority(t *testing.T) {
},
},
}
}
test40
14
0
:=
v1
.
PodSpec
{
test40
30
0
:=
v1
.
PodSpec
{
Containers
:
[]
v1
.
Container
{
Containers
:
[]
v1
.
Container
{
{
{
Image
:
"gcr.io/40"
,
Image
:
"gcr.io/40"
,
},
},
{
{
Image
:
"gcr.io/
14
0"
,
Image
:
"gcr.io/
30
0"
,
},
},
},
},
}
}
...
@@ -64,7 +64,7 @@ func TestImageLocalityPriority(t *testing.T) {
...
@@ -64,7 +64,7 @@ func TestImageLocalityPriority(t *testing.T) {
},
},
}
}
node40
14
02000
:=
v1
.
NodeStatus
{
node40
30
02000
:=
v1
.
NodeStatus
{
Images
:
[]
v1
.
ContainerImage
{
Images
:
[]
v1
.
ContainerImage
{
{
{
Names
:
[]
string
{
Names
:
[]
string
{
...
@@ -76,10 +76,10 @@ func TestImageLocalityPriority(t *testing.T) {
...
@@ -76,10 +76,10 @@ func TestImageLocalityPriority(t *testing.T) {
},
},
{
{
Names
:
[]
string
{
Names
:
[]
string
{
"gcr.io/
14
0:"
+
parsers
.
DefaultImageTag
,
"gcr.io/
30
0:"
+
parsers
.
DefaultImageTag
,
"gcr.io/
14
0:v1"
,
"gcr.io/
30
0:v1"
,
},
},
SizeBytes
:
int64
(
14
0
*
mb
),
SizeBytes
:
int64
(
30
0
*
mb
),
},
},
{
{
Names
:
[]
string
{
Names
:
[]
string
{
...
@@ -120,29 +120,29 @@ func TestImageLocalityPriority(t *testing.T) {
...
@@ -120,29 +120,29 @@ func TestImageLocalityPriority(t *testing.T) {
// Node1
// Node1
// Image: gcr.io/40:latest 40MB
// Image: gcr.io/40:latest 40MB
// Score:
(40M-23M)/97.7M + 1 = 1
// Score:
0 (40M/2 < 23M, min-threshold)
// Node2
// Node2
// Image: gcr.io/250:latest 250MB
// Image: gcr.io/250:latest 250MB
// Score:
(250M-23M)/97.7M + 1 = 3
// Score:
10 * (250M/2 - 23M)/(1000M - 23M) = 1
pod
:
&
v1
.
Pod
{
Spec
:
test40250
},
pod
:
&
v1
.
Pod
{
Spec
:
test40250
},
nodes
:
[]
*
v1
.
Node
{
makeImageNode
(
"machine1"
,
node40
14
02000
),
makeImageNode
(
"machine2"
,
node25010
)},
nodes
:
[]
*
v1
.
Node
{
makeImageNode
(
"machine1"
,
node40
30
02000
),
makeImageNode
(
"machine2"
,
node25010
)},
expectedList
:
[]
schedulerapi
.
HostPriority
{{
Host
:
"machine1"
,
Score
:
1
},
{
Host
:
"machine2"
,
Score
:
3
}},
expectedList
:
[]
schedulerapi
.
HostPriority
{{
Host
:
"machine1"
,
Score
:
0
},
{
Host
:
"machine2"
,
Score
:
1
}},
name
:
"two images spread on two nodes, prefer the larger image one"
,
name
:
"two images spread on two nodes, prefer the larger image one"
,
},
},
{
{
// Pod: gcr.io/40 gcr.io/
14
0
// Pod: gcr.io/40 gcr.io/
30
0
// Node1
// Node1
// Image: gcr.io/40:latest 40MB, gcr.io/
140:latest 14
0MB
// Image: gcr.io/40:latest 40MB, gcr.io/
300:latest 30
0MB
// Score:
(40M+140M-23M)/97.7M + 1 = 2
// Score:
10 * ((40M + 300M)/2 - 23M)/(1000M - 23M) = 1
// Node2
// Node2
// Image: not present
// Image: not present
// Score: 0
// Score: 0
pod
:
&
v1
.
Pod
{
Spec
:
test40
14
0
},
pod
:
&
v1
.
Pod
{
Spec
:
test40
30
0
},
nodes
:
[]
*
v1
.
Node
{
makeImageNode
(
"machine1"
,
node40
14
02000
),
makeImageNode
(
"machine2"
,
node25010
)},
nodes
:
[]
*
v1
.
Node
{
makeImageNode
(
"machine1"
,
node40
30
02000
),
makeImageNode
(
"machine2"
,
node25010
)},
expectedList
:
[]
schedulerapi
.
HostPriority
{{
Host
:
"machine1"
,
Score
:
2
},
{
Host
:
"machine2"
,
Score
:
0
}},
expectedList
:
[]
schedulerapi
.
HostPriority
{{
Host
:
"machine1"
,
Score
:
1
},
{
Host
:
"machine2"
,
Score
:
0
}},
name
:
"two images on one node, prefer this node"
,
name
:
"two images on one node, prefer this node"
,
},
},
{
{
...
@@ -150,13 +150,13 @@ func TestImageLocalityPriority(t *testing.T) {
...
@@ -150,13 +150,13 @@ func TestImageLocalityPriority(t *testing.T) {
// Node1
// Node1
// Image: gcr.io/2000:latest 2000MB
// Image: gcr.io/2000:latest 2000MB
// Score:
2000 > max score = 10
// Score:
10 (2000M/2 >= 1000M, max-threshold)
// Node2
// Node2
// Image: gcr.io/10:latest 10MB
// Image: gcr.io/10:latest 10MB
// Score:
10 < min score = 0
// Score:
0 (10M/2 < 23M, min-threshold)
pod
:
&
v1
.
Pod
{
Spec
:
testMinMax
},
pod
:
&
v1
.
Pod
{
Spec
:
testMinMax
},
nodes
:
[]
*
v1
.
Node
{
makeImageNode
(
"machine1"
,
node40
14
02000
),
makeImageNode
(
"machine2"
,
node25010
)},
nodes
:
[]
*
v1
.
Node
{
makeImageNode
(
"machine1"
,
node40
30
02000
),
makeImageNode
(
"machine2"
,
node25010
)},
expectedList
:
[]
schedulerapi
.
HostPriority
{{
Host
:
"machine1"
,
Score
:
schedulerapi
.
MaxPriority
},
{
Host
:
"machine2"
,
Score
:
0
}},
expectedList
:
[]
schedulerapi
.
HostPriority
{{
Host
:
"machine1"
,
Score
:
schedulerapi
.
MaxPriority
},
{
Host
:
"machine2"
,
Score
:
0
}},
name
:
"if exceed limit, use limit"
,
name
:
"if exceed limit, use limit"
,
},
},
...
@@ -165,7 +165,7 @@ func TestImageLocalityPriority(t *testing.T) {
...
@@ -165,7 +165,7 @@ func TestImageLocalityPriority(t *testing.T) {
for
_
,
test
:=
range
tests
{
for
_
,
test
:=
range
tests
{
t
.
Run
(
test
.
name
,
func
(
t
*
testing
.
T
)
{
t
.
Run
(
test
.
name
,
func
(
t
*
testing
.
T
)
{
nodeNameToInfo
:=
schedulercache
.
CreateNodeNameToInfoMap
(
test
.
pods
,
test
.
nodes
)
nodeNameToInfo
:=
schedulercache
.
CreateNodeNameToInfoMap
(
test
.
pods
,
test
.
nodes
)
list
,
err
:=
priorityFunction
(
ImageLocalityPriorityMap
,
nil
,
nil
)(
test
.
pod
,
nodeNameToInfo
,
test
.
nodes
)
list
,
err
:=
priorityFunction
(
ImageLocalityPriorityMap
,
nil
,
&
priorityMetadata
{
totalNumNodes
:
len
(
test
.
nodes
)}
)(
test
.
pod
,
nodeNameToInfo
,
test
.
nodes
)
if
err
!=
nil
{
if
err
!=
nil
{
t
.
Errorf
(
"unexpected error: %v"
,
err
)
t
.
Errorf
(
"unexpected error: %v"
,
err
)
}
}
...
...
pkg/scheduler/algorithm/priorities/metadata.go
View file @
2003a0db
...
@@ -52,6 +52,7 @@ type priorityMetadata struct {
...
@@ -52,6 +52,7 @@ type priorityMetadata struct {
podSelectors
[]
labels
.
Selector
podSelectors
[]
labels
.
Selector
controllerRef
*
metav1
.
OwnerReference
controllerRef
*
metav1
.
OwnerReference
podFirstServiceSelector
labels
.
Selector
podFirstServiceSelector
labels
.
Selector
totalNumNodes
int
}
}
// PriorityMetadata is a PriorityMetadataProducer. Node info can be nil.
// PriorityMetadata is a PriorityMetadataProducer. Node info can be nil.
...
@@ -67,6 +68,7 @@ func (pmf *PriorityMetadataFactory) PriorityMetadata(pod *v1.Pod, nodeNameToInfo
...
@@ -67,6 +68,7 @@ func (pmf *PriorityMetadataFactory) PriorityMetadata(pod *v1.Pod, nodeNameToInfo
podSelectors
:
getSelectors
(
pod
,
pmf
.
serviceLister
,
pmf
.
controllerLister
,
pmf
.
replicaSetLister
,
pmf
.
statefulSetLister
),
podSelectors
:
getSelectors
(
pod
,
pmf
.
serviceLister
,
pmf
.
controllerLister
,
pmf
.
replicaSetLister
,
pmf
.
statefulSetLister
),
controllerRef
:
priorityutil
.
GetControllerRef
(
pod
),
controllerRef
:
priorityutil
.
GetControllerRef
(
pod
),
podFirstServiceSelector
:
getFirstServiceSelector
(
pod
,
pmf
.
serviceLister
),
podFirstServiceSelector
:
getFirstServiceSelector
(
pod
,
pmf
.
serviceLister
),
totalNumNodes
:
len
(
nodeNameToInfo
),
}
}
}
}
...
...
pkg/scheduler/cache/util.go
View file @
2003a0db
...
@@ -16,7 +16,10 @@ limitations under the License.
...
@@ -16,7 +16,10 @@ limitations under the License.
package
cache
package
cache
import
"k8s.io/api/core/v1"
import
(
"k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/sets"
)
// CreateNodeNameToInfoMap obtains a list of pods and pivots that list into a map where the keys are node names
// CreateNodeNameToInfoMap obtains a list of pods and pivots that list into a map where the keys are node names
// and the values are the aggregated information for that node.
// and the values are the aggregated information for that node.
...
@@ -29,11 +32,47 @@ func CreateNodeNameToInfoMap(pods []*v1.Pod, nodes []*v1.Node) map[string]*NodeI
...
@@ -29,11 +32,47 @@ func CreateNodeNameToInfoMap(pods []*v1.Pod, nodes []*v1.Node) map[string]*NodeI
}
}
nodeNameToInfo
[
nodeName
]
.
AddPod
(
pod
)
nodeNameToInfo
[
nodeName
]
.
AddPod
(
pod
)
}
}
imageExistenceMap
:=
createImageExistenceMap
(
nodes
)
for
_
,
node
:=
range
nodes
{
for
_
,
node
:=
range
nodes
{
if
_
,
ok
:=
nodeNameToInfo
[
node
.
Name
];
!
ok
{
if
_
,
ok
:=
nodeNameToInfo
[
node
.
Name
];
!
ok
{
nodeNameToInfo
[
node
.
Name
]
=
NewNodeInfo
()
nodeNameToInfo
[
node
.
Name
]
=
NewNodeInfo
()
}
}
nodeNameToInfo
[
node
.
Name
]
.
SetNode
(
node
)
nodeInfo
:=
nodeNameToInfo
[
node
.
Name
]
nodeInfo
.
SetNode
(
node
)
nodeInfo
.
imageStates
=
getNodeImageStates
(
node
,
imageExistenceMap
)
}
}
return
nodeNameToInfo
return
nodeNameToInfo
}
}
// getNodeImageStates returns the given node's image states based on the given imageExistence map.
func
getNodeImageStates
(
node
*
v1
.
Node
,
imageExistenceMap
map
[
string
]
sets
.
String
)
map
[
string
]
*
ImageStateSummary
{
imageStates
:=
make
(
map
[
string
]
*
ImageStateSummary
)
for
_
,
image
:=
range
node
.
Status
.
Images
{
for
_
,
name
:=
range
image
.
Names
{
imageStates
[
name
]
=
&
ImageStateSummary
{
Size
:
image
.
SizeBytes
,
NumNodes
:
len
(
imageExistenceMap
[
name
]),
}
}
}
return
imageStates
}
// createImageExistenceMap returns a map recording on which nodes the images exist, keyed by the images' names.
func
createImageExistenceMap
(
nodes
[]
*
v1
.
Node
)
map
[
string
]
sets
.
String
{
imageExistenceMap
:=
make
(
map
[
string
]
sets
.
String
)
for
_
,
node
:=
range
nodes
{
for
_
,
image
:=
range
node
.
Status
.
Images
{
for
_
,
name
:=
range
image
.
Names
{
if
_
,
ok
:=
imageExistenceMap
[
name
];
!
ok
{
imageExistenceMap
[
name
]
=
sets
.
NewString
(
node
.
Name
)
}
else
{
imageExistenceMap
[
name
]
.
Insert
(
node
.
Name
)
}
}
}
}
return
imageExistenceMap
}
pkg/scheduler/cache/util_test.go
0 → 100644
View file @
2003a0db
/*
Copyright 2018 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package
cache
import
(
"reflect"
"testing"
"k8s.io/api/core/v1"
metav1
"k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets"
)
const
mb
int64
=
1024
*
1024
func
TestGetNodeImageStates
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
node
*
v1
.
Node
imageExistenceMap
map
[
string
]
sets
.
String
expected
map
[
string
]
*
ImageStateSummary
}{
{
node
:
&
v1
.
Node
{
ObjectMeta
:
metav1
.
ObjectMeta
{
Name
:
"node-0"
},
Status
:
v1
.
NodeStatus
{
Images
:
[]
v1
.
ContainerImage
{
{
Names
:
[]
string
{
"gcr.io/10:v1"
,
},
SizeBytes
:
int64
(
10
*
mb
),
},
{
Names
:
[]
string
{
"gcr.io/200:v1"
,
},
SizeBytes
:
int64
(
200
*
mb
),
},
},
},
},
imageExistenceMap
:
map
[
string
]
sets
.
String
{
"gcr.io/10:v1"
:
sets
.
NewString
(
"node-0"
,
"node-1"
),
"gcr.io/200:v1"
:
sets
.
NewString
(
"node-0"
),
},
expected
:
map
[
string
]
*
ImageStateSummary
{
"gcr.io/10:v1"
:
{
Size
:
int64
(
10
*
mb
),
NumNodes
:
2
,
},
"gcr.io/200:v1"
:
{
Size
:
int64
(
200
*
mb
),
NumNodes
:
1
,
},
},
},
}
for
_
,
test
:=
range
tests
{
imageStates
:=
getNodeImageStates
(
test
.
node
,
test
.
imageExistenceMap
)
if
!
reflect
.
DeepEqual
(
test
.
expected
,
imageStates
)
{
t
.
Errorf
(
"expected: %#v, got: %#v"
,
test
.
expected
,
imageStates
)
}
}
}
func
TestCreateImageExistenceMap
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
nodes
[]
*
v1
.
Node
expected
map
[
string
]
sets
.
String
}{
{
nodes
:
[]
*
v1
.
Node
{
{
ObjectMeta
:
metav1
.
ObjectMeta
{
Name
:
"node-0"
},
Status
:
v1
.
NodeStatus
{
Images
:
[]
v1
.
ContainerImage
{
{
Names
:
[]
string
{
"gcr.io/10:v1"
,
},
SizeBytes
:
int64
(
10
*
mb
),
},
},
},
},
{
ObjectMeta
:
metav1
.
ObjectMeta
{
Name
:
"node-1"
},
Status
:
v1
.
NodeStatus
{
Images
:
[]
v1
.
ContainerImage
{
{
Names
:
[]
string
{
"gcr.io/10:v1"
,
},
SizeBytes
:
int64
(
10
*
mb
),
},
{
Names
:
[]
string
{
"gcr.io/200:v1"
,
},
SizeBytes
:
int64
(
200
*
mb
),
},
},
},
},
},
expected
:
map
[
string
]
sets
.
String
{
"gcr.io/10:v1"
:
sets
.
NewString
(
"node-0"
,
"node-1"
),
"gcr.io/200:v1"
:
sets
.
NewString
(
"node-1"
),
},
},
}
for
_
,
test
:=
range
tests
{
imageMap
:=
createImageExistenceMap
(
test
.
nodes
)
if
!
reflect
.
DeepEqual
(
test
.
expected
,
imageMap
)
{
t
.
Errorf
(
"expected: %#v, got: %#v"
,
test
.
expected
,
imageMap
)
}
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment