├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── continuous_integration.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── ecsmanage ├── __init__.py ├── apps.py └── management │ ├── __init__.py │ └── commands │ ├── __init__.py │ └── ecsmanage.py ├── scripts ├── test └── update ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── settings_test.py └── test_configuration.py └── tox.ini /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | _Add a brief description of what this PR does, and why it is needed._ 4 | 5 | Closes #XXX 6 | 7 | ### Demo 8 | 9 | _Optional. Screenshots, `curl` examples, etc._ 10 | 11 | ### Notes 12 | 13 | _Optional. Ancillary topics, caveats, alternative strategies that didn't work out, anything else._ 14 | 15 | ## Testing Instructions 16 | 17 | * How to test this PR 18 | * Prefer bulleted description 19 | * Start after checking out this branch 20 | * Include any setup required, such as bundling scripts, restarting services, etc. 21 | * Include test case, and expected output -------------------------------------------------------------------------------- /.github/workflows/continuous_integration.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - master 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | name: build 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: ["3.8", "3.10", "3.12"] 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Cache dependencies 26 | uses: actions/cache@v2 27 | with: 28 | path: ~/.cache/pip 29 | key: pip-${{ hashFiles('setup.cfg') }}-${{ hashFiles('tox.ini') }} 30 | restore-keys: pip- 31 | 32 | - name: Install Tox and any other packages 33 | run: pip install tox-gh-actions 34 | 35 | - name: Run Tox 36 | run: tox 37 | env: 38 | PYTHONPATH: ./tests/ 39 | DJANGO_SETTINGS_MODULE: settings_test 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | release: 10 | name: release 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write 14 | steps: 15 | - name: Checkout commit and fetch tag history 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Python 3.x 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: "3.x" 24 | 25 | - name: Install release dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install setuptools wheel 29 | 30 | - name: Build package 31 | run: python setup.py sdist bdist_wheel 32 | 33 | - name: Upload release to TestPyPI 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | pyre/ 117 | 118 | # Editors 119 | .vscode 120 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [4.0.1] - 2024-04-15 10 | ### Changed 11 | - Update PyPI Integration for Publishing [#36](https://github.com/azavea/django-ecsmanage/issues/36) 12 | 13 | ## [4.0.0] - 2024-01-05 14 | ### Changed 15 | - Return task ARN from the handle command [#34](https://github.com/azavea/django-ecsmanage/pull/34) 16 | 17 | ## [3.0.0] - 2023-12-18 18 | ### Added 19 | - Add support for Django 3.2, 4.2, 5.0 [#33](https://github.com/azavea/django-ecsmanage/pull/33) 20 | 21 | ### Changed 22 | - Support default network modes [#33](https://github.com/azavea/django-ecsmanage/pull/33) 23 | - Upgrade CI to use newer GitHub Actions for `checkout` and `setup-python` [#33](https://github.com/azavea/django-ecsmanage/pull/33) 24 | 25 | ### Removed 26 | - Remove support for end-of-life Python 3.6 and 3.7, Django 2.2, 3.0, and 3.1 [#33](https://github.com/azavea/django-ecsmanage/pull/33) 27 | 28 | ## [2.0.1] - 2020-11-24 29 | ### Added 30 | - Add ASSIGN_PUBLIC_IP configuration option [#28](https://github.com/azavea/django-ecsmanage/pull/28) 31 | 32 | ### Fixed 33 | - Fix parsing of ECS task ID from task ARN [#30](https://github.com/azavea/django-ecsmanage/pull/30) 34 | 35 | ## [2.0.0] - 2020-10-13 36 | ### Added 37 | - Add support for Django 3.x and Python 3.8; no actual code changes were required [#14](https://github.com/azavea/django-ecsmanage/pull/14) 38 | - Add support for Django 3.1 and Black source code formatting [#25](https://github.com/azavea/django-ecsmanage/pull/25) 39 | - Add support for supplying Fargate platform version [#26](https://github.com/azavea/django-ecsmanage/pull/26) 40 | - Add support for overriding Django container name [#27](https://github.com/azavea/django-ecsmanage/pull/27) 41 | 42 | ### Removed 43 | - Remove support for end-of-life Python 2.7 and 3.4 [#16](https://github.com/azavea/django-ecsmanage/pull/16) 44 | - Remove support for end-of-life Python 3.5 [#25](https://github.com/azavea/django-ecsmanage/pull/25) 45 | - Remove support for end-of-life Django 2.0 and 2.1 [#16](https://github.com/azavea/django-ecsmanage/pull/16) 46 | - Remove support for end-of-life Django 1.11 [#22](https://github.com/azavea/django-ecsmanage/pull/22) 47 | 48 | ### Changed 49 | - Updated `tox` and Travis configs to improve efficiencies, remove deprecated settings, and implement current best practices [#16](https://github.com/azavea/django-ecsmanage/pull/16) 50 | - Moved most package distribution configuration from setup.py to setup.cfg [#16](https://github.com/azavea/django-ecsmanage/pull/16) 51 | - Migrate from Travis to GitHub Actions [#22](https://github.com/azavea/django-ecsmanage/pull/22) 52 | 53 | ### Fixed 54 | - Fixed `flake8` config in scripts/test to ignore eggs [#16](https://github.com/azavea/django-ecsmanage/pull/16) 55 | - Fixed `black` formatter path in scripts/test to check all Python files [#16](https://github.com/azavea/django-ecsmanage/pull/16) 56 | 57 | ## [1.1.0] - 2019-07-15 58 | ### Changed 59 | - Use argparse.REMAINDER nargs value [#12](https://github.com/azavea/django-ecsmanage/pull/12) 60 | - Updated Django requirement from <=2.1,>=1.11 to >=1.11,<2.3 [#11](https://github.com/azavea/django-ecsmanage/pull/11) 61 | 62 | ## [1.0.1] - 2019-05-21 63 | ### Added 64 | - Added test suite for matrix of Python/Django versions [#7](https://github.com/azavea/django-ecsmanage/pull/7) 65 | 66 | ### Fixed 67 | - Fixed support for `future-fstrings` in Python 3.6+ [#9](https://github.com/azavea/django-ecsmanage/pull/9) 68 | 69 | ## [1.0.0] - 2019-05-01 70 | ### Added 71 | - Added Python 2.7 support [#6](https://github.com/azavea/django-ecsmanage/pull/6) 72 | 73 | ### Fixed 74 | - Fixed reference to taskDefinition in describe_task_definition response [#5](https://github.com/azavea/django-ecsmanage/pull/5) 75 | 76 | ## [0.1.0] - 2019-04-10 77 | ### Added 78 | - Update PyPi credentials [#4](https://github.com/azavea/django-ecsmanage/pull/4) 79 | - Initialize Django module for one-off management commands [#2](https://github.com/azavea/django-ecsmanage/pull/2) 80 | 81 | [Unreleased]: https://github.com/azavea/django-ecsmanage/compare/4.0.0...HEAD 82 | [4.0.0]: https://github.com/azavea/django-ecsmanage/compare/3.0.0...4.0.0 83 | [3.0.0]: https://github.com/azavea/django-ecsmanage/compare/2.0.1...3.0.0 84 | [2.0.1]: https://github.com/azavea/django-ecsmanage/compare/2.0.0...2.0.1 85 | [2.0.0]: https://github.com/azavea/django-ecsmanage/compare/1.1.0...2.0.0 86 | [1.1.0]: https://github.com/:azavea/django-ecsmanage/compare/1.0.1...1.1.0 87 | [1.0.1]: https://github.com/:azavea/django-ecsmanage/compare/1.0.0...1.0.1 88 | [1.0.0]: https://github.com/azavea/django-ecsmanage/compare/0.1.0...1.0.0 89 | [0.1.0]: https://github.com/azavea/django-ecsmanage/releases/tag/0.1.0 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2020 Azavea Inc. 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include LICENSE 3 | graft .github 4 | graft tests 5 | graft scripts 6 | global-exclude __pycache__ 7 | global-exclude *.py[co] 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-ecsmanage 2 | ================ 3 | 4 | .. image:: https://github.com/azavea/django-ecsmanage/workflows/CI/badge.svg?branch=develop 5 | :target: https://github.com/azavea/django-ecsmanage/actions?query=workflow%3ACI 6 | 7 | A Django app that provides a management command allowing you to run any 8 | other management command on an AWS Elastic Container Service (ECS) 9 | cluster. 10 | 11 | With ``django-ecsmanage``, you can easily run migrations and other 12 | one-off tasks on a remote cluster from the command line: 13 | 14 | :: 15 | 16 | $ django-admin ecsmanage migrate 17 | 18 | Table of Contents 19 | ----------------- 20 | 21 | - `Installation`_ 22 | - `Configuration`_ 23 | 24 | - `Environments`_ 25 | - `AWS Resources`_ 26 | 27 | - `Developing`_ 28 | 29 | Installation 30 | ------------ 31 | 32 | Install from PyPi using pip: 33 | 34 | .. code:: bash 35 | 36 | $ pip install django-ecsmanage 37 | 38 | Update ``INSTALLED_APPS`` in your Django settings to install the app: 39 | 40 | .. code:: python 41 | 42 | INSTALLED_APPS = ( 43 | ... 44 | 'ecsmanage', 45 | ) 46 | 47 | Configuration 48 | ------------- 49 | 50 | Settings for the management command are kept in a single configuration 51 | dictionary in your Django settings named ``ECSMANAGE_ENVIRONMENTS``. 52 | Each entry in ``ECSMANAGE_ENVIRONMENTS`` should be a key-value pair 53 | corresponding to a named environment (like ``default`` or 54 | ``production``) and a set of AWS resources associated with that 55 | environment. For example: 56 | 57 | .. code:: python 58 | 59 | ECSMANAGE_ENVIRONMENTS = { 60 | 'default': { 61 | 'TASK_DEFINITION_NAME': 'StagingAppCLI', 62 | 'CONTAINER_NAME': 'django', 63 | 'CLUSTER_NAME': 'ecsStagingCluster', 64 | 'LAUNCH_TYPE': 'FARGATE', 65 | 'PLATFORM_VERSION': '1.4.0', 66 | 'SECURITY_GROUP_TAGS': { 67 | 'Name': 'sgAppEcsService', 68 | 'Environment': 'Staging', 69 | 'Project': 'ProjectName' 70 | }, 71 | 'SUBNET_TAGS': { 72 | 'Name': 'PrivateSubnet', 73 | 'Environment': 'Staging', 74 | 'Project': 'ProjectName' 75 | }, 76 | 'ASSIGN_PUBLIC_IP': 'DISABLED', 77 | 'AWS_REGION': 'us-east-1', 78 | }, 79 | } 80 | 81 | This configuration defines a single environment, named ``default``, with 82 | associated AWS ECS resources. 83 | 84 | Environments 85 | ~~~~~~~~~~~~ 86 | 87 | The key name for an environment can be any string. You can use this name 88 | with the ``--env`` flag when running the command to run a command on a 89 | different environment. Take this ``ECSMANAGE_ENVIRONMENTS`` 90 | configuration as an example: 91 | 92 | .. code:: python 93 | 94 | ECSMANAGE_ENVIRONMENTS = { 95 | 'default': { 96 | 'TASK_DEFINITION_NAME': 'StagingAppCLI', 97 | 'CLUSTER_NAME': 'ecsStagingCluster', 98 | 'SECURITY_GROUP_TAGS': { 99 | 'Name': 'sgStagingAppEcsService', 100 | }, 101 | 'SUBNET_TAGS': { 102 | 'Name': 'StagingPrivateSubnet', 103 | }, 104 | }, 105 | 'production': { 106 | 'TASK_DEFINITION_NAME': 'ProductionAppCLI', 107 | 'CLUSTER_NAME': 'ecsProductionCluster', 108 | 'SECURITY_GROUP_TAGS': { 109 | 'Name': 'sgProductionAppEcsService', 110 | }, 111 | 'SUBNET_TAGS': { 112 | 'Name': 'ProductionPrivateSubnet', 113 | }, 114 | }, 115 | } 116 | 117 | This configuration defines two environments, ``default`` and 118 | ``production``. Using the above settings, you could run production 119 | migrations with the following command: 120 | 121 | .. code:: bash 122 | 123 | $ django-admin ecsmanage --env production migrate 124 | 125 | If the ``--env`` argument is not present, the command will default to 126 | the environment named ``default``. 127 | 128 | AWS Resources 129 | ~~~~~~~~~~~~~ 130 | 131 | The following environment configuration keys help the management command locate 132 | the appropriate AWS resources for your cluster: 133 | 134 | +--------------------------+------------------------------------------------------------------+---------------+ 135 | | Key | Description | Default | 136 | | | | | 137 | | | | | 138 | | | | | 139 | +==========================+==================================================================+===============+ 140 | | ``TASK_DEFINITION_NAME`` | The name of your ECS task definition. The command | | 141 | | | will automatically retrieve the latest definition. | | 142 | +--------------------------+------------------------------------------------------------------+---------------+ 143 | | ``CONTAINER_NAME`` | The name of the Django container in your ECS task definition. | ``django`` | 144 | +--------------------------+------------------------------------------------------------------+---------------+ 145 | | ``CLUSTER_NAME`` | The name of your ECS cluster. | | 146 | +--------------------------+------------------------------------------------------------------+---------------+ 147 | | ``SECURITY_GROUP_TAGS`` | A dictionary of tags to use to identify a security | | 148 | | | group for your task. | | 149 | +--------------------------+------------------------------------------------------------------+---------------+ 150 | | ``SUBNET_TAGS`` | A dictionary of tags to use to identify a subnet | | 151 | | | for your task. | | 152 | +--------------------------+------------------------------------------------------------------+---------------+ 153 | | ``ASSIGN_PUBLIC_IP`` | Whether to automatically assign a public IP address to your | ``DISABLED`` | 154 | | | task. Can be ``ENABLED`` or ``DISABLED``. | | 155 | +--------------------------+------------------------------------------------------------------+---------------+ 156 | | ``LAUNCH_TYPE`` | The ECS launch type for your task. | ``FARGATE`` | 157 | +--------------------------+------------------------------------------------------------------+---------------+ 158 | | ``PLATFORM_VERSION`` | The Fargate platform version, if ``LAUNCH_TYPE`` is ``FARGATE``. | ``LATEST`` | 159 | +--------------------------+------------------------------------------------------------------+---------------+ 160 | | ``AWS_REGION`` | The AWS region to run your task. | ``us-east-1`` | 161 | +--------------------------+------------------------------------------------------------------+---------------+ 162 | 163 | Developing 164 | ---------- 165 | 166 | Local development is managed with Python virtual environments. Make sure 167 | that you have Python 3.8+ and pip installed before starting. 168 | 169 | Install the development package in a virtual environment: 170 | 171 | .. code:: bash 172 | 173 | $ ./scripts/update 174 | 175 | Run the tests: 176 | 177 | .. code:: bash 178 | 179 | $ ./scripts/test 180 | 181 | .. _Installation: #installation 182 | .. _Configuration: #configuration 183 | .. _Environments: #environments 184 | .. _AWS Resources: #aws-resources 185 | .. _Developing: #developing 186 | -------------------------------------------------------------------------------- /ecsmanage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azavea/django-ecsmanage/cee8ec1b361fae37f941381118d900010cf3478d/ecsmanage/__init__.py -------------------------------------------------------------------------------- /ecsmanage/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class EcsManageConfig(AppConfig): 5 | name = "ecsmanage" 6 | verbose_name = "ECS Manage" 7 | -------------------------------------------------------------------------------- /ecsmanage/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azavea/django-ecsmanage/cee8ec1b361fae37f941381118d900010cf3478d/ecsmanage/management/__init__.py -------------------------------------------------------------------------------- /ecsmanage/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azavea/django-ecsmanage/cee8ec1b361fae37f941381118d900010cf3478d/ecsmanage/management/commands/__init__.py -------------------------------------------------------------------------------- /ecsmanage/management/commands/ecsmanage.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from django.core.management.base import BaseCommand, CommandError 3 | from django.conf import settings 4 | from argparse import REMAINDER 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Run a one-off management command on an ECS cluster." 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument( 12 | "-e", 13 | "--env", 14 | type=str, 15 | default="default", 16 | help=( 17 | "Environment to run the task in, as defined in" 18 | "ECSMANAGE_ENVIRONMENTS." 19 | ), 20 | ) 21 | 22 | parser.add_argument( 23 | "cmd", 24 | type=str, 25 | nargs=REMAINDER, 26 | help="Command override for the ECS container (e.g. 'migrate')", 27 | ) 28 | 29 | def handle(self, *args, **options): 30 | """ 31 | Run the given command on the latest app CLI task definition and print 32 | out a URL to view the status. Returns the task ARN of the started task. 33 | """ 34 | self.env = options["env"] 35 | cmd = options["cmd"] 36 | 37 | config = self.parse_config() 38 | 39 | aws_region = config["AWS_REGION"] 40 | 41 | self.ecs_client = boto3.client("ecs", region_name=aws_region) 42 | self.ec2_client = boto3.client("ec2", region_name=aws_region) 43 | 44 | task_def_arn = self.get_task_def(config["TASK_DEFINITION_NAME"]) 45 | security_group_id = self.get_security_group(config["SECURITY_GROUP_TAGS"]) 46 | subnet_id = self.get_subnet(config["SUBNET_TAGS"]) 47 | 48 | task_arn = self.run_task(config, task_def_arn, security_group_id, subnet_id, cmd) 49 | 50 | # Task ARNs have at least two formats: 51 | # 52 | # - Old: arn:aws:ecs:region:aws_account_id:task/task-id 53 | # - New: arn:aws:ecs:region:aws_account_id:task/cluster-name/task-id 54 | # 55 | # See: https://docs.aws.amazon.com/AmazonECS/latest/userguide/ecs-account-settings.html#ecs-resource-ids # NOQA 56 | task_id = task_arn.split("/")[-1] 57 | 58 | cluster_name = config["CLUSTER_NAME"] 59 | 60 | url = ( 61 | f"https://console.aws.amazon.com/ecs/home?region={aws_region}#" 62 | f"/clusters/{cluster_name}/tasks/{task_id}/details" # NOQA 63 | ) 64 | 65 | self.stdout.write( 66 | self.style.SUCCESS(f"Task started! View here:\n{url}") 67 | ) # NOQA 68 | 69 | return task_arn 70 | 71 | def parse_config(self): 72 | """ 73 | Parse configuration settings for the app, checking to make sure that 74 | they're valid. 75 | """ 76 | if getattr(settings, "ECSMANAGE_ENVIRONMENTS") is None: 77 | raise CommandError( 78 | "ECSMANAGE_ENVIRONMENTS was not found in the Django settings." 79 | ) 80 | 81 | ecs_configs = settings.ECSMANAGE_ENVIRONMENTS.get(self.env, None) 82 | if ecs_configs is None: 83 | raise CommandError( 84 | f'Environment "{self.env}" is not a recognized environment in ' 85 | "ECSMANAGE_ENVIRONMENTS (environments include: " 86 | f"{settings.ECSMANAGE_ENVIRONMENTS.keys()})" 87 | ) 88 | 89 | config = { 90 | "TASK_DEFINITION_NAME": "", 91 | "CONTAINER_NAME": "django", 92 | "CLUSTER_NAME": "", 93 | "SECURITY_GROUP_TAGS": "", 94 | "SUBNET_TAGS": "", 95 | "ASSIGN_PUBLIC_IP": "DISABLED", 96 | "LAUNCH_TYPE": "FARGATE", 97 | "PLATFORM_VERSION": "LATEST", 98 | "AWS_REGION": "us-east-1", 99 | } 100 | 101 | for config_name, config_default in config.items(): 102 | if ecs_configs.get(config_name) is None: 103 | if config_default == "": 104 | raise CommandError( 105 | f'Environment "{self.env}" is missing required config ' 106 | f"attribute {config_name}" 107 | ) 108 | else: 109 | config[config_name] = config_default 110 | else: 111 | config[config_name] = ecs_configs[config_name] 112 | 113 | return config 114 | 115 | def parse_response(self, response, key, idx=None): 116 | """ 117 | Perform a key-value lookup on a response from the AWS API, wrapping it 118 | in error handling such that if the lookup fails the response body 119 | will get propagated to the end user. 120 | """ 121 | if not response.get(key): 122 | msg = f"Unexpected response from ECS API: {response}" 123 | raise KeyError(msg) 124 | else: 125 | if idx is not None: 126 | try: 127 | return response[key][0] 128 | except (IndexError, TypeError): 129 | msg = ( 130 | f"Unexpected value for '{key}' in response: " # NOQA 131 | f"{response}" # NOQA 132 | ) 133 | raise IndexError(msg) 134 | else: 135 | return response[key] 136 | 137 | def get_task_def(self, task_def_name): 138 | """ 139 | Get the ARN of the latest ECS task definition with the name 140 | task_def_name. 141 | """ 142 | task_def_response = self.ecs_client.list_task_definitions( 143 | familyPrefix=task_def_name, sort="DESC", maxResults=1 144 | ) 145 | 146 | return self.parse_response(task_def_response, "taskDefinitionArns", 0) 147 | 148 | def get_security_group(self, security_group_tags): 149 | """ 150 | Get the ID of the first security group with tags corresponding to 151 | security_group_tags. 152 | """ 153 | filters = [] 154 | for tagname, tagvalue in security_group_tags.items(): 155 | filters.append({"Name": f"tag:{tagname}", "Values": [tagvalue]}) 156 | 157 | sg_response = self.ec2_client.describe_security_groups(Filters=filters) 158 | 159 | security_group = self.parse_response(sg_response, "SecurityGroups", 0) 160 | return security_group["GroupId"] 161 | 162 | def get_subnet(self, subnet_tags): 163 | """ 164 | Get the ID of the first subnet with tags corresponding to subnet_tags. 165 | """ 166 | filters = [] 167 | for tagname, tagvalue in subnet_tags.items(): 168 | filters.append({"Name": f"tag:{tagname}", "Values": [tagvalue]}) 169 | 170 | subnet_response = self.ec2_client.describe_subnets(Filters=filters) 171 | 172 | subnet = self.parse_response(subnet_response, "Subnets", 0) 173 | return subnet["SubnetId"] 174 | 175 | def run_task(self, config, task_def_arn, security_group_id, subnet_id, cmd): 176 | """ 177 | Run a task for a given task definition ARN using the given security 178 | group and subnets, and return the task ARN of the started task. 179 | """ 180 | task_def = self.ecs_client.describe_task_definition( 181 | taskDefinition=task_def_arn 182 | )["taskDefinition"] 183 | 184 | kwargs = { 185 | "cluster": config["CLUSTER_NAME"], 186 | "taskDefinition": task_def_arn, 187 | "overrides": { 188 | "containerOverrides": [ 189 | {"name": config["CONTAINER_NAME"], "command": cmd} 190 | ] 191 | }, 192 | "count": 1, 193 | "launchType": config["LAUNCH_TYPE"], 194 | } 195 | 196 | # Only the awsvpc network mode supports the networkConfiguration 197 | # input value. 198 | if task_def.get("networkMode") == "awsvpc": 199 | kwargs["networkConfiguration"] = { 200 | "awsvpcConfiguration": { 201 | "subnets": [subnet_id], 202 | "securityGroups": [security_group_id], 203 | "assignPublicIp": config["ASSIGN_PUBLIC_IP"], 204 | } 205 | } 206 | 207 | # Setting platformVersion of only relevant if launchType is FARGATE. 208 | if config["LAUNCH_TYPE"] == "FARGATE": 209 | kwargs["platformVersion"] = config["PLATFORM_VERSION"] 210 | 211 | task = self.parse_response(self.ecs_client.run_task(**kwargs), "tasks", 0) 212 | 213 | return task["taskArn"] 214 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${ECSMANAGE_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") [OPTIONS] 12 | 13 | Run tests for the django-ecsmanage app. 14 | 15 | Options: 16 | --help Display help text. 17 | --lint Run shell and Python linters. 18 | --app Run app tests. 19 | " 20 | } 21 | 22 | function run_linters() { 23 | # Lint Bash scripts. 24 | if command -v shellcheck > /dev/null; then 25 | shellcheck scripts/* 26 | fi 27 | 28 | # Lint Python scripts. 29 | ./.venv/bin/flake8 --exclude "*.pyc,.eggs,.venv" 30 | if command -v ./.venv/bin/black > /dev/null; then 31 | ./.venv/bin/black --check --diff . 32 | fi 33 | } 34 | 35 | function run_tests() { 36 | PYTHONPATH="./tests/" DJANGO_SETTINGS_MODULE="settings_test" \ 37 | ./.venv/bin/django-admin test --noinput 38 | } 39 | 40 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 41 | if [ "${1:-}" = "--help" ]; then 42 | usage 43 | elif [ "${1:-}" = "--lint" ]; then 44 | run_linters 45 | elif [ "${1:-}" = "--app" ]; then 46 | run_tests 47 | else 48 | run_linters 49 | run_tests 50 | fi 51 | fi 52 | -------------------------------------------------------------------------------- /scripts/update: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | if [[ -n "${ECSMANAGE_DEBUG}" ]]; then 6 | set -x 7 | fi 8 | 9 | function usage() { 10 | echo -n \ 11 | "Usage: $(basename "$0") 12 | 13 | Install the package for testing. 14 | " 15 | } 16 | 17 | if [ "${BASH_SOURCE[0]}" = "${0}" ]; then 18 | if [ "${1:-}" = "--help" ]; then 19 | usage 20 | else 21 | if ! [ -x "$(command -v python3)" ]; then 22 | echo "Error: python3 is not installed." 23 | exit 1 24 | elif ! [ -x "$(command -v pip3)" ]; then 25 | echo "Error: pip3 is not installed." 26 | exit 1 27 | else 28 | if ! [ -d ".venv" ]; then 29 | python3 -m venv ./.venv 30 | fi 31 | 32 | ./.venv/bin/pip3 install -e ".[tests]" 33 | fi 34 | fi 35 | fi 36 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = django-ecsmanage 3 | description = Run any Django management command on an AWS Elastic Container Service (ECS) cluster. 4 | long_description = file: README.rst 5 | url = https://github.com/azavea/django-ecsmanage/ 6 | author = Azavea, Inc. 7 | author_email = systems@azavea.com 8 | license = Apache License 2.0 9 | classifiers = 10 | Environment :: Web Environment 11 | Framework :: Django 12 | Framework :: Django :: 3.2 13 | Framework :: Django :: 4.2 14 | Framework :: Django :: 5.0 15 | Intended Audience :: Developers 16 | License :: OSI Approved :: Apache Software License 17 | Programming Language :: Python 18 | Programming Language :: Python :: 3 19 | Programming Language :: Python :: 3 :: Only 20 | Programming Language :: Python :: 3.8 21 | Programming Language :: Python :: 3.10 22 | Programming Language :: Python :: 3.12 23 | 24 | [options] 25 | include_package_data = True 26 | packages = find: 27 | python_requires = >=3.8 28 | install_requires = 29 | Django >=2.2 30 | boto3 >=1.9.0 31 | setup_requires = 32 | setuptools_scm ==3.* 33 | 34 | [options.extras_require] 35 | tests = 36 | flake8 >=3.7.7 37 | black; python_version > "3.8" 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | use_scm_version=True, 5 | ) 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azavea/django-ecsmanage/cee8ec1b361fae37f941381118d900010cf3478d/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testing purposes. 3 | """ 4 | SECRET_KEY = "testing!" 5 | -------------------------------------------------------------------------------- /tests/test_configuration.py: -------------------------------------------------------------------------------- 1 | from django.core.management import call_command 2 | from django.core.management.base import CommandError 3 | from django.test import SimpleTestCase 4 | 5 | 6 | class ConfigurationTestCase(SimpleTestCase): 7 | """ 8 | Test the configuration of settings for the management command. 9 | """ 10 | 11 | def test_failure_when_no_settings(self): 12 | """ 13 | Test that the command throws an error when the ECSMANAGE_ENVIRONMENTS 14 | setting does not exist. 15 | """ 16 | with self.assertRaises(CommandError): 17 | call_command("ecsmanage", "help") 18 | 19 | def test_failure_when_missing_environment(self): 20 | """ 21 | Test that the command throws an error when the environment passed to 22 | the CLI does not exist in ECSMANAGE_ENVIRONMENTS. 23 | """ 24 | ECSMANAGE_ENVIRONMENTS = {"staging": {}, "production": {}} 25 | with self.assertRaises(CommandError): 26 | with self.settings(ECSMANAGE_ENVIRONMENTS=ECSMANAGE_ENVIRONMENTS): 27 | call_command("ecsmanage", "help", env="foobar") 28 | 29 | def test_failure_when_no_task_def_name(self): 30 | """ 31 | Test that the command throws an error when the configuration is missing 32 | a task definition name. 33 | """ 34 | ECSMANAGE_ENVIRONMENTS = { 35 | "staging": { 36 | "CLUSTER_NAME": "foo", 37 | "SECURITY_GROUP_TAGS": {}, 38 | "SUBNET_TAGS": {}, 39 | } 40 | } 41 | with self.assertRaises(CommandError): 42 | with self.settings(ECSMANAGE_ENVIRONMENTS=ECSMANAGE_ENVIRONMENTS): 43 | call_command("ecsmanage", "help", env="staging") 44 | 45 | def test_failure_when_no_cluster_name(self): 46 | """ 47 | Test that the command throws an error when the configuration is missing 48 | a cluster name. 49 | """ 50 | ECSMANAGE_ENVIRONMENTS = { 51 | "staging": { 52 | "TASK_DEFINITION_NAME": "foo", 53 | "SECURITY_GROUP_TAGS": {}, 54 | "SUBNET_TAGS": {}, 55 | } 56 | } 57 | with self.assertRaises(CommandError): 58 | with self.settings(ECSMANAGE_ENVIRONMENTS=ECSMANAGE_ENVIRONMENTS): 59 | call_command("ecsmanage", "help", env="staging") 60 | 61 | def test_failure_when_no_security_group_tags(self): 62 | """ 63 | Test that the command throws an error when the configuration is missing 64 | security group tags. 65 | """ 66 | ECSMANAGE_ENVIRONMENTS = { 67 | "staging": { 68 | "TASK_DEFINITION_NAME": "foo", 69 | "CLUSTER_NAME": "bar", 70 | "SUBNET_TAGS": {}, 71 | } 72 | } 73 | with self.assertRaises(CommandError): 74 | with self.settings(ECSMANAGE_ENVIRONMENTS=ECSMANAGE_ENVIRONMENTS): 75 | call_command("ecsmanage", "help", env="staging") 76 | 77 | def test_failure_when_no_subnet_tags(self): 78 | """ 79 | Test that the command throws an error when the configuration is missing 80 | subnet tags. 81 | """ 82 | ECSMANAGE_ENVIRONMENTS = { 83 | "staging": { 84 | "TASK_DEFINITION_NAME": "foo", 85 | "CLUSTER_NAME": "bar", 86 | "SECURITY_GROUP_TAGS": {}, 87 | } 88 | } 89 | with self.assertRaises(CommandError): 90 | with self.settings(ECSMANAGE_ENVIRONMENTS=ECSMANAGE_ENVIRONMENTS): 91 | call_command("ecsmanage", "help", env="staging") 92 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint 4 | py{38,310,312}-django32 5 | py{310,312}-django42 6 | py{310,312}-django50 7 | py{310,312}-djangomaster 8 | 9 | [gh-actions] 10 | python = 11 | 3.8: py38 12 | 3.10: py310 13 | 3.12: py312 14 | 15 | [testenv] 16 | passenv = PYTHONPATH,DJANGO_SETTINGS_MODULE 17 | deps = 18 | django32: Django>=3.2,<3.3 19 | django42: Django>=4.2,<4.3 20 | django50: Django>=5.0,<5.1 21 | djangomaster: https://github.com/django/django/archive/master.tar.gz 22 | commands = 23 | django-admin test --noinput 24 | 25 | [testenv:lint] 26 | deps = 27 | black 28 | check-manifest 29 | flake8 30 | readme_renderer 31 | commands = 32 | check-manifest --ignore tox.ini 33 | {envpython} setup.py check -m -r -s 34 | flake8 . 35 | black --check --diff . 36 | skip_install = true 37 | 38 | [flake8] 39 | max-line-length = 88 40 | extend-ignore = E203, W503 41 | --------------------------------------------------------------------------------