├── tests ├── __init__.py ├── test_newrelic.py └── test_slack.py ├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ ├── docker.yml │ └── build.yml ├── ecs_deploy ├── __init__.py ├── newrelic.py ├── slack.py ├── cli.py └── ecs.py ├── setup.cfg ├── requirements-test.txt ├── .gitignore ├── Dockerfile ├── tox.ini ├── LICENSE.rst ├── setup.py ├── .dockerignore └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: fabfuel 2 | -------------------------------------------------------------------------------- /ecs_deploy/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = '1.15.2' 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [tool:pytest] 5 | testpaths = tests 6 | flake8-max-line-length = 120 7 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pytest-mock 4 | mock 5 | requests 6 | boto3 7 | freezegun 8 | flake8 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.pyc 3 | *.swp 4 | 5 | dist/ 6 | build/ 7 | env/ 8 | *.egg-info/ 9 | 10 | .tox/ 11 | .coverage 12 | .cache 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine 2 | 3 | ADD . /usr/src/app 4 | WORKDIR /usr/src/app 5 | 6 | RUN ["python", "setup.py", "install"] 7 | 8 | CMD ["ecs"] 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py27, py35, py36, py37, py38, py39, flake8 3 | 4 | [testenv:py27] 5 | basepython = python2.7 6 | 7 | [testenv:py35] 8 | basepython = python3.5 9 | 10 | [testenv:py36] 11 | basepython = python3.6 12 | 13 | [testenv:py37] 14 | basepython = python3.7 15 | 16 | [testenv:py38] 17 | basepython = python3.8 18 | 19 | [testenv:py39] 20 | basepython = python3.9 21 | 22 | [testenv] 23 | commands=py.test --cov ecs_deploy {posargs} 24 | deps= 25 | pytest 26 | pytest-cov 27 | pytest-mock 28 | mock 29 | requests 30 | boto3 31 | freezegun 32 | 33 | [testenv:flake8] 34 | basepython = python2.7 35 | deps = 36 | flake8 37 | commands = 38 | flake8 ecs_deploy tests --max-line-length=120 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: [ '**' ] 6 | 7 | jobs: 8 | build: 9 | uses: ./.github/workflows/build.yml 10 | 11 | release: 12 | needs: 13 | - build 14 | runs-on: ubuntu-latest 15 | permissions: 16 | id-token: write 17 | contents: read 18 | strategy: 19 | fail-fast: false 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: 3.12 27 | 28 | - name: Install tools 29 | run: | 30 | pip install build twine 31 | 32 | - name: Build project 33 | run: python -m build 34 | 35 | - name: Upload to PyPI 36 | env: 37 | TWINE_USERNAME: __token__ 38 | TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} 39 | run: twine upload dist/* 40 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Hub 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'develop' 7 | - 'master' 8 | tags: 9 | - '*.*.*' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | 16 | steps: 17 | - 18 | name: Checkout 19 | uses: actions/checkout@v3 20 | - 21 | name: Login to Docker Hub 22 | uses: docker/login-action@v1 23 | with: 24 | username: fabfuel 25 | password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} 26 | - 27 | name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v1 29 | - 30 | name: Build and push 31 | uses: docker/build-push-action@v2 32 | with: 33 | context: . 34 | file: ./Dockerfile 35 | platforms: linux/amd64,linux/arm64 36 | push: true 37 | tags: fabfuel/ecs-deploy:${{ github.ref_name }} 38 | - 39 | name: "Build and push (tag: latest)" 40 | if: github.ref == 'refs/heads/develop' 41 | uses: docker/build-push-action@v2 42 | with: 43 | context: . 44 | file: ./Dockerfile 45 | platforms: linux/amd64,linux/arm64 46 | push: true 47 | tags: fabfuel/ecs-deploy:latest 48 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ develop ] 9 | pull_request: 10 | branches: [ develop ] 11 | 12 | workflow_call: {} 13 | 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.12", "3.13"] 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install . 33 | pip install -r requirements-test.txt 34 | - name: Lint with flake8 35 | run: | 36 | # stop the build if there are Python syntax errors or undefined names 37 | flake8 ecs_deploy tests --count --select=E9,F63,F7,F82 --show-source --statistics 38 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 39 | flake8 ecs_deploy tests --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 40 | - name: Test with pytest --cov ecs_deploy 41 | run: | 42 | pytest 43 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | ecs-deploy 2 | 3 | Copyright (c) 2016-2019, Fabian Fuelling opensource@fabfuel.de. All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided 6 | that the following conditions are met: 7 | 8 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following 9 | disclaimer. 10 | 11 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided with the distribution. 13 | 14 | Neither the name of Fabian Fuelling nor the names of his contributors may be used to endorse or promote products derived 15 | from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 18 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 20 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 22 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 23 | USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simplify AWS ECS deployments 3 | """ 4 | from setuptools import find_packages, setup 5 | 6 | from ecs_deploy import VERSION 7 | 8 | 9 | def readme(): 10 | with open('README.rst') as f: 11 | return f.read() 12 | 13 | 14 | dependencies = [ 15 | 'click>=7.1.2, <9', 16 | 'click-log==0.3.2', 17 | 'botocore>=1.32.6', 18 | 'boto3>=1.29.6', 19 | 'future', 20 | 'requests<2.30.0', 21 | 'dictdiffer>=0.9.0', 22 | ] 23 | 24 | setup( 25 | name='ecs-deploy', 26 | version=VERSION, 27 | url='https://github.com/fabfuel/ecs-deploy', 28 | download_url='https://github.com/fabfuel/ecs-deploy/archive/%s.tar.gz' % VERSION, 29 | license='BSD-3-Clause', 30 | author='Fabian Fuelling', 31 | author_email='pypi@fabfuel.de', 32 | description='Powerful CLI tool to simplify Amazon ECS deployments, ' 33 | 'rollbacks & scaling', 34 | long_description=readme(), 35 | packages=find_packages(exclude=['tests']), 36 | include_package_data=True, 37 | zip_safe=False, 38 | platforms='any', 39 | install_requires=dependencies, 40 | entry_points={ 41 | 'console_scripts': [ 42 | 'ecs = ecs_deploy.cli:ecs', 43 | ], 44 | }, 45 | classifiers=[ 46 | 'Environment :: Console', 47 | 'Intended Audience :: Developers', 48 | 'License :: OSI Approved :: BSD License', 49 | 'Operating System :: POSIX', 50 | 'Operating System :: MacOS', 51 | 'Operating System :: Unix', 52 | 'Programming Language :: Python', 53 | 'Programming Language :: Python :: 3', 54 | 'Topic :: Software Development :: Libraries :: Python Modules', 55 | ] 56 | ) 57 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | 3 | # Created by .ignore support plugin (hsz.mobi) 4 | ### Python template 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | 111 | -------------------------------------------------------------------------------- /ecs_deploy/newrelic.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class NewRelicException(Exception): 5 | pass 6 | 7 | 8 | class NewRelicDeploymentException(NewRelicException): 9 | pass 10 | 11 | 12 | class Deployment(object): 13 | API_HOST_US = 'api.newrelic.com' 14 | API_HOST_EU = 'api.eu.newrelic.com' 15 | ENDPOINT = 'https://%(host)s/v2/applications/%(app_id)s/deployments.json' 16 | 17 | def __init__(self, api_key, app_id, user, region): 18 | self.__api_key = api_key 19 | self.__app_id = app_id 20 | self.__user = user 21 | self.__region = region.lower() if region else 'us' 22 | 23 | @property 24 | def endpoint(self): 25 | if self.__region == 'eu': 26 | host = self.API_HOST_EU 27 | else: 28 | host = self.API_HOST_US 29 | return self.ENDPOINT % dict(host=host, app_id=self.__app_id) 30 | 31 | @property 32 | def headers(self): 33 | return { 34 | 'Content-Type': 'application/json', 35 | 'X-Api-Key': self.__api_key 36 | } 37 | 38 | def get_payload(self, revision, changelog, description): 39 | return { 40 | "deployment" : { 41 | "revision": str(revision), 42 | "changelog": str(changelog), 43 | "description": str(description), 44 | "user": str(self.__user) 45 | } 46 | } 47 | 48 | def deploy(self, revision, changelog, description): 49 | payload = self.get_payload(revision, changelog, description) 50 | response = requests.post(self.endpoint, headers=self.headers, json=payload) 51 | 52 | if response.status_code != 201: 53 | try: 54 | raise NewRelicDeploymentException('Recording deployment failed: %s' % 55 | response.json()['error']['title']) 56 | except (AttributeError, KeyError): 57 | raise NewRelicDeploymentException('Recording deployment failed') 58 | 59 | return response 60 | -------------------------------------------------------------------------------- /ecs_deploy/slack.py: -------------------------------------------------------------------------------- 1 | import re 2 | import requests 3 | from datetime import datetime 4 | 5 | 6 | class SlackException(Exception): 7 | pass 8 | 9 | 10 | class SlackNotification(object): 11 | def __init__(self, url, service_match): 12 | self.__url = url 13 | self.__service_match_re = re.compile(service_match or '') 14 | self.__timestamp_start = datetime.utcnow() 15 | 16 | def get_payload(self, title, messages, color=None): 17 | fields = [] 18 | for message in messages: 19 | field = { 20 | 'title': message[0], 21 | 'value': message[1], 22 | 'short': True 23 | } 24 | fields.append(field) 25 | 26 | payload = { 27 | "username": "ECS Deploy", 28 | "attachments": [ 29 | { 30 | "pretext": title, 31 | "color": color, 32 | "fields": fields 33 | } 34 | ] 35 | } 36 | 37 | return payload 38 | 39 | def notify_start(self, cluster, tag, task_definition, comment, user, service=None, rule=None): 40 | if not self.__url or not self.__service_match_re.search(service or rule): 41 | return 42 | 43 | messages = [ 44 | ('Cluster', cluster), 45 | ] 46 | 47 | if service: 48 | messages.append(('Service', service)) 49 | 50 | if rule: 51 | messages.append(('Scheduled Task', rule)) 52 | 53 | if tag: 54 | messages.append(('Tag', tag)) 55 | 56 | if user: 57 | messages.append(('User', user)) 58 | 59 | if comment: 60 | messages.append(('Comment', comment)) 61 | 62 | for diff in task_definition.diff: 63 | if tag and diff.field == 'image' and diff.value.endswith(':' + tag): 64 | continue 65 | if diff.field == 'environment': 66 | messages.append(('Environment', '_sensitive (therefore hidden)_')) 67 | continue 68 | 69 | messages.append((diff.field, diff.value)) 70 | 71 | payload = self.get_payload('Deployment has started', messages) 72 | 73 | response = requests.post(self.__url, json=payload) 74 | 75 | if response.status_code != 200: 76 | raise SlackException('Notifying deployment failed') 77 | 78 | return response 79 | 80 | def notify_success(self, cluster, revision, service=None, rule=None): 81 | if not self.__url or not self.__service_match_re.search(service or rule): 82 | return 83 | 84 | duration = datetime.utcnow() - self.__timestamp_start 85 | 86 | messages = [ 87 | ('Cluster', cluster), 88 | ] 89 | 90 | if service: 91 | messages.append(('Service', service)) 92 | if rule: 93 | messages.append(('Scheduled Task', rule)) 94 | 95 | messages.append(('Revision', revision)) 96 | messages.append(('Duration', str(duration))) 97 | 98 | payload = self.get_payload('Deployment finished successfully', messages, 'good') 99 | 100 | response = requests.post(self.__url, json=payload) 101 | 102 | if response.status_code != 200: 103 | raise SlackException('Notifying deployment failed') 104 | 105 | def notify_failure(self, cluster, error, service=None, rule=None): 106 | if not self.__url or not self.__service_match_re.search(service or rule): 107 | return 108 | 109 | duration = datetime.utcnow() - self.__timestamp_start 110 | 111 | messages = [ 112 | ('Cluster', cluster), 113 | ] 114 | 115 | if service: 116 | messages.append(('Service', service)) 117 | if rule: 118 | messages.append(('Scheduled Task', rule)) 119 | 120 | messages.append(('Duration', str(duration))) 121 | messages.append(('Error', error)) 122 | 123 | payload = self.get_payload('Deployment failed', messages, 'danger') 124 | 125 | response = requests.post(self.__url, json=payload) 126 | 127 | if response.status_code != 200: 128 | raise SlackException('Notifying deployment failed') 129 | 130 | return response 131 | -------------------------------------------------------------------------------- /tests/test_newrelic.py: -------------------------------------------------------------------------------- 1 | from pytest import fixture, raises 2 | from mock import patch 3 | 4 | from ecs_deploy.newrelic import Deployment, NewRelicDeploymentException 5 | 6 | 7 | class DeploymentResponseSuccessfulMock(object): 8 | status_code = 201 9 | content = { 10 | "deployment": { 11 | "id": 1234567890, 12 | "revision": "1.2.3", 13 | "changelog": "Lorem Ipsum", 14 | "description": "Lorem ipsum usu amet dicat nullam ea. Nec detracto lucilius democritum in.", 15 | "user": "username", "timestamp": "2016-06-21T09:45:08+00:00", 16 | "links": {"application": 12345} 17 | }, 18 | "links": {"deployment.agent": "/v2/applications/{application_id}"} 19 | } 20 | 21 | 22 | class DeploymentResponseUnsuccessfulMock(object): 23 | status_code = 400 24 | content = {"message": "Something went wrong"} 25 | 26 | 27 | @fixture 28 | def api_key(): 29 | return 'APIKEY' 30 | 31 | 32 | @fixture 33 | def app_id(): 34 | return '12345' 35 | 36 | 37 | @fixture 38 | def user(): 39 | return 'username' 40 | 41 | 42 | @fixture 43 | def region(): 44 | return 'eu' 45 | 46 | 47 | @fixture 48 | def revision(): 49 | return '1.2.3' 50 | 51 | 52 | @fixture 53 | def changelog(): 54 | return 'Lorem Ipsum' 55 | 56 | 57 | @fixture 58 | def description(): 59 | return 'Lorem ipsum usu amet dicat nullam ea. Nec detracto lucilius democritum in.' 60 | 61 | 62 | def test_get_endpoint_us(api_key, app_id, user): 63 | endpoint = 'https://api.newrelic.com/v2/applications/%(app_id)s/deployments.json' % dict(app_id=app_id) 64 | deployment = Deployment(api_key, app_id, user, 'us') 65 | assert deployment.endpoint == endpoint 66 | 67 | 68 | def test_get_endpoint_eu(api_key, app_id, user): 69 | endpoint = 'https://api.eu.newrelic.com/v2/applications/%(app_id)s/deployments.json' % dict(app_id=app_id) 70 | deployment = Deployment(api_key, app_id, user, 'eu') 71 | assert deployment.endpoint == endpoint 72 | 73 | 74 | def test_get_endpoint_unknown_region(api_key, app_id, user): 75 | endpoint = 'https://api.newrelic.com/v2/applications/%(app_id)s/deployments.json' % dict(app_id=app_id) 76 | deployment = Deployment(api_key, app_id, user, 'unknown') 77 | assert deployment.endpoint == endpoint 78 | 79 | 80 | def test_get_endpoint_no_region(api_key, app_id, user): 81 | endpoint = 'https://api.newrelic.com/v2/applications/%(app_id)s/deployments.json' % dict(app_id=app_id) 82 | deployment = Deployment(api_key, app_id, user, None) 83 | assert deployment.endpoint == endpoint 84 | 85 | 86 | def test_get_headers(api_key, app_id, user, region): 87 | headers = { 88 | 'X-Api-Key': api_key, 89 | 'Content-Type': 'application/json', 90 | } 91 | 92 | deployment = Deployment(api_key, app_id, user, region) 93 | assert deployment.headers == headers 94 | 95 | 96 | def test_get_payload(api_key, app_id, user, region, revision, changelog, description): 97 | payload = { 98 | 'deployment': { 99 | 'revision': revision, 100 | 'changelog': changelog, 101 | 'description': description, 102 | 'user': user, 103 | } 104 | } 105 | deployment = Deployment(api_key, app_id, user, region) 106 | assert deployment.get_payload(revision, changelog, description) == payload 107 | 108 | 109 | @patch('requests.post') 110 | def test_deploy_sucessful(post, api_key, app_id, user, region, revision, changelog, description): 111 | post.return_value = DeploymentResponseSuccessfulMock() 112 | 113 | deployment = Deployment(api_key, app_id, user, region) 114 | response = deployment.deploy(revision, changelog, description) 115 | payload = deployment.get_payload(revision, changelog, description) 116 | 117 | post.assert_called_with(deployment.endpoint, headers=deployment.headers, json=payload) 118 | assert response.status_code == 201 119 | 120 | 121 | @patch('requests.post') 122 | def test_deploy_unsucessful(post, api_key, app_id, user, region, revision, changelog, description): 123 | with raises(NewRelicDeploymentException): 124 | post.return_value = DeploymentResponseUnsuccessfulMock() 125 | deployment = Deployment(api_key, app_id, user, region) 126 | deployment.deploy(revision, changelog, description) 127 | -------------------------------------------------------------------------------- /tests/test_slack.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from freezegun import freeze_time 4 | from pytest import fixture, raises 5 | from mock import patch 6 | 7 | from ecs_deploy.ecs import EcsTaskDefinition 8 | from ecs_deploy.slack import SlackNotification, SlackException 9 | from tests.test_ecs import PAYLOAD_TASK_DEFINITION_1 10 | 11 | 12 | class NotifyResponseSuccessfulMock(object): 13 | status_code = 200 14 | 15 | 16 | class NotifyResponseUnsuccessfulMock(object): 17 | status_code = 400 18 | content = {"message": "Something went wrong"} 19 | 20 | 21 | @fixture 22 | def url(): 23 | return 'https://slack.test' 24 | 25 | 26 | @fixture 27 | def service_match(): 28 | return '.*' 29 | 30 | 31 | @fixture 32 | def task_definition(): 33 | return EcsTaskDefinition(**deepcopy(PAYLOAD_TASK_DEFINITION_1)) 34 | 35 | 36 | def test_get_payload_without_messages(url, service_match): 37 | slack = SlackNotification(url, service_match) 38 | 39 | payload = slack.get_payload('Foobar', [], 'good') 40 | 41 | expected = { 42 | 'username': 'ECS Deploy', 43 | 'attachments': [ 44 | { 45 | 'color': 'good', 46 | 'pretext': 'Foobar', 47 | 'fields': [], 48 | } 49 | ], 50 | } 51 | 52 | assert payload == expected 53 | 54 | 55 | def test_get_payload_with_messages(url, service_match): 56 | slack = SlackNotification(url, service_match) 57 | 58 | messages = ( 59 | ('foo', 'bar'), 60 | ('lorem', 'ipsum'), 61 | ) 62 | 63 | payload = slack.get_payload('Foobar', messages, 'good') 64 | 65 | expected = { 66 | 'username': 'ECS Deploy', 67 | 'attachments': [ 68 | { 69 | 'color': 'good', 70 | 'pretext': 'Foobar', 71 | 'fields': [ 72 | {'short': True, 'title': 'foo', 'value': 'bar'}, 73 | {'short': True, 'title': 'lorem', 'value': 'ipsum'} 74 | ], 75 | } 76 | ], 77 | } 78 | 79 | assert payload == expected 80 | 81 | 82 | @patch('requests.post') 83 | def test_notify_start_without_url(post_mock, url, service_match, task_definition): 84 | slack = SlackNotification(None, None) 85 | slack.notify_start('my-cluster', 'my-tag', task_definition, 'my-comment', 'my-user', 'my-service', 'my-rule') 86 | 87 | post_mock.assert_not_called() 88 | 89 | 90 | @patch('requests.post') 91 | def test_notify_start(post_mock, url, service_match, task_definition): 92 | post_mock.return_value = NotifyResponseSuccessfulMock() 93 | 94 | task_definition.set_images(webserver=u'new-image:my-tag', application=u'app-image:another-tag') 95 | task_definition.set_environment((('webserver', 'foo', 'baz'),)) 96 | 97 | slack = SlackNotification(url, service_match) 98 | slack.notify_start('my-cluster', 'my-tag', task_definition, 'my-comment', 'my-user', 'my-service', 'my-rule') 99 | 100 | payload = { 101 | 'username': 'ECS Deploy', 102 | 'attachments': [ 103 | { 104 | 'pretext': 'Deployment has started', 105 | 'color': None, 106 | 'fields': [ 107 | {'title': 'Cluster', 'value': 'my-cluster', 'short': True}, 108 | {'title': 'Service', 'value': 'my-service', 'short': True}, 109 | {'title': 'Scheduled Task', 'value': 'my-rule', 'short': True}, 110 | {'title': 'Tag', 'value': 'my-tag', 'short': True}, 111 | {'title': 'User', 'value': 'my-user', 'short': True}, 112 | {'title': 'Comment', 'value': 'my-comment', 'short': True}, 113 | {'title': 'image', 'value': 'app-image:another-tag', 'short': True}, 114 | {'title': 'Environment', 'value': '_sensitive (therefore hidden)_', 'short': True} 115 | ] 116 | } 117 | ] 118 | } 119 | 120 | post_mock.assert_called_with(url, json=payload) 121 | 122 | 123 | @patch('requests.post') 124 | def test_notify_start_without_tag(post_mock, url, service_match, task_definition): 125 | post_mock.return_value = NotifyResponseSuccessfulMock() 126 | 127 | task_definition.set_images(webserver=u'new-image:my-tag', application=u'app-image:another-tag') 128 | task_definition.set_environment((('webserver', 'foo', 'baz'),)) 129 | 130 | slack = SlackNotification(url, service_match) 131 | slack.notify_start('my-cluster', None, task_definition, 'my-comment', 'my-user', 'my-service', 'my-rule') 132 | 133 | payload = { 134 | 'username': 'ECS Deploy', 135 | 'attachments': [ 136 | { 137 | 'pretext': 'Deployment has started', 138 | 'color': None, 139 | 'fields': [ 140 | {'title': 'Cluster', 'value': 'my-cluster', 'short': True}, 141 | {'title': 'Service', 'value': 'my-service', 'short': True}, 142 | {'title': 'Scheduled Task', 'value': 'my-rule', 'short': True}, 143 | {'title': 'User', 'value': 'my-user', 'short': True}, 144 | {'title': 'Comment', 'value': 'my-comment', 'short': True}, 145 | {'title': 'image', 'value': 'new-image:my-tag', 'short': True}, 146 | {'title': 'image', 'value': 'app-image:another-tag', 'short': True}, 147 | {'title': 'Environment', 'value': '_sensitive (therefore hidden)_', 'short': True} 148 | ] 149 | } 150 | ] 151 | } 152 | 153 | post_mock.assert_called_with(url, json=payload) 154 | 155 | 156 | @patch('requests.post') 157 | @freeze_time() 158 | def test_notify_success(post_mock, url, service_match, task_definition): 159 | post_mock.return_value = NotifyResponseSuccessfulMock() 160 | 161 | slack = SlackNotification(url, service_match) 162 | slack.notify_success('my-cluster', 'my-tag', 'my-service', 'my-rule') 163 | 164 | payload = { 165 | 'username': 'ECS Deploy', 166 | 'attachments': [ 167 | { 168 | 'pretext': 'Deployment finished successfully', 169 | 'color': 'good', 170 | 'fields': [ 171 | {'title': 'Cluster', 'value': 'my-cluster', 'short': True}, 172 | {'title': 'Service', 'value': 'my-service', 'short': True}, 173 | {'title': 'Scheduled Task', 'value': 'my-rule', 'short': True}, 174 | {'title': 'Revision', 'value': 'my-tag', 'short': True}, 175 | {'title': 'Duration', 'value': '0:00:00', 'short': True} 176 | ] 177 | } 178 | ] 179 | } 180 | 181 | post_mock.assert_called_with(url, json=payload) 182 | 183 | 184 | @patch('requests.post') 185 | @freeze_time() 186 | def test_notify_success(post_mock, url, service_match, task_definition): 187 | post_mock.return_value = NotifyResponseSuccessfulMock() 188 | 189 | slack = SlackNotification(url, service_match) 190 | slack.notify_failure('my-cluster', 'my-error', 'my-service', 'my-rule') 191 | 192 | payload = { 193 | 'username': 'ECS Deploy', 194 | 'attachments': [ 195 | { 196 | 'pretext': 'Deployment failed', 197 | 'color': 'danger', 198 | 'fields': [ 199 | {'title': 'Cluster', 'value': 'my-cluster', 'short': True}, 200 | {'title': 'Service', 'value': 'my-service', 'short': True}, 201 | {'title': 'Scheduled Task', 'value': 'my-rule', 'short': True}, 202 | {'title': 'Duration', 'value': '0:00:00', 'short': True}, 203 | {'title': 'Error', 'value': 'my-error', 'short': True}, 204 | ] 205 | } 206 | ] 207 | } 208 | 209 | post_mock.assert_called_with(url, json=payload) 210 | 211 | 212 | @patch('requests.post') 213 | def test_notify_start_without_url(post_mock, url, service_match, task_definition): 214 | slack = SlackNotification(None, None) 215 | slack.notify_start('my-cluster', 'my-tag', task_definition, 'my-comment', 'my-user', 'my-service', 'my-rule') 216 | post_mock.assert_not_called() 217 | 218 | 219 | @patch('requests.post') 220 | def test_notify_success_without_url(post_mock, url, service_match, task_definition): 221 | slack = SlackNotification(None, None) 222 | slack.notify_success('my-cluster', 13, 'my-service', 'my-rule') 223 | post_mock.assert_not_called() 224 | 225 | 226 | @patch('requests.post') 227 | def test_notify_failure_without_url(post_mock, url, service_match, task_definition): 228 | slack = SlackNotification(None, None) 229 | slack.notify_failure('my-cluster', 'my-error', 'my-service', 'my-rule') 230 | post_mock.assert_not_called() 231 | 232 | 233 | 234 | @patch('requests.post') 235 | def test_notify_start_failed(post, url, service_match, task_definition): 236 | with raises(SlackException): 237 | post.return_value = NotifyResponseUnsuccessfulMock() 238 | slack = SlackNotification(url, service_match) 239 | slack.notify_start('my-cluster', 'my-tag', task_definition, 'my-comment', 'my-user', 'my-service', 'my-rule') 240 | 241 | 242 | @patch('requests.post') 243 | def test_notify_success_failed(post, url, service_match, task_definition): 244 | with raises(SlackException): 245 | post.return_value = NotifyResponseUnsuccessfulMock() 246 | slack = SlackNotification(url, service_match) 247 | slack.notify_success('my-cluster', 'my-tag', 'my-service', 'my-rule') 248 | 249 | 250 | @patch('requests.post') 251 | def test_notify_failure_failed(post, url, service_match, task_definition): 252 | with raises(SlackException): 253 | post.return_value = NotifyResponseUnsuccessfulMock() 254 | slack = SlackNotification(url, service_match) 255 | slack.notify_failure('my-cluster', 'my-error', 'my-service', 'my-rule') 256 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ECS Deploy 2 | ---------- 3 | 4 | .. image:: https://badge.fury.io/py/ecs-deploy.svg 5 | :target: https://badge.fury.io/py/ecs-deploy 6 | 7 | .. image:: https://github.com/fabfuel/ecs-deploy/actions/workflows/build.yml/badge.svg 8 | :target: https://github.com/fabfuel/ecs-deploy/actions/workflows/build.yml 9 | 10 | `ecs-deploy` simplifies deployments on Amazon ECS by providing a convenience CLI tool for complex actions, which are executed pretty often. 11 | 12 | Key Features 13 | ------------ 14 | - support for complex task definitions (e.g. multiple containers & task role) 15 | - easily redeploy the current task definition (including `docker pull` of eventually updated images) 16 | - deploy new versions/tags or all containers or just a single container in your task definition 17 | - scale up or down by adjusting the desired count of running tasks 18 | - add or adjust containers environment variables 19 | - run one-off tasks from the CLI 20 | - automatically monitor deployments in New Relic 21 | 22 | TL;DR 23 | ----- 24 | Deploy a new version of your service:: 25 | 26 | $ ecs deploy my-cluster my-service --tag 1.2.3 27 | 28 | Redeploy the current version of a service:: 29 | 30 | $ ecs deploy my-cluster my-service 31 | 32 | Scale up or down a service:: 33 | 34 | $ ecs scale my-cluster my-service 4 35 | 36 | Updating a cron job:: 37 | 38 | $ ecs cron my-cluster my-task my-rule 39 | 40 | Update a task definition (without running or deploying):: 41 | 42 | $ ecs update my-task 43 | 44 | 45 | Installation 46 | ------------ 47 | 48 | The project is available on PyPI. Simply run:: 49 | 50 | $ pip install ecs-deploy 51 | 52 | For [Homebrew](https://brew.sh/) users, you can also install [it](https://formulae.brew.sh/formula/ecs-deploy) via brew:: 53 | 54 | $ brew install ecs-deploy 55 | 56 | Run via Docker 57 | -------------- 58 | Instead of installing **ecs-deploy** locally, which requires a Python environment, you can run **ecs-deploy** via Docker. All versions starting from 1.7.1 are available on Docker Hub: https://cloud.docker.com/repository/docker/fabfuel/ecs-deploy 59 | 60 | Running **ecs-deploy** via Docker is easy as:: 61 | 62 | docker run fabfuel/ecs-deploy:1.10.2 63 | 64 | In this example, the stable version 1.10.2 is executed. Alternatively you can use Docker tags ``master`` or ``latest`` for the latest stable version or Docker tag ``develop`` for the newest development version of **ecs-deploy**. 65 | 66 | Please be aware, that when running **ecs-deploy** via Docker, the configuration - as described below - does not apply. You have to provide credentials and the AWS region via the command as attributes or environment variables:: 67 | 68 | docker run fabfuel/ecs-deploy:1.10.2 ecs deploy my-cluster my-service --region eu-central-1 --access-key-id ABC --secret-access-key ABC 69 | 70 | 71 | Configuration 72 | ------------- 73 | As **ecs-deploy** is based on boto3 (the official AWS Python library), there are several ways to configure and store the 74 | authentication credentials. Please read the boto3 documentation for more details 75 | (http://boto3.readthedocs.org/en/latest/guide/configuration.html#configuration). The simplest way is by running:: 76 | 77 | $ aws configure 78 | 79 | Alternatively you can pass the AWS credentials (via `--access-key-id` and `--secret-access-key`) or the AWS 80 | configuration profile (via `--profile`) as options when you run `ecs`. 81 | 82 | AWS IAM 83 | ------- 84 | 85 | If you are using **ecs-deploy** with a role or user account that does not have full AWS access, such as in a deploy script, you will 86 | need to use or create an IAM policy with the correct set of permissions in order for your deploys to succeed. One option is to use the 87 | pre-specified ``AmazonECS_FullAccess`` (https://docs.aws.amazon.com/AmazonECS/latest/userguide/security-iam-awsmanpol.html#security-iam-awsmanpol-AmazonECS_FullAccess) policy. If you would prefer to create a role with a more minimal set of permissions, 88 | the following are required: 89 | 90 | * ``ecs:ListServices`` 91 | * ``ecs:UpdateService`` 92 | * ``ecs:ListTasks`` 93 | * ``ecs:RegisterTaskDefinition`` 94 | * ``ecs:DescribeServices`` 95 | * ``ecs:DescribeTasks`` 96 | * ``ecs:ListTaskDefinitions`` 97 | * ``ecs:DescribeTaskDefinition`` 98 | * ``ecs:DeregisterTaskDefinition`` 99 | 100 | If using custom IAM permissions, you will also need to set the ``iam:PassRole`` policy for each IAM role. See here https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html for more information. 101 | 102 | Note that not every permission is required for every action you can take in **ecs-deploy**. You may be able to adjust permissions based on your specific needs. 103 | 104 | Actions 105 | ------- 106 | Currently the following actions are supported: 107 | 108 | deploy 109 | ====== 110 | Redeploy a service either without any modifications or with a new image, environment variable, docker label, and/or command definition. 111 | 112 | scale 113 | ===== 114 | Scale a service up or down and change the number of running tasks. 115 | 116 | run 117 | === 118 | Run a one-off task based on an existing task-definition and optionally override command, environment variables and/or docker labels. 119 | 120 | update 121 | ====== 122 | Update a task definition by creating a new revision to set a new image, 123 | environment variable, docker label, and/or command definition, etc. 124 | 125 | cron (scheduled task) 126 | ===================== 127 | Update a task definition and update a events rule (scheduled task) to use the 128 | new task definition. 129 | 130 | 131 | Usage 132 | ----- 133 | 134 | For detailed information about the available actions, arguments and options, run:: 135 | 136 | $ ecs deploy --help 137 | $ ecs scale --help 138 | $ ecs run --help 139 | 140 | Examples 141 | -------- 142 | All examples assume, that authentication has already been configured. 143 | 144 | Deployment 145 | ---------- 146 | 147 | Simple Redeploy 148 | =============== 149 | To redeploy a service without any modifications, but pulling the most recent image versions, run the following command. 150 | This will duplicate the current task definition and cause the service to redeploy all running tasks.:: 151 | 152 | $ ecs deploy my-cluster my-service 153 | 154 | 155 | Deploy a new tag 156 | ================ 157 | To change the tag for **all** images in **all** containers in the task definition, run the following command:: 158 | 159 | $ ecs deploy my-cluster my-service -t 1.2.3 160 | 161 | 162 | Deploy a new image 163 | ================== 164 | To change the image of a specific container, run the following command:: 165 | 166 | $ ecs deploy my-cluster my-service --image webserver nginx:1.11.8 167 | 168 | This will modify the **webserver** container only and change its image to "nginx:1.11.8". 169 | 170 | 171 | Deploy several new images 172 | ========================= 173 | The `-i` or `--image` option can also be passed several times:: 174 | 175 | $ ecs deploy my-cluster my-service -i webserver nginx:1.9 -i application my-app:1.2.3 176 | 177 | This will change the **webserver**'s container image to "nginx:1.9" and the **application**'s image to "my-app:1.2.3". 178 | 179 | Deploy a custom task definition 180 | =============================== 181 | To deploy any task definition (independent of which is currently used in the service), you can use the ``--task`` parameter. The value can be: 182 | 183 | A fully qualified task ARN:: 184 | 185 | $ ecs deploy my-cluster my-service --task arn:aws:ecs:eu-central-1:123456789012:task-definition/my-task:20 186 | 187 | A task family name with revision:: 188 | 189 | $ ecs deploy my-cluster my-service --task my-task:20 190 | 191 | Or just a task family name. It this case, the most recent revision is used:: 192 | 193 | $ ecs deploy my-cluster my-service --task my-task 194 | 195 | .. important:: 196 | ``ecs`` will still create a new task definition, which then is used in the service. 197 | This is done, to retain consistent behaviour and to ensure the ECS agent e.g. pulls all images. 198 | But the newly created task definition will be based on the given task, not the currently used task. 199 | 200 | 201 | Set an environment variable 202 | =========================== 203 | To add a new or adjust an existing environment variable of a specific container, run the following command:: 204 | 205 | $ ecs deploy my-cluster my-service -e webserver SOME_VARIABLE SOME_VALUE 206 | 207 | This will modify the **webserver** container definition and add or overwrite the environment variable `SOME_VARIABLE` with the value "SOME_VALUE". This way you can add new or adjust already existing environment variables. 208 | 209 | 210 | Adjust multiple environment variables 211 | ===================================== 212 | You can add or change multiple environment variables at once, by adding the `-e` (or `--env`) options several times:: 213 | 214 | $ ecs deploy my-cluster my-service -e webserver SOME_VARIABLE SOME_VALUE -e webserver OTHER_VARIABLE OTHER_VALUE -e app APP_VARIABLE APP_VALUE 215 | 216 | This will modify the definition **of two containers**. 217 | The **webserver**'s environment variable `SOME_VARIABLE` will be set to "SOME_VALUE" and the variable `OTHER_VARIABLE` to "OTHER_VALUE". 218 | The **app**'s environment variable `APP_VARIABLE` will be set to "APP_VALUE". 219 | 220 | 221 | Set environment variables exclusively, remove all other pre-existing environment variables 222 | ========================================================================================== 223 | To reset all existing environment variables of a task definition, use the flag ``--exclusive-env`` :: 224 | 225 | $ ecs deploy my-cluster my-service -e webserver SOME_VARIABLE SOME_VALUE --exclusive-env 226 | 227 | This will remove **all other** existing environment variables of **all containers** of the task definition, except for the variable `SOME_VARIABLE` with the value "SOME_VALUE" in the webserver container. 228 | 229 | 230 | Set a secret environment variable from the AWS Parameter Store 231 | ============================================================== 232 | 233 | .. important:: 234 | This option was introduced by AWS in ECS Agent v1.22.0. Make sure your ECS agent version is >= 1.22.0 or else your task will not deploy. 235 | 236 | To add a new or adjust an existing secret of a specific container, run the following command:: 237 | 238 | $ ecs deploy my-cluster my-service -s webserver SOME_SECRET KEY_OF_SECRET_IN_PARAMETER_STORE 239 | 240 | You can also specify the full arn of the parameter:: 241 | 242 | $ ecs deploy my-cluster my-service -s webserver SOME_SECRET arn:aws:ssm:::parameter/KEY_OF_SECRET_IN_PARAMETER_STORE 243 | 244 | This will modify the **webserver** container definition and add or overwrite the environment variable `SOME_SECRET` with the value of the `KEY_OF_SECRET_IN_PARAMETER_STORE` in the AWS Parameter Store of the AWS Systems Manager. 245 | 246 | 247 | Set secrets exclusively, remove all other pre-existing secret environment variables 248 | =================================================================================== 249 | To reset all existing secrets (secret environment variables) of a task definition, use the flag ``--exclusive-secrets`` :: 250 | 251 | $ ecs deploy my-cluster my-service -s webserver NEW_SECRET KEY_OF_SECRET_IN_PARAMETER_STORE --exclusive-secret 252 | 253 | This will remove **all other** existing secret environment variables of **all containers** of the task definition, except for the new secret variable `NEW_SECRET` with the value coming from the AWS Parameter Store with the name "KEY_OF_SECRET_IN_PARAMETER_STORE" in the webserver container. 254 | 255 | 256 | Set environment via .env files 257 | ============================== 258 | Instead of setting environment variables separately, you can pass a .env file per container to set the whole environment at once. You can either point to a local file or a file stored on S3, via:: 259 | 260 | $ ecs deploy my-cluster my-service --env-file my-app env/my-app.env 261 | 262 | $ ecs deploy my-cluster my-service --s3-env-file my-app arn:aws:s3:::my-ecs-environment/my-app.env 263 | 264 | Set secrets via .env files 265 | ============================== 266 | Instead of setting secrets separately, you can pass a .env file per container to set all secrets at once. 267 | 268 | This will expect an env file format, but any values will be set as the `valueFrom` parameter in the secrets config. 269 | This value can be either the path or the full ARN of a secret in the AWS Parameter Store. For example, with a secrets.env 270 | file like the following: 271 | 272 | ``` 273 | SOME_SECRET=arn:aws:ssm:::parameter/KEY_OF_SECRET_IN_PARAMETER_STORE 274 | ``` 275 | 276 | $ ecs deploy my-cluster my-service --secret-env-file webserver env/secrets.env 277 | 278 | This will modify the **webserver** container definition and add or overwrite the environment variable `SOME_SECRET` with the value of the `KEY_OF_SECRET_IN_PARAMETER_STORE` in the AWS Parameter Store of the AWS Systems Manager. 279 | 280 | 281 | Set a docker label 282 | =================== 283 | To add a new or adjust an existing docker labels of a specific container, run the following command:: 284 | 285 | $ ecs deploy my-cluster my-service -d webserver somelabel somevalue 286 | 287 | This will modify the **webserver** container definition and add or overwrite the docker label "somelabel" with the value "somevalue". This way you can add new or adjust already existing docker labels. 288 | 289 | 290 | Adjust multiple docker labels 291 | ============================= 292 | You can add or change multiple docker labels at once, by adding the `-d` (or `--docker-label`) options several times:: 293 | 294 | $ ecs deploy my-cluster my-service -d webserver somelabel somevalue -d webserver otherlabel othervalue -d app applabel appvalue 295 | 296 | This will modify the definition **of two containers**. 297 | The **webserver**'s docker label "somelabel" will be set to "somevalue" and the label "otherlabel" to "othervalue". 298 | The **app**'s docker label "applabel" will be set to "appvalue". 299 | 300 | 301 | Set docker labels exclusively, remove all other pre-existing docker labels 302 | ========================================================================== 303 | To reset all existing docker labels of a task definition, use the flag ``--exclusive-docker-labels`` :: 304 | 305 | $ ecs deploy my-cluster my-service -d webserver somelabel somevalue --exclusive-docker-labels 306 | 307 | This will remove **all other** existing docker labels of **all containers** of the task definition, except for the label "somelabel" with the value "somevalue" in the webserver container. 308 | 309 | 310 | Modify a command 311 | ================ 312 | To change the command of a specific container, run the following command:: 313 | 314 | $ ecs deploy my-cluster my-service --command webserver "nginx" 315 | 316 | This will modify the **webserver** container and change its command to "nginx". If you have 317 | a command that requires arguments as well, then you can simply specify it like this as you would normally do: 318 | 319 | $ ecs deploy my-cluster my-service --command webserver "ngnix -c /etc/ngnix/ngnix.conf" 320 | 321 | This works fine as long as any of the arguments do not contain any spaces. In case arguments to the 322 | command itself contain spaces, then you can use the JSON format: 323 | 324 | $ ecs deploy my-cluster my-service --command webserver '["sh", "-c", "while true; do echo Time files like an arrow $(date); sleep 1; done;"]' 325 | 326 | More about this can be looked up in documentation. 327 | https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#container_definitions 328 | 329 | 330 | 331 | 332 | Set a task role 333 | =============== 334 | To change or set the role, the service's task should run as, use the following command:: 335 | 336 | $ ecs deploy my-cluster my-service -r arn:aws:iam::123456789012:role/MySpecialEcsTaskRole 337 | 338 | This will set the task role to "MySpecialEcsTaskRole". 339 | 340 | 341 | Set CPU and memory reservation 342 | ============================== 343 | - Set the `cpu` value for a task: :code:`--task-cpu 0`. 344 | - Set the `cpu` value for a task container: :code:`--cpu 0`. 345 | - Set the `memory` value (`hard limit`) for a task: :code:`--task-memory 256`. 346 | - Set the `memory` value (`hard limit`) for a task container: :code:`--memory 256`. 347 | - Set the `memoryreservation` value (`soft limit`) for a task definition: :code:`--memoryreservation 256`. 348 | 349 | Set privileged or essential flags 350 | ================================= 351 | - Set the `privileged` value for a task definition: :code:`--privileged True|False`. 352 | - Set the `essential` value for a task definition: :code:`--essential True|False`. 353 | 354 | Set logging configuration 355 | ========================= 356 | Set the `logConfiguration` values for a task definition:: 357 | 358 | --log awslogs awslogs-group 359 | --log awslogs awslogs-region 360 | --log awslogs awslogs-stream-prefix 361 | 362 | 363 | Set port mapping 364 | ================ 365 | - Set the `port mappings` values for a task definition: :code:`--port `. 366 | 367 | - Supports :code:`--exclusive-ports`. 368 | - The `protocol` is fixed to `tcp`. 369 | 370 | Set volumes & mount points 371 | ========================== 372 | - Set the `volumes` values for a task definition :code:`--volume /host/path`. 373 | 374 | - :code:`` can then be used with :code:`--mount`. 375 | - Set the `mount points` values for a task definition: :code:`--mount /container/path`. 376 | 377 | - Supports :code:`--exclusive-mounts`. 378 | 379 | - :code:`` is the one set by :code:`--volume`. 380 | - Set the `ulimits` values for a task definition: :code:`--ulimit memlock 67108864 67108864`. 381 | 382 | - Supports :code:`--exclusive-ulimits`. 383 | - Set the `systemControls` values for a task definition: :code:`--system-control net.core.somaxconn 511`. 384 | 385 | - Supports :code:`--exclusive-system-controls`. 386 | - Set the `healthCheck` values for a task definition: :code:`--health-check `. 387 | 388 | 389 | Set Health Checks 390 | ================= 391 | - Example :code:`--health-check webserver "curl -f http://localhost/alive/" 30 5 3 0` 392 | 393 | 394 | Placeholder Container 395 | ===================== 396 | - Add placeholder containers: :code:`--add-container `. 397 | - To comply with the minimum requirements for a task definition, a placeholder container is set like this: 398 | + The container name is :code:``. 399 | + The container image is :code:`PLACEHOLDER`. 400 | + The container soft limit is :code:`128`. 401 | - The idea is to set sensible values with the deployment. 402 | 403 | It is possible to add and define a new container with the same deployment:: 404 | 405 | --add-container redis --image redis redis:6 --port redis 6379 6379 406 | 407 | Remove containers 408 | ================= 409 | - Containers can be removed: :code:`--remove-container `. 410 | 411 | - Leaves the original containers, if all containers would be removed. 412 | 413 | 414 | All but the container flags can be used with `ecs deploy` and `ecs cron`. 415 | The container flags are used with `ecs deploy` only. 416 | 417 | 418 | Ignore capacity issues 419 | ====================== 420 | If your cluster is undersized or the service's deployment options are not optimally set, the cluster 421 | might be incapable to run blue-green-deployments. In this case, you might see errors like these: 422 | 423 | ERROR: (service my-service) was unable to place a task because no container instance met all of 424 | its requirements. The closest matching (container-instance 123456-1234-1234-1234-1234567890) is 425 | already using a port required by your task. For more information, see the Troubleshooting 426 | section of the Amazon ECS Developer Guide. 427 | 428 | There might also be warnings about insufficient memory or CPU. 429 | 430 | To ignore these warnings, you can run the deployment with the flag ``--ignore-warnings``:: 431 | 432 | $ ecs deploy my-cluster my-service --ignore-warnings 433 | 434 | In that case, the warning is printed, but the script continues and waits for a successful 435 | deployment until it times out. 436 | 437 | Deployment timeout 438 | ================== 439 | The deploy and scale actions allow defining a timeout (in seconds) via the ``--timeout`` parameter. 440 | This instructs ecs-deploy to wait for ECS to finish the deployment for the given number of seconds. 441 | 442 | To run a deployment without waiting for the successful or failed result at all, set ``--timeout`` to the value of ``-1``. 443 | 444 | 445 | Multi-Account Setup 446 | =================== 447 | If you manage different environments of your system in multiple differnt AWS accounts, you can now easily assume a 448 | deployment role in the target account in which your ECS cluster is running. You only need to provide ``--account`` 449 | with the AWS account id and ``--assume-role`` with the name of the role you want to assume in the target account. 450 | ecs-deploy automatically assumes this role and deploys inside your target account: 451 | 452 | Example:: 453 | 454 | $ ecs deploy my-cluster my-service --account 1234567890 --assume-role ecsDeployRole 455 | 456 | 457 | 458 | 459 | Scaling 460 | ------- 461 | 462 | Scale a service 463 | =============== 464 | To change the number of running tasks and scale a service up and down, run this command:: 465 | 466 | $ ecs scale my-cluster my-service 4 467 | 468 | 469 | Running a Task 470 | -------------- 471 | 472 | Run a one-off task 473 | ================== 474 | To run a one-off task, based on an existing task-definition, run this command:: 475 | 476 | $ ecs run my-cluster my-task 477 | 478 | You can define just the task family (e.g. ``my-task``) or you can run a specific revision of the task-definition (e.g. 479 | ``my-task:123``). And optionally you can add or adjust environment variables like this:: 480 | 481 | $ ecs run my-cluster my-task:123 -e my-container MY_VARIABLE "my value" 482 | 483 | 484 | Run a task with a custom command 485 | ================================ 486 | 487 | You can override the command definition via option ``-c`` or ``--command`` followed by the container name and the 488 | command in a natural syntax, e.g. no conversion to comma-separation required:: 489 | 490 | $ ecs run my-cluster my-task -c my-container "python some-script.py param1 param2" 491 | 492 | The JSON syntax explained above regarding modifying a command is also applicable here. 493 | 494 | 495 | Run a task in a Fargate Cluster 496 | =============================== 497 | 498 | If you want to run a one-off task in a Fargate cluster, additional configuration is required, to instruct AWS e.g. which 499 | subnets or security groups to use. The required parameters for this are: 500 | 501 | - launchtype 502 | - securitygroup 503 | - subnet 504 | - public-ip 505 | 506 | Example:: 507 | 508 | $ ecs run my-fargate-cluster my-task --launchtype=FARGATE --securitygroup sg-01234567890123456 --subnet subnet-01234567890123456 --public-ip 509 | 510 | You can pass multiple ``subnet`` as well as multiple ``securitygroup`` values. the ``public-ip`` flag determines, if the task receives a public IP address or not. 511 | Please see ``ecs run --help`` for more details. 512 | 513 | 514 | Monitoring 515 | ---------- 516 | With ECS deploy you can track your deployments automatically. Currently only New Relic is supported: 517 | 518 | New Relic 519 | ========= 520 | To record a deployment in New Relic, you can provide the the API Key (**Attention**: this is a specific REST API Key, not the license key) and the application id in two ways: 521 | 522 | Via cli options:: 523 | 524 | $ ecs deploy my-cluster my-service --newrelic-apikey ABCDEFGHIJKLMN --newrelic-appid 1234567890 525 | 526 | Or implicitly via environment variables ``NEW_RELIC_API_KEY`` and ``NEW_RELIC_APP_ID`` :: 527 | 528 | $ export NEW_RELIC_API_KEY=ABCDEFGHIJKLMN 529 | $ export NEW_RELIC_APP_ID=1234567890 530 | $ ecs deploy my-cluster my-service 531 | 532 | Optionally you can provide additional information for the deployment: 533 | 534 | - ``--comment "New feature X"`` - comment to the deployment 535 | - ``--user john.doe`` - the name of the user who deployed with 536 | - ``--newrelic-revision 1.0.0`` - explicitly set the revision to use for the deployment 537 | 538 | Note: If neither ``--tag`` nor ``--newrelic-revision`` are provided, the deployment will not be recorded. 539 | 540 | 541 | Troubleshooting 542 | --------------- 543 | If the service configuration in ECS is not optimally set, you might be seeing 544 | timeout or other errors during the deployment. 545 | 546 | Timeout 547 | ======= 548 | The timeout error means, that AWS ECS takes longer for the full deployment cycle then ecs-deploy is told to wait. The deployment itself still might finish successfully, if there are no other problems with the deployed containers. 549 | 550 | You can increase the time (in seconds) to wait for finishing the deployment via the ``--timeout`` parameter. This time includes the full cycle of stopping all old containers and (re)starting all new containers. Different stacks require different timeout values, the default is 300 seconds. 551 | 552 | The overall deployment time depends on different things: 553 | 554 | - the type of the application. For example node.js containers tend to take a long time to get stopped. But nginx containers tend to stop immediately, etc. 555 | - are old and new containers able to run in parallel (e.g. using dynamic ports)? 556 | - the deployment options and strategy (Maximum percent > 100)? 557 | - the desired count of running tasks, compared to 558 | - the number of ECS instances in the cluster 559 | 560 | 561 | Alternative Implementation 562 | -------------------------- 563 | There are some other libraries/tools available on GitHub, which also handle the deployment of containers in AWS ECS. If you prefer another language over Python, have a look at these projects: 564 | 565 | Shell 566 | ecs-deploy - https://github.com/silinternational/ecs-deploy 567 | 568 | Ruby 569 | broadside - https://github.com/lumoslabs/broadside 570 | -------------------------------------------------------------------------------- /ecs_deploy/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function, absolute_import 2 | 3 | from os import getenv 4 | from time import sleep 5 | 6 | import click 7 | import json 8 | import getpass 9 | from datetime import datetime, timedelta 10 | from botocore.exceptions import ClientError 11 | from ecs_deploy import VERSION 12 | from ecs_deploy.ecs import DeployAction, ScaleAction, RunAction, EcsClient, DiffAction, \ 13 | TaskPlacementError, EcsError, UpdateAction, LAUNCH_TYPE_EC2, LAUNCH_TYPE_FARGATE 14 | from ecs_deploy.newrelic import Deployment, NewRelicException 15 | from ecs_deploy.slack import SlackNotification 16 | 17 | 18 | @click.group() 19 | @click.version_option(version=VERSION, prog_name='ecs-deploy') 20 | def ecs(): # pragma: no cover 21 | pass 22 | 23 | 24 | def get_client(access_key_id, secret_access_key, region, profile, assume_account, assume_role): 25 | return EcsClient(access_key_id, secret_access_key, region, profile, assume_account=assume_account, assume_role=assume_role) 26 | 27 | 28 | @click.command() 29 | @click.argument('cluster') 30 | @click.argument('service') 31 | @click.option('-t', '--tag', help='Changes the tag for ALL container images') 32 | @click.option('-i', '--image', type=(str, str), multiple=True, help='Overwrites the image for a container: ') 33 | @click.option('-c', '--command', type=(str, str), multiple=True, help='Overwrites the command in a container: ') 34 | @click.option('-h', '--health-check', type=(str, str, int, int, int, int), multiple=True, help='Overwrites the healthcheck in a container: ') 35 | @click.option('--cpu', type=(str, int), multiple=True, help='Overwrites the cpu value for a container: ') 36 | @click.option('--memory', type=(str, int), multiple=True, help='Overwrites the memory value for a container: ') 37 | @click.option('--memoryreservation', type=(str, int), multiple=True, help='Overwrites the memory reservation value for a container: ') 38 | @click.option('--task-cpu', type=int, help='Overwrites the cpu value for a task: ') 39 | @click.option('--task-memory', type=int, help='Overwrites the memory value for a task: ') 40 | @click.option('--privileged', type=(str, bool), multiple=True, help='Overwrites the privileged value for a container: ') 41 | @click.option('--essential', type=(str, bool), multiple=True, help='Overwrites the essential value for a container: ') 42 | @click.option('-e', '--env', type=(str, str, str), multiple=True, help='Adds or changes an environment variable: ') 43 | @click.option('--env-file', type=(str, str), default=((None, None),), multiple=True, required=False, help='Load environment variables from .env-file: ') 44 | @click.option('--s3-env-file', type=(str, str), multiple=True, required=False, help='Location of .env-file in S3 in ARN format (eg arn:aws:s3:::/bucket_name/object_name): ') 45 | @click.option('-s', '--secret', type=(str, str, str), multiple=True, help='Adds or changes a secret environment variable from the AWS Parameter Store (Not available for Fargate): ') 46 | @click.option('--secrets-env-file', type=(str, str), default=((None, None),), multiple=True, required=False, help='Load secrets from .env-file: ') 47 | @click.option('-d', '--docker-label', type=(str, str, str), multiple=True, help='Adds or changes a docker label: ') 48 | @click.option('-u', '--ulimit', type=(str, str, int, int), multiple=True, help='Adds or changes a ulimit variable in the container description (Not available for Fargate): ') 49 | @click.option('--system-control', type=(str, str, str), multiple=True, help='Adds or changes a system control variable in the container description (Not available for Fargate): ') 50 | @click.option('-p', '--port', type=(str, int, int), multiple=True, help='Adds or changes a port mappings in the container description (Not available for Fargate): ') 51 | @click.option('-m', '--mount', type=(str, str, str), multiple=True, help='Adds or changes a mount points in the container description (Not available for Fargate): ') 52 | @click.option('-l', '--log', type=(str, str, str, str), multiple=True, help='Adds or changes a log configuration in the container description (Not available for Fargate):