cloudamatic/mu

View on GitHub
bin/mu-aws-setup

Summary

Maintainability
Test Coverage
#!/usr/local/ruby-current/bin/ruby
#
# 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.

# Perform initial Mu setup tasks:
# 1. Set up an appropriate Security Group
# 2. Associate a specific Elastic IP address to this MU server, if required.
# 3. Create an S3 bucket for Mu logs.

require 'etc'
require 'securerandom'

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

require 'rubygems'
require 'bundler/setup'
require 'json'
require 'erb'
require 'optimist'
require 'json-schema'
require 'mu'
Dir.chdir(MU.installDir)

$opts = Optimist::options do
  banner <<-EOS
Usage:
#{$0} [-i] [-s] [-l] [-u] [-d]
  EOS
  opt :ip, "Attempt to configure the IP requested in the CHEF_PUBLIC_IP environment variable, or if none is set, to associate an arbitrary Elastic IP.", :require => false, :default => false, :type => :boolean
  opt :sg, "Attempt to configure a Security Group with appropriate permissions.", :require => false, :default => false, :type => :boolean
  opt :logs, "Ensure the presence of a cloud storage bucket for use with CloudTrails, syslog, deploy secrets, node SSL certificates, etc.", :require => false, :default => false, :type => :boolean
  opt :dns, "Ensure the presence of a private DNS Zone called for internal amongst Mu resources.", :require => false, :default => false, :type => :boolean
  opt :uploadlogs, "Push today's log files to the S3 bucket created by the -l option.", :require => false, :default => false, :type => :boolean
  opt :ephemeral, "Make sure all of our instance store (ephemeral) block devices are mapped and available.", :require => false, :default => false, :type => :boolean
  opt :optdisk, "Create an EBS volume for /opt and slide our installation onto it", :require => false, :default => false, :type => :boolean
end

if MU::Cloud::AWS.hosted? and !$MU_CFG['aws']
  new_cfg = $MU_CFG.dup
  cfg_blob = MU::Cloud::AWS.hosted_config
  if cfg_blob
    cfg_blob['log_bucket_name'] ||= $MU_CFG['hostname']
    new_cfg["aws"] = { "default" => cfg_blob }
    MU.log "Adding auto-detected AWS stanza to #{cfgPath}", MU::NOTICE
    if new_cfg != $MU_CFG or !cfgExists?
      MU.log "Generating #{cfgPath}"
      saveMuConfig(new_cfg)
      $MU_CFG = new_cfg
    end
  end
end

my_instance_id = MU::Cloud::AWS.getAWSMetaData("instance-id")

resp = MU::Cloud::AWS.ec2.describe_instances(instance_ids: [my_instance_id])
instance = resp.reservations.first.instances.first

preferred_ip = MU.mu_public_ip

if $opts[:ephemeral] and !MU::Cloud::AWS.isGovCloud?
  instancetypes = MU::Cloud::AWS.listInstanceTypes
  if !instancetypes or !instancetypes[MU::Cloud::AWS.myRegion] or !instancetypes[MU::Cloud::AWS.myRegion][instance.instance_type]
    MU.log "Failed to load instance type mappings from Pricing API for #{instance.instance_type} in #{MU::Cloud::AWS.myRegion}", MU::ERR
  elsif instancetypes[MU::Cloud::AWS.myRegion][instance.instance_type]["storage"] == "EBS only"
    MU.log "#{instance.instance_type} instance types do not have ephemeral volumes, skipping ephemeral device setup", MU::NOTICE
  else
#    instance.block_device_mappings.each { |dev|
#      next if dev.ebs
#    }
    MU::Cloud::AWS.ec2.modify_instance_attribute(
      instance_id: instance.instance_id,
      block_device_mappings: MU::Cloud::AWS::Server.ephemeral_mappings
    )
  end
end

# Create a security group, or manipulate an existing one, so that we have all
# of the appropriate network holes.
if $opts[:sg]
  open_ports = [443, MU.mommaCatPort, 7443, 8443, 9443, 8200]
  ranges = if $MU_CFG and $MU_CFG['my_networks'] and $MU_CFG['my_networks'].size > 0
    $MU_CFG['my_networks'].map { |r|
      r = r+"/32" if r.match(/^\d+\.\d+\.\d+\.\d+$/)
      r
    }
  else
    ["0.0.0.0/0"]
  end

  # This doesn't make sense. we can have multiple security groups in our account with a name tag of "Mu Master". This will then find and modify a security group that has nothing to do with us.

  admin_sg = nil
  if instance.security_groups.size > 0
    instance.security_groups.each { |sg|
      found = MU::MommaCat.findStray("AWS", "firewall_rule", region: MU::Cloud::AWS.myRegion, dummy_ok: true, cloud_id: sg.group_id)
      if found.size > 0 and
         !found.first.cloud_desc.group_name.match(/^Mu Client Rules for /)
        admin_sg = found.first

        break
      end
    }
  end

  # Clean out any old rules that aren't part of our current config
  admin_sg.cloud_desc.ip_permissions.each { |rule|
    rule.ip_ranges.each { |range|
      if range.description == "Mu Master service access" and
         !ranges.include?(range.cidr_ip) and rule.to_port != 80 and
         !(rule.to_port == 22 and range.cidr_ip == "#{preferred_ip}/32")
        MU.log "Revoking old Mu Master service access rule for #{range.cidr_ip} port #{rule.to_port.to_s}", MU::NOTICE
        MU::Cloud::AWS.ec2(region: MU::Cloud::AWS.myRegion, credentials: admin_sg.credentials).revoke_security_group_ingress(
          group_id: admin_sg.cloud_desc.group_id,
          ip_permissions: [
            {
              to_port: rule.to_port,
              from_port: rule.from_port,
              ip_protocol: rule.ip_protocol,
              ip_ranges: [
                { cidr_ip: range.cidr_ip }
              ]
            }
          ]
        )

      end
    }
  }

  rules = Array.new
  open_ports.each { |port|
    rules << {
      "port" => port,
      "hosts" => ranges,
      "description" => "Mu Master service access"
    }
  }
  rules << {
    "port" => 22,
    "hosts" => ["#{preferred_ip}/32"],
    "description" => "Mu Master service access"
  }
  rules << {
    "port" => 80,
    "hosts" => ["0.0.0.0/0"],
    "description" => "Mu Master service access"
  }
  rules << {
    "port_range" => "0-65535",
    "sgs" => admin_sg.cloud_id,
    "description" => "Mu Master service access"
  }
  MU.log "Configuring basic TCP access for Mu services", MU::NOTICE, details: rules

  if !admin_sg.nil?
    MU.log "Using an existing Security Group, #{admin_sg}, already associated with this Mu server."
    open_ports.each { |port|
      admin_sg.addRule(ranges, port: port, comment: "Mu Master service access")
    }
    admin_sg.addRule(["#{preferred_ip}/32"], port: 22, comment: "Mu Master service access")
    admin_sg.addRule(["0.0.0.0/0"], port: 80, comment: "Mu Master service access")
    admin_sg.addRule([admin_sg.cloud_id], comment: "Mu Master service access")
  else
    cfg = {
      "name" => "Mu Master",
      "cloud" => "AWS",
      "region" => MU::Cloud::AWS.myRegion,
      "rules" => rules
    }

    if !instance.vpc_id.nil?
      cfg["vpc"] = {"vpc_id" => instance.vpc_id}
    end
    admin_sg = MU::Cloud::FirewallRule.new(kitten_cfg: cfg, mu_name: "Mu Master")
    admin_sg.create
    admin_sg.groom
  end
end

# Muddle with our IP address
if instance.public_ip_address != preferred_ip and !preferred_ip.nil? and !preferred_ip.empty? and $opts[:ip]

  has_elastic_ip = false
  if !instance.public_ip_address.nil?
    filters = Array.new
    filters << {name: "domain", values: ["vpc"]} if !instance.vpc_id.nil?
    filters << {name: "public-ip", values: [instance.public_ip_address]}
    resp = MU::Cloud::AWS.ec2.describe_addresses(filters: filters)
    if resp.addresses.size > 0
      has_elastic_ip
    end
  end

  if has_elastic_ip
    MU.log "Public IP address is #{instance.public_ip_address}"
  else
    is_private = false
    if !instance.vpc_id.nil?
      # Fix this to actually verify the subnet is private
      is_private = true if instance.public_ip_address.nil? && instance.public_dns_name.empty?
      # is_private = MU::VPC.isSubnetPrivate?(instance.subnet_id)
      public_ip = MU::Cloud::AWS::Server.findFreeElasticIp if !is_private
    else
      public_ip = MU::Cloud::AWS::Server.findFreeElasticIp(classic: true)
    end

    if !is_private
      if public_ip.nil?
        MU.log "Warning: Could not find a free Elastic IP to associate, continuing to use #{instance.public_ip_address} for now", MU::NOTICE
      else
        MU.log "Warning: About to associate the IP address #{public_ip} with this instance. This will disconnect your session. It is safe to reconnect and restart configuration.", MU::NOTICE
        sleep 5
        if !instance.vpc_id.nil?
          MU::Cloud::AWS::Server.associateElasticIp(my_instance_id, ip: public_ip)
        else
          MU::Cloud::AWS::Server.associateElasticIp(my_instance_id, classic: true, ip: public_ip)
        end
      end
    else
      MU.log "We are in a private subnet, will not attempt to assign a public IP."
    end
  end
elsif $opts[:ip]
  MU.log "Currently assigned IP address is #{instance.public_ip_address}"
end

if $opts[:optdisk] and !File.open("/etc/mtab").read.match(/ \/opt[\s\/]/)
  wd = Dir.getwd
  Dir.chdir("/")
  if File.exists?("/opt/opscode/bin/chef-server-ctl")
    system("/opt/opscode/bin/chef-server-ctl stop")
  end
  if !File.exists?("/sbin/mkfs.xfs")
    system("/usr/bin/yum -y install xfsprogs")
  end
  MU::Master.disk("/dev/xvdj", "/opt_tmp", 30)
  uuid = MU::Master.diskUUID("/dev/xvdj")
  if !uuid or uuid.empty?
    MU.log "Failed to retrieve UUID of block device xvdj", MU::ERR, details: MU::Cloud::AWS.realDevicePath("/dev/xvdj")
    exit 1
  end
  MU.log "Moving contents of /opt to /opt_tmp", MU::NOTICE
  system("/bin/mv /opt/* /opt_tmp/")
  exit 1 if $?.exitstatus != 0
  MU.log "Remounting /opt_tmp /opt", MU::NOTICE
  system("/bin/umount /opt_tmp")
  exit 1 if $?.exitstatus != 0
  system("echo '#{uuid} /opt xfs defaults 0 0' >> /etc/fstab")
  system("/bin/mount -a")
  exit 1 if $?.exitstatus != 0
  if File.exists?("/opt/opscode/bin/chef-server-ctl")
    system("/opt/opscode/bin/chef-server-ctl start")
  end
  Dir.chdir(wd)
end


if $opts[:logs]
  MU::Cloud::AWS.listCredentials.each { |credset|
    bucketname = MU::Cloud::AWS.adminBucketName(credset)

    exists = false

    MU.log "Configuring log and secret Amazon S3 bucket '#{bucketname}' for credential set #{credset}"

    resp = MU::Cloud::AWS.s3(credentials: credset).list_buckets
    resp.buckets.each { |bucket|
      exists = true if bucket.name == bucketname
    }
    if !exists
      MU.log "Creating #{bucketname} bucket"
      begin
        resp = MU::Cloud::AWS.s3(credentials: credset).create_bucket(bucket: bucketname, acl: "private")
      rescue Aws::S3::Errors::BucketAlreadyExists => e
        MU.log "#{e.inspect}", MU::NOTICE
      end
    end

    resp = MU::Cloud::AWS.s3(credentials: credset).list_objects(
      bucket: bucketname,
      prefix: "log_vol_ebs_key"
    )
    found = false
    resp.contents.each { |object|
      found = true if object.key == "log_vol_ebs_key"
    }
    if !found
      MU.log "Creating new key for encrypted EBS log volume"
      key = SecureRandom.random_bytes(32)
      MU::Cloud::AWS.s3(credentials: credset).put_object(
        bucket: bucketname,
        key: "log_vol_ebs_key",
        body: "#{key}"
      )
    end
    if File.exist?("#{MU.mySSLDir}/Mu_CA.pem")
      MU.log "Putting the Mu Master's public SSL certificate into #{bucketname}/Mu_CA.pem"
      MU::Cloud::AWS.s3(credentials: credset).put_object(
        bucket: bucketname,
        key: "Mu_CA.pem",
        body: File.read("#{MU.mySSLDir}/Mu_CA.pem"),
            acl: "public-read",
      )
    end

    MU::Master.disk("/dev/xvdl", "/Mu_Logs", 50, "log_vol_ebs_key", "ram7")

#    MU.log "Uploading Mu_CA.pem to #{bucketname}"
#    MU::Cloud::AWS.s3.put_object(
#        bucket: bucketname,
#        acl: "public-read",
#        key: "Mu_CA.pem",
#        body: File.read("#{ENV['MU_DATADIR']}/ssl/Mu_CA.pem")
#    )

    resp = MU::Cloud::AWS.s3(credentials: credset).list_objects(
      bucket: bucketname,
      prefix: "log_vol_ebs_key"
    )
    owner = MU.structToHash(resp.contents.first.owner)

    MU::Cloud::AWS.s3(credentials: credset).put_bucket_acl(
        bucket: bucketname,
        acl: "log-delivery-write"
    )

    MU::Cloud::AWS.s3(credentials: credset).put_bucket_versioning(
        bucket: bucketname,
        versioning_configuration: {
            status: "Enabled"
        }
    )

    MU::Cloud::AWS.s3(credentials: credset).put_bucket_lifecycle(
        bucket: bucketname,
        lifecycle_configuration: {
            rules: [
                {
                    expiration: {
                        days: 180
                    },
                    prefix: "master.log/",
                    status: "Enabled"
                },
                {
                    expiration: {
                        days: 180
                    },
                    prefix: "nodes.log/",
                    status: "Enabled"
                },
                {
                    expiration: {
                        days: 180
                    },
                    prefix: "AWSLogs/",
                    status: "Enabled"
                }
            ]
        }
    )

    begin
      MU::Cloud::AWS.s3(credentials: credset).put_bucket_policy(
        bucket: bucketname,
        policy: MU::Cloud::AWS.cloudtrailBucketPolicy(credset)
      )
    rescue Aws::S3::Errors::MalformedPolicy => e
      MU.log e.message, MU::ERR, details: MU::Cloud::AWS.cloudtrailBucketPolicy(credset)
      next
    end


    begin
      resp = MU::Cloud::AWS.cloudtrail(credentials: credset).describe_trails.trail_list
    rescue Aws::CloudTrail::Errors::AccessDeniedException => e
      MU.log e.inspect, MU::WARN
    end
    if resp.empty?
      MU.log "Enabling Cloud Trails, logged to bucket #{bucketname}"

      begin
        MU::Cloud::AWS.cloudtrail(credentials: credset).create_trail(
            name: "cloudtrail",
            s3_bucket_name: bucketname,
            include_global_service_events: true
        )
      rescue Aws::CloudTrail::Errors::MaximumNumberOfTrailsExceededException, Aws::CloudTrail::Errors::AccessDeniedException => e
        MU.log e.inspect, MU::WARN
      end

    # Make sure we actually enable cloudtrail logging
    MU::Cloud::AWS.cloudtrail(credentials: credset).start_logging(
      name: "cloudtrail"
    )
    end

  }
  # Now that we've got S3 logging, let's also create an Mu_Logs stack in
  # CloudWatch logs.
  # For instances to log to this, they need to invoke the Chef recipe
  # aws-cloudwatch-logs.
  # XXX this isn't supported on CentOS yet, ostensibly. Bother later.

end

if $opts[:dns] and !MU::Cloud::AWS.isGovCloud?
  $bucketname ||= MU.adminBucketName
  if instance.vpc_id.nil? or instance.vpc_id.empty?
    MU.log "This Mu master appears to be in EC2 Classic. Route53 private DNS zones are not supported. Falling back to old /etc/hosts chicanery.", MU::ERR
  else
    ext_zone = MU::Cloud::DNSZone.find(cloud_id: "platform-mu")

    if ext_zone.nil? or ext_zone.size == 0
      params = {
          :name => "platform-mu",
          :vpc => {
              :vpc_region => MU::Cloud::AWS.myRegion,
              :vpc_id => instance.vpc_id
          },
          :hosted_zone_config => {
              :comment => $bucketname,
          },
          :caller_reference => $bucketname
      }

      begin
        resp = MU::Cloud::AWS.route53.create_hosted_zone(params)
      rescue Aws::Route53::Errors::HostedZoneAlreadyExists => e
        MU.log "#{e.inspect}, appending some gibberish...", MU::WARN
        params[:caller_reference] = params[:caller_reference]+(0...2).map { ('a'..'z').to_a[rand(26)] }.join
        retry
      end
      MU.log ".platform-mu private domain created"
    else
      ext_zone = ext_zone.values.first
      begin
        MU::Cloud::AWS.route53.associate_vpc_with_hosted_zone(
            hosted_zone_id: ext_zone.id,
            vpc: {
                vpc_region: MU::Cloud::AWS.myRegion,
                vpc_id: instance.vpc_id
            }
        )
      rescue Aws::Route53::Errors::ConflictingDomainExists
      end
    end
    resolver = Resolv::DNS.new
    my_ip = ""
    begin
      my_ip = resolver.getaddress($MU_CFG['hostname']).to_s
    end rescue Resolv::ResolvError
    if my_ip != MU.mu_public_ip
      MU::Cloud::AWS::DNSZone.manageRecord(ext_zone.id, $MU_CFG['hostname'], "A", targets: [MU.mu_public_ip], sync_wait: false)
   end
  end
end

if $opts[:uploadlogs]
  $bucketname ||= MU.adminBucketName
  today = Time.new.strftime("%Y%m%d").to_s
  ["master.log", "nodes.log"].each { |log|
    if File.exist?("/Mu_Logs/#{log}-#{today}")
      MU.log "Uploading /Mu_Logs/#{log}-#{today} to bucket #{$bucketname}"
      MU::Cloud::AWS.s3.put_object(
          bucket: $bucketname,
          key: "#{log}/#{today}",
          body: File.read("/Mu_Logs/#{log}-#{today}")
      )
    else
      MU.log "No log /Mu_Logs/#{log}-#{today} was found", MU::WARN
    end
  }
end