# Copyright 2008-2009 Amazon.com, Inc. or its affiliates.  All Rights
# Reserved.  Licensed under the Amazon Software License (the
# "License").  You may not use this file except in compliance with the
# License. A copy of the License is located at
# http://aws.amazon.com/asl or in the "license" file accompanying this
# file.  This file 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 'open-uri'
require 'ec2/amitools/fileutil'
require 'ec2/amitools/manifestv20071010'
require 'ec2/amitools/instance-data'
require 'ec2/amitools/version'
require 'ec2/platform/current'

# Module containing utility methods for bundling an AMI.
module Bundle
  class ImageType < String
    MACHINE = new 'machine'
    KERNEL  = new 'kernel'
    RAMDISK = new 'ramdisk'
    VOLUME  = new 'machine'
    def ==(o)
      self.object_id == o.object_id
    end
  end
  
  CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB in bytes.

  def self.bundle_image( image_file,
                         user,
                         arch,
                         image_type,
                         destination,
                         user_private_key_path,
                         user_cert_path,
                         ec2_cert_path,
                         prefix,
                         optional_args,
                         debug = false,
                         inherit = true
                       )
    begin
      raise "invalid image-type #{image_type}" unless image_type.is_a? Bundle::ImageType
      # Create named pipes.
      digest_pipe = File::join('/tmp', "ec2-bundle-image-digest-pipe-#{$$}")
      File::delete(digest_pipe) if File::exist?(digest_pipe)
      unless system( "mkfifo #{digest_pipe}" )
        raise "Error creating named pipe #{digest_pipe}"
      end
      
      # If the prefix differs from the file name create a symlink
      # so that the file is tarred with the prefix name.
      if prefix and File::basename( image_file ) != prefix
        image_file_link = File::join( destination, prefix )
        begin
          FileUtils.ln_s(image_file, image_file_link)
        rescue Exception => e
          raise "Error creating symlink to image file, #{e.message}."
        end
        image_file = image_file_link
      end
      
      # Load and generate necessary keys.
      name = prefix || File::basename( image_file )
      manifest_file = File.join( destination, name + '.manifest.xml')
      bundled_file_path = File::join( destination, name + '.tar.gz.enc' )
      user_public_key = Crypto::certfile2pubkey( user_cert_path )
      ec2_public_key = Crypto::certfile2pubkey( ec2_cert_path )
      key = Format::bin2hex( Crypto::gensymkey )
      iv = Format::bin2hex( Crypto::gensymkey )
      
      # Bundle the AMI.
      # The image file is tarred - to maintain sparseness, gzipped for
      # compression and then encrypted with AES in CBC mode for
      # confidentiality.
      # To minimize disk I/O the file is read from disk once and
      # piped via several processes. The tee is used to allow a
      # digest of the file to be calculated without having to re-read
      # it from disk.
      tar = EC2::Platform::Current::Tar::Command.new.create.dereference.sparse
      tar.owner(0).group(0)
      tar.add(File::basename( image_file ), File::dirname( image_file ))
      openssl = EC2::Platform::Current::Constants::Utility::OPENSSL
      pipeline = EC2::Platform::Current::Pipeline.new('image-bundle-pipeline', debug)
      pipeline.concat([
        ['tar', "#{openssl} sha1 < #{digest_pipe} & " + tar.expand],
        ['tee', "tee #{digest_pipe}"],
        ['gzip', 'gzip'],
        ['encrypt', "#{openssl} enc -e -aes-128-cbc -K #{key} -iv #{iv} > #{bundled_file_path}"]
        ])
      digest = nil
      begin
        digest = pipeline.execute.split(/\s+/).last.strip
      rescue EC2::Platform::Current::Pipeline::ExecutionError => e
        $stderr.puts e.message
        exit 1
      end

      # Split the bundled AMI. 
      # Splitting is not done as part of the compress, encrypt digest
      # stream, so that the filenames of the parts can be easily
      # tracked. The alternative is to create a dedicated output
      # directory, but this leaves the user less choice.
      parts = Bundle::split( bundled_file_path, name, destination )
      
      # Sum the parts file sizes to get the encrypted file size.
      bundled_size = 0
      parts.each do |part|
        bundled_size += File.size( File.join( destination, part ) )
      end
      
      # Encrypt key and iv.
      padding = OpenSSL::PKey::RSA::PKCS1_PADDING
      user_encrypted_key = user_public_key.public_encrypt( key, padding )
      ec2_encrypted_key = ec2_public_key.public_encrypt( key, padding )
      user_encrypted_iv = user_public_key.public_encrypt( iv, padding )
      ec2_encrypted_iv = ec2_public_key.public_encrypt( iv, padding )
      
      # Digest parts.
      part_digest_list = Bundle::digest_parts( parts, destination )
      
      # Launch-customization data
      patch_in_instance_meta_data(image_type, optional_args) if inherit

      # Sanity-check block-device-mappings
      bdm = optional_args[:block_device_mapping]
      if bdm.is_a? Hash
        [ 'root', 'ami' ].each do |item|
          if bdm[item].to_s.strip.empty?
            $stdout.puts "Block-device-mapping has no '#{item}' entry. A launch-time default will be used."
          end
        end
      end
 
 
      # Create bundle manifest.
      $stdout.puts 'Creating bundle manifest...'
      manifest = ManifestV20071010.new()
      manifest.init(optional_args.merge({:name => name,
                     :user => user,
                     :image_type => image_type.to_s,
                     :arch => arch,
                     :reserved => nil,
                     :parts => part_digest_list,
                     :size => File::size( image_file ),
                     :bundled_size => bundled_size,
                     :user_encrypted_key => Format::bin2hex( user_encrypted_key ),
                     :ec2_encrypted_key => Format::bin2hex( ec2_encrypted_key ),
                     :cipher_algorithm => Crypto::SYM_ALG,
                     :user_encrypted_iv => Format::bin2hex( user_encrypted_iv ),
                     :ec2_encrypted_iv => Format::bin2hex( ec2_encrypted_iv ),
                     :digest => digest,
                     :digest_algorithm => Crypto::DIGEST_ALG,
                     :privkey_filename => user_private_key_path,
                     :kernel_id => optional_args[:kernel_id],
                     :ramdisk_id => optional_args[:ramdisk_id],
                     :product_codes => optional_args[:product_codes],
                     :ancestor_ami_ids => optional_args[:ancestor_ami_ids],
                     :block_device_mapping => optional_args[:block_device_mapping],
                     :bundler_name => EC2Version::PKG_NAME,
                     :bundler_version => EC2Version::PKG_VERSION,
                     :bundler_release  => EC2Version::PKG_RELEASE}))
      
      # Write out the manifest file.
      File.open( manifest_file, 'w' ) { |f| f.write( manifest.to_s ) }
    ensure
      # Clean up.
      if bundled_file_path and File.exist?( bundled_file_path )
        File.delete( bundled_file_path )
      end
      File::delete( digest_pipe ) if digest_pipe and File::exist?(digest_pipe)
      if image_file_link and File::exist?( image_file_link )
        File::delete( image_file_link )
      end
    end
  end

  def self.patch_in_instance_meta_data(image_type, optional_args)
    if (image_type == ImageType::VOLUME || image_type == ImageType::MACHINE )
      instance_data = EC2::InstanceData.new
      if !instance_data.instance_data_accessible
        raise "Error accessing instance data. If you are not bundling on an EC2 instance use --no-inherit." 
      else
        [
          [:ancestor_ami_ids,     instance_data.ancestor_ami_ids, Proc.new do |key, value|
            if (optional_args[key].nil?)
              ancestry = nil
              if value.nil? or value.to_s.empty?
                ancestry = []
              elsif value.is_a? Array
                ancestry = value
              else
                ancestry = [value]
              end
              ami_id = instance_data.ami_id
              $stdout.puts "Unable to read instance meta-data for ami-id" if ami_id.nil?
              ancestry << ami_id unless(ami_id.nil? or ancestry.include?(ami_id))
              optional_args[key] = ancestry if ancestry && ancestry.length > 0
            end
          end],
          [:kernel_id,            instance_data.kernel_id, nil],
          [:ramdisk_id,           instance_data.ramdisk_id, nil],
          [:product_codes,        instance_data.product_codes, nil],
          [:block_device_mapping, instance_data.block_device_mapping, nil],
        ].each do |key, value, block|
          begin
            if value.nil?
              $stdout.puts "Unable to read instance meta-data for #{key.to_s.gsub('_','-')}"
              block.call(key, value) if block
            else
              if block
                block.call(key, value)
              else
                optional_args[key] ||= value
              end
            end
          rescue
            $stdout.puts "Unable to set #{key.to_s.gsub('_','-')} from instance meta-data"
          end
        end
      end
    end
  end

  def self.split( filename, prefix, destination )
    $stdout.puts "Splitting #{filename}..."
    part_filenames = FileUtil::split(filename,
                                     prefix,
                                     CHUNK_SIZE,
                                     destination)
    part_filenames.each { |name| puts "Created #{name}" }
    part_filenames
  end
  
  def self.digest_parts( basenames, dir )
    $stdout.puts 'Generating digests for each part...'
    parts_digests = Array.new
    basenames.each do |basename|
      File.open(File.join(dir, basename)) do |f|
        parts_digests << [basename, Crypto.digest( f )]
      end
    end
    $stdout.puts 'Digests generated.'
    parts_digests
  end
end
