github.com/niedbalski/juju@v0.0.0-20190215020005-8ff100488e47/provider/ec2/environ_vpc.go (about) 1 // Copyright 2016 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package ec2 5 6 import ( 7 "fmt" 8 "strings" 9 10 "github.com/juju/collections/set" 11 "github.com/juju/errors" 12 "gopkg.in/amz.v3/ec2" 13 14 "github.com/juju/juju/environs" 15 "github.com/juju/juju/environs/context" 16 "github.com/juju/juju/network" 17 ) 18 19 const ( 20 activeState = "active" 21 availableState = "available" 22 localRouteGatewayID = "local" 23 defaultRouteCIDRBlock = "0.0.0.0/0" 24 vpcIDNone = "none" 25 ) 26 27 var ( 28 vpcNotUsableForBootstrapErrorPrefix = ` 29 Juju cannot use the given vpc-id for bootstrapping a controller 30 instance. Please, double check the given VPC ID is correct, and that 31 the VPC contains at least one subnet. 32 33 Error details`[1:] 34 35 vpcNotUsableForModelErrorPrefix = ` 36 Juju cannot use the given vpc-id for the model being added. 37 Please double check the given VPC ID is correct, and that 38 the VPC contains at least one subnet. 39 40 Error details`[1:] 41 42 vpcNotRecommendedErrorPrefix = ` 43 The given vpc-id does not meet one or more of the following minimum 44 Juju requirements: 45 46 1. VPC should be in "available" state and contain one or more subnets. 47 2. An Internet Gateway (IGW) should be attached to the VPC. 48 3. The main route table of the VPC should have both a default route 49 to the attached IGW and a local route matching the VPC CIDR block. 50 4. At least one of the VPC subnets should have MapPublicIPOnLaunch 51 attribute enabled (i.e. at least one subnet needs to be 'public'). 52 5. All subnets should be implicitly associated to the VPC main route 53 table, rather than explicitly to per-subnet route tables. 54 55 A default VPC already satisfies all of the requirements above. If you 56 still want to use the VPC, try running 'juju bootstrap' again with: 57 58 --config vpc-id=%s --config vpc-id-force=true 59 60 to force Juju to bypass the requirements check (NOT recommended unless 61 you understand the implications: most importantly, not being able to 62 access the Juju controller, likely causing bootstrap to fail, or trying 63 to deploy exposed workloads on instances started in private or isolated 64 subnets). 65 66 Error details`[1:] 67 68 cannotValidateVPCErrorPrefix = ` 69 Juju could not verify whether the given vpc-id meets the minimum Juju 70 connectivity requirements. Please, double check the VPC ID is correct, 71 you have a working connection to the Internet, your AWS credentials are 72 sufficient to access VPC features, or simply retry bootstrapping again. 73 74 Error details`[1:] 75 76 vpcNotRecommendedButForcedWarning = ` 77 WARNING! The specified vpc-id does not satisfy the minimum Juju requirements, 78 but will be used anyway because vpc-id-force=true is also specified. 79 80 `[1:] 81 ) 82 83 // vpcNotUsableError indicates a user-specified VPC cannot be used either 84 // because it is missing or because it contains no subnets. 85 type vpcNotUsableError struct { 86 errors.Err 87 } 88 89 // vpcNotUsablef returns an error which satisfies isVPCNotUsableError(). 90 func vpcNotUsablef(optionalCause error, format string, args ...interface{}) error { 91 outerErr := errors.Errorf(format, args...) 92 if optionalCause != nil { 93 outerErr = errors.Maskf(optionalCause, format, args...) 94 } 95 96 innerErr, _ := outerErr.(*errors.Err) // cannot fail. 97 return &vpcNotUsableError{*innerErr} 98 } 99 100 // isVPCNotUsableError reports whether err was created with vpcNotUsablef(). 101 func isVPCNotUsableError(err error) bool { 102 err = errors.Cause(err) 103 _, ok := err.(*vpcNotUsableError) 104 return ok 105 } 106 107 // vpcNotRecommendedError indicates a user-specified VPC is unlikely to be 108 // suitable for hosting a Juju controller instance and/or exposed workloads, due 109 // to not satisfying the mininum requirements described in validateVPC()'s doc 110 // comment. Users can still force Juju to use such a VPC by passing 111 // 'vpc-id-force=true' setting. 112 type vpcNotRecommendedError struct { 113 errors.Err 114 } 115 116 // vpcNotRecommendedf returns an error which satisfies isVPCNotRecommendedError(). 117 func vpcNotRecommendedf(format string, args ...interface{}) error { 118 outerErr := errors.Errorf(format, args...) 119 innerErr, _ := outerErr.(*errors.Err) // cannot fail. 120 return &vpcNotRecommendedError{*innerErr} 121 } 122 123 // isVPCNotRecommendedError reports whether err was created with vpcNotRecommendedf(). 124 func isVPCNotRecommendedError(err error) bool { 125 err = errors.Cause(err) 126 _, ok := err.(*vpcNotRecommendedError) 127 return ok 128 } 129 130 // vpcAPIClient defines a subset of the goamz API calls needed to validate a VPC. 131 type vpcAPIClient interface { 132 // AccountAttributes, called with the "default-vpc" attribute. is used to 133 // find the ID of the region's default VPC (if any). 134 AccountAttributes(attributeNames ...string) (*ec2.AccountAttributesResp, error) 135 136 // VPCs is used to get details for the VPC being validated, including 137 // whether it exists, is available, and its CIDRBlock and IsDefault fields. 138 VPCs(ids []string, filter *ec2.Filter) (*ec2.VPCsResp, error) 139 140 // Subnets is used to get a list of all subnets of the validated VPC (if 141 // any),including their Id, AvailZone, and MapPublicIPOnLaunch fields. 142 Subnets(ids []string, filter *ec2.Filter) (*ec2.SubnetsResp, error) 143 144 // InternetGateways is used to get details of the Internet Gateway (IGW) 145 // attached to the validated VPC (if any), its Id to check against routes, 146 // and whether it's available. 147 InternetGateways(ids []string, filter *ec2.Filter) (*ec2.InternetGatewaysResp, error) 148 149 // RouteTables is used to find the main route table of the VPC (if any), 150 // whether it includes a default route to the attached IGW, a local route to 151 // the VPC CIDRBlock, and any per-subnet route tables. 152 RouteTables(ids []string, filter *ec2.Filter) (*ec2.RouteTablesResp, error) 153 } 154 155 // validateVPC requires both arguments to be set and validates that vpcID refers 156 // to an existing AWS VPC (default or non-default) for the current region. 157 // Returns an error satifying isVPCNotUsableError() when the VPC with the given 158 // vpcID cannot be found, or when the VPC exists but contains no subnets. 159 // Returns an error satisfying isVPCNotRecommendedError() in the following 160 // cases: 161 // 162 // 1. The VPC's state is not "available". 163 // 2. The VPC does not have an Internet Gateway (IGW) attached. 164 // 3. A main route table is not associated with the VPC. 165 // 4. The main route table lacks both a default route via the IGW and a local 166 // route matching the VPC's CIDR block. 167 // 5. One or more of the VPC's subnets are not associated with the main route 168 // table of the VPC. 169 // 6. None of the the VPC's subnets have the MapPublicIPOnLaunch attribute set. 170 // 171 // With the vpc-id-force config setting set to true, the provider can ignore a 172 // vpcNotRecommendedError. A vpcNotUsableError cannot be ignored, while 173 // unexpected API responses and errors could be retried. 174 // 175 // The above minimal requirements allow Juju to work out-of-the-box with most 176 // common (and officially documented by AWS) VPC setups, easy try out with AWS 177 // Console / VPC Wizard / CLI. Detecting VPC setups indicating intentional 178 // customization by experienced users, protecting beginners from bad Juju-UX due 179 // to broken VPC setup, while still allowing power users to override that and 180 // continue (but knowing what that implies). 181 func validateVPC(apiClient vpcAPIClient, ctx context.ProviderCallContext, vpcID string) error { 182 if vpcID == "" || apiClient == nil { 183 return errors.Errorf("invalid arguments: empty VPC ID or nil client") 184 } 185 186 vpc, err := getVPCByID(apiClient, ctx, vpcID) 187 if err != nil { 188 return errors.Trace(err) 189 } 190 191 if err := checkVPCIsAvailable(vpc); err != nil { 192 return errors.Trace(err) 193 } 194 195 subnets, err := getVPCSubnets(apiClient, ctx, vpc) 196 if err != nil { 197 return errors.Trace(err) 198 } 199 200 publicSubnet, err := findFirstPublicSubnet(subnets) 201 if err != nil { 202 return errors.Trace(err) 203 } 204 205 // TODO(dimitern): Rather than just logging that, use publicSubnet.Id or 206 // even publicSubnet.AvailZone as default bootstrap placement directive, so 207 // the controller would be reachable. 208 logger.Infof( 209 "found subnet %q (%s) in AZ %q, suitable for a Juju controller instance", 210 publicSubnet.Id, publicSubnet.CIDRBlock, publicSubnet.AvailZone, 211 ) 212 213 gateway, err := getVPCInternetGateway(apiClient, ctx, vpc) 214 if err != nil { 215 return errors.Trace(err) 216 } 217 218 if err := checkInternetGatewayIsAvailable(gateway); err != nil { 219 return errors.Trace(err) 220 } 221 222 routeTables, err := getVPCRouteTables(apiClient, ctx, vpc) 223 if err != nil { 224 return errors.Trace(err) 225 } 226 227 mainRouteTable, err := findVPCMainRouteTable(routeTables) 228 if err != nil { 229 return errors.Trace(err) 230 } 231 232 if err := checkVPCRouteTableRoutes(vpc, mainRouteTable, gateway); err != nil { 233 return errors.Annotatef(err, "VPC %q main route table %q", vpcID, mainRouteTable.Id) 234 } 235 236 logger.Infof("VPC %q is suitable for Juju controllers and expose-able workloads", vpc.Id) 237 return nil 238 } 239 240 func getVPCByID(apiClient vpcAPIClient, ctx context.ProviderCallContext, vpcID string) (*ec2.VPC, error) { 241 response, err := apiClient.VPCs([]string{vpcID}, nil) 242 if isVPCNotFoundError(err) { 243 return nil, vpcNotUsablef(err, "") 244 } else if err != nil { 245 return nil, errors.Annotatef(maybeConvertCredentialError(err, ctx), "unexpected AWS response getting VPC %q", vpcID) 246 } 247 248 if numResults := len(response.VPCs); numResults == 0 { 249 return nil, vpcNotUsablef(nil, "VPC %q not found", vpcID) 250 } else if numResults > 1 { 251 logger.Debugf("VPCs() returned %#v", response) 252 return nil, errors.Errorf("expected 1 result from AWS, got %d", numResults) 253 } 254 255 vpc := response.VPCs[0] 256 return &vpc, nil 257 } 258 259 func isVPCNotFoundError(err error) bool { 260 return err != nil && ec2ErrCode(err) == "InvalidVpcID.NotFound" 261 } 262 263 func checkVPCIsAvailable(vpc *ec2.VPC) error { 264 if vpc.State != availableState { 265 return vpcNotRecommendedf("VPC has unexpected state %q", vpc.State) 266 } 267 268 if vpc.IsDefault { 269 logger.Infof("VPC %q is the default VPC for the region", vpc.Id) 270 } 271 272 return nil 273 } 274 275 func getVPCSubnets(apiClient vpcAPIClient, ctx context.ProviderCallContext, vpc *ec2.VPC) ([]ec2.Subnet, error) { 276 filter := ec2.NewFilter() 277 filter.Add("vpc-id", vpc.Id) 278 response, err := apiClient.Subnets(nil, filter) 279 if err != nil { 280 return nil, errors.Annotatef(maybeConvertCredentialError(err, ctx), "unexpected AWS response getting subnets of VPC %q", vpc.Id) 281 } 282 283 if len(response.Subnets) == 0 { 284 return nil, vpcNotUsablef(nil, "no subnets found for VPC %q", vpc.Id) 285 } 286 287 return response.Subnets, nil 288 } 289 290 func findFirstPublicSubnet(subnets []ec2.Subnet) (*ec2.Subnet, error) { 291 for _, subnet := range subnets { 292 // TODO(dimitern): goamz's AddDefaultVPCAndSubnets() does not set 293 // MapPublicIPOnLaunch only DefaultForAZ, but in reality the former is 294 // always set when the latter is. Until this is fixed in goamz, we check 295 // for both below to allow testing the behavior. 296 if subnet.MapPublicIPOnLaunch || subnet.DefaultForAZ { 297 logger.Debugf( 298 "VPC %q subnet %q has MapPublicIPOnLaunch=%v, DefaultForAZ=%v", 299 subnet.VPCId, subnet.Id, subnet.MapPublicIPOnLaunch, subnet.DefaultForAZ, 300 ) 301 return &subnet, nil 302 } 303 304 } 305 return nil, vpcNotRecommendedf("VPC contains no public subnets") 306 } 307 308 func getVPCInternetGateway(apiClient vpcAPIClient, ctx context.ProviderCallContext, vpc *ec2.VPC) (*ec2.InternetGateway, error) { 309 filter := ec2.NewFilter() 310 filter.Add("attachment.vpc-id", vpc.Id) 311 response, err := apiClient.InternetGateways(nil, filter) 312 if err != nil { 313 return nil, errors.Annotatef(maybeConvertCredentialError(err, ctx), "unexpected AWS response getting Internet Gateway of VPC %q", vpc.Id) 314 } 315 316 if numResults := len(response.InternetGateways); numResults == 0 { 317 return nil, vpcNotRecommendedf("VPC has no Internet Gateway attached") 318 } else if numResults > 1 { 319 logger.Debugf("InternetGateways() returned %#v", response) 320 return nil, errors.Errorf("expected 1 result from AWS, got %d", numResults) 321 } 322 323 gateway := response.InternetGateways[0] 324 return &gateway, nil 325 } 326 327 func checkInternetGatewayIsAvailable(gateway *ec2.InternetGateway) error { 328 if state := gateway.AttachmentState; state != availableState { 329 return vpcNotRecommendedf("VPC has Internet Gateway %q in unexpected state %q", gateway.Id, state) 330 } 331 332 return nil 333 } 334 335 func getVPCRouteTables(apiClient vpcAPIClient, ctx context.ProviderCallContext, vpc *ec2.VPC) ([]ec2.RouteTable, error) { 336 filter := ec2.NewFilter() 337 filter.Add("vpc-id", vpc.Id) 338 response, err := apiClient.RouteTables(nil, filter) 339 if err != nil { 340 return nil, errors.Annotatef(maybeConvertCredentialError(err, ctx), "unexpected AWS response getting route tables of VPC %q", vpc.Id) 341 } 342 343 if len(response.Tables) == 0 { 344 return nil, vpcNotRecommendedf("VPC has no route tables") 345 } 346 logger.Tracef("RouteTables() returned %#v", response) 347 348 return response.Tables, nil 349 } 350 351 func getVPCCIDR(apiClient vpcAPIClient, ctx context.ProviderCallContext, vpcID string) (string, error) { 352 vpc, err := getVPCByID(apiClient, ctx, vpcID) 353 if err != nil { 354 return "", err 355 } 356 return vpc.CIDRBlock, nil 357 } 358 359 func findVPCMainRouteTable(routeTables []ec2.RouteTable) (*ec2.RouteTable, error) { 360 var mainTable *ec2.RouteTable 361 for i, table := range routeTables { 362 if len(table.Associations) < 1 { 363 logger.Tracef("ignoring VPC %q route table %q with no associations", table.VPCId, table.Id) 364 continue 365 } 366 367 for _, association := range table.Associations { 368 // TODO(dimitern): Of all the requirements, this seems like the most 369 // strict and likely to push users to use vpc-id-force=true. On the 370 // other hand, having to deal with more than the main route table's 371 // routes will likely overcomplicate the routes checks that follow. 372 if subnetID := association.SubnetId; subnetID != "" { 373 return nil, vpcNotRecommendedf("subnet %q not associated with VPC %q main route table", subnetID, table.VPCId) 374 } 375 376 if association.IsMain && mainTable == nil { 377 logger.Tracef("main route table of VPC %q has ID %q", table.VPCId, table.Id) 378 mainTable = &routeTables[i] 379 } 380 } 381 } 382 383 if mainTable == nil { 384 return nil, vpcNotRecommendedf("VPC has no associated main route table") 385 } 386 387 return mainTable, nil 388 } 389 390 func checkVPCRouteTableRoutes(vpc *ec2.VPC, routeTable *ec2.RouteTable, gateway *ec2.InternetGateway) error { 391 hasDefaultRoute := false 392 hasLocalRoute := false 393 394 logger.Tracef("checking route table %+v routes", routeTable) 395 for _, route := range routeTable.Routes { 396 if route.State != activeState { 397 logger.Tracef("skipping inactive route %+v", route) 398 continue 399 } 400 401 switch route.DestinationCIDRBlock { 402 case defaultRouteCIDRBlock: 403 if route.GatewayId == gateway.Id { 404 logger.Tracef("default route uses expected gateway %q", gateway.Id) 405 hasDefaultRoute = true 406 } 407 case vpc.CIDRBlock: 408 if route.GatewayId == localRouteGatewayID { 409 logger.Tracef("local route uses expected CIDR %q", vpc.CIDRBlock) 410 hasLocalRoute = true 411 } 412 default: 413 logger.Tracef("route %+v is neither local nor default (skipping)", route) 414 } 415 } 416 417 if hasDefaultRoute && hasLocalRoute { 418 return nil 419 } 420 421 if !hasDefaultRoute { 422 return vpcNotRecommendedf("missing default route via gateway %q", gateway.Id) 423 } 424 return vpcNotRecommendedf("missing local route with destination %q", vpc.CIDRBlock) 425 } 426 427 func findDefaultVPCID(apiClient vpcAPIClient, ctx context.ProviderCallContext) (string, error) { 428 response, err := apiClient.AccountAttributes("default-vpc") 429 if err != nil { 430 return "", errors.Annotate(maybeConvertCredentialError(err, ctx), "unexpected AWS response getting default-vpc account attribute") 431 } 432 433 if len(response.Attributes) == 0 || 434 len(response.Attributes[0].Values) == 0 || 435 response.Attributes[0].Name != "default-vpc" { 436 // No value for the requested "default-vpc" attribute, all bets are off. 437 return "", errors.NotFoundf("default-vpc account attribute") 438 } 439 440 firstAttributeValue := response.Attributes[0].Values[0] 441 if firstAttributeValue == vpcIDNone { 442 return "", errors.NotFoundf("default VPC") 443 } 444 445 return firstAttributeValue, nil 446 } 447 448 // getVPCSubnetIDsForAvailabilityZone returns a sorted list of subnet IDs, which 449 // are both in the given vpcID and the given zoneName. If allowedSubnetIDs is 450 // not empty, the returned list will only contain IDs present there. Returns an 451 // error satisfying errors.IsNotFound() when no results match. 452 func getVPCSubnetIDsForAvailabilityZone( 453 apiClient vpcAPIClient, 454 ctx context.ProviderCallContext, 455 vpcID, zoneName string, 456 allowedSubnetIDs []string, 457 ) ([]string, error) { 458 allowedSubnets := set.NewStrings(allowedSubnetIDs...) 459 vpc := &ec2.VPC{Id: vpcID} 460 subnets, err := getVPCSubnets(apiClient, ctx, vpc) 461 if err != nil && !isVPCNotUsableError(err) { 462 return nil, errors.Annotatef(maybeConvertCredentialError(err, ctx), "cannot get VPC %q subnets", vpcID) 463 } else if isVPCNotUsableError(err) { 464 // We're reusing getVPCSubnets(), but not while validating a VPC 465 // pre-bootstrap, so we should change vpcNotUsableError to a simple 466 // NotFoundError. 467 message := fmt.Sprintf("VPC %q has no subnets in AZ %q", vpcID, zoneName) 468 return nil, errors.NewNotFound(err, message) 469 } 470 471 matchingSubnetIDs := set.NewStrings() 472 for _, subnet := range subnets { 473 if subnet.AvailZone != zoneName { 474 logger.Debugf("skipping subnet %q (in VPC %q): not in the chosen AZ %q", subnet.Id, vpcID, zoneName) 475 continue 476 } 477 if !allowedSubnets.IsEmpty() && !allowedSubnets.Contains(subnet.Id) { 478 logger.Debugf("skipping subnet %q (in VPC %q, AZ %q): not matching spaces constraints", subnet.Id, vpcID, zoneName) 479 continue 480 } 481 matchingSubnetIDs.Add(subnet.Id) 482 } 483 484 if matchingSubnetIDs.IsEmpty() { 485 message := fmt.Sprintf("VPC %q has no subnets in AZ %q", vpcID, zoneName) 486 return nil, errors.NewNotFound(nil, message) 487 } 488 489 sortedIDs := matchingSubnetIDs.SortedValues() 490 logger.Infof("found %d subnets in VPC %q matching AZ %q and constraints: %v", len(sortedIDs), vpcID, zoneName, sortedIDs) 491 return sortedIDs, nil 492 } 493 494 func findSubnetIDsForAvailabilityZone(zoneName string, subnetsToZones map[network.Id][]string) ([]string, error) { 495 matchingSubnetIDs := set.NewStrings() 496 for subnetID, zones := range subnetsToZones { 497 zonesSet := set.NewStrings(zones...) 498 if zonesSet.Contains(zoneName) { 499 matchingSubnetIDs.Add(string(subnetID)) 500 } 501 } 502 503 if matchingSubnetIDs.IsEmpty() { 504 return nil, errors.NotFoundf("subnets in AZ %q", zoneName) 505 } 506 507 return matchingSubnetIDs.SortedValues(), nil 508 } 509 510 func isVPCIDSetButInvalid(vpcID string) bool { 511 return isVPCIDSet(vpcID) && !strings.HasPrefix(vpcID, "vpc-") 512 } 513 514 func isVPCIDSet(vpcID string) bool { 515 return vpcID != "" && vpcID != vpcIDNone 516 } 517 518 func validateBootstrapVPC(apiClient vpcAPIClient, cloudCtx context.ProviderCallContext, region, vpcID string, forceVPCID bool, ctx environs.BootstrapContext) error { 519 if vpcID == vpcIDNone { 520 ctx.Infof("Using EC2-classic features or default VPC in region %q", region) 521 } 522 if !isVPCIDSet(vpcID) { 523 return nil 524 } 525 526 err := validateVPC(apiClient, cloudCtx, vpcID) 527 switch { 528 case isVPCNotUsableError(err): 529 // VPC missing or has no subnets at all. 530 return errors.Annotate(err, vpcNotUsableForBootstrapErrorPrefix) 531 case isVPCNotRecommendedError(err): 532 // VPC does not meet minimum validation criteria. 533 if !forceVPCID { 534 return errors.Annotatef(err, vpcNotRecommendedErrorPrefix, vpcID) 535 } 536 ctx.Infof(vpcNotRecommendedButForcedWarning) 537 case err != nil: 538 // Anything else unexpected while validating the VPC. 539 return errors.Annotate(maybeConvertCredentialError(err, cloudCtx), cannotValidateVPCErrorPrefix) 540 } 541 542 ctx.Infof("Using VPC %q in region %q", vpcID, region) 543 544 return nil 545 } 546 547 func validateModelVPC(apiClient vpcAPIClient, ctx context.ProviderCallContext, modelName, vpcID string) error { 548 if !isVPCIDSet(vpcID) { 549 return nil 550 } 551 552 err := validateVPC(apiClient, ctx, vpcID) 553 switch { 554 case isVPCNotUsableError(err): 555 // VPC missing or has no subnets at all. 556 return errors.Annotate(err, vpcNotUsableForModelErrorPrefix) 557 case isVPCNotRecommendedError(err): 558 // VPC does not meet minimum validation criteria, but that's less 559 // important for hosted models, as the controller is already accessible. 560 logger.Infof( 561 "Juju will use, but does not recommend using VPC %q: %v", 562 vpcID, err.Error(), 563 ) 564 case err != nil: 565 // Anything else unexpected while validating the VPC. 566 return errors.Annotate(maybeConvertCredentialError(err, ctx), cannotValidateVPCErrorPrefix) 567 } 568 logger.Infof("Using VPC %q for model %q", vpcID, modelName) 569 570 return nil 571 }