├── tests ├── __init__.py └── docker2ami │ ├── __init__.py │ ├── fixtures │ ├── empty.conf │ ├── copy_dockerfile │ ├── workdir_dockerfile │ ├── add_dockerfile │ ├── archive │ │ ├── images │ │ │ ├── apple.jpg │ │ │ └── docker.png │ │ └── hello.c │ ├── env_dockerfile │ ├── misc_dockerfile │ ├── example.conf │ ├── coco-dev │ │ ├── docker-build-ami.conf │ │ └── Dockerfile │ ├── run_dockerfile │ └── example_dockerfile │ ├── test_docker2ami.py │ ├── test_ami_builder.py │ └── test_parser.py ├── src └── docker2ami │ ├── __init__.py │ ├── docker2ami.py │ ├── ami_builder.py │ └── parser.py ├── tox.ini ├── package.json ├── TODO.rst ├── .gitignore ├── requirements.txt ├── Makefile ├── .pre-commit-config.yaml ├── MANIFEST.in ├── requirements-test.txt ├── bump-info.json ├── .github └── workflows │ ├── bump-version.yml │ ├── build.yml │ └── make-release.yml ├── LICENSE ├── etc └── docker-build-ami.conf ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/docker2ami/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/docker2ami/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/empty.conf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/copy_dockerfile: -------------------------------------------------------------------------------- 1 | COPY foo bar 2 | COPY foo . 3 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | commands = {posargs:pytest} 3 | deps = .[test] 4 | 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docker-build-ami", 3 | "version": "0.7" 4 | } 5 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/workdir_dockerfile: -------------------------------------------------------------------------------- 1 | WORKDIR /home/docker/app 2 | WorkDir some/folder 3 | -------------------------------------------------------------------------------- /TODO.rst: -------------------------------------------------------------------------------- 1 | TODO 2 | ==== 3 | - Support source image using name/version 4 | - Trap exit and stop instance/cleanup 5 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/add_dockerfile: -------------------------------------------------------------------------------- 1 | ADD foo bar 2 | ADD foo . 3 | AdD http://www.me.com?foo=bar hello.txt 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | .coverage 4 | __pycache__ 5 | build/ 6 | dist/ 7 | docker_build_ami.egg-info/ 8 | .tox 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3>=1.9.230 2 | configparser>=4.0.2 3 | colorlog>=4.0.2 4 | cryptography>=2.7 5 | paramiko>=2.6.0 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: publish 2 | 3 | clean: 4 | rm -rf build/ dist/ *.egg-info/ 5 | 6 | publish: 7 | python setup.py sdist register upload 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v1.2.3 4 | hooks: 5 | - id: flake8 6 | 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include etc/docker-build-ami.conf 2 | include README.rst 3 | include LICENSE 4 | include requirements.txt 5 | include requirements-test.txt 6 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/archive/images/apple.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ake-persson/docker-build-ami/HEAD/tests/docker2ami/fixtures/archive/images/apple.jpg -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/archive/images/docker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ake-persson/docker-build-ami/HEAD/tests/docker2ami/fixtures/archive/images/docker.png -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/env_dockerfile: -------------------------------------------------------------------------------- 1 | ENV FOO=BAR 2 | ENV HOME=/root \ 3 | TZ=:America/New_York \ 4 | LANG=EN.UTF-8 \ 5 | ME_BASE_HOME=/usr/src/me-base 6 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | black 2 | pycodestyle 3 | pylint 4 | flake8>=3.7.8 5 | pre-commit>=1.18.3 6 | pytest>=5.1.2 7 | pytest-cov>=2.8.1 8 | pytest-mock>=1.10.4 9 | tox 10 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/archive/hello.c: -------------------------------------------------------------------------------- 1 | #include 2 | int main() 3 | { 4 | // printf() displays the string inside quotation 5 | printf("Hello, World!"); 6 | return 0; 7 | } 8 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/misc_dockerfile: -------------------------------------------------------------------------------- 1 | # https://github.com/phusion/passenger-docker 2 | # https://hub.docker.com/r/phusion/passenger-full/ 3 | FROM phusion/passenger-full:1.0.6 4 | LABEL maintainer "Me " 5 | 6 | ARG DEBIAN_FRONTEND=noninteractive 7 | 8 | VOLUME $PIP_OVERRIDE_DIR 9 | CMD ["/sbin/my_init"] 10 | 11 | # aws-skip 12 | # AWS-SKIP 13 | -------------------------------------------------------------------------------- /bump-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "scheme": "custom", 3 | "schemeDefinition": "major.minor[.build][->patch]", 4 | "versionFile": "./package.json", 5 | "files": [{ 6 | "path": "./setup.py", 7 | "line": 10 8 | }], 9 | "rules": [{ 10 | "trigger": "manual", 11 | "bump": "minor", 12 | "branch": "master", 13 | "reset": "build", 14 | "tag": true 15 | }] 16 | } 17 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/example.conf: -------------------------------------------------------------------------------- 1 | [main] 2 | image_name = ubuntu-test 3 | image_id = ami-0df67e2624dedbae1 4 | image_user = ubuntu 5 | region = eu-east-1 6 | subnet_id = subnet-123abc45 7 | instance_type = m5.medium 8 | security_group_ids = ["sg-1234", "sg-23456"] 9 | host_tag = docker-build-ami-host-tag 10 | host_tags = [{"Key": "foo", "Value": "bar"}] 11 | image_tags = [{"Key": "foo", "Value": "baz"}] 12 | aws_access_key_id = DFSDF3HGDF4SDSD1DDFF 13 | aws_secret_access_key = 3riljdsf5SDFSDvsdfds452sdSDFDfsdf44SDFdRA 14 | tmp_dir = /usr/tmp 15 | 16 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/coco-dev/docker-build-ami.conf: -------------------------------------------------------------------------------- 1 | [main] 2 | image_name = ubuntu-test 3 | image_id = ami-0df67e2624dedbae1 4 | image_user = ubuntu 5 | region = eu-east-1 6 | subnet_id = subnet-123abc45 7 | instance_type = m5.medium 8 | security_group_ids = ["sg-1234", "sg-23456"] 9 | host_tag = docker-build-ami-host-tag 10 | host_tags = [{"Key": "foo", "Value": "bar"}] 11 | image_tags = [{"Key": "foo", "Value": "baz"}] 12 | aws_access_key_id = DFSDF3HGDF4SDSD1DDFF 13 | aws_secret_access_key = 3riljdsf5SDFSDvsdfds452sdSDFDfsdf44SDFdRA 14 | tmp_dir = /usr/tmp 15 | 16 | -------------------------------------------------------------------------------- /.github/workflows/bump-version.yml: -------------------------------------------------------------------------------- 1 | name: Bump and Tag Version 2 | 3 | on: [workflow_dispatch] 4 | 5 | jobs: 6 | bump: 7 | if: "!contains(github.event.head_commit.author.name, 'version-bumper')" 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | # Checkout action is required 12 | - uses: actions/checkout@v2 13 | with: 14 | token: ${{ secrets.ADMIN_TOKEN }} 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: '12' 18 | - name: Bump Versions 19 | uses: michmich112/version-bumper@master 20 | with: 21 | options-file: './bump-info.json' 22 | github-token: ${{ secrets.ADMIN_TOKEN }} 23 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/run_dockerfile: -------------------------------------------------------------------------------- 1 | RUN apt-get update 2 | RUN apt-get upgrade --assume-yes --verbose-versions --option Dpkg::Options::="--force-confold"\ 3 | && apt-get install --assume-yes --verbose-versions \ 4 | apt-utils \ 5 | binfmt-support \ 6 | build-essential \ 7 | curl \ 8 | dnsutils \ 9 | git \ 10 | htop \ 11 | iftop \ 12 | iotop \ 13 | iputils-ping \ 14 | libssl-dev \ 15 | lsof \ 16 | man \ 17 | mlocate \ 18 | netcat \ 19 | pkg-config \ 20 | rsync \ 21 | strace \ 22 | sudo \ 23 | tcpdump \ 24 | telnet \ 25 | tzdata \ 26 | vim \ 27 | wget 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | docker-build-ami - Build and AMIs from Dockerfiles 2 | 3 | Copyright (c) 2013 Michael Persson 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | - push 5 | 6 | jobs: 7 | build: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest, macos-latest] 12 | python-version: ['3.6', '3.7', '3.8', '3.9'] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: set pythonpath 20 | run: | 21 | echo "PYTHONPATH=$(pwd)/src" >> $GITHUB_ENV 22 | echo "PATH=${PATH}:${HOME}/.local/bin" >> $GITHUB_ENV 23 | - name: Install Requirements 24 | run: | 25 | sudo pip install .[test] 26 | - name: Run tests 27 | run: | 28 | pycodestyle src tests setup.py 29 | tox 30 | -------------------------------------------------------------------------------- /etc/docker-build-ami.conf: -------------------------------------------------------------------------------- 1 | [main] 2 | # The AMI Name of the output image 3 | # image_name = ubuntu-test 4 | 5 | # Base image from which the output image is built 6 | # image_id = ami-0df67e2624dedbae1 7 | 8 | # EC2 user used to build instances (usually AMI dependent) 9 | # image_user = ubuntu 10 | 11 | # Region for the image and EC2 instance that builds the image 12 | # region = eu-west-1 13 | 14 | # Subnet ID used for the EC2 that builds the image 15 | # subnet_id = subnet-123abc45 16 | 17 | # Instance type of the EC2 that builds the image 18 | # instance_type = m3.medium 19 | 20 | # Security Groups of the EC2 that builds the image 21 | # security_group_ids = ["sg-1234", "sg-23456"] 22 | 23 | # Name tag for host building AMI image 24 | # host_tag = docker-build-ami 25 | 26 | # Host Tags - additional tags to add to EC2 host 27 | # host_tags = [{"Key": "foo", "Value": "bar"}] 28 | 29 | # Image Tags - tags to add to AMI 30 | # image_tags = [{"Key": "foo", "Value": "bar"}] 31 | 32 | # AWS access key id for creating the image 33 | # aws_access_key_id = DFSDF3HGDF4SDSD1DDFF 34 | 35 | # AWS secret access key for the access key 36 | # aws_secret_access_key = 3riljdsf5SDFSDvsdfds452sdSDFDfsdf44SDFdRA 37 | 38 | # Temporary directory to use on the EC2 instance 39 | # tmp_dir = /tmp 40 | -------------------------------------------------------------------------------- /.github/workflows/make-release.yml: -------------------------------------------------------------------------------- 1 | name: Make a New Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | create-release: 10 | name: Create GitHub Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Get the tag 15 | run: | 16 | echo "RELEASE_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 17 | echo "REPO_NAME=${GITHUB_REPOSITORY#*/}" >> $GITHUB_ENV 18 | - uses: ncipollo/release-action@v1 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | allowUpdates: true 22 | draft: true 23 | name: "${{ format('{0} {1}', env.REPO_NAME, env.RELEASE_TAG) }}" 24 | 25 | 26 | build-n-publish: 27 | name: Build and publish distribution PyPI 28 | needs: create-release 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@master 32 | 33 | - name: Set up Python 3.6 34 | uses: actions/setup-python@v1 35 | with: 36 | python-version: 3.6 37 | 38 | - name: Build a binary wheel and a source tarball 39 | run: ./setup.py sdist 40 | 41 | - name: Publish distribution to PyPI 42 | uses: pypa/gh-action-pypi-publish@master 43 | with: 44 | password: ${{ secrets.PYPI_API_TOKEN }} 45 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/example_dockerfile: -------------------------------------------------------------------------------- 1 | # https://github.com/phusion/passenger-docker 2 | # https://hub.docker.com/r/phusion/passenger-full/ 3 | FROM phusion/passenger-full:1.0.6 4 | LABEL maintainer "Me " 5 | 6 | ARG DEBIAN_FRONTEND=noninteractive 7 | 8 | # Set simple env variable 9 | ENV FOO=BAR 10 | 11 | # Set multiple env variables 12 | ENV HOME=/root \ 13 | TZ=:America/New_York \ 14 | LANG=EN.UTF-8 \ 15 | ME_BASE_HOME=/usr/src/me-base 16 | 17 | # Do copies 18 | COPY foo $FOO 19 | COPY foo . 20 | 21 | # Do add 22 | ADD foo $ME_BASE_HOME/foo 23 | ADD https://raw.githubusercontent.com/jamieleecho/coco-dev/master/Dockerfile . 24 | 25 | # Do run 26 | RUN apt-get update 27 | RUN apt-get upgrade --assume-yes --verbose-versions --option Dpkg::Options::="--force-confold"\ 28 | && apt-get install --assume-yes --verbose-versions \ 29 | apt-utils \ 30 | binfmt-support \ 31 | build-essential \ 32 | curl \ 33 | dnsutils \ 34 | git \ 35 | htop \ 36 | iftop \ 37 | iotop \ 38 | iputils-ping \ 39 | libssl-dev \ 40 | lsof \ 41 | man \ 42 | mlocate \ 43 | netcat \ 44 | pkg-config \ 45 | rsync \ 46 | strace \ 47 | sudo \ 48 | tcpdump \ 49 | telnet \ 50 | tzdata \ 51 | vim \ 52 | wget 53 | 54 | # AWS-SKIP 55 | RUN echo this should be skipped 56 | 57 | # Should do nothing here 58 | VOLUME $PIP_OVERRIDE_DIR 59 | CMD ["/sbin/my_init"] 60 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | ''' 4 | Setup script for docker-build-ami 5 | ''' 6 | 7 | from setuptools import setup, find_packages 8 | 9 | # VERSION MUST be defined on line 10 10 | VERSION = '0.7.0' 11 | 12 | with open('requirements.txt') as f: 13 | requires = f.read().splitlines() 14 | 15 | with open('requirements-test.txt') as f: 16 | test_deps = f.read().splitlines() 17 | extras = { 18 | "test": test_deps, 19 | } 20 | 21 | 22 | CLASSIFIERS = [ 23 | 'Development Status :: 4 - Beta', 24 | 'Environment :: Console', 25 | 'Intended Audience :: System Administrators', 26 | 'Intended Audience :: Developers', 27 | 'License :: OSI Approved :: Apache Software License', 28 | 'Operating System :: POSIX :: Linux', 29 | 'Operating System :: MacOS :: MacOS X', 30 | 'Programming Language :: Python', 31 | ] 32 | 33 | with open('README.rst', 'r') as fh: 34 | long_description = fh.read() 35 | 36 | setup( 37 | name='jamieleecho-docker-build-ami', 38 | version=VERSION, 39 | 40 | description='Build Amazon EC2 AMI image using a Dockerfile', 41 | long_description=long_description, 42 | long_description_content_type='text/x-rst', 43 | 44 | # The project's main homepage. 45 | url='https://github.com/jamieleecho/docker-build-ami.git', 46 | 47 | # Author details 48 | author='Michael Persson', 49 | author_email='michael.ake.persson@gmail.com', 50 | 51 | # Choose your license 52 | license='Apache License, Version 2.0', 53 | license_files=('LICENSE.txt',), 54 | 55 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 56 | classifiers=CLASSIFIERS, 57 | 58 | # What does your project relate to? 59 | keywords='docker aws ami ec2', 60 | 61 | # You can just specify the packages manually here if your project is 62 | # simple. Or you can use find_packages(). 63 | packages=find_packages(where='src'), 64 | package_dir={ 65 | '': 'src', 66 | }, 67 | data_files=[('/etc', ['etc/docker-build-ami.conf'])], 68 | install_requires=requires, 69 | tests_require=test_deps, 70 | extras_require=extras, 71 | python_requires=">=3.6", 72 | 73 | entry_points={ 74 | "console_scripts": [ 75 | "docker-build-ami=docker2ami.docker2ami:main", 76 | ], 77 | }, 78 | ) 79 | -------------------------------------------------------------------------------- /tests/docker2ami/fixtures/coco-dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 2 | 3 | MAINTAINER Jamie Cho version: 0.16 4 | 5 | # Setup sources 6 | RUN apt-get update && apt-get upgrade -y && apt-get install -y \ 7 | bison \ 8 | build-essential \ 9 | curl \ 10 | default-jdk \ 11 | dos2unix \ 12 | ffmpeg \ 13 | flex \ 14 | fuse \ 15 | g++ \ 16 | git \ 17 | libfuse-dev \ 18 | libmagickwand-dev \ 19 | mame-tools \ 20 | markdown \ 21 | python \ 22 | python-dev \ 23 | python-pip \ 24 | python-setuptools \ 25 | ruby \ 26 | software-properties-common \ 27 | vim 28 | 29 | # Install useful Python tools 30 | RUN pip install \ 31 | numpy==1.16.5 \ 32 | Pillow==6.2.0 \ 33 | pypng==0.0.20 \ 34 | wand==0.5.7 35 | 36 | # Install CoCo Specific stuff 37 | RUN add-apt-repository ppa:tormodvolden/m6809 && \ 38 | echo deb http://ppa.launchpad.net/tormodvolden/m6809/ubuntu trusty main >> /etc/apt/sources.list.d/tormodvolden-m6809-trusty.list && \ 39 | echo deb http://ppa.launchpad.net/tormodvolden/m6809/ubuntu precise main >> /etc/apt/sources.list.d/tormodvolden-m6809-trusty.list && \ 40 | apt-get update && apt-get upgrade -y && apt-get install -y \ 41 | cmoc=0.1.60-0~tormod \ 42 | gcc6809=4.6.4-0~lw9a~trusty \ 43 | lwtools=4.17-0~tormod~~trusty \ 44 | toolshed=2.2-0~tormod 45 | 46 | # Install CoCo image conversion scripts 47 | RUN git config --global core.autocrlf input && \ 48 | git clone https://github.com/jamieleecho/coco-tools.git && \ 49 | (cd coco-tools && python setup.py install) 50 | 51 | # Install milliluk-tools 52 | RUN git config --global core.autocrlf input && \ 53 | git clone https://github.com/milliluk/milliluk-tools.git && \ 54 | (cd milliluk-tools && git checkout 454e7247c892f7153136b9e5e6b12aeeecc9dd36 && \ 55 | dos2unix < cgp220/cgp220.py > /usr/local/bin/cgp220.py && \ 56 | chmod a+x /usr/local/bin/cgp220.py) 57 | 58 | # Install tlidner/cmoc_os9 59 | RUN git clone https://github.com/tlindner/cmoc_os9.git && \ 60 | (cd cmoc_os9/lib && \ 61 | git checkout 9f9dfda1406d152f137131f0670c94d105b9b072 && \ 62 | make && \ 63 | cd ../cgfx && \ 64 | make && \ 65 | cd .. && \ 66 | mkdir -p /usr/share/cmoc/lib/os9 && \ 67 | mkdir -p /usr/share/cmoc/include/os9/cgfx && \ 68 | cp lib/libc.a cgfx/libcgfx.a /usr/share/cmoc/lib/os9 && \ 69 | cp -R include/* /usr/share/cmoc/include/os9 && \ 70 | cp -R cgfx/include/* /usr/share/cmoc/include/os9) 71 | 72 | # Install java grinder 73 | RUN git clone https://github.com/mikeakohn/naken_asm.git && \ 74 | git clone https://github.com/mikeakohn/java_grinder && \ 75 | (cd naken_asm && \ 76 | git checkout e9ad7c8181c39ed09bde0d9fd1c285a2ee97edd7 && \ 77 | ./configure && make && make install && \ 78 | cd ../java_grinder && \ 79 | git checkout b3ef7b33343fd877573af5f63502393ffe31f9ab && \ 80 | make && make java && \ 81 | (cd samples/trs80_coco && make grind) && \ 82 | cp java_grinder /usr/local/bin/) 83 | 84 | # Clean up 85 | RUN apt-get clean && \ 86 | ln -s /home /Users 87 | 88 | # For java_grinder 89 | ENV CLASSPATH=/root/java_grinder/build/JavaGrinder.jar 90 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | docker-build-ami 2 | ================ 3 | 4 | Build Amazon EC2 AMI image using a Dockerfile 5 | 6 | Limitations 7 | =========== 8 | Only supports instructions ENV, RUN, COPY and ADD, other instructions will just be ignored. 9 | 10 | Configuration 11 | ============= 12 | 13 | There is a separate config file for the script in either "/etc/docker-build-ami.conf" or "~/.docker-build-ami.conf". 14 | 15 | .. code-block:: 16 | 17 | # [main] 18 | 19 | # Temporary directory 20 | # tmp_dir = /tmp 21 | 22 | # Name tag for host building AMI image 23 | # host_tag = 'docker-build-ami' 24 | 25 | # Region 26 | # region = eu-west-1 27 | 28 | # Instance type 29 | # instance_type = m3.medium 30 | 31 | # Subnet ID 32 | # subnet_id = subnet-123abc45 33 | 34 | # Security Groups 35 | # security_group_ids = ["sg-1234", "sg-23456"] 36 | 37 | # Host Tags - additional tags to add to EC2 host 38 | # host_tags = [{"Key": "foo", "Value": "bar"}] 39 | 40 | # AWS access key id 41 | # aws_access_key_id = DFSDF3HGDF4SDSD1DDFF 42 | 43 | # AWS secret access key 44 | # aws_secret_access_key = 3riljdsf5SDFSDvsdfds452sdSDFDfsdf44SDFdRA 45 | 46 | # Base image from which the output image is built 47 | # image_id = ami-0df67e2624dedbae1 48 | 49 | # EC2 user used to build instances (usually AMI dependent) 50 | # image_user = ubuntu 51 | 52 | # The AMI Name of the output image 53 | # image_name = ubuntu-test 54 | 55 | # Image Tags - tags to add to AMI 56 | # image_tags = [{"Key": "foo", "Value": "bar"}] 57 | 58 | 59 | Usage 60 | ===== 61 | 62 | .. code-block:: 63 | 64 | usage: docker-build-ami [-h] [-c CONFIG] [-d] [-r REGION] [-t INSTANCE_TYPE] 65 | [-s SUBNET_ID] [-n IMAGE_NAME] [-i IMAGE_ID] 66 | [-u IMAGE_USER] 67 | 68 | optional arguments: 69 | -h, --help show this help message and exit 70 | -c CONFIG, --config CONFIG 71 | Configuration file 72 | -d, --debug Print debug info 73 | -r REGION, --region REGION 74 | AWS region 75 | -t INSTANCE_TYPE, --instance-type INSTANCE_TYPE 76 | EC2 instance type 77 | -s SUBNET_ID, --subnet-id SUBNET_ID 78 | AWS subnet id 79 | -n IMAGE_NAME, --image-name IMAGE_NAME 80 | Target AMI image name 81 | -i IMAGE_ID, --image-id IMAGE_ID 82 | Source AMI image ID 83 | -u IMAGE_USER, --image-user IMAGE_USER 84 | AMI image user 85 | 86 | Running Tests 87 | ============= 88 | 89 | .. code-block:: 90 | 91 | # Run these lines once 92 | pip3 install -r requirements-test.txt 93 | pre-commit install 94 | 95 | # Run these lines to check code formatting and correctness 96 | flake8 --show-source --filename="\*.py" . 97 | pytest --cov=docker2ami 98 | 99 | -------------------------------------------------------------------------------- /src/docker2ami/docker2ami.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import colorlog 3 | import configparser 4 | import logging 5 | import os 6 | import re 7 | import sys 8 | 9 | from .ami_builder import AmiBuilder, AwsConfig, Color 10 | from .parser import AbstractParserDelegate, ParserState, \ 11 | SimpleStateParserDelegate, is_url_arg, parse_dockerfile_with_delegate 12 | 13 | 14 | class Docker2AmiParserDelegate(AbstractParserDelegate): 15 | """ 16 | ParserDelegate that creates an AMI using an AmiBuilder 17 | """ 18 | def __init__(self, ami_builder, parser_state): 19 | self._ami_builder = ami_builder 20 | self._parser_state = parser_state 21 | 22 | def run_run(self, cmds): 23 | self._ami_builder.run_cmd(self._parser_state.env, cmds) 24 | 25 | def run_copy(self, src, dst): 26 | self._ami_builder.run_cmd(self._parser_state.env, 27 | f'cp -rf /tmp/docker-build-ami/{src} {dst}') 28 | 29 | def run_add(self, src, dst): 30 | if is_url_arg(src): 31 | dst = os.path.basename(src) if dst == '.' else dst 32 | self._ami_builder.run_cmd( 33 | self._parser_state.env, f'curl {src} -o {dst}') 34 | else: 35 | if re.match(r'.*\.(tgz|tar|tar\.gz|tar\.bz|tar\.xz)$', src): 36 | self._ami_builder.run_cmd( 37 | self._parser_state.env, 38 | f'tar -xpvf /tmp/docker-build-ami/{src} -C {dst}') 39 | else: 40 | self._ami_builder.run_cmd( 41 | self._parser_state.env, 42 | f'cp -rf /tmp/docker-build-ami/{src} {dst}') 43 | 44 | def run_unknown(self, line): 45 | print(f'{Color.YELLOW}Unknown Command: {line}{Color.CLEAR}') 46 | 47 | 48 | def create_arg_parser(): 49 | parser = argparse.ArgumentParser() 50 | parser.add_argument('-c', '--config', help='Configuration file') 51 | parser.add_argument('-d', '--debug', action='store_true', 52 | help='Print debug info') 53 | parser.add_argument('-r', '--region', help='AWS region') 54 | parser.add_argument('-t', '--instance-type', help='EC2 instance type') 55 | parser.add_argument('-s', '--subnet-id', help='AWS subnet id') 56 | parser.add_argument('-n', '--image-name', help='Target AMI image name') 57 | parser.add_argument('-i', '--image-id', help='Source AMI image ID') 58 | parser.add_argument('-u', '--image-user', help='AMI image user') 59 | return parser 60 | 61 | 62 | def create_config_parser(): 63 | config = configparser.ConfigParser() 64 | config.add_section('main') 65 | config.set('main', 'image_name', 'docker-build-ami') 66 | config.set('main', 'image_id', 'ami-e4ff5c93') 67 | config.set('main', 'image_user', 'centos') 68 | config.set('main', 'region', 'us-west-1') 69 | config.set('main', 'subnet_id', '') 70 | config.set('main', 'instance_type', 'm3.medium') 71 | config.set('main', 'security_group_ids', '[]') 72 | config.set('main', 'host_tag', 'docker-build-ami') 73 | config.set('main', 'host_tags', '[]') 74 | config.set('main', 'image_tags', '[]') 75 | config.set('main', 'aws_access_key_id', '') 76 | config.set('main', 'aws_secret_access_key', '') 77 | config.set('main', 'tmp_dir', '/tmp') 78 | return config 79 | 80 | 81 | def setup_logger(debug): 82 | """ Sets up logging, putting it in debug mode if debug is True """ 83 | # Create formatter 84 | formatter = colorlog.ColoredFormatter( 85 | '[%(log_color)s%(levelname)-8s%(reset)s] ' 86 | '%(log_color)s%(message)s%(reset)s') 87 | console = logging.StreamHandler() 88 | console.setFormatter(formatter) 89 | logger = logging.root 90 | logger.addHandler(console) 91 | logger.setLevel(logging.DEBUG if debug else logging.WARN) 92 | 93 | 94 | def get_config_path(config_path): 95 | logger = logging.root 96 | if config_path: 97 | if os.path.isfile(os.path.expanduser(config_path)): 98 | cfile = os.path.expanduser(config_path) 99 | else: 100 | logger.error('Config file doesn\'t exist: {0}'.format(config_path)) 101 | exit(1) 102 | elif os.path.isfile(os.path.expanduser('~/.docker-build-ami.conf')): 103 | cfile = os.path.expanduser('~/.docker-build-ami.conf') 104 | elif os.path.isfile('/etc/docker-build-ami.conf'): 105 | cfile = '/etc/docker-build-ami.conf' 106 | else: 107 | cfile = None 108 | 109 | return cfile 110 | 111 | 112 | def main_with_args(argv): 113 | # get the configuration 114 | argparser = create_arg_parser() 115 | args = argparser.parse_args(argv) 116 | setup_logger(args.debug) 117 | config = create_config_parser() 118 | config_path = get_config_path(args.config) 119 | if config_path: 120 | config.read(config_path) 121 | aws_config = AwsConfig(config, 'main', args) 122 | 123 | # check for errors 124 | if not os.path.isfile('Dockerfile'): 125 | logging.critical( 126 | 'There needs to be a Dockerfile in the current directory') 127 | exit(1) 128 | if not aws_config.aws_access_key_id: 129 | logging.critical('You need to specify an AWS Access Key ID') 130 | exit(1) 131 | if not aws_config.aws_secret_access_key: 132 | logging.critical('You need to specify a AWS Secret Access Key') 133 | exit(1) 134 | 135 | # Parse the Dockerfile and create the AMI 136 | with open('Dockerfile', 'r') as dockerfile: 137 | with AmiBuilder(aws_config) as ami_builder: 138 | parser_state = ParserState() 139 | ami_parser_delegate = Docker2AmiParserDelegate( 140 | ami_builder, parser_state) 141 | parser_delegate = SimpleStateParserDelegate( 142 | ami_parser_delegate, parser_state) 143 | ami_builder.send_archive() 144 | parse_dockerfile_with_delegate(dockerfile, parser_delegate) 145 | ami_builder.save_ami() 146 | 147 | 148 | def main(): 149 | main_with_args(sys.argv[1:]) 150 | -------------------------------------------------------------------------------- /src/docker2ami/ami_builder.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import datetime 3 | import glob 4 | import json 5 | import logging 6 | import paramiko 7 | import shlex 8 | import socket 9 | import tarfile 10 | import time 11 | import uuid 12 | 13 | from os.path import expanduser, join 14 | from sys import stdout 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | class Color: 21 | RED = '\033[31m' 22 | YELLOW = '\033[33m' 23 | DARK_GREY = '\033[90m' 24 | CLEAR = '\033[0m' 25 | 26 | 27 | def _check_port(host, port): 28 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 29 | try: 30 | s.connect((host, port)) 31 | s.shutdown(socket.SHUT_RDWR) 32 | s.close() 33 | return True 34 | except socket.error: 35 | return False 36 | 37 | 38 | class AwsConfig(object): 39 | """ 40 | Holds AWS configuration info for the AMI Builder. 41 | """ 42 | def __init__(self, config, section, overrides): 43 | """ 44 | Initialize configuration information from the given config file and 45 | section. overrides is an object that will ovverde the values in config. 46 | """ 47 | key_names = [ 48 | 'host_tag', 'tmp_dir', 'image_name', 'region', 'instance_type', 49 | 'subnet_id', 'image_id', 'image_user', 'aws_access_key_id', 50 | 'aws_secret_access_key', 'security_group_ids', 'host_tags', 51 | 'image_tags', 52 | ] 53 | for key in key_names: 54 | setattr(self, key, 55 | getattr(overrides, key) 56 | if hasattr(overrides, key) and 57 | not getattr(overrides, key) is None 58 | else config.get(section, key)) 59 | 60 | 61 | class AmiBuilder(object): 62 | """ 63 | Object for building up an AMI. Can be invoked via with: or by explicitly 64 | invoking start() and finish() 65 | """ 66 | def __init__(self, aws_config): 67 | """ 68 | Initializes the AMI from the given configuration. 69 | """ 70 | self._config = aws_config 71 | self._key_name = str(uuid.uuid4()) 72 | self._key_path = expanduser(join(self._config.tmp_dir, 73 | f'{self._key_name}.pem')) 74 | self._security_group_ids = json.loads(aws_config.security_group_ids) 75 | self._host_tags = json.loads(aws_config.host_tags) 76 | self._image_tags = json.loads(aws_config.image_tags) 77 | self._ec2 = None 78 | self._key_pair = None 79 | self._instance_obj = None 80 | 81 | def start(self): 82 | """ 83 | Starts the process of building an AMI by launching and connecting to 84 | an EC2 85 | """ 86 | # Connect to AWS 87 | try: 88 | self._ec2 = boto3.client( 89 | 'ec2', region_name=self._config.region, 90 | aws_access_key_id=self._config.aws_access_key_id, 91 | aws_secret_access_key=self._config.aws_secret_access_key) 92 | self._ec2_resource = boto3.resource('ec2') 93 | except BaseException: 94 | raise RuntimeError('Failed to connect to EC2') 95 | 96 | # Create the EC2 instance that we will use to build the AMI 97 | self._key_pair = self._ec2.create_key_pair(KeyName=self._key_name) 98 | with open(self._key_path, 'w') as f: 99 | f.write(self._key_pair['KeyMaterial']) 100 | tags = self._host_tags + [ 101 | {'Key': 'Name', 'Value': self._config.host_tag}, 102 | ] 103 | tag_spec = [ 104 | {'ResourceType': 'instance', 'Tags': tags}, 105 | {'ResourceType': 'volume', 'Tags': tags}, 106 | ] 107 | reservation = self._ec2.run_instances( 108 | ImageId=self._config.image_id, KeyName=self._key_name, 109 | InstanceType=self._config.instance_type, 110 | SubnetId=self._config.subnet_id, MinCount=1, MaxCount=1, 111 | TagSpecifications=tag_spec, 112 | SecurityGroupIds=self._security_group_ids) 113 | 114 | # Find the newly created EC2 115 | self._instance = None 116 | for r in self._ec2.describe_instances()['Reservations']: 117 | if r['ReservationId'] == reservation['ReservationId']: 118 | self._instance = r['Instances'][0] 119 | break 120 | if not self._instance: 121 | raise RuntimeError( 122 | f'Unable to find EC2: {reservation["ReservationId"]}') 123 | print(f'Instance: {self._instance["InstanceId"]}') 124 | print(f'Instance IP: {self._instance["PrivateIpAddress"]}') 125 | print(f'Connection SSH key: {self._key_path}') 126 | 127 | # Wait around for the EC2 to be running 128 | stdout.write('Waiting for instance status running.') 129 | stdout.flush() 130 | self._instance_obj = self._ec2_resource.Instance( 131 | self._instance['InstanceId']) 132 | while self._instance_obj.state['Name'] != 'running': 133 | stdout.write('.') 134 | stdout.flush() 135 | time.sleep(5) 136 | self._instance_obj.reload() 137 | 138 | # Wait for the EC2 to be accessible via SSH 139 | stdout.write('\nWaiting for SSH to become ready.') 140 | stdout.flush() 141 | while not _check_port(self._instance_obj.private_ip_address, 22): 142 | stdout.write('.') 143 | stdout.flush() 144 | time.sleep(5) 145 | 146 | # Connect via ssh 147 | key = paramiko.RSAKey.from_private_key_file(self._key_path) 148 | self._ssh = paramiko.SSHClient() 149 | self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 150 | self._ssh.connect(hostname=self._instance_obj.private_ip_address, 151 | username=self._config.image_user, 152 | pkey=key) 153 | 154 | def send_archive(self): 155 | print('\nCreate archive...') 156 | with tarfile.open( 157 | join(self._config.tmp_dir, 'docker-build-ami.tar.gz'), 158 | 'w:gz') as tar: 159 | for fn in glob.glob('*'): 160 | logger.info(f'Adding file to archive: {fn}') 161 | tar.add(fn) 162 | 163 | print('\nCopy archive...') 164 | sftp = self._ssh.open_sftp() 165 | sftp.put(self._config.tmp_dir + '/docker-build-ami.tar.gz', 166 | '/tmp/docker-build-ami.tar.gz') 167 | sftp.close() 168 | 169 | # Untar archive 170 | print('\nUntar archive...') 171 | self.run_cmd('', 172 | 'mkdir /tmp/docker-build-ami; ' 173 | 'tar -xzf /tmp/docker-build-ami.tar.gz' 174 | ' -C /tmp/docker-build-ami') 175 | 176 | def run_cmd(self, env, cmd): 177 | stdin, stdout, stderr = self._ssh.exec_command( 178 | f'set -ex; echo {shlex.quote(env)} {shlex.quote(cmd)} | sudo -i --', 179 | get_pty=True) 180 | output = stdout.read() 181 | if output: 182 | print(f'{Color.YELLOW}{str(output, "utf8")}{Color.CLEAR}') 183 | output = stderr.read() 184 | if output: 185 | print(f'{Color.RED}{str(output, "utf8")}{Color.CLEAR}') 186 | 187 | ecode = stdout.channel.recv_exit_status() 188 | if ecode != 0: 189 | logger.error( 190 | f'The command {cmd} returned a non-zero code: {ecode}') 191 | exit(ecode) 192 | 193 | def save_ami(self): 194 | print(f'\nCreate AMI from instance: {self._instance_obj.instance_id}') 195 | image_obj = self._instance_obj.create_image( 196 | Name=f'{self._config.image_name}-' 197 | f'{datetime.datetime.now().strftime("%Y%m%d%H%M%S")}') 198 | self._ec2.create_tags(Resources=[image_obj.image_id], 199 | Tags=self._image_tags 200 | + [{'Key': 'Name', 201 | 'Value': self._config.image_name}]) 202 | while image_obj.state == 'pending': 203 | stdout.write('.') 204 | stdout.flush() 205 | time.sleep(5) 206 | image_obj.reload() 207 | 208 | print(f'\nCreated image: {image_obj.image_id}') 209 | 210 | def finish(self): 211 | try: 212 | if self._instance_obj: 213 | self._instance_obj.terminate() 214 | finally: 215 | self._instance_obj = None 216 | 217 | def __enter__(self): 218 | self.start() 219 | return self 220 | 221 | def __exit__(self, exception_type, exception_value, traceback): 222 | self.finish() 223 | -------------------------------------------------------------------------------- /src/docker2ami/parser.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import re 4 | from docker2ami.ami_builder import Color 5 | 6 | 7 | # Match bash args 8 | BASH_ARG_REGEX_STR = r'((?:[^\s"\'\`\\]|(?:\\.))+|(?:\".*(?!\\)\")' \ 9 | r'|(?:\'.*(?!\\)\')|(?:`.*(?!\\)`))' 10 | 11 | # Match bash lvalues 12 | BASH_LVALUE_REGEX_STR = r'((?:[^\s"\'\`\\=]|(?:\\.))+|(?:\".*(?!\\)\")|' \ 13 | r'(?:\'.*(?!\\)\')|(?:`.*(?!\\)`))' 14 | 15 | # Matches foo=bar 16 | ASSIGNMENT_REGEX_STR = BASH_LVALUE_REGEX_STR + r'(?:(?:\s*=\s*)|\s+)' \ 17 | + BASH_ARG_REGEX_STR 18 | 19 | # Match URLs 20 | # See (https://daringfireball.net/2010/07/improved_regex_for_matching_urls) 21 | URL_REGEX = re.compile( 22 | r'(?i)\b((?:[a-z][\w-]+:(?:/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.]' 23 | r'[a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+' 24 | r'(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:\'".,<>?«»“”‘’]))') 25 | 26 | # Matches # AWS-SKIP 27 | AWS_SKIP_REGEX = re.compile(r'^\s*#\s*AWS-SKIP.*$') 28 | 29 | # Matches empty lines and comments 30 | COMMENT_REGEX = re.compile(r'^(\s*#.*|)$') 31 | 32 | # Matches multi-line lines 33 | MULTI_LINE_REGEX = re.compile(r'(.*)\\\s*$') 34 | 35 | # Matches begining of ENV lines 36 | ENV_START_REGEX_STR = r'^\s*(ENV)\s+' 37 | 38 | # Matches ENV commands 39 | ENV_REGEX = re.compile(ENV_START_REGEX_STR + r'(' + ASSIGNMENT_REGEX_STR 40 | + r'(\s+' + ASSIGNMENT_REGEX_STR + r')*)\s*$', 41 | re.IGNORECASE) 42 | 43 | # Separate ENV from assigments 44 | ENV_COMMAND_ASSIGNMENTS_REGEX = re.compile(ENV_START_REGEX_STR + r'(.+)$', 45 | re.IGNORECASE) 46 | 47 | # Matches FOO=BAR 48 | ASSIGNMENT_REGEX = re.compile(ASSIGNMENT_REGEX_STR, re.IGNORECASE) 49 | 50 | # Matches COPY src dst 51 | COPY_REGEX = re.compile(r'\s*(COPY)\s+' + BASH_ARG_REGEX_STR + r'\s+' 52 | + BASH_ARG_REGEX_STR + r'\s*\\?\s*$', re.IGNORECASE) 53 | 54 | # Matches ADD src dst 55 | ADD_REGEX = re.compile(r'^\s*(ADD)\s+' + BASH_ARG_REGEX_STR + r'\s+' 56 | + BASH_ARG_REGEX_STR + r'\s*\\?\s*$', re.IGNORECASE) 57 | 58 | # Matches WORKDIR path 59 | WORKDIR_REGEX = re.compile(r'^\s*(WORKDIR)\s+' + BASH_ARG_REGEX_STR 60 | + r'\s*\\?\s*$', re.IGNORECASE) 61 | 62 | # Matches the beinging of RUN lines 63 | RUN_START_REGEX_STR = r'^\s*(RUN)\s+' 64 | 65 | # Mathes RUN do some command now 66 | RUN_REGEX = re.compile(RUN_START_REGEX_STR + r'([^\s]+(?:[^s]+[^\s]+)*)\s*$', 67 | re.IGNORECASE) 68 | 69 | # Separates RUN from commands to be run 70 | RUN_COMMAND_COMMANDS_REGEX = re.compile(RUN_START_REGEX_STR + r'(.+)$', 71 | re.IGNORECASE) 72 | 73 | 74 | def is_quoted(str): 75 | """ whether or not str is quoted """ 76 | return ((len(str) > 2) 77 | and ((str[0] == "'" and str[-1] == "'") 78 | or (str[0] == '"' and str[-1] == '"'))) 79 | 80 | 81 | def is_url_arg(str): 82 | """ whether or not str is a URL argument """ 83 | return (True if URL_REGEX.match(str[1:-1] if is_quoted(str) else str) 84 | else False) 85 | 86 | 87 | def parse_dockerfile_with_delegate(fp, delegate): 88 | """ 89 | Parses the Dockerfile which is referenced in f, invoking the appropriate 90 | methods in delegate 91 | """ 92 | mline = False 93 | line = '' 94 | for line0 in fp.read().splitlines(): 95 | # Skip next instruction 96 | if AWS_SKIP_REGEX.match(line0): 97 | delegate.run_skip() 98 | continue 99 | 100 | # Skip empty lines and comments 101 | elif COMMENT_REGEX.match(line0): 102 | delegate.run_nop() 103 | continue 104 | 105 | # accumulate lines 106 | append_line = mline 107 | mline_match = MULTI_LINE_REGEX.match(line0) 108 | mline = not (mline_match is None) 109 | line0 = mline_match.groups()[0] if mline else line0 110 | line = f'{line} {line0}' if append_line else line0 111 | if mline: 112 | continue 113 | 114 | # Environment variable 115 | if ENV_REGEX.match(line): 116 | assignments_str = \ 117 | ENV_COMMAND_ASSIGNMENTS_REGEX.match(line).groups()[1] 118 | assignments = ASSIGNMENT_REGEX.findall(assignments_str) 119 | for (key, value) in assignments: 120 | delegate.run_env(key, value) 121 | 122 | # Run command 123 | elif RUN_REGEX.match(line): 124 | cmds = RUN_COMMAND_COMMANDS_REGEX.match(line).groups()[1] 125 | delegate.run_run(cmds) 126 | 127 | # Copy command 128 | elif COPY_REGEX.match(line): 129 | (op, src, dst) = COPY_REGEX.match(line).groups() 130 | delegate.run_copy(src, dst) 131 | 132 | # Add command 133 | elif ADD_REGEX.match(line): 134 | (op, src, dst) = ADD_REGEX.match(line).groups() 135 | delegate.run_add(src, dst) 136 | 137 | # Workdir command 138 | elif WORKDIR_REGEX.match(line): 139 | (op, path) = WORKDIR_REGEX.match(line).groups() 140 | delegate.run_workdir(path) 141 | 142 | # Unknown command 143 | else: 144 | delegate.run_unknown(line) 145 | 146 | 147 | class AbstractParserDelegate(object): 148 | """ 149 | Class responsible for processing on behalf of 150 | parse_dockerfile_with_delegate 151 | """ 152 | def run_skip(self): 153 | """ Invoked when the AWS-SKIP tag is encountered """ 154 | pass 155 | 156 | def run_nop(self): 157 | """ Invoked when a COMMENT or blank linke is encountered """ 158 | pass 159 | 160 | def run_env(self, key, value): 161 | """ Invoked when the ENV variable key is assigned value """ 162 | pass 163 | 164 | def run_run(self, cmds): 165 | """ Invoked when cmds should be run """ 166 | pass 167 | 168 | def run_copy(self, src, dst): 169 | """ Invoked when src should be copied to dst """ 170 | pass 171 | 172 | def run_add(self, src, dst): 173 | """ Invoked when src should be added to dst """ 174 | pass 175 | 176 | def run_workdir(self, path): 177 | """ Invoked when working directory should be changed to path """ 178 | pass 179 | 180 | def run_unknown(self, line): 181 | """ Invoked when an unknown command is run """ 182 | pass 183 | 184 | 185 | class ParserState(object): 186 | """ 187 | Object that holds simple state information such as the step 188 | number, the skip state and the environment state 189 | """ 190 | def __init__(self): 191 | self.step = 0 192 | self.skip = False 193 | self.env = '' 194 | 195 | 196 | class SimpleStateParserDelegate(AbstractParserDelegate): 197 | """ 198 | ParserDelegate that updates a ParserState object and invokes another 199 | ParserDelegate. 200 | """ 201 | def __init__(self, parser_delegate, parser_state): 202 | self._parser_delegate = parser_delegate 203 | self._parser_state = parser_state 204 | 205 | def run_skip(self): 206 | self._parser_state.skip = True 207 | self._parser_delegate.run_skip() 208 | 209 | def run_nop(self): 210 | self._parser_delegate.run_nop() 211 | 212 | def run_env(self, key, value): 213 | if (not self._parser_state.skip): 214 | self._parser_state.step = self._parser_state.step + 1 215 | print(f'Step {self._parser_state.step}: ENV {key} {value}') 216 | self._parser_state.env += f'{key}={value};' 217 | self._parser_delegate.run_env(key, value) 218 | else: 219 | print(f'{Color.DARK_GREY}Skipping for AWS: ' 220 | f'ENV {key} {value}{Color.CLEAR}') 221 | self._parser_state.skip = False 222 | 223 | def run_run(self, cmds): 224 | if (not self._parser_state.skip): 225 | self._parser_state.step = self._parser_state.step + 1 226 | print(f'Step {self._parser_state.step}: RUN {cmds}') 227 | self._parser_delegate.run_run(cmds) 228 | else: 229 | print(f'{Color.DARK_GREY}Skipping for AWS: ' 230 | f'RUN {cmds}{Color.CLEAR}') 231 | self._parser_state.skip = False 232 | 233 | def run_copy(self, src, dst): 234 | if (not self._parser_state.skip): 235 | self._parser_state.step = self._parser_state.step + 1 236 | print(f'Step {self._parser_state.step}: COPY {src} {dst}') 237 | self._parser_delegate.run_copy(src, dst) 238 | else: 239 | print(f'{Color.DARK_GREY}Skipping for AWS: ' 240 | f'COPY {src} {dst}{Color.CLEAR}') 241 | self._parser_state.skip = False 242 | 243 | def run_add(self, src, dst): 244 | if (not self._parser_state.skip): 245 | self._parser_state.step = self._parser_state.step + 1 246 | print(f'Step {self._parser_state.step}: ADD {src} {dst}') 247 | self._parser_delegate.run_add(src, dst) 248 | else: 249 | print(f'{Color.DARK_GREY}Skipping for AWS: ' 250 | f'ADD {src} {dst}{Color.CLEAR}') 251 | self._parser_state.skip = False 252 | 253 | def run_workdir(self, path): 254 | if (not self._parser_state.skip): 255 | self._parser_state.step = self._parser_state.step + 1 256 | print(f'Step {self._parser_state.step}: WORKDIR {path}') 257 | self._parser_state.env += f'cd {path};' 258 | self._parser_delegate.run_workdir(path) 259 | else: 260 | print(f'{Color.DARK_GREY}Skipping for AWS: ' 261 | f'WORKDIR {path}{Color.CLEAR}') 262 | self._parser_state.skip = False 263 | 264 | def run_unknown(self, line): 265 | self._parser_delegate.run_unknown(line) 266 | -------------------------------------------------------------------------------- /tests/docker2ami/test_docker2ami.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import pytest 4 | 5 | from unittest import mock 6 | 7 | from docker2ami.ami_builder import Color 8 | from docker2ami import docker2ami, parser 9 | 10 | 11 | @pytest.fixture(scope='function') 12 | def docker2ami_fixtures(request): 13 | request.cls.ami_builder_mock = mock.MagicMock() 14 | request.cls.parser_state = parser.ParserState() 15 | request.cls.parser_state.env = "FOO=BAR;" 16 | request.cls.target = docker2ami.Docker2AmiParserDelegate( 17 | request.cls.ami_builder_mock, request.cls.parser_state) 18 | 19 | 20 | @pytest.mark.usefixtures('docker2ami_fixtures') 21 | class TestDocker2Ami(object): 22 | def test_run_run_invokes_run_cmd(self): 23 | self.target.run_run('echo hello; echo goodbye') 24 | assert self.ami_builder_mock.run_cmd.called_with( 25 | self.parser_state.env, 26 | 'echo hello; echo goodbye') 27 | 28 | def test_run_copy_would_copy(self): 29 | self.target.run_copy('foo/src', '/dst/place') 30 | assert self.ami_builder_mock.run_cmd.called_with( 31 | self.parser_state.env, 32 | f'cp -rf /tmp/docker-build-ami/foo/src /dst/place') 33 | 34 | def test_run_add_with_file_path_src(self): 35 | self.target.run_add('docker/foo.json', '/dst/place') 36 | assert self.ami_builder_mock.run_cmd.called_with( 37 | self.parser_state.env, 38 | f'cp -rf /tmp/docker-build-ami/docker/foo.json /dst/place') 39 | 40 | def test_run_add_with_tarball_file_path_src(self): 41 | for ext in ('tar', 'tar.gz', 'tgz', 'tar.bz', 'tar.xz'): 42 | self.target.run_add(f'docker/foo.{ext}', '/dst/place') 43 | assert self.ami_builder_mock.run_cmd.called_with( 44 | self.parser_state.env, 45 | f'tar -xpvf /tmp/docker-build-ami/docker/foo.{ext} ' 46 | '-C /dst/place') 47 | 48 | def test_run_add_with_url_src(self): 49 | for url in ( 50 | 'http://www.ginkgobioworks.com/wp-content/uploads/2019/10/foo.png', 51 | 'https://www.ginkgobioworks.com/wp-content/uploads/2019/10/foo.tgz'): 52 | self.target.run_add(f'{url}', '/dst/place') 53 | assert self.ami_builder_mock.run_cmd.called_with( 54 | self.parser_state.env, 55 | f'curl {url} -o /dst/place') 56 | 57 | @mock.patch('builtins.print') 58 | def test_run_unknown_prints_message(self, print_mock): 59 | self.target.run_unknown('echo oops, I forgot the RUN') 60 | assert print_mock.called_with( 61 | f'{Color.YELLOW}Unknown Command: ' 62 | f'echo oops, I forgot the RUN{Color.CLEAR}') 63 | 64 | 65 | @pytest.fixture(scope='function') 66 | def argparser_fixture(request): 67 | return docker2ami.create_arg_parser() 68 | 69 | 70 | def assert_args_work(parser, short_flag, long_flag, arg_val): 71 | def arg_val_getter(args): 72 | return getattr(args, long_flag[2:].replace('-', '_')) 73 | args = parser.parse_args([short_flag, arg_val]) 74 | assert arg_val_getter(args) == arg_val 75 | args = parser.parse_args([long_flag, arg_val]) 76 | assert arg_val_getter(args) == arg_val 77 | 78 | 79 | def test_accepts_configuration_files(argparser_fixture): 80 | assert_args_work(argparser_fixture, '-c', '--config', '/foo/bar.cfg') 81 | 82 | 83 | def test_accepts_debug(argparser_fixture): 84 | args = argparser_fixture.parse_args([]) 85 | assert args.debug is False 86 | args = argparser_fixture.parse_args(['-d']) 87 | assert args.debug is True 88 | 89 | 90 | def test_accepts_region(argparser_fixture): 91 | assert_args_work(argparser_fixture, '-r', '--region', 'us-east-1a') 92 | 93 | 94 | def test_accepts_instance_type(argparser_fixture): 95 | assert_args_work(argparser_fixture, '-t', '--instance-type', 'a1.large') 96 | 97 | 98 | def test_accepts_image_name(argparser_fixture): 99 | assert_args_work(argparser_fixture, '-n', '--image-name', 'my-first-image') 100 | 101 | 102 | def test_accepts_image_id(argparser_fixture): 103 | assert_args_work(argparser_fixture, '-i', '--image-id', 104 | 'my-first-image-id') 105 | 106 | 107 | def test_accepts_image_user(argparser_fixture): 108 | assert_args_work(argparser_fixture, '-u', '--image-user', '12345678') 109 | 110 | 111 | @pytest.fixture(scope='function') 112 | def empty_config_fixture_path(request): 113 | fixture_dir = os.path.join(os.path.dirname(__file__), 'fixtures') 114 | return os.path.join(fixture_dir, 'empty.conf') 115 | 116 | 117 | @pytest.fixture(scope='function') 118 | def example_config_fixture_path(request): 119 | fixture_dir = os.path.join(os.path.dirname(__file__), 'fixtures') 120 | return os.path.join(fixture_dir, 'example.conf') 121 | 122 | 123 | @pytest.fixture(scope='function') 124 | def config_fixture(request): 125 | return docker2ami.create_config_parser() 126 | 127 | 128 | def test_reads_empty_config_files(config_fixture, empty_config_fixture_path): 129 | config_fixture.read(empty_config_fixture_path) 130 | conf = config_fixture 131 | assert conf.get('main', 'image_name') == 'docker-build-ami' 132 | assert conf.get('main', 'image_id') == 'ami-e4ff5c93' 133 | assert conf.get('main', 'image_user') == 'centos' 134 | assert conf.get('main', 'region') == 'us-west-1' 135 | assert conf.get('main', 'subnet_id') == '' 136 | assert conf.get('main', 'instance_type'), 'm3.medium' 137 | assert conf.get('main', 'security_group_ids') == '[]' 138 | assert conf.get('main', 'host_tag') == 'docker-build-ami' 139 | assert conf.get('main', 'host_tags') == '[]' 140 | assert conf.get('main', 'image_tags') == '[]' 141 | assert conf.get('main', 'aws_access_key_id') == '' 142 | assert conf.get('main', 'aws_secret_access_key') == '' 143 | assert conf.get('main', 'tmp_dir') == '/tmp' 144 | 145 | 146 | def test_reads_example_config_files(config_fixture, 147 | example_config_fixture_path): 148 | config_fixture.read(example_config_fixture_path) 149 | conf = config_fixture 150 | assert conf.get('main', 'image_name') == 'ubuntu-test' 151 | assert conf.get('main', 'image_id') == 'ami-0df67e2624dedbae1' 152 | assert conf.get('main', 'image_user') == 'ubuntu' 153 | assert conf.get('main', 'region') == 'eu-east-1' 154 | assert conf.get('main', 'subnet_id') == 'subnet-123abc45' 155 | assert conf.get('main', 'instance_type'), 'm5.medium' 156 | assert conf.get('main', 'security_group_ids') == '["sg-1234", "sg-23456"]' 157 | assert conf.get('main', 'host_tag') == 'docker-build-ami-host-tag' 158 | assert conf.get('main', 'host_tags') == '[{"Key": "foo", "Value": "bar"}]' 159 | assert conf.get('main', 'image_tags') == '[{"Key": "foo", "Value": "baz"}]' 160 | assert conf.get('main', 'aws_access_key_id') == 'DFSDF3HGDF4SDSD1DDFF' 161 | assert conf.get('main', 'aws_secret_access_key') == \ 162 | '3riljdsf5SDFSDvsdfds452sdSDFDfsdf44SDFdRA' 163 | assert conf.get('main', 'tmp_dir') == '/usr/tmp' 164 | 165 | 166 | @mock.patch('docker2ami.docker2ami.logging.root') 167 | def test_setup_non_debug_logger(root_logger): 168 | docker2ami.setup_logger(False) 169 | assert root_logger.setLevel.called_with(logging.WARN) 170 | 171 | 172 | @mock.patch('docker2ami.docker2ami.logging.root') 173 | def test_setup_debug_logger(root_logger): 174 | docker2ami.setup_logger(True) 175 | assert root_logger.setLevel.called_with(logging.DEBUG) 176 | 177 | 178 | @mock.patch('docker2ami.docker2ami.logging.root') 179 | @mock.patch('docker2ami.docker2ami.os.path.isfile') 180 | def test_get_config_path_valid_non_user_file(isfile, logger): 181 | isfile.return_value = True 182 | assert docker2ami.get_config_path('foo.conf') == 'foo.conf' 183 | assert isfile.called_with('foo.conf') 184 | assert not logger.error.called 185 | 186 | 187 | @mock.patch('docker2ami.docker2ami.logging.root') 188 | @mock.patch('docker2ami.docker2ami.os.path.isfile') 189 | def test_get_config_path_valid_user_file(isfile, logger): 190 | isfile.return_value = True 191 | assert docker2ami.get_config_path('~/foo.conf') == \ 192 | os.path.expanduser('~/foo.conf') 193 | assert isfile.called_with(os.path.expanduser('foo.conf')) 194 | assert not logger.error.called 195 | 196 | 197 | @mock.patch('docker2ami.docker2ami.logging.root') 198 | @mock.patch('docker2ami.docker2ami.os.path.isfile') 199 | def test_get_config_path_invalid_non_user_file(isfile, logger): 200 | isfile.return_value = False 201 | with pytest.raises(SystemExit): 202 | docker2ami.get_config_path('foo.conf') 203 | assert isfile.called_with('foo.conf') 204 | assert logger.error.called 205 | 206 | 207 | @mock.patch('docker2ami.docker2ami.logging.root') 208 | @mock.patch('docker2ami.docker2ami.os.path.isfile') 209 | def test_get_config_path_invalid_user_file(isfile, logger): 210 | isfile.return_value = False 211 | with pytest.raises(SystemExit): 212 | docker2ami.get_config_path('~/foo.conf') 213 | assert isfile.called_with(os.path.expanduser('foo.conf')) 214 | assert logger.error.called 215 | 216 | 217 | @mock.patch('docker2ami.docker2ami.logging.root') 218 | @mock.patch('docker2ami.docker2ami.os.path.isfile') 219 | def test_get_config_path_default_user_file(isfile, logger): 220 | isfile.side_effect = (True, False) 221 | assert docker2ami.get_config_path(None) == \ 222 | os.path.expanduser('~/.docker-build-ami.conf') 223 | assert isfile.called_with(os.path.expanduser('~/.docker-build-ami.conf')) 224 | assert not logger.error.called 225 | 226 | 227 | @mock.patch('docker2ami.docker2ami.logging.root') 228 | @mock.patch('docker2ami.docker2ami.os.path.isfile') 229 | def test_get_config_path_default_file(isfile, logger): 230 | isfile.side_effect = (False, True) 231 | assert docker2ami.get_config_path(None) == \ 232 | os.path.expanduser('/etc/docker-build-ami.conf') 233 | isfile_calls = [mock.call('~/.docker-build-ami.conf'), 234 | mock.call('/etc/docker-build-ami.conf')] 235 | assert isfile.has_calls(isfile_calls) 236 | assert not logger.error.called 237 | 238 | 239 | @mock.patch('docker2ami.docker2ami.logging.root') 240 | @mock.patch('docker2ami.docker2ami.os.path.isfile') 241 | def test_get_config_path_no_default_files(isfile, logger): 242 | isfile.return_value = False 243 | assert docker2ami.get_config_path(None) is None 244 | isfile_calls = [mock.call('~/.docker-build-ami.conf'), 245 | mock.call('/etc/docker-build-ami.conf')] 246 | assert isfile.has_calls(isfile_calls) 247 | assert not logger.error.called 248 | 249 | 250 | class TestMain(object): 251 | def setup(self): 252 | fixture_dir = os.path.join(os.path.dirname(__file__), 'fixtures') 253 | self.start_dir = os.getcwd() 254 | os.chdir(os.path.join(fixture_dir, 'coco-dev')) 255 | 256 | def teardown(self): 257 | os.chdir(self.start_dir) 258 | 259 | @mock.patch('docker2ami.docker2ami.parse_dockerfile_with_delegate') 260 | @mock.patch('docker2ami.docker2ami.SimpleStateParserDelegate') 261 | @mock.patch('docker2ami.docker2ami.Docker2AmiParserDelegate') 262 | @mock.patch('docker2ami.docker2ami.ParserState') 263 | @mock.patch('docker2ami.docker2ami.AmiBuilder') 264 | @mock.patch('builtins.open') 265 | @mock.patch('docker2ami.docker2ami.AwsConfig') 266 | @mock.patch('docker2ami.docker2ami.create_config_parser') 267 | @mock.patch('docker2ami.docker2ami.get_config_path') 268 | @mock.patch('docker2ami.docker2ami.setup_logger') 269 | @mock.patch('docker2ami.docker2ami.create_arg_parser') 270 | def test_main_with_args(self, create_arg_parser, setup_logger, 271 | get_config_path, create_config_parser, aws_config, 272 | open_mock, ami_builder, parser_state, 273 | docker2ami_parser_delegate, 274 | simple_state_parser_delegate, 275 | parse_dockerfile_with_delegate): 276 | get_config_path.return_value = 'config_file.conf' 277 | docker2ami.main_with_args(['-c', 'docker-build-ami.conf']) 278 | assert create_arg_parser.called_with(['-c', 'docker-build-ami.conf']) 279 | assert setup_logger.called_with(False) 280 | assert get_config_path.called_with('docker-build-ami.conf') 281 | assert create_config_parser.called_with('config_file.conf') 282 | assert aws_config.called_with( 283 | create_config_parser.return_value, 'main', 284 | create_arg_parser.return_value) 285 | assert open_mock.called_with('Dockerfile', 'r') 286 | assert open_mock.return_value.__enter__.called 287 | assert ami_builder.called_with(aws_config.return_value) 288 | assert parser_state.called 289 | assert docker2ami_parser_delegate.called_with( 290 | ami_builder.return_value, parser_state.return_value) 291 | assert simple_state_parser_delegate(ami_builder.return_value, 292 | parser_state.return_value) 293 | assert ami_builder.return_value.__enter__.return_value \ 294 | .send_archive.called 295 | assert parse_dockerfile_with_delegate.called_with( 296 | open_mock.return_value.__enter__.return_value, 297 | docker2ami_parser_delegate.return_value) 298 | assert ami_builder.return_value.__enter__.return_value.save_ami.called 299 | assert ami_builder.return_value.__exit__ 300 | assert open_mock.return_value.__exit__ 301 | 302 | @mock.patch('docker2ami.docker2ami.main_with_args') 303 | @mock.patch('docker2ami.docker2ami.sys') 304 | def test_main(self, sys, main_with_args): 305 | sys.argv = ['test_main', '-c', 'docker-build-ami.conf'] 306 | docker2ami.main() 307 | assert main_with_args.called_with(['-c', 'docker-build-ami.conf']) 308 | -------------------------------------------------------------------------------- /tests/docker2ami/test_ami_builder.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import os 3 | import pytest 4 | import socket 5 | from unittest.mock import call, MagicMock, patch 6 | 7 | import docker2ami.ami_builder as ami_builder 8 | from docker2ami.ami_builder import Color 9 | 10 | 11 | class EmptyObj(object): 12 | """ So we can dynamically add attributes """ 13 | pass 14 | 15 | 16 | def test_color(): 17 | assert Color.RED == '\033[31m' 18 | assert Color.YELLOW == '\033[33m' 19 | assert Color.DARK_GREY == '\033[90m' 20 | assert Color.CLEAR == '\033[0m' 21 | 22 | 23 | @patch('docker2ami.ami_builder.socket.socket') 24 | def test_check_port(socket_socket): 25 | s = socket_socket.return_value 26 | assert True is ami_builder._check_port('10.0.0.1', 123) 27 | assert socket_socket.called_with(socket.AF_INET, socket.SOCK_STREAM) 28 | assert s.connect.called_with(('10.0.0.1', 123)) 29 | assert s.shutdown.called_with(socket.SHUT_RDWR) 30 | assert s.close.called 31 | 32 | 33 | @patch('docker2ami.ami_builder.socket.socket') 34 | def test_check_port_error(socket_socket): 35 | def raise_exception(self): 36 | raise socket.error() 37 | s = socket_socket.return_value 38 | s.connect.side_effect = raise_exception 39 | assert False is ami_builder._check_port('10.0.0.1', 123) 40 | 41 | 42 | @pytest.fixture(scope='function') 43 | def config_test_fixtures(request): 44 | # Get configuration 45 | request.cls.config = configparser.ConfigParser() 46 | 47 | # Set defaults 48 | request.cls.section_name = 'section' 49 | request.cls.defaults = { 50 | 'host_tag': 'docker-build-ami', 51 | 'tmp_dir': '/tmp', 52 | 'image_name': 'docker-build-ami-image', 53 | 'region': 'us-west-1', 54 | 'instance_type': 'm3.medium', 55 | 'subnet_id': 's12345', 56 | 'image_id': 'ami-e4ff5c93', 57 | 'image_user': 'centos', 58 | 'aws_access_key_id': 'myid', 59 | 'aws_secret_access_key': 'mypassword', 60 | 'security_group_ids': '["s12345"]', 61 | 'host_tags': '[{"Key": "Name", "Value": "myname"}]', 62 | 'image_tags': '[{"Key": "Name", "Value": "myimage"}]', 63 | } 64 | request.cls.config.add_section(request.cls.section_name) 65 | for key, val in request.cls.defaults.items(): 66 | request.cls.config.set(request.cls.section_name, key, val) 67 | 68 | # Set overrides 69 | request.cls.overrides = { 70 | 'host_tag': 'docker-build-ami2', 71 | 'tmp_dir': '/tmp2', 72 | 'image_name': 'docker-build-ami-image2', 73 | 'region': 'us-west-12', 74 | 'instance_type': 'm3.medium2', 75 | 'subnet_id': 's123452', 76 | 'image_id': 'ami-e4ff5c932', 77 | 'image_user': 'centos2', 78 | 'aws_access_key_id': 'myid2', 79 | 'aws_secret_access_key': 'mypassword2', 80 | 'security_group_ids': '[s123452]', 81 | 'host_tags': '[{"Key": "Name2", "Value": "myname2"}]', 82 | 'image_tags': '[{"Key": "Name2", "Value": "myimage2"}]', 83 | } 84 | request.cls.overrideobj = EmptyObj() 85 | for key, val in request.cls.overrides.items(): 86 | setattr(request.cls.overrideobj, key, val) 87 | 88 | # Set overrides with None 89 | request.cls.override_with_nones_obj = EmptyObj() 90 | for key, val in request.cls.overrides.items(): 91 | setattr(request.cls.override_with_nones_obj, key, None) 92 | 93 | 94 | @pytest.mark.usefixtures('config_test_fixtures') 95 | class TestAwsConfig(object): 96 | def test_initializes_from_config(self): 97 | target = ami_builder.AwsConfig(self.config, 'section', object()) 98 | for key, val in self.defaults.items(): 99 | assert getattr(target, key) == val 100 | 101 | def test_initializes_from_overrides(self): 102 | target = ami_builder.AwsConfig( 103 | self.config, 'section', self.overrideobj) 104 | for key, val in self.overrides.items(): 105 | assert getattr(target, key) == val 106 | 107 | def test_initializes_from_override_with_nones(self): 108 | target = ami_builder.AwsConfig( 109 | self.config, 'section', self.override_with_nones_obj) 110 | for key, val in self.overrides.items(): 111 | assert getattr(target, key) == self.config.get('section', key) 112 | 113 | 114 | @pytest.fixture(scope='function') 115 | def config_fixtures(request): 116 | overrides = { 117 | 'host_tag': 'docker-build-ami2', 118 | 'tmp_dir': '/tmp', 119 | 'image_name': 'docker-build-ami-image2', 120 | 'region': 'us-west-12', 121 | 'instance_type': 'm3.medium2', 122 | 'subnet_id': 's123452', 123 | 'image_id': 'ami-e4ff5c932', 124 | 'image_user': 'centos2', 125 | 'aws_access_key_id': 'myid2', 126 | 'aws_secret_access_key': 'mypassword2', 127 | 'security_group_ids': '["s123452"]', 128 | 'host_tags': '[{"Key": "Name2", "Value": "myname2"}]', 129 | 'image_tags': '[{"Key": "Name2", "Value": "myimage2"}]', 130 | } 131 | overrideobj = EmptyObj() 132 | for key, val in overrides.items(): 133 | setattr(overrideobj, key, val) 134 | 135 | # Get configuration 136 | config = configparser.ConfigParser() 137 | request.cls.config = ami_builder.AwsConfig(config, 'main', overrideobj) 138 | 139 | 140 | @pytest.mark.usefixtures('config_fixtures') 141 | class TestAmiBuilder(object): 142 | def setup(self): 143 | self._target = ami_builder.AmiBuilder(self.config) 144 | self._start_dir = os.getcwd() 145 | 146 | def teardown(self): 147 | self._target.finish() 148 | os.chdir(self._start_dir) 149 | 150 | def test_finish_before_start_does_not_raise(self): 151 | self._target.finish() 152 | 153 | @patch('time.sleep') 154 | @patch('builtins.print') 155 | @patch('docker2ami.ami_builder.paramiko') 156 | @patch('docker2ami.ami_builder._check_port') 157 | @patch('docker2ami.ami_builder.boto3') 158 | def test_start(self, boto3, check_port, paramiko, print, sleep): 159 | ec2 = boto3.client.return_value = MagicMock() 160 | ec2_resource = boto3.resource.return_value = MagicMock() 161 | ec2.create_key_pair.return_value = {'KeyMaterial': 'abcdefg'} 162 | ec2.run_instances.return_value = { 163 | 'ReservationId': '12345', 164 | } 165 | ec2.describe_instances.return_value = { 166 | 'Reservations': [ 167 | {'ReservationId': 'ABCDEFG'}, 168 | {'ReservationId': '12345', 169 | 'Instances': [{ 170 | 'InstanceId': 'i12345', 171 | 'PrivateIpAddress': '10.0.0.1', 172 | }], }, 173 | {'ReservationId': '678910'}, 174 | ] 175 | } 176 | instance_obj = ec2_resource.Instance.return_value = MagicMock() 177 | instance_obj.private_ip_address = '10.0.0.1' 178 | ii = -1 179 | 180 | def reload_side_effect(): 181 | nonlocal ii 182 | ii = ii + 1 183 | instance_obj.state = {'Name': ['starting', 'running'][ii]} 184 | 185 | instance_obj.reload.side_effect = reload_side_effect 186 | check_port.side_effect = [False, True] 187 | self._target.start() 188 | 189 | assert boto3.client.called_with( 190 | 'ec2', region_name=self._target._config.region, 191 | aws_access_key_id=self._target._config.aws_access_key_id, 192 | aws_secret_access_key=self._target._config.aws_secret_access_key) 193 | 194 | tags = [ 195 | {"Key": "Name2", "Value": "myname2"}, 196 | {"Key": "Name", "Value": self._target._config.host_tag}, 197 | ] 198 | assert ec2.run_instances.called_with( 199 | ImageId=self._target._config.image_id, 200 | KeyName=self._target._key_name, 201 | InstanceType=self._target._config.instance_type, 202 | SubnetId=self._target._config.subnet_id, 203 | MinCount=1, 204 | MaxCount=1, 205 | TagSpecifications=[ 206 | {'ResourceType': 'instance', 'Tags': tags}, 207 | {'ResourceType': 'volume', 'Tags': tags}, 208 | ], 209 | SecurityGroupIds=['s123452'], 210 | ) 211 | 212 | assert paramiko.RSAKey.from_private_key_file.called_with( 213 | self._target._key_path) 214 | assert self._target._ssh == paramiko.SSHClient.return_value 215 | assert self._target._ssh.set_missing_host_key_policy.called_with( 216 | paramiko.AutoAddPolicy.return_value) 217 | assert self._target._ssh.connect.called_with( 218 | hostname='10.0.0.1', 219 | username='centos2', 220 | pkey=paramiko.RSAKey.from_private_key_file.return_value 221 | ) 222 | 223 | assert print.has_calls([ 224 | call('Instance: i12345'), 225 | call('Instance IP: 10.0.0.1'), 226 | call(f'Connection SSH key: {self._target._key_path}'), 227 | ]) 228 | 229 | assert self._target._instance_obj == instance_obj 230 | assert self._target._ssh == paramiko.SSHClient.return_value 231 | 232 | @patch('docker2ami.ami_builder.boto3') 233 | def test_start_throws_when_cant_find_ec2(self, boto3): 234 | ec2 = boto3.client.return_value = MagicMock() 235 | ec2.create_key_pair.return_value = {'KeyMaterial': 'abcdefg'} 236 | with pytest.raises(RuntimeError): 237 | self._target.start() 238 | 239 | def test_finish_terminates(self): 240 | instance_obj = self._target._instance_obj = MagicMock() 241 | self._target.finish() 242 | assert instance_obj.terminate.called 243 | assert self._target._instance_obj is None 244 | 245 | @patch('time.sleep') 246 | @patch('docker2ami.ami_builder.paramiko') 247 | @patch('docker2ami.ami_builder._check_port') 248 | @patch('docker2ami.ami_builder.boto3') 249 | def test_with(self, boto3, check_port, paramiko, sleep): 250 | ec2 = boto3.client.return_value = MagicMock() 251 | ec2_resource = boto3.resource.return_value = MagicMock() 252 | ec2.create_key_pair.return_value = {'KeyMaterial': 'abcdefg'} 253 | ec2.run_instances.return_value = { 254 | 'ReservationId': '12345', 255 | } 256 | ec2.describe_instances.return_value = { 257 | 'Reservations': [ 258 | {'ReservationId': '12345', 259 | 'Instances': [{ 260 | 'InstanceId': 'i12345', 261 | 'PrivateIpAddress': '10.0.0.1', 262 | }], }, 263 | ] 264 | } 265 | instance_obj = ec2_resource.Instance.return_value = MagicMock() 266 | instance_obj.private_ip_address = '10.0.0.1' 267 | 268 | def reload_side_effect(): 269 | instance_obj.state = {'Name': 'running'} 270 | 271 | instance_obj.reload.side_effect = reload_side_effect 272 | check_port.return_value = True 273 | with self._target: 274 | pass 275 | assert instance_obj.terminate.called 276 | assert self._target._instance_obj is None 277 | 278 | @patch('docker2ami.ami_builder.tarfile') 279 | def test_send_archive(self, tarfile): 280 | archive_dir = os.path.join( 281 | os.path.dirname(__file__), 'fixtures/archive') 282 | os.chdir(archive_dir) 283 | ssh = self._target._ssh = MagicMock() 284 | run_cmd = self._target.run_cmd = MagicMock() 285 | self._target.send_archive() 286 | tar = tarfile.open.return_value.__enter__.return_value 287 | assert tar.add.has_calls( 288 | (call('hello.c'), call('images')), 289 | any_order=True, 290 | ) 291 | assert tarfile.open.return_value.__exit__.called 292 | assert ssh.open_sftp.called 293 | sftp = ssh.open_sftp.return_value 294 | assert sftp.put.called_with( 295 | '/tmp/docker-build-ami.tar.gz', '/tmp/docker-build-ami.tar.gz') 296 | assert sftp.close.called 297 | assert run_cmd.called_with( 298 | '', 299 | 'mkdir /tmp/docker-build-ami; ' 300 | 'tar -xzf /tmp/docker-build-ami.tar.gz' 301 | ' -C /tmp/docker-build-ami') 302 | 303 | @patch('builtins.print') 304 | def test_run_cmd(self, print): 305 | ssh = self._target._ssh = MagicMock() 306 | stdin, stdout, stderr = (MagicMock(), MagicMock(), MagicMock()) 307 | stdout.read.return_value = b'Hello world' 308 | stderr.read.return_value = b'Error: something bad happened' 309 | self._target._ssh.exec_command.return_value = (stdin, stdout, stderr) 310 | stdout.channel.recv_exit_status.return_value = 0 311 | self._target.run_cmd( 312 | 'FOO=BAR; BAR=BAZ;', 313 | 'echo "hello world" && echo goodbye') 314 | assert ssh.exec_command.called_with( 315 | 'FOO=BAR; BAR=BAZ; set -ex; echo \'echo "hello world" ' 316 | '&& echo goodbye\' | sudo -i --', 317 | get_pty=True) 318 | print.has_calls( 319 | f'{Color.YELLOW}HelloWorld{Color.CLEAR}', 320 | f'{Color.RED}Error: something bad happened{Color.CLEAR}', 321 | ) 322 | 323 | @patch('builtins.print') 324 | def test_run_cmd2(self, print): 325 | ssh = self._target._ssh = MagicMock() 326 | stdin, stdout, stderr = (MagicMock(), MagicMock(), MagicMock()) 327 | stdout.read.return_value = b'Hello world' 328 | stderr.read.return_value = b'Error: something bad happened' 329 | self._target._ssh.exec_command.return_value = (stdin, stdout, stderr) 330 | stdout.channel.recv_exit_status.return_value = 0 331 | self._target.run_cmd( 332 | 'FOO=BAR; BAR=BAZ;', 333 | 'echo "hello world" && echo goodbye') 334 | assert ssh.exec_command.called_with( 335 | 'FOO=BAR; BAR=BAZ; set -ex; echo \'echo "hello world" ' 336 | '&& echo goodbye\' | sudo -i --', 337 | get_pty=True) 338 | print.has_calls( 339 | f'{Color.YELLOW}HelloWorld{Color.CLEAR}', 340 | f'{Color.RED}Error: something bad happened{Color.CLEAR}', 341 | ) 342 | 343 | @patch('builtins.exit') 344 | @patch('builtins.print') 345 | def test_run_cmd_error(self, print, exit): 346 | ssh = self._target._ssh = MagicMock() 347 | stdin, stdout, stderr = (MagicMock(), MagicMock(), MagicMock()) 348 | stdout.read.return_value = b'Hello world' 349 | stderr.read.return_value = b'Error: something bad happened' 350 | self._target._ssh.exec_command.return_value = (stdin, stdout, stderr) 351 | stdout.channel.recv_exit_status.return_value = 123 352 | self._target.run_cmd( 353 | 'FOO=BAR; BAR=BAZ;', 354 | 'echo "hello world" && echo goodbye') 355 | assert ssh.exec_command.called_with( 356 | 'FOO=BAR; BAR=BAZ; set -ex; echo \'echo "hello world" ' 357 | '&& echo goodbye\' | sudo -i --', 358 | get_pty=True) 359 | assert print.has_calls( 360 | f'{Color.YELLOW}HelloWorld{Color.CLEAR}', 361 | f'{Color.RED}Error: something bad happened{Color.CLEAR}', 362 | ) 363 | assert exit.called_with(123) 364 | 365 | @patch('builtins.print') 366 | @patch('docker2ami.ami_builder.datetime') 367 | @patch('time.sleep') 368 | def test_save_ami(self, sleep, datetime, print): 369 | instance_obj = self._target._instance_obj = MagicMock() 370 | instance_obj.instance_id = 'i12345' 371 | image_obj = instance_obj.create_image.return_value 372 | image_obj.image_id = 'i54321' 373 | ec2 = self._target._ec2 = MagicMock() 374 | datetime.datetime.now.return_value.strftime.return_value =\ 375 | '20191112085423' 376 | 377 | ii = -1 378 | 379 | def image_reload_sideeffect(): 380 | nonlocal ii 381 | ii = ii + 1 382 | image_obj.state = ['pending', 'ready'] 383 | 384 | self._target.save_ami() 385 | 386 | assert print.has_calls(( 387 | call('\nCreate AMI from instance: i12345'), 388 | call('\nCreated image: i54321'))) 389 | assert instance_obj.create_image.called_with( 390 | Name='docker-build-ami-image2-20191112085423') 391 | assert ec2.create_tags.called_with( 392 | Resources=['i54321'], 393 | Tags=self._target._image_tags + [ 394 | {'Key': 'Name', 'Value': self.config.image_name} 395 | ] 396 | ) 397 | -------------------------------------------------------------------------------- /tests/docker2ami/test_parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | import re 4 | from unittest import mock 5 | 6 | from docker2ami import parser 7 | from docker2ami.ami_builder import Color 8 | 9 | 10 | def test_bash_arg_regex_str(): 11 | target = re.compile(parser.BASH_ARG_REGEX_STR) 12 | assert ('FoO12x',) == target.match('FoO12x').groups() 13 | assert None is target.match(' FoO12x') 14 | assert ('FoO12x',) == target.match('FoO12x ').groups() 15 | assert ('foo=bar',) == target.match('foo=bar').groups() 16 | assert ('"foo"',) == target.match('"foo"').groups() 17 | assert ('"f \'o\'o"',) == target.match('"f \'o\'o"').groups() 18 | assert ("'f \"o\"o'",) == target.match("'f \"o\"o'").groups() 19 | assert ("`'f \"o\"o'`",) == target.match("`'f \"o\"o'`").groups() 20 | assert (('http://www.me.com?foo=bar',) 21 | == target.match("http://www.me.com?foo=bar").groups()) 22 | assert (('`http://www.me.com?foo=bar`',) 23 | == target.match("`http://www.me.com?foo=bar`").groups()) 24 | 25 | 26 | def test_bash_lvalue_regex_str(): 27 | target = re.compile(parser.BASH_LVALUE_REGEX_STR) 28 | assert ('FoO12x',) == target.match('FoO12x').groups() 29 | assert None is target.match(' FoO12x') 30 | assert ('FoO12x',) == target.match('FoO12x ').groups() 31 | assert ('foo',) == target.match('foo=bar').groups() 32 | assert ('"foo"',) == target.match('"foo"').groups() 33 | assert ('"f \'o\'o"',) == target.match('"f \'o\'o"').groups() 34 | assert ("'f \"o\"o'",) == target.match("'f \"o\"o'").groups() 35 | assert ("`'f \"o\"o'`",) == target.match("`'f \"o\"o'`").groups() 36 | assert (('`http://www.me.com?foo=bar`',) 37 | == target.match("`http://www.me.com?foo=bar`").groups()) 38 | assert (('http://www.me.com?foo',) == 39 | target.match("http://www.me.com?foo=bar").groups()) 40 | 41 | 42 | def test_assignment_regex_str(): 43 | target = re.compile(parser.ASSIGNMENT_REGEX_STR) 44 | assert ('foo', 'bar') == target.match('foo=bar').groups() 45 | assert ('foo', '"b A r"') == target.match('foo="b A r"').groups() 46 | assert ('foo', '"b A r"') == target.match('foo "b A r"').groups() 47 | assert None is not target.match('foo equals bar') 48 | 49 | 50 | def test_url_regex(): 51 | target = parser.URL_REGEX 52 | assert None is not target.match('http://www.ginkgobiowors.com/foo?x=y') 53 | assert None is not target.match('fTp://www.ginkgobiowors.com/foo?x=y') 54 | assert (None is not 55 | target.match('twitter://jamie@www.ginkgobiowors.com/foo?x=y')) 56 | assert (None is not target.match( 57 | 'twitter://jamie:password@www.ginkgobiowors.com/foo?x=y')) 58 | assert None is target.match('http/foo.bar.baz') 59 | 60 | 61 | def test_aws_skip_regex(): 62 | target = parser.AWS_SKIP_REGEX 63 | assert None is not target.match('# AWS-SKIP') 64 | assert None is not target.match('#AWS-SKIP') 65 | assert None is not target.match('# AWS-SKIP ') 66 | assert None is not target.match(' # AWS-SKIP ') 67 | assert None is not target.match(' # AWS-SKIP \\') 68 | assert None is target.match('AWS-SKIP') 69 | assert None is target.match('#aWS-SKIP') 70 | 71 | 72 | def test_comment_regex(): 73 | target = parser.COMMENT_REGEX 74 | assert None is not target.match('# AWSSKIP') 75 | assert None is not target.match('#AWIP') 76 | assert None is not target.match('# AP ') 77 | assert None is not target.match(' # AIP \\') 78 | 79 | 80 | def test_env_start_regex_str(): 81 | target = re.compile(parser.ENV_START_REGEX_STR, re.IGNORECASE) 82 | assert None is not target.match('ENV foo=bar') 83 | assert None is not target.match('ENV foo=bar bar=baz boo=bobby') 84 | assert None is not target.match(' env foo=bar bar=baz boo=bobby') 85 | assert None is target.match('ENVx foo=bar') 86 | 87 | 88 | def test_env_regex(): 89 | target = parser.ENV_REGEX 90 | assert None is not target.match('ENV foo=bar') 91 | assert None is not target.match('ENV foo=bar bar=baz boo=bobby') 92 | assert None is not target.match(' env foo=bar bar=baz boo=bobby') 93 | assert None is target.match('ENV fo') 94 | 95 | 96 | def test_env_command_assignments_regex(): 97 | target = parser.ENV_COMMAND_ASSIGNMENTS_REGEX 98 | assert (('EnV', 'foo=bar bar=baz boo=bobby ') 99 | == target.match(' EnV foo=bar bar=baz boo=bobby ').groups()) 100 | 101 | 102 | def test_assignment_regex(): 103 | target = parser.ASSIGNMENT_REGEX 104 | assert ('foo', 'bar') == target.match('foo=bar').groups() 105 | assert ('foo', '"b A r"') == target.match('foo="b A r"').groups() 106 | assert ('foo', '"b A r"') == target.match('foo "b A r"').groups() 107 | assert ('foo', '"b A r"') == target.match('foo "b A r"').groups() 108 | assert None is not target.match('foo equals bar') 109 | 110 | 111 | def test_run_start_regex_str(): 112 | target = re.compile(parser.RUN_START_REGEX_STR, re.IGNORECASE) 113 | assert None is not target.match('RUN echo hello') 114 | assert None is not target.match(' run echo hello && goodbye') 115 | assert None is target.match('RUNNING echo hello') 116 | 117 | 118 | def test_run_regex(): 119 | target = parser.RUN_REGEX 120 | assert ('RUN', 'foo=bar',) == target.match('RUN foo=bar').groups() 121 | assert ('RuN', 'foo=bar',) == target.match(' RuN foo=bar ').groups() 122 | assert (('RuN', 'foo = bAr && baz=boop',) 123 | == target.match(' RuN foo = bAr && baz=boop ').groups()) 124 | 125 | 126 | def test_copy_regex(): 127 | target = parser.COPY_REGEX 128 | assert ('foo', 'bar') == target.match('COPY foo bar').groups()[1:] 129 | assert (('foo.bar', '/bar/bar') 130 | == target.match(' CopY foo.bar /bar/bar ').groups()[1:]) 131 | assert None is target.match('copying foo.bar /bar/bar ') 132 | 133 | 134 | def test_run_command_commands_regex(): 135 | target = parser.RUN_COMMAND_COMMANDS_REGEX 136 | assert (('RuN', 'echo hello world ') 137 | == target.match(' RuN echo hello world ').groups()) 138 | 139 | 140 | def test_add_regex(): 141 | target = parser.ADD_REGEX 142 | assert ('foo', 'bar') == target.match('ADD foo bar').groups()[1:] 143 | assert (('foo.bar', '/bar/bar') 144 | == target.match(' aDd foo.bar /bar/bar ').groups()[1:]) 145 | assert (('foo.bar', 'http://www.foo.com/foo?x=y') 146 | == target.match(' aDd foo.bar http://www.foo.com/foo?x=y') 147 | .groups()[1:]) 148 | assert None is target.match('adding foo.bar /bar/bar ') 149 | 150 | 151 | def test_workdir_regex(): 152 | target = parser.WORKDIR_REGEX 153 | assert ('WORKDIR', '/foo/bar') == \ 154 | target.match('WORKDIR /foo/bar').groups() 155 | assert ('Workdir', '/foo/bar') == \ 156 | target.match('Workdir /foo/bar ').groups() 157 | assert None is target.match('CD /bar/bar ') 158 | 159 | 160 | @pytest.fixture(scope='function') 161 | def dockerfile_fixtures(request): 162 | fixture_dir = os.path.join(os.path.dirname(__file__), 'fixtures') 163 | request.cls.example_dockerfile_stream = open( 164 | os.path.join(fixture_dir, 'example_dockerfile')) 165 | request.cls.env_dockerfile_stream = open( 166 | os.path.join(fixture_dir, 'env_dockerfile')) 167 | request.cls.copy_dockerfile_stream = open( 168 | os.path.join(fixture_dir, 'copy_dockerfile')) 169 | request.cls.add_dockerfile_stream = open( 170 | os.path.join(fixture_dir, 'add_dockerfile')) 171 | request.cls.run_dockerfile_stream = open( 172 | os.path.join(fixture_dir, 'run_dockerfile')) 173 | request.cls.workdir_dockerfile_stream = open( 174 | os.path.join(fixture_dir, 'workdir_dockerfile')) 175 | request.cls.misc_dockerfile_stream = open( 176 | os.path.join(fixture_dir, 'misc_dockerfile')) 177 | 178 | yield 179 | request.cls.example_dockerfile_stream.close() 180 | request.cls.env_dockerfile_stream.close() 181 | request.cls.copy_dockerfile_stream.close() 182 | request.cls.add_dockerfile_stream.close() 183 | request.cls.run_dockerfile_stream.close() 184 | request.cls.workdir_dockerfile_stream.close() 185 | request.cls.misc_dockerfile_stream.close() 186 | 187 | 188 | @pytest.mark.usefixtures('dockerfile_fixtures') 189 | class TestParseDockerfileWithDelegate(object): 190 | def test_can_parse(self): 191 | parser.parse_dockerfile_with_delegate( 192 | self.example_dockerfile_stream, parser.AbstractParserDelegate()) 193 | 194 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 195 | def test_parses_env(self, mock_delegate): 196 | parser.parse_dockerfile_with_delegate( 197 | self.env_dockerfile_stream, mock_delegate) 198 | assert mock_delegate.has_calls( 199 | [mock.call.run_env('FOO', 'BAR'), 200 | mock.call.run_env('HOME', '/root'), 201 | mock.call.run_env('TZ', ':America/New_York'), 202 | mock.call.run_env('LANG', 'EN.UTF-8'), 203 | mock.call.run_env('ME_BASE_HOME', '/usr/src/me-base')]) 204 | 205 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 206 | def test_parses_copy(self, mock_delegate): 207 | parser.parse_dockerfile_with_delegate( 208 | self.copy_dockerfile_stream, mock_delegate) 209 | assert mock_delegate.has_calls( 210 | [mock.call.run_copy('foo', 'bar'), 211 | mock.call.run_copy('foo', '.')]) 212 | 213 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 214 | def test_parses_add(self, mock_delegate): 215 | parser.parse_dockerfile_with_delegate( 216 | self.add_dockerfile_stream, mock_delegate) 217 | assert mock_delegate.has_calls( 218 | [mock.call.run_add('foo', 'bar'), 219 | mock.call.run_add('foo', '.'), 220 | mock.call.run_add('http://www.me.com?foo=bar', 'hello.txt')]) 221 | 222 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 223 | def test_parses_workdir(self, mock_delegate): 224 | parser.parse_dockerfile_with_delegate( 225 | self.workdir_dockerfile_stream, mock_delegate) 226 | assert mock_delegate.has_calls( 227 | [mock.call.run_workdir('/home/docker/app')], 228 | [mock.call.run_workdir('/home/docker/app')]) 229 | 230 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 231 | def test_parses_run(self, mock_delegate): 232 | parser.parse_dockerfile_with_delegate( 233 | self.run_dockerfile_stream, mock_delegate) 234 | assert mock_delegate.has_calls( 235 | [mock.call.run_run('apt-get update'), 236 | mock.call.run_run( 237 | 'apt-get upgrade --assume-yes --verbose-versions ' 238 | '--option Dpkg::Options::="--force-confold" && apt-get install ' 239 | '--assume-yes --verbose-versions apt-utils ' 240 | 'binfmt-support build-essential curl ' 241 | 'dnsutils git htop iftop ' 242 | 'iotop iputils-ping libssl-dev lsof ' 243 | 'man mlocate netcat pkg-config ' 244 | 'rsync strace sudo tcpdump ' 245 | 'telnet tzdata vim wget')]) 246 | 247 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 248 | def test_parses_misc(self, mock_delegate): 249 | parser.parse_dockerfile_with_delegate( 250 | self.misc_dockerfile_stream, mock_delegate) 251 | assert mock_delegate.has_calls( 252 | [mock.call.run_nop(), 253 | mock.call.run_nop(), 254 | mock.call.run_unknown('FROM phusion/passenger-full:1.0.6'), 255 | mock.call.run_unknown('LABEL maintainer "Me "'), 256 | mock.call.run_nop(), 257 | mock.call.run_unknown('ARG DEBIAN_FRONTEND=noninteractive'), 258 | mock.call.run_nop(), 259 | mock.call.run_unknown('VOLUME $PIP_OVERRIDE_DIR'), 260 | mock.call.run_unknown('CMD ["/sbin/my_init"]'), 261 | mock.call.run_nop(), 262 | mock.call.run_nop(), 263 | mock.call.run_skip()]) 264 | 265 | 266 | def test_parser_state_initializes_with_right_state(): 267 | target = parser.ParserState() 268 | assert target.step == 0 269 | assert target.skip is False 270 | assert target.env == '' 271 | 272 | 273 | def test_parser_state_updates_state(): 274 | target = parser.ParserState() 275 | target.step = target.step + 1 276 | target.skip = True 277 | target.env = target.env + 'foo = bar;' 278 | assert target.step == 1 279 | assert target.skip is True 280 | assert target.env == 'foo = bar;' 281 | target.step = target.step + 1 282 | target.skip = False 283 | target.env = target.env + 'foo2 = bar2;' 284 | assert target.step == 2 285 | assert target.skip is False 286 | assert target.env == 'foo = bar;foo2 = bar2;' 287 | 288 | 289 | @pytest.fixture(scope='function') 290 | def simple_state_parser_delegate_fixtures(request): 291 | request.cls.parser_delegate = parser.AbstractParserDelegate() 292 | request.cls.parser_state = parser.ParserState() 293 | 294 | 295 | @pytest.mark.usefixtures( 296 | 'simple_state_parser_delegate_fixtures', 'dockerfile_fixtures') 297 | class TestSimpleStateParserDelegate(object): 298 | def setup(self): 299 | self.target = parser.SimpleStateParserDelegate( 300 | self.parser_delegate, self.parser_state) 301 | 302 | def test_can_parse(self): 303 | parser.parse_dockerfile_with_delegate( 304 | self.example_dockerfile_stream, self.target) 305 | 306 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 307 | def test_invokes_delegate_when_skip_is_false(self, mock_parser_delegate): 308 | target = parser.SimpleStateParserDelegate( 309 | mock_parser_delegate, self.parser_state) 310 | target.run_nop() 311 | assert mock_parser_delegate.run_nop.called_with() 312 | target.run_env('key', 'val') 313 | assert mock_parser_delegate.run_env.called_with('key', 'val') 314 | target.run_run('echo hello world') 315 | assert mock_parser_delegate.run_run.called_with('echo hello world') 316 | target.run_copy('src', 'dst') 317 | assert mock_parser_delegate.run_copy.called_with('src', 'dst') 318 | target.run_add('src', 'dst') 319 | assert mock_parser_delegate.run_add.called_with('src', 'dst') 320 | target.run_workdir('/foo/bar') 321 | assert mock_parser_delegate.run_workdir.called_with('/foo/bar') 322 | target.run_unknown('FROM foo:jamie') 323 | assert mock_parser_delegate.run_unknown.called_with('FROM foo:jamie') 324 | assert self.parser_state.skip is False 325 | target.run_skip() 326 | mock_parser_delegate.run_skip_called_with() 327 | 328 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 329 | def test_invokes_delegate_when_skip_is_true(self, mock_parser_delegate): 330 | target = parser.SimpleStateParserDelegate( 331 | mock_parser_delegate, self.parser_state) 332 | self.parser_state.skip = True 333 | target.run_nop() 334 | assert mock_parser_delegate.run_nop.called_with() 335 | target.run_unknown('FROM foo:jamie') 336 | assert mock_parser_delegate.run_unknown.called_with('FROM foo:jamie') 337 | assert self.parser_state.skip 338 | target.run_skip() 339 | assert mock_parser_delegate.run_skip.called_with() 340 | assert self.parser_state.skip 341 | 342 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 343 | def test_does_not_invoke_delegate_when_skip_is_true( 344 | self, mock_parser_delegate): 345 | target = parser.SimpleStateParserDelegate( 346 | mock_parser_delegate, self.parser_state) 347 | 348 | self.parser_state.skip = True 349 | target.run_env('key', 'val') 350 | assert not mock_parser_delegate.run_env.called 351 | assert self.parser_state.skip is False 352 | 353 | self.parser_state.skip = True 354 | target.run_run('echo hello world') 355 | assert not mock_parser_delegate.run_run.called 356 | assert self.parser_state.skip is False 357 | 358 | self.parser_state.skip = True 359 | target.run_copy('src', 'dst') 360 | assert not mock_parser_delegate.run_copy.called 361 | assert self.parser_state.skip is False 362 | 363 | self.parser_state.skip = True 364 | target.run_add('src', 'dst') 365 | assert not mock_parser_delegate.run_add.called 366 | assert self.parser_state.skip is False 367 | 368 | assert self.parser_state.step == 0 369 | 370 | def test_does_not_sets_skip(self): 371 | self.target.run_skip() 372 | assert self.parser_state.skip is True 373 | self.target.run_skip() 374 | assert self.parser_state.skip is True 375 | 376 | def test_increments_step(self): 377 | self.target.run_run('echo hello world') 378 | assert self.parser_state.step == 1 379 | self.target.run_env('key', 'val') 380 | assert self.parser_state.step == 2 381 | self.target.run_copy('src', 'dst') 382 | assert self.parser_state.step == 3 383 | self.target.run_add('src', 'dst') 384 | assert self.parser_state.step == 4 385 | self.target.run_workdir('/foo/bar') 386 | assert self.parser_state.step == 5 387 | 388 | def test_does_not_increment_step(self): 389 | self.target.run_nop() 390 | assert self.parser_state.step == 0 391 | self.target.run_unknown('FROM foo:jamie') 392 | assert self.parser_state.step == 0 393 | self.parser_state.skip = True 394 | self.target.run_run('echo hello world') 395 | assert self.parser_state.step == 0 396 | self.parser_state.skip = True 397 | self.target.run_copy('src', 'dst') 398 | assert self.parser_state.step == 0 399 | self.parser_state.skip = True 400 | self.target.run_add('src', 'dst') 401 | assert self.parser_state.step == 0 402 | self.parser_state.skip = True 403 | self.target.run_skip() 404 | assert self.parser_state.step == 0 405 | 406 | @mock.patch('builtins.print') 407 | def test_run_env_prints(self, print_mock): 408 | self.parser_state.step = 3 409 | self.target.run_env('FOO', 'BAR') 410 | assert print_mock.called_with('Step 4: ENV FOO BAR') 411 | self.parser_state.skip = True 412 | self.target.run_env('FOO', 'BAR') 413 | assert print_mock.called_with(f'{Color.DARK_GREY}Skipping for AWS: ' 414 | f'ENV FOO BAR{Color.CLEAR}') 415 | 416 | @mock.patch('builtins.print') 417 | def test_run_run_prints(self, print_mock): 418 | self.parser_state.step = 3 419 | self.target.run_run('echo hello') 420 | assert print_mock.called_with('Step 4: RUN echo hello') 421 | self.parser_state.skip = True 422 | self.target.run_run('echo hello') 423 | assert print_mock.called_with(f'{Color.DARK_GREY}Skipping for AWS: ' 424 | f'RUN echo hello{Color.CLEAR}') 425 | 426 | @mock.patch('builtins.print') 427 | def test_run_copy_prints(self, print_mock): 428 | self.parser_state.step = 3 429 | self.target.run_copy('src', 'dst') 430 | assert print_mock.called_with('Step 4: COPY src dst') 431 | self.parser_state.skip = True 432 | self.target.run_copy('src', 'dst') 433 | assert print_mock.called_with(f'{Color.DARK_GREY}Skipping for AWS: ' 434 | f'COPY src dst{Color.CLEAR}') 435 | 436 | @mock.patch('builtins.print') 437 | def test_run_add_prints(self, print_mock): 438 | self.parser_state.step = 3 439 | self.target.run_add('src', 'dst') 440 | assert print_mock.called_with('Step 4: ADD src dst') 441 | self.parser_state.skip = True 442 | self.target.run_add('src', 'dst') 443 | assert print_mock.called_with(f'{Color.DARK_GREY}Skipping for AWS: ' 444 | f'ADD src dst{Color.CLEAR}') 445 | 446 | @mock.patch('builtins.print') 447 | def test_run_workdir_prints(self, print_mock): 448 | self.parser_state.step = 3 449 | self.target.run_workdir('foo') 450 | assert print_mock.called_with('Step 4: WORKDIR foo') 451 | self.parser_state.skip = True 452 | self.target.run_workdir('foo') 453 | assert print_mock.called_with(f'{Color.DARK_GREY}Skipping for AWS: ' 454 | f'WORKDIR foo{Color.CLEAR}') 455 | 456 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 457 | def test_updates_step_after_invocation(self, mock_parser_delegate): 458 | target = parser.SimpleStateParserDelegate( 459 | mock_parser_delegate, self.parser_state) 460 | 461 | def make_step_checker(val): 462 | def check_step(*args): 463 | assert self.parser_state.step == val 464 | return check_step 465 | mock_parser_delegate.run_run.side_effect = make_step_checker(1) 466 | target.run_run('echo hello world') 467 | assert mock_parser_delegate.run_run.called 468 | mock_parser_delegate.run_add.side_effect = make_step_checker(2) 469 | target.run_add('src', 'env') 470 | assert mock_parser_delegate.run_add.called 471 | mock_parser_delegate.run_copy.side_effect = make_step_checker(3) 472 | target.run_copy('src', 'env') 473 | assert mock_parser_delegate.run_copy.called 474 | 475 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 476 | def test_run_env(self, mock_parser_delegate): 477 | target = parser.SimpleStateParserDelegate( 478 | mock_parser_delegate, self.parser_state) 479 | 480 | target.run_env('FOO', 'BAR') 481 | assert self.parser_state.env == "FOO=BAR;" 482 | target.run_env('BAR', '`echo hello`') 483 | assert self.parser_state.env == "FOO=BAR;BAR=`echo hello`;" 484 | 485 | @mock.patch('docker2ami.parser.AbstractParserDelegate') 486 | def test_run_workdir(self, mock_parser_delegate): 487 | target = parser.SimpleStateParserDelegate( 488 | mock_parser_delegate, self.parser_state) 489 | 490 | target.run_workdir('/foo/bar') 491 | assert self.parser_state.env == "cd /foo/bar;" 492 | target.run_workdir('bar') 493 | assert self.parser_state.env == "cd /foo/bar;cd bar;" 494 | --------------------------------------------------------------------------------