# 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. require 'net/ssh' require 'net/ssh/multi' require 'net/ssh/proxy/command' autoload :OpenStruct, "ostruct" autoload :Timeout, "timeout" autoload :ERB, "erb" autoload :Base64, "base64" require 'open-uri' module MU class Cloud class Google # A server as configured in {MU::Config::BasketofKittens::servers}. In # Google Cloud, this amounts to a single Instance in an Unmanaged # Instance Group. class Server < MU::Cloud::Server # 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 @userdata = if @config['userdata_script'] @config['userdata_script'] elsif @deploy and !@config['scrub_mu_isms'] MU::Cloud.fetchUserdata( platform: @config["platform"], cloud: "Google", credentials: @config['credentials'], template_variables: { "deployKey" => Base64.urlsafe_encode64(@deploy.public_key), "deploySSHKey" => @deploy.ssh_public_key, "muID" => MU.deploy_id, "muUser" => MU.mu_user, "publicIP" => MU.mu_public_ip, "skipApplyUpdates" => @config['skipinitialupdates'], "windowsAdminName" => @config['windows_admin_username'], "adminBucketName" => MU::Cloud::Google.adminBucketName(@credentials), "chefVersion" => MU.chefVersion, "mommaCatPort" => MU.mommaCatPort, "resourceName" => @config["name"], "resourceType" => "server", "platform" => @config["platform"] }, custom_append: @config['userdata_script'] ) end # XXX writing things into @config at runtime is a bad habit and we should stop if !@mu_name.nil? @config['mu_name'] = @mu_name # XXX whyyyy # describe @mu_windows_name = @deploydata['mu_windows_name'] if @mu_windows_name.nil? and @deploydata else if kitten_cfg.has_key?("basis") @mu_name = @deploy.getResourceName(@config['name'], need_unique_string: true) else @mu_name = @deploy.getResourceName(@config['name']) end @config['mu_name'] = @mu_name end @config['instance_secret'] ||= Password.random(50) @config['ssh_user'] ||= "muadmin" end # Return the date/time a machine image was created. # @param image_id [String]: URL to a Google disk image # @param credentials [String] # @return [DateTime] def self.imageTimeStamp(image_id, credentials: nil) begin img = fetchImage(image_id, credentials: credentials) return DateTime.new if img.nil? return DateTime.parse(img.creation_timestamp) rescue ::Google::Apis::ClientError => e end return DateTime.new end @@image_id_map = {} # Retrieve the cloud descriptor for this machine image, which can be # a whole or partial URL. Will follow deprecation notices and retrieve # the latest version, if applicable. # @param image_id [String]: URL to a Google disk image # @param credentials [String] # @return [Google::Apis::ComputeV1::Image] def self.fetchImage(image_id, credentials: nil) return @@image_id_map[image_id] if @@image_id_map[image_id] img_proj = img_name = nil if image_id.match(/\//) img_proj = image_id.gsub(/(?:https?:\/\/.*?\.googleapis\.com\/compute\/.*?\/)?.*?\/?(?:projects\/)?([^\/]+)\/.*/, '\1') img_name = image_id.gsub(/.*?([^\/]+)$/, '\1') else img_name = image_id end begin @@image_id_map[image_id] = MU::Cloud::Google.compute(credentials: credentials).get_image_from_family(img_proj, img_name) return @@image_id_map[image_id] rescue ::Google::Apis::ClientError # This is fine- we don't know that what we asked for is really an # image family name, instead of just an image. end begin img = MU::Cloud::Google.compute(credentials: credentials).get_image(img_proj, img_name) if !img.deprecated.nil? and !img.deprecated.replacement.nil? image_id = img.deprecated.replacement img_proj = image_id.gsub(/(?:https?:\/\/.*?\.googleapis\.com\/compute\/.*?\/)?.*?\/?(?:projects\/)?([^\/]+)\/.*/, '\1') img_name = image_id.gsub(/.*?([^\/]+)$/, '\1') end rescue ::Google::Apis::ClientError => e # SOME people *cough* don't use deprecation or image family names # and just spew out images with a version appended to the name, so # let's try some crude semantic versioning list. if e.message.match(/^notFound: /) and img_name.match(/-[^\-]+$/) list = MU::Cloud::Google.compute(credentials: credentials).list_images(img_proj, filter: "name eq #{img_name.sub(/-[^\-]+$/, '')}-.*") if list and list.items latest = nil list.items.each { |candidate| created = DateTime.parse(candidate.creation_timestamp) if latest.nil? or created > latest latest = created img = candidate end } if latest MU.log "Mapped #{image_id} to #{img.name} with semantic versioning guesswork", MU::WARN @@image_id_map[image_id] = img return @@image_id_map[image_id] end end end raise e # if our little semantic versioning party trick failed end while !img.deprecated.nil? and img.deprecated.state == "DEPRECATED" and !img.deprecated.replacement.nil? final = MU::Cloud::Google.compute(credentials: credentials).get_image(img_proj, img_name) @@image_id_map[image_id] = final @@image_id_map[image_id] end # Generator for disk configuration parameters for a Compute instance # @param config [Hash]: The MU::Cloud::Server config hash for whom we're configuring disks # @param create [Boolean]: Actually create extra (non-root) disks, or just the one declared as the root disk of the image # @param disk_as_url [Boolean]: Whether to declare the disk type as a short string or full URL, which can vary depending on the calling resource # @return [Array]: The Compute :AttachedDisk objects describing disks that've been created def self.diskConfig(config, create = true, disk_as_url = true, credentials: nil) disks = [] if config['image_id'].nil? and config['basis'].nil? pp config.keys raise MuError, "Can't generate disk configuration for server #{config['name']} without an image ID or basis specified" end img = fetchImage(config['image_id'] || config['basis']['launch_config']['image_id'], credentials: credentials) # XXX slurp settings from /dev/sda or w/e by convention? disktype = "projects/#{config['project']}/zones/#{config['availability_zone']}/diskTypes/pd-standard" disktype = "pd-standard" if !disk_as_url # disk_type: projects/project/zones/#{config['availability_zone']}/diskTypes/pd-standard Other values include pd-ssd and local-ssd imageobj = MU::Cloud::Google.compute(:AttachedDiskInitializeParams).new( source_image: img.self_link, disk_size_gb: 10, # this is binary? 2gb, that says disk_type: disktype, ) disks << MU::Cloud::Google.compute(:AttachedDisk).new( auto_delete: true, boot: true, mode: "READ_WRITE", type: "PERSISTENT", initialize_params: imageobj ) if config["storage"] config["storage"].each { |vol| devicename = vol['device'].gsub(/[^\w\-\.]/, "-").sub(/^[^\w]/, "") disk_desc = { :auto_delete => true, :device_name => devicename, # XXX empty string is also legit :mode => "READ_WRITE", :type => "PERSISTENT" # SCRATCH is equivalent of ephemeral? cheap virtual memory disk? maybe ship a standard set } if vol['snapshot_id'] disk_desc[:source_snapshot] = vol['snapshot_id'] # XXX check existence in in validateConfig elsif vol['somekindofidforaloosevolume'] disk_desc[:source] = vol['somekindofidforaloosevolume'] # XXX check existence in in validateConfig end # XXX I don't know how to do this in managed instance groups #next next if !create diskname = MU::Cloud::Google.nameStr(config['mu_name']+"-"+devicename) newdiskobj = MU::Cloud::Google.compute(:Disk).new( size_gb: vol['size'], description: MU.deploy_id, zone: config['availability_zone'], # type: "projects/#{config['project']}/zones/#{config['availability_zone']}/diskTypes/pd-ssd", type: "projects/#{config['project']}/zones/#{config['availability_zone']}/diskTypes/pd-standard", source_snapshot: vol['snapshot_id'], # type: projects/project/zones/#{config['availability_zone']}/diskTypes/pd-standard Other values include pd-ssd and local-ssd name: diskname ) MU.log "Creating disk #{diskname}", details: newdiskobj newdisk = MU::Cloud::Google.compute(credentials: credentials).insert_disk( config['project'], config['availability_zone'], newdiskobj ) disk_desc[:source] = newdisk.self_link disks << MU::Cloud::Google.compute(:AttachedDisk).new(disk_desc) } end disks end # Generator for disk configuration parameters for a Compute instance # @param config [Hash]: The MU::Cloud::Server config hash for whom we're configuring network interfaces # @param vpc [MU::Cloud::Google::VPC]: The VPC in which this interface should reside # @return [Array]: Configuration objects for network interfaces, suitable for passing to the Compute API def self.interfaceConfig(config, vpc) subnet_cfg = config['vpc'] if config['vpc']['subnets'] and !subnet_cfg['subnet_name'] and !subnet_cfg['subnet_id'] subnet_cfg = config['vpc']['subnets'].sample end subnet = vpc.getSubnet(name: subnet_cfg['subnet_name'], cloud_id: subnet_cfg['subnet_id']) if subnet.nil? raise MuError, "Couldn't find subnet details for #{subnet_cfg['subnet_name'] || subnet_cfg['subnet_id']} while configuring Server #{config['name']} (VPC: #{vpc.mu_name})" end base_iface_obj = { :network => vpc.url, :subnetwork => subnet.url } if config['associate_public_ip'] base_iface_obj[:access_configs] = [ MU::Cloud::Google.compute(:AccessConfig).new ] end interfaces = [base_iface_obj] # XXX add more if they asked for it (e.g. config['private_ip']) interfaces end # Called automatically by {MU::Deploy#createResources} def create @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloud_id sa = MU::Config::Ref.get(@config['service_account']) if !sa or !sa.kitten or !sa.kitten.cloud_desc raise MuError, "Failed to get service account cloud id from #{@config['service_account'].to_s}" end @service_acct = MU::Cloud::Google.compute(:ServiceAccount).new( email: sa.kitten.cloud_desc.email, scopes: @config['scopes'] ) if !@config['scrub_mu_isms'] MU::Cloud::Google.grantDeploySecretAccess(@service_acct.email, credentials: @config['credentials']) end begin disks = MU::Cloud::Google::Server.diskConfig(@config, credentials: @config['credentials']) interfaces = MU::Cloud::Google::Server.interfaceConfig(@config, @vpc) if @config['routes'] @config['routes'].each { |route| @vpc.cloudobj.createRouteForInstance(route, self) } end desc = { :name => MU::Cloud::Google.nameStr(@mu_name), :can_ip_forward => !@config['src_dst_check'], :description => @deploy.deploy_id, :service_accounts => [@service_acct], :network_interfaces => interfaces, :machine_type => "zones/"+@config['availability_zone']+"/machineTypes/"+@config['size'], :tags => MU::Cloud::Google.compute(:Tags).new(items: [MU::Cloud::Google.nameStr(@mu_name)]) } desc[:disks] = disks if disks.size > 0 metadata = {} if @config['metadata'] metadata = Hash[@config['metadata'].map { |m| [m["key"], m["value"]] }] end metadata["startup-script"] = @userdata if @userdata and !@userdata.empty? deploykey = @config['ssh_user']+":"+@deploy.ssh_public_key if metadata["ssh-keys"] metadata["ssh-keys"] += "\n"+deploykey else metadata["ssh-keys"] = deploykey end desc[:metadata] = MU::Cloud::Google.compute(:Metadata).new( :items => metadata.keys.map { |k| MU::Cloud::Google.compute(:Metadata)::Item.new( key: k, value: metadata[k] ) } ) # Tags in GCP means something other than what we think of; # labels are the thing you think you mean desc[:labels] = {} MU::MommaCat.listStandardTags.each_pair { |name, value| if !value.nil? desc[:labels][name.downcase] = value.downcase.gsub(/[^a-z0-9\-\_]/i, "_") end } desc[:labels]["name"] = @mu_name.downcase if @config['network_tags'] and @config['network_tags'].size > 0 desc[:tags] = U::Cloud::Google.compute(:Tags).new( items: @config['network_tags'] ) end instanceobj = MU::Cloud::Google.compute(:Instance).new(desc) MU.log "Creating instance #{@mu_name}", MU::NOTICE, details: instanceobj begin instance = MU::Cloud::Google.compute(credentials: @config['credentials']).insert_instance( @project_id, @config['availability_zone'], instanceobj ) if instance and instance.name @cloud_id = instance.name else sleep 10 end rescue ::Google::Apis::ClientError => e MU.log e.message, MU::ERR raise e end while @cloud_id.nil? if !@config['async_groom'] sleep 5 MU::MommaCat.lock(@cloud_id+"-create") if !postBoot MU.log "#{@config['name']} is already being groomed, skipping", MU::NOTICE else MU.log "Node creation complete for #{@config['name']}" end MU::MommaCat.unlock(@cloud_id+"-create") end done = false @deploy.saveNodeSecret(@cloud_id, @config['instance_secret'], "instance_secret") @config.delete("instance_secret") if cloud_desc.nil? or cloud_desc.status != "RUNNING" raiseert MuError, "#{@cloud_id} appears to have gone sideways mid-bootstrap #{cloud_desc.status if cloud_desc.nil?}" end notify rescue Exception => e if !cloud_desc.nil? and !done MU.log "Aborted before I could finish setting up #{@config['name']}, cleaning it up. Stack trace will print once cleanup is complete.", MU::WARN if !@deploy.nocleanup MU::MommaCat.unlockAll if !@deploy.nocleanup parent_thread_id = Thread.current.object_id Thread.new { MU.dupGlobals(parent_thread_id) MU::Cloud::Google::Server.cleanup(noop: false, ignoremaster: false, flags: { "skipsnapshots" => true }, region: @config['region'] ) } end end raise e end return @config end # Return a BoK-style config hash describing a NAT instance. We use this # to approximate Amazon's NAT gateway functionality with a plain # instance. # @return [Hash] def self.genericNAT return { "cloud" => "Google", "size" => "g1-small", "run_list" => [ "mu-utility::nat" ], "platform" => "centos7", "ssh_user" => "centos", "associate_public_ip" => true, "static_ip" => { "assign_ip" => true }, "routes" => [ { "gateway" => "#INTERNET", "priority" => 50, "destination_network" => "0.0.0.0/0" } ] } end # Ask the Google API to stop this node def stop MU.log "Stopping #{@cloud_id}" MU::Cloud::Google.compute(credentials: @config['credentials']).stop_instance( @project_id, @config['availability_zone'], @cloud_id ) begin sleep 5 end while cloud_desc.status != "TERMINATED" # means STOPPED end # Ask the Google API to start this node def start MU.log "Starting #{@cloud_id}" MU::Cloud::Google.compute(credentials: @config['credentials']).start_instance( @project_id, @config['availability_zone'], @cloud_id ) begin sleep 5 end while cloud_desc.status != "RUNNING" end # Ask the Google API to restart this node # XXX unimplemented def reboot(hard = false) return if @cloud_id.nil? end # Figure out what's needed to SSH into this server. # @return [Array]: nat_ssh_key, nat_ssh_user, nat_ssh_host, canonical_ip, ssh_user, ssh_key_name, alternate_names def getSSHConfig node, config, deploydata = describe(cloud_id: @cloud_id) # XXX add some awesome alternate names from metadata and make sure they end # up in MU::MommaCat's ssh config wangling ssh_keydir = Etc.getpwuid(Process.uid).dir+"/.ssh" return nil if @config.nil? or @deploy.nil? nat_ssh_key = nat_ssh_user = nat_ssh_host = nil if !@config["vpc"].nil? and !MU::Cloud::Google::VPC.haveRouteToInstance?(cloud_desc, region: @config['region'], credentials: @config['credentials']) if !@nat.nil? if @nat.cloud_desc.nil? MU.log "NAT was missing cloud descriptor when called in #{@mu_name}'s getSSHConfig", MU::ERR return nil end foo, bar, baz, nat_ssh_host, nat_ssh_user, nat_ssh_key = @nat.getSSHConfig if nat_ssh_user.nil? and !nat_ssh_host.nil? MU.log "#{@config["name"]} (#{MU.deploy_id}) is configured to use #{@config['vpc']} NAT #{nat_ssh_host}, but username isn't specified. Guessing root.", MU::ERR, details: caller nat_ssh_user = "root" end end end if @config['ssh_user'].nil? if windows? @config['ssh_user'] = "Administrator" else @config['ssh_user'] = "root" end end return [nat_ssh_key, nat_ssh_user, nat_ssh_host, canonicalIP, @config['ssh_user'], @deploy.ssh_key_name] end # Apply tags, bootstrap our configuration management, and other # administravia for a new instance. def postBoot(instance_id = nil) if !instance_id.nil? @cloud_id = instance_id end instance = cloud_desc node, config, deploydata = describe(cloud_id: @cloud_id) instance = cloud_desc raise MuError, "Couldn't find instance of #{@mu_name} (#{@cloud_id})" if !instance return false if !MU::MommaCat.lock(@cloud_id+"-orchestrate", true) return false if !MU::MommaCat.lock(@cloud_id+"-groom", true) # MU::Cloud::AWS.createStandardTags(@cloud_id, region: @config['region']) # MU::MommaCat.createTag(@cloud_id, "Name", node, region: @config['region']) # # if @config['optional_tags'] # MU::MommaCat.listOptionalTags.each { |key, value| # MU::MommaCat.createTag(@cloud_id, key, value, region: @config['region']) # } # end # # if !@config['tags'].nil? # @config['tags'].each { |tag| # MU::MommaCat.createTag(@cloud_id, tag['key'], tag['value'], region: @config['region']) # } # end # MU.log "Tagged #{node} (#{@cloud_id}) with MU-ID=#{MU.deploy_id}", MU::DEBUG # # Make double sure we don't lose a cached mu_windows_name value. if windows? or !@config['active_directory'].nil? if @mu_windows_name.nil? @mu_windows_name = deploydata['mu_windows_name'] end end # punchAdminNAT # # # # If we came up via AutoScale, the Alarm module won't have had our # # instance ID to associate us with itself. So invoke that here. # if !@config['basis'].nil? and @config["alarms"] and !@config["alarms"].empty? # @config["alarms"].each { |alarm| # alarm_obj = MU::MommaCat.findStray( # "AWS", # "alarms", # region: @config["region"], # deploy_id: @deploy.deploy_id, # name: alarm['name'] # ).first # alarm["dimensions"] = [{:name => "InstanceId", :value => @cloud_id}] # # if alarm["enable_notifications"] # topic_arn = MU::Cloud::AWS::Notification.createTopic(alarm["notification_group"], region: @config["region"]) # MU::Cloud::AWS::Notification.subscribe(arn: topic_arn, protocol: alarm["notification_type"], endpoint: alarm["notification_endpoint"], region: @config["region"]) # alarm["alarm_actions"] = [topic_arn] # alarm["ok_actions"] = [topic_arn] # end # # alarm_name = alarm_obj ? alarm_obj.cloud_id : "#{node}-#{alarm['name']}".upcase # # MU::Cloud::AWS::Alarm.setAlarm( # name: alarm_name, # ok_actions: alarm["ok_actions"], # alarm_actions: alarm["alarm_actions"], # insufficient_data_actions: alarm["no_data_actions"], # metric_name: alarm["metric_name"], # namespace: alarm["namespace"], # statistic: alarm["statistic"], # dimensions: alarm["dimensions"], # period: alarm["period"], # unit: alarm["unit"], # evaluation_periods: alarm["evaluation_periods"], # threshold: alarm["threshold"], # comparison_operator: alarm["comparison_operator"], # region: @config["region"] # ) # } # end # # # We have issues sometimes where our dns_records are pointing at the wrong node name and IP address. # # Make sure that doesn't happen. Happens with server pools only # if @config['dns_records'] && !@config['dns_records'].empty? # @config['dns_records'].each { |dnsrec| # if dnsrec.has_key?("name") # if dnsrec['name'].start_with?(MU.deploy_id.downcase) && !dnsrec['name'].start_with?(node.downcase) # MU.log "DNS records for #{node} seem to be wrong, deleting from current config", MU::WARN, details: dnsrec # dnsrec.delete('name') # dnsrec.delete('target') # end # end # } # end # Unless we're planning on associating a different IP later, set up a # DNS entry for this thing and let it sync in the background. We'll # come back to it later. if @config['static_ip'].nil? && !@named MU::MommaCat.nameKitten(self) @named = true end nat_ssh_key, nat_ssh_user, nat_ssh_host, canonical_ip, ssh_user, ssh_key_name = getSSHConfig if !nat_ssh_host and !MU::Cloud::Google::VPC.haveRouteToInstance?(cloud_desc, region: @config['region'], credentials: @config['credentials']) # XXX check if canonical_ip is in the private ranges # raise MuError, "#{node} has no NAT host configured, and I have no other route to it" end # See if this node already exists in our config management. If it does, # we're done. if @groomer.haveBootstrapped? MU.log "Node #{node} has already been bootstrapped, skipping groomer setup.", MU::NOTICE @groomer.saveDeployData MU::MommaCat.unlock(@cloud_id+"-orchestrate") MU::MommaCat.unlock(@cloud_id+"-groom") return true end @groomer.bootstrap # Make sure we got our name written everywhere applicable if !@named MU::MommaCat.nameKitten(self) @named = true end MU::MommaCat.unlock(@cloud_id+"-groom") MU::MommaCat.unlock(@cloud_id+"-orchestrate") return true end #postBoot # Locate an existing instance or instances and return an array containing matching AWS resource descriptors for those that match. # @return [Array>]: The cloud provider's complete descriptions of matching instances def self.find(**args) args[:project] ||= args[:habitat] args[:project] ||= MU::Cloud::Google.defaultProject(args[:credentials]) if !args[:region].nil? and MU::Cloud::Google.listRegions.include?(args[:region]) regions = [args[:region]] else regions = MU::Cloud::Google.listRegions end found = {} search_semaphore = Mutex.new search_threads = [] # If we got an instance id, go get it parent_thread_id = Thread.current.object_id regions.each { |r| search_threads << Thread.new(r) { |region| Thread.abort_on_exception = false MU.dupGlobals(parent_thread_id) MU.log "Hunting for instance with cloud id '#{args[:cloud_id]}' in #{region}", MU::DEBUG MU::Cloud::Google.listAZs(region).each { |az| begin if !args[:cloud_id].nil? and !args[:cloud_id].empty? resp = MU::Cloud::Google.compute(credentials: args[:credentials]).get_instance( args[:project], az, args[:cloud_id] ) search_semaphore.synchronize { found[args[:cloud_id]] = resp if !resp.nil? } else resp = MU::Cloud::Google.compute(credentials: args[:credentials]).list_instances( args[:project], az ) if resp and resp.items resp.items.each { |instance| search_semaphore.synchronize { found[instance.name] = instance } } end end rescue ::OpenSSL::SSL::SSLError => e MU.log "Got #{e.message} looking for instance #{args[:cloud_id]} in project #{args[:project]} (#{az}). Usually this means we've tried to query a non-functional region.", MU::DEBUG rescue ::Google::Apis::ClientError => e raise e if !e.message.match(/^(?:notFound|forbidden): /) end } } } done_threads = [] begin search_threads.reject! { |t| t.nil? } search_threads.each { |t| joined = t.join(2) done_threads << joined if !joined.nil? } end while found.size < 1 and done_threads.size != search_threads.size # Ok, well, let's try looking it up by IP then # if instance.nil? and !args[:ip].nil? # MU.log "Hunting for instance by IP '#{args[:ip]}'", MU::DEBUG # end # if !instance.nil? # return {instance.name => instance} if !instance.nil? # end # Fine, let's try it by tag. # if !args[:tag_value].nil? # MU.log "Searching for instance by tag '#{args[:tag_key]}=#{args[:tag_value]}'", MU::DEBUG # end return found end # Return a description of this resource appropriate for deployment # metadata. Arguments reflect the return values of the MU::Cloud::[Resource].describe method def notify node, config, deploydata = describe(cloud_id: @cloud_id, update_cache: true) deploydata = {} if deploydata.nil? if cloud_desc.nil? raise MuError, "Failed to load instance metadata for #{@config['mu_name']}/#{@cloud_id}" end interfaces = [] private_ips = [] public_ips = [] cloud_desc.network_interfaces.each { |iface| private_ips << iface.network_ip if iface.access_configs iface.access_configs.each { |acfg| public_ips << acfg.nat_ip if acfg.nat_ip } end interfaces << { "network_interface_id" => iface.name, "subnet_id" => iface.subnetwork, "vpc_id" => iface.network } } deploydata = { "nodename" => @mu_name, "run_list" => @config['run_list'], "image_created" => @config['image_created'], # "iam_role" => @config['iam_role'], "cloud_desc_id" => @cloud_id, "project_id" => @project_id, "private_ip_address" => private_ips.first, "public_ip_address" => public_ips.first, "private_ip_list" => private_ips, # "key_name" => cloud_desc.key_name, # "subnet_id" => cloud_desc.subnet_id, # "cloud_desc_type" => cloud_desc.instance_type #, # "network_interfaces" => interfaces, # "config" => server } if !@mu_windows_name.nil? deploydata["mu_windows_name"] = @mu_windows_name end if !@config['chef_data'].nil? deploydata.merge!(@config['chef_data']) end deploydata["region"] = @config['region'] if !@config['region'].nil? if !@named MU::MommaCat.nameKitten(self) @named = true end return deploydata end # Called automatically by {MU::Deploy#createResources} def groom @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloud_id MU::MommaCat.lock(@cloud_id+"-groom") node, config, deploydata = describe(cloud_id: @cloud_id) if node.nil? or node.empty? raise MuError, "MU::Cloud::Google::Server.groom was called without a mu_name" end # Make double sure we don't lose a cached mu_windows_name value. if windows? or !@config['active_directory'].nil? if @mu_windows_name.nil? @mu_windows_name = deploydata['mu_windows_name'] end end # punchAdminNAT # MU::Cloud::AWS::Server.tagVolumes(@cloud_id) # If we have a loadbalancer configured, attach us to it # if !@config['loadbalancers'].nil? # if @loadbalancers.nil? # raise MuError, "#{@mu_name} is configured to use LoadBalancers, but none have been loaded by dependencies()" # end # @loadbalancers.each { |lb| # lb.registerNode(@cloud_id) # } # end # Let us into any databases we depend on. # This is probelmtic with autscaling - old ips are not removed, and access to the database can easily be given at the BoK level # if @dependencies.has_key?("database") # @dependencies['database'].values.each { |db| # db.allowHost(@deploydata["private_ip_address"]+"/32") # if @deploydata["public_ip_address"] # db.allowHost(@deploydata["public_ip_address"]+"/32") # end # } # end @groomer.saveDeployData begin @groomer.run(purpose: "Full Initial Run", max_retries: 15) rescue MU::Groomer::RunError MU.log "Proceeding after failed initial Groomer run, but #{node} may not behave as expected!", MU::WARN end if !@config['create_image'].nil? and !@config['image_created'] img_cfg = @config['create_image'] # Scrub things that don't belong on an AMI session = getSSHSession sudo = purgecmd = "" sudo = "sudo" if @config['ssh_user'] != "root" if windows? purgecmd = "rm -rf /cygdrive/c/mu_installed_chef" else purgecmd = "rm -rf /opt/mu_installed_chef" end if img_cfg['image_then_destroy'] if windows? purgecmd = "rm -rf /cygdrive/c/chef/ /home/#{@config['windows_admin_username']}/.ssh/authorized_keys /home/Administrator/.ssh/authorized_keys /cygdrive/c/mu-installer-ran-updates /cygdrive/c/mu_installed_chef" # session.exec!("powershell -Command \"& {(Get-WmiObject -Class Win32_Product -Filter \"Name='UniversalForwarder'\").Uninstall()}\"") else purgecmd = "#{sudo} rm -rf /root/.ssh/authorized_keys /etc/ssh/ssh_host_*key* /etc/chef /etc/opscode/* /.mu-installer-ran-updates /var/chef /opt/mu_installed_chef /opt/chef ; #{sudo} sed -i 's/^HOSTNAME=.*//' /etc/sysconfig/network" end end session.exec!(purgecmd) session.close stop image_id = MU::Cloud::Google::Server.createImage( name: MU::Cloud::Google.nameStr(@mu_name), instance_id: @cloud_id, region: @config['region'], storage: @config['storage'], project: @project_id, exclude_storage: img_cfg['image_exclude_storage'], make_public: img_cfg['public'], tags: @config['tags'], zone: @config['availability_zone'], family: @config['family'], credentials: @config['credentials'] ) @deploy.notify("images", @config['name'], {"image_id" => image_id}) @config['image_created'] = true if img_cfg['image_then_destroy'] MU.log "Image #{image_id} ready, removing source node #{node}" MU::Cloud::Google.compute(credentials: @config['credentials']).delete_instance( @project_id, @config['availability_zone'], @cloud_id ) destroy else start end end MU::MommaCat.unlock(@cloud_id+"-groom") end # Create an image out of a running server. Requires either the name of a MU resource in the current deployment, or the cloud provider id of a running instance. # @param name [String]: The MU resource name of the server to use as the basis for this image. # @param instance_id [String]: The cloud provider resource identifier of the server to use as the basis for this image. # @param storage [Hash]: The storage devices to include in this image. # @param exclude_storage [Boolean]: Do not include the storage device profile of the running instance when creating this image. # @param region [String]: The cloud provider region # @param tags [Array]: Extra/override tags to apply to the image. # @return [String]: The cloud provider identifier of the new machine image. def self.createImage(name: nil, instance_id: nil, storage: {}, exclude_storage: false, project: nil, make_public: false, tags: [], region: nil, family: nil, zone: MU::Cloud::Google.listAZs.sample, credentials: nil) project ||= MU::Cloud::Google.defaultProject(credentials) instance = MU::Cloud::Server.find(cloud_id: instance_id, region: region) if instance.nil? raise MuError, "Failed to find instance '#{instance_id}' in createImage" end labels = {} MU::MommaCat.listStandardTags.each_pair { |key, value| if !value.nil? labels[key.downcase] = value.downcase.gsub(/[^a-z0-9\-\_]/i, "_") end } bootdisk = nil threads = [] parent_thread_id = Thread.current.object_id instance[instance_id].disks.each { |disk| threads << Thread.new { Thread.abort_on_exception = false MU.dupGlobals(parent_thread_id) if disk.boot bootdisk = disk.source else snapobj = MU::Cloud::Google.compute(:Snapshot).new( name: name+"-"+disk.device_name, description: "Mu image created from #{name} (#{disk.device_name})" ) diskname = disk.source.gsub(/.*?\//, "") MU.log "Creating snapshot of #{diskname} in #{zone}", MU::NOTICE, details: snapobj snap = MU::Cloud::Google.compute(credentials: credentials).create_disk_snapshot( project, zone, diskname, snapobj ) MU::Cloud::Google.compute(credentials: credentials).set_snapshot_labels( project, snap.name, MU::Cloud::Google.compute(:GlobalSetLabelsRequest).new( label_fingerprint: snap.label_fingerprint, labels: labels.merge({ "mu-device-name" => disk.device_name, "mu-parent-image" => name, "mu-orig-zone" => zone }) ) ) end } } threads.each do |t| t.join end labels["name"] = instance_id.downcase image_desc = { :name => name, :source_disk => bootdisk, :description => "Mu image created from #{name}", :labels => labels } image_desc[:family] = family if family newimage = MU::Cloud::Google.compute(credentials: @config['credentials']).insert_image( project, MU::Cloud::Google.compute(:Image).new(image_desc) ) newimage.name end # Return the IP address that we, the Mu server, should be using to access # this host via the network. Note that this does not factor in SSH # bastion hosts that may be in the path, see getSSHConfig if that's what # you need. def canonicalIP mu_name, config, deploydata = describe(cloud_id: @cloud_id) if !cloud_desc raise MuError, "Couldn't retrieve cloud descriptor for server #{self}" end private_ips = [] public_ips = [] cloud_desc.network_interfaces.each { |iface| private_ips << iface.network_ip if iface.access_configs iface.access_configs.each { |acfg| public_ips << acfg.nat_ip if acfg.nat_ip } end } # Our deploydata gets corrupted often with server pools, this will cause us to use the wrong IP to identify a node # which will cause us to create certificates, DNS records and other artifacts with incorrect information which will cause our deploy to fail. # The cloud_id is always correct so lets use 'cloud_desc' to get the correct IPs if MU::Cloud::Google::VPC.haveRouteToInstance?(cloud_desc, credentials: @config['credentials']) or public_ips.size == 0 @config['canonical_ip'] = private_ips.first return private_ips.first else @config['canonical_ip'] = public_ips.first return public_ips.first end end # return [String]: A password string. def getWindowsAdminPassword end # Add a volume to this instance # @param dev [String]: Device name to use when attaching to instance # @param size [String]: Size (in gb) of the new volume # @param type [String]: Cloud storage type of the volume, if applicable # @param delete_on_termination [Boolean]: Value of delete_on_termination flag to set def addVolume(dev, size, type: "pd-standard", delete_on_termination: false) devname = dev.gsub(/.*?\/([^\/]+)$/, '\1') resname = MU::Cloud::Google.nameStr(@mu_name+"-"+devname) MU.log "Creating disk #{resname}" description = @deploy ? @deploy.deploy_id : @mu_name+"-"+devname newdiskobj = MU::Cloud::Google.compute(:Disk).new( size_gb: size, description: description, zone: @config['availability_zone'], # type: "projects/#{config['project']}/zones/#{config['availability_zone']}/diskTypes/pd-ssd", type: "projects/#{@project_id}/zones/#{@config['availability_zone']}/diskTypes/pd-standard", # Other values include pd-ssd and local-ssd name: resname ) begin newdisk = MU::Cloud::Google.compute(credentials: @config['credentials']).insert_disk( @project_id, @config['availability_zone'], newdiskobj ) rescue ::Google::Apis::ClientError => e if e.message.match(/^alreadyExists: /) MU.log "Disk #{resname} already exists, ignoring request to create", MU::WARN return else raise e end end attachobj = MU::Cloud::Google.compute(:AttachedDisk).new( device_name: devname, source: newdisk.self_link, type: "PERSISTENT", auto_delete: delete_on_termination ) MU.log "Attaching disk #{resname} to #{@cloud_id} at #{devname}" attachment = MU::Cloud::Google.compute(credentials: @config['credentials']).attach_disk( @project_id, @config['availability_zone'], @cloud_id, attachobj ) end # Determine whether the node in question exists at the Cloud provider # layer. # @return [Boolean] def active? true 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. def toKitten(rootparent: nil, billing: nil, habitats: nil) bok = { "cloud" => "Google", "credentials" => @config['credentials'], "cloud_id" => @cloud_id, "project" => @project_id } if !cloud_desc MU.log "toKitten failed to load a cloud_desc from #{@cloud_id}", MU::ERR, details: @config return nil end bok['name'] = cloud_desc.name # XXX we can have multiple network interfaces, and often do; need # language to account for this iface = cloud_desc.network_interfaces.first iface.network.match(/(?:^|\/)projects\/(.*?)\/.*?\/networks\/([^\/]+)(?:$|\/)/) vpc_proj = Regexp.last_match[1] vpc_id = Regexp.last_match[2] bok['vpc'] = MU::Config::Ref.get( id: vpc_id, cloud: "Google", habitat: MU::Config::Ref.get( id: vpc_proj, cloud: "Google", credentials: @credentials, type: "habitats" ), credentials: @credentials, type: "vpcs", subnet_id: iface.subnetwork.sub(/.*?\/([^\/]+)$/, '\1') ) cloud_desc.disks.each { |disk| next if !disk.source disk.source.match(/\/projects\/([^\/]+)\/zones\/([^\/]+)\/disks\/(.*)/) proj = Regexp.last_match[1] az = Regexp.last_match[2] name = Regexp.last_match[3] begin disk_desc = MU::Cloud::Google.compute(credentials: @credentials).get_disk(proj, az, name) if disk_desc.source_image and disk.boot bok['image_id'] ||= disk_desc.source_image.sub(/^https:\/\/www\.googleapis\.com\/compute\/[^\/]+\//, '') else bok['storage'] ||= [] storage_blob = { "size" => disk_desc.size_gb, "device" => "/dev/xvd"+(disk.index+97).chr.downcase } bok['storage'] << storage_blob end rescue ::Google::Apis::ClientError => e MU.log "Failed to retrieve disk #{name} attached to server #{@cloud_id} in #{proj}/#{az}", MU::WARN, details: e.message next end } if cloud_desc.labels bok['tags'] = cloud_desc.labels.keys.map { |k| { "key" => k, "value" => cloud_desc.labels[k] } } end if cloud_desc.tags and cloud_desc.tags.items and cloud_desc.tags.items.size > 0 bok['network_tags'] = cloud_desc.tags.items end bok['src_dst_check'] = !cloud_desc.can_ip_forward bok['size'] = cloud_desc.machine_type.sub(/.*?\/([^\/]+)$/, '\1') bok['project'] = @project_id if cloud_desc.service_accounts bok['scopes'] = cloud_desc.service_accounts.map { |sa| sa.scopes }.flatten.uniq end if cloud_desc.metadata and cloud_desc.metadata.items bok['metadata'] = cloud_desc.metadata.items.map { |m| MU.structToHash(m) } end # Skip nodes that are just members of GKE clusters if bok['name'].match(/^gke-.*?-[a-f0-9]+-[a-z0-9]+$/) and bok['image_id'].match(/(:?^|\/)projects\/gke-node-images\//) found_gke_tag = false bok['network_tags'].each { |tag| if tag.match(/^gke-/) found_gke_tag = true break end } if found_gke_tag MU.log "Server #{bok['name']} appears to belong to a ContainerCluster, skipping adoption", MU::DEBUG return nil end end if bok['metadata'] bok['metadata'].each { |item| if item[:key] == "created-by" and item[:value].match(/\/instanceGroupManagers\//) MU.log "Server #{bok['name']} appears to belong to a ServerPool, skipping adoption", MU::DEBUG, details: item[:value] return nil end } end bok 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? false 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 instances associated with the currently loaded deployment. Also cleans up associated volumes, droppings in the MU master's /etc/hosts and ~/.ssh, and in whatever Groomer was used. # @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 # @param region [String]: The cloud provider region # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) return if !MU::Cloud::Google::Habitat.isLive?(flags["project"], credentials) skipsnapshots = flags["skipsnapshots"] onlycloud = flags["onlycloud"] # XXX make damn sure MU.deploy_id is set MU::Cloud::Google.listAZs(region).each { |az| disks = [] resp = MU::Cloud::Google.compute(credentials: credentials).list_instances( flags["project"], az, filter: "description eq #{MU.deploy_id}" ) if !resp.items.nil? and resp.items.size > 0 resp.items.each { |instance| saname = instance.tags.items.first.gsub(/[^a-z]/, "") # XXX this nonsense again MU.log "Terminating instance #{instance.name}" if !instance.disks.nil? and instance.disks.size > 0 instance.disks.each { |disk| disks << disk if !disk.auto_delete } end deletia = MU::Cloud::Google.compute(credentials: credentials).delete_instance( flags["project"], az, instance.name ) if !noop MU.log "Removing service account #{saname}" begin MU::Cloud::Google.iam(credentials: credentials).delete_project_service_account( "projects/#{flags["project"]}/serviceAccounts/#{saname}@#{flags["project"]}.iam.gserviceaccount.com" ) if !noop rescue ::Google::Apis::ClientError => e raise e if !e.message.match(/^notFound: /) end # XXX wait-loop on pending? # pp deletia } end if disks.size > 0 # XXX make sure we don't miss anything that got created with dumb flags end # XXX honor snapshotting MU::Cloud::Google.compute(credentials: credentials).delete( "disk", flags["project"], az, noop ) if !noop } 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) toplevel_required = [] schema = { "roles" => MU::Cloud::Google::User.schema(config)[1]["roles"], "create_image" => { "properties" => { "family" => { "type" => "string", "description" => "Add a GCP image +family+ string to the created image(s)" } } }, "availability_zone" => { "type" => "string", "description" => "Target this instance to a specific Availability Zone" }, "ssh_user" => { "type" => "string", "description" => "Account to use when connecting via ssh. Google Cloud images don't come with predefined remote access users, and some don't work with our usual default of +root+, so we recommend using some other (non-root) username.", "default" => "muadmin" }, "network_tags" => { "type" => "array", "items" => { "type" => "string", "description" => "Add a network tag to this host, which can be used to selectively apply routes or firewall rules." } }, "service_account" => MU::Config::Ref.schema( type: "users", desc: "An existing service account to use instead of the default one generated by Mu during the deployment process." ), "metadata" => { "type" => "array", "items" => { "type" => "object", "description" => "Custom key-value pairs to be added to the metadata of Google Cloud virtual machines", "required" => ["key", "value"], "properties" => { "key" => { "type" => "string" }, "value" => { "type" => "string" } } } }, "routes" => { "type" => "array", "items" => MU::Config::VPC.routeschema }, "scopes" => { "type" => "array", "items" => { "type" => "string", "description" => "API scopes to make available to this resource's service account." }, "default" => ["https://www.googleapis.com/auth/compute.readonly", "https://www.googleapis.com/auth/logging.write", "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/monitoring.write", "https://www.googleapis.com/auth/devstorage.read_only"] } } [toplevel_required, schema] end @@instance_type_cache = {} # Confirm that the given instance size is valid for the given region. # If someone accidentally specified an equivalent size from some other cloud provider, return something that makes sense. If nothing makes sense, return nil. # @param size [String]: Instance type to check # @param region [String]: Region to check against # @return [String,nil] def self.validateInstanceType(size, region, project: nil, credentials: nil) size = size.dup.to_s if @@instance_type_cache[project] and @@instance_type_cache[project][region] and @@instance_type_cache[project][region][size] return @@instance_type_cache[project][region][size] end if size.match(/\/?custom-(\d+)-(\d+)(?:-ext)?$/) cpus = Regexp.last_match[1].to_i mem = Regexp.last_match[2].to_i ok = true if cpus < 1 or cpus > 32 or (cpus % 2 != 0 and cpus != 1) MU.log "Custom instance type #{size} illegal: CPU count must be 1 or an even number between 2 and 32", MU::ERR ok = false end if (mem % 256) != 0 MU.log "Custom instance type #{size} illegal: Memory must be a multiple of 256 (MB)", MU::ERR ok = false end if ok return "custom-#{cpus.to_s}-#{mem.to_s}" else return nil end end @@instance_type_cache[project] ||= {} @@instance_type_cache[project][region] ||= {} types = (MU::Cloud::Google.listInstanceTypes(region, project: project, credentials: credentials))[project][region] realsize = size.dup if types and (realsize.nil? or !types.has_key?(realsize)) # See if it's a type we can approximate from one of the other clouds foundmatch = false MU::Cloud.availableClouds.each { |cloud| next if cloud == "Google" cloudbase = Object.const_get("MU").const_get("Cloud").const_get(cloud) foreign_types = (cloudbase.listInstanceTypes)[cloudbase.myRegion] if foreign_types and foreign_types.size > 0 and foreign_types.has_key?(size) vcpu = foreign_types[size]["vcpu"] mem = foreign_types[size]["memory"] ecu = foreign_types[size]["ecu"] types.keys.sort.reverse.each { |type| features = types[type] next if ecu == "Variable" and ecu != features["ecu"] next if features["vcpu"] != vcpu if (features["memory"] - mem.to_f).abs < 0.10*mem foundmatch = true MU.log "You specified #{cloud} instance type '#{realsize}.' Approximating with Google Compute type '#{type}.'", MU::WARN realsize = type break end } end break if foundmatch } if !foundmatch MU.log "Invalid size '#{realsize}' for Google Compute instance in #{region} (checked project #{project}). Supported types:", MU::ERR, details: types.keys.sort.join(", ") @@instance_type_cache[project][region][size] = nil return nil end end @@instance_type_cache[project][region][size] = realsize @@instance_type_cache[project][region][size] end # Cloud-specific pre-processing of {MU::Config::BasketofKittens::servers}, bare and unvalidated. # @param server [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(server, configurator) ok = true server['project'] ||= MU::Cloud::Google.defaultProject(server['credentials']) size = validateInstanceType(server["size"], server["region"], project: server['project'], credentials: server['credentials']) if size.nil? MU.log "Failed to verify instance size #{server["size"]} for Server #{server['name']}", MU::WARN else server["size"] = size end # If we're not targeting an availability zone, pick one randomly if !server['availability_zone'] server['availability_zone'] = MU::Cloud::Google.listAZs(server['region']).sample end if server['service_account'] server['service_account']['cloud'] = "Google" server['service_account']['habitat'] ||= server['project'] found = MU::Config::Ref.get(server['service_account']) if found.id and !found.kitten MU.log "GKE server #{server['name']} failed to locate service account #{server['service_account']} in project #{server['project']}", MU::ERR ok = false end else user = { "name" => server['name'], "cloud" => "Google", "project" => server["project"], "credentials" => server["credentials"], "type" => "service" } if server['roles'] user['roles'] = server['roles'].dup end configurator.insertKitten(user, "users", true) server['dependencies'] ||= [] server['service_account'] = MU::Config::Ref.get( type: "users", cloud: "Google", name: server["name"], project: server["project"], credentials: server["credentials"] ) server['dependencies'] << { "type" => "user", "name" => server["name"] } end subnets = nil if !server['vpc'] vpcs = MU::Cloud::Google::VPC.find(credentials: server['credentials']) if vpcs["default"] server["vpc"] ||= {} server["vpc"]["vpc_id"] = vpcs["default"].self_link subnets = vpcs["default"].subnetworks MU.log "No VPC specified for Server #{server['name']}, using default VPC for project #{server['project']}", MU::NOTICE else ok = false MU.log "You must specify a target VPC when creating a Server", MU::ERR end end if !server['vpc']['subnet_id'] and server['vpc']['subnet_name'].nil? if !subnets if server["vpc"]["vpc_id"] vpcs = MU::Cloud::Google::VPC.find(cloud_id: server["vpc"]["vpc_id"]) subnets = vpcs["default"].subnetworks.sample end end if subnets server['vpc']['subnet_id'] = subnets.delete_if { |subnet| !subnet.match(/regions\/#{Regexp.quote(server['region'])}\/subnetworks/) }.sample end if server['vpc']['subnet_id'].nil? ok = false MU.log "Failed to identify a subnet in my region (#{server['region']})", MU::ERR, details: server["vpc"]["vpc_id"] end end if server['image_id'].nil? img_id = MU::Cloud.getStockImage("Google", platform: server['platform']) if img_id server['image_id'] = configurator.getTail("server"+server['name']+"Image", value: img_id, prettyname: "server"+server['name']+"Image", cloudtype: "Google::Apis::ComputeV1::Image") else MU.log "No image specified for #{server['name']} and no default available for platform #{server['platform']}", MU::ERR, details: server ok = false end end real_image = nil begin real_image = MU::Cloud::Google::Server.fetchImage(server['image_id'].to_s, credentials: server['credentials']) rescue ::Google::Apis::ClientError => e end if real_image.nil? MU.log "Image #{server['image_id']} for server #{server['name']} does not appear to exist", MU::ERR ok = false else server['image_id'] = real_image.self_link server['image_id'].match(/projects\/([^\/]+)\/.*?\/([^\/]+)$/) img_project = Regexp.last_match[1] img_name = Regexp.last_match[2] begin img = MU::Cloud::Google.compute(credentials: server['credentials']).get_image(img_project, img_name) snaps = MU::Cloud::Google.compute(credentials: server['credentials']).list_snapshots( img_project, filter: "name eq #{img_name}-.*" ) server['storage'] ||= [] used_devs = server['storage'].map { |disk| disk['device'].gsub(/.*?\//, "") } if snaps and snaps.items snaps.items.each { |snap| next if !snap.labels.is_a?(Hash) or !snap.labels["mu-device-name"] or snap.labels["mu-parent-image"] != img_name devname = snap.labels["mu-device-name"] if used_devs.include?(devname) MU.log "Device name #{devname} already declared in server #{server['name']} (snapshot #{snap.name} wants the name)", MU::ERR ok = false end server['storage'] << { "snapshot_id" => snap.self_link, "size" => snap.disk_size_gb, "delete_on_termination" => true, "device" => devname } used_devs << devname } if snaps.items.size > 0 # MU.log img_name, MU::WARN, details: snaps.items end end rescue ::Google::Apis::ClientError => e # it's ok, sometimes we don't have permission to list snapshots # in other peoples' projects # MU.log img_name, MU::WARN, details: img raise e if !e.message.match(/^forbidden: /) end end ok end private end #class end #class end end #module