cloudamatic/mu

View on GitHub
modules/mommacat.ru

Summary

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

require 'pp'
require 'base64'
require 'etc'

Etc.getpwuid(Process.uid).dir

if !ENV.include?('MU_INSTALLDIR')
  ENV['MU_INSTALLDIR'] = "/opt/mu"
end
if !ENV.include?('MU_LIBDIR')
  ENV['MU_LIBDIR'] = ENV['MU_INSTALLDIR']+"/lib"
end

$MUDIR = ENV['MU_LIBDIR']

$LOAD_PATH << "#{$MUDIR}/modules"

require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb"))
require 'mu'

begin
  MU::Groomer::Chef.loadChefLib # pre-cache this so we don't take a hit on a user-interactive need
  $ENABLE_SCRATCHPAD = true
rescue LoadError
  MU.log "Chef libraries not available, disabling Scratchpad", MU::WARN
end
#MU.setLogging($opts[:verbose], $opts[:web])
if MU.myCloud == "AWS"
  MU::Cloud::AWS.openFirewallForClients # XXX add the other clouds, or abstract
end

Signal.trap("URG") do
  puts "------------------------------"
  puts "Open flock() locks:"
  pp MU::MommaCat.trapSafeLocks
  puts "------------------------------"
end

begin
  MU::Master.syncMonitoringConfig(false)
rescue StandardError => e
  MU.log e.inspect, MU::ERR, details: e.backtrace
  # ...but don't die!
end

parent_thread_id = Thread.current.object_id
Thread.new {
  MU.dupGlobals(parent_thread_id)
  begin
    MU::MommaCat.cleanTerminatedInstances
    MU::Master.cleanExpiredScratchpads if $ENABLE_SCRATCHPAD
    sleep 60
  rescue StandardError => e
    MU.log "Error in cleanTerminatedInstances thread: #{e.inspect}", MU::ERR, details: e.backtrace
    retry
  end while true
}

# Use a template to generate a pleasant-looking HTML page for simple messages
# and errors.
def genHTMLMessage(title: "", headline: "", msg: "", template: $MU_CFG['html_template'], extra_vars: {})
  logo_url = "/cloudamatic.png"
  page = "<img src='#{logo_url}'><h1>#{title}</h1>#{msg}"
  vars = {
    "title" => title,
    "msg" => msg,
    "headline" => headline,
    "logo" => logo_url
  }.merge(extra_vars)
  if !template.nil? and File.exist?(template) and File.readable?(template)
    page = Erubis::Eruby.new(File.read(template)).result(vars)
  elsif $MU_CFG.has_key?('html_template') and
     File.exist?($MU_CFG['html_template']) and
     File.readable?($MU_CFG['html_template'])
    page = Erubis::Eruby.new(File.read($MU_CFG['html_template'])).result(vars)
  elsif File.exist?("#{$MU_CFG['libdir']}/modules/html.erb") and
     File.readable?("#{$MU_CFG['libdir']}/modules/html.erb")
    page = Erubis::Eruby.new(File.read("#{$MU_CFG['libdir']}/modules/html.erb")).result(vars)
  end
  page
end

# Return an error message to web clients.
def throw500(msg = "", details = nil)
  MU.log "Returning 500 to client: #{msg}", MU::ERR, details: details
  page = genHTMLMessage(title: "500 Error", headline: msg, msg: details)
  [
      500,
      {
          'Content-Type' => 'text/html',
          'Content-Length' => page.length.to_s
      },
      [page]
  ]
end

def throw404(msg = "", details = nil)
  MU.log "Returning 404 to client: #{msg}", MU::ERR, details: details
  page = genHTMLMessage(title: "404 Not Found", headline: msg, msg: details)
  [
      404,
      {
          'Content-Type' => 'text/html',
          'Content-Length' => page.length.to_s
      },
      [page]
  ]
end

def returnRawJSON(data)
  MU.log "Returning 200 to client", MU::NOTICE, details: data
  [
      200,
      {
          'Content-Type' => 'application/json',
          'Content-Length' => data.length.to_s
      },
      [data]
  ]
end

@litters = Hash.new
@litter_semaphore = Mutex.new


# Load a {MU::MommaCat} instance for the requested deployment.
# @param req [Hash]: The web request describing the requested deployment. Must include a *mu_id* and *mu_deploy_secret*. 
# return [MU::MommaCat]
def getKittenPile(req)
  @litter_semaphore.synchronize {
    mu_id = req["mu_id"]
    if !@litters.has_key?(mu_id)
      begin
        kittenpile = MU::MommaCat.new(
            mu_id,
            deploy_secret: Base64.urlsafe_decode64(req["mu_deploy_secret"]),
            set_context_to_me: true,
            mu_user: req['mu_user']
        )
      rescue MU::MommaCat::DeployInitializeError => e
        MU.log e.inspect, MU::ERR, details: req
        return nil
      end
      @litters[mu_id] = Hash.new
      @litters[mu_id]['kittenpile'] = kittenpile
      @litters[mu_id]['kittencount'] = 1
      @litters[mu_id]['threads'] = [Thread.current.object_id]
    else
      @litters[mu_id]['kittencount'] += 1
      @litters[mu_id]['threads'] << Thread.current.object_id
      MU.dupGlobals(@litters[mu_id]['threads'].first)
    end
    # Make sure enough per-thread global MU class variables are set for us
    # to operate in this thread context

    MU.setVar("mu_id", mu_id)
    MU.setVar("mommacat", @litters[mu_id]['kittenpile'])
    MU.log "Adding kitten in #{mu_id}: #{@litters[mu_id]['kittencount']}", MU::DEBUG, details: @litters
    return @litters[mu_id]['kittenpile']
  }
end

# Release a {MU::MommaCat} object.
# @param mu_id [String]: The MU identifier of the loaded deploy to replace.
def releaseKitten(mu_id)
  @litter_semaphore.synchronize {
    if @litters.has_key?(mu_id)
      @litters[mu_id]['kittencount'] -= 1
      @litters[mu_id]['threads'].delete(Thread.current.object_id)
      MU.log "Releasing kitten in #{mu_id}: #{@litters[mu_id]['kittencount']}", MU::DEBUG, details: @litters
      if @litters[mu_id]['kittencount'] < 1
        @litters.delete(mu_id)
      end
      MU.purgeGlobals
    end
  }
end

app = proc do |env|
  returnval = [
      200,
      {
          'Content-Type' => 'text/html',
          'Content-Length' => '2'
      },
      ['hi']
  ]
  begin
    if !env.nil? and !env['REQUEST_PATH'].nil? and env['REQUEST_PATH'].match(/^\/scratchpad/)
      if !$ENABLE_SCRATCHPAD
        msg = "Scratchpad disabled in non-Chef Mu installations"
        return [
          504,
          {
            'Content-Type' => 'text/html',
            'Content-Length' => msg.length.to_s
          },
          [msg]
        ]
      end
      itemname = env['REQUEST_PATH'].sub(/^\/scratchpad\//, "")
      begin
        if itemname.sub!(/\/secret$/, "")
          secret = MU::Master.fetchScratchPadSecret(itemname)
          MU.log "Retrieved scratchpad secret #{itemname} for #{env['REMOTE_ADDR']}"
          returnval = [
            200,
            {
              'Content-Type' => 'text/plain',
              'Content-Length' => secret.length.to_s
            },
            [secret]
          ]
        else
          secret = "
<script>
  var xhttp = new XMLHttpRequest();
  xhttp.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      document.getElementById('scratchpad-button').outerHTML = this.responseText;
    }
  };
  function showScratchPadSecret(){
    xhttp.open('GET', '#{env['REQUEST_PATH']}/secret', true);
    xhttp.send();
  }
</script>
<button id='scratchpad-button' onclick='showScratchPadSecret()'>Show My Secret</button>
"
          page = nil
          if $MU_CFG.has_key?('scratchpad') and
             $MU_CFG['scratchpad'].has_key?("template_path") and
             File.exist?($MU_CFG['scratchpad']['template_path']) and
             File.readable?($MU_CFG['scratchpad']['template_path'])
            page = genHTMLMessage(
              title: "Your Scratchpad Secret",
              headline:"<strong>YOU MAY ONLY RETRIVE THIS SECRET ONCE!</strong> Be sure to copy it somewhere safe before reloading, browsing away, or closing your browser window.",
              msg: secret,
              template: $MU_CFG['scratchpad']['template_path'],
              extra_vars: { "secret" => secret }
            )
          else
            page = genHTMLMessage(
              title: "Your Scratchpad Secret",
              headline:"<strong>YOU MAY ONLY RETRIVE THIS SECRET ONCE!</strong> Be sure to copy it somewhere safe before reloading, browsing away, or closing your browser window.",
              msg: secret
            )
          end

          returnval = [
            200,
            {
              'Content-Type' => 'text/html',
              'Content-Length' => page.length.to_s
            },
            [page]
          ]
        end
      rescue MU::Groomer::MuNoSuchSecret
        page = nil
        if $MU_CFG.has_key?('scratchpad') and
           $MU_CFG['scratchpad'].has_key?("template_path") and
           File.exist?($MU_CFG['scratchpad']['template_path']) and
           File.readable?($MU_CFG['scratchpad']['template_path'])
          page = genHTMLMessage(
            title: "No such secret",
            headline: "No such secret",
            msg: "The secret '#{itemname}' does not exist or has already been retrieved",
            template: $MU_CFG['scratchpad']['template_path'],
            extra_vars: { "secret" => nil }
          )
        else
          page = genHTMLMessage(
            title: "No such secret",
            headline: "No such secret",
            msg: "The secret '#{itemname}' does not exist or has already been retrieved"
            )
        end
        returnval = [
          200,
          {
            'Content-Type' => 'text/html',
            'Content-Length' => page.length.to_s
          },
          [page]
        ]
      end
    elsif !env.nil? and !env['REQUEST_PATH'].nil? and env['REQUEST_PATH'].match(/^\/rest\//)

      action, filter, path = env['REQUEST_PATH'].sub(/^\/rest\/?/, "").split(/\//, 3)
      # Don't give away the store. This can't be public until we can
      # authenticate and access-control properly.
      if env['REMOTE_ADDR'] != "127.0.0.1" and action != "bucketname"
        returnval = throw500 "Service not available"
        next
      end

      if action == "hosts_add"
        if Process.uid != 0
          returnval = throw500 "Service not available"
        elsif !filter or !path
          returnval = throw404 env['REQUEST_PATH']
        else
          MU::Master.addInstanceToEtcHosts(path, filter)
          returnval = [
            200,
            {
              'Content-Type' => 'text/plain',
              'Content-Length' => 2
            },
            ["ok"]
          ]
        end
      elsif action == "deploy"
        returnval = throw404 env['REQUEST_PATH'] if !filter
        MU.log "Loading deploy data for #{filter} #{path}"
        kittenpile = MU::MommaCat.getLitter(filter)
        returnval = returnRawJSON JSON.generate(kittenpile.deployment)
      elsif action == "config"
        returnval = throw404 env['REQUEST_PATH'] if !filter
        MU.log "Loading config #{filter} #{path}"
        kittenpile = MU::MommaCat.getLitter(filter)
        returnval = returnRawJSON JSON.generate(kittenpile.original_config)
      elsif action == "list"
        MU.log "Listing deployments"
        returnval = returnRawJSON JSON.generate(MU::MommaCat.listDeploys)
      elsif action == "bucketname"
        returnval = [
          200,
          {
            'Content-Type' => 'text/plain',
            'Content-Length' => MU.adminBucketName(filter, credentials: path).length.to_s
          },
          [MU.adminBucketName(filter, credentials: path)]
        ]
      else
        returnval = throw404 env['REQUEST_PATH']
      end

    elsif !env["rack.input"].nil?
      req = Rack::Utils.parse_nested_query(env["rack.input"].read)

      if req["mu_user"].nil?
        req["mu_user"] = "mu"
      end
      requesttype = nil
      ["mu_ssl_sign", "mu_bootstrap", "mu_windows_admin_creds", "add_volume"].each { |rt|
        if req[rt]
          requesttype = rt
          break
        end
      }

      MU.log "Processing #{requesttype} request from #{env["REMOTE_ADDR"]} (MU-ID #{req["mu_id"]}, #{req["mu_resource_type"]}: #{req["mu_resource_name"]}, instance: #{req["mu_instance_id"]}, mu_user #{req['mu_user']}, path #{env['REQUEST_PATH']})"
      kittenpile = getKittenPile(req)
      if kittenpile.nil? or kittenpile.original_config.nil? or kittenpile.original_config[req["mu_resource_type"]+"s"].nil?
        returnval = throw500 "Couldn't find config data for #{req["mu_resource_type"]} in deploy_id #{req["mu_id"]}"
        next
      end
      server_cfg = nil
      kittenpile.original_config[req["mu_resource_type"]+"s"].each { |svr|
        if svr["name"] == req["mu_resource_name"]
          server_cfg = svr.dup
          break
        end
      }
      if server_cfg.nil?
        returnval = throw500 "Couldn't find config data for #{req["mu_resource_type"]} name: #{req["mu_resource_name"]} deploy_id: #{req["mu_id"]}"
        next
      end

      MU.log "Dug up server config for #{req["mu_resource_type"]} name: #{req["mu_resource_name"]} deploy_id: #{req["mu_id"]}", MU::DEBUG, details: server_cfg

# XXX We can't assume AWS anymore. What does this look like otherwise?
# If this is an already-groomed instance, try to get a real object for it
      instance = MU::MommaCat.findStray("AWS", "server", cloud_id: req["mu_instance_id"], region: server_cfg["region"], deploy_id: req["mu_id"], name: req["mu_resource_name"], dummy_ok: true, calling_deploy: kittenpile).first
      mu_name = nil
      if instance.nil?
        # Now we're just checking for existence in the cloud provider, really
        MU.log "No existing groomed server found, verifying that a server with this cloud id exists"
        instance = MU::Cloud::Server.find(cloud_id: req["mu_instance_id"], region: server_cfg["region"])
#        instance = MU::MommaCat.findStray("AWS", "server", cloud_id: req["mu_instance_id"], region: server_cfg["region"], deploy_id: req["mu_id"], name: req["mu_resource_name"], dummy_ok: true, calling_deploy: kittenpile).first
        if instance.nil?
          returnval = throw500 "Failed to find an instance with cloud id #{req["mu_instance_id"]}"
        end
      else
        mu_name = instance.mu_name
        MU.log "Found an existing node named #{mu_name}"
      end

      if !req["mu_windows_admin_creds"].nil?
        if !instance.is_a?(MU::Cloud::Server)
          instance = MU::Cloud::Server.new(mommacat: kittenpile, kitten_cfg: server_cfg, cloud_id: req["mu_instance_id"])
        end
        returnval[2] = [kittenpile.retrieveWindowsAdminCreds(instance).join(";")]
        logstr = returnval[2].is_a?(Array) ? returnval[2].first.sub(/;.*/, ";*********") : returnval[2].sub(/;.*/, ";*********")
        MU.log logstr, MU::NOTICE
      elsif !req["mu_ssl_sign"].nil?
        kittenpile.signSSLCert(req["mu_ssl_sign"], req["mu_ssl_sans"].split(/,/))
        kittenpile.signSSLCert(req["mu_ssl_sign"], req["mu_ssl_sans"].split(/,/))
      elsif !req["add_volume"].nil?
        if instance.respond_to?(:addVolume)
# XXX make sure we handle mangled input safely
          params = JSON.parse(Base64.decode64(req["add_volume"]))
          MU.log "add_volume request", MU::NOTICE, details: params
          instance.addVolume(params["dev"], params["size"], delete_on_termination: params["delete_on_termination"])
        else
          returnval = throw500 "I don't know how to add a volume for #{instance}"
        end
      elsif !instance.nil?
        if !req["mu_bootstrap"].nil?
          kittenpile.groomNode(req["mu_instance_id"], req["mu_resource_name"], req["mu_resource_type"], mu_name: mu_name, sync_wait: true)
          returnval[2] = ["Grooming asynchronously, check Momma Cat logs on the master for details."]
        else
          returnval = throw500 "Didn't get 'mu_bootstrap' parameter from instance id '#{req["mu_instance_id"]}'"
        end
      else
        returnval = throw500 "No such instance id '#{req["mu_instance_id"]}' nor was this an SSL signing request"
      end
    end
  rescue StandardError => e
    returnval = throw500 "Invalid request: #{e.inspect} (#{req})", e.backtrace
  ensure
    if !req.nil?
      releaseKitten(req['mu_id'])
      MU.purgeGlobals
    end
  end
  if returnval[1] and returnval[1].has_key?("Content-Length") and
     returnval[2] and returnval[2].is_a?(Array)
    returnval[1]["Content-Length"] = returnval[2][0].size.to_s
  end
  returnval
end

run app