├── .gcloudignore ├── .gitattributes ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .whitesource ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── billing.py ├── chat.py ├── config.py ├── example-bigquery-billing-costs-view.sql ├── example-chat-message.png ├── example-config.yaml ├── example-slack-message.png ├── filters.py ├── logging.yaml ├── logging_config.py ├── main.py ├── requirements.txt ├── slack.py └── utils.py /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | node_modules 17 | #!include:.gitignore 18 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | 3 | # 4 | ## These files are binary and should be left untouched 5 | # 6 | 7 | # (binary is a macro for -text -diff) 8 | *.png binary 9 | *.jpg binary 10 | *.jpeg binary 11 | *.gif binary 12 | *.ico binary 13 | *.mov binary 14 | *.mp4 binary 15 | *.mp3 binary 16 | *.flv binary 17 | *.fla binary 18 | *.swf binary 19 | *.gz binary 20 | *.zip binary 21 | *.7z binary 22 | *.ttf binary 23 | *.eot binary 24 | *.woff binary 25 | *.pyc binary 26 | *.pdf binary 27 | *.exe binary 28 | *.iml binary 29 | *.jar binary 30 | *.woff2 binary 31 | *.icns binary 32 | chromedriver binary 33 | gradlew.bat binary 34 | -------------------------------------------------------------------------------- /.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: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '35 0 * * 5' 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', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/linux,macos,flask,python,angular,pycharm,windows,pycharm+all,jupyternotebook,visualstudiocode,archives,node,terraform 3 | # Edit at https://www.gitignore.io/?templates=linux,macos,flask,python,angular,pycharm,windows,pycharm+all,jupyternotebook,visualstudiocode,archives,node,terraform 4 | 5 | ### Terraform ### 6 | # Local .terraform directories 7 | **/.terraform/* 8 | 9 | # .tfstate files 10 | *.tfstate 11 | *.tfstate.* 12 | 13 | # Crash log files 14 | crash.log 15 | 16 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 17 | # .tfvars files are managed as part of configuration and so should be included in 18 | # version control. 19 | # 20 | # example.tfvars 21 | 22 | # Ignore override files as they are usually used to override resources locally and so 23 | # are not checked in 24 | override.tf 25 | override.tf.json 26 | *_override.tf 27 | *_override.tf.json 28 | 29 | # Include override files you do wish to add to version control using negated pattern 30 | # !example_override.tf 31 | 32 | # End of https://www.gitignore.io/api/terraform 33 | 34 | ### Angular ### 35 | ## Angular ## 36 | # compiled output 37 | /dist 38 | /tmp 39 | /app/**/*.js 40 | /app/**/*.js.map 41 | 42 | # dependencies 43 | /node_modules 44 | /bower_components 45 | 46 | # IDEs and editors 47 | /.idea 48 | 49 | # misc 50 | /.sass-cache 51 | /connect.lock 52 | /coverage/* 53 | /libpeerconnection.log 54 | npm-debug.log 55 | testem.log 56 | /typings 57 | 58 | # e2e 59 | /e2e/*.js 60 | /e2e/*.map 61 | 62 | #System Files 63 | .DS_Store 64 | 65 | ### Archives ### 66 | # It's better to unpack these files and commit the raw source because 67 | # git has its own built in compression methods. 68 | *.7z 69 | *.jar 70 | *.rar 71 | *.zip 72 | *.gz 73 | *.tgz 74 | *.bzip 75 | *.bz2 76 | *.xz 77 | *.lzma 78 | *.cab 79 | 80 | # Packing-only formats 81 | *.iso 82 | *.tar 83 | 84 | # Package management formats 85 | *.dmg 86 | *.xpi 87 | *.gem 88 | *.egg 89 | *.deb 90 | *.rpm 91 | *.msi 92 | *.msm 93 | *.msp 94 | 95 | ### Flask ### 96 | instance/* 97 | !instance/.gitignore 98 | .webassets-cache 99 | 100 | ### Flask.Python Stack ### 101 | # Byte-compiled / optimized / DLL files 102 | __pycache__/ 103 | *.py[cod] 104 | *$py.class 105 | 106 | # C extensions 107 | *.so 108 | 109 | # Distribution / packaging 110 | .Python 111 | build/ 112 | develop-eggs/ 113 | dist/ 114 | downloads/ 115 | eggs/ 116 | .eggs/ 117 | lib/ 118 | lib64/ 119 | parts/ 120 | sdist/ 121 | var/ 122 | wheels/ 123 | *.egg-info/ 124 | .installed.cfg 125 | MANIFEST 126 | 127 | # PyInstaller 128 | # Usually these files are written by a python script from a template 129 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 130 | *.manifest 131 | *.spec 132 | 133 | # Installer logs 134 | pip-log.txt 135 | pip-delete-this-directory.txt 136 | 137 | # Unit test / coverage reports 138 | htmlcov/ 139 | .tox/ 140 | .nox/ 141 | .coverage 142 | .coverage.* 143 | .cache 144 | nosetests.xml 145 | coverage.xml 146 | *.cover 147 | .hypothesis/ 148 | .pytest_cache/ 149 | 150 | # Translations 151 | *.mo 152 | *.pot 153 | 154 | # Django stuff: 155 | *.log 156 | local_settings.py 157 | db.sqlite3 158 | 159 | # Flask stuff: 160 | instance/ 161 | 162 | # Scrapy stuff: 163 | .scrapy 164 | 165 | # Sphinx documentation 166 | docs/_build/ 167 | 168 | # PyBuilder 169 | target/ 170 | 171 | # Jupyter Notebook 172 | .ipynb_checkpoints 173 | 174 | # IPython 175 | profile_default/ 176 | ipython_config.py 177 | 178 | # pyenv 179 | .python-version 180 | 181 | # celery beat schedule file 182 | celerybeat-schedule 183 | 184 | # SageMath parsed files 185 | *.sage.py 186 | 187 | # Environments 188 | .env 189 | .venv 190 | env/ 191 | venv/ 192 | ENV/ 193 | env.bak/ 194 | venv.bak/ 195 | 196 | # Spyder project settings 197 | .spyderproject 198 | .spyproject 199 | 200 | # Rope project settings 201 | .ropeproject 202 | 203 | # mkdocs documentation 204 | /site 205 | 206 | # mypy 207 | .mypy_cache/ 208 | .dmypy.json 209 | dmypy.json 210 | 211 | # Pyre type checker 212 | .pyre/ 213 | 214 | ### JupyterNotebook ### 215 | */.ipynb_checkpoints/* 216 | 217 | # Remove previous ipynb_checkpoints 218 | # git rm -r .ipynb_checkpoints/ 219 | # 220 | 221 | ### Linux ### 222 | *~ 223 | 224 | # temporary files which can be created if a process still has a handle open of a deleted file 225 | .fuse_hidden* 226 | 227 | # KDE directory preferences 228 | .directory 229 | 230 | # Linux trash folder which might appear on any partition or disk 231 | .Trash-* 232 | 233 | # .nfs files are created when an open file is removed but is still being accessed 234 | .nfs* 235 | 236 | ### macOS ### 237 | # General 238 | .AppleDouble 239 | .LSOverride 240 | 241 | # Icon must end with two \r 242 | Icon 243 | 244 | # Thumbnails 245 | ._* 246 | 247 | # Files that might appear in the root of a volume 248 | .DocumentRevisions-V100 249 | .fseventsd 250 | .Spotlight-V100 251 | .TemporaryItems 252 | .Trashes 253 | .VolumeIcon.icns 254 | .com.apple.timemachine.donotpresent 255 | 256 | # Directories potentially created on remote AFP share 257 | .AppleDB 258 | .AppleDesktop 259 | Network Trash Folder 260 | Temporary Items 261 | .apdisk 262 | 263 | ### Node ### 264 | # Logs 265 | logs 266 | npm-debug.log* 267 | yarn-debug.log* 268 | yarn-error.log* 269 | 270 | # Runtime data 271 | pids 272 | *.pid 273 | *.seed 274 | *.pid.lock 275 | 276 | # Directory for instrumented libs generated by jscoverage/JSCover 277 | lib-cov 278 | 279 | # Coverage directory used by tools like istanbul 280 | coverage 281 | 282 | # nyc test coverage 283 | .nyc_output 284 | 285 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 286 | .grunt 287 | 288 | # Bower dependency directory (https://bower.io/) 289 | bower_components 290 | 291 | # node-waf configuration 292 | .lock-wscript 293 | 294 | # Compiled binary addons (https://nodejs.org/api/addons.html) 295 | build/Release 296 | 297 | # Dependency directories 298 | node_modules/ 299 | jspm_packages/ 300 | 301 | # TypeScript v1 declaration files 302 | typings/ 303 | 304 | # Optional npm cache directory 305 | .npm 306 | 307 | # Optional eslint cache 308 | .eslintcache 309 | 310 | # Optional REPL history 311 | .node_repl_history 312 | 313 | # Output of 'npm pack' 314 | 315 | # Yarn Integrity file 316 | .yarn-integrity 317 | 318 | # dotenv environment variables file 319 | 320 | # parcel-bundler cache (https://parceljs.org/) 321 | 322 | # next.js build output 323 | .next 324 | 325 | # nuxt.js build output 326 | .nuxt 327 | 328 | # vuepress build output 329 | .vuepress/dist 330 | 331 | # Serverless directories 332 | .serverless 333 | 334 | # FuseBox cache 335 | .fusebox/ 336 | 337 | ### PyCharm ### 338 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 339 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 340 | 341 | # User-specific stuff 342 | .idea/**/workspace.xml 343 | .idea/**/tasks.xml 344 | .idea/**/usage.statistics.xml 345 | .idea/**/dictionaries 346 | .idea/**/shelf 347 | 348 | # Generated files 349 | .idea/**/contentModel.xml 350 | 351 | # Sensitive or high-churn files 352 | .idea/**/dataSources/ 353 | .idea/**/dataSources.ids 354 | .idea/**/dataSources.local.xml 355 | .idea/**/sqlDataSources.xml 356 | .idea/**/dynamic.xml 357 | .idea/**/uiDesigner.xml 358 | .idea/**/dbnavigator.xml 359 | 360 | # Gradle 361 | .idea/**/gradle.xml 362 | .idea/**/libraries 363 | 364 | # Gradle and Maven with auto-import 365 | # When using Gradle or Maven with auto-import, you should exclude module files, 366 | # since they will be recreated, and may cause churn. Uncomment if using 367 | # auto-import. 368 | # .idea/modules.xml 369 | # .idea/*.iml 370 | # .idea/modules 371 | 372 | # CMake 373 | cmake-build-*/ 374 | 375 | # Mongo Explorer plugin 376 | .idea/**/mongoSettings.xml 377 | 378 | # File-based project format 379 | *.iws 380 | 381 | # IntelliJ 382 | out/ 383 | 384 | # mpeltonen/sbt-idea plugin 385 | .idea_modules/ 386 | 387 | # JIRA plugin 388 | atlassian-ide-plugin.xml 389 | 390 | # Cursive Clojure plugin 391 | .idea/replstate.xml 392 | 393 | # Crashlytics plugin (for Android Studio and IntelliJ) 394 | com_crashlytics_export_strings.xml 395 | crashlytics.properties 396 | crashlytics-build.properties 397 | fabric.properties 398 | 399 | # Editor-based Rest Client 400 | .idea/httpRequests 401 | 402 | # Android studio 3.1+ serialized cache file 403 | .idea/caches/build_file_checksums.ser 404 | 405 | ### PyCharm Patch ### 406 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 407 | 408 | # *.iml 409 | # modules.xml 410 | # .idea/misc.xml 411 | # *.ipr 412 | 413 | # Sonarlint plugin 414 | .idea/sonarlint 415 | 416 | ### PyCharm+all ### 417 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 418 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 419 | 420 | # User-specific stuff 421 | 422 | # Generated files 423 | 424 | # Sensitive or high-churn files 425 | 426 | # Gradle 427 | 428 | # Gradle and Maven with auto-import 429 | # When using Gradle or Maven with auto-import, you should exclude module files, 430 | # since they will be recreated, and may cause churn. Uncomment if using 431 | # auto-import. 432 | # .idea/modules.xml 433 | # .idea/*.iml 434 | # .idea/modules 435 | 436 | # CMake 437 | 438 | # Mongo Explorer plugin 439 | 440 | # File-based project format 441 | 442 | # IntelliJ 443 | 444 | # mpeltonen/sbt-idea plugin 445 | 446 | # JIRA plugin 447 | 448 | # Cursive Clojure plugin 449 | 450 | # Crashlytics plugin (for Android Studio and IntelliJ) 451 | 452 | # Editor-based Rest Client 453 | 454 | # Android studio 3.1+ serialized cache file 455 | 456 | ### PyCharm+all Patch ### 457 | # Ignores the whole .idea folder and all .iml files 458 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 459 | 460 | .idea/ 461 | 462 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 463 | 464 | *.iml 465 | modules.xml 466 | .idea/misc.xml 467 | *.ipr 468 | 469 | ### Python ### 470 | # Byte-compiled / optimized / DLL files 471 | 472 | # C extensions 473 | 474 | # Distribution / packaging 475 | 476 | # PyInstaller 477 | # Usually these files are written by a python script from a template 478 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 479 | 480 | # Installer logs 481 | 482 | # Unit test / coverage reports 483 | 484 | # Translations 485 | 486 | # Django stuff: 487 | 488 | # Flask stuff: 489 | 490 | # Scrapy stuff: 491 | 492 | # Sphinx documentation 493 | 494 | # PyBuilder 495 | 496 | # Jupyter Notebook 497 | 498 | # IPython 499 | 500 | # pyenv 501 | 502 | # celery beat schedule file 503 | 504 | # SageMath parsed files 505 | 506 | # Environments 507 | 508 | # Spyder project settings 509 | 510 | # Rope project settings 511 | 512 | # mkdocs documentation 513 | 514 | # mypy 515 | 516 | # Pyre type checker 517 | 518 | ### Python Patch ### 519 | .venv/ 520 | 521 | ### Python.VirtualEnv Stack ### 522 | # Virtualenv 523 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 524 | [Bb]in 525 | [Ii]nclude 526 | [Ll]ib 527 | [Ll]ib64 528 | [Ll]ocal 529 | [Ss]cripts 530 | pyvenv.cfg 531 | pip-selfcheck.json 532 | 533 | ### VisualStudioCode ### 534 | .vscode 535 | .vscode/* 536 | !.vscode/tasks.json 537 | !.vscode/launch.json 538 | !.vscode/extensions.json 539 | 540 | ### VisualStudioCode Patch ### 541 | # Ignore all local history of files 542 | .history 543 | 544 | ### Windows ### 545 | # Windows thumbnail cache files 546 | Thumbs.db 547 | ehthumbs.db 548 | ehthumbs_vista.db 549 | 550 | # Dump file 551 | *.stackdump 552 | 553 | # Folder config file 554 | [Dd]esktop.ini 555 | 556 | # Recycle Bin used on file shares 557 | $RECYCLE.BIN/ 558 | 559 | # Windows Installer files 560 | *.msix 561 | 562 | # Windows shortcuts 563 | *.lnk 564 | 565 | # End of https://www.gitignore.io/api/linux,macos,flask,python,angular,pycharm,windows,pycharm+all,jupyternotebook,visualstudiocode,archives,node 566 | 567 | **/service_accounts 568 | **/accounts 569 | **/service_account.json 570 | report.xml 571 | result.json 572 | config.yaml 573 | docs/*.pdf 574 | deploy.sh 575 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | ########################################################## 2 | #### WhiteSource Integration configuration file #### 3 | ########################################################## 4 | 5 | # Configuration # 6 | #---------------# 7 | ws.repo.scan=true 8 | vulnerable.check.run.conclusion.level=failure 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kieras 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 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | verify_ssl = true 3 | url = "https://pypi.org/simple" 4 | name = "pypi" 5 | 6 | [packages] 7 | google-api-python-client = "==2.37.0" 8 | google-cloud-bigquery = "==2.32.0" 9 | grpcio = "==1.43.0" 10 | oauth2client = "==3.0.0" 11 | confuse = "==1.7.0" 12 | slackclient = "==1.3.1" 13 | requests = "==2.27.1" 14 | functions-framework= "==3.*" 15 | 16 | [requires] 17 | python_version = "3.8" 18 | 19 | [dev-packages] 20 | pytest = "==7.0.0" 21 | pytest-mock = "==3.7.0" 22 | coverage = "*" 23 | pylint = "*" 24 | rope = "*" 25 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "c7b935b71671a249a06360da8133e2a41e0bbd4e6877e2b7c2d0bb0f34f5523b" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "cachetools": { 20 | "hashes": [ 21 | "sha256:486471dfa8799eb7ec503a8059e263db000cdda20075ce5e48903087f79d5fd6", 22 | "sha256:8fecd4203a38af17928be7b90689d8083603073622229ca7077b72d8e5a976e4" 23 | ], 24 | "version": "==5.0.0" 25 | }, 26 | "certifi": { 27 | "hashes": [ 28 | "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", 29 | "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" 30 | ], 31 | "version": "==2021.10.8" 32 | }, 33 | "charset-normalizer": { 34 | "hashes": [ 35 | "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45", 36 | "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c" 37 | ], 38 | "markers": "python_version >= '3'", 39 | "version": "==2.0.11" 40 | }, 41 | "click": { 42 | "hashes": [ 43 | "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3", 44 | "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b" 45 | ], 46 | "version": "==8.0.3" 47 | }, 48 | "cloudevents": { 49 | "hashes": [ 50 | "sha256:cddf15c5f29a80c254b1521170fae367c5828b0255c2ef84e3aa4c5f02e44c0f", 51 | "sha256:ec7b6c99aed64d16aa9cda8f66010f9d950f5fba6ea9fc8d0ab78139eee6ae3b" 52 | ], 53 | "version": "==1.2.0" 54 | }, 55 | "confuse": { 56 | "hashes": [ 57 | "sha256:c9fe8474516a62397f8e52fcf89547bb2f2737b1a4a6f6dec11a286f0b3a7401", 58 | "sha256:f002a733b3a4c16f73a094fcca3c80715b66e0ec08000983dc755267e2bd069f" 59 | ], 60 | "index": "pypi", 61 | "version": "==1.7.0" 62 | }, 63 | "deprecation": { 64 | "hashes": [ 65 | "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", 66 | "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a" 67 | ], 68 | "version": "==2.1.0" 69 | }, 70 | "flask": { 71 | "hashes": [ 72 | "sha256:7b2fb8e934ddd50731893bdcdb00fc8c0315916f9fcd50d22c7cc1a95ab634e2", 73 | "sha256:cb90f62f1d8e4dc4621f52106613488b5ba826b2e1e10a33eac92f723093ab6a" 74 | ], 75 | "version": "==2.0.2" 76 | }, 77 | "functions-framework": { 78 | "hashes": [ 79 | "sha256:422aa72c54c5e26076e34e074eadf3e713595a25b387ec51068aa7b9952a5047", 80 | "sha256:9c1fe2dd78e9ac7e87aae7f0f59a8c4bd2dec2b31010c2552c8c4dc061d52011" 81 | ], 82 | "index": "pypi", 83 | "version": "==3.0.0" 84 | }, 85 | "google-api-core": { 86 | "hashes": [ 87 | "sha256:7d030edbd3a0e994d796e62716022752684e863a6df9864b6ca82a1616c2a5a6", 88 | "sha256:f33863a6709651703b8b18b67093514838c79f2b04d02aa501203079f24b8018" 89 | ], 90 | "version": "==2.5.0" 91 | }, 92 | "google-api-python-client": { 93 | "hashes": [ 94 | "sha256:39bb945d00ce5f70207a312b32418c238f3ae16559e30c4ff10dac1e0ed69244", 95 | "sha256:8f9176e4f26c11b2561c43458da33d82425673d32298114e26d0e0a83c2011bc" 96 | ], 97 | "index": "pypi", 98 | "version": "==2.37.0" 99 | }, 100 | "google-auth": { 101 | "hashes": [ 102 | "sha256:218ca03d7744ca0c8b6697b6083334be7df49b7bf76a69d555962fd1a7657b5f", 103 | "sha256:ad160fc1ea8f19e331a16a14a79f3d643d813a69534ba9611d2c80dc10439dad" 104 | ], 105 | "version": "==2.6.0" 106 | }, 107 | "google-auth-httplib2": { 108 | "hashes": [ 109 | "sha256:31e49c36c6b5643b57e82617cb3e021e3e1d2df9da63af67252c02fa9c1f4a10", 110 | "sha256:a07c39fd632becacd3f07718dfd6021bf396978f03ad3ce4321d060015cc30ac" 111 | ], 112 | "version": "==0.1.0" 113 | }, 114 | "google-cloud-bigquery": { 115 | "hashes": [ 116 | "sha256:09588ffdf0ae92673d980cdade81a371ff1852c043bebd8bab68a47fd69d8a1f", 117 | "sha256:f3863ac4293f0a4585e44461d82b91f9239721ef33fc9575d401b4da7dc127bd" 118 | ], 119 | "index": "pypi", 120 | "version": "==2.32.0" 121 | }, 122 | "google-cloud-core": { 123 | "hashes": [ 124 | "sha256:7d19bf8868b410d0bdf5a03468a3f3f2db233c0ee86a023f4ecc2b7a4b15f736", 125 | "sha256:d9cffaf86df6a876438d4e8471183bbe404c9a15de9afe60433bc7dce8cb4252" 126 | ], 127 | "version": "==2.2.2" 128 | }, 129 | "google-crc32c": { 130 | "hashes": [ 131 | "sha256:04e7c220798a72fd0f08242bc8d7a05986b2a08a0573396187fd32c1dcdd58b3", 132 | "sha256:05340b60bf05b574159e9bd940152a47d38af3fb43803ffe71f11d704b7696a6", 133 | "sha256:12674a4c3b56b706153a358eaa1018c4137a5a04635b92b4652440d3d7386206", 134 | "sha256:127f9cc3ac41b6a859bd9dc4321097b1a4f6aa7fdf71b4f9227b9e3ebffb4422", 135 | "sha256:13af315c3a0eec8bb8b8d80b8b128cb3fcd17d7e4edafc39647846345a3f003a", 136 | "sha256:1926fd8de0acb9d15ee757175ce7242e235482a783cd4ec711cc999fc103c24e", 137 | "sha256:226f2f9b8e128a6ca6a9af9b9e8384f7b53a801907425c9a292553a3a7218ce0", 138 | "sha256:276de6273eb074a35bc598f8efbc00c7869c5cf2e29c90748fccc8c898c244df", 139 | "sha256:318f73f5484b5671f0c7f5f63741ab020a599504ed81d209b5c7129ee4667407", 140 | "sha256:3bbce1be3687bbfebe29abdb7631b83e6b25da3f4e1856a1611eb21854b689ea", 141 | "sha256:42ae4781333e331a1743445931b08ebdad73e188fd554259e772556fc4937c48", 142 | "sha256:58be56ae0529c664cc04a9c76e68bb92b091e0194d6e3c50bea7e0f266f73713", 143 | "sha256:5da2c81575cc3ccf05d9830f9e8d3c70954819ca9a63828210498c0774fda1a3", 144 | "sha256:6311853aa2bba4064d0c28ca54e7b50c4d48e3de04f6770f6c60ebda1e975267", 145 | "sha256:650e2917660e696041ab3dcd7abac160b4121cd9a484c08406f24c5964099829", 146 | "sha256:6a4db36f9721fdf391646685ecffa404eb986cbe007a3289499020daf72e88a2", 147 | "sha256:779cbf1ce375b96111db98fca913c1f5ec11b1d870e529b1dc7354b2681a8c3a", 148 | "sha256:7f6fe42536d9dcd3e2ffb9d3053f5d05221ae3bbcefbe472bdf2c71c793e3183", 149 | "sha256:891f712ce54e0d631370e1f4997b3f182f3368179198efc30d477c75d1f44942", 150 | "sha256:95c68a4b9b7828ba0428f8f7e3109c5d476ca44996ed9a5f8aac6269296e2d59", 151 | "sha256:96a8918a78d5d64e07c8ea4ed2bc44354e3f93f46a4866a40e8db934e4c0d74b", 152 | "sha256:9c3cf890c3c0ecfe1510a452a165431b5831e24160c5fcf2071f0f85ca5a47cd", 153 | "sha256:9f58099ad7affc0754ae42e6d87443299f15d739b0ce03c76f515153a5cda06c", 154 | "sha256:a0b9e622c3b2b8d0ce32f77eba617ab0d6768b82836391e4f8f9e2074582bf02", 155 | "sha256:a7f9cbea4245ee36190f85fe1814e2d7b1e5f2186381b082f5d59f99b7f11328", 156 | "sha256:bab4aebd525218bab4ee615786c4581952eadc16b1ff031813a2fd51f0cc7b08", 157 | "sha256:c124b8c8779bf2d35d9b721e52d4adb41c9bfbde45e6a3f25f0820caa9aba73f", 158 | "sha256:c9da0a39b53d2fab3e5467329ed50e951eb91386e9d0d5b12daf593973c3b168", 159 | "sha256:ca60076c388728d3b6ac3846842474f4250c91efbfe5afa872d3ffd69dd4b318", 160 | "sha256:cb6994fff247987c66a8a4e550ef374671c2b82e3c0d2115e689d21e511a652d", 161 | "sha256:d1c1d6236feab51200272d79b3d3e0f12cf2cbb12b208c835b175a21efdb0a73", 162 | "sha256:dd7760a88a8d3d705ff562aa93f8445ead54f58fd482e4f9e2bafb7e177375d4", 163 | "sha256:dda4d8a3bb0b50f540f6ff4b6033f3a74e8bf0bd5320b70fab2c03e512a62812", 164 | "sha256:e0f1ff55dde0ebcfbef027edc21f71c205845585fffe30d4ec4979416613e9b3", 165 | "sha256:e7a539b9be7b9c00f11ef16b55486141bc2cdb0c54762f84e3c6fc091917436d", 166 | "sha256:eb0b14523758e37802f27b7f8cd973f5f3d33be7613952c0df904b68c4842f0e", 167 | "sha256:ed447680ff21c14aaceb6a9f99a5f639f583ccfe4ce1a5e1d48eb41c3d6b3217", 168 | "sha256:f52a4ad2568314ee713715b1e2d79ab55fab11e8b304fd1462ff5cccf4264b3e", 169 | "sha256:fbd60c6aaa07c31d7754edbc2334aef50601b7f1ada67a96eb1eb57c7c72378f", 170 | "sha256:fc28e0db232c62ca0c3600884933178f0825c99be4474cdd645e378a10588125", 171 | "sha256:fe31de3002e7b08eb20823b3735b97c86c5926dd0581c7710a680b418a8709d4", 172 | "sha256:fec221a051150eeddfdfcff162e6db92c65ecf46cb0f7bb1bf812a1520ec026b", 173 | "sha256:ff71073ebf0e42258a42a0b34f2c09ec384977e7f6808999102eedd5b49920e3" 174 | ], 175 | "version": "==1.3.0" 176 | }, 177 | "google-resumable-media": { 178 | "hashes": [ 179 | "sha256:9142a7600eba233645eedad863f78173123f374e50e1bc0eaf4f358ce08aa757", 180 | "sha256:9f30ce7fcd13b8ef159b2cb9583d4a9bb99e7f7b85816b112059b032f2f084a0" 181 | ], 182 | "version": "==2.2.0" 183 | }, 184 | "googleapis-common-protos": { 185 | "hashes": [ 186 | "sha256:a4031d6ec6c2b1b6dc3e0be7e10a1bd72fb0b18b07ef9be7b51f2c1004ce2437", 187 | "sha256:e54345a2add15dc5e1a7891c27731ff347b4c33765d79b5ed7026a6c0c7cbcae" 188 | ], 189 | "version": "==1.54.0" 190 | }, 191 | "grpcio": { 192 | "hashes": [ 193 | "sha256:0110310eff07bb69782f53b7a947490268c4645de559034c43c0a635612e250f", 194 | "sha256:01f4b887ed703fe82ebe613e1d2dadea517891725e17e7a6134dcd00352bd28c", 195 | "sha256:04239e8f71db832c26bbbedb4537b37550a39d77681d748ab4678e58dd6455d6", 196 | "sha256:08cf25f2936629db062aeddbb594bd76b3383ab0ede75ef0461a3b0bc3a2c150", 197 | "sha256:0aa8285f284338eb68962fe1a830291db06f366ea12f213399b520c062b01f65", 198 | "sha256:0e731f660e1e68238f56f4ce11156f02fd06dc58bc7834778d42c0081d4ef5ad", 199 | "sha256:0edbfeb6729aa9da33ce7e28fb7703b3754934115454ae45e8cc1db601756fd3", 200 | "sha256:124e718faf96fe44c98b05f3f475076be8b5198bb4c52a13208acf88a8548ba9", 201 | "sha256:138f57e3445d4a48d9a8a5af1538fdaafaa50a0a3c243f281d8df0edf221dc02", 202 | "sha256:17b75f220ee6923338155b4fcef4c38802b9a57bc57d112c9599a13a03e99f8d", 203 | "sha256:1898f999383baac5fcdbdef8ea5b1ef204f38dc211014eb6977ac6e55944d738", 204 | "sha256:1f16725a320460435a8a5339d8b06c4e00d307ab5ad56746af2e22b5f9c50932", 205 | "sha256:2f96142d0abc91290a63ba203f01649e498302b1b6007c67bad17f823ecde0cf", 206 | "sha256:31e6e489ccd8f08884b9349a39610982df48535881ec34f05a11c6e6b6ebf9d0", 207 | "sha256:45401d00f2ee46bde75618bf33e9df960daa7980e6e0e7328047191918c98504", 208 | "sha256:47b6821238d8978014d23b1132713dac6c2d72cbb561cf257608b1673894f90a", 209 | "sha256:4b4a7152187a49767a47d1413edde2304c96f41f7bc92cc512e230dfd0fba095", 210 | "sha256:50cfb7e1067ee5e00b8ab100a6b7ea322d37ec6672c0455106520b5891c4b5f5", 211 | "sha256:5449ae564349e7a738b8c38583c0aad954b0d5d1dd3cea68953bfc32eaee11e3", 212 | "sha256:577e024c8dd5f27cd98ba850bc4e890f07d4b5942e5bc059a3d88843a2f48f66", 213 | "sha256:57f1aeb65ed17dfb2f6cd717cc109910fe395133af7257a9c729c0b9604eac10", 214 | "sha256:594aaa0469f4fca7773e80d8c27bf1298e7bbce5f6da0f084b07489a708f16ab", 215 | "sha256:6620a5b751b099b3b25553cfc03dfcd873cda06f9bb2ff7e9948ac7090e20f05", 216 | "sha256:6e463b4aa0a6b31cf2e57c4abc1a1b53531a18a570baeed39d8d7b65deb16b7e", 217 | "sha256:735d9a437c262ab039d02defddcb9f8f545d7009ae61c0114e19dda3843febe5", 218 | "sha256:772b943f34374744f70236bbbe0afe413ed80f9ae6303503f85e2b421d4bca92", 219 | "sha256:77ef653f966934b3bfdd00e4f2064b68880eb40cf09b0b99edfa5ee22a44f559", 220 | "sha256:80398e9fb598060fa41050d1220f5a2440fe74ff082c36dda41ac3215ebb5ddd", 221 | "sha256:8b2b9dc4d7897566723b77422e11c009a0ebd397966b165b21b89a62891a9fdf", 222 | "sha256:a4b4543e13acb4806917d883d0f70f21ba93b29672ea81f4aaba14821aaf9bb0", 223 | "sha256:a4e786a8ee8b30b25d70ee52cda6d1dbba2a8ca2f1208d8e20ed8280774f15c8", 224 | "sha256:ade8b79a6b6aea68adb9d4bfeba5d647667d842202c5d8f3ba37ac1dc8e5c09c", 225 | "sha256:af78ac55933811e6a25141336b1f2d5e0659c2f568d44d20539b273792563ca7", 226 | "sha256:af9c3742f6c13575c0d4147a8454da0ff5308c4d9469462ff18402c6416942fe", 227 | "sha256:b8cc936a29c65ab39714e1ba67a694c41218f98b6e2a64efb83f04d9abc4386b", 228 | "sha256:bdf41550815a831384d21a498b20597417fd31bd084deb17d31ceb39ad9acc79", 229 | "sha256:c354017819201053d65212befd1dcb65c2d91b704d8977e696bae79c47cd2f82", 230 | "sha256:c36f418c925a41fccada8f7ae9a3d3e227bfa837ddbfddd3d8b0ac252d12dda9", 231 | "sha256:cbc9b83211d905859dcf234ad39d7193ff0f05bfc3269c364fb0d114ee71de59", 232 | "sha256:e95b5d62ec26d0cd0b90c202d73e7cb927c369c3358e027225239a4e354967dc", 233 | "sha256:f11d05402e0ac3a284443d8a432d3dfc76a6bd3f7b5858cddd75617af2d7bd9b", 234 | "sha256:fa26a8bbb3fe57845acb1329ff700d5c7eaf06414c3e15f4cb8923f3a466ef64", 235 | "sha256:fb7229fa2a201a0c377ff3283174ec966da8f9fd7ffcc9a92f162d2e7fc9025b", 236 | "sha256:fdac966699707b5554b815acc272d81e619dd0999f187cd52a61aef075f870ee" 237 | ], 238 | "index": "pypi", 239 | "version": "==1.43.0" 240 | }, 241 | "grpcio-status": { 242 | "hashes": [ 243 | "sha256:21759006f36a7ffbff187d4191f4118c072d8aa9fa6823a11aad7842a3c6ccd0", 244 | "sha256:9036b24f5769adafdc3e91d9434c20e9ede0b30f50cc6bff105c0f414bb9e0e0" 245 | ], 246 | "version": "==1.43.0" 247 | }, 248 | "gunicorn": { 249 | "hashes": [ 250 | "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", 251 | "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8" 252 | ], 253 | "markers": "platform_system != 'Windows'", 254 | "version": "==20.1.0" 255 | }, 256 | "httplib2": { 257 | "hashes": [ 258 | "sha256:58a98e45b4b1a48273073f905d2961666ecf0fbac4250ea5b47aef259eb5c585", 259 | "sha256:8b6a905cb1c79eefd03f8669fd993c36dc341f7c558f056cb5a33b5c2f458543" 260 | ], 261 | "version": "==0.20.4" 262 | }, 263 | "idna": { 264 | "hashes": [ 265 | "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", 266 | "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" 267 | ], 268 | "markers": "python_version >= '3'", 269 | "version": "==3.3" 270 | }, 271 | "itsdangerous": { 272 | "hashes": [ 273 | "sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c", 274 | "sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0" 275 | ], 276 | "version": "==2.0.1" 277 | }, 278 | "jinja2": { 279 | "hashes": [ 280 | "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8", 281 | "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7" 282 | ], 283 | "version": "==3.0.3" 284 | }, 285 | "markupsafe": { 286 | "hashes": [ 287 | "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", 288 | "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", 289 | "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", 290 | "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", 291 | "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", 292 | "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", 293 | "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", 294 | "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", 295 | "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", 296 | "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", 297 | "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", 298 | "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", 299 | "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", 300 | "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", 301 | "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", 302 | "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", 303 | "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", 304 | "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", 305 | "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", 306 | "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", 307 | "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", 308 | "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", 309 | "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", 310 | "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", 311 | "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", 312 | "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", 313 | "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", 314 | "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", 315 | "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", 316 | "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", 317 | "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", 318 | "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", 319 | "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", 320 | "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", 321 | "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", 322 | "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", 323 | "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", 324 | "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", 325 | "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", 326 | "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", 327 | "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", 328 | "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", 329 | "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", 330 | "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", 331 | "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", 332 | "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", 333 | "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", 334 | "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", 335 | "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", 336 | "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", 337 | "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", 338 | "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", 339 | "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", 340 | "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", 341 | "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", 342 | "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", 343 | "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", 344 | "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", 345 | "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", 346 | "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", 347 | "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", 348 | "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", 349 | "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", 350 | "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", 351 | "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", 352 | "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", 353 | "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", 354 | "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", 355 | "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" 356 | ], 357 | "version": "==2.0.1" 358 | }, 359 | "oauth2client": { 360 | "hashes": [ 361 | "sha256:5b5b056ec6f2304e7920b632885bd157fa71d1a7f3ddd00a43b1541a8d1a2460" 362 | ], 363 | "index": "pypi", 364 | "version": "==3.0.0" 365 | }, 366 | "packaging": { 367 | "hashes": [ 368 | "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", 369 | "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" 370 | ], 371 | "version": "==21.3" 372 | }, 373 | "proto-plus": { 374 | "hashes": [ 375 | "sha256:b98bad701a0d6c511af544a4271295bb499dd325a530d61f4bca9c1f1f06fbbb", 376 | "sha256:d98d61c9a8d50aa5253934f8b153535414eb75cd40d9724247395256d41d9cc7" 377 | ], 378 | "version": "==1.20.0" 379 | }, 380 | "protobuf": { 381 | "hashes": [ 382 | "sha256:072fbc78d705d3edc7ccac58a62c4c8e0cec856987da7df8aca86e647be4e35c", 383 | "sha256:09297b7972da685ce269ec52af761743714996b4381c085205914c41fcab59fb", 384 | "sha256:16f519de1313f1b7139ad70772e7db515b1420d208cb16c6d7858ea989fc64a9", 385 | "sha256:1c91ef4110fdd2c590effb5dca8fdbdcb3bf563eece99287019c4204f53d81a4", 386 | "sha256:3112b58aac3bac9c8be2b60a9daf6b558ca3f7681c130dcdd788ade7c9ffbdca", 387 | "sha256:36cecbabbda242915529b8ff364f2263cd4de7c46bbe361418b5ed859677ba58", 388 | "sha256:4276cdec4447bd5015453e41bdc0c0c1234eda08420b7c9a18b8d647add51e4b", 389 | "sha256:435bb78b37fc386f9275a7035fe4fb1364484e38980d0dd91bc834a02c5ec909", 390 | "sha256:48ed3877fa43e22bcacc852ca76d4775741f9709dd9575881a373bd3e85e54b2", 391 | "sha256:54a1473077f3b616779ce31f477351a45b4fef8c9fd7892d6d87e287a38df368", 392 | "sha256:69da7d39e39942bd52848438462674c463e23963a1fdaa84d88df7fbd7e749b2", 393 | "sha256:6cbc312be5e71869d9d5ea25147cdf652a6781cf4d906497ca7690b7b9b5df13", 394 | "sha256:7bb03bc2873a2842e5ebb4801f5c7ff1bfbdf426f85d0172f7644fcda0671ae0", 395 | "sha256:7ca7da9c339ca8890d66958f5462beabd611eca6c958691a8fe6eccbd1eb0c6e", 396 | "sha256:835a9c949dc193953c319603b2961c5c8f4327957fe23d914ca80d982665e8ee", 397 | "sha256:84123274d982b9e248a143dadd1b9815049f4477dc783bf84efe6250eb4b836a", 398 | "sha256:8961c3a78ebfcd000920c9060a262f082f29838682b1f7201889300c1fbe0616", 399 | "sha256:96bd766831596d6014ca88d86dc8fe0fb2e428c0b02432fd9db3943202bf8c5e", 400 | "sha256:9df0c10adf3e83015ced42a9a7bd64e13d06c4cf45c340d2c63020ea04499d0a", 401 | "sha256:b38057450a0c566cbd04890a40edf916db890f2818e8682221611d78dc32ae26", 402 | "sha256:bd95d1dfb9c4f4563e6093a9aa19d9c186bf98fa54da5252531cc0d3a07977e7", 403 | "sha256:c1068287025f8ea025103e37d62ffd63fec8e9e636246b89c341aeda8a67c934", 404 | "sha256:c438268eebb8cf039552897d78f402d734a404f1360592fef55297285f7f953f", 405 | "sha256:cdc076c03381f5c1d9bb1abdcc5503d9ca8b53cf0a9d31a9f6754ec9e6c8af0f", 406 | "sha256:f358aa33e03b7a84e0d91270a4d4d8f5df6921abe99a377828839e8ed0c04e07", 407 | "sha256:f51d5a9f137f7a2cec2d326a74b6e3fc79d635d69ffe1b036d39fc7d75430d37" 408 | ], 409 | "version": "==3.19.4" 410 | }, 411 | "pyasn1": { 412 | "hashes": [ 413 | "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", 414 | "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", 415 | "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", 416 | "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", 417 | "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", 418 | "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", 419 | "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", 420 | "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", 421 | "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", 422 | "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", 423 | "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", 424 | "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", 425 | "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" 426 | ], 427 | "version": "==0.4.8" 428 | }, 429 | "pyasn1-modules": { 430 | "hashes": [ 431 | "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", 432 | "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", 433 | "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", 434 | "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", 435 | "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", 436 | "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", 437 | "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", 438 | "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", 439 | "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", 440 | "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", 441 | "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", 442 | "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", 443 | "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" 444 | ], 445 | "version": "==0.2.8" 446 | }, 447 | "pyparsing": { 448 | "hashes": [ 449 | "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea", 450 | "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484" 451 | ], 452 | "version": "==3.0.7" 453 | }, 454 | "python-dateutil": { 455 | "hashes": [ 456 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 457 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 458 | ], 459 | "version": "==2.8.2" 460 | }, 461 | "pyyaml": { 462 | "hashes": [ 463 | "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", 464 | "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", 465 | "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", 466 | "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", 467 | "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", 468 | "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", 469 | "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", 470 | "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", 471 | "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", 472 | "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", 473 | "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", 474 | "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", 475 | "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", 476 | "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", 477 | "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", 478 | "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", 479 | "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", 480 | "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", 481 | "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", 482 | "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", 483 | "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", 484 | "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", 485 | "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", 486 | "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", 487 | "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", 488 | "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", 489 | "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", 490 | "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", 491 | "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", 492 | "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", 493 | "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", 494 | "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", 495 | "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" 496 | ], 497 | "version": "==6.0" 498 | }, 499 | "requests": { 500 | "hashes": [ 501 | "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61", 502 | "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d" 503 | ], 504 | "index": "pypi", 505 | "version": "==2.27.1" 506 | }, 507 | "rsa": { 508 | "hashes": [ 509 | "sha256:5c6bd9dc7a543b7fe4304a631f8a8a3b674e2bbfc49c2ae96200cdbe55df6b17", 510 | "sha256:95c5d300c4e879ee69708c428ba566c59478fd653cc3a22243eeb8ed846950bb" 511 | ], 512 | "markers": "python_version >= '3.6'", 513 | "version": "==4.8" 514 | }, 515 | "six": { 516 | "hashes": [ 517 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 518 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 519 | ], 520 | "version": "==1.16.0" 521 | }, 522 | "slackclient": { 523 | "hashes": [ 524 | "sha256:ac99b6ad8de027b5f6bdee4b1545d6b95ea1cca9a19ca0e5f3fda51b34d3f9ab" 525 | ], 526 | "index": "pypi", 527 | "version": "==1.3.1" 528 | }, 529 | "uritemplate": { 530 | "hashes": [ 531 | "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0", 532 | "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e" 533 | ], 534 | "version": "==4.1.1" 535 | }, 536 | "urllib3": { 537 | "hashes": [ 538 | "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed", 539 | "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c" 540 | ], 541 | "version": "==1.26.8" 542 | }, 543 | "watchdog": { 544 | "hashes": [ 545 | "sha256:016b01495b9c55b5d4126ed8ae75d93ea0d99377084107c33162df52887cee18", 546 | "sha256:101532b8db506559e52a9b5d75a308729b3f68264d930670e6155c976d0e52a0", 547 | "sha256:27d9b4666938d5d40afdcdf2c751781e9ce36320788b70208d0f87f7401caf93", 548 | "sha256:2f1ade0d0802503fda4340374d333408831cff23da66d7e711e279ba50fe6c4a", 549 | "sha256:376cbc2a35c0392b0fe7ff16fbc1b303fd99d4dd9911ab5581ee9d69adc88982", 550 | "sha256:57f05e55aa603c3b053eed7e679f0a83873c540255b88d58c6223c7493833bac", 551 | "sha256:5f1f3b65142175366ba94c64d8d4c8f4015825e0beaacee1c301823266b47b9b", 552 | "sha256:602dbd9498592eacc42e0632c19781c3df1728ef9cbab555fab6778effc29eeb", 553 | "sha256:68744de2003a5ea2dfbb104f9a74192cf381334a9e2c0ed2bbe1581828d50b61", 554 | "sha256:85e6574395aa6c1e14e0f030d9d7f35c2340a6cf95d5671354ce876ac3ffdd4d", 555 | "sha256:b1d723852ce90a14abf0ec0ca9e80689d9509ee4c9ee27163118d87b564a12ac", 556 | "sha256:d948ad9ab9aba705f9836625b32e965b9ae607284811cd98334423f659ea537a", 557 | "sha256:e2a531e71be7b5cc3499ae2d1494d51b6a26684bcc7c3146f63c810c00e8a3cc", 558 | "sha256:e7c73edef48f4ceeebb987317a67e0080e5c9228601ff67b3c4062fa020403c7", 559 | "sha256:ee21aeebe6b3e51e4ba64564c94cee8dbe7438b9cb60f0bb350c4fa70d1b52c2", 560 | "sha256:f1d0e878fd69129d0d68b87cee5d9543f20d8018e82998efb79f7e412d42154a", 561 | "sha256:f84146f7864339c8addf2c2b9903271df21d18d2c721e9a77f779493234a82b5" 562 | ], 563 | "version": "==1.0.2" 564 | }, 565 | "websocket-client": { 566 | "hashes": [ 567 | "sha256:8c8bf2d4f800c3ed952df206b18c28f7070d9e3dcbd6ca6291127574f57ee786", 568 | "sha256:e51562c91ddb8148e791f0155fdb01325d99bb52c4cdbb291aee7a3563fd0849" 569 | ], 570 | "version": "==0.54.0" 571 | }, 572 | "werkzeug": { 573 | "hashes": [ 574 | "sha256:1421ebfc7648a39a5c58c601b154165d05cf47a3cd0ccb70857cbdacf6c8f2b8", 575 | "sha256:b863f8ff057c522164b6067c9e28b041161b4be5ba4d0daceeaa50a163822d3c" 576 | ], 577 | "version": "==2.0.3" 578 | } 579 | }, 580 | "develop": { 581 | "astroid": { 582 | "hashes": [ 583 | "sha256:1efdf4e867d4d8ba4a9f6cf9ce07cd182c4c41de77f23814feb27ca93ca9d877", 584 | "sha256:506daabe5edffb7e696ad82483ad0228245a9742ed7d2d8c9cdb31537decf9f6" 585 | ], 586 | "version": "==2.9.3" 587 | }, 588 | "attrs": { 589 | "hashes": [ 590 | "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4", 591 | "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd" 592 | ], 593 | "version": "==21.4.0" 594 | }, 595 | "coverage": { 596 | "hashes": [ 597 | "sha256:1245ab82e8554fa88c4b2ab1e098ae051faac5af829efdcf2ce6b34dccd5567c", 598 | "sha256:1bc6d709939ff262fd1432f03f080c5042dc6508b6e0d3d20e61dd045456a1a0", 599 | "sha256:25e73d4c81efa8ea3785274a2f7f3bfbbeccb6fcba2a0bdd3be9223371c37554", 600 | "sha256:276b13cc085474e482566c477c25ed66a097b44c6e77132f3304ac0b039f83eb", 601 | "sha256:2aed4761809640f02e44e16b8b32c1a5dee5e80ea30a0ff0912158bde9c501f2", 602 | "sha256:2dd70a167843b4b4b2630c0c56f1b586fe965b4f8ac5da05b6690344fd065c6b", 603 | "sha256:352c68e233409c31048a3725c446a9e48bbff36e39db92774d4f2380d630d8f8", 604 | "sha256:3f2b05757c92ad96b33dbf8e8ec8d4ccb9af6ae3c9e9bd141c7cc44d20c6bcba", 605 | "sha256:448d7bde7ceb6c69e08474c2ddbc5b4cd13c9e4aa4a717467f716b5fc938a734", 606 | "sha256:463e52616ea687fd323888e86bf25e864a3cc6335a043fad6bbb037dbf49bbe2", 607 | "sha256:482fb42eea6164894ff82abbcf33d526362de5d1a7ed25af7ecbdddd28fc124f", 608 | "sha256:56c4a409381ddd7bbff134e9756077860d4e8a583d310a6f38a2315b9ce301d0", 609 | "sha256:56d296cbc8254a7dffdd7bcc2eb70be5a233aae7c01856d2d936f5ac4e8ac1f1", 610 | "sha256:5e15d424b8153756b7c903bde6d4610be0c3daca3986173c18dd5c1a1625e4cd", 611 | "sha256:618eeba986cea7f621d8607ee378ecc8c2504b98b3fdc4952b30fe3578304687", 612 | "sha256:61d47a897c1e91f33f177c21de897267b38fbb45f2cd8e22a710bcef1df09ac1", 613 | "sha256:621f6ea7260ea2ffdaec64fe5cb521669984f567b66f62f81445221d4754df4c", 614 | "sha256:6a5cdc3adb4f8bb8d8f5e64c2e9e282bc12980ef055ec6da59db562ee9bdfefa", 615 | "sha256:6c3f6158b02ac403868eea390930ae64e9a9a2a5bbfafefbb920d29258d9f2f8", 616 | "sha256:704f89b87c4f4737da2860695a18c852b78ec7279b24eedacab10b29067d3a38", 617 | "sha256:72128176fea72012063200b7b395ed8a57849282b207321124d7ff14e26988e8", 618 | "sha256:78fbb2be068a13a5d99dce9e1e7d168db880870f7bc73f876152130575bd6167", 619 | "sha256:7bff3a98f63b47464480de1b5bdd80c8fade0ba2832c9381253c9b74c4153c27", 620 | "sha256:84f2436d6742c01136dd940ee158bfc7cf5ced3da7e4c949662b8703b5cd8145", 621 | "sha256:9976fb0a5709988778ac9bc44f3d50fccd989987876dfd7716dee28beed0a9fa", 622 | "sha256:9ad0a117b8dc2061ce9461ea4c1b4799e55edceb236522c5b8f958ce9ed8fa9a", 623 | "sha256:9e3dd806f34de38d4c01416344e98eab2437ac450b3ae39c62a0ede2f8b5e4ed", 624 | "sha256:9eb494070aa060ceba6e4bbf44c1bc5fa97bfb883a0d9b0c9049415f9e944793", 625 | "sha256:9fde6b90889522c220dd56a670102ceef24955d994ff7af2cb786b4ba8fe11e4", 626 | "sha256:9fff3ff052922cb99f9e52f63f985d4f7a54f6b94287463bc66b7cdf3eb41217", 627 | "sha256:a06c358f4aed05fa1099c39decc8022261bb07dfadc127c08cfbd1391b09689e", 628 | "sha256:a4f923b9ab265136e57cc14794a15b9dcea07a9c578609cd5dbbfff28a0d15e6", 629 | "sha256:c5b81fb37db76ebea79aa963b76d96ff854e7662921ce742293463635a87a78d", 630 | "sha256:d5ed164af5c9078596cfc40b078c3b337911190d3faeac830c3f1274f26b8320", 631 | "sha256:d651fde74a4d3122e5562705824507e2f5b2d3d57557f1916c4b27635f8fbe3f", 632 | "sha256:de73fca6fb403dd72d4da517cfc49fcf791f74eee697d3219f6be29adf5af6ce", 633 | "sha256:e647a0be741edbb529a72644e999acb09f2ad60465f80757da183528941ff975", 634 | "sha256:e92c7a5f7d62edff50f60a045dc9542bf939758c95b2fcd686175dd10ce0ed10", 635 | "sha256:eeffd96882d8c06d31b65dddcf51db7c612547babc1c4c5db6a011abe9798525", 636 | "sha256:f5a4551dfd09c3bd12fca8144d47fe7745275adf3229b7223c2f9e29a975ebda", 637 | "sha256:fac0bcc5b7e8169bffa87f0dcc24435446d329cbc2b5486d155c2e0f3b493ae1" 638 | ], 639 | "index": "pypi", 640 | "version": "==6.3.1" 641 | }, 642 | "iniconfig": { 643 | "hashes": [ 644 | "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", 645 | "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" 646 | ], 647 | "version": "==1.1.1" 648 | }, 649 | "isort": { 650 | "hashes": [ 651 | "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7", 652 | "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951" 653 | ], 654 | "version": "==5.10.1" 655 | }, 656 | "lazy-object-proxy": { 657 | "hashes": [ 658 | "sha256:043651b6cb706eee4f91854da4a089816a6606c1428fd391573ef8cb642ae4f7", 659 | "sha256:07fa44286cda977bd4803b656ffc1c9b7e3bc7dff7d34263446aec8f8c96f88a", 660 | "sha256:12f3bb77efe1367b2515f8cb4790a11cffae889148ad33adad07b9b55e0ab22c", 661 | "sha256:2052837718516a94940867e16b1bb10edb069ab475c3ad84fd1e1a6dd2c0fcfc", 662 | "sha256:2130db8ed69a48a3440103d4a520b89d8a9405f1b06e2cc81640509e8bf6548f", 663 | "sha256:39b0e26725c5023757fc1ab2a89ef9d7ab23b84f9251e28f9cc114d5b59c1b09", 664 | "sha256:46ff647e76f106bb444b4533bb4153c7370cdf52efc62ccfc1a28bdb3cc95442", 665 | "sha256:4dca6244e4121c74cc20542c2ca39e5c4a5027c81d112bfb893cf0790f96f57e", 666 | "sha256:553b0f0d8dbf21890dd66edd771f9b1b5f51bd912fa5f26de4449bfc5af5e029", 667 | "sha256:677ea950bef409b47e51e733283544ac3d660b709cfce7b187f5ace137960d61", 668 | "sha256:6a24357267aa976abab660b1d47a34aaf07259a0c3859a34e536f1ee6e76b5bb", 669 | "sha256:6a6e94c7b02641d1311228a102607ecd576f70734dc3d5e22610111aeacba8a0", 670 | "sha256:6aff3fe5de0831867092e017cf67e2750c6a1c7d88d84d2481bd84a2e019ec35", 671 | "sha256:6ecbb350991d6434e1388bee761ece3260e5228952b1f0c46ffc800eb313ff42", 672 | "sha256:7096a5e0c1115ec82641afbdd70451a144558ea5cf564a896294e346eb611be1", 673 | "sha256:70ed0c2b380eb6248abdef3cd425fc52f0abd92d2b07ce26359fcbc399f636ad", 674 | "sha256:8561da8b3dd22d696244d6d0d5330618c993a215070f473b699e00cf1f3f6443", 675 | "sha256:85b232e791f2229a4f55840ed54706110c80c0a210d076eee093f2b2e33e1bfd", 676 | "sha256:898322f8d078f2654d275124a8dd19b079080ae977033b713f677afcfc88e2b9", 677 | "sha256:8f3953eb575b45480db6568306893f0bd9d8dfeeebd46812aa09ca9579595148", 678 | "sha256:91ba172fc5b03978764d1df5144b4ba4ab13290d7bab7a50f12d8117f8630c38", 679 | "sha256:9d166602b525bf54ac994cf833c385bfcc341b364e3ee71e3bf5a1336e677b55", 680 | "sha256:a57d51ed2997e97f3b8e3500c984db50a554bb5db56c50b5dab1b41339b37e36", 681 | "sha256:b9e89b87c707dd769c4ea91f7a31538888aad05c116a59820f28d59b3ebfe25a", 682 | "sha256:bb8c5fd1684d60a9902c60ebe276da1f2281a318ca16c1d0a96db28f62e9166b", 683 | "sha256:c19814163728941bb871240d45c4c30d33b8a2e85972c44d4e63dd7107faba44", 684 | "sha256:c4ce15276a1a14549d7e81c243b887293904ad2d94ad767f42df91e75fd7b5b6", 685 | "sha256:c7a683c37a8a24f6428c28c561c80d5f4fd316ddcf0c7cab999b15ab3f5c5c69", 686 | "sha256:d609c75b986def706743cdebe5e47553f4a5a1da9c5ff66d76013ef396b5a8a4", 687 | "sha256:d66906d5785da8e0be7360912e99c9188b70f52c422f9fc18223347235691a84", 688 | "sha256:dd7ed7429dbb6c494aa9bc4e09d94b778a3579be699f9d67da7e6804c422d3de", 689 | "sha256:df2631f9d67259dc9620d831384ed7732a198eb434eadf69aea95ad18c587a28", 690 | "sha256:e368b7f7eac182a59ff1f81d5f3802161932a41dc1b1cc45c1f757dc876b5d2c", 691 | "sha256:e40f2013d96d30217a51eeb1db28c9ac41e9d0ee915ef9d00da639c5b63f01a1", 692 | "sha256:f769457a639403073968d118bc70110e7dce294688009f5c24ab78800ae56dc8", 693 | "sha256:fccdf7c2c5821a8cbd0a9440a456f5050492f2270bd54e94360cac663398739b", 694 | "sha256:fd45683c3caddf83abbb1249b653a266e7069a09f486daa8863fb0e7496a9fdb" 695 | ], 696 | "version": "==1.7.1" 697 | }, 698 | "mccabe": { 699 | "hashes": [ 700 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 701 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 702 | ], 703 | "version": "==0.6.1" 704 | }, 705 | "packaging": { 706 | "hashes": [ 707 | "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", 708 | "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" 709 | ], 710 | "version": "==21.3" 711 | }, 712 | "platformdirs": { 713 | "hashes": [ 714 | "sha256:30671902352e97b1eafd74ade8e4a694782bd3471685e78c32d0fdfd3aa7e7bb", 715 | "sha256:8ec11dfba28ecc0715eb5fb0147a87b1bf325f349f3da9aab2cd6b50b96b692b" 716 | ], 717 | "version": "==2.5.0" 718 | }, 719 | "pluggy": { 720 | "hashes": [ 721 | "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", 722 | "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" 723 | ], 724 | "version": "==1.0.0" 725 | }, 726 | "py": { 727 | "hashes": [ 728 | "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", 729 | "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378" 730 | ], 731 | "version": "==1.11.0" 732 | }, 733 | "pylint": { 734 | "hashes": [ 735 | "sha256:9d945a73640e1fec07ee34b42f5669b770c759acd536ec7b16d7e4b87a9c9ff9", 736 | "sha256:daabda3f7ed9d1c60f52d563b1b854632fd90035bcf01443e234d3dc794e3b74" 737 | ], 738 | "index": "pypi", 739 | "version": "==2.12.2" 740 | }, 741 | "pyparsing": { 742 | "hashes": [ 743 | "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea", 744 | "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484" 745 | ], 746 | "version": "==3.0.7" 747 | }, 748 | "pytest": { 749 | "hashes": [ 750 | "sha256:42901e6bd4bd4a0e533358a86e848427a49005a3256f657c5c8f8dd35ef137a9", 751 | "sha256:dad48ffda394e5ad9aa3b7d7ddf339ed502e5e365b1350e0af65f4a602344b11" 752 | ], 753 | "index": "pypi", 754 | "version": "==7.0.0" 755 | }, 756 | "pytest-mock": { 757 | "hashes": [ 758 | "sha256:5112bd92cc9f186ee96e1a92efc84969ea494939c3aead39c50f421c4cc69534", 759 | "sha256:6cff27cec936bf81dc5ee87f07132b807bcda51106b5ec4b90a04331cba76231" 760 | ], 761 | "index": "pypi", 762 | "version": "==3.7.0" 763 | }, 764 | "rope": { 765 | "hashes": [ 766 | "sha256:2847220bf72ead09b5abe72b1edc9cacff90ab93663ece06913fc97324167870", 767 | "sha256:b00fbc064a26fc62d7220578a27fd639b2fad57213663cc396c137e92d73f10f" 768 | ], 769 | "index": "pypi", 770 | "version": "==0.22.0" 771 | }, 772 | "toml": { 773 | "hashes": [ 774 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 775 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 776 | ], 777 | "version": "==0.10.2" 778 | }, 779 | "tomli": { 780 | "hashes": [ 781 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 782 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 783 | ], 784 | "version": "==2.0.1" 785 | }, 786 | "typing-extensions": { 787 | "hashes": [ 788 | "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e", 789 | "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b" 790 | ], 791 | "markers": "python_version < '3.10'", 792 | "version": "==4.0.1" 793 | }, 794 | "wrapt": { 795 | "hashes": [ 796 | "sha256:086218a72ec7d986a3eddb7707c8c4526d677c7b35e355875a0fe2918b059179", 797 | "sha256:0877fe981fd76b183711d767500e6b3111378ed2043c145e21816ee589d91096", 798 | "sha256:0a017a667d1f7411816e4bf214646d0ad5b1da2c1ea13dec6c162736ff25a374", 799 | "sha256:0cb23d36ed03bf46b894cfec777eec754146d68429c30431c99ef28482b5c1df", 800 | "sha256:1fea9cd438686e6682271d36f3481a9f3636195578bab9ca3382e2f5f01fc185", 801 | "sha256:220a869982ea9023e163ba915077816ca439489de6d2c09089b219f4e11b6785", 802 | "sha256:25b1b1d5df495d82be1c9d2fad408f7ce5ca8a38085e2da41bb63c914baadff7", 803 | "sha256:2dded5496e8f1592ec27079b28b6ad2a1ef0b9296d270f77b8e4a3a796cf6909", 804 | "sha256:2ebdde19cd3c8cdf8df3fc165bc7827334bc4e353465048b36f7deeae8ee0918", 805 | "sha256:43e69ffe47e3609a6aec0fe723001c60c65305784d964f5007d5b4fb1bc6bf33", 806 | "sha256:46f7f3af321a573fc0c3586612db4decb7eb37172af1bc6173d81f5b66c2e068", 807 | "sha256:47f0a183743e7f71f29e4e21574ad3fa95676136f45b91afcf83f6a050914829", 808 | "sha256:498e6217523111d07cd67e87a791f5e9ee769f9241fcf8a379696e25806965af", 809 | "sha256:4b9c458732450ec42578b5642ac53e312092acf8c0bfce140ada5ca1ac556f79", 810 | "sha256:51799ca950cfee9396a87f4a1240622ac38973b6df5ef7a41e7f0b98797099ce", 811 | "sha256:5601f44a0f38fed36cc07db004f0eedeaadbdcec90e4e90509480e7e6060a5bc", 812 | "sha256:5f223101f21cfd41deec8ce3889dc59f88a59b409db028c469c9b20cfeefbe36", 813 | "sha256:610f5f83dd1e0ad40254c306f4764fcdc846641f120c3cf424ff57a19d5f7ade", 814 | "sha256:6a03d9917aee887690aa3f1747ce634e610f6db6f6b332b35c2dd89412912bca", 815 | "sha256:705e2af1f7be4707e49ced9153f8d72131090e52be9278b5dbb1498c749a1e32", 816 | "sha256:766b32c762e07e26f50d8a3468e3b4228b3736c805018e4b0ec8cc01ecd88125", 817 | "sha256:77416e6b17926d953b5c666a3cb718d5945df63ecf922af0ee576206d7033b5e", 818 | "sha256:778fd096ee96890c10ce96187c76b3e99b2da44e08c9e24d5652f356873f6709", 819 | "sha256:78dea98c81915bbf510eb6a3c9c24915e4660302937b9ae05a0947164248020f", 820 | "sha256:7dd215e4e8514004c8d810a73e342c536547038fb130205ec4bba9f5de35d45b", 821 | "sha256:7dde79d007cd6dfa65afe404766057c2409316135cb892be4b1c768e3f3a11cb", 822 | "sha256:81bd7c90d28a4b2e1df135bfbd7c23aee3050078ca6441bead44c42483f9ebfb", 823 | "sha256:85148f4225287b6a0665eef08a178c15097366d46b210574a658c1ff5b377489", 824 | "sha256:865c0b50003616f05858b22174c40ffc27a38e67359fa1495605f96125f76640", 825 | "sha256:87883690cae293541e08ba2da22cacaae0a092e0ed56bbba8d018cc486fbafbb", 826 | "sha256:8aab36778fa9bba1a8f06a4919556f9f8c7b33102bd71b3ab307bb3fecb21851", 827 | "sha256:8c73c1a2ec7c98d7eaded149f6d225a692caa1bd7b2401a14125446e9e90410d", 828 | "sha256:936503cb0a6ed28dbfa87e8fcd0a56458822144e9d11a49ccee6d9a8adb2ac44", 829 | "sha256:944b180f61f5e36c0634d3202ba8509b986b5fbaf57db3e94df11abee244ba13", 830 | "sha256:96b81ae75591a795d8c90edc0bfaab44d3d41ffc1aae4d994c5aa21d9b8e19a2", 831 | "sha256:981da26722bebb9247a0601e2922cedf8bb7a600e89c852d063313102de6f2cb", 832 | "sha256:ae9de71eb60940e58207f8e71fe113c639da42adb02fb2bcbcaccc1ccecd092b", 833 | "sha256:b73d4b78807bd299b38e4598b8e7bd34ed55d480160d2e7fdaabd9931afa65f9", 834 | "sha256:d4a5f6146cfa5c7ba0134249665acd322a70d1ea61732723c7d3e8cc0fa80755", 835 | "sha256:dd91006848eb55af2159375134d724032a2d1d13bcc6f81cd8d3ed9f2b8e846c", 836 | "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a", 837 | "sha256:e6906d6f48437dfd80464f7d7af1740eadc572b9f7a4301e7dd3d65db285cacf", 838 | "sha256:e92d0d4fa68ea0c02d39f1e2f9cb5bc4b4a71e8c442207433d8db47ee79d7aa3", 839 | "sha256:e94b7d9deaa4cc7bac9198a58a7240aaf87fe56c6277ee25fa5b3aa1edebd229", 840 | "sha256:ea3e746e29d4000cd98d572f3ee2a6050a4f784bb536f4ac1f035987fc1ed83e", 841 | "sha256:ec7e20258ecc5174029a0f391e1b948bf2906cd64c198a9b8b281b811cbc04de", 842 | "sha256:ec9465dd69d5657b5d2fa6133b3e1e989ae27d29471a672416fd729b429eb554", 843 | "sha256:f122ccd12fdc69628786d0c947bdd9cb2733be8f800d88b5a37c57f1f1d73c10", 844 | "sha256:f99c0489258086308aad4ae57da9e8ecf9e1f3f30fa35d5e170b4d4896554d80", 845 | "sha256:f9c51d9af9abb899bd34ace878fbec8bf357b3194a10c4e8e0a25512826ef056", 846 | "sha256:fd76c47f20984b43d93de9a82011bb6e5f8325df6c9ed4d8310029a55fa361ea" 847 | ], 848 | "version": "==1.13.3" 849 | } 850 | } 851 | } 852 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zombie Projects Watcher 2 | 3 | Helps your team control infra costs by pointing potential unused 'zombie' projects. 4 | 5 | If you use the Slack integration, the Owners of the Projects receive messages like this one: 6 | 7 | ![Example Slack message](example-slack-message.png?raw=true "Example Slack message") 8 | 9 | If you use the Google Chat integration, messages similar to this one are sent to your Chat Space: 10 | 11 | ![Example Chat message](example-chat-message.png?raw=true "Example Chat message") 12 | 13 | ## Installation 14 | 15 | ### Dependencies and Config 16 | 17 | Clone this repository. 18 | 19 | Install Python dependencies and prepare the config file: 20 | 21 | ```bash 22 | pipenv install --ignore-pipfile --dev 23 | cp example-config.yaml config.yaml 24 | ``` 25 | 26 | Change `config.yaml` to fit your needs. 27 | 28 | ### Usage as a CLI command 29 | 30 | 31 | If you want to execute the program using your GCP user credentials, use the commands below: 32 | ```bash 33 | gcloud auth application-default login 34 | gcloud config set project PROJECT_ID 35 | ``` 36 | 37 | Instead of using your GCP user credentials, you can use a Service Account Key. In this case, use the following command: 38 | 39 | ```bash 40 | export GOOGLE_APPLICATION_CREDENTIALS='service-account-key.json' 41 | ``` 42 | 43 | Run the following command: 44 | 45 | ```bash 46 | pipenv run python main.py 47 | ``` 48 | 49 | If you have created an API token for the Slack integration, execute the following commands: 50 | 51 | ```bash 52 | export SLACK_API_TOKEN= 53 | pipenv run python main.py 54 | ``` 55 | 56 | See the `example-bigquery-billing-costs-view.sql` file of an example query to use for adding Project cost information from your [Billing Export](https://cloud.google.com/billing/docs/how-to/export-data-bigquery) data in BigQuery. 57 | 58 | ### Usage as a Google Cloud Function 59 | 60 | If you want to deploy the code as a Cloud Function, run the following command: 61 | 62 | ```bash 63 | gcloud functions deploy zombie-project-watcher \ 64 | --entry-point=http_request \ 65 | --runtime python38 \ 66 | --trigger-http 67 | ``` 68 | 69 | If you need to run or debug the Google Cloud Function locally, run the command: 70 | 71 | ```bash 72 | functions-framework --target http_request --debug 73 | ``` 74 | -------------------------------------------------------------------------------- /billing.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from config import CONFIG 3 | from google.cloud import bigquery 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | BIGQUERY_CLIENT_PROJECT = CONFIG['billing']['bigquery_client_project'].get() 8 | COST_VIEW_FULL_NAME = CONFIG['billing']['cost_view_full_name'].get() 9 | 10 | def query_billing_info(): 11 | client = bigquery.Client(project=BIGQUERY_CLIENT_PROJECT) 12 | query_job = client.query(""" 13 | SELECT 14 | billing_account_name 15 | , billing_account_id 16 | , project_id 17 | , cost_generated 18 | , currency 19 | , cost_reference_start_date 20 | FROM 21 | `{}` 22 | ORDER BY 23 | cost_generated DESC 24 | LIMIT 1000 25 | """.format(COST_VIEW_FULL_NAME)) 26 | 27 | logger.debug('Executing cost query.') 28 | results = query_job.result() 29 | 30 | results_by_project = {} 31 | for row in results: 32 | results_by_project[row.project_id] = { 33 | 'billingAccountName': row.billing_account_name, 34 | 'billingAccountId': row.billing_account_id, 35 | 'projectId': row.project_id, 36 | 'costGenerated': row.cost_generated, 37 | 'currency': row.currency, 38 | 'costReferenceStartDate': row.cost_reference_start_date.strftime('%Y-%m-%d') 39 | } 40 | 41 | return results_by_project 42 | -------------------------------------------------------------------------------- /chat.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging 4 | import requests 5 | from pprint import pformat 6 | from config import CONFIG 7 | from datetime import datetime as dt 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | ORGS_NAME_MAPPING = CONFIG['org_names_mapping'].get() 13 | CHAT_ACTIVATED = CONFIG['chat']['activate'].get(bool) 14 | WEBHOOK_URL = CONFIG['chat']['webhook_url'].get() 15 | USERS_MAP = CONFIG['chat']['users_mapping'].get() 16 | PRINT_ONLY = CONFIG['chat']['print_only'].get(bool) 17 | COST_ALERT_THRESHOLD = CONFIG['chat']['cost_alert_threshold'].get(float) 18 | COST_ALERT_EMOJI = CONFIG['chat']['cost_alert_emoji'].get() 19 | COST_MIN_TO_NOTIFY = CONFIG['chat']['cost_min_to_notify'].get(float) 20 | 21 | 22 | 23 | def send_message(message): 24 | logger.info('Sending Chat message to webhook %s:\n%s', WEBHOOK_URL, message) 25 | if PRINT_ONLY: 26 | return 27 | message_headers = {'Content-Type': 'application/json; charset=UTF-8'} 28 | message_data = { 29 | 'text': message 30 | } 31 | message_data_json = json.dumps(message_data, indent=2) 32 | response = requests.post( 33 | WEBHOOK_URL, data=message_data_json, headers=message_headers) 34 | if response.status_code != 200: 35 | logger.error('Error sending message to Chat. Error: %s, Response: %s, Webhook: %s', response.status_code, pformat(response.text), WEBHOOK_URL) 36 | 37 | 38 | def _get_message(user_to_mention): 39 | if user_to_mention == 'NO_OWNER': 40 | return "I've noticed there are no owners for the following projects:\n\n" 41 | return "Hey *@{}*, I've noticed you are one of the owners of the following projects:\n\n".format(user_to_mention) 42 | 43 | 44 | def send_messages_to_chat(projects_by_owner): 45 | number_of_notified_projects = 0 46 | if not CHAT_ACTIVATED: 47 | logger.info('Chat integration is not active.') 48 | return 49 | 50 | for owner in projects_by_owner.keys(): 51 | user_to_mention = USERS_MAP.get(owner, owner) 52 | message = _get_message(user_to_mention) 53 | send_message_to_this_owner = False 54 | for project in projects_by_owner.get(owner): 55 | project_id = project.get('projectId') 56 | org = ORGS_NAME_MAPPING.get(project.get('org')) 57 | path = project.get('path') 58 | created_days_ago = int(project.get('createdDaysAgo')) 59 | cost = project.get('costSincePreviousMonth', 0.0) 60 | currency = project.get('costCurrency', '$') 61 | emoji = '' 62 | emoji_codepoint = chr(int(COST_ALERT_EMOJI, base = 16)) 63 | cost_alert_emoji = "{} ".format(emoji_codepoint) 64 | if cost <= COST_MIN_TO_NOTIFY: 65 | logger.debug('- `{}/{}`, will not be in the message, due to its cost being lower than the minimum warning value'\ 66 | .format(owner, project_id)) 67 | else: 68 | if cost > COST_ALERT_THRESHOLD: 69 | emoji = ' ' + cost_alert_emoji 70 | send_message_to_this_owner = True 71 | message += "`{}/{}{}` created `{} days ago`, costing *`{}`* {}.{}\n\n"\ 72 | .format(org, path, project_id, created_days_ago, cost, currency, emoji) 73 | number_of_notified_projects = number_of_notified_projects + 1 74 | message += "\nIf these projects are not being used anymore, please consider `deleting them to reduce infra costs` and clutter." 75 | 76 | if send_message_to_this_owner: 77 | send_message(message) 78 | 79 | today_weekday=dt.today().strftime('%A') 80 | final_of_execution_message = f'''Happy {today_weekday}! 81 | Today I found *{number_of_notified_projects} projects* with costs higher 82 | than the defined notification threshold of ${COST_MIN_TO_NOTIFY}.''' 83 | send_message(final_of_execution_message) 84 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import confuse 3 | 4 | appName = 'ZombieProjectsWatcher' 5 | os.environ[appName.upper()+'DIR'] = '.' 6 | CONFIG = confuse.Configuration(appName, __name__) 7 | -------------------------------------------------------------------------------- /example-bigquery-billing-costs-view.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | '' AS billing_account_name 3 | , billing_account_id 4 | , project.id AS project_id 5 | , ROUND(SUM(cost), 2) AS cost_generated 6 | , currency 7 | , DATE_SUB(DATE_TRUNC(current_date, MONTH), INTERVAL 1 MONTH) AS cost_reference_start_date 8 | FROM 9 | `..` 10 | WHERE 11 | project.id IS NOT NULL 12 | -- the cost occurred after cost_reference_start_date (first day of previous month) 13 | AND PARSE_DATE("%Y-%m-%d", FORMAT_TIMESTAMP("%Y-%m-%d", usage_start_time)) 14 | >= DATE_SUB(DATE_TRUNC(current_date, MONTH), INTERVAL 1 MONTH) 15 | GROUP BY 16 | billing_account_name 17 | , billing_account_id 18 | , project.id 19 | , currency 20 | , cost_reference_start_date 21 | UNION ALL 22 | SELECT 23 | '' AS billing_account_name 24 | , billing_account_id 25 | , project.id AS project_id 26 | , ROUND(SUM(cost), 2) AS cost_generated 27 | , currency 28 | , DATE_SUB(DATE_TRUNC(current_date, MONTH), INTERVAL 1 MONTH) AS cost_reference_start_date 29 | FROM 30 | `..` 31 | WHERE 32 | project.id IS NOT NULL 33 | -- the cost occurred after cost_reference_start_date (first day of previous month) 34 | AND PARSE_DATE("%Y-%m-%d", FORMAT_TIMESTAMP("%Y-%m-%d", usage_start_time)) 35 | >= DATE_SUB(DATE_TRUNC(current_date, MONTH), INTERVAL 1 MONTH) 36 | GROUP BY 37 | billing_account_name 38 | , billing_account_id 39 | , project.id 40 | , currency 41 | , cost_reference_start_date 42 | -------------------------------------------------------------------------------- /example-chat-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kieras/zombie-projects-watcher/2f54ec80a124726e9e6ddc05f51381d3988f3796/example-chat-message.png -------------------------------------------------------------------------------- /example-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | filters: 3 | # Search for projects only in the organizations listed. 4 | orgs: 5 | - '9999999999999' 6 | - '8888888888888' 7 | # Filter out projects created in less than X days. 8 | age_minimum_days: 5 9 | # Filter out projects if any owner matches with any regex listed. 10 | users_regex: 11 | - kieras@.* 12 | - .*@mycompany.com 13 | # Filter out projects that matches with any of the list. 14 | projects: 15 | - my-special-project 16 | - my-other-special-project 17 | # Activate/deactivate organization information integration. 18 | org_info: 19 | activate: false 20 | slack: 21 | # Activate/deactivate Slack messages integration. 22 | activate: false 23 | # Do not send messages, only print them. 24 | print_only: true 25 | # Force send messages to a specific user. Set null to send to Project owners. 26 | test_user: null 27 | # Slack channel used by the team. 28 | team_channel: my-team 29 | # Send messages to channel if an error occurs when sending privately. 30 | team_channel_fallback: true 31 | # Force always send to team channel, instead of private messages. 32 | send_to_team_channel: false 33 | # Cost minimum to actually send a message (notify) to the owner. 34 | cost_min_to_notify: 0.0 35 | # Cost threshold to signal an alert highlight. 36 | cost_alert_threshold: 10.0 37 | # Cost threshold alert highlight emoji. 38 | cost_alert_emoji: ':scream:' 39 | bot: 40 | # Bot name. 41 | name: Zombie Projects Watcher Alpha 42 | # Bot icon. An emoji. 43 | emoji: ':money_with_wings:' 44 | users_mapping: 45 | # Mapping between Project owners login and Slack users. 46 | akieras: kieras 47 | eduardo: dudu 48 | chat: 49 | # Activate/deactivate Chat messages integration. 50 | activate: false 51 | # Do not send messages, only print them. 52 | print_only: true 53 | # Chat webhook URL. 54 | webhook_url: https://... 55 | # Cost minimum to actually send a message (notify) to the owner. 56 | cost_min_to_notify: 0.0 57 | # Cost threshold to signal an alert highlight. 58 | cost_alert_threshold: 10.0 59 | # Cost threshold alert highlight emoji. 60 | # To get emoji hexdecimal code, https://unicode.org/emoji/charts/full-emoji-list.html 61 | cost_alert_emoji: '0x1F631' 62 | users_mapping: 63 | # Mapping between Project owners login and Slack users. 64 | akieras: akieras 65 | billing: 66 | # Activate/deactivate billing integration. 67 | activate: false 68 | # Project BigQuery client use to connect to dataset. 69 | bigquery_client_project: my-project 70 | # Full BigQuery view name with Billin export information. 71 | # See example-bigquery-billing-costs-view.sql file for an example query. 72 | cost_view_full_name: my-project.billing.cost_per_project_starting_from_last_month 73 | org_names_mapping: 74 | # Mapping to give meaningful names to organizations in messages. 75 | '9999999999999': my-org 76 | '8888888888888': my-other-org 77 | # Activate/deactivate further debugging info. 78 | debug: 79 | enriched_projects: false 80 | filtered_by_projects: false 81 | filtered_by_users: false 82 | filtered_by_age: false 83 | filtered_by_org: false 84 | grouped_by_owners: false 85 | # Filename for a dump of the projects with enriched information in JSON format. 86 | # Set null to not generate the dump file. 87 | dump_json_file_name: null 88 | -------------------------------------------------------------------------------- /example-slack-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kieras/zombie-projects-watcher/2f54ec80a124726e9e6ddc05f51381d3988f3796/example-slack-message.png -------------------------------------------------------------------------------- /filters.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def filter_projects_matching_org_level(orgs): 5 | def filter_projects(project): 6 | if project.get('org') in orgs: 7 | return True 8 | else: 9 | return False 10 | return filter_projects 11 | 12 | 13 | def filter_whitelisted_projects(whitelisted_project_ids): 14 | def filter_projects(project): 15 | if project.get('projectId') not in whitelisted_project_ids: 16 | return True 17 | else: 18 | return False 19 | return filter_projects 20 | 21 | 22 | def filter_older_than(days): 23 | def filter_older_projects(project): 24 | if int(project.get('createdDaysAgo')) > days: 25 | return True 26 | else: 27 | return False 28 | return filter_older_projects 29 | 30 | 31 | def filter_owners(bindings): 32 | if bindings.get('role') == 'roles/owner': 33 | return True 34 | else: 35 | return False 36 | 37 | 38 | def filter_users(member): 39 | if 'user:' in member: 40 | return True 41 | else: 42 | return False 43 | 44 | 45 | def filter_whitelisted_users(whitelisted_users_regex): 46 | def filter_users(project): 47 | for owner in project.get('owners'): 48 | for whitelisted_user in whitelisted_users_regex: 49 | if re.search(whitelisted_user, owner): 50 | return False 51 | return True 52 | return filter_users 53 | -------------------------------------------------------------------------------- /logging.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | formatters: 4 | simple: 5 | format: '%(asctime)s [%(name)-10.10s] %(levelname)8s - %(message)s' 6 | handlers: 7 | console: 8 | class: logging.StreamHandler 9 | level: DEBUG 10 | formatter: simple 11 | stream: ext://sys.stdout 12 | loggers: 13 | __main__: 14 | level: DEBUG 15 | handlers: [console] 16 | propagate: no 17 | billing: 18 | level: DEBUG 19 | handlers: [console] 20 | propagate: no 21 | slack: 22 | level: DEBUG 23 | handlers: [console] 24 | propagate: no 25 | urllib3: 26 | level: WARNING 27 | google: 28 | level: WARNING 29 | root: 30 | level: DEBUG 31 | handlers: [console] 32 | -------------------------------------------------------------------------------- /logging_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import yaml 3 | import logging.config 4 | import logging 5 | 6 | def setup_logging(default_path='logging.yaml', default_level=logging.INFO, env_key='LOG_CFG'): 7 | path = default_path 8 | value = os.getenv(env_key, None) 9 | if value: 10 | path = value 11 | if os.path.exists(path): 12 | with open(path, 'rt') as f: 13 | try: 14 | config = yaml.safe_load(f.read()) 15 | logging.config.dictConfig(config) 16 | except Exception as e: 17 | print(e) 18 | print('Error in Logging Configuration. Using default configs') 19 | logging.basicConfig(level=default_level) 20 | else: 21 | logging.basicConfig(level=default_level) 22 | print('Failed to load configuration file. Using default configs') 23 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | import json 4 | from pprint import pformat 5 | from datetime import datetime as dt 6 | import traceback as tb 7 | from googleapiclient import discovery 8 | 9 | from logging_config import setup_logging 10 | import functions_framework 11 | 12 | setup_logging() 13 | logger = logging.getLogger(__name__) 14 | 15 | from config import CONFIG 16 | from utils import ( 17 | extract_username, 18 | group_projects_by_owner 19 | ) 20 | from filters import ( 21 | filter_projects_matching_org_level, 22 | filter_older_than, 23 | filter_owners, 24 | filter_users, 25 | filter_whitelisted_projects, 26 | filter_whitelisted_users 27 | ) 28 | from billing import query_billing_info 29 | from slack import send_messages_to_slack 30 | from chat import send_messages_to_chat 31 | 32 | 33 | ORGS_FILTER = CONFIG['filters']['orgs'].get() 34 | PROJECTS_FILTER = CONFIG['filters']['projects'].get() or [] 35 | USERS_REGEX_FILTER = CONFIG['filters']['users_regex'].get() or [] 36 | AGE_MINIMUM_DAYS_FILTER = CONFIG['filters']['age_minimum_days'].get(int) 37 | SLACK_ACTIVATED = CONFIG['slack']['activate'].get(bool) 38 | CHAT_ACTIVATED = CONFIG['chat']['activate'].get(bool) 39 | BILLING_ACTIVATED = CONFIG['billing']['activate'].get(bool) 40 | DUMP_JSON_FILE_NAME = CONFIG['dump_json_file_name'].get() 41 | ORGS_ACTIVATED = CONFIG['org_info']['activate'].get(bool) 42 | 43 | DEBUG_ENRICHED_PROJECTS = CONFIG['debug']['enriched_projects'].get(bool) 44 | DEBUG_FILTERED_BY_PROJECTS = CONFIG['debug']['filtered_by_projects'].get(bool) 45 | DEBUG_FILTERED_BY_USERS = CONFIG['debug']['filtered_by_users'].get(bool) 46 | DEBUG_FILTERED_BY_AGE = CONFIG['debug']['filtered_by_age'].get(bool) 47 | DEBUG_GROUPED_BY_OWNERS = CONFIG['debug']['grouped_by_owners'].get(bool) 48 | DEBUG_FILTERED_BY_ORGS = CONFIG['debug']['filtered_by_org'].get(bool) 49 | 50 | @functions_framework.http 51 | def http_request(request): 52 | now = dt.now() 53 | date_value = dt.strftime(now, "%Y-%m-%dT%H:%M:%S.%fZ") 54 | message = '' 55 | status_code = '' 56 | try: 57 | message, status_code = main() 58 | except Exception as err: 59 | logging.exception(err) 60 | exception_message = tb.format_exc().splitlines() 61 | message = exception_message[-1].capitalize() 62 | message = message.replace('<',' ') 63 | message = message.replace('>',' ') 64 | message = 'An error occurred. Details: ' + message 65 | for item in message.split(): 66 | if item.isnumeric(): 67 | status_code = item 68 | else: 69 | status_code = 500 70 | finally: 71 | message = (message + ' on ' + date_value + '!') 72 | return message, status_code 73 | 74 | def main(): 75 | client = _get_resource_manager_client() 76 | client_v1 = _get_resource_manager_client_v1() 77 | 78 | logger.info('Retrieving Projects.') 79 | active_projects = _get_projects(client) 80 | 81 | logger.info('Calculating Project age information.') 82 | enriched_projects = _enrich_project_info_with_age(active_projects) 83 | 84 | logger.info('Retrieving Project owners information.') 85 | enriched_projects = _enrich_project_info_with_owners(client, enriched_projects) 86 | 87 | if ORGS_ACTIVATED: 88 | logger.info('Retrieving Project organization information.') 89 | enriched_projects = _enrich_project_info_with_org_and_path(client_v1, client, enriched_projects) 90 | else: 91 | logger.info('Project organization information is not active.') 92 | 93 | if BILLING_ACTIVATED: 94 | logger.info('Retrieving Project cost information.') 95 | enriched_projects = _enrich_project_info_with_costs(enriched_projects) 96 | else: 97 | logger.info('Project cost information is not active.') 98 | 99 | if DEBUG_ENRICHED_PROJECTS: 100 | logger.debug('Projects with enriched information:\n%s', pformat(enriched_projects)) 101 | 102 | if DUMP_JSON_FILE_NAME: 103 | logger.debug('Dumping info to JSON file %s.', DUMP_JSON_FILE_NAME) 104 | with open(DUMP_JSON_FILE_NAME, 'w') as fp: 105 | json.dump(enriched_projects, fp, indent=2, sort_keys=True) 106 | 107 | logger.info('Filtering Projects by project.') 108 | project_filtered = list(filter(filter_whitelisted_projects( 109 | PROJECTS_FILTER), enriched_projects)) 110 | 111 | if DEBUG_FILTERED_BY_PROJECTS: 112 | logger.debug('Project filter applied:\n%s', pformat(project_filtered)) 113 | 114 | logger.info('Filtering Projects by user.') 115 | user_filtered = list(filter(filter_whitelisted_users( 116 | USERS_REGEX_FILTER), project_filtered)) 117 | 118 | if DEBUG_FILTERED_BY_USERS: 119 | logger.debug('User filter applied:\n%s', pformat(user_filtered)) 120 | 121 | logger.info('Filtering Projects by age.') 122 | older_projects = list(filter(filter_older_than( 123 | AGE_MINIMUM_DAYS_FILTER), user_filtered)) 124 | 125 | if DEBUG_FILTERED_BY_AGE: 126 | logger.debug('Aged Projects filter applied:\n%s', pformat(older_projects)) 127 | 128 | logger.info('Filtering Projects by org level.') 129 | 130 | org_projects = list(filter(filter_projects_matching_org_level( 131 | ORGS_FILTER), older_projects)) 132 | 133 | if DEBUG_FILTERED_BY_ORGS: 134 | logger.debug('Project by orgs:\n%s', pformat(org_projects)) 135 | 136 | logger.info('Grouping Projects by owner(s).') 137 | projects_by_owner = group_projects_by_owner(org_projects) 138 | 139 | if DEBUG_GROUPED_BY_OWNERS: 140 | logger.debug('Project by owner:\n%s', pformat(projects_by_owner)) 141 | 142 | if SLACK_ACTIVATED: 143 | logger.info('Sending Slack messages.') 144 | send_messages_to_slack(projects_by_owner) 145 | logger.info('All messages sent.') 146 | else: 147 | logger.info('Slack integration is not active.') 148 | 149 | if CHAT_ACTIVATED: 150 | logger.info('Sending Chat messages.') 151 | send_messages_to_chat(projects_by_owner) 152 | logger.info('All messages sent.') 153 | else: 154 | logger.info('Chat integration is not active.') 155 | 156 | logger.info('Happy Friday! :)') 157 | 158 | response_message = "Success " 159 | response_code = 200 160 | 161 | return response_message, response_code 162 | 163 | 164 | def _get_projects(client): 165 | project_list_request = client.projects().search(query='state:ACTIVE') 166 | project_list_response = project_list_request.execute() 167 | projects = project_list_response.get('projects', []) 168 | while project_list_response.get('nextPageToken'): 169 | project_list_request = client.projects().list_next(previous_request=project_list_request,\ 170 | previous_response=project_list_response) 171 | project_list_response = project_list_request.execute() 172 | projects = projects + project_list_response.get('projects', []) 173 | return projects 174 | 175 | 176 | def _get_resource_manager_client(): 177 | client = discovery.build("cloudresourcemanager", "v3") 178 | return client 179 | 180 | 181 | def _get_resource_manager_client_v1(): 182 | client_v1 = discovery.build("cloudresourcemanager", "v1") 183 | return client_v1 184 | 185 | 186 | def _enrich_project_info_with_owners(client, projects): 187 | for project in projects: 188 | project['owners'] = _get_owners(client, project) 189 | project['owners_id'] = _get_owners_id(project.get('owners')) 190 | logger.debug('Owners for Project %s: %s', project.get('projectId'), project.get('owners')) 191 | return projects 192 | 193 | 194 | def _enrich_project_info_with_age(projects): 195 | for project in projects: 196 | project['createdDaysAgo'] = _get_created_days_ago(project) 197 | return projects 198 | 199 | 200 | def _enrich_project_info_with_costs(projects): 201 | costs_by_project = query_billing_info() 202 | for project in projects: 203 | project['costSincePreviousMonthFull'] =\ 204 | _get_cost_since_previous_month_full(costs_by_project, project) 205 | project['costSincePreviousMonth'] =\ 206 | _get_cost_since_previous_month_value(costs_by_project, project) 207 | project['costCurrency'] =\ 208 | _get_cost_currency(costs_by_project, project) 209 | project['costBillingAccountName'] =\ 210 | _get_cost_billing_account_name(costs_by_project, project) 211 | project['costBillingAccountId'] =\ 212 | _get_cost_billing_account_id(costs_by_project, project) 213 | logger.debug('Cost for Project %s: %s %s (Billing account: %s, Id: %s)', 214 | project.get('projectId'), project.get('costSincePreviousMonth'), 215 | project.get('costCurrency'), project.get('costBillingAccountName'), 216 | project.get('costBillingAccountId')) 217 | return projects 218 | 219 | 220 | def _enrich_project_info_with_org_and_path(client_v1, client, projects): 221 | folders = _get_folders(client) 222 | for project in projects: 223 | response = _get_ancestry(client_v1, folders, project) 224 | project['org'] = response['org'] 225 | project['path'] = response['path'] 226 | logger.debug('Organization root for Project %s: %s',project.get('projectId'), project.get('org')) 227 | logger.debug('Path for Project %s: %s',project.get('projectId'), project.get('path')) 228 | return projects 229 | 230 | 231 | def _get_cost_since_previous_month_full(costs_by_project, project): 232 | project_id = project.get('projectId') 233 | cost = costs_by_project.get(project_id, {}) 234 | return cost 235 | 236 | 237 | def _get_cost_since_previous_month_value(costs_by_project, project): 238 | cost = _get_cost_since_previous_month_full(costs_by_project, project) 239 | if not cost: 240 | return 0.0 241 | else: 242 | return cost.get('costGenerated', 0.0) 243 | 244 | 245 | def _get_cost_currency(costs_by_project, project): 246 | cost = _get_cost_since_previous_month_full(costs_by_project, project) 247 | if not cost: 248 | return '$' 249 | else: 250 | return cost.get('currency', '$') 251 | 252 | 253 | def _get_cost_billing_account_name(costs_by_project, project): 254 | cost = _get_cost_since_previous_month_full(costs_by_project, project) 255 | if not cost: 256 | return 'Unknown' 257 | else: 258 | return cost.get('billingAccountName', 'Unknown') 259 | 260 | 261 | def _get_cost_billing_account_id(costs_by_project, project): 262 | cost = _get_cost_since_previous_month_full(costs_by_project, project) 263 | if not cost: 264 | return 'Unknown' 265 | else: 266 | return cost.get('billingAccountId', 'Unknown') 267 | 268 | 269 | def _get_owners_id(owners): 270 | usernames = set([extract_username(user) for user in owners]) 271 | return list(usernames) 272 | 273 | 274 | def _get_owners(client, project): 275 | users = [] 276 | project_name = project.get('name') 277 | iamPolicy = client.projects().getIamPolicy(resource=project_name, body={}).execute() 278 | bindings = iamPolicy.get('bindings', []) 279 | owners = list(filter(filter_owners, bindings)) 280 | if not owners: 281 | logger.debug('No owners found for Project %s.', project_name) 282 | else: 283 | members = owners[0].get('members') 284 | users = list(filter(filter_users, members)) 285 | if not users: 286 | logger.debug('No owner is a user for Project %s.', project_name) 287 | users = set([user.strip('user:') for user in users]) 288 | return list(users) 289 | 290 | 291 | def _get_ancestry(client_v1, folders, project): 292 | response = dict(org='No organization', path='') 293 | projectId = project.get('projectId') 294 | ancestry_request = client_v1.projects().getAncestry(projectId=projectId, body=None) 295 | ancestry_response = ancestry_request.execute() 296 | 297 | for resourceId in ancestry_response['ancestor']: 298 | if resourceId['resourceId']['type'] in ['organization', 'project', 'folder']: 299 | if resourceId['resourceId']['type'] == 'organization': 300 | response['org'] = resourceId['resourceId']['id'] 301 | if resourceId['resourceId']['type'] == 'folder': 302 | response['path'] = folders[resourceId['resourceId']['id']] + '/' + response['path'] 303 | else: 304 | logger.debug('No organization info for project %s.', projectId) 305 | return response 306 | 307 | 308 | def _get_folders(client): 309 | folders = dict() 310 | folders_request = client.folders().search(query='name:folders/*') 311 | folders_response = folders_request.execute() 312 | for folder in folders_response['folders']: 313 | folder_number = folder['name'].split('/')[1] 314 | folders[folder_number] = folder['displayName'] 315 | return folders 316 | 317 | 318 | def _get_created_days_ago(project): 319 | now = dt.now() 320 | create_time = project.get('createTime') 321 | create_date_value = dt.strptime(create_time, "%Y-%m-%dT%H:%M:%S.%fZ") 322 | delta = now - create_date_value 323 | return delta.days 324 | 325 | 326 | if __name__ == '__main__': 327 | main() 328 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | google-api-python-client==1.7.11 3 | google-cloud-bigquery==1.11.1 4 | confuse==0.5.0 5 | slackclient==1.3.1 6 | requests==2.22.0 7 | functions-framework==3.* 8 | -------------------------------------------------------------------------------- /slack.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | from pprint import pformat 4 | from slackclient import SlackClient 5 | from config import CONFIG 6 | from datetime import datetime as dt 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | slack_token = os.getenv("SLACK_API_TOKEN") 11 | sc = SlackClient(slack_token) 12 | 13 | ORGS_NAME_MAPPING = CONFIG['org_names_mapping'].get() 14 | SLACK_ACTIVATED = CONFIG['slack']['activate'].get(bool) 15 | USERS_MAP = CONFIG['slack']['users_mapping'].get() 16 | PRINT_ONLY = CONFIG['slack']['print_only'].get(bool) 17 | TEST_USER = CONFIG['slack']['test_user'].get() 18 | TEAM_CHANNEL = CONFIG['slack']['team_channel'].get() 19 | SEND_TO_TEAM_CHANNEL = CONFIG['slack']['send_to_team_channel'].get(bool) 20 | TEAM_CHANNEL_FALLBACK = CONFIG['slack']['team_channel_fallback'].get(bool) 21 | BOT_NAME = CONFIG['slack']['bot']['name'].get() 22 | BOT_EMOJI = CONFIG['slack']['bot']['emoji'].get() 23 | COST_ALERT_THRESHOLD = CONFIG['slack']['cost_alert_threshold'].get(float) 24 | COST_ALERT_EMOJI = CONFIG['slack']['cost_alert_emoji'].get() 25 | COST_MIN_TO_NOTIFY = CONFIG['slack']['cost_min_to_notify'].get(float) 26 | 27 | 28 | def prepare_message(slack_user, message): 29 | slack_channel = "@{}".format(slack_user) 30 | if SEND_TO_TEAM_CHANNEL: 31 | slack_channel = "#{}".format(TEAM_CHANNEL) 32 | resp = _send_message(slack_channel, message) 33 | if resp and not resp.get('ok'): 34 | if resp.get('error') == 'channel_not_found': 35 | logger.error('Error: %s, Channel: %s', resp.get('error'), slack_channel) 36 | if TEAM_CHANNEL_FALLBACK: 37 | resp = _send_message("#{}".format(TEAM_CHANNEL), message) 38 | if resp and not resp.get('ok'): 39 | logger.error('Error in fallback to team channel: %s, Channel: %s, Response: %s', resp.get('error'), slack_channel, pformat(resp)) 40 | else: 41 | logger.error('Error: %s, Channel: %s, Response: %s', resp.get('error'), slack_channel, pformat(resp)) 42 | 43 | def send_messages_to_slack(projects_by_owner): 44 | number_of_notified_projects = 0 45 | if not SLACK_ACTIVATED: 46 | logger.info('Slack integration is not active.') 47 | return 48 | 49 | today_weekday=dt.today().strftime('%A') 50 | initial_message = f'Happy {today_weekday}!' 51 | prepare_message(TEAM_CHANNEL, initial_message) 52 | 53 | for owner in projects_by_owner.keys(): 54 | slack_user = USERS_MAP.get(owner, owner) 55 | message = "Hey @{}, I've noticed you are one of the owners of the following projects:\n".format(slack_user) 56 | send_message_to_this_owner = False 57 | for project in projects_by_owner.get(owner): 58 | project_id = project.get('projectId') 59 | org = ORGS_NAME_MAPPING.get(project.get('org')) 60 | created_days_ago = int(project.get('createdDaysAgo')) 61 | cost = project.get('costSincePreviousMonth', 0.0) 62 | currency = project.get('costCurrency', '$') 63 | emoji = '' 64 | if cost <= COST_MIN_TO_NOTIFY: 65 | logger.debug('- `{}/{}`, will not be in the message, due to its cost being lower than the minimum warning value'.format(owner, project_id)) 66 | else: 67 | if cost > COST_ALERT_THRESHOLD: 68 | emoji = ' ' + COST_ALERT_EMOJI 69 | send_message_to_this_owner = True 70 | message += "- `{}/{}` created `{} days ago`, costing *`{}`* {}.{}\n".format(org, project_id, created_days_ago, cost, currency, emoji) 71 | number_of_notified_projects = number_of_notified_projects + 1 72 | message += "If these projects are not being used anymore, please consider `deleting them to reduce infra costs` and clutter. :rip:" 73 | 74 | if send_message_to_this_owner: 75 | prepare_message(slack_user, message) 76 | 77 | final_of_execution_message = '\nToday I found *{number_of_notified_projects} projects* with costs higher \ 78 | than the defined notification threshold ${COST_MIN_TO_NOTIFY}.\ 79 | \n\n_Note: Only projects whose owner is a real user (and not a Service Account) were considered._' 80 | 81 | prepare_message(TEAM_CHANNEL, final_of_execution_message) 82 | 83 | def _send_message(channel, message): 84 | if TEST_USER: 85 | channel = "@{}".format(TEST_USER) 86 | 87 | logger.info('Sending Slack message to channel %s:\n%s', channel, message) 88 | 89 | if PRINT_ONLY: 90 | return 91 | 92 | resp = sc.api_call( 93 | "chat.postMessage", 94 | channel=channel, 95 | text=message, 96 | as_user="false", 97 | username=BOT_NAME, 98 | icon_emoji=BOT_EMOJI, 99 | link_names="true" 100 | ) 101 | return resp 102 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from config import CONFIG 3 | 4 | ORGS_NAME_MAPPING = CONFIG['org_names_mapping'].get() 5 | 6 | def group_projects_by_owner(projects): 7 | projects_by_owner = dict(NO_OWNER=[]) 8 | for project in projects: 9 | owners = project.get('owners_id') 10 | for owner in owners: 11 | prjs = projects_by_owner.setdefault(owner, []) 12 | prjs.append(project) 13 | if not owners: 14 | projects_by_owner['NO_OWNER'].append(project) 15 | return projects_by_owner 16 | 17 | 18 | def extract_username(member): 19 | username = re.search('(.*)@.*', member).group(1) 20 | return username 21 | 22 | 23 | def print_info(projects): 24 | print('#{} projects.'.format(len(projects))) 25 | for project in projects: 26 | project_id = project.get('projectId') 27 | org = ORGS_NAME_MAPPING.get(project.get('parent').get('id')) 28 | created_days_ago = int(project.get('createdDaysAgo', '-1')) 29 | cost_value = project.get('costSincePreviousMonth', 'Unknown') 30 | cost_currency = project.get('costCurrency', 'Unknown') 31 | owners = project.get('owners', 'Unknown') 32 | owners_id = project.get('owners_id', 'Unknown') 33 | created_time = project.get('createTime', 'Unknown') 34 | print('{}/{}, CreatedTime={}, Age={}d, Cost={} {}, Owners={}, ' 35 | 'Users={}.'.format( 36 | org, project_id, created_time, created_days_ago, cost_value, 37 | cost_currency, owners, owners_id)) 38 | --------------------------------------------------------------------------------