# Copyright:: Copyright (c) 2017 eGlobalTech, Inc., all rights reserved # # Licensed under the BSD-3 license (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License in the root of the project or at # # http://egt-labs.com/mu/LICENSE.html # # 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. module MU class Cloud class Google # Creation of Virtual Private Clouds and associated artifacts (routes, subnets, etc). class VPC < MU::Cloud::VPC attr_reader :cloud_desc_cache attr_reader :routes # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat def initialize(**args) super @subnets ||= [] @subnetcachesemaphore = Mutex.new loadSubnets if @cloud_id @mu_name ||= @config['scrub_mu_isms'] ? @config['name'] : @deploy.getResourceName(@config['name']) end # Called automatically by {MU::Deploy#createResources} def create networkobj = MU::Cloud::Google.compute(:Network).new( name: MU::Cloud::Google.nameStr(@mu_name), description: @deploy.deploy_id, auto_create_subnetworks: false ) MU.log "Creating network #{@mu_name} (#{@config['ip_block']}) in project #{@project_id}", details: networkobj resp = MU::Cloud::Google.compute(credentials: @config['credentials']).insert_network(@project_id, networkobj) @url = resp.self_link @cloud_id = resp.name if @config['subnets'] subnetthreads = [] parent_thread_id = Thread.current.object_id @config['subnets'].each { |subnet| subnetthreads << Thread.new { MU.dupGlobals(parent_thread_id) subnet_name = @config['name']+subnet['name'] subnet_mu_name = @config['scrub_mu_isms'] ? @cloud_id+subnet_name.downcase : MU::Cloud::Google.nameStr(@deploy.getResourceName(subnet_name, max_length: 61)) MU.log "Creating subnetwork #{subnet_mu_name} (#{subnet['ip_block']}) in project #{@project_id} region #{subnet['availability_zone']}", details: subnet subnetobj = MU::Cloud::Google.compute(:Subnetwork).new( name: subnet_mu_name, description: @deploy.deploy_id, ip_cidr_range: subnet['ip_block'], network: @url, region: subnet['availability_zone'] ) MU::Cloud::Google.compute(credentials: @config['credentials']).insert_subnetwork(@project_id, subnet['availability_zone'], subnetobj) # make sure the subnet we created exists, before moving on subnetdesc = nil begin subnetdesc = MU::Cloud::Google.compute(credentials: @config['credentials']).get_subnetwork(@project_id, subnet['availability_zone'], subnet_mu_name) if !subnetdesc.nil? subnet_cfg = {} subnet_cfg["ip_block"] = subnet['ip_block'] subnet_cfg["name"] = subnet_name subnet_cfg['mu_name'] = subnet_mu_name subnet_cfg["cloud_id"] = subnetdesc.self_link.gsub(/.*?\/([^\/]+)$/, '\1') subnet_cfg['az'] = subnet['availability_zone'] @subnets << MU::Cloud::Google::VPC::Subnet.new(self, subnet_cfg, precache_description: false) end sleep 1 end while subnetdesc.nil? } } subnetthreads.each do |t| t.join end end if !@config['route_tables'].nil? @config['route_tables'].each { |rtb| rtb['routes'].each { |route| # GCP does these for us, by default next if route['destination_network'] == "0.0.0.0/0" and route['gateway'] == "#INTERNET" # sibling NAT host routes will get set up our groom phrase next if route['gateway'] == "#NAT" and !route['nat_host_name'].nil? createRoute(route, network: @url) } } end end # Configure IP traffic logging on a given VPC/Subnet. Logs are saved in cloudwatch based on the network interface ID of each instance. # @param log_group_name [String]: The name of the CloudWatch log group all logs will be saved in. # @param resource_id [String]: The cloud provider's identifier of the resource that traffic logging will be enabled on. # @param resource_type [String]: What resource type to enable logging on (VPC or Subnet). # @param traffic_type [String]: What traffic to log (ALL, ACCEPT or REJECT). def trafficLogging(log_group_name: nil, resource_id: nil, resource_type: "VPC", traffic_type: "ALL") end # Describe this VPC # @return [Hash] def notify base = MU.structToHash(cloud_desc, stringify_keys: true) base["cloud_id"] = @cloud_id base["project_id"] = habitat_id base.merge!(@config.to_h) if @subnets base["subnets"] = @subnets.map { |s| s.notify } end base end # Describe this VPC from the cloud platform's perspective # @return [Google::Apis::Core::Hashable] def cloud_desc(use_cache: true) if @cloud_desc_cache and use_cache return @cloud_desc_cache end resp = MU::Cloud::Google.compute(credentials: @config['credentials']).get_network(@project_id, @cloud_id) if @cloud_id.nil? or @cloud_id == "" or resp.nil? MU.log "Couldn't describe #{self}, @cloud_id #{@cloud_id.nil? ? "undefined" : "empty" }", MU::ERR return nil end @cloud_desc_cache = resp # populate other parts and pieces of ourself @url ||= resp.self_link routes = MU::Cloud::Google.compute(credentials: @config['credentials']).list_routes( @project_id, filter: "network = \"#{@url}\"" ).items @routes = routes if routes and routes.size > 0 @cloud_desc_cache end # Called automatically by {MU::Deploy#createResources} def groom rtb = @config['route_tables'].first # there's only ever one rtb['routes'].each { |route| # If we had a sibling server being spun up as a NAT, rig up the # route that the hosts behind it will need. if route['gateway'] == "#NAT" and !route['nat_host_name'].nil? createRoute(route, network: @url) end } if !@config['peers'].nil? count = 0 @config['peers'].each { |peer| if peer['vpc']['name'] peer_obj = @deploy.findLitterMate(name: peer['vpc']['name'], type: "vpcs", habitat: peer['vpc']['project']) else tag_key, tag_value = peer['vpc']['tag'].split(/=/, 2) if !peer['vpc']['tag'].nil? if peer['vpc']['deploy_id'].nil? and peer['vpc']['id'].nil? and tag_key.nil? peer['vpc']['deploy_id'] = @deploy.deploy_id end peer_obj = MU::MommaCat.findStray( "Google", "vpcs", deploy_id: peer['vpc']['deploy_id'], cloud_id: peer['vpc']['id'], name: peer['vpc']['name'], # XXX project flag tho tag_key: tag_key, tag_value: tag_value, dummy_ok: true ).first end if peer_obj.nil? MU.log "Failed VPC peer lookup on behalf of #{@cloud_id}", MU::WARN, details: peer pr = peer['vpc']['project'] || @project_id MU.log "all the VPCs I can see", MU::WARN, details: MU::Cloud::Google.compute(credentials: @config['credentials']).list_networks(pr) end raise MuError, "No result looking for #{@mu_name}'s peer VPCs (#{peer['vpc']})" if peer_obj.nil? url = if peer_obj.cloudobj.url peer_obj.cloudobj.url elsif peer_obj.cloudobj.deploydata peer_obj.cloudobj.deploydata['self_link'] else raise MuError, "Can't find the damn URL of my damn peer VPC #{peer['vpc']}" end cnxn_name = MU::Cloud::Google.nameStr(@mu_name+"-peer-"+count.to_s) peerreq = MU::Cloud::Google.compute(:NetworksAddPeeringRequest).new( name: cnxn_name, auto_create_routes: true, peer_network: url ) begin MU.log "Peering #{@cloud_id} with #{peer_obj.cloudobj.cloud_id}, connection name is #{cnxn_name}", details: peerreq MU::Cloud::Google.compute(credentials: @config['credentials']).add_network_peering( @project_id, @cloud_id, peerreq ) rescue ::Google::Apis::ClientError => e if e.message.match(/operation in progress on the local or peer network/) MU.log e.message, MU::DEBUG, details: peerreq sleep 10 retry end end count += 1 } end loadSubnets(use_cache: false) end # Locate and return cloud provider descriptors of this resource type # which match the provided parameters, or all visible resources if no # filters are specified. At minimum, implementations of +find+ must # honor +credentials+ and +cloud_id+ arguments. We may optionally # support other search methods, such as +tag_key+ and +tag_value+, or # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat # @return [Hash]: The cloud provider's complete descriptions of matching resources def self.find(**args) args = MU::Cloud::Google.findLocationArgs(args) resp = {} if args[:cloud_id] and args[:project] begin vpc = MU::Cloud::Google.compute(credentials: args[:credentials]).get_network( args[:project], args[:cloud_id].to_s.sub(/^.*?\/([^\/]+)$/, '\1') ) resp[args[:cloud_id]] = vpc if !vpc.nil? rescue ::Google::Apis::ClientError MU.log "VPC #{args[:cloud_id]} in project #{args[:project]} does not exist, or I do not have permission to view it", MU::WARN end else # XXX other criteria vpcs = begin MU::Cloud::Google.compute(credentials: args[:credentials]).list_networks( args[:project] ) rescue ::Google::Apis::ClientError => e raise e if !e.message.match(/^(?:notFound|forbidden): /) end if vpcs and vpcs.items vpcs.items.each { |v| resp[v.name] = v } end end resp end # Return an array of MU::Cloud::Google::VPC::Subnet objects describe the # member subnets of this VPC. # # @return [Array] def subnets if @subnets.nil? or @subnets.size == 0 return loadSubnets end return @subnets end # Describe subnets associated with this VPC. We'll compose identifying # information similar to what MU::Cloud.describe builds for first-class # resources. # @param use_cache [Boolean]: If available, use saved deployment metadata to describe subnets, instead of querying the cloud API # @return [Array]: A list of cloud provider identifiers of subnets associated with this VPC. def loadSubnets(use_cache: true) @subnetcachesemaphore.synchronize { return @subnets if use_cache and @subnets and @subnets.size > 0 } network = cloud_desc if network.nil? MU.log "Unabled to load cloud description in #{self}", MU::ERR return nil end found = [] if @deploy and @deploy.deployment and @deploy.deployment["vpcs"] and @deploy.deployment["vpcs"][@config['name']] and @deploy.deployment["vpcs"][@config['name']]["subnets"] and @deploy.deployment["vpcs"][@config['name']]["subnets"].size > 0 @deploy.deployment["vpcs"][@config['name']]["subnets"].each { |desc| subnet = desc.clone subnet['mu_name'] = @config['scrub_mu_isms'] ? @cloud_id+subnet['name'].downcase : MU::Cloud::Google.nameStr(@deploy.getResourceName(subnet['name'], max_length: 61)) subnet["cloud_id"] ||= desc['self_link'].gsub(/.*?\/([^\/]+)$/, '\1') subnet["cloud_id"] ||= subnet['mu_name'] subnet['az'] ||= desc["region"].gsub(/.*?\/([^\/]+)$/, '\1') @subnets << MU::Cloud::Google::VPC::Subnet.new(self, subnet, precache_description: false) } else resp = MU::Cloud::Google.compute(credentials: @config['credentials']).list_subnetwork_usable( @project_id, filter: "network eq #{network.self_link}" ) resp.items.each { |subnet| found << subnet } @subnetcachesemaphore.synchronize { @subnets ||= [] ext_ids = @subnets.each.collect { |s| s.cloud_id } # If we're a plain old Mu resource, load our config and deployment # metadata. Like ya do. if !@config.nil? and @config.has_key?("subnets") @config['subnets'].each { |subnet| # subnet['mu_name'] = @mu_name+"-"+subnet['name'] if !subnet.has_key?("mu_name") subnet_name = @config['name']+subnet['name'] subnet['mu_name'] ||= @config['scrub_mu_isms'] ? @cloud_id+subnet_name.downcase : MU::Cloud::Google.nameStr(@deploy.getResourceName(subnet_name, max_length: 61)) subnet['region'] = @config['region'] found.each { |desc| if desc.ip_cidr_range == subnet["ip_block"] desc.subnetwork.match(/\/projects\/[^\/]+\/regions\/([^\/]+)\/subnetworks\/(.+)$/) subnet['az'] = Regexp.last_match[1] subnet['name'] ||= Regexp.last_match[2] subnet["cloud_id"] = subnet['mu_name'] subnet["url"] = desc.subnetwork break end } if !ext_ids.include?(subnet["cloud_id"]) @subnets << MU::Cloud::Google::VPC::Subnet.new(self, subnet, precache_description: false) end } # Of course we might be loading up a dummy subnet object from a # foreign or non-Mu-created VPC and subnet. So make something up. elsif !found.nil? found.each { |desc| subnet = {} desc.subnetwork.match(/\/projects\/[^\/]+\/regions\/([^\/]+)\/subnetworks\/(.+)$/) subnet['az'] = Regexp.last_match[1] subnet['name'] = Regexp.last_match[2] subnet["cloud_id"] = subnet['name'] subnet["ip_block"] = desc.ip_cidr_range subnet["url"] = desc.subnetwork subnet['mu_name'] = @mu_name+"-"+subnet['name'] if !ext_ids.include?(subnet["cloud_id"]) @subnets << MU::Cloud::Google::VPC::Subnet.new(self, subnet, precache_description: false) end } end } end # The API is filled with lies @subnets.reject! { |s| !MU::Cloud::Google.listRegions(credentials: @credentials).include?(s.az) } return @subnets end # Given some search criteria try locating a NAT Gaateway in this VPC. # @param nat_cloud_id [String]: The cloud provider's identifier for this NAT. # @param nat_filter_key [String]: A cloud provider filter to help identify the resource, used in conjunction with nat_filter_value. # @param nat_filter_value [String]: A cloud provider filter to help identify the resource, used in conjunction with nat_filter_key. # @param region [String]: The cloud provider region of the target instance. def findNat(nat_cloud_id: nil, nat_filter_key: nil, nat_filter_value: nil, region: MU.curRegion) end # Given some search criteria for a {MU::Cloud::Server}, see if we can # locate a NAT host in this VPC. # @param nat_name [String]: The name of the resource as defined in its 'name' Basket of Kittens field, typically used in conjunction with deploy_id. # @param nat_cloud_id [String]: The cloud provider's identifier for this NAT. # @param nat_tag_key [String]: A cloud provider tag to help identify the resource, used in conjunction with tag_value. # @param nat_tag_value [String]: A cloud provider tag to help identify the resource, used in conjunction with tag_key. # @param nat_ip [String]: An IP address associated with the NAT instance. def findBastion(nat_name: nil, nat_cloud_id: nil, nat_tag_key: nil, nat_tag_value: nil, nat_ip: nil) if nat_name svr_obj = @deploy.findLitterMate(name: nat_name, type: "servers") return svr_obj if svr_obj end deploy_id = nil nat_name = nat_name.to_s if !nat_name.nil? and nat_name.class.to_s == "MU::Config::Tail" nat_ip = nat_ip.to_s if !nat_ip.nil? and nat_ip.class.to_s == "MU::Config::Tail" nat_cloud_id = nat_cloud_id.to_s if !nat_cloud_id.nil? and nat_cloud_id.class.to_s == "MU::Config::Tail" nat_tag_key = nat_tag_key.to_s if !nat_tag_key.nil? and nat_tag_key.class.to_s == "MU::Config::Tail" nat_tag_value = nat_tag_value.to_s if !nat_tag_value.nil? and nat_tag_value.class.to_s == "MU::Config::Tail" # If we're searching by name, assume it's part of this here deploy. if nat_cloud_id.nil? and !@deploy.nil? deploy_id = @deploy.deploy_id end found = MU::MommaCat.findStray( "Google", "server", name: nat_name, cloud_id: nat_cloud_id, deploy_id: deploy_id, tag_key: nat_tag_key, tag_value: nat_tag_value, allow_multi: true, dummy_ok: true, calling_deploy: @deploy ) return nil if found.nil? || found.empty? if found.size == 1 return found.first elsif found.size > 1 found.each { |nat| next if !nat.cloud_desc # Try some cloud-specific criteria nat.cloud_desc.network_interfaces.each { |iface| if !nat_ip.nil? return nat if iface.network_ip == nat_ip if iface.access_configs iface.access_configs.each { |public_iface| return if public_iface.nat_ip == nat_ip } end end if iface.network == @url # XXX Strictly speaking we could have different NATs in # different subnets, so this can be wrong in corner cases. return nat end } } end return nil end # Check for a subnet in this VPC matching one or more of the specified # criteria, and return it if found. def getSubnet(cloud_id: nil, name: nil, tag_key: nil, tag_value: nil, ip_block: nil, region: nil, subnet_mu_name: nil) if !cloud_id.nil? and cloud_id.match(/^https:\/\//) cloud_id.match(/\/regions\/([^\/]+)\/subnetworks\/([^\/]+)$/) region = Regexp.last_match[1] cloud_id = Regexp.last_match[2] cloud_id.gsub!(/.*?\//, "") end if name subnet_mu_name ||= (@config['scrub_mu_isms'] or !@deploy) ? @cloud_id+name.downcase : MU::Cloud::Google.nameStr(@deploy.getResourceName(name, max_length: 61)) end MU.log "getSubnet(cloud_id: #{cloud_id}, name: #{name}, tag_key: #{tag_key}, tag_value: #{tag_value}, ip_block: #{ip_block}, region: #{region}, subnet_mu_name: #{subnet_mu_name})", MU::DEBUG, details: caller[0] subnets.each { |subnet| next if region and subnet.az != region if !cloud_id.nil? and !subnet.cloud_id.nil? and subnet.cloud_id.to_s == cloud_id.to_s return subnet elsif !name.nil? and !subnet.name.nil? and subnet.name.downcase.to_s == name.downcase.to_s return subnet elsif !subnet_mu_name.nil? and !subnet.name.nil? and subnet.name.downcase.to_s == subnet_mu_name.downcase.to_s return subnet end } return nil end # Get the subnets associated with an instance. # @param instance_id [String]: The cloud identifier of the instance # @param instance [String]: A cloud descriptor for the instance, to save us an API call if we already have it # @param region [String]: The cloud provider region of the target instance # @return [Array] def self.getInstanceSubnets(instance_id: nil, instance: nil, region: MU.curRegion) end @route_cache = {} @rtb_cache = {} @rtb_cache_semaphore = Mutex.new # Check whether we (the Mu Master) have a direct route to a particular # instance. Useful for skipping hops through bastion hosts to get # directly at child nodes in peered VPCs, the public internet, and the # like. # @param target_instance [OpenStruct]: The cloud descriptor of the instance to check. # @return [Boolean] def self.haveRouteToInstance?(target_instance, credentials: nil) project ||= MU::Cloud::Google.defaultProject(credentials) return false if MU.myCloud != "Google" # XXX see if we reside in the same Network and overlap subnets # XXX see if we peer with the target's Network target_instance.network_interfaces.each { |iface| resp = MU::Cloud::Google.compute(credentials: credentials).list_routes( project, filter: "network eq #{iface.network}" ) if resp and resp.items MU.log "ROUTES TO #{target_instance.name}", MU::WARN, details: resp end } false end # updates the route table cache (@rtb_cache). # @param subnet_key [String]: The subnet/subnets route tables will be extracted from. # @param use_cache [Boolean]: If to use the existing cache and add records to cache only if missing, or to also replace exising records in cache. # @param region [String]: The cloud provider region of the target subnet. def self.update_route_tables_cache(subnet_key, use_cache: true, region: MU.curRegion) end # Checks if the MU master has a route to a subnet in a peered VPC. Can be used on any subnets # @param source_subnets_key [String]: The subnet/subnets on one side of the peered VPC. # @param target_subnets_key [String]: The subnet/subnets on the other side of the peered VPC. # @param instance_id [String]: The instance ID in the target subnet/subnets. # @return [Boolean] def self.can_route_to_master_peer?(source_subnets_key, target_subnets_key, instance_id) end # Retrieves the route tables of used by subnets # @param subnet_ids [Array]: The cloud identifier of the subnets to retrieve the route tables for. # @param vpc_ids [Array]: The cloud identifier of the VPCs to retrieve route tables for. # @param region [String]: The cloud provider region of the target subnet. # @return [Array]: The cloud provider's complete descriptions of the route tables def self.get_route_tables(subnet_ids: [], vpc_ids: [], region: MU.curRegion) end # Does this resource type exist as a global (cloud-wide) artifact, or # is it localized to a region/zone? # @return [Boolean] def self.isGlobal? true end # Denote whether this resource implementation is experiment, ready for # testing, or ready for production use. def self.quality MU::Cloud::RELEASE end # Remove all VPC resources associated with the currently loaded deployment. # @param noop [Boolean]: If true, will only print what would be done # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server # @return [void] def self.cleanup(noop: false, deploy_id: MU.deploy_id, ignoremaster: false, credentials: nil, flags: {}) flags["habitat"] ||= MU::Cloud::Google.defaultProject(credentials) return if !MU::Cloud.resourceClass("Google", "Habitat").isLive?(flags["habitat"], credentials) filter = %Q{(labels.mu-id = "#{MU.deploy_id.downcase}")} if !ignoremaster and MU.mu_public_ip filter += %Q{ AND (labels.mu-master-ip = "#{MU.mu_public_ip.gsub(/\./, "_")}")} end MU.log "Placeholder: Google VPC artifacts do not support labels, so ignoremaster cleanup flag has no effect", MU::DEBUG, details: filter purge_subnets(noop, project: flags['habitat'], credentials: credentials) ["route", "network"].each { |type| # XXX tagged routes aren't showing up in list, and the networks that own them # fail to delete silently retries = 0 begin MU::Cloud::Google.compute(credentials: credentials).delete( type, flags["habitat"], nil, noop ) rescue MU::MuError, ::Google::Apis::ClientError => e if retries < 5 if type == "network" MU.log e.message, MU::WARN if e.message.match(/Failed to delete network (.+)/) network_name = Regexp.last_match[1] fwrules = MU::Cloud.resourceClass("Google", "FirewallRule").find(project: flags['habitat'], credentials: credentials) fwrules.reject! { |_name, desc| !desc.network.match(/.*?\/#{Regexp.quote(network_name)}$/) } fwrules.keys.each { |name| MU.log "Attempting to delete firewall rule #{name} so that VPC #{network_name} can be removed", MU::NOTICE MU::Cloud::Google.compute(credentials: credentials).delete_firewall(flags['habitat'], name) } end end sleep retries*3 retries += 1 retry else raise e end end } end # Reverse-map our cloud description into a runnable config hash. # We assume that any values we have in +@config+ are placeholders, and # calculate our own accordingly based on what's live in the cloud. # XXX add flag to return the diff between @config and live cloud def toKitten(**_args) return nil if cloud_desc.name == "default" # parent project builds these bok = { "cloud" => "Google", "project" => @config['project'], "credentials" => @config['credentials'] } MU::Cloud::Google.listRegions.size _schema, valid = MU::Config.loadResourceSchema("VPC", cloud: "Google") return [nil, nil] if !valid # pp schema # MU.log "++++++++++++++++++++++++++++++++" bok['name'] = cloud_desc.name.dup bok['cloud_id'] = cloud_desc.name.dup bok['create_standard_subnets'] = false if @subnets and @subnets.size > 0 bok['subnets'] = [] regions_seen = [] names_seen = [] @subnets.reject! { |x| x.cloud_desc.nil? } @subnets.map { |x| x.cloud_desc }.each { |s| subnet_name = s.name.dup names_seen << s.name.dup regions_seen << s.region bok['subnets'] << { "name" => subnet_name, "ip_block" => s.ip_cidr_range } } # If all of the subnets are named 'default' and there's one per # region, we're using GCP-generated subnets instead of explicitly # declared ones. if names_seen.uniq.size == 1 and names_seen.first == "default" and regions_seen.uniq.size == regions_seen.size and regions_seen.size >= (MU::Cloud::Google.listRegions.size * 0.8) bok.delete("subnets") bok['auto_create_subnetworks'] = true end end if cloud_desc.peerings and cloud_desc.peerings.size > 0 bok['peers'] = [] cloud_desc.peerings.each { |peer| peer.network.match(/projects\/([^\/]+?)\/[^\/]+?\/networks\/([^\/]+)$/) vpc_project = Regexp.last_match[1] vpc_name = Regexp.last_match[2] vpc_id = vpc_name.dup # Make sure the peer is something we have permission to look at peer_descs = MU::Cloud::Google::VPC.find(cloud_id: vpc_id, project: vpc_project) if peer_descs.nil? or peer_descs.empty? MU.log "VPC #{@cloud_id} peer #{vpc_id} #{vpc_project} is not accessible, will remove from peer list", MU::WARN next end # XXX need to decide which of these parameters to use based on whether the peer is also in the mix of things being harvested, which is above this method's pay grade bok['peers'] << { "vpc" => MU::Config::Ref.get( id: vpc_id, name: vpc_name, cloud: "Google", habitat: MU::Config::Ref.get( id: vpc_project, cloud: "Google", credentials: @credentials, type: "habitats" ), credentials: @config['credentials'], type: "vpcs" ) } } end # XXX need to grok VPN tunnels, priorities, and maybe preserve descriptions; make sure we know where next_hop_gateway and next_hop_ip come from if @routes routes = [] @routes.each { |r| next if r.next_hop_peering # these are auto-created route = { "destination_network" => r.dest_range } if r.next_hop_instance route["nat_host_id"] = r.next_hop_instance end } if routes.size > 0 bok['route_tables'] = [ { "name" => "default", "routes" => routes } ] end end # XXX validate that we've at least touched every required attribute (maybe upstream?) bok end # Cloud-specific configuration properties. # @param _config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource def self.schema(_config = nil) toplevel_required = [] schema = { "regions" => { "type" => "array", "items" => MU::Config.region_primitive }, "project" => { "type" => "string", "description" => "The project into which to deploy resources. This is shorthand for a +habitat+ key with a +name+ or +id+ set. The config parser will attempt to correctly resolve this." }, "auto_create_subnetworks" => { "type" => "boolean", "default" => false, "description" => "Sets the +auto_create_subnetworks+ flag, which causes Google to generate a set of generic subnets, one per region. This effectively overrides Mu's +create_standard_subnets+ and any explicitly defined +subnets+." } } [toplevel_required, schema] end # If the VPC a config block was set to one that's been "split," try to # figure out which of the new VPCs we really want to be in. For use by # resource types that don't go in subnets, but do tie to VPCs. # @param vpc_block [Hash] # @param configurator [MU::Config] # @return [Hash] def self.pickVPC(vpc_block, my_config, my_type, configurator) _shortclass, cfg_name, cfg_plural, _classname = MU::Cloud.getResourceNames(my_type) return if vpc_block.nil? vpc_block['name'] ||= vpc_block['vpc_name'] return if !vpc_block['name'] vpcs = configurator.haveLitterMate?( nil, "vpcs", has_multiple: true ) # drop all virtual vpcs that aren't real anymore vpcs.reject! { |v| v['virtual_name'] == v['name'] } # drop the ones that have nothing to do with us vpcs.reject! { |v| v['virtual_name'] != vpc_block['name'] } return vpc_block if vpcs.size == 0 # see if one of this thing's siblings declared a subnet_pref we can # use to guess which one we should marry ourselves to configurator.kittens.values.each { |siblings| siblings.each { |sibling| next if !sibling['dependencies'] sibling['dependencies'].each { |dep| if [cfg_name, cfg_plural].include?(dep['type']) and dep['name'] == my_config['name'] vpcs.each { |v| if sibling['vpc']['name'] == v['name'] vpc_block['name'] = v['name'] return vpc_block end } if sibling['vpc']['subnet_pref'] vpcs.each { |v| gateways = v['route_tables'].map { |rtb| rtb['routes'].map { |r| r["gateway"] } }.flatten.uniq if ["public", "all_public"].include?(sibling['vpc']['subnet_pref']) and gateways.include?("#INTERNET") vpc_block['name'] = v['name'] return vpc_block elsif ["private", "all_private"].include?(sibling['vpc']['subnet_pref']) and !gateways.include?("#INTERNET") vpc_block['name'] = v['name'] return vpc_block end } end end } } } vpc_block end # Cloud-specific pre-processing of {MU::Config::BasketofKittens::vpcs}, bare and unvalidated. # @param vpc [Hash]: The resource to process and validate # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member # @return [Boolean]: True if validation succeeded, False otherwise def self.validateConfig(vpc, configurator) ok = true vpc['project'] ||= MU::Cloud::Google.defaultProject(vpc['credentials']) if vpc["project"] and !vpc["habitat"] vpc["habitat"] = MU::Cloud::Google.projectToRef(vpc["project"], config: configurator, credentials: vpc["credentials"]) end # Generate a set of subnets per route, if none are declared if !vpc['subnets'] or vpc['subnets'].empty? if vpc['regions'].nil? or vpc['regions'].empty? vpc['regions'] = MU::Cloud::Google.listRegions(vpc['us_only']) end blocks = configurator.divideNetwork(vpc['ip_block'], vpc['regions'].size*vpc['route_tables'].size, 29) ok = false if blocks.nil? vpc["subnets"] = [] vpc['route_tables'].each { |t| is_public = false t['routes'].each { |r| if !vpc["virtual_name"] and !vpc["create_nat_gateway"] and !vpc['bastion'] and r["gateway"] == "#NAT" r["gateway"] = "#DENY" end is_public = true if r["gateway"] == "#INTERNET" } count = 0 vpc['regions'].each { |r| block = blocks.shift subnet = { "availability_zone" => r, "route_table" => t["name"], "ip_block" => block.to_s, "name" => "Subnet"+count.to_s+t["name"].capitalize } if is_public subnet["map_public_ips"] = true subnet["is_public"] = true end vpc["subnets"] << subnet count = count + 1 } } end vpc['subnets'].each { |s| if !s['availability_zone'] s['availability_zone'] = vpc['region'] s['availability_zone'] ||= MU::Cloud::Google.myRegion(vpc['credentials']) end } # Google VPCs can't have routes that are anything other than global # (they can be tied to individual instances by tags, but w/e). So we # decompose our VPCs into littler VPCs, one for each declared route # table, so that the routes therein will only apply to the portion of # our network we want them to. if vpc['route_tables'].size > 1 blocks = configurator.divideNetwork(vpc['ip_block'], vpc['route_tables'].size*2, 29) peernames = [] vpc['route_tables'].each { |tbl| peernames << vpc['name']+"-"+tbl['name'] } vpc['route_tables'].each { |tbl| newvpc = { "name" => vpc['name']+"-"+tbl['name'], "cloud" => "Google", "credentials" => vpc['credentials'], "virtual_name" => vpc['name'], "ip_block" => blocks.shift, "route_tables" => [tbl], "parent_block" => vpc['ip_block'], "subnets" => [], "peers" => vpc['peers'] } MU.log "Splitting VPC #{newvpc['name']} off from #{vpc['name']}", MU::NOTICE vpc.each_pair { |key, val| next if ["name", "route_tables", "subnets", "ip_block"].include?(key) newvpc[key] = val } if vpc["bastion"] and !tbl["routes"].map { |r| r["gateway"] }.include?("#INTERNET") newvpc["bastion"] = vpc["bastion"] vpc.delete("bastion") end newvpc['peers'] ||= [] # Add the peer connections we're generating, in addition peernames.each { |peer| if peer != newvpc['name'] newvpc['peers'] << { "vpc" => { "vpc_name" => peer } } end } newvpc['peers'].reject! { |p| p.values.first['vpc_name'] == newvpc['name'] or p.values.first['vpc_name'] == vpc['name'] } vpc["subnets"].each { |subnet| newvpc["subnets"] << subnet if subnet["route_table"] == tbl["name"] } ok = false if !configurator.insertKitten(newvpc, "vpcs", true) } configurator.removeKitten(vpc['name'], "vpcs") else has_nat = vpc['route_tables'].first["routes"].include?({"gateway"=>"#NAT", "destination_network"=>"0.0.0.0/0"}) has_deny = vpc['route_tables'].first["routes"].include?({"gateway"=>"#DENY", "destination_network"=>"0.0.0.0/0"}) # XXX we need routes to peered Networks too if has_nat or has_deny ok = false if !genStandardSubnetACLs(vpc['parent_block'] || vpc['ip_block'], vpc['name'], configurator, vpc["project"], false, credentials: vpc['credentials']) else ok = false if !genStandardSubnetACLs(vpc['parent_block'] || vpc['ip_block'], vpc['name'], configurator, vpc["project"], credentials: vpc['credentials']) end if has_nat and !has_deny and !vpc['bastion'] vpc['route_tables'].first["routes"] << { "gateway"=>"#DENY", "destination_network"=>"0.0.0.0/0" } end # You know what, let's just guarantee that we'll have a route from # this master, always # XXX this confuses machines that don't have public IPs if !vpc['scrub_mu_isms'] # vpc['route_tables'].first["routes"] << { # 'gateway' => "#INTERNET", # 'destination_network' => MU.mu_public_ip+"/32" # } end vpc['route_tables'].first["routes"].each { |route| # No such thing as a NAT gateway in Google... so make an instance # that'll do the deed. if route['gateway'] == "#NAT" # theoretically our upstream validation should have inserted # a NAT/bastion host we can use nat = if vpc['virtual_name'] configurator.haveLitterMate?(vpc['virtual_name']+"-natstion", "servers") else configurator.haveLitterMate?(vpc['name']+"-natstion", "servers") end if !nat MU.log "Google VPC #{vpc['name']} declared a #NAT route, but I don't see an upstream NAT host I can use. Do I even have public subnets?", MU::ERR ok = false else route['nat_host_name'] = nat['name'] route['priority'] = 100 MU::Config.addDependency(vpc, nat['name'], "server", their_phase: "groom", my_phase: "groom") vpc["bastion"] = MU::Config::Ref.get( name: nat['name'], cloud: vpc['cloud'], credentials: vpc['credentials'], type: "servers" ) end end } end # MU.log "GOOGLE VPC", MU::WARN, details: vpc ok end # @param route [Hash]: A route description, per the Basket of Kittens schema # @param server [MU::Cloud::Google::Server]: Instance to which this route will apply def createRouteForInstance(route, server) createRoute(route, network: @url, tags: [MU::Cloud::Google.nameStr(server.mu_name)]) end # Looks at existing subnets, and attempts to find the next available # IP block that's roughly similar to the ones we already have. This # checks against secondary IP ranges, as well as each subnet's primary # CIDR block. # @param exclude [Array]: One or more CIDRs to treat as unavailable, in addition to those allocated to existing subnets # @return [String] def getUnusedAddressBlock(exclude: [], max_bits: 28) used_ranges = exclude.map { |cidr| NetAddr::IPv4Net.parse(cidr) } used_ranges.concat(listSubnetRanges) # XXX sort used_ranges candidate = used_ranges.first.next_sib begin if candidate.netmask.prefix_len > max_bits candidate = candidate.resize(max_bits) end try_again = false used_ranges.each { |cidr| if !cidr.rel(candidate).nil? candidate = candidate.next_sib try_again = true break end } try_again = false if candidate.nil? end while try_again candidate.to_s end # Add a new secondary IP range to the given subnet, if it doesn't # already exist def addSecondaryRange(subnet, cidr, name) subnet = getSubnet(cloud_id: subnet, name: subnet, subnet_mu_name: subnet) if !subnet raise MuError, "#{self.to_s} failed to locate a subnet from '#{subnet}'" end secondary_ranges = subnet.cloud_desc.secondary_ip_ranges secondary_ranges ||= [] secondary_ranges.each { |r| if r.ip_cidr_range == cidr and r.range_name == name return elsif r.ip_cidr_range == cidr or r.range_name == name MU.log "Conflicting secondary IP range, cannot add #{name} (#{cidr}) to network #{cloud_desc.name} subnet #{subnet.cloud_desc.name}", MU::WARN, details: r return end } secondary_ranges << MU::Cloud::Google.compute(:SubnetworkSecondaryRange).new( ip_cidr_range: cidr, range_name: name ) MU.log "Adding new secondary IP range #{name} (#{cidr}) to network #{cloud_desc.name} subnet #{subnet.cloud_desc.name}" subnetobj = MU::Cloud::Google.compute(:Subnetwork).new( name: subnet.cloud_desc.name, secondary_ip_ranges: secondary_ranges, fingerprint: subnet.cloud_desc.fingerprint ) MU::Cloud::Google.compute(credentials: @credentials).patch_subnetwork(@project_id, subnet.az, subnet.cloud_desc.name, subnetobj) end def connector(id: nil, name: nil) end private # @return [Array] def listSubnetRanges ranges = [] subnets.each { |s| ranges << NetAddr::IPv4Net.parse(s.cloud_desc.ip_cidr_range) if s.cloud_desc.secondary_ip_ranges ranges.concat(s.cloud_desc.secondary_ip_ranges.map { |r| NetAddr::IPv4Net.parse(r.ip_cidr_range) }) end } ranges end def self.genStandardSubnetACLs(vpc_cidr, vpc_name, configurator, project, _publicroute = true, credentials: nil) private_acl = { "name" => vpc_name+"-rt", "cloud" => "Google", "credentials" => credentials, "project" => project, "vpc" => { "vpc_name" => vpc_name }, "dependencies" => [ { "type" => "vpc", "name" => vpc_name } ], "rules" => [ { "ingress" => true, "proto" => "all", "hosts" => [vpc_cidr] } ] } # if publicroute # XXX distinguish between "I have a NAT" and "I really shouldn't be # able to talk to the world" private_acl["rules"] << { "egress" => true, "proto" => "all", "hosts" => ["0.0.0.0/0"] } # else # private_acl["rules"] << { # "egress" => true, "proto" => "all", "hosts" => [vpc_cidr], "weight" => 999 # } # private_acl["rules"] << { # "egress" => true, "proto" => "all", "hosts" => ["0.0.0.0/0"], "deny" => true # } # end configurator.insertKitten(private_acl, "firewall_rules", true) end private_class_method :genStandardSubnetACLs # Helper method for manufacturing routes. Expect to be called from # {MU::Cloud::Google::VPC#create} or {MU::Cloud::Google::VPC#groom}. # @param route [Hash]: A route description, per the Basket of Kittens schema # @param network [String]: Cloud identifier of the VPC to which we're adding this route # @param tags [Array]: Instance tags to which this route applies. If empty, applies to entire VPC. # @return [Hash]: The modified configuration that was originally passed in. def createRoute(route, network: @url, tags: []) routename = MU::Cloud::Google.nameStr(@mu_name+"-route-"+route['destination_network']) if !tags.nil? and tags.size > 0 routename = MU::Cloud::Google.nameStr(routename+"-"+tags.first).slice(0,63) end route["priority"] ||= 999 if route['gateway'] == "#NAT" if !route['nat_host_name'].nil? or !route['nat_host_id'].nil? sleep 5 nat_instance = findBastion( nat_name: route["nat_host_name"], nat_cloud_id: route["nat_host_id"] ) if nat_instance.nil? or nat_instance.cloud_desc.nil? raise MuError, "Failed to find NAT host for #NAT route in #{@mu_name} (#{route})" end routeobj = ::Google::Apis::ComputeV1::Route.new( name: routename, next_hop_instance: nat_instance.cloud_desc.self_link, dest_range: route['destination_network'], priority: route["priority"], description: @deploy.deploy_id, tags: tags, network: network ) end # several other cases missing for various types of routers (raw IPs, instance ids, etc) XXX elsif route['gateway'] == "#DENY" resp = MU::Cloud::Google.compute(credentials: @config['credentials']).list_routes( @project_id, filter: "network eq #{network}" ) if !resp.nil? and !resp.items.nil? resp.items.each { |r| next if r.next_hop_gateway.nil? or !r.next_hop_gateway.match(/\/global\/gateways\/default-internet-gateway$/) MU.log "Removing standard route #{r.name} per our #DENY entry" MU::Cloud::Google.compute(credentials: @config['credentials']).delete_route(@project_id, r.name) } end elsif route['gateway'] == "#INTERNET" routeobj = ::Google::Apis::ComputeV1::Route.new( name: routename, next_hop_gateway: "global/gateways/default-internet-gateway", dest_range: route['destination_network'], priority: route["priority"], description: @deploy.deploy_id, tags: tags, network: network ) end if route['gateway'] != "#DENY" and routeobj begin MU::Cloud::Google.compute(credentials: @config['credentials']).get_route(@project_id, routename) rescue ::Google::Apis::ClientError, MU::MuError => e if e.message.match(/notFound/) MU.log "Creating route #{routename} in project #{@project_id}", details: routeobj MU::Cloud::Google.compute(credentials: @config['credentials']).insert_route(@project_id, routeobj) else # TODO can't update GCP routes, would have to delete and re-create end end end end # Remove all subnets associated with the currently loaded deployment. # @param noop [Boolean]: If true, will only print what would be done # @param _tagfilters [Array]: Labels to filter against when search for resources to purge # @param regions [Array]: The cloud provider regions to check # @return [void] def self.purge_subnets(noop = false, _tagfilters = [{name: "tag:MU-ID", values: [MU.deploy_id]}], regions: MU::Cloud::Google.listRegions, project: nil, credentials: nil) project ||= MU::Cloud::Google.defaultProject(credentials) parent_thread_id = Thread.current.object_id regionthreads = [] regions.each { |r| regionthreads << Thread.new { MU.dupGlobals(parent_thread_id) begin MU::Cloud::Google.compute(credentials: credentials).delete( "subnetwork", project, r, noop ) rescue MU::Cloud::MuDefunctHabitat Thread.exit end } } regionthreads.each do |t| t.join end end private_class_method :purge_subnets # Subnets are almost a first-class resource. So let's kinda sorta treat # them like one. This should only be invoked on objects that already # exists in the cloud layer. class Subnet < MU::Cloud::Google::VPC attr_reader :cloud_id attr_reader :ip_block attr_reader :mu_name attr_reader :name attr_reader :cloud_desc_cache attr_reader :az # @param parent [MU::Cloud::Google::VPC]: The parent VPC of this subnet. # @param config [Hash]: def initialize(parent, config, precache_description: true) @parent = parent @config = MU::Config.manxify(config) @cloud_id = config['cloud_id'] @url = config['url'] @mu_name = config['mu_name'] @name = config['name'] @deploydata = config # This is a dummy for the sake of describe() @az = config['az'] @ip_block = config['ip_block'] @cloud_desc_cache = nil cloud_desc if precache_description end # Return the cloud identifier for the default route of this subnet. def defaultRoute end # Describe this VPC Subnet # @return [Hash] def notify MU.structToHash(cloud_desc, stringify_keys: true) end # Return the +self_link+ to this subnet def url cloud_desc if !@url @url end @cloud_desc_cache = nil # Describe this VPC Subnet from the cloud platform's perspective # @return [Google::Apis::Core::Hashable] def cloud_desc(use_cache: true) return @cloud_desc_cache if @cloud_desc_cache and use_cache begin @cloud_desc_cache = MU::Cloud::Google.compute(credentials: @parent.config['credentials']).get_subnetwork(@parent.habitat_id, @az, @cloud_id) rescue ::Google::Apis::ClientError => e if e.message.match(/notFound: /) MU.log "Failed to fetch cloud description for Google subnet #{@cloud_id}", MU::WARN, details: { "project" => @parent.habitat_id, "region" => @az, "name" => @cloud_id } return nil elsif e.message.match(/Unknown region\. /) MU.log "Google subnet #{@cloud_id} seems like it should live in #{@az}, but that's not a valid region", MU::WARN, details: { "project" => @parent.habitat_id, "region" => @az, "name" => @cloud_id } return nil else raise e end end @url ||= @cloud_desc_cache.self_link @cloud_desc_cache end # Is this subnet privately-routable only, or public? # @return [Boolean] def private? @parent.cloud_desc if !@parent.routes MU.log "Failed to retrieve routes from #{@parent.to_s}", MU::WARN return true end @parent.routes.map { |r| if r.dest_range == "0.0.0.0/0" and !r.next_hop_gateway.nil? and (r.tags.nil? or r.tags.size == 0) and r.next_hop_gateway.match(/\/global\/gateways\/default-internet-gateway/) return false end } return true end end end #class end #class end end #module