github.com/jingruilea/kubeedge@v1.2.0-beta.0.0.20200410162146-4bb8902b3879/edge/pkg/edged/volume/csi/nodeinfomanager/nodeinfomanager.go (about) 1 /* 2 Copyright 2018 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 16 @CHANGELOG 17 KubeEdge Authors: To create mini-kubelet for edge deployment scenario, 18 this file is derived from kubernetes v1.15.3, 19 and the full file path is k8s.io/kubernetes/pkg/volume/csi/nodeinfomanager/nodeinfomanager.go 20 and make some modifications including: 21 1. remove some Unnecessary in nodeUpdateFunc. 22 2. replace PatchNodeStatus with self-defined Update. 23 */ 24 25 // Package nodeinfomanager includes internal functions used to add/delete labels to 26 // kubernetes nodes for corresponding CSI drivers 27 package nodeinfomanager 28 29 import ( 30 "encoding/json" 31 goerrors "errors" 32 "fmt" 33 "strings" 34 "time" 35 36 v1 "k8s.io/api/core/v1" 37 storagev1beta1 "k8s.io/api/storage/v1beta1" 38 "k8s.io/apimachinery/pkg/api/errors" 39 "k8s.io/apimachinery/pkg/api/resource" 40 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 41 "k8s.io/apimachinery/pkg/types" 42 utilerrors "k8s.io/apimachinery/pkg/util/errors" 43 "k8s.io/apimachinery/pkg/util/sets" 44 "k8s.io/apimachinery/pkg/util/wait" 45 utilfeature "k8s.io/apiserver/pkg/util/feature" 46 clientset "k8s.io/client-go/kubernetes" 47 "k8s.io/klog" 48 "k8s.io/kubernetes/pkg/features" 49 "k8s.io/kubernetes/pkg/volume" 50 "k8s.io/kubernetes/pkg/volume/util" 51 ) 52 53 const ( 54 // Name of node annotation that contains JSON map of driver names to node 55 annotationKeyNodeID = "csi.volume.kubernetes.io/nodeid" 56 ) 57 58 var ( 59 nodeKind = v1.SchemeGroupVersion.WithKind("Node") 60 updateBackoff = wait.Backoff{ 61 Steps: 4, 62 Duration: 10 * time.Millisecond, 63 Factor: 5.0, 64 Jitter: 0.1, 65 } 66 ) 67 68 // nodeInfoManager contains necessary common dependencies to update node info on both 69 // the Node and CSINode objects. 70 type nodeInfoManager struct { 71 nodeName types.NodeName 72 volumeHost volume.VolumeHost 73 migratedPlugins map[string](func() bool) 74 } 75 76 // If no updates is needed, the function must return the same Node object as the input. 77 type nodeUpdateFunc func(*v1.Node) (newNode *v1.Node, updated bool, err error) 78 79 // Interface implements an interface for managing labels of a node 80 type Interface interface { 81 CreateCSINode() (*storagev1beta1.CSINode, error) 82 83 // Updates or Creates the CSINode object with annotations for CSI Migration 84 InitializeCSINodeWithAnnotation() error 85 86 // Record in the cluster the given node information from the CSI driver with the given name. 87 // Concurrent calls to InstallCSIDriver() is allowed, but they should not be intertwined with calls 88 // to other methods in this interface. 89 InstallCSIDriver(driverName string, driverNodeID string, maxVolumeLimit int64, topology map[string]string) error 90 91 // Remove in the cluster node information from the CSI driver with the given name. 92 // Concurrent calls to UninstallCSIDriver() is allowed, but they should not be intertwined with calls 93 // to other methods in this interface. 94 UninstallCSIDriver(driverName string) error 95 } 96 97 // NewNodeInfoManager initializes nodeInfoManager 98 func NewNodeInfoManager( 99 nodeName types.NodeName, 100 volumeHost volume.VolumeHost, 101 migratedPlugins map[string](func() bool)) Interface { 102 return &nodeInfoManager{ 103 nodeName: nodeName, 104 volumeHost: volumeHost, 105 migratedPlugins: migratedPlugins, 106 } 107 } 108 109 // InstallCSIDriver updates the node ID annotation in the Node object and CSIDrivers field in the 110 // CSINode object. If the CSINode object doesn't yet exist, it will be created. 111 // If multiple calls to InstallCSIDriver() are made in parallel, some calls might receive Node or 112 // CSINode update conflicts, which causes the function to retry the corresponding update. 113 func (nim *nodeInfoManager) InstallCSIDriver(driverName string, driverNodeID string, maxAttachLimit int64, topology map[string]string) error { 114 if driverNodeID == "" { 115 return fmt.Errorf("error adding CSI driver node info: driverNodeID must not be empty") 116 } 117 118 nodeUpdateFuncs := []nodeUpdateFunc{ 119 updateNodeIDInNode(driverName, driverNodeID), 120 } 121 122 err := nim.updateNode(nodeUpdateFuncs...) 123 if err != nil { 124 return fmt.Errorf("error updating Node object with CSI driver node info: %v", err) 125 } 126 return nil 127 } 128 129 // UninstallCSIDriver removes the node ID annotation from the Node object and CSIDrivers field from the 130 // CSINode object. If the CSINOdeInfo object contains no CSIDrivers, it will be deleted. 131 // If multiple calls to UninstallCSIDriver() are made in parallel, some calls might receive Node or 132 // CSINode update conflicts, which causes the function to retry the corresponding update. 133 func (nim *nodeInfoManager) UninstallCSIDriver(driverName string) error { 134 if utilfeature.DefaultFeatureGate.Enabled(features.CSINodeInfo) { 135 err := nim.uninstallDriverFromCSINode(driverName) 136 if err != nil { 137 return fmt.Errorf("error uninstalling CSI driver from CSINode object %v", err) 138 } 139 } 140 141 err := nim.updateNode( 142 removeMaxAttachLimit(driverName), 143 removeNodeIDFromNode(driverName), 144 ) 145 if err != nil { 146 return fmt.Errorf("error removing CSI driver node info from Node object %v", err) 147 } 148 return nil 149 } 150 151 func (nim *nodeInfoManager) updateNode(updateFuncs ...nodeUpdateFunc) error { 152 var updateErrs []error 153 err := wait.ExponentialBackoff(updateBackoff, func() (bool, error) { 154 if err := nim.tryUpdateNode(updateFuncs...); err != nil { 155 updateErrs = append(updateErrs, err) 156 return false, nil 157 } 158 return true, nil 159 }) 160 if err != nil { 161 return fmt.Errorf("error updating node: %v; caused by: %v", err, utilerrors.NewAggregate(updateErrs)) 162 } 163 return nil 164 } 165 166 // updateNode repeatedly attempts to update the corresponding node object 167 // which is modified by applying the given update functions sequentially. 168 // Because updateFuncs are applied sequentially, later updateFuncs should take into account 169 // the effects of previous updateFuncs to avoid potential conflicts. For example, if multiple 170 // functions update the same field, updates in the last function are persisted. 171 func (nim *nodeInfoManager) tryUpdateNode(updateFuncs ...nodeUpdateFunc) error { 172 // Retrieve the latest version of Node before attempting update, so that 173 // existing changes are not overwritten. 174 175 kubeClient := nim.volumeHost.GetKubeClient() 176 if kubeClient == nil { 177 return fmt.Errorf("error getting kube client") 178 } 179 180 nodeClient := kubeClient.CoreV1().Nodes() 181 originalNode, err := nodeClient.Get(string(nim.nodeName), metav1.GetOptions{}) 182 if err != nil { 183 return err 184 } 185 node := originalNode.DeepCopy() 186 187 needUpdate := false 188 for _, update := range updateFuncs { 189 newNode, updated, err := update(node) 190 if err != nil { 191 return err 192 } 193 node = newNode 194 needUpdate = needUpdate || updated 195 } 196 197 if needUpdate { 198 // PatchNodeStatus can update both node's status and labels or annotations 199 // Updating status by directly updating node does not work 200 _, updateErr := nodeClient.Update(node) 201 return updateErr 202 } 203 204 return nil 205 } 206 207 // Guarantees the map is non-nil if no error is returned. 208 func buildNodeIDMapFromAnnotation(node *v1.Node) (map[string]string, error) { 209 var previousAnnotationValue string 210 if node.ObjectMeta.Annotations != nil { 211 previousAnnotationValue = 212 node.ObjectMeta.Annotations[annotationKeyNodeID] 213 } 214 215 var existingDriverMap map[string]string 216 if previousAnnotationValue != "" { 217 // Parse previousAnnotationValue as JSON 218 if err := json.Unmarshal([]byte(previousAnnotationValue), &existingDriverMap); err != nil { 219 return nil, fmt.Errorf( 220 "failed to parse node's %q annotation value (%q) err=%v", 221 annotationKeyNodeID, 222 previousAnnotationValue, 223 err) 224 } 225 } 226 227 if existingDriverMap == nil { 228 return make(map[string]string), nil 229 } 230 return existingDriverMap, nil 231 } 232 233 // updateNodeIDInNode returns a function that updates a Node object with the given 234 // Node ID information. 235 func updateNodeIDInNode( 236 csiDriverName string, 237 csiDriverNodeID string) nodeUpdateFunc { 238 return func(node *v1.Node) (*v1.Node, bool, error) { 239 existingDriverMap, err := buildNodeIDMapFromAnnotation(node) 240 if err != nil { 241 return nil, false, err 242 } 243 244 if val, ok := existingDriverMap[csiDriverName]; ok { 245 if val == csiDriverNodeID { 246 // Value already exists in node annotation, nothing more to do 247 return node, false, nil 248 } 249 } 250 251 // Add/update annotation value 252 existingDriverMap[csiDriverName] = csiDriverNodeID 253 jsonObj, err := json.Marshal(existingDriverMap) 254 if err != nil { 255 return nil, false, fmt.Errorf( 256 "error while marshalling node ID map updated with driverName=%q, nodeID=%q: %v", 257 csiDriverName, 258 csiDriverNodeID, 259 err) 260 } 261 262 if node.ObjectMeta.Annotations == nil { 263 node.ObjectMeta.Annotations = make(map[string]string) 264 } 265 node.ObjectMeta.Annotations[annotationKeyNodeID] = string(jsonObj) 266 267 node.ObjectMeta.Annotations["volumes.kubernetes.io/controller-managed-attach-detach"] = "true" 268 269 return node, true, nil 270 } 271 } 272 273 // removeNodeIDFromNode returns a function that removes node ID information matching the given 274 // driver name from a Node object. 275 func removeNodeIDFromNode(csiDriverName string) nodeUpdateFunc { 276 return func(node *v1.Node) (*v1.Node, bool, error) { 277 var previousAnnotationValue string 278 if node.ObjectMeta.Annotations != nil { 279 previousAnnotationValue = 280 node.ObjectMeta.Annotations[annotationKeyNodeID] 281 } 282 283 if previousAnnotationValue == "" { 284 return node, false, nil 285 } 286 287 // Parse previousAnnotationValue as JSON 288 existingDriverMap := map[string]string{} 289 if err := json.Unmarshal([]byte(previousAnnotationValue), &existingDriverMap); err != nil { 290 return nil, false, fmt.Errorf( 291 "failed to parse node's %q annotation value (%q) err=%v", 292 annotationKeyNodeID, 293 previousAnnotationValue, 294 err) 295 } 296 297 if _, ok := existingDriverMap[csiDriverName]; !ok { 298 // Value is already missing in node annotation, nothing more to do 299 return node, false, nil 300 } 301 302 // Delete annotation value 303 delete(existingDriverMap, csiDriverName) 304 if len(existingDriverMap) == 0 { 305 delete(node.ObjectMeta.Annotations, annotationKeyNodeID) 306 } else { 307 jsonObj, err := json.Marshal(existingDriverMap) 308 if err != nil { 309 return nil, false, fmt.Errorf( 310 "failed while trying to remove key %q from node %q annotation. Existing data: %v", 311 csiDriverName, 312 annotationKeyNodeID, 313 previousAnnotationValue) 314 } 315 316 node.ObjectMeta.Annotations[annotationKeyNodeID] = string(jsonObj) 317 } 318 319 return node, true, nil 320 } 321 } 322 323 // updateTopologyLabels returns a function that updates labels of a Node object with the given 324 // topology information. 325 func updateTopologyLabels(topology map[string]string) nodeUpdateFunc { 326 return func(node *v1.Node) (*v1.Node, bool, error) { 327 if topology == nil || len(topology) == 0 { 328 return node, false, nil 329 } 330 331 for k, v := range topology { 332 if curVal, exists := node.Labels[k]; exists && curVal != v { 333 return nil, false, fmt.Errorf("detected topology value collision: driver reported %q:%q but existing label is %q:%q", k, v, k, curVal) 334 } 335 } 336 337 if node.Labels == nil { 338 node.Labels = make(map[string]string) 339 } 340 for k, v := range topology { 341 node.Labels[k] = v 342 } 343 return node, true, nil 344 } 345 } 346 347 func (nim *nodeInfoManager) updateCSINode( 348 driverName string, 349 driverNodeID string, 350 topology map[string]string) error { 351 352 csiKubeClient := nim.volumeHost.GetKubeClient() 353 if csiKubeClient == nil { 354 return fmt.Errorf("error getting CSI client") 355 } 356 357 var updateErrs []error 358 err := wait.ExponentialBackoff(updateBackoff, func() (bool, error) { 359 if err := nim.tryUpdateCSINode(csiKubeClient, driverName, driverNodeID, topology); err != nil { 360 updateErrs = append(updateErrs, err) 361 return false, nil 362 } 363 return true, nil 364 }) 365 if err != nil { 366 return fmt.Errorf("error updating CSINode: %v; caused by: %v", err, utilerrors.NewAggregate(updateErrs)) 367 } 368 return nil 369 } 370 371 func (nim *nodeInfoManager) tryUpdateCSINode( 372 csiKubeClient clientset.Interface, 373 driverName string, 374 driverNodeID string, 375 topology map[string]string) error { 376 377 nodeInfo, err := csiKubeClient.StorageV1beta1().CSINodes().Get(string(nim.nodeName), metav1.GetOptions{}) 378 if nodeInfo == nil || errors.IsNotFound(err) { 379 nodeInfo, err = nim.CreateCSINode() 380 } 381 if err != nil { 382 return err 383 } 384 385 return nim.installDriverToCSINode(nodeInfo, driverName, driverNodeID, topology) 386 } 387 388 func (nim *nodeInfoManager) InitializeCSINodeWithAnnotation() error { 389 csiKubeClient := nim.volumeHost.GetKubeClient() 390 if csiKubeClient == nil { 391 return goerrors.New("error getting CSI client") 392 } 393 394 var updateErrs []error 395 err := wait.ExponentialBackoff(updateBackoff, func() (bool, error) { 396 if err := nim.tryInitializeCSINodeWithAnnotation(csiKubeClient); err != nil { 397 updateErrs = append(updateErrs, err) 398 return false, nil 399 } 400 return true, nil 401 }) 402 if err != nil { 403 return fmt.Errorf("error updating CSINode annotation: %v; caused by: %v", err, utilerrors.NewAggregate(updateErrs)) 404 } 405 406 return nil 407 } 408 409 func (nim *nodeInfoManager) tryInitializeCSINodeWithAnnotation(csiKubeClient clientset.Interface) error { 410 nodeInfo, err := csiKubeClient.StorageV1beta1().CSINodes().Get(string(nim.nodeName), metav1.GetOptions{}) 411 if nodeInfo == nil || errors.IsNotFound(err) { 412 // CreateCSINode will set the annotation 413 _, err = nim.CreateCSINode() 414 return err 415 } else if err != nil { 416 return err 417 } 418 419 annotationModified := setMigrationAnnotation(nim.migratedPlugins, nodeInfo) 420 421 if annotationModified { 422 _, err := csiKubeClient.StorageV1beta1().CSINodes().Update(nodeInfo) 423 return err 424 } 425 return nil 426 427 } 428 429 func (nim *nodeInfoManager) CreateCSINode() (*storagev1beta1.CSINode, error) { 430 431 kubeClient := nim.volumeHost.GetKubeClient() 432 if kubeClient == nil { 433 return nil, fmt.Errorf("error getting kube client") 434 } 435 436 csiKubeClient := nim.volumeHost.GetKubeClient() 437 if csiKubeClient == nil { 438 return nil, fmt.Errorf("error getting CSI client") 439 } 440 441 node, err := kubeClient.CoreV1().Nodes().Get(string(nim.nodeName), metav1.GetOptions{}) 442 if err != nil { 443 return nil, err 444 } 445 446 nodeInfo := &storagev1beta1.CSINode{ 447 ObjectMeta: metav1.ObjectMeta{ 448 Name: string(nim.nodeName), 449 OwnerReferences: []metav1.OwnerReference{ 450 { 451 APIVersion: nodeKind.Version, 452 Kind: nodeKind.Kind, 453 Name: node.Name, 454 UID: node.UID, 455 }, 456 }, 457 }, 458 Spec: storagev1beta1.CSINodeSpec{ 459 Drivers: []storagev1beta1.CSINodeDriver{}, 460 }, 461 } 462 463 setMigrationAnnotation(nim.migratedPlugins, nodeInfo) 464 465 return csiKubeClient.StorageV1beta1().CSINodes().Create(nodeInfo) 466 } 467 468 func setMigrationAnnotation(migratedPlugins map[string](func() bool), nodeInfo *storagev1beta1.CSINode) (modified bool) { 469 if migratedPlugins == nil { 470 return false 471 } 472 473 nodeInfoAnnotations := nodeInfo.GetAnnotations() 474 if nodeInfoAnnotations == nil { 475 nodeInfoAnnotations = map[string]string{} 476 } 477 478 var oldAnnotationSet sets.String 479 mpa := nodeInfoAnnotations[v1.MigratedPluginsAnnotationKey] 480 tok := strings.Split(mpa, ",") 481 if len(mpa) == 0 { 482 oldAnnotationSet = sets.NewString() 483 } else { 484 oldAnnotationSet = sets.NewString(tok...) 485 } 486 487 newAnnotationSet := sets.NewString() 488 for pluginName, migratedFunc := range migratedPlugins { 489 if migratedFunc() { 490 newAnnotationSet.Insert(pluginName) 491 } 492 } 493 494 if oldAnnotationSet.Equal(newAnnotationSet) { 495 return false 496 } 497 498 nas := strings.Join(newAnnotationSet.List(), ",") 499 if len(nas) != 0 { 500 nodeInfoAnnotations[v1.MigratedPluginsAnnotationKey] = nas 501 } else { 502 delete(nodeInfoAnnotations, v1.MigratedPluginsAnnotationKey) 503 } 504 505 nodeInfo.Annotations = nodeInfoAnnotations 506 return true 507 } 508 509 func (nim *nodeInfoManager) installDriverToCSINode( 510 nodeInfo *storagev1beta1.CSINode, 511 driverName string, 512 driverNodeID string, 513 topology map[string]string) error { 514 515 csiKubeClient := nim.volumeHost.GetKubeClient() 516 if csiKubeClient == nil { 517 return fmt.Errorf("error getting CSI client") 518 } 519 520 topologyKeys := make(sets.String) 521 for k := range topology { 522 topologyKeys.Insert(k) 523 } 524 525 specModified := true 526 // Clone driver list, omitting the driver that matches the given driverName 527 newDriverSpecs := []storagev1beta1.CSINodeDriver{} 528 for _, driverInfoSpec := range nodeInfo.Spec.Drivers { 529 if driverInfoSpec.Name == driverName { 530 if driverInfoSpec.NodeID == driverNodeID && 531 sets.NewString(driverInfoSpec.TopologyKeys...).Equal(topologyKeys) { 532 specModified = false 533 } 534 } else { 535 // Omit driverInfoSpec matching given driverName 536 newDriverSpecs = append(newDriverSpecs, driverInfoSpec) 537 } 538 } 539 540 annotationModified := setMigrationAnnotation(nim.migratedPlugins, nodeInfo) 541 542 if !specModified && !annotationModified { 543 return nil 544 } 545 546 // Append new driver 547 driverSpec := storagev1beta1.CSINodeDriver{ 548 Name: driverName, 549 NodeID: driverNodeID, 550 TopologyKeys: topologyKeys.List(), 551 } 552 553 newDriverSpecs = append(newDriverSpecs, driverSpec) 554 nodeInfo.Spec.Drivers = newDriverSpecs 555 556 _, err := csiKubeClient.StorageV1beta1().CSINodes().Update(nodeInfo) 557 return err 558 } 559 560 func (nim *nodeInfoManager) uninstallDriverFromCSINode( 561 csiDriverName string) error { 562 563 csiKubeClient := nim.volumeHost.GetKubeClient() 564 if csiKubeClient == nil { 565 return fmt.Errorf("error getting CSI client") 566 } 567 568 var updateErrs []error 569 err := wait.ExponentialBackoff(updateBackoff, func() (bool, error) { 570 if err := nim.tryUninstallDriverFromCSINode(csiKubeClient, csiDriverName); err != nil { 571 updateErrs = append(updateErrs, err) 572 return false, nil 573 } 574 return true, nil 575 }) 576 if err != nil { 577 return fmt.Errorf("error updating CSINode: %v; caused by: %v", err, utilerrors.NewAggregate(updateErrs)) 578 } 579 return nil 580 } 581 582 func (nim *nodeInfoManager) tryUninstallDriverFromCSINode( 583 csiKubeClient clientset.Interface, 584 csiDriverName string) error { 585 586 nodeInfoClient := csiKubeClient.StorageV1beta1().CSINodes() 587 nodeInfo, err := nodeInfoClient.Get(string(nim.nodeName), metav1.GetOptions{}) 588 if err != nil && errors.IsNotFound(err) { 589 return nil 590 } else if err != nil { 591 return err 592 } 593 594 hasModified := false 595 // Uninstall CSINodeDriver with name csiDriverName 596 drivers := nodeInfo.Spec.Drivers[:0] 597 for _, driver := range nodeInfo.Spec.Drivers { 598 if driver.Name != csiDriverName { 599 drivers = append(drivers, driver) 600 } else { 601 // Found a driver with name csiDriverName 602 // Set hasModified to true because it will be removed 603 hasModified = true 604 } 605 } 606 607 if !hasModified { 608 // No changes, don't update 609 return nil 610 } 611 nodeInfo.Spec.Drivers = drivers 612 613 _, err = nodeInfoClient.Update(nodeInfo) 614 615 return err // do not wrap error 616 617 } 618 619 func updateMaxAttachLimit(driverName string, maxLimit int64) nodeUpdateFunc { 620 return func(node *v1.Node) (*v1.Node, bool, error) { 621 if maxLimit <= 0 { 622 klog.V(4).Infof("skipping adding attach limit for %s", driverName) 623 return node, false, nil 624 } 625 626 if node.Status.Capacity == nil { 627 node.Status.Capacity = v1.ResourceList{} 628 } 629 if node.Status.Allocatable == nil { 630 node.Status.Allocatable = v1.ResourceList{} 631 } 632 limitKeyName := util.GetCSIAttachLimitKey(driverName) 633 node.Status.Capacity[v1.ResourceName(limitKeyName)] = *resource.NewQuantity(maxLimit, resource.DecimalSI) 634 node.Status.Allocatable[v1.ResourceName(limitKeyName)] = *resource.NewQuantity(maxLimit, resource.DecimalSI) 635 636 return node, true, nil 637 } 638 } 639 640 func removeMaxAttachLimit(driverName string) nodeUpdateFunc { 641 return func(node *v1.Node) (*v1.Node, bool, error) { 642 limitKey := v1.ResourceName(util.GetCSIAttachLimitKey(driverName)) 643 644 capacityExists := false 645 if node.Status.Capacity != nil { 646 _, capacityExists = node.Status.Capacity[limitKey] 647 } 648 649 allocatableExists := false 650 if node.Status.Allocatable != nil { 651 _, allocatableExists = node.Status.Allocatable[limitKey] 652 } 653 654 if !capacityExists && !allocatableExists { 655 return node, false, nil 656 } 657 658 delete(node.Status.Capacity, limitKey) 659 if len(node.Status.Capacity) == 0 { 660 node.Status.Capacity = nil 661 } 662 663 delete(node.Status.Allocatable, limitKey) 664 if len(node.Status.Allocatable) == 0 { 665 node.Status.Allocatable = nil 666 } 667 668 return node, true, nil 669 } 670 }