cloudamatic/mu

View on GitHub
modules/mu/providers/google/server.rb

Summary

Maintainability
D
2 days
Test Coverage
# 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 <tt>@vpc</tt>, 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
          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?
            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)

#          if img.source_disk and img.source_disk.match(/projects\/([^\/]+)\/zones\/([^\/]+)\/disks\/(.*)/)
#            _junk, proj, az, name = Regexp.last_match
#            disk_desc = MU::Cloud::Google.compute(credentials: credentials).get_disk(proj, az, name)
#            pp disk_desc
#            raise "nah"
#          end

          disktype = "projects/#{config['project']}/zones/#{config['availability_zone']}/diskTypes/pd-standard"


          disktype.gsub!(/.*?\/([^\/])$/, '\1') if !disk_as_url

          imageobj = MU::Cloud::Google.compute(:AttachedDiskInitializeParams).new(
            source_image: img.self_link,
            disk_size_gb: img.disk_size_gb,
            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']
            # XXX if illegal subnets somehow creep in here, we'll need to be
            # picky by region or somesuch
            subnet_cfg = config['vpc']['subnets'].sample

          end

          subnet = vpc.getSubnet(name: subnet_cfg['subnet_name'], cloud_id: subnet_cfg['subnet_id'])
          if subnet.nil?
            if config['vpc']['name']
              subnet = vpc.getSubnet(name: config['vpc']['name']+subnet_cfg['subnet_name'], cloud_id: subnet_cfg['subnet_id'])
            end
            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
          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 = nil
          retries = 0
          begin
            sa = MU::Config::Ref.get(@config['service_account'])
            if !sa or !sa.kitten or !sa.kitten.cloud_desc
              sleep 10
            end
          end while !sa or !sa.kitten or !sa.kitten.cloud_desc and retries < 5

          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] = MU::Cloud::Google.compute(:Tags).new(
                items: @config['network_tags']
              )
            end

            instanceobj = MU::Cloud::Google.compute(:Instance).new(desc)

            MU.log "Creating instance #{@mu_name} in #{@project_id} #{@config['availability_zone']}", 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+" inserting instance into #{@project_id}/#{@config['availability_zone']}", MU::ERR, details: instanceobj
              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 StandardError => 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-nat" ],
            "groomer" => "Ansible",
            "platform" => "centos7",
            "src_dst_check" => false,
            "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(use_cache: false).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
        # @param _hard [Boolean]: [IGNORED] Force a stop/start. This is the only available way to restart an instance in Google, so this flag is ignored.
        def reboot(_hard = false)
          return if @cloud_id.nil?
          stop
          start
        end

        # Figure out what's needed to SSH into this server.
        # @return [Array<String>]: nat_ssh_key, nat_ssh_user, nat_ssh_host, canonical_ip, ssh_user, ssh_key_name, alternate_names
        def getSSHConfig
          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
          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.resourceClass("Google", "VPC").haveRouteToInstance?(cloud_desc, 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'] = @config['windows_admin_user']
              @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

          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::Cloud::AWS.createTag(@cloud_id, "Name", node, region: @config['region'])
#
#          if @config['optional_tags']
#            MU::MommaCat.listOptionalTags.each { |key, value|
#              MU::Cloud::AWS.createTag(@cloud_id, key, value, region: @config['region'])
#            }
#          end
#
#          if !@config['tags'].nil?
#            @config['tags'].each { |tag|
#              MU::Cloud::AWS.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.resourceClass("Google", "VPC").haveRouteToInstance?(cloud_desc, 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<Hash<String,OpenStruct>>]: The cloud provider's complete descriptions of matching instances
        def self.find(**args)
          args = MU::Cloud::Google.findLocationArgs(args)

          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
          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

          @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: @tags,
                zone: @config['availability_zone'],
                family: img_cfg['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<String>]: 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 = Hash[tags.keys.map { |k|
            [k.downcase, tags[k].downcase.gsub(/[^-_a-z0-9]/, '-')] }
          ]
          labels["name"] = name

          bootdisk = nil
          threads = []
          parent_thread_id = Thread.current.object_id
          if !exclude_storage
            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
              }
            }
          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

          MU.log "Creating image of #{name}", MU::NOTICE, details: image_desc
          newimage = MU::Cloud::Google.compute(credentials: credentials).insert_image(
            project,
            MU::Cloud::Google.compute(:Image).new(image_desc)
          )

          if make_public
            MU.log "Making image #{newimage.name} public"
            MU::Cloud::Google.compute(credentials: credentials).set_image_iam_policy(
              project,
              newimage.name,
              MU::Cloud::Google.compute(:GlobalSetPolicyRequest).new(
                bindings: [
                  MU::Cloud::Google.compute(:Binding).new(
                    members: ["allAuthenticatedUsers"],
                    role: "roles/compute.imageUser"
                  )
                ],
              )
            )
          end

          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
          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.resourceClass("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 all of the IP addresses, public and private, from all of our
        # network interfaces.
        # @return [Array<String>]
        def listIPs
          ips = []
          cloud_desc.network_interfaces.each { |iface|
            ips << iface.network_ip
            if iface.access_configs
              iface.access_configs.each { |acfg|
                ips << acfg.nat_ip if acfg.nat_ip
              }
            end
          }
          ips
        end

        # return [String]: A password string.
        def getWindowsAdminPassword(use_cache: true)
          @config['windows_auth_vault'] ||= {
            "vault" => @mu_name,
            "item" => "windows_credentials",
            "password_field" => "password"
          }

          if use_cache
            begin
              win_admin_password = @groomer.getSecret(
                vault: @config['windows_auth_vault']['vault'],
                item: @config['windows_auth_vault']['item'],
                field: @config["windows_auth_vault"]["password_field"]
              )
              return win_admin_password if win_admin_password
            rescue MU::Groomer::MuNoSuchSecret, MU::Groomer::RunError
            end
          end

          require 'openssl/oaep'
          timeout = 300

          serial_out = nil
          key = OpenSSL::PKey::RSA.generate 2048

          missing_response = Proc.new {
            !serial_out or !serial_out.contents or serial_out.contents.empty? or JSON.parse(serial_out.contents)["userName"] != @config['windows_admin_username']
          }

          did_metadata = false
          MU.retrier(loop_if: missing_response, wait: 10, max: timeout/10) {
            serial_out = MU::Cloud::Google.compute(credentials: @credentials).get_instance_serial_port_output(@project_id, @config['availability_zone'], @cloud_id, port: 4)

            if missing_response.call and
               !cloud_desc(use_cache: false).metadata.items.map { |i| i.key }.include?("windows-keys")
              keybytes = Base64.decode64(key.public_key.export.gsub(/-----(?:BEGIN|END) PUBLIC KEY-----/, ''))
              modulus = keybytes.byteslice(33,256)
              exponent = keybytes.byteslice(291,3)
              keydata = {
                "userName" => @config['windows_admin_username'],
                "modulus" => Base64.strict_encode64(modulus),
                "exponent" => Base64.strict_encode64(exponent),
                "email" => MU.muCfg['mu_admin_email'],
                "expireOn" => (Time.now.utc+timeout).strftime('%Y-%m-%dT%H:%M:%SZ')
              }

              new_items = cloud_desc.metadata.items.map { |item|
                MU::Cloud::Google.compute(:Metadata)::Item.new(
                  key: item.key,
                  value: item.value
                )
              }
              new_items.reject! { |item| item.key == "windows-keys" }
              new_items << MU::Cloud::Google.compute(:Metadata)::Item.new(
                key: "windows-keys",
                value: JSON.generate(keydata)
              )
              new_metadata = MU::Cloud::Google.compute(:Metadata).new(
                fingerprint: cloud_desc(use_cache: false).metadata.fingerprint,
                items: new_items
              )

              MU::Cloud::Google.compute(credentials: @credentials).set_instance_metadata(@project_id, @config['availability_zone'], @cloud_id, new_metadata)
            end
          }

          return nil if missing_response.call

          pwdata = JSON.parse(serial_out.contents)
          if pwdata['encryptedPassword'] and pwdata['userName'] == @config['windows_admin_username']
            decrypted_pw = key.private_decrypt_oaep(Base64.strict_decode64(pwdata['encryptedPassword']))
            creds = {
              "username" => @config['windows_admin_username'],
              "password" => decrypted_pw,
              "sshd_username" => "sshd_service",
              "sshd_password" => decrypted_pw
            }
            @groomer.saveSecret(vault: @mu_name, item: "windows_credentials", data: creds, permissions: "name:#{@mu_name}")

            return decrypted_pw
          end

          nil
        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/#{type}",
# 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}"
          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(**_args)
          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, deploy_id: MU.deploy_id, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {})
          flags["habitat"] ||= MU::Cloud::Google.defaultProject(credentials)
          return if !MU::Cloud.resourceClass("Google", "Habitat").isLive?(flags["habitat"], credentials)

# XXX make damn sure MU.deploy_id is set
          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::Cloud::Google.listAZs(region).each { |az|
            disks = []
            resp = MU::Cloud::Google.compute(credentials: credentials).list_instances(
              flags["habitat"],
              az,
              filter: filter
            )
            if !resp.items.nil? and resp.items.size > 0
              resp.items.each { |instance|
                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
                MU::Cloud::Google.compute(credentials: credentials).delete_instance(
                  flags["habitat"],
                  az,
                  instance.name
                ) if !noop
                if instance.service_accounts
                  instance.service_accounts.each { |sa|
                    MU.log "Removing service account #{sa.email}"
                    begin
                      MU::Cloud::Google.iam(credentials: credentials).delete_project_service_account(
                        "projects/#{flags["habitat"]}/serviceAccounts/#{sa.email}"
                      ) if !noop
                    rescue ::Google::Apis::ClientError => e
                      raise e if !e.message.match(/^notFound: /)
                    end
                  }
                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["habitat"],
              az,
              noop
            ) if !noop
          }
        end

        # Cloud-specific configuration properties.
        # @param config [MU::Config]: The calling MU::Config object
        # @return [Array<Array,Hash>]: 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.resourceClass("Google", "User").schema(config)[1]["roles"],
            "windows_admin_username" => {
              "type" => "string",
              "default" => "muadmin"
            },
            "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

          project ||= MU::Cloud::Google.defaultProject(credentials)

          @@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"
              foreign_types = (MU::Cloud.cloudClass(cloud).listInstanceTypes).values.first
              if foreign_types.size == 1
                foreign_types = foreign_types.values.first
              end
              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'] = server['service_account'].to_h
            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
            server = MU::Cloud.resourceClass("Google", "User").genericServiceAccount(server, configurator)
          end

          subnets = nil
          if !server['vpc']
            vpcs = MU::Cloud.resourceClass("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.resourceClass("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['vpc']
            server['vpc']['project'] ||= server['project']
          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
              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

      end #class
    end #class
  end
end #module