├── .dockerignore ├── .github └── workflows │ ├── build-and-push.yaml │ ├── codeql-analysis.yml │ └── test-and-lint.yaml ├── .gitignore ├── CODEOWNERS ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── ecr_exporter ├── __init__.py ├── collector.py └── server.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── test_collector.py └── tox.ini /.dockerignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-exporters/ecr/504dc05fe98fd21c224b68c2134752eb5143404f/.dockerignore -------------------------------------------------------------------------------- /.github/workflows/build-and-push.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | push_to_registry: 7 | name: Push Docker image to GitHub Packages 8 | runs-on: ubuntu-latest 9 | permissions: 10 | packages: write 11 | contents: read 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v2 15 | - name: Docker meta 16 | id: meta 17 | uses: docker/metadata-action@v3 18 | with: 19 | # list of Docker images to use as base name for tags 20 | images: | 21 | ghcr.io/${{ github.repository_owner }}/prometheus-ecr-exporter 22 | # generate Docker tags based on the following events/attributes 23 | tags: | 24 | type=schedule 25 | type=ref,event=branch 26 | type=ref,event=pr 27 | type=semver,pattern={{version}} 28 | type=semver,pattern={{major}}.{{minor}} 29 | type=semver,pattern={{major}} 30 | type=sha 31 | - name: Log in to GitHub Container Registry 32 | uses: docker/login-action@v1 33 | if: github.event_name != 'pull_request' 34 | with: 35 | registry: ghcr.io 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | - name: Build and push 39 | uses: docker/build-push-action@v2 40 | with: 41 | context: . 42 | push: ${{ github.event_name != 'pull_request' }} 43 | tags: ${{ steps.meta.outputs.tags }} 44 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '29 2 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/test-and-lint.yaml: -------------------------------------------------------------------------------- 1 | name: Test and Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | schedule: 10 | - cron: '30 11 * * *' 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: [3.5, 3.6, 3.7, 3.8, 3.9] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install tox tox-gh-actions 27 | - name: Test with Tox 28 | run: | 29 | tox 30 | lint: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: psf/black@stable -------------------------------------------------------------------------------- /.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 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 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 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @pysysops @js-timbirkett -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3 2 | 3 | # Installing required packages 4 | RUN apk add --update --no-cache \ 5 | python3~=3.9 py-pip 6 | 7 | # Install app code 8 | RUN mkdir /app 9 | ADD ./setup.* README.md /app/ 10 | ADD ecr_exporter /app/ecr_exporter 11 | ADD tests /app/tests 12 | 13 | # Install app deps 14 | RUN cd /app && pip install -e . 15 | 16 | # Run as non-root 17 | RUN adduser app -S -u 1000 18 | USER app 19 | 20 | # Switch the cwd to /app so that running app and tests is easier 21 | WORKDIR /app 22 | 23 | ENV AWS_DEFAULT_REGION eu-west-1 24 | CMD [ "ecr_exporter" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 aws-exporters 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include README.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CodeQL](https://github.com/aws-exporters/ecr/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/aws-exporters/ecr/actions/workflows/codeql-analysis.yml) 2 | [![Test and Lint](https://github.com/aws-exporters/ecr/actions/workflows/test-and-lint.yaml/badge.svg)](https://github.com/aws-exporters/ecr/actions/workflows/test-and-lint.yaml) 3 | ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/aws-exporters/ecr) 4 | ![GitHub](https://img.shields.io/github/license/aws-exporters/ecr) 5 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 6 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) 7 | 8 | # ecr_exporter 9 | A Prometheus exporter for AWS ECR. 10 | 11 | ## Motivation 12 | ECR repositories and images have a lot of useful information, image sizes, repository 13 | configuration, scan results... I'm sure there will be more. 14 | 15 | Information that might be useful to display on team based dashboards alongside 16 | Kubernetes workload availability and Istio traffic metrics. 17 | 18 | ## Technical Design 19 | This exporter makes use of `boto3` to query ECR for repositories and images. 20 | 21 | To be kind to the AWS APIs, results are cached and refreshed in the background every 22 | 30 minutes (by default). 23 | 24 | ### Configuration 25 | Configuration with environment variables: 26 | 27 | | Variable | Description | Default | Example | 28 | | -------- | ----------- | ------- | ------- | 29 | | `APP_PORT` | The port to expose the exporter on | `9000` | `8080` | 30 | | `APP_HOST` | The host to bind the application to | `0.0.0.0` | `localhost` | 31 | | `CACHE_REFRESH_INTERVAL` | How many seconds to wait before refreshing caches in the background | `1800` | `3600` | 32 | | `ECR_REGISTRY_ID` | The ID of the registry to export metrics for | `current AWS account` | `112233445566` | 33 | | `LOG_LEVEL` | How much or little logging do you want | `INFO` | `DEBUG` | 34 | 35 | ### Exported Metrics 36 | The metrics currently exported are: 37 | 38 | #### `aws_ecr_repository_count` 39 | - **Type:** Gauge 40 | - **Description:** The count of all repositories in this ECR registry 41 | - **Example:** 42 | ``` 43 | # HELP aws_ecr_repository_count Total count of all ECR repositories 44 | # TYPE aws_ecr_repository_count gauge 45 | aws_ecr_repository_count{registry_id="112233445566"} 171.0 46 | ``` 47 | 48 | #### `aws_ecr_repository_info` 49 | - **Type:** Info (Gauge) 50 | - **Description:** Key/value labels relating to each repository 51 | - **Example:** 52 | ``` 53 | # HELP aws_ecr_repository_info ECR repository information 54 | # TYPE aws_ecr_repository_info gauge 55 | aws_ecr_repository_info{encryption_type="AES256",name="flimflam",registry_id="112233445566",repository_uri="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flimflam",scan_on_push="false",tag_mutability="MUTABLE"} 1.0 56 | aws_ecr_repository_info{encryption_type="AES256",name="flipflop",registry_id="112233445566",repository_uri="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flipflop",scan_on_push="true",tag_mutability="IMMUTABLE"} 1.0 57 | aws_ecr_repository_info{encryption_type="AES256",name="parcel-bird",registry_id="112233445566",repository_uri="112233445566.dkr.ecr.eu-west-1.amazonaws.com/parcel-bird",scan_on_push="true",tag_mutability="MUTABLE"} 1.0 58 | .... 59 | .... 60 | ``` 61 | 62 | #### `aws_ecr_image_size_in_bytes` 63 | - **Type:** Gauge 64 | - **Description:** The size in bytes of each TAGGED image in each repository 65 | - **Example:** 66 | ``` 67 | # HELP aws_ecr_image_size_in_bytes The size of an image in bytes 68 | # TYPE aws_ecr_image_size_in_bytes gauge 69 | aws_ecr_image_size_in_bytes{digest="sha256:046c3c95cfd4ab660947885571130d34fef6fd5ddabb3ef84ac7fd7b79e4b8f1",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flimflam:1df508a3",name="flimflam",registry_id="112233445566",tag="1df508a3"} 9.1320109e+07 70 | aws_ecr_image_size_in_bytes{digest="sha256:10bcbc280f1bc017e767a2fc1ecb37085979dd0807fe312411ee9d3abc78f0b6",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flimflam:v1.0.41",name="flimflam",registry_id="112233445566",tag="v1.0.41"} 9.1054438e+07 71 | aws_ecr_image_size_in_bytes{digest="sha256:b869d1ffa62b8aba6ac3e26056acacf276425287513bcc77317fa9d2b607c054",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flimflam:8fd066ee",name="flimflam",registry_id="112233445566",tag="8fd066ee"} 9.1161959e+07 72 | aws_ecr_image_size_in_bytes{digest="sha256:9f47a709e9bea292ce1906f216df5f080493403b45c5b3e9fbe43e1c10733da6",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flipflop:v0.0.2",name="flipflop",registry_id="112233445566",tag="v0.0.2"} 2.46800685e+08 73 | aws_ecr_image_size_in_bytes{digest="sha256:9f47a709e9bea292ce1906f216df5f080493403b45c5b3e9fbe43e1c10733da6",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flipflop:v0.0.1",name="flipflop",registry_id="112233445566",tag="v0.0.1"} 2.46800685e+08 74 | .... 75 | .... 76 | ``` 77 | 78 | #### `aws_ecr_image_pushed_at_timestamp_seconds` 79 | - **Type:** Gauge 80 | - **Description:** The unix timestamp that this image was pushed at 81 | - **Example:** 82 | ``` 83 | # HELP aws_ecr_image_pushed_at_timestamp_seconds The unix timestamp that this image was pushed at 84 | # TYPE aws_ecr_image_pushed_at_timestamp_seconds gauge 85 | aws_ecr_image_pushed_at_timestamp_seconds{digest="sha256:046c3c95cfd4ab660947885571130d34fef6fd5ddabb3ef84ac7fd7b79e4b8f1",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/chimunk:1df508a3,name="chimunk",registry_id="112233445566",tag="1df508a3"} 1.601994911e+09 86 | aws_ecr_image_pushed_at_timestamp_seconds{digest="sha256:10bcbc280f1bc017e767a2fc1ecb37085979dd0807fe312411ee9d3abc78f0b6",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/savage-lands:v1.0.41,name="savage-lands",registry_id="112233445566",tag="v1.0.41"} 1.593518011e+09 87 | aws_ecr_image_pushed_at_timestamp_seconds{digest="sha256:b869d1ffa62b8aba6ac3e26056acacf276425287513bcc77317fa9d2b607c054",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/luna-tuna:8fd066ee,name="luna-tuna",registry_id="112233445566",tag="8fd066ee"} 1.596207388e+09 88 | .... 89 | .... 90 | ``` 91 | 92 | #### `aws_ecr_image_scan_severity_count` 93 | - **Type:** Gauge 94 | - **Description:** Scan result counts per image/tag/ by severity 95 | - **Example:** 96 | ``` 97 | # HELP aws_ecr_image_scan_severity_count ECR image scan summary results 98 | # TYPE aws_ecr_image_scan_severity_count gauge 99 | aws_ecr_image_scan_severity_count{digest="sha256:0b26628c113374546c4790e01bce65c3f4642db063286f16fe13e256923b2689",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flimflam:5a35d50d",name="flimflam",registry_id="112233445566",severity="MEDIUM",tag="5a35d50d"} 5.0 100 | aws_ecr_image_scan_severity_count{digest="sha256:a910ed7e15cb5fc7e5f0f2294f8028b56689be563bd1d352a4254197739dfa8e",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flipflop:2faa6445",name="flipflop",registry_id="112233445566",severity="MEDIUM",tag="2faa6445"} 3.0 101 | aws_ecr_image_scan_severity_count{digest="sha256:a910ed7e15cb5fc7e5f0f2294f8028b56689be563bd1d352a4254197739dfa8e",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flipflop:2faa6445",name="flipflop",registry_id="112233445566",severity="INFORMATIONAL",tag="2faa6445"} 5.0 102 | aws_ecr_image_scan_severity_count{digest="sha256:a910ed7e15cb5fc7e5f0f2294f8028b56689be563bd1d352a4254197739dfa8e",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/flipflop:2faa6445",name="flipflop",registry_id="112233445566",severity="LOW",tag="2faa6445"} 14.0 103 | aws_ecr_image_scan_severity_count{digest="sha256:981a9c17106eee1099d815f82dfb45f4e8d016a63816fec92f290f1af0117c37",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/birdbath:227c8031",name="birdbath",registry_id="112233445566",severity="MEDIUM",tag="227c8031"} 4.0 104 | aws_ecr_image_scan_severity_count{digest="sha256:981a9c17106eee1099d815f82dfb45f4e8d016a63816fec92f290f1af0117c37",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/birdbath:227c8031",name="birdbath",registry_id="112233445566",severity="INFORMATIONAL",tag="227c8031"} 5.0 105 | aws_ecr_image_scan_severity_count{digest="sha256:981a9c17106eee1099d815f82dfb45f4e8d016a63816fec92f290f1af0117c37",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/birdbath:227c8031",name="birdbath",registry_id="112233445566",severity="LOW",tag="227c8031"} 16.0 106 | aws_ecr_image_scan_severity_count{digest="sha256:f340879c042e88e08d7540c7ec26fb0895814743aefbdd4e62f63b5e41e9f1cf",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/birdbath:227c8031",name="birdbath",registry_id="112233445566",severity="MEDIUM",tag="77b36acb"} 4.0 107 | .... 108 | .... 109 | ``` 110 | 111 | #### `aws_ecr_image_scan_completed_at_timestamp_seconds` 112 | - **Type:** Gauge 113 | - **Description:** The unix timestamp when the scan was completed 114 | - **Example:** 115 | ``` 116 | # HELP aws_ecr_image_scan_completed_at_timestamp_seconds The unix timestamp when the scan was completed 117 | # TYPE aws_ecr_image_scan_completed_at_timestamp_seconds gauge 118 | aws_ecr_image_scan_completed_at_timestamp_seconds{digest="sha256:0b26628c113374546c4790e01bce65c3f4642db063286f16fe13e256923b2689",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/moose-juice:5a35d50d",name="moose-juice",registry_id="112233445566",tag="5a35d50d"} 1.617208126e+09 119 | aws_ecr_image_scan_completed_at_timestamp_seconds{digest="sha256:a910ed7e15cb5fc7e5f0f2294f8028b56689be563bd1d352a4254197739dfa8e",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/super-goggles:2faa6445",name="super-goggles",registry_id="112233445566",tag="2faa6445"} 1.618313952e+09 120 | aws_ecr_image_scan_completed_at_timestamp_seconds{digest="sha256:981a9c17106eee1099d815f82dfb45f4e8d016a63816fec92f290f1af0117c37",image="112233445566.dkr.ecr.eu-west-1.amazonaws.com/foot-massage:227c8031",name="foot-massage",registry_id="112233445566",tag="227c8031"} 1.622629411e+09 121 | .... 122 | .... 123 | ``` 124 | 125 | It should be possible to join metrics to show things like whether or not your repository is set 126 | to scan on push, the number of images in your repository, the size and scan result summaries for currently 127 | running images. 128 | 129 | #### Example Prometheus Queries 130 | 131 | 1. List all pods with container images with CRITICAL vulnerabilities: 132 | ``` 133 | max by (namespace, container, image, pod) (kube_pod_container_info) * on (image) group_left(name, tag) aws_ecr_image_scan_severity_count{severity="CRITICAL"} 134 | ``` 135 | 136 | 2. List all container images (that have run) with CRITICAL vulnerabilities: 137 | ``` 138 | max by (namespace, container, image) (kube_pod_container_info) * on (image) group_left(name, tag) aws_ecr_image_scan_severity_count{severity="CRITICAL"} 139 | ``` 140 | 141 | 3. List all pods running containers that have CRITICAL vulnerabilities: 142 | ``` 143 | max by (pod) (kube_pod_container_status_running > 0) * on (pod) group_right() max by (pod, namespace, container, image) (kube_pod_container_info) * on (image) group_left(name, tag) aws_ecr_image_scan_severity_count{severity="CRITICAL"} 144 | ``` 145 | 146 | 4. List all running containers that have CRITICAL vulnerabilities: 147 | ``` 148 | max without (pod) (max by (pod) (kube_pod_container_status_running > 0) * on (pod) group_right() max by (pod, namespace, container, image) (kube_pod_container_info) * on (image) group_left(name, tag) aws_ecr_image_scan_severity_count{severity="CRITICAL"}) 149 | ``` 150 | 151 | ##### Example Prometheus Alert (for Slack) 152 | ``` 153 | groups: 154 | - name: CriticalImageVulnerability 155 | rules: 156 | - alert: CriticalImageVulnerability 157 | expr: > 158 | max without (pod) (max by (pod) (kube_pod_container_status_running > 0) 159 | * on (pod) group_right() max by (pod, namespace, container, image) (kube_pod_container_info) 160 | * on (image) group_left(name, tag) aws_ecr_image_scan_severity_count{severity="CRITICAL"}) > 0 161 | for: 1m 162 | labels: 163 | severity: warning 164 | type: security-alert 165 | annotations: 166 | summary: ':skull_and_crossbones: An image is running with *CRITICAL* vulnerabilities in: *{{ $labels.namespace }}*' 167 | description: 'Image: {{ $labels.image }} is running with *CRITICAL* vulnerabilities. The base image should be reviewed and updated as required.' 168 | ``` 169 | 170 | ### Required IAM Permissions 171 | You'll require a role with the following IAM permissions: 172 | ``` 173 | { 174 | "Version": "2012-10-17", 175 | "Statement": [ 176 | { 177 | "Effect": "Allow", 178 | "Action": [ 179 | "ecr:DescribeImages", 180 | "ecr:DescribeRegistry", 181 | "ecr:DescribeRepositories" 182 | ], 183 | "Resource": "*" 184 | } 185 | ] 186 | } 187 | ``` 188 | 189 | ### Error Handling 190 | Currently, there is very little in the way of error handling and all stack traces 191 | are thrown out to the console. This will be improved as issues are encountered. 192 | 193 | ### Running Locally 194 | There are a number of ways to run this project locally. You'll need to have your 195 | environment configured to auth with AWS, how you do that is entirely up to you. 196 | 197 | By default, the AWS region is set to `eu-west-1`, but can be overridden in all of 198 | the usual ways. 199 | 200 | 1. Locally with Python: 201 | ``` 202 | python3 -m venv venv 203 | source venv/bin/activate 204 | pip install --upgrade pip 205 | pip install -e . 206 | # export any options here 207 | ecr_exporter 208 | ``` 209 | 210 | 2. Locally with Docker build: 211 | ``` 212 | docker build . -t ecr_exporter 213 | docker run -e AWS_PROFILE= -v ~/.aws:/home/app/.aws -p 9000:9000 --rm ecr_exporter 214 | ``` 215 | 216 | 3. Locally with hosted Docker image: 217 | ``` 218 | docker run -e AWS_PROFILE= -v ~/.aws:/home/app/.aws -p 9000:9000 --rm ghcr.io/aws-exporters/prometheus-ecr-exporter 219 | ``` 220 | 221 | Once initial metrics collection is complete, go to: http://localhost:9000/metrics 222 | -------------------------------------------------------------------------------- /ecr_exporter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-exporters/ecr/504dc05fe98fd21c224b68c2134752eb5143404f/ecr_exporter/__init__.py -------------------------------------------------------------------------------- /ecr_exporter/collector.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import botocore 3 | import logging 4 | 5 | from prometheus_client.core import InfoMetricFamily, GaugeMetricFamily 6 | from cachetools import TTLCache 7 | from datetime import timezone 8 | 9 | 10 | def _ecr_client(): 11 | boto_config = botocore.client.Config( 12 | connect_timeout=2, read_timeout=10, retries={"max_attempts": 2} 13 | ) 14 | session = boto3.session.Session() 15 | return session.client("ecr", config=boto_config) 16 | 17 | 18 | class ECRMetricsCollector: 19 | def __init__(self, registry_id): 20 | self.logger = logging.getLogger() 21 | self.registry_id = ( 22 | registry_id or _ecr_client().describe_registry()["registryId"] 23 | ) 24 | self.repocache = TTLCache(1, 86400) 25 | self.imagecache = TTLCache(10000, 86400) 26 | 27 | def collect(self): 28 | repositories = self.repocache.get("cache", []) 29 | 30 | repository_count_metric = GaugeMetricFamily( 31 | "aws_ecr_repository_count", 32 | "Total count of all ECR repositories", 33 | labels=["registry_id"], 34 | ) 35 | 36 | repository_count_metric.add_metric([self.registry_id], len(repositories)) 37 | 38 | repository_info_metrics = InfoMetricFamily( 39 | "aws_ecr_repository", "ECR repository information" 40 | ) 41 | 42 | for repo in repositories: 43 | repository_info_metrics.add_metric( 44 | [], 45 | { 46 | "name": repo["repositoryName"], 47 | "registry_id": repo["registryId"], 48 | "repository_uri": repo["repositoryUri"], 49 | "tag_mutability": repo["imageTagMutability"], 50 | "scan_on_push": str( 51 | repo["imageScanningConfiguration"]["scanOnPush"] 52 | ).lower(), 53 | "encryption_type": repo["encryptionConfiguration"][ 54 | "encryptionType" 55 | ], 56 | }, 57 | ) 58 | 59 | image_common_label_keys = ["name", "tag", "digest", "registry_id", "image"] 60 | 61 | image_size_metrics = GaugeMetricFamily( 62 | "aws_ecr_image_size_in_bytes", 63 | "The size of an image in bytes", 64 | labels=image_common_label_keys, 65 | ) 66 | 67 | image_push_timestamp_metrics = GaugeMetricFamily( 68 | "aws_ecr_image_pushed_at_timestamp_seconds", 69 | "The unix timestamp that this image was pushed at", 70 | labels=image_common_label_keys, 71 | ) 72 | 73 | image_scan_metrics = GaugeMetricFamily( 74 | "aws_ecr_image_scan_severity_count", 75 | "ECR image scan summary results", 76 | labels=image_common_label_keys + ["severity"], 77 | ) 78 | 79 | image_scan_timestamp_metrics = GaugeMetricFamily( 80 | "aws_ecr_image_scan_completed_at_timestamp_seconds", 81 | "The unix timestamp when the scan was completed", 82 | labels=image_common_label_keys, 83 | ) 84 | 85 | for repo in repositories: 86 | images = self.imagecache.get(repo["repositoryName"], []) 87 | 88 | for image in images: 89 | tags = image.get("imageTags") 90 | if tags: 91 | for tag in tags: 92 | image_common_label_values = [ 93 | repo["repositoryName"], 94 | tag, 95 | image["imageDigest"], 96 | self.registry_id, 97 | f'{repo["repositoryUri"]}:{tag}', 98 | ] 99 | 100 | image_size_metrics.add_metric( 101 | image_common_label_values, 102 | int(image["imageSizeInBytes"]), 103 | ) 104 | image_push_timestamp_metrics.add_metric( 105 | image_common_label_values, 106 | int( 107 | image["imagePushedAt"] 108 | .replace(tzinfo=timezone.utc) 109 | .timestamp() 110 | ), 111 | ) 112 | 113 | scan_summary = image.get("imageScanFindingsSummary") 114 | if scan_summary and scan_summary.get("findingSeverityCounts"): 115 | severity_counts = scan_summary.get("findingSeverityCounts") 116 | for severity in severity_counts: 117 | image_scan_metrics.add_metric( 118 | image_common_label_values + [severity], 119 | int(severity_counts[severity]), 120 | ) 121 | image_scan_timestamp_metrics.add_metric( 122 | image_common_label_values, 123 | int( 124 | scan_summary["imageScanCompletedAt"] 125 | .replace(tzinfo=timezone.utc) 126 | .timestamp() 127 | ), 128 | ) 129 | 130 | return [ 131 | repository_count_metric, 132 | repository_info_metrics, 133 | image_size_metrics, 134 | image_push_timestamp_metrics, 135 | image_scan_metrics, 136 | image_scan_timestamp_metrics, 137 | ] 138 | 139 | def refresh_repository_cache(self): 140 | ecr_client = _ecr_client() 141 | self.logger.info("refreshing repositories cache") 142 | paginator = ecr_client.get_paginator("describe_repositories") 143 | 144 | repositories = paginator.paginate( 145 | registryId=self.registry_id, PaginationConfig={"pageSize": 1000} 146 | ).build_full_result()["repositories"] 147 | 148 | self.logger.info(f"caching {len(repositories)} repositories") 149 | self.repocache["cache"] = repositories 150 | 151 | def refresh_image_cache(self, repositories, repository_name=""): 152 | ecr_client = _ecr_client() 153 | self.logger.info("refreshing image cache") 154 | paginator = ecr_client.get_paginator("describe_images") 155 | for repo in repositories: 156 | 157 | images = paginator.paginate( 158 | registryId=self.registry_id, 159 | repositoryName=repo["repositoryName"], 160 | filter={"tagStatus": "TAGGED"}, 161 | PaginationConfig={"pageSize": 1000}, 162 | ).build_full_result()["imageDetails"] 163 | 164 | self.imagecache[repo["repositoryName"]] = images 165 | self.logger.debug( 166 | f"refreshed cache with {len(images)} images for {repo['repositoryName']}" 167 | ) 168 | 169 | def refresh_caches(self): 170 | self.refresh_repository_cache() 171 | repositories = self.repocache.get("cache") 172 | self.refresh_image_cache(repositories) 173 | self.logger.info("cache refresh complete") 174 | -------------------------------------------------------------------------------- /ecr_exporter/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import time 4 | import sys 5 | import signal 6 | import traceback 7 | 8 | from pythonjsonlogger import jsonlogger 9 | from prometheus_client import start_http_server, Gauge 10 | from prometheus_client.core import REGISTRY 11 | from ecr_exporter.collector import ECRMetricsCollector 12 | 13 | 14 | def config_from_env(): 15 | config = {} 16 | config["port"] = int(os.getenv("APP_PORT", 9000)) 17 | config["host"] = os.getenv("APP_HOST", "0.0.0.0") 18 | config["log_level"] = os.getenv("LOG_LEVEL", "INFO") 19 | config["registry_id"] = os.getenv("ECR_REGISTRY_ID", None) 20 | config["refresh_interval"] = int(os.getenv("CACHE_REFRESH_INTERVAL", 1800)) 21 | 22 | return config 23 | 24 | 25 | def setup_logging(log_level): 26 | logger = logging.getLogger() 27 | logger.setLevel(log_level) 28 | logHandler = logging.StreamHandler(sys.stdout) 29 | formatter = jsonlogger.JsonFormatter( 30 | fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s" 31 | ) 32 | logHandler.setFormatter(formatter) 33 | logger.addHandler(logHandler) 34 | 35 | 36 | def main(config): 37 | try: 38 | shutdown = False 39 | 40 | # Setup logging 41 | setup_logging(config["log_level"]) 42 | logger = logging.getLogger() 43 | 44 | # Register signal handler 45 | def _on_sigterm(signal, frame): 46 | logging.getLogger().warning("exporter is shutting down") 47 | nonlocal shutdown 48 | shutdown = True 49 | 50 | signal.signal(signal.SIGINT, _on_sigterm) 51 | signal.signal(signal.SIGTERM, _on_sigterm) 52 | 53 | # Set the up metric value, which will be steady to 1 for the entire app lifecycle 54 | upMetric = Gauge( 55 | "aws_ecr_repository_exporter_up", 56 | "always 1 - can by used to check if it's running", 57 | ) 58 | 59 | upMetric.set(1) 60 | 61 | # Register our custom collector 62 | logger.warning("collecting initial metrics") 63 | ecr_collector = ECRMetricsCollector(config["registry_id"]) 64 | REGISTRY.register(ecr_collector) 65 | 66 | # Start server 67 | start_http_server(config["port"], config["host"]) 68 | logger.warning( 69 | f"exporter listening on http://{config['host']}:{config['port']}/" 70 | ) 71 | 72 | logger.info( 73 | f"caches will be refreshed every {config['refresh_interval']} seconds" 74 | ) 75 | loop_count = 0 76 | while not shutdown: 77 | if loop_count == 0: 78 | ecr_collector.refresh_caches() 79 | 80 | loop_count += 1 81 | time.sleep(1) 82 | 83 | # reset loop every refresh_interval seconds 84 | if loop_count >= config["refresh_interval"]: 85 | loop_count = 0 86 | 87 | logger.info("exporter has shutdown") 88 | except Exception: 89 | logger.exception(f"Uncaught Exception: {traceback.format_exc()}") 90 | sys.exit(1) 91 | 92 | 93 | def run(): 94 | main(config_from_env()) 95 | 96 | 97 | if __name__ == "__main__": 98 | run() 99 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # ecr_exporter 2 | # --------------- 3 | # A Prometheus exporter for AWS ECR 4 | # 5 | # Author: Tim Birkett 6 | # Website: https://github.com/aws-exporters/ecr 7 | # License: MIT License (see LICENSE file) 8 | 9 | import codecs 10 | from setuptools import find_packages, setup 11 | 12 | dependencies = [ 13 | "boto3==1.20.22", 14 | "prometheus-client==0.12.0", 15 | "cachetools==4.2.4", 16 | "python-json-logger==2.0.2", 17 | ] 18 | 19 | setup( 20 | name="ecr_exporter", 21 | version="0.1.4", 22 | url="https://github.com/aws-exporters/ecr", 23 | license="MIT", 24 | author="Tim Birkett", 25 | author_email="tim.birkett@devopsmakers.com", 26 | description="A Prometheus exporter for AWS ECR", 27 | long_description=codecs.open("README.md", encoding="utf-8").read(), 28 | long_description_content_type="text/markdown", 29 | packages=find_packages(exclude=["tests"]), 30 | include_package_data=True, 31 | zip_safe=False, 32 | platforms="any", 33 | install_requires=dependencies, 34 | entry_points={ 35 | "console_scripts": [ 36 | "ecr_exporter = ecr_exporter.server:run", 37 | ], 38 | }, 39 | classifiers=[ 40 | # As from http://pypi.python.org/pypi?%3Aaction=list_classifiers 41 | "Development Status :: 4 - Beta", 42 | "Topic :: Utilities", 43 | "Topic :: System :: Monitoring", 44 | "Intended Audience :: Information Technology", 45 | "Intended Audience :: System Administrators", 46 | "License :: OSI Approved :: MIT License", 47 | "Operating System :: OS Independent", 48 | "Programming Language :: Python :: 3 :: Only", 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-exporters/ecr/504dc05fe98fd21c224b68c2134752eb5143404f/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_collector.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ecr_exporter.collector import ECRMetricsCollector 4 | 5 | 6 | def test_collector_without_values(): 7 | with pytest.raises(TypeError): 8 | ECRMetricsCollector() 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py{36,37,38,39} 3 | 4 | [gh-actions] 5 | python = 6 | 3.6: py36 7 | 3.7: py37 8 | 3.8: py38 9 | 3.9: py39 10 | 11 | [testenv] 12 | commands=py.test --cov ecr_exporter -vv {posargs} 13 | deps= 14 | pytest 15 | pytest-cov 16 | mock 17 | pytest-mock --------------------------------------------------------------------------------