diff --git a/.gitignore b/.gitignore index 41f0c71..17c08b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.egg-info dist build +work/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a176105 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# ami-creator + +A simple tool built on python-imgcreate to create CentOS AMIs for EC2 using +Kickstart configs. Supports ebs- and instance-store-backed instances. + +## howto + +1. clone this repo on an EC2 instance +2. attach a 10G EBS volume, attach it so it appears at `/dev/xvdj` +3. create `/media/ephemeral0/build` and `/media/ephemeral0/cache` directories +4. create the base image from a kickstart config: + + sudo ./ami_creator/ami_creator.py -c ks-centos6.cfg -n my-image-name -t /media/ephemeral0/build --cache=/media/ephemeral0/cache + +5. transfer the image to the attached EBS volume: + + sudo prepare-volume.sh + +6. create and launch the instance: + + ./create-image.sh + + +## add'l resources + +* [Announcing ami-creator](http://velohacker.com/2010/12/13/announcing-ami-creator/) +* [CentOS AMIs from Kickstart](http://dan.carley.co/blog/2012/04/17/centos-amis-from-kickstart/) +* [How to build your own S3- and EBS-backed instances](http://amazonaws.michael--martinez.com) +* [Using Your Own Linux Kernels - Amazon Elastic Compute Cloud](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/UserProvidedKernels.html) +* [How to make your own CentOS 6 AMIs](http://www.bashton.com/blog/2012/how-to-make-your-own-centos-6-amis/) +* [CentOS 5 kickstart options](http://www.centos.org/docs/5/html/Installation_Guide-en-US/s1-kickstart2-options.html) + +## original `README` + + ami-creator + + A simple tool based on python-imgcreate to create Fedora/Red Hat style + images that can be used as an AMI in EC2. + + Takes a kickstart config like the rest of livecd-creator, etc and spits out a + disk image file that's suitable to upload as an s3 ami. To do the upload right + now, you'll want to run something like + ec2-bundle-image + ec2-upload-bundle + ec2-register + after having created your base image file. + + Tested with the following as guests: + * CentOS 5.5 + * Fedora 14 + See the configs/ directory for example configs that work for each of these. + + + Jeremy Katz + 2010 December 10 + diff --git a/ami_creator/ami_creator.py b/ami_creator/ami_creator.py index a6d5c07..873026a 100755 --- a/ami_creator/ami_creator.py +++ b/ami_creator/ami_creator.py @@ -25,8 +25,6 @@ import imgcreate -import rpmUtils.arch - class Usage(Exception): def __init__(self, msg = None): Exception.__init__(self, msg) @@ -81,6 +79,9 @@ def __init__(self, *args, **kwargs): "sd_mod", "mptsas", "sg" ] self.__modules.extend(imgcreate.kickstart.get_modules(self.ks)) + ## for some reason ExtDiskMount prefixes the fslabel with "_" + self.__real_fslabel = "_" + self.fslabel + # FIXME: refactor into imgcreate.LoopImageCreator def _get_kernel_options(self): """Return a kernel options string for bootloader configuration.""" @@ -88,20 +89,29 @@ def _get_kernel_options(self): return r def _get_fstab(self): - s = "LABEL=_/ / %s defaults 0 0\n" % self._fstype + s = "LABEL=%s / %s defaults 0 0\n" % (self.__real_fslabel, self._fstype) s += self._get_fstab_special() return s def _create_bootconfig(self): - imgtemplate = """title %(title)s %(version)s - root (hd0) - kernel /boot/vmlinuz-%(version)s root=LABEL=_/ %(bootargs)s - initrd /boot/%(initrdfn)s-%(version)s.img + imgtemplate = """ +title %(title)s %(version)s EBS + root (hd0,0) + kernel /boot/vmlinuz-%(version)s root=LABEL=%(fslabel)s %(bootargs)s + initrd /boot/%(initrdfn)s-%(version)s.img + +title %(title)s %(version)s S3 + root (hd0) + kernel /boot/vmlinuz-%(version)s root=LABEL=%(fslabel)s %(bootargs)s + initrd /boot/%(initrdfn)s-%(version)s.img """ - cfg = """default=0 + cfg = """ +default=0 +fallback=1 timeout=%(timeout)s +hiddenmenu """ % { "timeout": imgcreate.kickstart.get_timeout(self.ks, 5) } @@ -119,7 +129,8 @@ def _create_bootconfig(self): cfg += imgtemplate % {"title": self.name, "version": version, "initrdfn": initrdfn, - "bootargs": self._get_kernel_options()} + "bootargs": self._get_kernel_options(), + "fslabel": self.__real_fslabel} with open(self._instroot + "/boot/grub/grub.conf", "w") as grubcfg: grubcfg.write(cfg) @@ -193,7 +204,7 @@ def main(): if options.name: name = options.name - creator = AmiCreator(ks, name, "/") + creator = AmiCreator(ks, name, fslabel="root") creator.tmpdir = os.path.abspath(options.tmpdir) if options.cachedir: options.cachedir = os.path.abspath(options.cachedir) diff --git a/ks-centos6.cfg b/ks-centos6.cfg index 1ddf2e7..007e73a 100644 --- a/ks-centos6.cfg +++ b/ks-centos6.cfg @@ -1,31 +1,45 @@ -# Build a basic CentOS 6 AMI lang en_US.UTF-8 keyboard us -timezone US/Eastern -auth --useshadow --enablemd5 -selinux --permissive -firewall --enabled --ssh -bootloader --timeout=1 --append xen_blkfront.sda_is_xvda=1 +timezone --utc UTC +authconfig --enableshadow --passalgo=sha512 +selinux --disabled +firewall --disabled +firstboot --disabled + +## selinux=0 required to log in via ssh; something missing in this config, I'm +## sure, but I hate/don't understand selinux, so disabling with a vengance. +## modifying /etc/sysconfig/selinux in %post below *should* do the trick, but it +## isn't. punting. + +## "xen_blkfront.sda_is_xvda=1" -- https://bugzilla.redhat.com/show_bug.cgi?id=729586 +## remainder stolen from bashton +## dracut options: http://fedoraproject.org/wiki/Dracut/Options + +bootloader --timeout=1 --append="xen_blkfront.sda_is_xvda=1 selinux=0 rd_NO_LUKS rd_NO_LVM rd_NO_DM rd_NO_MD LANG=en_US.UTF-8 KEYTABLE=us SYSFONT=latarcyrheb-sun16 console=hvc0 crashkernel=auto" + network --bootproto=dhcp --device=eth0 --onboot=on -services --enabled=network +services --enabled=network,sshd,cloud-init # Uncomment the next line to make the root password be password # By default the root password is locked #rootpw password -rootpw --lock +#rootpw --lock # # Define how large you want your rootfs to be # NOTE: S3-backed AMIs have a limit of 10G # +# fs label is not used for the root fs! if you need to refer to the label, use +# "_root". part / --size 768 --fstype ext4 # # Repositories repo --name=CentOS6-Base --mirrorlist=http://mirrorlist.centos.org/?release=6&arch=$basearch&repo=os repo --name=CentOS6-Updates --mirrorlist=http://mirrorlist.centos.org/?release=6&arch=$basearch&repo=updates -repo --name=EPEL --baseurl=http://download.fedoraproject.org/pub/epel/6/$basearch/ +#repo --name=EPEL --baseurl=http://download.fedoraproject.org/pub/epel/6/$basearch/ +repo --name=EPEL --baseurl=http://mirror.pnl.gov/epel/6/$basearch/ # @@ -34,8 +48,6 @@ repo --name=EPEL --baseurl=http://download.fedoraproject.org/pub/epel/6/$basearc # %packages --nobase --instLangs=en @core -system-config-securitylevel-tui -system-config-firewall-base audit pciutils bash @@ -53,6 +65,7 @@ acpid openssh-clients openssh-server curl +epel-release cloud-init sudo @@ -64,16 +77,22 @@ iputils # set up ec2-user %post --erroronfail +set -e +set -x +set -u + /usr/sbin/useradd ec2-user echo 'ec2-user ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers -%end +## hrm, even with "selinux --disabled" above, selinux still appears to be enabled! +## relevant? https://bugzilla.redhat.com/show_bug.cgi?id=690279 +sed -i -e 's#^SELINUX=.*$#SELINUX=disabled#' /etc/sysconfig/selinux +## only one console needed (if that…) +sed -i -e 's#^ACTIVE_CONSOLES=.*$#ACTIVE_CONSOLES=/dev/tty1#' /etc/sysconfig/init -# -# Add custom post scripts after the base post. -# -%post -%end - +## this could help diagnostics later +sed -i -e 's#^BOOTUP=.*$#BOOTUP=verbose#' /etc/sysconfig/init +yum clean all +%end diff --git a/utils/BUILDING_IN_DOCKER.md b/utils/BUILDING_IN_DOCKER.md new file mode 100644 index 0000000..a548744 --- /dev/null +++ b/utils/BUILDING_IN_DOCKER.md @@ -0,0 +1,32 @@ +Since `ami_creator` uses the yum on the host system to build an image, you can +run into problems if the host and target distributions don't agree. For +example, CentOS 7 packages use xz compression, but that's not available to yum +on CentOS 6. And it seems plausible that building a CentOS 6 image on CentOS 7 +would result in an RPM database that can't be read by a live CentOS 6 instance. + +Fortunately, we have Docker. + +## `build_in_docker.sh` + +Usage: + + build_in_docker.sh + +`host distro` is matched against the corresponding `Dockerfile.?` in this +directory. `../work/build/.img` is created, which can then be used for an +S3-backed AMI, or written to an EBS volume to be snapshotted and registered as +an EBS-backed AMI. + +## CentOS 6 + +Build CentOS 6 images using a CentOS 6 host environment. + + ./build_in_docker.sh centos6 /path/to/centos6.ks centos6 + +## CentOS 7 + +Build CentOS 7 images using a _Fedora 20_ host environment. CentOS 7 doesn't +have the required `python-imgcreate` package available, either in the base +repository or in EPEL. + + ./build_in_docker.sh fedora20 /path/to/centos7.ks centos7 diff --git a/utils/Dockerfile.centos6 b/utils/Dockerfile.centos6 new file mode 100644 index 0000000..7a481d0 --- /dev/null +++ b/utils/Dockerfile.centos6 @@ -0,0 +1,8 @@ +# -*- Dockerfile -*- +FROM centos:centos6 + +RUN yum -y localinstall http://mirror.pnl.gov/epel/6/i386/epel-release-6-8.noarch.rpm +RUN yum -y install python-imgcreate + +VOLUME [ "/srv/work" ] +WORKDIR /tmp diff --git a/utils/Dockerfile.fedora20 b/utils/Dockerfile.fedora20 new file mode 100644 index 0000000..5178f1d --- /dev/null +++ b/utils/Dockerfile.fedora20 @@ -0,0 +1,11 @@ +# -*- Dockerfile -*- + +## "xz compression not available" from yum building on centos6 +## python-imgcreate not available on centos7 +## fedora:20 is the current stable version, and python-imgcreate *is* available +FROM fedora:20 + +RUN yum -y install python-imgcreate + +VOLUME [ "/srv/work" ] +WORKDIR /tmp diff --git a/utils/build_in_docker.sh b/utils/build_in_docker.sh new file mode 100755 index 0000000..830df16 --- /dev/null +++ b/utils/build_in_docker.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +set -e -u + +basedir="$( cd $( dirname $0 )/.. && /bin/pwd -P )" +workdir="${basedir}/work" + +if [ $# -ne 3 ]; then + echo "usage: $0 " + exit 1 +fi + +host_distro="$1" +kickstart_cfg="$2" +name="$3" + +docker_file="${basedir}/utils/Dockerfile.${host_distro}" +docker_image="ami_creator/${host_distro}" + +if [ ! -e "${docker_file}" ]; then + echo "unsupported host distro: ${host_distro}" + exit 1 +fi + +if [ ! -e "${kickstart_cfg}" ]; then + echo "${kickstart_cfg} does not exist" + exit 1 +fi + +kickstart_base="$( basename ${kickstart_cfg} )" +kickstart_root="$( cd $( dirname ${kickstart_cfg} ) && /bin/pwd -P )" + +mkdir -p ${workdir}/{build,cache} + +## build the image if it doesn't already exist +docker inspect ${docker_image} >/dev/null 2>&1 || \ + docker build -t ${docker_image} - < ${docker_file} + +docker run \ + -i -t \ + --rm \ + --privileged \ + --volume=${basedir}:/srv/ami-creator:ro \ + --volume=${kickstart_root}:/srv/image-config:ro \ + --volume=${workdir}:/srv/work \ + ${docker_image} \ + \ + /bin/bash -c " \ + /srv/ami-creator/ami_creator/ami_creator.py \ + -c /srv/image-config/${kickstart_base} \ + -n ${name} \ + -t /tmp \ + --cache=/srv/work/cache \ + && mv ${name}.img /srv/work/build/ + " diff --git a/utils/create-ami.sh b/utils/create-ami.sh new file mode 100755 index 0000000..d88710e --- /dev/null +++ b/utils/create-ami.sh @@ -0,0 +1,228 @@ +#!/bin/bash + +set -e -u +# set -x + +function die() { + echo "$@" + exit 1 +} + +function usage() { + die "usage: $0 []" +} + +[ $EUID -eq 0 ] || die "must be root" +[ $# -eq 5 ] || [ $# -eq 6 ] || usage + +_basedir="$( cd $( dirname -- $0 )/.. && /bin/pwd )" + +[ -e "${1}" ] || die "$1 doesn't exist" + +img="$( readlink -f ${1} )" +ami_name="${2}" +block_dev="${3}" +img_target_dev="${block_dev}1" +vol_id="${4}" +virt_type="${5}" +shift 5 + +[ "${virt_type}" = "hvm" ] || [ "${virt_type}" = "paravirtual" ] || die "virtualization type must be hvm or paravirtual" + +if [ "${virt_type}" = "hvm" ] && [ $# -ne 1 ]; then + usage +fi + +if [ "${virt_type}" = "paravirtual" ]; then + grub_ver="not applicable" + + ## http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/UserProvidedKernels.html + ## pv-grub-hd0_1.04-x86_64.gz; specific to us-east-1! + kernel_id="--kernel-id aki-919dcaf8" + + ## http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/block-device-mapping-concepts.html + root_device="/dev/sda" +elif [ "${virt_type}" = "hvm" ]; then + grub_ver="${1}" + + [ "${grub_ver}" = "grub0" ] || [ "${grub_ver}" = "grub2" ] || die "grub version must be grub0 or grub2" + + ## kernel id only applies for paravirtualized instances + kernel_id="" + + ## the root device for the block device mapping + root_device="/dev/xvda" +fi + +## A client error (InvalidAMIName.Malformed) occurred when calling the +## RegisterImage operation: AMI names must be between 3 and 128 characters long, +## and may contain letters, numbers, '(', ')', '.', '-', '/' and '_' +if [ ${#ami_name} -lt 3 ] || [ ${#ami_name} -gt 128 ]; then + echo "illegal length for ami_name; must be >= 3, <= 128" + exit 1 +fi + +# if echo $ami_name | egrep -q '[^a-z0-9 ()./_-]' ; then +# echo "illegal characters in ami_name; must be [a-z0-9 ()./_-]" +# exit 1 +# fi + +echo "executing with..." +echo " img: ${img}" +echo " ami_name: ${ami_name}" +echo " block_dev: ${block_dev}" +echo " vol_id: ${vol_id}" +echo " virt_type: ${virt_type}" +echo " grub_ver: ${grub_ver}" +echo " kernel_id: ${kernel_id}" +echo " root_device: ${root_device}" + +## check for required programs +which aws >/dev/null 2>&1 || die "need aws" +which curl >/dev/null 2>&1 || die "need curl" +which jq >/dev/null 2>&1 || die "need jq" +which e2fsck >/dev/null 2>&1 || die "need e2fsck" +which resize2fs >/dev/null 2>&1 || die "need resize2fs" +which hdparm >/dev/null 2>&1 || die "need hdparm" +which parted >/dev/null 2>&1 || die "need parted" + +## the block device must exist +[ -e "${block_dev}" ] || die "${block_dev} does not exist" + +## volume should be attached to this instance +my_instance_id="$( curl -s http://169.254.169.254/latest/meta-data/instance-id )" + +## set up/verify aws credentials and settings +## http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html +export AWS_DEFAULT_REGION="$( curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed -e 's#.$##g' )" +[ -n "${AWS_ACCESS_KEY_ID}" ] || die "AWS_ACCESS_KEY_ID not set" +[ -n "${AWS_SECRET_ACCESS_KEY}" ] || die "AWS_SECRET_ACCESS_KEY not set" + +if [ "$( aws ec2 describe-images --filters "Name=name,Values=${ami_name}" | jq -r '.Images | length' )" -ne 0 ]; then + die "AMI with that name already exists!" +fi + +if [ "$( aws ec2 describe-volumes --volume-ids ${vol_id} | jq -r .Volumes[].Attachments[].InstanceId )" != "${my_instance_id}" ]; then + die "volume ${vol_id} is not attached to this instance!" +fi + +## ok, this is fucked up. the dd writing the image to the volume is exiting +## with zero, but the data isn't getting written. Bringing out the big guns. + +## forcibly corrupt the fucker so we know we're not working with stale data +dd if=/dev/zero of=${block_dev} bs=8M count=10 conv=fsync oflag=sync +sync;sync;sync + +## reread partition table +## I've observed this failing when running this script back-to-back; try it a +## couple of times… +reread_count=0 +reread_success=0 +while [ $reread_count -lt 3 ]; do + if hdparm -z ${block_dev} ; then + reread_success=1 + break + fi + + sleep 5 + reread_count=$(( reread_count + 1 )) +done + +[ $reread_success -eq 1 ] || die "failed to reread partition table" + +## partition volume +## http://telinit0.blogspot.com/2011/12/scripting-parted.html +## need to leave more space for grub2; start partition on 2nd cylinder +## http://serverfault.com/questions/523985/fixing-a-failed-grub-upgrade-on-raid +parted ${block_dev} --script -- mklabel msdos +parted ${block_dev} --script -- mkpart primary ext2 1 -1 ## validated by running grub2-install +parted ${block_dev} --script -- set 1 boot on + +## reread partition table +hdparm -z ${block_dev} + +## write image to volume +echo "writing image to ${img_target_dev}" +dd if=${img} of=${img_target_dev} conv=fsync oflag=sync bs=8k + +## force-check the filesystem; re-write the image if it fails +if ! fsck.ext4 -n -f ${img_target_dev} ; then + echo "well, that didn't work; trying again" + dd if=${img} of=${img_target_dev} conv=fsync oflag=sync bs=8k + fsck.ext4 -n -f ${img_target_dev} +fi + +## resize the filesystem +e2fsck -f ${img_target_dev} +resize2fs ${img_target_dev} + +if [ "${virt_type}" = "hvm" ]; then + ## install grub bootloader for hvm instances; they boot like real hardware + + # mount the volume so + vol_mnt="/mnt/ebs_vol" + mkdir -p ${vol_mnt} + mount -t ext4 ${img_target_dev} ${vol_mnt} + + ## mount special filesystems so the chroot is like running in a real + ## instance. hopefully this is pretty distro- and kernel-agnostic… + mount -t proc none ${vol_mnt}/proc + mount -o bind /dev ${vol_mnt}/dev + mount -o bind /sys ${vol_mnt}/sys + + if [ ${grub_ver} = "grub0" ]; then + # make ${vol_mnt}/boot/grub/device.map with contents + # (hd0) ${block_dev} + # because otherwise grub-install isn't happy, even with --recheck + echo "(hd0) ${block_dev}" > ${vol_mnt}/boot/grub/device.map + + ## also need to create fake /etc/mtab so grub-install can figure out + ## what device / is + echo "${img_target_dev} / ext4 rw,relatime 0 0" > ${vol_mnt}/etc/mtab + + chroot ${vol_mnt} /sbin/grub-install --no-floppy ${block_dev} + + rm -f ${vol_mnt}/etc/mtab ${vol_mnt}/boot/grub/device.map + elif [ ${grub_ver} = "grub2" ]; then + ## *aaah* so much simpler! + + ## @todo move grub2-mkconfig to ami-creator (add grub2 support) + chroot ${vol_mnt} /sbin/grub2-mkconfig -o /boot/grub2/grub.cfg + chroot ${vol_mnt} /sbin/grub2-install ${block_dev} + fi + + umount ${vol_mnt}/{proc,dev,sys,} +fi + +## create a snapshot of the volume +snap_id=$( aws ec2 create-snapshot --volume-id ${vol_id} --description "root image for ${ami_name}" | jq -r .SnapshotId ) + +while [ $( aws ec2 describe-snapshots --snapshot-ids ${snap_id} | jq -r .Snapshots[].State ) != "completed" ]; do + echo "waiting for snapshot ${snap_id} to complete" + sleep 5 +done + +## kernel-id hard-coded +## see http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/UserProvidedKernels.html +## fuck me, bash space escaping is a pain in the ass. +image_id=$( \ + aws ec2 register-image \ + ${kernel_id} \ + --architecture x86_64 \ + --name "${ami_name}" \ + --root-device-name ${root_device} \ + --block-device-mappings "[{\"DeviceName\":\"${root_device}\",\"Ebs\":{\"SnapshotId\":\"${snap_id}\",\"VolumeSize\":10}},{\"DeviceName\":\"/dev/sdb\",\"VirtualName\":\"ephemeral0\"}]" \ + --virtualization-type ${virt_type} \ + | jq -r .ImageId +) + +echo "created AMI with id ${image_id}" + +## create json file next to input image +{ + echo "{" + echo " \"virt_type\": \"${virt_type}\", " + echo " \"snapshot_id\": \"${snap_id}\", " + echo " \"ami_id\": \"${image_id}\"" + echo "}" +} > "${img%.*}.json" diff --git a/utils/remove-ami-and-snapshot.sh b/utils/remove-ami-and-snapshot.sh new file mode 100755 index 0000000..df88609 --- /dev/null +++ b/utils/remove-ami-and-snapshot.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +## deregisters an AMI and removes its associated block device snapshot. Will +## likely fail if there are multiple EBS BlockDeviceMappings. + +set -e +set -u + +[ $# -eq 1 ] || { echo "usage: $0 "; exit 1; } + +image_id="${1}" + +snap_id="$( aws ec2 describe-images --image-ids ${image_id} | jq -r '.Images[].BlockDeviceMappings[] | select(.Ebs).Ebs.SnapshotId' )" + +echo "deregistering ${image_id}" +aws ec2 deregister-image --image-id ${image_id} + +echo "deleting snapshot ${snap_id}" +aws ec2 delete-snapshot --snapshot-id ${snap_id}