github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/provider/maas/devices.go (about) 1 // Copyright 2016 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package maas 5 6 import ( 7 "encoding/json" 8 "fmt" 9 "net/url" 10 "path" 11 "strconv" 12 "strings" 13 14 "github.com/juju/errors" 15 "github.com/juju/gomaasapi" 16 17 "github.com/juju/juju/core/instance" 18 "github.com/juju/juju/network" 19 ) 20 21 // TODO(dimitern): The types below should be part of gomaasapi. 22 // LKK Card: https://canonical.leankit.com/Boards/View/101652562/119310616 23 24 type maasZone struct { 25 Name string `json:"name"` 26 Description string `json:"description"` 27 ResourceURI string `json:"resource_uri"` 28 } 29 30 type maasMACAddress struct { 31 MACAddress string `json:"mac_address"` 32 } 33 34 type maasDevice struct { 35 SystemID string `json:"system_id"` 36 Parent string `json:"parent"` 37 Hostname string `json:"hostname"` 38 IPAddresses []string `json:"ip_addresses"` 39 Owner string `json:"owner"` 40 Zone maasZone `json:"zone"` 41 MACAddressSet []maasMACAddress `json:"macaddress_set"` 42 TagNames []string `json:"tag_names"` 43 ResourceURI string `json:"resource_uri"` 44 } 45 46 func parseDevice(jsonBytes []byte) (*maasDevice, error) { 47 var device maasDevice 48 if err := json.Unmarshal(jsonBytes, &device); err != nil { 49 return nil, errors.Annotate(err, "parsing device") 50 } 51 return &device, nil 52 } 53 54 func getJSONBytes(object json.Marshaler) ([]byte, error) { 55 rawBytes, err := object.MarshalJSON() 56 if err != nil { 57 return nil, errors.Annotate(err, "cannot get JSON bytes") 58 } 59 return rawBytes, nil 60 } 61 62 func (env *maasEnviron) createDevice(hostInstanceID instance.Id, hostname string, primaryMACAddress string) (*maasDevice, error) { 63 devicesAPI := env.getMAASClient().GetSubObject("devices") 64 params := make(url.Values) 65 params.Add("hostname", hostname) 66 params.Add("parent", extractSystemId(hostInstanceID)) 67 params.Add("mac_addresses", primaryMACAddress) 68 69 result, err := devicesAPI.CallPost("new", params) 70 if err != nil { 71 return nil, errors.Trace(err) 72 } 73 deviceJSON, err := getJSONBytes(result) 74 if err != nil { 75 return nil, errors.Trace(err) 76 } 77 device, err := parseDevice(deviceJSON) 78 if err != nil { 79 return nil, errors.Trace(err) 80 } 81 logger.Debugf("created device: %+v", device) 82 return device, nil 83 } 84 85 func parseInterface(jsonBytes []byte) (*maasInterface, error) { 86 var iface maasInterface 87 if err := json.Unmarshal(jsonBytes, &iface); err != nil { 88 return nil, errors.Annotate(err, "parsing interface") 89 } 90 return &iface, nil 91 } 92 93 func (env *maasEnviron) createDeviceInterface(deviceID instance.Id, name, macAddress, vlanID string) (*maasInterface, error) { 94 deviceSystemID := extractSystemId(deviceID) 95 uri := path.Join("nodes", deviceSystemID, "interfaces") 96 interfacesAPI := env.getMAASClient().GetSubObject(uri) 97 98 params := make(url.Values) 99 params.Add("name", name) 100 params.Add("mac_address", macAddress) 101 params.Add("vlan", vlanID) 102 103 result, err := interfacesAPI.CallPost("create_physical", params) 104 if err != nil { 105 return nil, errors.Trace(err) 106 } 107 interfaceJSON, err := getJSONBytes(result) 108 if err != nil { 109 return nil, errors.Trace(err) 110 } 111 iface, err := parseInterface(interfaceJSON) 112 if err != nil { 113 return nil, errors.Trace(err) 114 } 115 return iface, nil 116 } 117 118 func (env *maasEnviron) updateDeviceInterface(deviceID instance.Id, interfaceID, name, macAddress, vlanID string) (*maasInterface, error) { 119 deviceSystemID := extractSystemId(deviceID) 120 uri := path.Join("nodes", deviceSystemID, "interfaces", interfaceID) 121 interfacesAPI := env.getMAASClient().GetSubObject(uri) 122 123 params := make(url.Values) 124 params.Add("name", name) 125 params.Add("mac_address", macAddress) 126 params.Add("vlan", vlanID) 127 128 result, err := interfacesAPI.Update(params) 129 if err != nil { 130 return nil, errors.Trace(err) 131 } 132 interfaceJSON, err := getJSONBytes(result) 133 if err != nil { 134 return nil, errors.Trace(err) 135 } 136 iface, err := parseInterface(interfaceJSON) 137 if err != nil { 138 return nil, errors.Trace(err) 139 } 140 return iface, nil 141 } 142 143 func (env *maasEnviron) linkDeviceInterfaceToSubnet(deviceID instance.Id, interfaceID, subnetID string, mode maasLinkMode) (*maasInterface, error) { 144 deviceSystemID := extractSystemId(deviceID) 145 uri := path.Join("nodes", deviceSystemID, "interfaces", interfaceID) 146 interfacesAPI := env.getMAASClient().GetSubObject(uri) 147 148 params := make(url.Values) 149 params.Add("mode", string(mode)) 150 params.Add("subnet", subnetID) 151 152 result, err := interfacesAPI.CallPost("link_subnet", params) 153 if err != nil { 154 return nil, errors.Trace(err) 155 } 156 interfaceJSON, err := getJSONBytes(result) 157 if err != nil { 158 return nil, errors.Trace(err) 159 } 160 iface, err := parseInterface(interfaceJSON) 161 if err != nil { 162 return nil, errors.Trace(err) 163 } 164 return iface, nil 165 } 166 167 func (env *maasEnviron) deviceInterfaces(deviceID instance.Id) ([]maasInterface, error) { 168 deviceSystemID := extractSystemId(deviceID) 169 uri := path.Join("nodes", deviceSystemID, "interfaces") 170 interfacesAPI := env.getMAASClient().GetSubObject(uri) 171 172 result, err := interfacesAPI.CallGet("", nil) 173 if err != nil { 174 return nil, errors.Trace(err) 175 } 176 interfacesJSON, err := getJSONBytes(result) 177 if err != nil { 178 return nil, errors.Trace(err) 179 } 180 interfaces, err := parseInterfaces(interfacesJSON) 181 if err != nil { 182 return nil, errors.Trace(err) 183 } 184 logger.Debugf("device %q interfaces: %+v", deviceSystemID, interfaces) 185 return interfaces, nil 186 187 } 188 189 func (env *maasEnviron) deviceInterfaceInfo(deviceID instance.Id, nameToParentName map[string]string) ([]network.InterfaceInfo, error) { 190 interfaces, err := env.deviceInterfaces(deviceID) 191 if err != nil { 192 return nil, errors.Trace(err) 193 } 194 195 interfaceInfo := make([]network.InterfaceInfo, 0, len(interfaces)) 196 for _, nic := range interfaces { 197 nicInfo := network.InterfaceInfo{ 198 InterfaceName: nic.Name, 199 InterfaceType: network.EthernetInterface, 200 MACAddress: nic.MACAddress, 201 MTU: nic.EffectveMTU, 202 VLANTag: nic.VLAN.VID, 203 ProviderId: network.Id(strconv.Itoa(nic.ID)), 204 ProviderVLANId: network.Id(strconv.Itoa(nic.VLAN.ID)), 205 Disabled: !nic.Enabled, 206 NoAutoStart: !nic.Enabled, 207 ParentInterfaceName: nameToParentName[nic.Name], 208 } 209 210 if len(nic.Links) == 0 { 211 logger.Debugf("device %q interface %q has no links", deviceID, nic.Name) 212 interfaceInfo = append(interfaceInfo, nicInfo) 213 continue 214 } 215 216 for _, link := range nic.Links { 217 nicInfo.ConfigType = maasLinkToInterfaceConfigType(string(link.Mode)) 218 219 if link.IPAddress == "" { 220 logger.Debugf("device %q interface %q has no address", deviceID, nic.Name) 221 interfaceInfo = append(interfaceInfo, nicInfo) 222 continue 223 } 224 225 if link.Subnet == nil { 226 logger.Debugf("device %q interface %q link %d missing subnet", deviceID, nic.Name, link.ID) 227 interfaceInfo = append(interfaceInfo, nicInfo) 228 continue 229 } 230 231 nicInfo.CIDR = link.Subnet.CIDR 232 nicInfo.Address = network.NewAddressOnSpace(link.Subnet.Space, link.IPAddress) 233 nicInfo.ProviderSubnetId = network.Id(strconv.Itoa(link.Subnet.ID)) 234 nicInfo.ProviderAddressId = network.Id(strconv.Itoa(link.ID)) 235 if link.Subnet.GatewayIP != "" { 236 nicInfo.GatewayAddress = network.NewAddressOnSpace(link.Subnet.Space, link.Subnet.GatewayIP) 237 } 238 if len(link.Subnet.DNSServers) > 0 { 239 nicInfo.DNSServers = network.NewAddressesOnSpace(link.Subnet.Space, link.Subnet.DNSServers...) 240 } 241 242 interfaceInfo = append(interfaceInfo, nicInfo) 243 } 244 } 245 logger.Debugf("device %q has interface info: %+v", deviceID, interfaceInfo) 246 return interfaceInfo, nil 247 } 248 249 func (env *maasEnviron) deviceInterfaceInfo2(device gomaasapi.Device, nameToParentName map[string]string, subnetToStaticRoutes map[string][]gomaasapi.StaticRoute) ([]network.InterfaceInfo, error) { 250 deviceID := device.SystemID() 251 interfaces := device.InterfaceSet() 252 253 interfaceInfo := make([]network.InterfaceInfo, 0, len(interfaces)) 254 for idx, nic := range interfaces { 255 vlanId := 0 256 vlanVid := 0 257 vlan := nic.VLAN() 258 if vlan != nil { 259 vlanId = vlan.ID() 260 vlanVid = vlan.VID() 261 } 262 nicInfo := network.InterfaceInfo{ 263 DeviceIndex: idx, 264 InterfaceName: nic.Name(), 265 InterfaceType: network.EthernetInterface, 266 MACAddress: nic.MACAddress(), 267 MTU: nic.EffectiveMTU(), 268 VLANTag: vlanVid, 269 ProviderId: network.Id(strconv.Itoa(nic.ID())), 270 ProviderVLANId: network.Id(strconv.Itoa(vlanId)), 271 Disabled: !nic.Enabled(), 272 NoAutoStart: !nic.Enabled(), 273 ParentInterfaceName: nameToParentName[nic.Name()], 274 } 275 for _, link := range nic.Links() { 276 subnet := link.Subnet() 277 if subnet == nil { 278 continue 279 } 280 routes := subnetToStaticRoutes[subnet.CIDR()] 281 for _, route := range routes { 282 nicInfo.Routes = append(nicInfo.Routes, network.Route{ 283 DestinationCIDR: route.Destination().CIDR(), 284 GatewayIP: route.GatewayIP(), 285 Metric: route.Metric(), 286 }) 287 } 288 } 289 290 if len(nic.Links()) == 0 { 291 logger.Debugf("device %q interface %q has no links", deviceID, nic.Name()) 292 interfaceInfo = append(interfaceInfo, nicInfo) 293 continue 294 } 295 296 for _, link := range nic.Links() { 297 nicInfo.ConfigType = maasLinkToInterfaceConfigType(link.Mode()) 298 299 subnet := link.Subnet() 300 if link.IPAddress() == "" || subnet == nil { 301 logger.Debugf("device %q interface %q has no address", deviceID, nic.Name()) 302 interfaceInfo = append(interfaceInfo, nicInfo) 303 continue 304 } 305 306 nicInfo.CIDR = subnet.CIDR() 307 nicInfo.Address = network.NewAddressOnSpace(subnet.Space(), link.IPAddress()) 308 nicInfo.ProviderSubnetId = network.Id(strconv.Itoa(subnet.ID())) 309 nicInfo.ProviderAddressId = network.Id(strconv.Itoa(link.ID())) 310 if subnet.Gateway() != "" { 311 nicInfo.GatewayAddress = network.NewAddressOnSpace(subnet.Space(), subnet.Gateway()) 312 } 313 if len(subnet.DNSServers()) > 0 { 314 nicInfo.DNSServers = network.NewAddressesOnSpace(subnet.Space(), subnet.DNSServers()...) 315 } 316 317 interfaceInfo = append(interfaceInfo, nicInfo) 318 } 319 } 320 logger.Debugf("device %q has interface info: %+v", deviceID, interfaceInfo) 321 return interfaceInfo, nil 322 } 323 324 type deviceCreatorParams struct { 325 Name string 326 Subnet gomaasapi.Subnet // may be nil 327 PrimaryMACAddress string 328 PrimaryNICName string 329 DesiredInterfaceInfo []network.InterfaceInfo 330 CIDRToMAASSubnet map[string]gomaasapi.Subnet 331 CIDRToStaticRoutes map[string][]gomaasapi.StaticRoute 332 Machine gomaasapi.Machine 333 } 334 335 func (env *maasEnviron) createAndPopulateDevice(params deviceCreatorParams) (gomaasapi.Device, error) { 336 createDeviceArgs := gomaasapi.CreateMachineDeviceArgs{ 337 Hostname: params.Name, 338 MACAddress: params.PrimaryMACAddress, 339 Subnet: params.Subnet, // can be nil 340 InterfaceName: params.PrimaryNICName, 341 } 342 device, err := params.Machine.CreateDevice(createDeviceArgs) 343 if err != nil { 344 return nil, errors.Trace(err) 345 } 346 defer func() { 347 if err != nil { 348 device.Delete() 349 } 350 }() 351 interface_set := device.InterfaceSet() 352 if len(interface_set) != 1 { 353 // Shouldn't be possible as machine.CreateDevice always 354 // returns a device with one interface. 355 names := make([]string, len(interface_set)) 356 for i, iface := range interface_set { 357 names[i] = iface.Name() 358 } 359 err = errors.Errorf("unexpected number of interfaces "+ 360 "in response from creating device: %v", names) 361 return nil, err 362 } 363 primaryNIC := interface_set[0] 364 primaryNICVLAN := primaryNIC.VLAN() 365 366 interfaceCreated := false 367 // Populate the rest of the desired interfaces on this device 368 for _, nic := range params.DesiredInterfaceInfo { 369 if nic.InterfaceName == params.PrimaryNICName { 370 // already handled in CreateDevice 371 continue 372 } 373 // We have to register an extra interface for this container 374 // (aka 'device'), and then link that device to the desired 375 // subnet so that it can acquire an IP address from MAAS. 376 createArgs := gomaasapi.CreateInterfaceArgs{ 377 Name: nic.InterfaceName, 378 MTU: nic.MTU, 379 MACAddress: nic.MACAddress, 380 } 381 382 subnet, knownSubnet := params.CIDRToMAASSubnet[nic.CIDR] 383 if !knownSubnet { 384 logger.Warningf("NIC %v has no subnet - setting to manual and using 'primaryNIC' VLAN %d", nic.InterfaceName, primaryNICVLAN.ID()) 385 createArgs.VLAN = primaryNICVLAN 386 } else { 387 createArgs.VLAN = subnet.VLAN() 388 logger.Infof("linking NIC %v to subnet %v - using static IP", nic.InterfaceName, subnet.CIDR()) 389 } 390 391 createdNIC, err := device.CreateInterface(createArgs) 392 if err != nil { 393 return nil, errors.Annotate(err, "creating device interface") 394 } 395 logger.Debugf("created device interface: %+v", createdNIC) 396 interfaceCreated = true 397 398 if !knownSubnet { 399 // If we didn't request an explicit subnet, then we 400 // don't need to link the device to that subnet 401 continue 402 } 403 404 linkArgs := gomaasapi.LinkSubnetArgs{ 405 Mode: gomaasapi.LinkModeStatic, 406 Subnet: subnet, 407 } 408 409 if err := createdNIC.LinkSubnet(linkArgs); err != nil { 410 return nil, errors.Annotatef(err, "linking NIC %v to subnet %v", nic.InterfaceName, subnet.CIDR()) 411 } else { 412 logger.Debugf("linked device interface to subnet: %+v", createdNIC) 413 } 414 } 415 // If we have created any secondary interfaces we need to reload device from maas 416 // so that the changes are reflected in structure. 417 if interfaceCreated { 418 deviceID := device.SystemID() 419 args := gomaasapi.DevicesArgs{SystemIDs: []string{deviceID}} 420 devices, err := env.maasController.Devices(args) 421 if err != nil { 422 return nil, errors.Trace(err) 423 } 424 if len(devices) != 1 { 425 err = errors.Errorf("unexpected response requesting device %v: %v", deviceID, devices) 426 return nil, err 427 } 428 device = devices[0] 429 } 430 return device, nil 431 } 432 433 func (env *maasEnviron) lookupSubnets() (map[string]gomaasapi.Subnet, error) { 434 subnetCIDRToSubnet := make(map[string]gomaasapi.Subnet) 435 spaces, err := env.maasController.Spaces() 436 if err != nil { 437 return nil, errors.Trace(err) 438 } 439 for _, space := range spaces { 440 for _, subnet := range space.Subnets() { 441 subnetCIDRToSubnet[subnet.CIDR()] = subnet 442 } 443 } 444 return subnetCIDRToSubnet, nil 445 } 446 func (env *maasEnviron) lookupStaticRoutes() (map[string][]gomaasapi.StaticRoute, error) { 447 // map from the source subnet (what subnet is the device in), to what 448 // static routes should be used. 449 subnetToStaticRoutes := make(map[string][]gomaasapi.StaticRoute) 450 staticRoutes, err := env.maasController.StaticRoutes() 451 if err != nil { 452 // MAAS 2.0 does not support static-routes, and will return a 404. MAAS 453 // does not report support for static-routes in its capabilities, nor 454 // does it have a different API version between 2.1 and 2.0. So we make 455 // the attempt, and treat a 404 as not having any configured static 456 // routes. 457 // gomaaasapi wraps a ServerError in an UnexpectedError, so we need to 458 // dig to make sure we have the right cause: 459 handled := false 460 if gomaasapi.IsUnexpectedError(err) { 461 msg := err.Error() 462 if strings.Contains(msg, "404") && 463 strings.Contains(msg, "Unknown API endpoint:") && 464 strings.Contains(msg, "/static-routes/") { 465 logger.Debugf("static-routes not supported: %v", err) 466 handled = true 467 staticRoutes = nil 468 } else { 469 logger.Warningf("looking up static routes generated IsUnexpectedError, but didn't match: %q %#v", msg, err) 470 } 471 } else { 472 logger.Warningf("not IsUnexpectedError: %#v", err) 473 } 474 if !handled { 475 logger.Warningf("error looking up static-routes: %v", err) 476 return nil, errors.Annotate(err, "unable to look up static-routes") 477 } 478 } 479 for _, route := range staticRoutes { 480 source := route.Source() 481 sourceCIDR := source.CIDR() 482 subnetToStaticRoutes[sourceCIDR] = append(subnetToStaticRoutes[sourceCIDR], route) 483 } 484 logger.Debugf("found static routes: %# v", subnetToStaticRoutes) 485 return subnetToStaticRoutes, nil 486 } 487 488 func (env *maasEnviron) prepareDeviceDetails(name string, machine gomaasapi.Machine, preparedInfo []network.InterfaceInfo) (deviceCreatorParams, error) { 489 var zeroParams deviceCreatorParams 490 491 subnetCIDRToSubnet, err := env.lookupSubnets() 492 if err != nil { 493 return zeroParams, errors.Trace(err) 494 } 495 subnetToStaticRoutes, err := env.lookupStaticRoutes() 496 if err != nil { 497 return zeroParams, errors.Trace(err) 498 } 499 params := deviceCreatorParams{ 500 // Containers always use 'eth0' as their primary NIC 501 // XXX(jam) 2017-04-13: Except we *don't* do that for KVM containers running Xenial 502 Name: name, 503 Machine: machine, 504 PrimaryNICName: "eth0", 505 DesiredInterfaceInfo: preparedInfo, 506 CIDRToMAASSubnet: subnetCIDRToSubnet, 507 CIDRToStaticRoutes: subnetToStaticRoutes, 508 } 509 510 var primaryNICInfo network.InterfaceInfo 511 for _, nic := range preparedInfo { 512 if nic.InterfaceName == params.PrimaryNICName { 513 primaryNICInfo = nic 514 break 515 } 516 } 517 if primaryNICInfo.InterfaceName == "" { 518 return zeroParams, errors.Errorf("cannot find primary interface for container") 519 } 520 logger.Debugf("primary device NIC prepared info: %+v", primaryNICInfo) 521 522 primaryNICSubnetCIDR := primaryNICInfo.CIDR 523 subnet, hasSubnet := subnetCIDRToSubnet[primaryNICSubnetCIDR] 524 if hasSubnet { 525 params.Subnet = subnet 526 } else { 527 logger.Debugf("primary device NIC %q has no linked subnet - leaving unconfigured", primaryNICInfo.InterfaceName) 528 } 529 params.PrimaryMACAddress = primaryNICInfo.MACAddress 530 return params, nil 531 } 532 533 func validateExistingDevice(netInfo []network.InterfaceInfo, device gomaasapi.Device) (bool, error) { 534 // Compare the desired device characteristics with the actual device 535 interfaces := device.InterfaceSet() 536 if len(interfaces) < len(netInfo) { 537 logger.Debugf("existing device doesn't have enough interfaces, wanted %d, found %d", len(netInfo), len(interfaces)) 538 return false, nil 539 } 540 actualByMAC := make(map[string]gomaasapi.Interface, len(interfaces)) 541 for _, iface := range interfaces { 542 actualByMAC[iface.MACAddress()] = iface 543 } 544 for _, desired := range netInfo { 545 actual, ok := actualByMAC[desired.MACAddress] 546 if !ok { 547 foundMACs := make([]string, 0, len(actualByMAC)) 548 for _, iface := range interfaces { 549 foundMACs = append(foundMACs, fmt.Sprintf("%s: %s", iface.Name(), iface.MACAddress())) 550 } 551 found := strings.Join(foundMACs, ", ") 552 logger.Debugf("existing device doesn't have device for MAC Address %q, found: %s", desired.MACAddress, found) 553 // No such network interface 554 return false, nil 555 } 556 // TODO: we should have a way to know what space we are targeting, rather than a desired subnet CIDR 557 foundCIDR := false 558 for _, link := range actual.Links() { 559 subnet := link.Subnet() 560 if subnet != nil { 561 cidr := subnet.CIDR() 562 if cidr == desired.CIDR { 563 foundCIDR = true 564 } 565 } 566 } 567 if !foundCIDR { 568 logger.Debugf("could not find Subnet link for CIDR: %q", desired.CIDR) 569 return false, nil 570 } 571 } 572 return true, nil 573 } 574 575 // checkForExistingDevice checks to see if we've already registered a device 576 // with this name, and if its information is appropriately populated. If we 577 // have, then we just return the existing interface info. If we find it, but 578 // it doesn't match, then we ask MAAS to remove it, which should cause the 579 // calling code to create it again. 580 func (env *maasEnviron) checkForExistingDevice(params deviceCreatorParams) (gomaasapi.Device, error) { 581 devicesArgs := gomaasapi.DevicesArgs{ 582 Hostname: []string{params.Name}, 583 } 584 maybeDevices, err := params.Machine.Devices(devicesArgs) 585 if err != nil { 586 logger.Warningf("error while trying to lookup %q: %v", params.Name, err) 587 // not considered fatal, since we'll attempt to create the device if we didn't find it 588 return nil, nil 589 } 590 if len(maybeDevices) == 0 { 591 logger.Debugf("no existing MAAS devices for container %q, creating", params.Name) 592 return nil, nil 593 } 594 if len(maybeDevices) > 1 { 595 logger.Warningf("found more than 1 MAAS devices (%d) for container %q", len(maybeDevices), 596 params.Name) 597 return nil, errors.Errorf("found more than 1 MAAS device (%d) for container %q", 598 len(maybeDevices), params.Name) 599 } 600 device := maybeDevices[0] 601 // Now validate that this device has the right interfaces 602 matches, err := validateExistingDevice(params.DesiredInterfaceInfo, device) 603 if err != nil { 604 return nil, err 605 } 606 if matches { 607 logger.Debugf("found MAAS device for container %q using existing device", params.Name) 608 return device, nil 609 } 610 logger.Debugf("found existing MAAS device for container %q but interfaces did not match, removing device", params.Name) 611 // We found a device, but it doesn't match what we need. remove it and we'll create again. 612 device.Delete() 613 return nil, nil 614 }