cloudamatic/mu

View on GitHub
modules/mu/mommacat.rb

Summary

Maintainability
A
3 hrs
Test Coverage
# Copyright:: Copyright (c) 2014 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.

autoload :Net, 'net/ssh'
require 'fileutils'
require 'json'
require 'stringio'
require 'securerandom'
require 'timeout'
require 'mu/mommacat/storage'
require 'mu/mommacat/search'
require 'mu/mommacat/daemon'
require 'mu/mommacat/naming'

module MU

  # MommaCat is in charge of managing metadata about resources we've created,
  # as well as orchestrating amongst them and bootstrapping nodes outside of
  # the normal synchronous deploy sequence invoked by *mu-deploy*.
  class MommaCat

    # An exception denoting a failure in MommaCat#fetchSecret and related methods
    class SecretError < MuError;
    end

    # Failure to load or create a deploy
    class DeployInitializeError < MuError;
    end

    # Failure to groom a node
    class GroomError < MuError;
    end

    @@litters = {}
    @@litters_loadtime = {}
    @@litter_semaphore = Mutex.new

    # Update the in-memory cache of a given deploy. This is intended for use by
    # {#save!}, primarily.
    # @param deploy_id [String]
    # @param litter [MU::MommaCat]
    def self.updateLitter(deploy_id, litter)
      return if litter.nil?
      @@litter_semaphore.synchronize {
        @@litters[deploy_id] = litter
        @@litters_loadtime[deploy_id] = Time.now
      }
    end

    attr_reader :initializing
    attr_reader :public_key
    attr_reader :deploy_secret
    attr_reader :deployment
    attr_reader :original_config
    attr_reader :environment
    attr_reader :ssh_key_name
    attr_reader :ssh_public_key
    attr_reader :nocleanup
    attr_reader :deploy_id
    attr_reader :timestamp
    attr_reader :appname
    attr_reader :handle
    attr_reader :seed
    attr_reader :mu_user
    attr_reader :clouds
    attr_reader :chef_user
    attr_reader :no_artifacts
    attr_accessor :kittens # really want a method only available to :Deploy
    @nocleanup = false

    @@deploy_struct_semaphore = Mutex.new
    # Don't let things that modify the deploy struct Hash step on each other.
    # @return [Mutex]
    def self.deploy_struct_semaphore;
      @@deploy_struct_semaphore
    end

    # Set the current threads' context (some knucklehead global variables) to
    # values pertinent to the given deployment object.
    # @param deploy [MU::MommaCat]: A deployment object
    def self.setThreadContext(deploy)
      raise MuError, "Didn't get a MU::MommaCat object in setThreadContext" if !deploy.is_a?(MU::MommaCat)
      if !deploy.mu_user.nil?
        MU.setVar("chef_user", deploy.chef_user)
        if deploy.mu_user != "mu" and deploy.mu_user != "root"
          MU.setVar("dataDir", Etc.getpwnam(deploy.mu_user).dir+"/.mu/var")
          MU.setVar("mu_user", deploy.mu_user)
        else
          MU.setVar("dataDir", MU.mainDataDir)
          MU.setVar("mu_user", "root")
        end
      end
      MU.setVar("mommacat", deploy)
      MU.setVar("deploy_id", deploy.deploy_id)
      MU.setVar("appname", deploy.appname)
      MU.setVar("environment", deploy.environment)
      MU.setVar("timestamp", deploy.timestamp)
      MU.setVar("seed", deploy.seed)
      MU.setVar("handle", deploy.handle)
    end

    # @param deploy_id [String]: The MU identifier of the deployment to load or create.
    # @param create [Boolean]: Create a new deployment instead of searching for an existing one.
    # @param deploy_secret [String]: A secret encrypted by the private key of a deployment we're loading. Used to validate remote requests to bootstrap into this deployment.
    # @param config [Hash]: The full configuration, parsed by {MU::Config}, of this deployment. Required when creating a new deployment.
    # @param environment [String]: The environment of a deployment to create.
    # @param ssh_key_name [String]: Required when creating a new deployment.
    # @param ssh_private_key [String]: Required when creating a new deployment.
    # @param ssh_public_key [String]: SSH public key for authorized_hosts on clients.
    # @param skip_resource_objects [Boolean]: Whether preload the cloud resource objects from this deploy. Can save load time for simple MommaCat tasks.
    # @param nocleanup [Boolean]: Skip automatic cleanup of failed resources
    # @param no_artifacts [Boolean]: Do not save deploy metadata
    # @param deployment_data [Hash]: Known deployment data.
    # @return [void]
    def initialize(deploy_id,
                   create: false,
                   deploy_secret: nil,
                   config: nil,
                   environment: "dev",
                   ssh_key_name: nil,
                   ssh_private_key: nil,
                   ssh_public_key: nil,
                   nocleanup: false,
                   appname: nil,
                   timestamp: nil,
                   set_context_to_me: true,
                   skip_resource_objects: false,
                   no_artifacts: false,
                   deployment_data: {},
                   delay_descriptor_load: false,
                   mu_user: Etc.getpwuid(Process.uid).name
    )
      if deploy_id.nil? or deploy_id.empty?
        raise DeployInitializeError, "MommaCat objects must specify a deploy_id"
      end
      set_context_to_me = true if create
      @initializing = true

      @deploy_id = deploy_id
      @mu_user = mu_user.dup
      @no_artifacts = no_artifacts

      # Make sure mu_user and chef_user are sane.
      if @mu_user == "root"
        @chef_user = "mu"
      else
        @chef_user = @mu_user.dup.delete(".")
        @mu_user = "root" if @mu_user == "mu"
      end
      @kitten_semaphore = Mutex.new
      @kittens = {}
      @original_config = MU::Config.manxify(config)
      @nocleanup = nocleanup
      @secret_semaphore = Mutex.new
      @notify_semaphore = Mutex.new
      @need_deploy_flush = false
      @node_cert_semaphore = Mutex.new
      @deployment = deployment_data

      @deployment['mu_public_ip'] = MU.mu_public_ip
      @private_key = nil
      @public_key = nil
      @secrets = Hash.new
      @secrets['instance_secret'] = Hash.new
      @secrets['windows_admin_password'] = Hash.new
      @ssh_key_name = ssh_key_name
      @ssh_private_key = ssh_private_key
      @ssh_public_key = ssh_public_key
      @clouds = {}
      @seed = MU.seed # pass this in
      @handle = MU.handle # pass this in
      @appname = appname
      @appname ||= @original_config['name'] if @original_config
      @timestamp = timestamp
      @environment = environment
      @original_config['environment'] ||= @environment if @original_config

      if set_context_to_me
        MU::MommaCat.setThreadContext(self)
      end

      if create and !@no_artifacts
        initDeployDirectory
        setDeploySecret
        MU::MommaCat.setThreadContext(self) if set_context_to_me
        save!
      end

      @appname ||= MU.appname
      @timestamp ||= MU.timestamp
      @environment ||= MU.environment

      loadDeploy(set_context_to_me: set_context_to_me)
      if !deploy_secret.nil? and !authKey(deploy_secret)
        raise DeployInitializeError, "Client request did not include a valid deploy authorization secret. Verify that userdata runs correctly?"
      end


      @@litter_semaphore.synchronize {
        @@litters[@deploy_id] ||= self
      }

      # Initialize a MU::Cloud object for each resource belonging to this
      # deploy, IF it already exists, which is to say if we're loading an
      # existing deploy instead of creating a new one.
      if !create and @deployment and @original_config and !skip_resource_objects
        loadObjects(delay_descriptor_load)
      end

      @initializing = false

# XXX this .owned? method may get changed by the Ruby maintainers
#     if !@@litter_semaphore.owned?
    end # end of initialize()

    # List all the cloud providers declared by resources in our deploy.
    def cloudsUsed
      seen = []
      seen << @original_config['cloud'] if @original_config['cloud']
      MU::Cloud.resource_types.each_value { |attrs|
        type = attrs[:cfg_plural]
        if @original_config[type]
          @original_config[type].each { |resource|
            seen << resource['cloud'] if resource['cloud']
          }
        end
      }
      seen.uniq
    end

    # Assay this deployment for a list of credentials (from mu.yaml) which are
    # used. Our Cleanup module can leverage this to skip unnecessary checks.
    # @return [Array<String>]
    def credsUsed
      return [] if !@original_config
      seen = []
#      clouds = []
      seen << @original_config['credentials'] if @original_config['credentials']
#      defaultcloud = @original_config['cloud']
      MU::Cloud.resource_types.each_value { |attrs|
        type = attrs[:cfg_plural]
        if @original_config[type]
          @original_config[type].each { |resource|
            if resource['credentials']
              seen << resource['credentials']
            else
              cloudconst = @original_config['cloud'] ? @original_config['cloud'] : MU::Config.defaultCloud
              seen << MU::Cloud.cloudClass(cloudconst).credConfig(name_only: true)
            end
          }
        end
      }
# XXX insert default for each cloud provider if not explicitly seen
      seen.uniq
    end

    # List the accounts/projects/subscriptions used by each resource in our
    # deploy.
    # @return [Array<String>]
    def habitatsUsed
      return [] if !@original_config
      habitats = []
      habitats << @original_config['project'] if @original_config['project']
      if @original_config['habitat']
        hab_ref = MU::Config::Ref.get(@original_config['habitat'])
        if hab_ref and hab_ref.id
          habitats << hab_ref.id
        end
      end

      MU::Cloud.resource_types.each_value { |attrs|
        type = attrs[:cfg_plural]
        if @original_config[type]
          @original_config[type].each { |resource|
            if resource['project']
              habitats << resource['project']
            elsif resource['habitat']
              hab_ref = MU::Config::Ref.get(resource['habitat'])
              if hab_ref and hab_ref.id
                habitats << hab_ref.id
              end
            elsif resource['cloud']
              # XXX this should be a general method implemented by each cloud
              # provider
              if resource['cloud'] == "Google"
                habitats << MU::Cloud.cloudClass(resource['cloud']).defaultProject(resource['credentials'])
              end
            end
          }
        end
      }

      habitats.uniq!
    end

    # List the regions used by each resource in our deploy. This will just be
    # a flat list of strings with no regard to which region belongs with what
    # cloud provider- things mostly use this as a lookup table so they can
    # safely skip unnecessary regions when creating/cleaning deploy artifacts.
    # @return [Array<String>]
    def regionsUsed
      return [] if !@original_config
      regions = []
      regions << @original_config['region'] if @original_config['region']
      MU::Cloud.resource_types.each_pair { |res_type, attrs|
        type = attrs[:cfg_plural]
        if @original_config[type]
          @original_config[type].each { |resource|
            if resource['cloud']
              if MU::Cloud.resourceClass(resource['cloud'], res_type).isGlobal?
# XXX why was I doing this, urgh
                next
              elsif !resource['region']
                regions << MU::Cloud.cloudClass(resource['cloud']).myRegion(resource['credentials'])
              end
            end
            if resource['region']
              regions << resource['region'] if resource['region']
            else
            end
          }
        end
      }

      regions.uniq
    end

    # Tell us the number of first-class resources we've configured, optionally
    # filtering results to only include a given type and/or in a given cloud
    # environment.
    # @param clouds [Array<String>]: The cloud environment(s) to check for. If unspecified, will match all environments in this deployment.
    # @param types [Array<String>]: The type of resource(s) to check for. If unspecified, will match all resources in this deployment.
    # @param negate [Boolean]: Invert logic of the other filters if they are specified, e.g. search for all cloud resources that are *not* AWS.
    def numKittens(clouds: [], types: [], negate: false)
      realtypes = []
      return 0 if @original_config.nil?
      if !types.nil? and types.size > 0
        types.each { |type|
          cfg_plural = MU::Cloud.getResourceNames(type)[2]
          realtypes << cfg_plural
        }
      end

      count = 0
      MU::Cloud.resource_types.each_value { |data|
        next if @original_config[data[:cfg_plural]].nil?
        next if realtypes.size > 0 and (!negate and !realtypes.include?(data[:cfg_plural]))
        @original_config[data[:cfg_plural]].each { |resource|
          if clouds.nil? or clouds.size == 0 or (!negate and clouds.include?(resource["cloud"])) or (negate and !clouds.include?(resource["cloud"]))
            count = count + 1
          end
        }
      }
      count
    end

    # @param object [MU::Cloud]:
    def removeKitten(object)
      if !object
        raise MuError, "Nil arguments to removeKitten are not allowed"
      end
      @kitten_semaphore.synchronize {
        MU::Cloud.resource_types.each_value { |attrs|
          type = attrs[:cfg_plural]
          next if !@kittens.has_key?(type)
          tmplitter = @kittens[type].values.dup
          tmplitter.each { |nodeclass, data|
            if data.is_a?(Hash)
              data.each_key { |mu_name|
                if data == object
                  @kittens[type][nodeclass].delete(mu_name)
                  return
                end
              }
            else
              if data == object
                @kittens[type].delete(nodeclass)
                return
              end
            end
          }
        }
      }
      @kittens
    end

    # Keep tabs on a {MU::Cloud} object so that it can be found easily by
    # #findLitterMate.
    # @param type [String]:
    # @param name [String]:
    # @param object [MU::Cloud]:
    def addKitten(type, name, object, do_notify: false)
      if !type or !name or !object or !object.mu_name
        raise MuError, "Nil arguments to addKitten are not allowed (got type: #{type}, name: #{name}, and '#{object}' to add)"
      end

      _shortclass, _cfg_name, type, _classname, attrs = MU::Cloud.getResourceNames(type)
      object.intoDeploy(self)

      add_block = Proc.new {
        @kittens[type] ||= {}
        @kittens[type][object.habitat] ||= {}
        if attrs[:has_multiples]
          @kittens[type][object.habitat][name] ||= {}
          @kittens[type][object.habitat][name][object.mu_name] = object
        else
          @kittens[type][object.habitat][name] = object
        end
        if do_notify
          notify(type, name, object.notify, triggering_node: object, delayed_save: true)
        end
      }

      begin
        @kitten_semaphore.synchronize {
          add_block.call()
        }
      rescue ThreadError => e
        # already locked by a parent call to this method, so this should be safe
        raise e if !e.message.match(/recursive locking/)
        add_block.call()
      end
    end

    # Encrypt a string with the deployment's public key.
    # @param ciphertext [String]: The string to encrypt
    def encryptWithDeployKey(ciphertext)
      my_public_key = OpenSSL::PKey::RSA.new(@public_key)
      return my_public_key.public_encrypt(ciphertext)
    end

    # Decrypt a string with the deployment's private key.
    # @param ciphertext [String]: The string to decrypt
    def decryptWithDeployKey(ciphertext)
      my_private_key = OpenSSL::PKey::RSA.new(@private_key)
      return my_private_key.private_decrypt(ciphertext)
    end

    # Save a string into deployment metadata for the current deployment,
    # encrypting it with our deploy key.
    # @param instance_id [String]: The cloud instance identifier with which this secret is associated.
    # @param raw_secret [String]: The unencrypted string to store.
    # @param type [String]: The type of secret, used to identify for retrieval.
    def saveNodeSecret(instance_id, raw_secret, type)
      return if @no_artifacts
      if instance_id.nil? or instance_id.empty? or raw_secret.nil? or raw_secret.empty? or type.nil? or type.empty?
        raise SecretError, "saveNodeSecret requires instance_id (#{instance_id}), raw_secret (#{raw_secret}), and type (#{type}) args"
      end
      MU::MommaCat.lock("deployment-notification")
      loadDeploy(true) # make sure we're not trampling deployment data
      @secret_semaphore.synchronize {
        if @secrets[type].nil?
          raise SecretError, "'#{type}' is not a valid secret type (valid types: #{@secrets.keys.join(", ")})"
        end
        @secrets[type][instance_id] = encryptWithDeployKey(raw_secret)
      }
      save!
      MU::MommaCat.unlock("deployment-notification")
    end

    # Retrieve an encrypted secret from metadata for the current deployment.
    # @param instance_id [String]: The cloud instance identifier with which this secret is associated.
    # @param type [String]: The type of secret, used to identify for retrieval.
    # @param quiet [Boolean]: Do not log errors for non-existent secrets
    def fetchSecret(instance_id, type, quiet: false)
      @secret_semaphore.synchronize {
        if @secrets[type].nil?
          return nil if quiet
          raise SecretError, "'#{type}' is not a valid secret type (valid types: #{@secrets.keys.join(", ")})"
        end
        if @secrets[type][instance_id].nil?
          return nil if quiet
          raise SecretError, "No '#{type}' secret known for instance #{instance_id}"
        end
      }
      return decryptWithDeployKey(@secrets[type][instance_id])
    end

    # Return the parts and pieces of this deploy's node ssh key set. Generate
    # or load if that hasn't been done already.
    def SSHKey
      return [@ssh_key_name, @ssh_private_key, @ssh_public_key] if !@ssh_key_name.nil?
      if numKittens(types: ["Server", "ServerPool", "ContainerCluster"]) == 0
        return []
      end
      @ssh_key_name="deploy-#{MU.deploy_id}"
      ssh_dir = Etc.getpwnam(@mu_user).dir+"/.ssh"

      if !File.directory?(ssh_dir) then
        MU.log "Creating #{ssh_dir}", MU::DEBUG
        Dir.mkdir(ssh_dir, 0700)
        if Process.uid == 0 and @mu_user != "mu"
          FileUtils.chown Etc.getpwnam(@mu_user).uid, Etc.getpwnam(@mu_user).gid, ssh_dir
        end
      end
      if !File.exist?("#{ssh_dir}/#{@ssh_key_name}")
        MU.log "Generating SSH key #{@ssh_key_name}"
        %x{/usr/bin/ssh-keygen -N "" -f #{ssh_dir}/#{@ssh_key_name}}
      end
      @ssh_public_key = File.read("#{ssh_dir}/#{@ssh_key_name}.pub")
      @ssh_public_key.chomp!
      @ssh_private_key = File.read("#{ssh_dir}/#{@ssh_key_name}")
      @ssh_private_key.chomp!

# XXX the following mess belongs in cloud layers, probably in their initDeploy
# methods
      if numKittens(clouds: ["AWS"], types: ["Server", "ServerPool", "ContainerCluster"]) > 0
        creds_used = []
        ["servers", "server_pools", "container_clusters"].each { |type|
          next if @original_config[type].nil?
          @original_config[type].each { |descriptor|
            next if descriptor['cloud'] != "AWS"
            if descriptor['credentials']
              creds_used << descriptor['credentials']
            else
              creds_used << MU::Cloud::AWS.credConfig(name_only: true)
            end
          }
        }
        creds_used << nil if creds_used.empty?

        creds_used.uniq.each { |credset|
          MU::Cloud::AWS.createEc2SSHKey(@ssh_key_name, @ssh_public_key, credentials: credset)
        }
      end

      return [@ssh_key_name, @ssh_private_key, @ssh_public_key]
    end

    @@dummy_cache = {}

    # Add or remove a resource's metadata to this deployment's structure and
    # flush it to disk.
    # @param type [String]: The type of resource (e.g. *server*, *database*).
    # @param key [String]: The name field of this resource.
    # @param mu_name [String]: The mu_name of this resource.
    # @param data [Hash]: The resource's metadata.
    # @param triggering_node [MU::Cloud]: A cloud object calling this notify, usually on behalf of itself
    # @param remove [Boolean]: Remove this resource from the deploy structure, instead of adding it.
    # @return [void]
    def notify(type, key, data, mu_name: nil, remove: false, triggering_node: nil, delayed_save: false)
      no_write = (@no_artifacts or !caller.grep(/\/mommacat\.rb:\d+:in `notify'/).empty?)

      begin
        if !no_write
          if !MU::MommaCat.lock("deployment-notification", deploy_id: @deploy_id, retries: 300)
            raise MuError, "Failed to get deployment-notifcation lock for #{@deploy_id}"
          end
        end

        if !@need_deploy_flush or @deployment.nil? or @deployment.empty?
          loadDeploy(true) # make sure we're saving the latest and greatest
        end

        @timestamp ||= @deployment['timestamp']
        @seed ||= @deployment['seed']
        @appname ||= @deployment['appname']
        @handle ||= @deployment['handle']
        
        _shortclass, _cfg_name, mu_type, _classname, attrs = MU::Cloud.getResourceNames(type, false)
        type = mu_type if mu_type
        has_multiples = attrs[:has_multiples] ? true : false

        mu_name ||= if !data.nil? and !data["mu_name"].nil?
          data["mu_name"]
        elsif !triggering_node.nil? and !triggering_node.mu_name.nil?
          triggering_node.mu_name
        end
        if mu_name.nil? and has_multiples
          MU.log "MU::MommaCat.notify called to modify deployment struct for a type (#{type}) with :has_multiples, but no mu_name available to look under #{key}. Call was #{caller(1..1)}", MU::WARN, details: data
          return
        end

        @need_deploy_flush = true
        @last_modified = Time.now

        if !remove
          if data.nil?
            MU.log "MU::MommaCat.notify called to modify deployment struct, but no data provided", MU::WARN
            return
          end
          @notify_semaphore.synchronize {
            @deployment[type] ||= {}
          }
          if has_multiples
            @notify_semaphore.synchronize {
              @deployment[type][key] ||= {}
            }
            @deployment[type][key][mu_name] = data
            MU.log "Adding to @deployment[#{type}][#{key}][#{mu_name}]", MU::DEBUG, details: data
          else
            @deployment[type][key] = data
            MU.log "Adding to @deployment[#{type}][#{key}]", MU::DEBUG, details: data
          end
          if !delayed_save and !no_write
            save!(key)
          end
        else
          have_deploy = true
          if @deployment[type].nil? or @deployment[type][key].nil?
            MU.log "MU::MommaCat.notify called to remove #{type} #{key}#{has_multiples ? " "+mu_name : ""} deployment struct, but no such data exist", MU::DEBUG
            return
          end

          if have_deploy
            @notify_semaphore.synchronize {
              if has_multiples
                MU.log "Removing @deployment[#{type}][#{key}][#{mu_name}]", MU::DEBUG, details: @deployment[type][key][mu_name]
                @deployment[type][key].delete(mu_name)
              end

              if @deployment[type][key].empty? or !has_multiples
                MU.log "Removing @deployment[#{type}][#{key}]", MU::DEBUG, details: @deployment[type][key]
                @deployment[type].delete(key)
              end

              if @deployment[type].empty?
                @deployment.delete(type)
              end
            }
          end
          save! if !delayed_save and !no_write
        end
      ensure
        MU::MommaCat.unlock("deployment-notification", deploy_id: @deploy_id) if !no_write
      end
    end

    # Send a Slack notification to a deployment's administrators.
    # @param subject [String]: The subject line of the message.
    # @param msg [String]: The message body.
    # @return [void]
    def sendAdminSlack(subject, msg: "", scrub_mu_isms: true, snippets: [], noop: false)
      if MU.muCfg['slack'] and MU.muCfg['slack']['webhook'] and
         (!MU.muCfg['slack']['skip_environments'] or !MU.muCfg['slack']['skip_environments'].any?{ |s| s.casecmp(MU.environment)==0 })
        require 'slack-notifier'
        slackargs = nil
        keyword_args = { channel: MU.muCfg['slack']['channel'] }
        begin
          slack = Slack::Notifier.new MU.muCfg['slack']['webhook']
          prefix = scrub_mu_isms ? subject : "#{MU.appname} \*\"#{MU.handle}\"\* (`#{MU.deploy_id}`) - #{subject}"

          text = if msg and !msg.empty?
            "#{prefix}:\n\n```#{msg}```"
          else
            prefix
          end

          if snippets and snippets.size > 0
            keyword_args[:attachments] = snippets
          end

          if !noop
            slack.ping(text, **keyword_args)
          else
            MU.log "Would send to #{MU.muCfg['slack']['channel']}", MU::NOTICE, details: [ text, keyword_args ]
          end
        rescue Slack::Notifier::APIError => e
          MU.log "Failed to send message to slack: #{e.message}", MU::ERR, details: keyword_args
          return false
        end
      end
      true
    end

    # Send an email notification to a deployment's administrators.
    # @param subject [String]: The subject line of the message.
    # @param msg [String]: The message body.
    # @param data [Array]: Supplemental data to add to the message body.
    # @param debug [Boolean]: If set, will include the full deployment structure and original {MU::Config}-parsed configuration.
    # @return [void]
    def sendAdminMail(subject, msg: "", kitten: nil, data: nil, debug: false)
      require 'net/smtp'
      if @deployment.nil?
        MU.log "Can't send admin mail without a loaded deployment", MU::ERR
        return
      end
      to = Array.new
      if !@original_config.nil?
        @original_config['admins'].each { |admin|
          to << "#{admin['name']} <#{admin['email']}>"
        }
      end
      message = <<MAIL_HEAD_END
From: #{MU.handle} <root@localhost>
To: #{to.join(",")}
Subject: #{subject}

      #{msg}
MAIL_HEAD_END
      if !kitten.nil? and kitten.kind_of?(MU::Cloud)
        message = message + "\n\n**** #{kitten}:\n"
        if !kitten.report.nil?
          kitten.report.each { |line|
            message = message + line
          }
        end
      end
      if !data.nil?
        message = message + "\n\n" + PP.pp(data, "")
      end
      if debug
        message = message + "\n\n**** Stack configuration:\n" + PP.pp(@original_config, "")
        message = message + "\n\n**** Deployment structure:\n" + PP.pp(@deployment, "")
      end
      begin
        Net::SMTP.start('localhost') do |smtp|
          smtp.send_message message, "root@localhost", to
        end
      rescue Net::SMTPFatalError, Errno::ECONNREFUSED => e
        MU.log e.inspect, MU::WARN
      end
    end

    # Return a list of all nodes associated with the current deployment.
    # @return [Hash]
    def listNodes
      nodes = Hash.new
      if !@deployment['servers'].nil?
        @deployment['servers'].each_pair { |nodetype, node|
          node.each_pair { |name, metadata|
            if name.nil? or metadata.nil? or !metadata.is_a?(Hash)
              MU.log "Original config of deploy #{MU.deploy_id} looks funny. It's probably very old.", MU::WARN
              next
            end
            metadata['deploy_id'] = MU.deploy_id
            nodes[name] = metadata
            ['servers', 'server_pools'].each { |res_type|
              if !@original_config[res_type].nil?
                @original_config[res_type].each { |srv_conf|
                  if srv_conf['name'] == nodetype
                    nodes[name]['conf'] = srv_conf.dup
                  end
                }
              end
            }
          }
        }
      end

      return nodes
    end

    # For a given (Windows) server, return it's administrator user and password.
    # This is generally for requests made to MommaCat from said server, which
    # we can assume have been authenticated with the deploy secret.
    # @param server [MU::Cloud::Server]: The Server object whose credentials we're fetching.
    def retrieveWindowsAdminCreds(server)
      if server.nil?
        raise MuError, "retrieveWindowsAdminCreds must be called with a Server object"
      elsif !server.is_a?(MU::Cloud::Server)
        raise MuError, "retrieveWindowsAdminCreds must be called with a Server object (got #{server.class.name})"
      end
      if server.config['use_cloud_provider_windows_password']
        return [server.config["windows_admin_username"], server.getWindowsAdminPassword]
      elsif server.config['windows_auth_vault'] && !server.config['windows_auth_vault'].empty?
        if server.config["windows_auth_vault"].has_key?("password_field")
          return [server.config["windows_admin_username"],
            server.groomer.getSecret(
              vault: server.config['windows_auth_vault']['vault'],
              item: server.config['windows_auth_vault']['item'],
              field: server.config["windows_auth_vault"]["password_field"]
            )]
        else
          return [server.config["windows_admin_username"], server.getWindowsAdminPassword]
        end
      end
      []
    end

    # Given a Certificate Signing Request, sign it with our internal CA and
    # write the resulting signed certificate. Only works on local files.
    # @param csr_path [String]: The CSR to sign, as a file.
    def signSSLCert(csr_path, sans = [])
      MU::Master::SSL.sign(csr_path, sans, for_user: MU.mu_user)
    end

    # Make sure deployment data is synchronized to/from each +Server+ in the
    # currently-loaded deployment.
    # @param nodeclasses [Array<String>]
    # @param triggering_node [String,MU::Cloud::Server]
    # @param save_only [Boolean]
    def syncLitter(nodeclasses = [], triggering_node: nil, save_only: false)
      return if MU.syncLitterThread # don't run recursively by accident
      return if !Dir.exist?(deploy_dir)

      if !triggering_node.nil? and triggering_node.is_a?(MU::Cloud::Server)
        triggering_node = triggering_node.mu_name
      end

      siblings = findLitterMate(type: "server", return_all: true)
      return if siblings.nil? or (siblings.respond_to?(:empty?) and siblings.empty?)

      update_servers = []
      siblings.each_pair { |mu_name, node|
        next if mu_name == triggering_node or node.groomer.nil?
        next if nodeclasses.size > 0 and !nodeclasses.include?(node.config['name'])
        if !node.deploydata or !node.deploydata['nodename']
          MU.log "#{mu_name} deploy data is missing (possibly retired or mid-bootstrap), so not syncing it", MU::NOTICE
          next
        end

        if @deployment["servers"][node.config['name']][node.mu_name].nil? or
           @deployment["servers"][node.config['name']][node.mu_name] != node.deploydata
          @deployment["servers"][node.config['name']][node.mu_name] = node.deploydata
        elsif !save_only
          # Don't bother running grooms on nodes that don't need to be updated,
          # unless we're just going to do a save.
          next
        end
        update_servers << node
      }

      return if update_servers.empty?

      MU.log "Updating nodes in #{@deploy_id}", MU::DEBUG, details: update_servers.map { |n| n.mu_name }

      threads = []
      update_servers.each { |sibling|
        next if sibling.config.has_key?("groom") and !sibling.config["groom"]
        threads << Thread.new {
          Thread.abort_on_exception = true
          Thread.current.thread_variable_set("name", "sync-"+sibling.mu_name.downcase)
          MU.setVar("syncLitterThread", true)
          begin
            sibling.groomer.saveDeployData
            sibling.groomer.run(purpose: "Synchronizing sibling kittens") if !save_only
          rescue MU::Groomer::RunError => e
            MU.log "Sync of #{sibling.mu_name} failed", MU::WARN, details: e.inspect
          end
        }
      }

      threads.each { |t|
        t.join
      }

      MU.log "Synchronization of #{@deploy_id} complete", MU::DEBUG, details: update_servers
    end

    @node_cert_semaphore = nil
    # Given a MU::Cloud object, return the generic self-signed SSL
    # certficate we made for it. If one doesn't exist yet, generate it first.
    # If it's a Windows node, also generate a certificate for WinRM client auth.
    # @param resource [MU::Cloud]: The server or other MU::Cloud resource object for which to generate or return the cert
    # @param poolname [Boolean]: If true, generate certificates for the base name of the server pool of which this node is a member, rather than for the individual node
    # @param keysize [Integer]: The size of the private key to use when generating this certificate
    def nodeSSLCerts(resource, poolname = false, keysize = 4096)
      _nat_ssh_key, _nat_ssh_user, _nat_ssh_host, canonical_ip, _ssh_user, _ssh_key_name = resource.getSSHConfig if resource.respond_to?(:getSSHConfig)

      deploy_id = resource.deploy_id || @deploy_id || resource.deploy.deploy_id

      cert_cn = poolname ? deploy_id + "-" + resource.config['name'].upcase : resource.mu_name

      results = {}

      is_windows = (resource.respond_to?(:windows?) and resource.windows?)

      @node_cert_semaphore.synchronize {
        MU::Master::SSL.bootstrap
        sans = []
        sans << canonical_ip if canonical_ip
        sans << resource.mu_name.downcase if resource.mu_name and resource.mu_name != cert_cn
        # XXX were there other names we wanted to include?
        key = MU::Master::SSL.getKey(cert_cn, keysize: keysize)
        cert, pfx_cert = MU::Master::SSL.getCert(cert_cn, "/CN=#{cert_cn}/O=Mu/C=US", sans: sans, pfx: is_windows)
        results[cert_cn] = [key, cert]

        winrm_cert = nil
        if is_windows
          winrm_key = MU::Master::SSL.getKey(cert_cn+"-winrm", keysize: keysize)
          winrm_cert = MU::Master::SSL.getCert(cert_cn+"-winrm", "/CN=#{resource.config['windows_admin_username']}/O=Mu/C=US", sans: ["otherName:1.3.6.1.4.1.311.20.2.3;UTF8:#{resource.config['windows_admin_username']}@localhost"], pfx: true)[0]
          results[cert_cn+"-winrm"] = [winrm_key, winrm_cert]
        end

        if resource and resource.config and resource.config['cloud']
          cloudclass = MU::Cloud.cloudClass(resource.config['cloud'])

          cloudclass.writeDeploySecret(self, cert.to_pem, cert_cn+".crt", credentials: resource.config['credentials'])
          cloudclass.writeDeploySecret(self, key.to_pem, cert_cn+".key", credentials: resource.config['credentials'])
          if pfx_cert
            cloudclass.writeDeploySecret(self, pfx_cert.to_der, cert_cn+".pfx", credentials: resource.config['credentials'])
          end
          if winrm_cert
            cloudclass.writeDeploySecret(self, winrm_cert.to_pem, cert_cn+"-winrm.crt", credentials: resource.config['credentials'])
          end
        end

      }

      results[cert_cn]
    end

    private

    def createDeployKey
      key = OpenSSL::PKey::RSA.generate(4096)
      MU.log "Generated deploy key for #{MU.deploy_id}", MU::DEBUG, details: key.public_key.export
      return [key.export, key.public_key.export]
    end

    ###########################################################################
    ###########################################################################
    def setThreadContextToMe

      ["appname", "environment", "timestamp", "seed", "handle"].each { |var|
        @deployment[var] ||= instance_variable_get("@#{var}".to_sym)
        if @deployment[var]
          if var != "handle"
            MU.setVar(var, @deployment[var].upcase)
          else
            MU.setVar(var, @deployment[var])
          end
        else
          MU.log "Missing global variable #{var} for #{MU.deploy_id}", MU::ERR
        end
      }
    end

  end #class
end #module