Add NEG Syncer, SyncerManager and interfaces
This commit is contained in:
parent
25bb9341e1
commit
b4cbc60fb7
6 changed files with 1666 additions and 0 deletions
194
controllers/gce/networkendpointgroup/fakes.go
Normal file
194
controllers/gce/networkendpointgroup/fakes.go
Normal file
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
Copyright 2017 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 networkendpointgroup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
computealpha "google.golang.org/api/compute/v0.alpha"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
TestZone1 = "zone1"
|
||||
TestZone2 = "zone2"
|
||||
TestInstance1 = "instance1"
|
||||
TestInstance2 = "instance2"
|
||||
TestInstance3 = "instance3"
|
||||
TestInstance4 = "instance4"
|
||||
)
|
||||
|
||||
type fakeZoneGetter struct {
|
||||
zoneInstanceMap map[string]sets.String
|
||||
}
|
||||
|
||||
func NewFakeZoneGetter() *fakeZoneGetter {
|
||||
return &fakeZoneGetter{
|
||||
zoneInstanceMap: map[string]sets.String{
|
||||
TestZone1: sets.NewString(TestInstance1, TestInstance2),
|
||||
TestZone2: sets.NewString(TestInstance3, TestInstance4),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeZoneGetter) ListZones() ([]string, error) {
|
||||
ret := []string{}
|
||||
for key := range f.zoneInstanceMap {
|
||||
ret = append(ret, key)
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
func (f *fakeZoneGetter) GetZoneForNode(name string) (string, error) {
|
||||
for zone, instances := range f.zoneInstanceMap {
|
||||
if instances.Has(name) {
|
||||
return zone, nil
|
||||
}
|
||||
}
|
||||
return "", NotFoundError
|
||||
}
|
||||
|
||||
type FakeNetworkEndpointGroupCloud struct {
|
||||
NetworkEndpointGroups map[string][]*computealpha.NetworkEndpointGroup
|
||||
NetworkEndpoints map[string][]*computealpha.NetworkEndpoint
|
||||
Subnetwork string
|
||||
Network string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFakeNetworkEndpointGroupCloud(subnetwork, network string) NetworkEndpointGroupCloud {
|
||||
return &FakeNetworkEndpointGroupCloud{
|
||||
Subnetwork: subnetwork,
|
||||
Network: network,
|
||||
NetworkEndpointGroups: map[string][]*computealpha.NetworkEndpointGroup{},
|
||||
NetworkEndpoints: map[string][]*computealpha.NetworkEndpoint{},
|
||||
}
|
||||
}
|
||||
|
||||
var NotFoundError = fmt.Errorf("Not Found")
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) GetNetworkEndpointGroup(name string, zone string) (*computealpha.NetworkEndpointGroup, error) {
|
||||
cloud.mu.Lock()
|
||||
defer cloud.mu.Unlock()
|
||||
negs, ok := cloud.NetworkEndpointGroups[zone]
|
||||
if ok {
|
||||
for _, neg := range negs {
|
||||
if neg.Name == name {
|
||||
return neg, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, NotFoundError
|
||||
}
|
||||
|
||||
func networkEndpointKey(name, zone string) string {
|
||||
return fmt.Sprintf("%s-%s", zone, name)
|
||||
}
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) ListNetworkEndpointGroup(zone string) ([]*computealpha.NetworkEndpointGroup, error) {
|
||||
cloud.mu.Lock()
|
||||
defer cloud.mu.Unlock()
|
||||
return cloud.NetworkEndpointGroups[zone], nil
|
||||
}
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) AggregatedListNetworkEndpointGroup() (map[string][]*computealpha.NetworkEndpointGroup, error) {
|
||||
cloud.mu.Lock()
|
||||
defer cloud.mu.Unlock()
|
||||
return cloud.NetworkEndpointGroups, nil
|
||||
}
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) CreateNetworkEndpointGroup(neg *computealpha.NetworkEndpointGroup, zone string) error {
|
||||
cloud.mu.Lock()
|
||||
defer cloud.mu.Unlock()
|
||||
if _, ok := cloud.NetworkEndpointGroups[zone]; !ok {
|
||||
cloud.NetworkEndpointGroups[zone] = []*computealpha.NetworkEndpointGroup{}
|
||||
}
|
||||
cloud.NetworkEndpointGroups[zone] = append(cloud.NetworkEndpointGroups[zone], neg)
|
||||
cloud.NetworkEndpoints[networkEndpointKey(neg.Name, zone)] = []*computealpha.NetworkEndpoint{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) DeleteNetworkEndpointGroup(name string, zone string) error {
|
||||
cloud.mu.Lock()
|
||||
defer cloud.mu.Unlock()
|
||||
delete(cloud.NetworkEndpoints, networkEndpointKey(name, zone))
|
||||
negs := cloud.NetworkEndpointGroups[zone]
|
||||
newList := []*computealpha.NetworkEndpointGroup{}
|
||||
found := false
|
||||
for _, neg := range negs {
|
||||
if neg.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newList = append(newList, neg)
|
||||
}
|
||||
if !found {
|
||||
return NotFoundError
|
||||
}
|
||||
cloud.NetworkEndpointGroups[zone] = newList
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) AttachNetworkEndpoints(name, zone string, endpoints []*computealpha.NetworkEndpoint) error {
|
||||
cloud.mu.Lock()
|
||||
defer cloud.mu.Unlock()
|
||||
cloud.NetworkEndpoints[networkEndpointKey(name, zone)] = append(cloud.NetworkEndpoints[networkEndpointKey(name, zone)], endpoints...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) DetachNetworkEndpoints(name, zone string, endpoints []*computealpha.NetworkEndpoint) error {
|
||||
cloud.mu.Lock()
|
||||
defer cloud.mu.Unlock()
|
||||
newList := []*computealpha.NetworkEndpoint{}
|
||||
for _, ne := range cloud.NetworkEndpoints[networkEndpointKey(name, zone)] {
|
||||
found := false
|
||||
for _, remove := range endpoints {
|
||||
if reflect.DeepEqual(*ne, *remove) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
newList = append(newList, ne)
|
||||
}
|
||||
cloud.NetworkEndpoints[networkEndpointKey(name, zone)] = newList
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) ListNetworkEndpoints(name, zone string, showHealthStatus bool) ([]*computealpha.NetworkEndpointWithHealthStatus, error) {
|
||||
cloud.mu.Lock()
|
||||
defer cloud.mu.Unlock()
|
||||
ret := []*computealpha.NetworkEndpointWithHealthStatus{}
|
||||
nes, ok := cloud.NetworkEndpoints[networkEndpointKey(name, zone)]
|
||||
if !ok {
|
||||
return nil, NotFoundError
|
||||
}
|
||||
for _, ne := range nes {
|
||||
ret = append(ret, &computealpha.NetworkEndpointWithHealthStatus{NetworkEndpoint: ne})
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) NetworkURL() string {
|
||||
return cloud.Network
|
||||
}
|
||||
|
||||
func (cloud *FakeNetworkEndpointGroupCloud) SubnetworkURL() string {
|
||||
return cloud.Subnetwork
|
||||
}
|
66
controllers/gce/networkendpointgroup/interfaces.go
Normal file
66
controllers/gce/networkendpointgroup/interfaces.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
Copyright 2017 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 networkendpointgroup
|
||||
|
||||
import (
|
||||
computealpha "google.golang.org/api/compute/v0.alpha"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
)
|
||||
|
||||
// NetworkEndpointGroupCloud is an interface for managing gce network endpoint group.
|
||||
type NetworkEndpointGroupCloud interface {
|
||||
GetNetworkEndpointGroup(name string, zone string) (*computealpha.NetworkEndpointGroup, error)
|
||||
ListNetworkEndpointGroup(zone string) ([]*computealpha.NetworkEndpointGroup, error)
|
||||
AggregatedListNetworkEndpointGroup() (map[string][]*computealpha.NetworkEndpointGroup, error)
|
||||
CreateNetworkEndpointGroup(neg *computealpha.NetworkEndpointGroup, zone string) error
|
||||
DeleteNetworkEndpointGroup(name string, zone string) error
|
||||
AttachNetworkEndpoints(name, zone string, endpoints []*computealpha.NetworkEndpoint) error
|
||||
DetachNetworkEndpoints(name, zone string, endpoints []*computealpha.NetworkEndpoint) error
|
||||
ListNetworkEndpoints(name, zone string, showHealthStatus bool) ([]*computealpha.NetworkEndpointWithHealthStatus, error)
|
||||
NetworkURL() string
|
||||
SubnetworkURL() string
|
||||
}
|
||||
|
||||
// NetworkEndpointGroupNamer is an interface for generating network endpoint group name.
|
||||
type NetworkEndpointGroupNamer interface {
|
||||
NEGName(namespace, name, port string) string
|
||||
NEGPrefix() string
|
||||
}
|
||||
|
||||
// ZoneGetter is an interface for retrieve zone related information
|
||||
type ZoneGetter interface {
|
||||
ListZones() ([]string, error)
|
||||
GetZoneForNode(name string) (string, error)
|
||||
}
|
||||
|
||||
// Syncer is an interface to interact with syncer
|
||||
type Syncer interface {
|
||||
Start() error
|
||||
Stop()
|
||||
Sync() bool
|
||||
IsStopped() bool
|
||||
IsShuttingDown() bool
|
||||
}
|
||||
|
||||
// SyncerManager is an interface for controllers to manage Syncers
|
||||
type SyncerManager interface {
|
||||
EnsureSyncer(namespace, name string, targetPorts sets.String) error
|
||||
StopSyncer(namespace, name string)
|
||||
Sync(namespace, name string)
|
||||
GC() error
|
||||
ShutDown()
|
||||
}
|
256
controllers/gce/networkendpointgroup/manager.go
Normal file
256
controllers/gce/networkendpointgroup/manager.go
Normal file
|
@ -0,0 +1,256 @@
|
|||
/*
|
||||
Copyright 2017 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 networkendpointgroup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/golang/glog"
|
||||
utilerrors "k8s.io/apimachinery/pkg/util/errors"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
"k8s.io/client-go/tools/record"
|
||||
)
|
||||
|
||||
// syncerManager exposes a few interfaces to manage syncer and ensures thread safety.
|
||||
type syncerManager struct {
|
||||
namer NetworkEndpointGroupNamer
|
||||
recorder record.EventRecorder
|
||||
cloud NetworkEndpointGroupCloud
|
||||
zoneGetter ZoneGetter
|
||||
|
||||
serviceLister cache.Indexer
|
||||
endpointLister cache.Indexer
|
||||
|
||||
// TODO: lock per service instead of global lock
|
||||
mu sync.Mutex
|
||||
// svcPortMap is the canonical indicator for whether a service needs NEG
|
||||
// key is service namespace/name, value is the list of target port that requires NEG
|
||||
svcPortMap map[string]sets.String
|
||||
// syncerMap stores the NEG syncer
|
||||
// key is service namespace/name/targetPort. Value is the corresponding syncer
|
||||
syncerMap map[string]Syncer
|
||||
}
|
||||
|
||||
func newSyncerManager(namer NetworkEndpointGroupNamer, recorder record.EventRecorder, cloud NetworkEndpointGroupCloud, zoneGetter ZoneGetter, serviceLister cache.Indexer, endpointLister cache.Indexer) *syncerManager {
|
||||
return &syncerManager{
|
||||
namer: namer,
|
||||
recorder: recorder,
|
||||
cloud: cloud,
|
||||
zoneGetter: zoneGetter,
|
||||
serviceLister: serviceLister,
|
||||
endpointLister: endpointLister,
|
||||
svcPortMap: make(map[string]sets.String),
|
||||
syncerMap: make(map[string]Syncer),
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureSyncer starts and stops syncers based on the input service ports.
|
||||
func (manager *syncerManager) EnsureSyncer(namespace, name string, targetPorts sets.String) error {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
key := serviceKeyFunc(namespace, name)
|
||||
currentPorts, ok := manager.svcPortMap[key]
|
||||
if !ok {
|
||||
currentPorts = sets.NewString()
|
||||
}
|
||||
|
||||
removes := currentPorts.Difference(targetPorts).List()
|
||||
adds := targetPorts.Difference(currentPorts).List()
|
||||
manager.svcPortMap[key] = targetPorts
|
||||
|
||||
// Stop syncer for removed ports
|
||||
for _, port := range removes {
|
||||
syncer, ok := manager.syncerMap[encodeSyncerKey(namespace, name, port)]
|
||||
if ok {
|
||||
syncer.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
errList := []error{}
|
||||
// Start syncer for added ports
|
||||
for _, port := range adds {
|
||||
syncer, ok := manager.syncerMap[encodeSyncerKey(namespace, name, port)]
|
||||
if !ok {
|
||||
syncer = newSyncer(
|
||||
servicePort{
|
||||
namespace: namespace,
|
||||
name: name,
|
||||
targetPort: port,
|
||||
},
|
||||
manager.namer.NEGName(namespace, name, port),
|
||||
manager.recorder,
|
||||
manager.cloud,
|
||||
manager.zoneGetter,
|
||||
manager.serviceLister,
|
||||
manager.endpointLister,
|
||||
)
|
||||
manager.syncerMap[encodeSyncerKey(namespace, name, port)] = syncer
|
||||
}
|
||||
|
||||
if syncer.IsStopped() {
|
||||
if err := syncer.Start(); err != nil {
|
||||
errList = append(errList, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return utilerrors.NewAggregate(errList)
|
||||
}
|
||||
|
||||
// StopSyncer stops all syncers for the input service.
|
||||
func (manager *syncerManager) StopSyncer(namespace, name string) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
key := serviceKeyFunc(namespace, name)
|
||||
if ports, ok := manager.svcPortMap[key]; ok {
|
||||
glog.V(2).Infof("Stopping NEG syncer for service %q", key)
|
||||
for _, port := range ports.List() {
|
||||
syncer, ok := manager.syncerMap[encodeSyncerKey(namespace, name, port)]
|
||||
if ok {
|
||||
syncer.Stop()
|
||||
}
|
||||
}
|
||||
delete(manager.svcPortMap, key)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Sync signals all syncers related to the service to sync.
|
||||
func (manager *syncerManager) Sync(namespace, name string) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
key := serviceKeyFunc(namespace, name)
|
||||
if portList, ok := manager.svcPortMap[key]; ok {
|
||||
for _, port := range portList.List() {
|
||||
if syncer, ok := manager.syncerMap[encodeSyncerKey(namespace, name, port)]; ok {
|
||||
if !syncer.IsStopped() {
|
||||
syncer.Sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ShutDown signals all syncers to stop
|
||||
func (manager *syncerManager) ShutDown() {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
for _, s := range manager.syncerMap {
|
||||
s.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// GC garbage collects syncers and NEGs.
|
||||
func (manager *syncerManager) GC() error {
|
||||
glog.V(2).Infof("Start NEG garbage collection.")
|
||||
defer glog.V(2).Infof("NEG garbage collection finished.")
|
||||
// Garbage collect syncer
|
||||
for _, key := range manager.getAllStoppedSyncerKeys().List() {
|
||||
manager.garbageCollectSyncer(key)
|
||||
}
|
||||
|
||||
// Garbage collect NEGs
|
||||
if err := manager.garbageCollectNEG(); err != nil {
|
||||
return fmt.Errorf("Failed to garbage collect negs: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *syncerManager) garbageCollectSyncer(key string) {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
if manager.syncerMap[key].IsStopped() && !manager.syncerMap[key].IsShuttingDown() {
|
||||
delete(manager.syncerMap, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *syncerManager) getAllStoppedSyncerKeys() sets.String {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
ret := sets.NewString()
|
||||
for key, syncer := range manager.syncerMap {
|
||||
if syncer.IsStopped() {
|
||||
ret.Insert(key)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func (manager *syncerManager) garbageCollectNEG() error {
|
||||
// Retrieve aggregated NEG list from cloud
|
||||
// Compare against svcPortMap and Remove unintended NEGs by best effort
|
||||
zoneNEGList, err := manager.cloud.AggregatedListNetworkEndpointGroup()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve aggregated NEG list: %v", err)
|
||||
}
|
||||
|
||||
negNames := sets.String{}
|
||||
for _, list := range zoneNEGList {
|
||||
for _, neg := range list {
|
||||
if strings.HasPrefix(neg.Name, manager.namer.NEGPrefix()) {
|
||||
negNames.Insert(neg.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func() {
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
for key, ports := range manager.svcPortMap {
|
||||
namespace, name, err := cache.SplitMetaNamespaceKey(key)
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to parse service key %q: %v", key, err)
|
||||
continue
|
||||
}
|
||||
for _, port := range ports.List() {
|
||||
name := manager.namer.NEGName(namespace, name, port)
|
||||
negNames.Delete(name)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// This section includes a potential race condition between deleting neg here and users adds the neg annotation.
|
||||
// The worst outcome of the race condition is that neg is deleted in the end but user actually specifies a neg.
|
||||
// This would be resolved (sync neg) when the next endpoint update or resync arrives.
|
||||
// TODO: avoid race condition here
|
||||
for zone := range zoneNEGList {
|
||||
for _, name := range negNames.List() {
|
||||
if err := manager.ensureDeleteNetworkEndpointGroup(name, zone); err != nil {
|
||||
return fmt.Errorf("failed to delete NEG %q in %q: %v", name, zone, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDeleteNetworkEndpointGroup ensures neg is delete from zone
|
||||
func (manager *syncerManager) ensureDeleteNetworkEndpointGroup(name, zone string) error {
|
||||
_, err := manager.cloud.GetNetworkEndpointGroup(name, zone)
|
||||
if err != nil {
|
||||
// Assume error is caused by not existing
|
||||
return nil
|
||||
}
|
||||
glog.V(2).Infof("Deleting NEG %q in %q.", name, zone)
|
||||
return manager.cloud.DeleteNetworkEndpointGroup(name, zone)
|
||||
}
|
||||
|
||||
// encodeSyncerKey encodes a service namespace, name and targetPort into a string key
|
||||
func encodeSyncerKey(namespace, name, port string) string {
|
||||
return fmt.Sprintf("%s||%s||%s", namespace, name, port)
|
||||
}
|
187
controllers/gce/networkendpointgroup/manager_test.go
Normal file
187
controllers/gce/networkendpointgroup/manager_test.go
Normal file
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
Copyright 2017 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 networkendpointgroup
|
||||
|
||||
import (
|
||||
compute "google.golang.org/api/compute/v0.alpha"
|
||||
apiv1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"k8s.io/ingress/controllers/gce/utils"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CluseterID = "clusterid"
|
||||
)
|
||||
|
||||
func NewTestSyncerManager(kubeClient kubernetes.Interface) *syncerManager {
|
||||
context := utils.NewControllerContext(kubeClient, apiv1.NamespaceAll, 1*time.Second, true)
|
||||
manager := newSyncerManager(
|
||||
utils.NewNamer(CluseterID, ""),
|
||||
record.NewFakeRecorder(100),
|
||||
NewFakeNetworkEndpointGroupCloud("test-subnetwork", "test-network"),
|
||||
NewFakeZoneGetter(),
|
||||
context.ServiceInformer.GetIndexer(),
|
||||
context.EndpointInformer.GetIndexer(),
|
||||
)
|
||||
return manager
|
||||
}
|
||||
|
||||
func TestEnsureAndStopSyncer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
namespace string
|
||||
name string
|
||||
ports sets.String
|
||||
stop bool
|
||||
expect sets.String // keys of running syncers
|
||||
}{
|
||||
{
|
||||
"ns1",
|
||||
"n1",
|
||||
sets.NewString("80", "443"),
|
||||
false,
|
||||
sets.NewString(
|
||||
encodeSyncerKey("ns1", "n1", "80"),
|
||||
encodeSyncerKey("ns1", "n1", "443"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"ns1",
|
||||
"n1",
|
||||
sets.NewString("80", "namedport"),
|
||||
false,
|
||||
sets.NewString(
|
||||
encodeSyncerKey("ns1", "n1", "80"),
|
||||
encodeSyncerKey("ns1", "n1", "namedport"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"ns2",
|
||||
"n1",
|
||||
sets.NewString("80"),
|
||||
false,
|
||||
sets.NewString(
|
||||
encodeSyncerKey("ns1", "n1", "80"),
|
||||
encodeSyncerKey("ns1", "n1", "namedport"),
|
||||
encodeSyncerKey("ns2", "n1", "80"),
|
||||
),
|
||||
},
|
||||
{
|
||||
"ns1",
|
||||
"n1",
|
||||
sets.NewString(),
|
||||
true,
|
||||
sets.NewString(
|
||||
encodeSyncerKey("ns2", "n1", "80"),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
manager := NewTestSyncerManager(fake.NewSimpleClientset())
|
||||
for _, tc := range testCases {
|
||||
if tc.stop {
|
||||
manager.StopSyncer(tc.namespace, tc.name)
|
||||
} else {
|
||||
if err := manager.EnsureSyncer(tc.namespace, tc.name, tc.ports); err != nil {
|
||||
t.Errorf("Failed to ensure syncer %s/%s-%v: %v", tc.namespace, tc.name, tc.ports, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range tc.expect.List() {
|
||||
syncer, ok := manager.syncerMap[key]
|
||||
if !ok {
|
||||
t.Errorf("Expect syncer key %q to be present.", key)
|
||||
continue
|
||||
}
|
||||
if syncer.IsStopped() || syncer.IsShuttingDown() {
|
||||
t.Errorf("Expect syncer %q to be running.", key)
|
||||
}
|
||||
}
|
||||
for key, syncer := range manager.syncerMap {
|
||||
if tc.expect.Has(key) {
|
||||
continue
|
||||
}
|
||||
if !syncer.IsStopped() {
|
||||
t.Errorf("Expect syncer %q to be stopped.", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// make sure there is no leaking go routine
|
||||
manager.StopSyncer("ns1", "n1")
|
||||
manager.StopSyncer("ns2", "n1")
|
||||
}
|
||||
|
||||
func TestGarbageCollectionSyncer(t *testing.T) {
|
||||
manager := NewTestSyncerManager(fake.NewSimpleClientset())
|
||||
if err := manager.EnsureSyncer("ns1", "n1", sets.NewString("80", "namedport")); err != nil {
|
||||
t.Fatalf("Failed to ensure syncer: %v", err)
|
||||
}
|
||||
manager.StopSyncer("ns1", "n1")
|
||||
|
||||
syncer1 := manager.syncerMap[encodeSyncerKey("ns1", "n1", "80")]
|
||||
syncer2 := manager.syncerMap[encodeSyncerKey("ns1", "n1", "namedport")]
|
||||
|
||||
if err := wait.PollImmediate(time.Second, 30*time.Second, func() (bool, error) {
|
||||
return !syncer1.IsShuttingDown() && syncer1.IsStopped() && !syncer2.IsShuttingDown() && syncer2.IsStopped(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Syncer failed to shutdown: %v", err)
|
||||
}
|
||||
|
||||
if err := manager.GC(); err != nil {
|
||||
t.Fatalf("Failed to GC: %v", err)
|
||||
}
|
||||
|
||||
if len(manager.syncerMap) != 0 {
|
||||
t.Fatalf("Expect 0 syncers left, but got %v", len(manager.syncerMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageCollectionNEG(t *testing.T) {
|
||||
kubeClient := fake.NewSimpleClientset()
|
||||
if _, err := kubeClient.Core().Endpoints(ServiceNamespace).Create(getDefaultEndpoint()); err != nil {
|
||||
t.Fatalf("Failed to create endpoint: %v", err)
|
||||
}
|
||||
manager := NewTestSyncerManager(kubeClient)
|
||||
if err := manager.EnsureSyncer(ServiceNamespace, ServiceName, sets.NewString("80")); err != nil {
|
||||
t.Fatalf("Failed to ensure syncer: %v", err)
|
||||
}
|
||||
|
||||
negName := manager.namer.NEGName("test", "test", "80")
|
||||
manager.cloud.CreateNetworkEndpointGroup(&compute.NetworkEndpointGroup{
|
||||
Name: negName,
|
||||
}, TestZone1)
|
||||
|
||||
if err := manager.GC(); err != nil {
|
||||
t.Fatalf("Failed to GC: %v", err)
|
||||
}
|
||||
|
||||
negs, _ := manager.cloud.ListNetworkEndpointGroup(TestZone1)
|
||||
for _, neg := range negs {
|
||||
if neg.Name == negName {
|
||||
t.Errorf("Expect NEG %q to be GCed.", negName)
|
||||
}
|
||||
}
|
||||
|
||||
// make sure there is no leaking go routine
|
||||
manager.StopSyncer(ServiceNamespace, ServiceName)
|
||||
}
|
524
controllers/gce/networkendpointgroup/syncer.go
Normal file
524
controllers/gce/networkendpointgroup/syncer.go
Normal file
|
@ -0,0 +1,524 @@
|
|||
/*
|
||||
Copyright 2017 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 networkendpointgroup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang/glog"
|
||||
compute "google.golang.org/api/compute/v0.alpha"
|
||||
apiv1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/clock"
|
||||
utilerrors "k8s.io/apimachinery/pkg/util/errors"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"k8s.io/kubernetes/pkg/cloudprovider/providers/gce"
|
||||
)
|
||||
|
||||
const (
|
||||
MAX_NETWORK_ENDPOINTS_PER_BATCH = 500
|
||||
minRetryDelay = 5 * time.Second
|
||||
maxRetryDelay = 300 * time.Second
|
||||
)
|
||||
|
||||
// servicePort includes information to uniquely identify a NEG
|
||||
type servicePort struct {
|
||||
namespace string
|
||||
name string
|
||||
// Serivice target port
|
||||
targetPort string
|
||||
}
|
||||
|
||||
// syncer handles synchorizing NEGs for one service port. It handles sync, resync and retry on error.
|
||||
type syncer struct {
|
||||
servicePort
|
||||
negName string
|
||||
|
||||
serviceLister cache.Indexer
|
||||
endpointLister cache.Indexer
|
||||
|
||||
recorder record.EventRecorder
|
||||
cloud NetworkEndpointGroupCloud
|
||||
zoneGetter ZoneGetter
|
||||
|
||||
stateLock sync.Mutex
|
||||
stopped bool
|
||||
shuttingDown bool
|
||||
|
||||
clock clock.Clock
|
||||
syncCh chan interface{}
|
||||
lastRetryDelay time.Duration
|
||||
retryCount int
|
||||
}
|
||||
|
||||
func newSyncer(svcPort servicePort, networkEndpointGroupName string, recorder record.EventRecorder, cloud NetworkEndpointGroupCloud, zoneGetter ZoneGetter, serviceLister cache.Indexer, endpointLister cache.Indexer) *syncer {
|
||||
glog.V(2).Infof("New syncer for service %s/%s port %s NEG %q", svcPort.namespace, svcPort.name, svcPort.targetPort, networkEndpointGroupName)
|
||||
return &syncer{
|
||||
servicePort: svcPort,
|
||||
negName: networkEndpointGroupName,
|
||||
recorder: recorder,
|
||||
serviceLister: serviceLister,
|
||||
cloud: cloud,
|
||||
endpointLister: endpointLister,
|
||||
zoneGetter: zoneGetter,
|
||||
stopped: true,
|
||||
shuttingDown: false,
|
||||
clock: clock.RealClock{},
|
||||
lastRetryDelay: time.Duration(0),
|
||||
retryCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *syncer) init() {
|
||||
s.stateLock.Lock()
|
||||
defer s.stateLock.Unlock()
|
||||
s.stopped = false
|
||||
s.syncCh = make(chan interface{}, 1)
|
||||
}
|
||||
|
||||
// Start starts the syncer go routine if it has not been started.
|
||||
func (s *syncer) Start() error {
|
||||
if !s.IsStopped() {
|
||||
return fmt.Errorf("NEG syncer for %s/%s-%s is already running.", s.namespace, s.name, s.targetPort)
|
||||
}
|
||||
if s.IsShuttingDown() {
|
||||
return fmt.Errorf("NEG syncer for %s/%s-%s is shutting down. ", s.namespace, s.name, s.targetPort)
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Starting NEG syncer for service port %s/%s-%s", s.namespace, s.name, s.targetPort)
|
||||
s.init()
|
||||
go func() {
|
||||
for {
|
||||
// equivalent to never retry
|
||||
retryCh := make(<-chan time.Time)
|
||||
err := s.sync()
|
||||
if err != nil {
|
||||
retryMesg := ""
|
||||
if s.retryCount > maxRetries {
|
||||
retryMesg = "(will not retry)"
|
||||
} else {
|
||||
retryCh = s.clock.After(s.nextRetryDelay())
|
||||
retryMesg = "(will retry)"
|
||||
}
|
||||
|
||||
if svc := getService(s.serviceLister, s.namespace, s.name); svc != nil {
|
||||
s.recorder.Eventf(svc, apiv1.EventTypeWarning, "SyncNetworkEndpiontGroupFailed", "Failed to sync NEG %q %s: %v", s.negName, retryMesg, err)
|
||||
}
|
||||
} else {
|
||||
s.resetRetryDelay()
|
||||
}
|
||||
|
||||
select {
|
||||
case _, open := <-s.syncCh:
|
||||
if !open {
|
||||
s.stateLock.Lock()
|
||||
s.shuttingDown = false
|
||||
s.stateLock.Unlock()
|
||||
glog.V(2).Infof("Stopping NEG syncer for %s/%s-%s", s.namespace, s.name, s.targetPort)
|
||||
return
|
||||
}
|
||||
case <-retryCh:
|
||||
// continue to sync
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops syncer and return only when syncer shutdown completes.
|
||||
func (s *syncer) Stop() {
|
||||
s.stateLock.Lock()
|
||||
defer s.stateLock.Unlock()
|
||||
if !s.stopped {
|
||||
s.stopped = true
|
||||
s.shuttingDown = true
|
||||
close(s.syncCh)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync informs syncer to run sync loop as soon as possible.
|
||||
func (s *syncer) Sync() bool {
|
||||
if s.IsStopped() {
|
||||
glog.Warningf("NEG syncer for %s/%s-%s is already stopped.", s.namespace, s.name, s.targetPort)
|
||||
return false
|
||||
}
|
||||
glog.V(2).Infof("=======Sync %s/%s-%s", s.namespace, s.name, s.targetPort)
|
||||
select {
|
||||
case s.syncCh <- struct{}{}:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *syncer) IsStopped() bool {
|
||||
s.stateLock.Lock()
|
||||
defer s.stateLock.Unlock()
|
||||
return s.stopped
|
||||
}
|
||||
|
||||
func (s *syncer) IsShuttingDown() bool {
|
||||
s.stateLock.Lock()
|
||||
defer s.stateLock.Unlock()
|
||||
return s.shuttingDown
|
||||
}
|
||||
|
||||
func (s *syncer) sync() error {
|
||||
if s.IsStopped() || s.IsShuttingDown() {
|
||||
glog.V(4).Infof("Skip syncing NEG %q for %s/%s-%s.", s.negName, s.namespace, s.name, s.targetPort)
|
||||
return nil
|
||||
}
|
||||
|
||||
glog.V(2).Infof("Sync NEG %q for %s/%s-%s", s.negName, s.namespace, s.name, s.targetPort)
|
||||
ep, exists, err := s.endpointLister.Get(
|
||||
&apiv1.Endpoints{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: s.name,
|
||||
Namespace: s.namespace,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
glog.Warningf("Endpoint %s/%s does not exists. Skipping NEG sync")
|
||||
return nil
|
||||
}
|
||||
|
||||
err = s.ensureNetworkEndpointGroups()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetMap, err := s.toZoneNetworkEndpointMap(ep.(*apiv1.Endpoints))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentMap, err := s.retrieveExistingZoneNetworkEndpointMap()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
addEndpoints, removeEndpoints := calculateDifference(targetMap, currentMap)
|
||||
if len(addEndpoints) == 0 && len(removeEndpoints) == 0 {
|
||||
glog.V(4).Infof("No endpoint change for %s/%s, skip syncing NEG. ", s.namespace, s.name)
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.syncNetworkEndpoints(addEndpoints, removeEndpoints)
|
||||
}
|
||||
|
||||
// ensureNetworkEndpointGroups ensures negs are created in the related zones.
|
||||
func (s *syncer) ensureNetworkEndpointGroups() error {
|
||||
var err error
|
||||
zones, err := s.zoneGetter.ListZones()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var errList []error
|
||||
for _, zone := range zones {
|
||||
// Assume error is caused by not existing
|
||||
neg, err := s.cloud.GetNetworkEndpointGroup(s.negName, zone)
|
||||
if err != nil {
|
||||
// Most likely to be caused by non-existed NEG
|
||||
glog.V(4).Infof("Error while retriving %q in zone %q: %v", s.negName, zone, err)
|
||||
}
|
||||
|
||||
needToCreate := false
|
||||
if neg == nil {
|
||||
needToCreate = true
|
||||
} else if retrieveName(neg.LoadBalancer.Network) != retrieveName(s.cloud.NetworkURL()) ||
|
||||
retrieveName(neg.LoadBalancer.Subnetwork) != retrieveName(s.cloud.SubnetworkURL()) {
|
||||
// Only compare network and subnetwork names to avoid api endpoint differences that cause deleting NEG accidentally.
|
||||
// TODO: change to compare network/subnetwork url instead of name when NEG API reach GA.
|
||||
needToCreate = true
|
||||
glog.V(2).Infof("NEG %q in %q does not match network and subnetwork of the cluster. Deleting NEG.", s.negName, zone)
|
||||
err = s.cloud.DeleteNetworkEndpointGroup(s.negName, zone)
|
||||
if err != nil {
|
||||
errList = append(errList, err)
|
||||
} else {
|
||||
if svc := getService(s.serviceLister, s.namespace, s.name); svc != nil {
|
||||
s.recorder.Eventf(svc, apiv1.EventTypeNormal, "Delete", "Deleted NEG %q for %s/%s-%s in %q.", s.negName, s.namespace, s.name, s.targetPort, zone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needToCreate {
|
||||
glog.V(2).Infof("Creating NEG %q for %s/%s in %q.", s.negName, s.namespace, s.name, zone)
|
||||
err = s.cloud.CreateNetworkEndpointGroup(&compute.NetworkEndpointGroup{
|
||||
Name: s.negName,
|
||||
Type: gce.NEGLoadBalancerType,
|
||||
NetworkEndpointType: gce.NEGIPPortNetworkEndpointType,
|
||||
LoadBalancer: &compute.NetworkEndpointGroupLbNetworkEndpointGroup{
|
||||
Network: s.cloud.NetworkURL(),
|
||||
Subnetwork: s.cloud.SubnetworkURL(),
|
||||
},
|
||||
}, zone)
|
||||
if err != nil {
|
||||
errList = append(errList, err)
|
||||
} else {
|
||||
if svc := getService(s.serviceLister, s.namespace, s.name); svc != nil {
|
||||
s.recorder.Eventf(svc, apiv1.EventTypeNormal, "Create", "Created NEG %q for %s/%s-%s in %q.", s.negName, s.namespace, s.name, s.targetPort, zone)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return utilerrors.NewAggregate(errList)
|
||||
}
|
||||
|
||||
// toZoneNetworkEndpointMap translates addresses in endpoints object into zone and endpoints map
|
||||
func (s *syncer) toZoneNetworkEndpointMap(endpoints *apiv1.Endpoints) (map[string]sets.String, error) {
|
||||
zoneNetworkEndpointMap := map[string]sets.String{}
|
||||
targetPort, _ := strconv.Atoi(s.targetPort)
|
||||
for _, subset := range endpoints.Subsets {
|
||||
matchPort := ""
|
||||
// service spec allows target port to be a named port.
|
||||
// support both explicit port and named port.
|
||||
for _, port := range subset.Ports {
|
||||
if targetPort != 0 {
|
||||
// targetPort is int
|
||||
if int(port.Port) == targetPort {
|
||||
matchPort = s.targetPort
|
||||
}
|
||||
} else {
|
||||
// targetPort is string
|
||||
if port.Name == s.targetPort {
|
||||
matchPort = strconv.Itoa(int(port.Port))
|
||||
}
|
||||
}
|
||||
if len(matchPort) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// subset does not contain target port
|
||||
if len(matchPort) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, address := range subset.Addresses {
|
||||
zone, err := s.zoneGetter.GetZoneForNode(*address.NodeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if zoneNetworkEndpointMap[zone] == nil {
|
||||
zoneNetworkEndpointMap[zone] = sets.String{}
|
||||
}
|
||||
zoneNetworkEndpointMap[zone].Insert(encodeEndpoint(address.IP, *address.NodeName, matchPort))
|
||||
}
|
||||
}
|
||||
return zoneNetworkEndpointMap, nil
|
||||
}
|
||||
|
||||
// retrieveExistingZoneNetworkEndpointMap lists existing network endpoints in the neg and return the zone and endpoints map
|
||||
func (s *syncer) retrieveExistingZoneNetworkEndpointMap() (map[string]sets.String, error) {
|
||||
zones, err := s.zoneGetter.ListZones()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zoneNetworkEndpointMap := map[string]sets.String{}
|
||||
for _, zone := range zones {
|
||||
zoneNetworkEndpointMap[zone] = sets.String{}
|
||||
networkEndpointsWithHealthStatus, err := s.cloud.ListNetworkEndpoints(s.negName, zone, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ne := range networkEndpointsWithHealthStatus {
|
||||
zoneNetworkEndpointMap[zone].Insert(encodeEndpoint(ne.NetworkEndpoint.IpAddress, ne.NetworkEndpoint.Instance, strconv.FormatInt(ne.NetworkEndpoint.Port, 10)))
|
||||
}
|
||||
}
|
||||
return zoneNetworkEndpointMap, nil
|
||||
}
|
||||
|
||||
type ErrorList struct {
|
||||
errList []error
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func (e *ErrorList) Add(err error) {
|
||||
e.lock.Lock()
|
||||
defer e.lock.Unlock()
|
||||
e.errList = append(e.errList, err)
|
||||
}
|
||||
|
||||
func (e *ErrorList) List() []error {
|
||||
e.lock.Lock()
|
||||
defer e.lock.Unlock()
|
||||
return e.errList
|
||||
}
|
||||
|
||||
// syncNetworkEndpoints adds and removes endpoints for negs
|
||||
func (s *syncer) syncNetworkEndpoints(addEndpoints, removeEndpoints map[string]sets.String) error {
|
||||
var wg sync.WaitGroup
|
||||
errList := &ErrorList{}
|
||||
|
||||
// Detach Endpoints
|
||||
for zone, endpointSet := range removeEndpoints {
|
||||
for {
|
||||
if endpointSet.Len() == 0 {
|
||||
break
|
||||
}
|
||||
networkEndpoints, err := s.toNetworkEndpointBatch(endpointSet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.detachNetworkEndpoints(&wg, zone, networkEndpoints, errList)
|
||||
}
|
||||
}
|
||||
|
||||
// Attach Endpoints
|
||||
for zone, endpointSet := range addEndpoints {
|
||||
for {
|
||||
if endpointSet.Len() == 0 {
|
||||
break
|
||||
}
|
||||
networkEndpoints, err := s.toNetworkEndpointBatch(endpointSet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.attachNetworkEndpoints(&wg, zone, networkEndpoints, errList)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
return utilerrors.NewAggregate(errList.List())
|
||||
}
|
||||
|
||||
// translate a endpoints set to a batch of network endpoints object
|
||||
func (s *syncer) toNetworkEndpointBatch(endpoints sets.String) ([]*compute.NetworkEndpoint, error) {
|
||||
var ok bool
|
||||
list := make([]string, int(math.Min(float64(endpoints.Len()), float64(MAX_NETWORK_ENDPOINTS_PER_BATCH))))
|
||||
for i := range list {
|
||||
list[i], ok = endpoints.PopAny()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
networkEndpointList := make([]*compute.NetworkEndpoint, len(list))
|
||||
for i, enc := range list {
|
||||
ip, instance, port := decodeEndpoint(enc)
|
||||
portNum, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to decode endpoint %q: %v", enc, err)
|
||||
}
|
||||
networkEndpointList[i] = &compute.NetworkEndpoint{
|
||||
Instance: instance,
|
||||
IpAddress: ip,
|
||||
Port: int64(portNum),
|
||||
}
|
||||
}
|
||||
return networkEndpointList, nil
|
||||
}
|
||||
|
||||
func (s *syncer) attachNetworkEndpoints(wg *sync.WaitGroup, zone string, networkEndpoints []*compute.NetworkEndpoint, errList *ErrorList) {
|
||||
wg.Add(1)
|
||||
go s.operationInternal(wg, zone, networkEndpoints, errList, s.cloud.AttachNetworkEndpoints, "Attach")
|
||||
}
|
||||
|
||||
func (s *syncer) detachNetworkEndpoints(wg *sync.WaitGroup, zone string, networkEndpoints []*compute.NetworkEndpoint, errList *ErrorList) {
|
||||
wg.Add(1)
|
||||
go s.operationInternal(wg, zone, networkEndpoints, errList, s.cloud.DetachNetworkEndpoints, "Detach")
|
||||
}
|
||||
|
||||
func (s *syncer) operationInternal(wg *sync.WaitGroup, zone string, networkEndpoints []*compute.NetworkEndpoint, errList *ErrorList, syncFunc func(name, zone string, endpoints []*compute.NetworkEndpoint) error, operationName string) {
|
||||
defer wg.Done()
|
||||
err := syncFunc(s.negName, zone, networkEndpoints)
|
||||
if err != nil {
|
||||
errList.Add(err)
|
||||
}
|
||||
if svc := getService(s.serviceLister, s.namespace, s.name); svc != nil {
|
||||
if err == nil {
|
||||
s.recorder.Eventf(svc, apiv1.EventTypeNormal, operationName, "%s %d network endpoints to NEG %q in %q.", operationName, len(networkEndpoints), s.negName, zone)
|
||||
} else {
|
||||
s.recorder.Eventf(svc, apiv1.EventTypeWarning, operationName+"Failed", "Failed to %s %d network endpoints to NEG %q in %q: %v", operationName, len(networkEndpoints), s.negName, zone, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *syncer) nextRetryDelay() time.Duration {
|
||||
s.retryCount += 1
|
||||
s.lastRetryDelay *= 2
|
||||
if s.lastRetryDelay < minRetryDelay {
|
||||
s.lastRetryDelay = minRetryDelay
|
||||
} else if s.lastRetryDelay > maxRetryDelay {
|
||||
s.lastRetryDelay = maxRetryDelay
|
||||
}
|
||||
return s.lastRetryDelay
|
||||
}
|
||||
|
||||
func (s *syncer) resetRetryDelay() {
|
||||
s.retryCount = 0
|
||||
s.lastRetryDelay = time.Duration(0)
|
||||
}
|
||||
|
||||
// encodeEndpoint encodes ip and instance into a single string
|
||||
func encodeEndpoint(ip, instance, port string) string {
|
||||
return fmt.Sprintf("%s||%s||%s", ip, instance, port)
|
||||
}
|
||||
|
||||
// decodeEndpoint decodes ip and instance from an encoded string
|
||||
func decodeEndpoint(str string) (string, string, string) {
|
||||
strs := strings.Split(str, "||")
|
||||
return strs[0], strs[1], strs[2]
|
||||
}
|
||||
|
||||
// calculateDifference determines what endpoints needs to be added and removed in order to move current state to target state.
|
||||
func calculateDifference(targetMap, currentMap map[string]sets.String) (map[string]sets.String, map[string]sets.String) {
|
||||
addSet := map[string]sets.String{}
|
||||
removeSet := map[string]sets.String{}
|
||||
for zone, endpointSet := range targetMap {
|
||||
diff := endpointSet.Difference(currentMap[zone])
|
||||
if len(diff) > 0 {
|
||||
addSet[zone] = diff
|
||||
}
|
||||
}
|
||||
|
||||
for zone, endpointSet := range currentMap {
|
||||
diff := endpointSet.Difference(targetMap[zone])
|
||||
if len(diff) > 0 {
|
||||
removeSet[zone] = diff
|
||||
}
|
||||
}
|
||||
return addSet, removeSet
|
||||
}
|
||||
|
||||
func retrieveName(url string) string {
|
||||
strs := strings.Split(url, "/")
|
||||
return strs[len(strs)-1]
|
||||
}
|
||||
|
||||
// getService retrieves service object from serviceLister based on the input namespace and name
|
||||
func getService(serviceLister cache.Indexer, namespace, name string) *apiv1.Service {
|
||||
service, exists, err := serviceLister.GetByKey(serviceKeyFunc(namespace, name))
|
||||
if exists && err == nil {
|
||||
return service.(*apiv1.Service)
|
||||
}
|
||||
if err != nil {
|
||||
glog.Errorf("Failed to retrieve service %s/%s from store: %v", namespace, name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
439
controllers/gce/networkendpointgroup/syncer_test.go
Normal file
439
controllers/gce/networkendpointgroup/syncer_test.go
Normal file
|
@ -0,0 +1,439 @@
|
|||
package networkendpointgroup
|
||||
|
||||
import (
|
||||
apiv1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
"k8s.io/client-go/tools/record"
|
||||
"k8s.io/ingress/controllers/gce/utils"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
NegName = "test-neg-name"
|
||||
ServiceNamespace = "test-ns"
|
||||
ServiceName = "test-name"
|
||||
NamedPort = "named-port"
|
||||
)
|
||||
|
||||
func NewTestSyncer() *syncer {
|
||||
kubeClient := fake.NewSimpleClientset()
|
||||
context := utils.NewControllerContext(kubeClient, apiv1.NamespaceAll, 1*time.Second, true)
|
||||
svcPort := servicePort{
|
||||
namespace: ServiceNamespace,
|
||||
name: ServiceName,
|
||||
targetPort: "80",
|
||||
}
|
||||
|
||||
return newSyncer(svcPort,
|
||||
NegName,
|
||||
record.NewFakeRecorder(100),
|
||||
NewFakeNetworkEndpointGroupCloud("test-subnetwork", "test-newtork"),
|
||||
NewFakeZoneGetter(),
|
||||
context.ServiceInformer.GetIndexer(),
|
||||
context.EndpointInformer.GetIndexer())
|
||||
}
|
||||
|
||||
func TestStartAndStopSyncer(t *testing.T) {
|
||||
syncer := NewTestSyncer()
|
||||
if !syncer.IsStopped() {
|
||||
t.Fatalf("Syncer is not stopped after creation.")
|
||||
}
|
||||
if syncer.IsShuttingDown() {
|
||||
t.Fatalf("Syncer is shutting down after creation.")
|
||||
}
|
||||
|
||||
if err := syncer.Start(); err != nil {
|
||||
t.Fatalf("Failed to start syncer: %v", err)
|
||||
}
|
||||
if syncer.IsStopped() {
|
||||
t.Fatalf("Syncer is stopped after Start.")
|
||||
}
|
||||
if syncer.IsShuttingDown() {
|
||||
t.Fatalf("Syncer is shutting down after Start.")
|
||||
}
|
||||
|
||||
syncer.Stop()
|
||||
if !syncer.IsStopped() {
|
||||
t.Fatalf("Syncer is not stopped after Stop.")
|
||||
}
|
||||
|
||||
if err := wait.PollImmediate(time.Second, 30*time.Second, func() (bool, error) {
|
||||
return !syncer.IsShuttingDown() && syncer.IsStopped(), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("Syncer failed to shutdown: %v", err)
|
||||
}
|
||||
|
||||
if err := syncer.Start(); err != nil {
|
||||
t.Fatalf("Failed to restart syncer: %v", err)
|
||||
}
|
||||
if syncer.IsStopped() {
|
||||
t.Fatalf("Syncer is stopped after restart.")
|
||||
}
|
||||
if syncer.IsShuttingDown() {
|
||||
t.Fatalf("Syncer is shutting down after restart.")
|
||||
}
|
||||
|
||||
syncer.Stop()
|
||||
if !syncer.IsStopped() {
|
||||
t.Fatalf("Syncer is not stopped after Stop.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureNetworkEndpointGroups(t *testing.T) {
|
||||
syncer := NewTestSyncer()
|
||||
if err := syncer.ensureNetworkEndpointGroups(); err != nil {
|
||||
t.Errorf("Failed to ensure NEGs: %v", err)
|
||||
}
|
||||
|
||||
ret, _ := syncer.cloud.AggregatedListNetworkEndpointGroup()
|
||||
expectZones := []string{TestZone1, TestZone2}
|
||||
for _, zone := range expectZones {
|
||||
negs, ok := ret[zone]
|
||||
if !ok {
|
||||
t.Errorf("Failed to find zone %q from ret %v", zone, ret)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(negs) != 1 {
|
||||
t.Errorf("Unexpected negs %v", negs)
|
||||
} else {
|
||||
if negs[0].Name != NegName {
|
||||
t.Errorf("Unexpected neg %q", negs[0].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToZoneNetworkEndpointMap(t *testing.T) {
|
||||
syncer := NewTestSyncer()
|
||||
testCases := []struct {
|
||||
targetPort string
|
||||
expect map[string]sets.String
|
||||
}{
|
||||
{
|
||||
targetPort: "80",
|
||||
expect: map[string]sets.String{
|
||||
TestZone1: sets.NewString("10.100.1.1||instance1||80", "10.100.1.2||instance1||80", "10.100.2.1||instance2||80"),
|
||||
TestZone2: sets.NewString("10.100.3.1||instance3||80"),
|
||||
},
|
||||
},
|
||||
{
|
||||
targetPort: NamedPort,
|
||||
expect: map[string]sets.String{
|
||||
TestZone1: sets.NewString("10.100.2.2||instance2||81"),
|
||||
TestZone2: sets.NewString("10.100.4.1||instance4||81", "10.100.3.2||instance3||8081", "10.100.4.2||instance4||8081"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
syncer.targetPort = tc.targetPort
|
||||
res, _ := syncer.toZoneNetworkEndpointMap(getDefaultEndpoint())
|
||||
|
||||
if !reflect.DeepEqual(res, tc.expect) {
|
||||
t.Errorf("Expect %v, but got %v.", tc.expect, res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeEndpoint(t *testing.T) {
|
||||
ip := "10.0.0.10"
|
||||
instance := "somehost"
|
||||
port := "8080"
|
||||
|
||||
retIp, retInstance, retPort := decodeEndpoint(encodeEndpoint(ip, instance, port))
|
||||
|
||||
if ip != retIp || instance != retInstance || retPort != port {
|
||||
t.Fatalf("Encode and decode endpoint failed. Expect %q, %q, %q but got %q, %q, %q.", ip, instance, port, retIp, retInstance, retPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateDifference(t *testing.T) {
|
||||
testCases := []struct {
|
||||
targetSet map[string]sets.String
|
||||
currentSet map[string]sets.String
|
||||
addSet map[string]sets.String
|
||||
removeSet map[string]sets.String
|
||||
}{
|
||||
// unchanged
|
||||
{
|
||||
targetSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
},
|
||||
currentSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
},
|
||||
addSet: map[string]sets.String{},
|
||||
removeSet: map[string]sets.String{},
|
||||
},
|
||||
// unchanged
|
||||
{
|
||||
targetSet: map[string]sets.String{},
|
||||
currentSet: map[string]sets.String{},
|
||||
addSet: map[string]sets.String{},
|
||||
removeSet: map[string]sets.String{},
|
||||
},
|
||||
// add in one zone
|
||||
{
|
||||
targetSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
},
|
||||
currentSet: map[string]sets.String{},
|
||||
addSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
},
|
||||
removeSet: map[string]sets.String{},
|
||||
},
|
||||
// add in 2 zones
|
||||
{
|
||||
targetSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
TestZone2: sets.NewString("e", "f", "g"),
|
||||
},
|
||||
currentSet: map[string]sets.String{},
|
||||
addSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
TestZone2: sets.NewString("e", "f", "g"),
|
||||
},
|
||||
removeSet: map[string]sets.String{},
|
||||
},
|
||||
// remove in one zone
|
||||
{
|
||||
targetSet: map[string]sets.String{},
|
||||
currentSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
},
|
||||
addSet: map[string]sets.String{},
|
||||
removeSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
},
|
||||
},
|
||||
// remove in 2 zones
|
||||
{
|
||||
targetSet: map[string]sets.String{},
|
||||
currentSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
TestZone2: sets.NewString("e", "f", "g"),
|
||||
},
|
||||
addSet: map[string]sets.String{},
|
||||
removeSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
TestZone2: sets.NewString("e", "f", "g"),
|
||||
},
|
||||
},
|
||||
// add and delete in one zone
|
||||
{
|
||||
targetSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
},
|
||||
currentSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("b", "c", "d"),
|
||||
},
|
||||
addSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a"),
|
||||
},
|
||||
removeSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("d"),
|
||||
},
|
||||
},
|
||||
// add and delete in 2 zones
|
||||
{
|
||||
targetSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a", "b", "c"),
|
||||
TestZone2: sets.NewString("a", "b", "c"),
|
||||
},
|
||||
currentSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("b", "c", "d"),
|
||||
TestZone2: sets.NewString("b", "c", "d"),
|
||||
},
|
||||
addSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("a"),
|
||||
TestZone2: sets.NewString("a"),
|
||||
},
|
||||
removeSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("d"),
|
||||
TestZone2: sets.NewString("d"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
addSet, removeSet := calculateDifference(tc.targetSet, tc.currentSet)
|
||||
|
||||
if !reflect.DeepEqual(addSet, tc.addSet) {
|
||||
t.Errorf("Failed to calculate difference for add, expecting %v, but got %v", tc.addSet, addSet)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(removeSet, tc.removeSet) {
|
||||
t.Errorf("Failed to calculate difference for remove, expecting %v, but got %v", tc.removeSet, removeSet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncNetworkEndpoints(t *testing.T) {
|
||||
syncer := NewTestSyncer()
|
||||
if err := syncer.ensureNetworkEndpointGroups(); err != nil {
|
||||
t.Fatalf("Failed to ensure NEG: %v", err)
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
expectSet map[string]sets.String
|
||||
addSet map[string]sets.String
|
||||
removeSet map[string]sets.String
|
||||
}{
|
||||
{
|
||||
expectSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("10.100.1.1||instance1||80", "10.100.2.1||instance2||80"),
|
||||
TestZone2: sets.NewString("10.100.3.1||instance3||80", "10.100.4.1||instance4||80"),
|
||||
},
|
||||
addSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("10.100.1.1||instance1||80", "10.100.2.1||instance2||80"),
|
||||
TestZone2: sets.NewString("10.100.3.1||instance3||80", "10.100.4.1||instance4||80"),
|
||||
},
|
||||
removeSet: map[string]sets.String{},
|
||||
},
|
||||
{
|
||||
expectSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("10.100.1.2||instance1||80"),
|
||||
TestZone2: sets.NewString(),
|
||||
},
|
||||
addSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("10.100.1.2||instance1||80"),
|
||||
},
|
||||
removeSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("10.100.1.1||instance1||80", "10.100.2.1||instance2||80"),
|
||||
TestZone2: sets.NewString("10.100.3.1||instance3||80", "10.100.4.1||instance4||80"),
|
||||
},
|
||||
},
|
||||
{
|
||||
expectSet: map[string]sets.String{
|
||||
TestZone1: sets.NewString("10.100.1.2||instance1||80"),
|
||||
TestZone2: sets.NewString("10.100.3.2||instance3||80"),
|
||||
},
|
||||
addSet: map[string]sets.String{
|
||||
TestZone2: sets.NewString("10.100.3.2||instance3||80"),
|
||||
},
|
||||
removeSet: map[string]sets.String{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
if err := syncer.syncNetworkEndpoints(tc.addSet, tc.removeSet); err != nil {
|
||||
t.Fatalf("Failed to sync network endpoints: %v", err)
|
||||
}
|
||||
examineNetworkEndpoints(tc.expectSet, syncer, t)
|
||||
}
|
||||
}
|
||||
|
||||
func examineNetworkEndpoints(expectSet map[string]sets.String, syncer *syncer, t *testing.T) {
|
||||
for zone, endpoints := range expectSet {
|
||||
expectEndpoints, err := syncer.toNetworkEndpointBatch(endpoints)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to convert endpoints to network endpoints: %v", err)
|
||||
}
|
||||
if cloudEndpoints, err := syncer.cloud.ListNetworkEndpoints(syncer.negName, zone, false); err == nil {
|
||||
if len(expectEndpoints) != len(cloudEndpoints) {
|
||||
t.Errorf("Expect number of endpoints to be %v, but got %v.", len(expectEndpoints), len(cloudEndpoints))
|
||||
}
|
||||
for _, expectEp := range expectEndpoints {
|
||||
found := false
|
||||
for _, cloudEp := range cloudEndpoints {
|
||||
if reflect.DeepEqual(*expectEp, *cloudEp.NetworkEndpoint) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Endpoint %v not found.", expectEp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Failed to list network endpoints in zone %q: %v.", zone, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getDefaultEndpoint() *apiv1.Endpoints {
|
||||
instance1 := TestInstance1
|
||||
instance2 := TestInstance2
|
||||
instance3 := TestInstance3
|
||||
instance4 := TestInstance4
|
||||
return &apiv1.Endpoints{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: ServiceName,
|
||||
Namespace: ServiceNamespace,
|
||||
},
|
||||
Subsets: []apiv1.EndpointSubset{
|
||||
{
|
||||
Addresses: []apiv1.EndpointAddress{
|
||||
{
|
||||
IP: "10.100.1.1",
|
||||
NodeName: &instance1,
|
||||
},
|
||||
{
|
||||
IP: "10.100.1.2",
|
||||
NodeName: &instance1,
|
||||
},
|
||||
{
|
||||
IP: "10.100.2.1",
|
||||
NodeName: &instance2,
|
||||
},
|
||||
{
|
||||
IP: "10.100.3.1",
|
||||
NodeName: &instance3,
|
||||
},
|
||||
},
|
||||
Ports: []apiv1.EndpointPort{
|
||||
{
|
||||
Name: "",
|
||||
Port: int32(80),
|
||||
Protocol: apiv1.ProtocolTCP,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Addresses: []apiv1.EndpointAddress{
|
||||
{
|
||||
IP: "10.100.2.2",
|
||||
NodeName: &instance2,
|
||||
},
|
||||
{
|
||||
IP: "10.100.4.1",
|
||||
NodeName: &instance4,
|
||||
},
|
||||
},
|
||||
Ports: []apiv1.EndpointPort{
|
||||
{
|
||||
Name: NamedPort,
|
||||
Port: int32(81),
|
||||
Protocol: apiv1.ProtocolTCP,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Addresses: []apiv1.EndpointAddress{
|
||||
{
|
||||
IP: "10.100.3.2",
|
||||
NodeName: &instance3,
|
||||
},
|
||||
{
|
||||
IP: "10.100.4.2",
|
||||
NodeName: &instance4,
|
||||
},
|
||||
},
|
||||
Ports: []apiv1.EndpointPort{
|
||||
{
|
||||
Name: NamedPort,
|
||||
Port: int32(8081),
|
||||
Protocol: apiv1.ProtocolTCP,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue