├── .coveragerc ├── .dockerignore ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .gitpod.yml ├── .pre-commit-config.yaml ├── .releaserc.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docs ├── CODEOWNERS ├── assets │ └── images │ │ ├── favicon.ico │ │ ├── logo-white.svg │ │ ├── screenshots │ │ ├── example-pr-commits.png │ │ ├── example-pr-diff.png │ │ └── example-pr-overview.png │ │ └── teaser.png ├── changelog.md ├── commands │ ├── add-pr-comment.md │ ├── create-pr-preview.md │ ├── create-preview.md │ ├── delete-pr-preview.md │ ├── delete-preview.md │ ├── deploy.md │ ├── sync-apps.md │ └── version.md ├── contributing.md ├── getting-started.md ├── includes │ └── preview-configuration.md ├── index.md ├── license.md ├── setup.md └── stylesheets │ └── extra.css ├── gitopscli ├── __init__.py ├── __main__.py ├── appconfig_api │ ├── __init__.py │ ├── app_tenant_config.py │ └── root_repo.py ├── cliparser.py ├── commands │ ├── __init__.py │ ├── add_pr_comment.py │ ├── command.py │ ├── command_factory.py │ ├── common │ │ ├── __init__.py │ │ └── gitops_config_loader.py │ ├── create_pr_preview.py │ ├── create_preview.py │ ├── delete_pr_preview.py │ ├── delete_preview.py │ ├── deploy.py │ ├── sync_apps.py │ └── version.py ├── git_api │ ├── __init__.py │ ├── bitbucket_git_repo_api_adapter.py │ ├── git_api_config.py │ ├── git_provider.py │ ├── git_repo.py │ ├── git_repo_api.py │ ├── git_repo_api_factory.py │ ├── git_repo_api_logging_proxy.py │ ├── github_git_repo_api_adapter.py │ └── gitlab_git_repo_api_adapter.py ├── gitops_config.py ├── gitops_exception.py └── io_api │ ├── __init__.py │ ├── tmp_dir.py │ └── yaml_util.py ├── mkdocs.yml ├── mypy.ini ├── poetry.lock ├── poetry.toml ├── pyproject.toml └── tests ├── __init__.py ├── commands ├── __init__.py ├── common │ ├── __init__.py │ └── test_gitops_config_loader.py ├── mock_mixin.py ├── test_add_pr_comment.py ├── test_command_factory.py ├── test_create_pr_preview.py ├── test_create_preview.py ├── test_delete_pr_preview.py ├── test_delete_preview.py ├── test_deploy.py ├── test_sync_apps.py └── test_version.py ├── git_api ├── __init__.py ├── test_git_repo.py ├── test_git_repo_api_logging_proxy.py └── test_repo_api_factory.py ├── io_api ├── __init__.py ├── test_tmp_dir.py └── test_yaml_util.py ├── test_cliparser.py ├── test_gitops_config_v0.py ├── test_gitops_config_v1.py └── test_gitops_config_v2.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = gitopscli 3 | 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | @abstractmethod 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | * 3 | !docs 4 | !gitopscli/ 5 | !tests/ 6 | !CONTRIBUTING.md 7 | !mkdocs.yml 8 | !Makefile 9 | !mypy.ini 10 | !poetry.lock 11 | !poetry.toml 12 | !pyproject.toml 13 | !README.md 14 | 15 | **/__pycache__/ 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | doc: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: Build docs 15 | uses: docker/build-push-action@v4 16 | with: 17 | context: . 18 | target: docs-site 19 | outputs: type=local,dest=. 20 | - name: Deploy docs to GitHub Pages 21 | if: github.ref == 'refs/heads/master' 22 | uses: peaceiris/actions-gh-pages@v3 23 | with: 24 | github_token: ${{ secrets.GITHUB_TOKEN }} 25 | publish_dir: ./site 26 | 27 | release: 28 | if: github.repository == 'baloise/gitopscli' 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Run tests 34 | uses: docker/build-push-action@v4 35 | with: 36 | context: . 37 | target: test 38 | - name: Setup Node.js 39 | uses: actions/setup-node@v3 40 | with: 41 | node-version: 'lts/*' 42 | - name: Install dependencies 43 | run: npm install semantic-release @semantic-release/exec semantic-release-replace-plugin conventional-changelog-conventionalcommits 44 | - name: Release 45 | if: github.ref == 'refs/heads/master' 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} 49 | DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} 50 | DOCKER_IMAGE: baloise/gitopscli 51 | DOCKER_BUILDKIT: '1' # use BuildKit backend (https://docs.docker.com/engine/reference/builder/#buildkit) 52 | BUILDKIT_PROGRESS: plain 53 | run: npx semantic-release 54 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | - name: Run tests 18 | uses: docker/build-push-action@v4 19 | with: 20 | context: . 21 | target: test 22 | - name: Test doc build 23 | uses: docker/build-push-action@v4 24 | with: 25 | context: . 26 | target: docs-site 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | __pycache__ 3 | .vscode 4 | .coverage 5 | dist/ 6 | htmlcov/ 7 | .venv/ 8 | testrepo/ 9 | .idea 10 | *.iml 11 | .eggs/ 12 | site/ 13 | build/ 14 | .mypy_cache 15 | .ruff_cache 16 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: gitpod/workspace-python-3.11:2023-10-19-14-24-02 2 | tasks: 3 | - init: make init && export COLUMNS=80 && poetry run gitopscli --help 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: local 4 | hooks: 5 | - id: format-check 6 | name: make format-check 7 | entry: make format-check 8 | language: system 9 | pass_filenames: false 10 | always_run: true 11 | - id: lint 12 | name: make lint 13 | entry: make lint 14 | language: system 15 | pass_filenames: false 16 | always_run: true 17 | - id: mypy 18 | name: make mypy 19 | entry: make mypy 20 | language: system 21 | pass_filenames: false 22 | always_run: true 23 | - id: test 24 | name: make test 25 | entry: make test 26 | language: system 27 | pass_filenames: false 28 | always_run: true 29 | -------------------------------------------------------------------------------- /.releaserc.yml: -------------------------------------------------------------------------------- 1 | branch: master 2 | preset: conventionalcommits # https://www.conventionalcommits.org/ 3 | tagFormat: "v${version}" 4 | plugins: 5 | - "@semantic-release/commit-analyzer" 6 | - "@semantic-release/release-notes-generator" 7 | - - "semantic-release-replace-plugin" 8 | - replacements: 9 | - files: [ "pyproject.toml" ] 10 | from: "version = \"0.0.0\"" 11 | to: "version = \"${nextRelease.version}\"" 12 | countMatches: true 13 | results: 14 | - file: "pyproject.toml" 15 | hasChanged: true 16 | numMatches: 1 17 | numReplacements: 1 18 | - - "@semantic-release/exec" 19 | - verifyConditionsCmd: echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin 20 | verifyReleaseCmd: | 21 | MAJOR_MINOR_PATCH=${/^((\d+)\.\d+)\.\d+$/.exec(nextRelease.version)} 22 | if [ -z $MAJOR_MINOR_PATCH ]; then echo "Only SemVer versions with version core (major.minor.patch) supported!"; exit 1; fi 23 | prepareCmd: docker build -t $DOCKER_IMAGE:latest . 24 | publishCmd: | 25 | MAJOR_MINOR_PATCH=${/^((\d+)\.\d+)\.\d+$/.exec(nextRelease.version)[0]} 26 | MAJOR_MINOR=${ /^((\d+)\.\d+)\.\d+$/.exec(nextRelease.version)[1]} 27 | MAJOR=${ /^((\d+)\.\d+)\.\d+$/.exec(nextRelease.version)[2]} 28 | docker tag $DOCKER_IMAGE:latest $DOCKER_IMAGE:$MAJOR_MINOR_PATCH 29 | docker tag $DOCKER_IMAGE:latest $DOCKER_IMAGE:$MAJOR_MINOR 30 | docker tag $DOCKER_IMAGE:latest $DOCKER_IMAGE:$MAJOR 31 | docker push $DOCKER_IMAGE:latest 32 | docker push $DOCKER_IMAGE:$MAJOR_MINOR_PATCH 33 | docker push $DOCKER_IMAGE:$MAJOR_MINOR 34 | docker push $DOCKER_IMAGE:$MAJOR 35 | - "@semantic-release/github" -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | **Thank you for your interest in _GitOps CLI_. Your contributions are highly welcome.** 4 | 5 | There are multiple ways of getting involved: 6 | 7 | - [Report a bug](#report-a-bug) 8 | - [Suggest a feature](#suggest-a-feature) 9 | - [Contribute code](#contribute-code) 10 | 11 | Below are a few guidelines we would like you to follow. 12 | If you need help, please reach out to us by opening an issue. 13 | 14 | ## Report a bug 15 | Reporting bugs is one of the best ways to contribute. Before creating a bug report, please check that an [issue](https://github.com/baloise/gitopscli/issues) reporting the same problem does not already exist. If there is such an issue, you may add your information as a comment. 16 | 17 | To report a new bug you should open an issue that summarizes the bug and set the label to ![bug](https://img.shields.io/badge/-bug-d73a4a). 18 | 19 | If you want to provide a fix along with your bug report: That is great! In this case please send us a pull request as described in section [Contribute code](#contribute-code). 20 | 21 | ## Suggest a feature 22 | To request a new feature you should open an [issue](https://github.com/baloise/gitopscli/issues/new) and summarize the desired functionality and its use case. Set the issue label to ![enhancement](https://img.shields.io/badge/-enhancement-52d13e). 23 | 24 | ## Contribute code 25 | This is an outline of what the workflow for code contributions looks like 26 | 27 | - Check the list of open [issues](https://github.com/baloise/gitopscli/issues). Either assign an existing issue to yourself, or 28 | create a new one that you would like work on and discuss your ideas and use cases. 29 | 30 | It is always best to discuss your plans beforehand, to ensure that your contribution is in line with our goals. 31 | 32 | - Fork the repository on GitHub 33 | - Create a topic branch from where you want to base your work. This is usually master. 34 | - Open a new pull request, label it ![work in progress](https://img.shields.io/badge/-work%20in%20progress-fc9979) and outline what you will be contributing 35 | - Make commits of logical units. 36 | - Make sure you sign-off on your commits `git commit -s -m "feat(xyz): added feature xyz"` 37 | - Write good commit messages (see below). 38 | - Push your changes to a topic branch in your fork of the repository. 39 | - As you push your changes, update the pull request with new information and tasks as you complete them 40 | - Project maintainers might comment on your work as you progress 41 | - When you are done, remove the ![work in progress](https://img.shields.io/badge/-work%20in%20progress-fc9979) label and ping the maintainers for a review 42 | - Your pull request must receive a :thumbsup: from two [MAINTAINERS](https://github.com/baloise/gitopscli/blob/master/docs/CODEOWNERS) 43 | 44 | Thanks for your contributions! 45 | 46 | ### Commit messages 47 | We are using the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) convention for our commit messages. This convention dovetails with [SemVer](https://semver.org/), by describing the features, fixes, and breaking changes made in commit messages. 48 | 49 | When creating a pull request, its description should reference the corresponding issue id. 50 | 51 | ### Sign your work / Developer certificate of origin 52 | All contributions (including pull requests) must agree to the Developer Certificate of Origin (DCO) version 1.1. This is exactly the same one created and used by the Linux kernel developers and posted on [http://developercertificate.org/](http://developercertificate.org/). This is a developer's certification that he or she has the right to submit the patch for inclusion into the project. Simply submitting a contribution implies this agreement, however, please include a `Signed-off-by` tag in every patch (this tag is a conventional way to confirm that you agree to the DCO) - you can automate this with a [Git hook](https://stackoverflow.com/questions/15015894/git-add-signed-off-by-line-using-format-signoff-not-working) 53 | 54 | ``` 55 | git commit -s -m "feat(xyz): added feature xyz" 56 | ``` 57 | 58 | **Have fun, and happy hacking!** 59 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ========= 2 | FROM alpine:3.18 AS base 3 | 4 | ENV PATH="/app/.venv/bin:$PATH" \ 5 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 6 | PIP_NO_CACHE_DIR=1 \ 7 | PYTHONFAULTHANDLER=1 \ 8 | PYTHONHASHSEED=random \ 9 | PYTHONUNBUFFERED=1 10 | RUN apk add --no-cache git python3 11 | 12 | # ========= 13 | FROM base AS dev 14 | 15 | WORKDIR /app 16 | RUN apk add --no-cache gcc linux-headers musl-dev make poetry python3-dev 17 | COPY pyproject.toml poetry.lock poetry.toml ./ 18 | 19 | # ========= 20 | FROM dev AS deps 21 | 22 | RUN poetry install --only main 23 | 24 | # ========= 25 | FROM deps AS test 26 | 27 | RUN poetry install --with test 28 | COPY . . 29 | RUN pip install . 30 | RUN make checks 31 | 32 | # ========= 33 | FROM deps AS docs 34 | 35 | RUN poetry install --with docs 36 | COPY docs ./docs 37 | COPY CONTRIBUTING.md mkdocs.yml ./ 38 | RUN mkdocs build 39 | 40 | # ========= 41 | FROM scratch AS docs-site 42 | 43 | COPY --from=docs /app/site /site 44 | 45 | # ========= 46 | FROM deps AS install 47 | 48 | COPY . . 49 | RUN poetry build 50 | RUN pip install dist/gitopscli-*.whl 51 | 52 | # ========= 53 | FROM base as final 54 | 55 | COPY --from=install /app/.venv /app/.venv 56 | ENTRYPOINT ["gitopscli"] 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | poetry install 3 | pre-commit install 4 | 5 | format: 6 | poetry run ruff format gitopscli tests 7 | poetry run ruff gitopscli tests --fix 8 | 9 | format-check: 10 | poetry run ruff format gitopscli tests --check 11 | 12 | lint: 13 | poetry run ruff gitopscli tests 14 | 15 | mypy: 16 | poetry run mypy --install-types --non-interactive . 17 | 18 | 19 | test: 20 | poetry run pytest -vv -s --typeguard-packages=gitopscli 21 | 22 | coverage: 23 | coverage run -m pytest 24 | coverage html 25 | coverage report 26 | 27 | checks: format-check lint mypy test 28 | 29 | image: 30 | DOCKER_BUILDKIT=1 docker build --progress=plain -t gitopscli:latest . 31 | 32 | docs: 33 | mkdocs serve 34 | 35 | update: 36 | poetry lock 37 | 38 | .PHONY: init format format-check lint mypy test coverage checks image docs update 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/baloise/gitopscli/actions/workflows/release.yml/badge.svg)](https://github.com/baloise/gitopscli/actions/workflows/release.yml) 2 | [![Latest Release)](https://img.shields.io/github/v/release/baloise/gitopscli)](https://github.com/baloise/gitopscli/releases) 3 | [![Docker Pulls](https://img.shields.io/docker/pulls/baloise/gitopscli)](https://hub.docker.com/r/baloise/gitopscli/tags) 4 | [![Python: 3.10](https://img.shields.io/badge/python-3.10-yellow.svg)](https://www.python.org/downloads/release/python-3108/) 5 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 6 | [![Gitpod](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/#https://github.com/baloise/gitopscli) 7 | [![License](https://img.shields.io/github/license/baloise/gitopscli?color=lightgrey)](https://github.com/baloise/gitopscli/blob/master/LICENSE) 8 | 9 | # GitOps CLI 10 | 11 | GitOps CLI is a command line interface (CLI) to perform operations on GitOps managed infrastructure repositories, including updates in YAML files. 12 | 13 | ![GitOps CLI Teaser](docs/assets/images/teaser.png) 14 | 15 | ## Quick Start 16 | The official GitOps CLI Docker image comes with all dependencies pre-installed and ready-to-use. Pull it with: 17 | ```bash 18 | docker pull baloise/gitopscli 19 | ``` 20 | Start the CLI and the print the help page with: 21 | ```bash 22 | docker run --rm -it baloise/gitopscli --help 23 | ``` 24 | 25 | ## Features 26 | - Update YAML values in config repository to e.g. deploy an application. 27 | - Add pull request comments. 28 | - Create and delete preview environments in the config repository for a pull request in an app repository. 29 | - Update root config repository with all apps from child config repositories. 30 | 31 | For detailed installation and usage instructions, visit [https://baloise.github.io/gitopscli/](https://baloise.github.io/gitopscli/). 32 | 33 | ## Git Provider Support 34 | Currently, we support BitBucket Server, GitHub and Gitlab. 35 | 36 | ## Development 37 | 38 | ### Setup 39 | 40 | ```bash 41 | make init # install dependencies, setup dev gitopscli, install pre-commit hooks, ... 42 | ``` 43 | 44 | ### Commands 45 | ```bash 46 | make format # format code 47 | make format-check # check formatting 48 | make lint # run linter 49 | make mypy # run type checks 50 | make test # run unit tests 51 | make coverage # run unit tests and create coverage report 52 | make checks # run all checks (format-check + lint + mypy + test) 53 | make image # build docker image 54 | make docs # serves web docs 55 | make update # update package dependencies 56 | ``` 57 | 58 | ## License 59 | [Apache-2.0](https://choosealicense.com/licenses/apache-2.0/) 60 | -------------------------------------------------------------------------------- /docs/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # see: https://help.github.com/articles/about-codeowners/ 2 | * @christiansiegel @niiku -------------------------------------------------------------------------------- /docs/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/docs/assets/images/favicon.ico -------------------------------------------------------------------------------- /docs/assets/images/logo-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/images/screenshots/example-pr-commits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/docs/assets/images/screenshots/example-pr-commits.png -------------------------------------------------------------------------------- /docs/assets/images/screenshots/example-pr-diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/docs/assets/images/screenshots/example-pr-diff.png -------------------------------------------------------------------------------- /docs/assets/images/screenshots/example-pr-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/docs/assets/images/screenshots/example-pr-overview.png -------------------------------------------------------------------------------- /docs/assets/images/teaser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/docs/assets/images/teaser.png -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 4 | You can find the changelog in the [Github releases](https://github.com/baloise/gitopscli/releases). -------------------------------------------------------------------------------- /docs/commands/add-pr-comment.md: -------------------------------------------------------------------------------- 1 | # add-pr-comment 2 | 3 | The `add-pr-comment` command adds a comment to a pull request. You can also reply to an existing comment by providing the `--parent-id`. 4 | 5 | ## Example 6 | ```bash 7 | gitopscli add-pr-comment \ 8 | --git-provider-url https://bitbucket.baloise.dev \ 9 | --username $GIT_USERNAME \ 10 | --password $GIT_PASSWORD \ 11 | --organisation "my-team" \ 12 | --repository-name "my-app" \ 13 | --pr-id 4711 \ 14 | --text "this is a comment" 15 | ``` 16 | 17 | ## Usage 18 | ``` 19 | usage: gitopscli add-pr-comment [-h] --username USERNAME --password PASSWORD 20 | --organisation ORGANISATION --repository-name 21 | REPOSITORY_NAME [--git-provider GIT_PROVIDER] 22 | [--git-provider-url GIT_PROVIDER_URL] --pr-id 23 | PR_ID [--parent-id PARENT_ID] [-v [VERBOSE]] 24 | --text TEXT 25 | 26 | options: 27 | -h, --help show this help message and exit 28 | --username USERNAME Git username (alternative: GITOPSCLI_USERNAME env 29 | variable) 30 | --password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD 31 | env variable) 32 | --organisation ORGANISATION 33 | Apps Git organisation/projectKey 34 | --repository-name REPOSITORY_NAME 35 | Git repository name (not the URL, e.g. my-repo) 36 | --git-provider GIT_PROVIDER 37 | Git server provider 38 | --git-provider-url GIT_PROVIDER_URL 39 | Git provider base API URL (e.g. 40 | https://bitbucket.example.tld) 41 | --pr-id PR_ID the id of the pull request 42 | --parent-id PARENT_ID 43 | the id of the parent comment, in case of a reply 44 | -v [VERBOSE], --verbose [VERBOSE] 45 | Verbose exception logging 46 | --text TEXT the text of the comment 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/commands/create-pr-preview.md: -------------------------------------------------------------------------------- 1 | # create-pr-preview 2 | 3 | The `create-pr-preview` command can be used to create a preview environment in your *deployment config repository* for a pull request of your *app repository*. You can later easily delete this preview with the [`delete-pr-preview` command](/gitopscli/commands/delete-pr-preview/). 4 | 5 | You need to provide some additional configuration files in your repositories for this command to work. 6 | 7 | {!preview-configuration.md!} 8 | 9 | ## Example 10 | 11 | ```bash 12 | gitopscli create-pr-preview \ 13 | --git-provider-url https://bitbucket.baloise.dev \ 14 | --username $GIT_USERNAME \ 15 | --password $GIT_PASSWORD \ 16 | --git-user "GitOps CLI" \ 17 | --git-email "gitopscli@baloise.dev" \ 18 | --organisation "my-team" \ 19 | --repository-name "app-xy" \ 20 | --pr-id 4711 21 | ``` 22 | 23 | ## Usage 24 | ``` 25 | usage: gitopscli create-pr-preview [-h] --username USERNAME --password 26 | PASSWORD [--git-user GIT_USER] 27 | [--git-email GIT_EMAIL] 28 | [--git-author-name GIT_AUTHOR_NAME] 29 | [--git-author-email GIT_AUTHOR_EMAIL] 30 | --organisation ORGANISATION 31 | --repository-name REPOSITORY_NAME 32 | [--git-provider GIT_PROVIDER] 33 | [--git-provider-url GIT_PROVIDER_URL] 34 | --pr-id PR_ID [--parent-id PARENT_ID] 35 | [-v [VERBOSE]] 36 | 37 | options: 38 | -h, --help show this help message and exit 39 | --username USERNAME Git username (alternative: GITOPSCLI_USERNAME env 40 | variable) 41 | --password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD 42 | env variable) 43 | --git-user GIT_USER Git Username 44 | --git-email GIT_EMAIL 45 | Git User Email 46 | --git-author-name GIT_AUTHOR_NAME 47 | Git Author Name 48 | --git-author-email GIT_AUTHOR_EMAIL 49 | Git Author Email 50 | --organisation ORGANISATION 51 | Apps Git organisation/projectKey 52 | --repository-name REPOSITORY_NAME 53 | Git repository name (not the URL, e.g. my-repo) 54 | --git-provider GIT_PROVIDER 55 | Git server provider 56 | --git-provider-url GIT_PROVIDER_URL 57 | Git provider base API URL (e.g. 58 | https://bitbucket.example.tld) 59 | --pr-id PR_ID the id of the pull request 60 | --parent-id PARENT_ID 61 | the id of the parent comment, in case of a reply 62 | -v [VERBOSE], --verbose [VERBOSE] 63 | Verbose exception logging 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/commands/create-preview.md: -------------------------------------------------------------------------------- 1 | # create-preview 2 | 3 | The `create-preview` command can be used to create a preview environment in your *deployment config repository* for a commit hash of your *app repository*. You can later easily delete this preview with the [`delete-preview` command](/gitopscli/commands/delete-preview/). 4 | 5 | You need to provide some additional configuration files in your repositories for this command to work. 6 | 7 | {!preview-configuration.md!} 8 | 9 | ## Returned Information 10 | 11 | After running this command you'll find a YAML file at `/tmp/gitopscli-preview-info.yaml`. It contains generated information about your preview environment: 12 | 13 | ```yaml 14 | previewId: PREVIEW_ID 15 | previewIdHash: 685912d3 16 | routeHost: app.xy-685912d3.example.tld 17 | namespace: my-app-685912d3-preview 18 | ``` 19 | 20 | ## Example 21 | 22 | ```bash 23 | gitopscli create-preview \ 24 | --git-provider-url https://bitbucket.baloise.dev \ 25 | --username $GIT_USERNAME \ 26 | --password $GIT_PASSWORD \ 27 | --git-user "GitOps CLI" \ 28 | --git-email "gitopscli@baloise.dev" \ 29 | --organisation "my-team" \ 30 | --repository-name "app-xy" \ 31 | --git-hash "c0784a34e834117e1489973327ff4ff3c2582b94" \ 32 | --preview-id "test-preview-id" \ 33 | ``` 34 | 35 | ## Usage 36 | ``` 37 | usage: gitopscli create-preview [-h] --username USERNAME --password PASSWORD 38 | [--git-user GIT_USER] [--git-email GIT_EMAIL] 39 | [--git-author-name GIT_AUTHOR_NAME] 40 | [--git-author-email GIT_AUTHOR_EMAIL] 41 | --organisation ORGANISATION --repository-name 42 | REPOSITORY_NAME [--git-provider GIT_PROVIDER] 43 | [--git-provider-url GIT_PROVIDER_URL] 44 | --git-hash GIT_HASH --preview-id PREVIEW_ID 45 | [-v [VERBOSE]] 46 | 47 | options: 48 | -h, --help show this help message and exit 49 | --username USERNAME Git username (alternative: GITOPSCLI_USERNAME env 50 | variable) 51 | --password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD 52 | env variable) 53 | --git-user GIT_USER Git Username 54 | --git-email GIT_EMAIL 55 | Git User Email 56 | --git-author-name GIT_AUTHOR_NAME 57 | Git Author Name 58 | --git-author-email GIT_AUTHOR_EMAIL 59 | Git Author Email 60 | --organisation ORGANISATION 61 | Apps Git organisation/projectKey 62 | --repository-name REPOSITORY_NAME 63 | Git repository name (not the URL, e.g. my-repo) 64 | --git-provider GIT_PROVIDER 65 | Git server provider 66 | --git-provider-url GIT_PROVIDER_URL 67 | Git provider base API URL (e.g. 68 | https://bitbucket.example.tld) 69 | --git-hash GIT_HASH the git hash which should be deployed 70 | --preview-id PREVIEW_ID 71 | The user-defined preview ID 72 | -v [VERBOSE], --verbose [VERBOSE] 73 | Verbose exception logging 74 | ``` 75 | -------------------------------------------------------------------------------- /docs/commands/delete-pr-preview.md: -------------------------------------------------------------------------------- 1 | # delete-pr-preview 2 | 3 | The `delete-pr-preview` command can be used to delete a preview previously created with the [`create-pr-preview` command](/gitopscli/commands/create-pr-preview/). Please refer to `create-pr-preview` documentation for the needed configuration files. 4 | 5 | ## Example 6 | 7 | ```bash 8 | gitopscli delete-pr-preview \ 9 | --git-provider-url https://bitbucket.baloise.dev \ 10 | --username $GIT_USERNAME \ 11 | --password $GIT_PASSWORD \ 12 | --git-user "GitOps CLI" \ 13 | --git-email "gitopscli@baloise.dev" \ 14 | --organisation "my-team" \ 15 | --repository-name "app-xy" \ 16 | --branch "my-pr-branch" \ 17 | ``` 18 | 19 | ## Usage 20 | ``` 21 | usage: gitopscli delete-pr-preview [-h] --username USERNAME --password 22 | PASSWORD [--git-user GIT_USER] 23 | [--git-email GIT_EMAIL] 24 | [--git-author-name GIT_AUTHOR_NAME] 25 | [--git-author-email GIT_AUTHOR_EMAIL] 26 | --organisation ORGANISATION 27 | --repository-name REPOSITORY_NAME 28 | [--git-provider GIT_PROVIDER] 29 | [--git-provider-url GIT_PROVIDER_URL] 30 | --branch BRANCH 31 | [--expect-preview-exists [EXPECT_PREVIEW_EXISTS]] 32 | [-v [VERBOSE]] 33 | 34 | options: 35 | -h, --help show this help message and exit 36 | --username USERNAME Git username (alternative: GITOPSCLI_USERNAME env 37 | variable) 38 | --password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD 39 | env variable) 40 | --git-user GIT_USER Git Username 41 | --git-email GIT_EMAIL 42 | Git User Email 43 | --git-author-name GIT_AUTHOR_NAME 44 | Git Author Name 45 | --git-author-email GIT_AUTHOR_EMAIL 46 | Git Author Email 47 | --organisation ORGANISATION 48 | Apps Git organisation/projectKey 49 | --repository-name REPOSITORY_NAME 50 | Git repository name (not the URL, e.g. my-repo) 51 | --git-provider GIT_PROVIDER 52 | Git server provider 53 | --git-provider-url GIT_PROVIDER_URL 54 | Git provider base API URL (e.g. 55 | https://bitbucket.example.tld) 56 | --branch BRANCH The branch for which the preview was created for 57 | --expect-preview-exists [EXPECT_PREVIEW_EXISTS] 58 | Fail if preview does not exist 59 | -v [VERBOSE], --verbose [VERBOSE] 60 | Verbose exception logging 61 | ``` -------------------------------------------------------------------------------- /docs/commands/delete-preview.md: -------------------------------------------------------------------------------- 1 | # delete-preview 2 | 3 | The `delete-preview` command can be used to delete a preview previously created with the [`create-preview` command](/gitopscli/commands/create-preview/). Please refer to `create-preview` documentation for the needed configuration files. 4 | 5 | ## Example 6 | 7 | ```bash 8 | gitopscli delete-preview \ 9 | --git-provider-url https://bitbucket.baloise.dev \ 10 | --username $GIT_USERNAME \ 11 | --password $GIT_PASSWORD \ 12 | --git-user "GitOps CLI" \ 13 | --git-email "gitopscli@baloise.dev" \ 14 | --organisation "my-team" \ 15 | --repository-name "app-xy" \ 16 | --preview-id "test123" \ 17 | ``` 18 | 19 | ## Usage 20 | ``` 21 | usage: gitopscli delete-preview [-h] --username USERNAME --password PASSWORD 22 | [--git-user GIT_USER] [--git-email GIT_EMAIL] 23 | [--git-author-name GIT_AUTHOR_NAME] 24 | [--git-author-email GIT_AUTHOR_EMAIL] 25 | --organisation ORGANISATION --repository-name 26 | REPOSITORY_NAME [--git-provider GIT_PROVIDER] 27 | [--git-provider-url GIT_PROVIDER_URL] 28 | --preview-id PREVIEW_ID 29 | [--expect-preview-exists [EXPECT_PREVIEW_EXISTS]] 30 | [-v [VERBOSE]] 31 | 32 | options: 33 | -h, --help show this help message and exit 34 | --username USERNAME Git username (alternative: GITOPSCLI_USERNAME env 35 | variable) 36 | --password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD 37 | env variable) 38 | --git-user GIT_USER Git Username 39 | --git-email GIT_EMAIL 40 | Git User Email 41 | --git-author-name GIT_AUTHOR_NAME 42 | Git Author Name 43 | --git-author-email GIT_AUTHOR_EMAIL 44 | Git Author Email 45 | --organisation ORGANISATION 46 | Apps Git organisation/projectKey 47 | --repository-name REPOSITORY_NAME 48 | Git repository name (not the URL, e.g. my-repo) 49 | --git-provider GIT_PROVIDER 50 | Git server provider 51 | --git-provider-url GIT_PROVIDER_URL 52 | Git provider base API URL (e.g. 53 | https://bitbucket.example.tld) 54 | --preview-id PREVIEW_ID 55 | The user-defined preview ID 56 | --expect-preview-exists [EXPECT_PREVIEW_EXISTS] 57 | Fail if preview does not exist 58 | -v [VERBOSE], --verbose [VERBOSE] 59 | Verbose exception logging 60 | ``` -------------------------------------------------------------------------------- /docs/commands/deploy.md: -------------------------------------------------------------------------------- 1 | # deploy 2 | 3 | The `deploy` command can be used to deploy applications by updating the image tags in the YAML files of a config repository. Of course, you can also use it to update any YAML values in a git repository. However, only _one_ YAML can be changed at a time. 4 | 5 | ## Example 6 | Let's assume you have a repository `deployment/myapp-non-prod` which contains your deployment configuration in the form of YAML files (e.g. [Helm](https://helm.sh/) charts). To deploy a new version of your application you need to update some values in `example/values.yaml`. 7 | 8 | ```yaml 9 | # Example Helm values.yaml 10 | frontend: 11 | repository: my-app/frontend 12 | tag: 1.0.0 # <- you want to change this value 13 | backend: 14 | repository: my-app/backend 15 | tag: 1.0.0 # <- and this one 16 | env: 17 | - name: TEST 18 | value: foo # <- and this one in a list, selected via sibling value 'TEST' 19 | ``` 20 | 21 | With the following command GitOps CLI will update all values on the default branch. 22 | 23 | ```bash 24 | gitopscli deploy \ 25 | --git-provider-url https://bitbucket.baloise.dev \ 26 | --username $GIT_USERNAME \ 27 | --password $GIT_PASSWORD \ 28 | --git-user "GitOps CLI" \ 29 | --git-email "gitopscli@baloise.dev" \ 30 | --organisation "deployment" \ 31 | --repository-name "myapp-non-prod" \ 32 | --file "example/values.yaml" \ 33 | --values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env[?name==''TEST''].value': bar}" 34 | ``` 35 | 36 | You could also use the list index to replace the latter (`my-app.env.[0].value`). For more details on the underlying *JSONPath* syntax, please refer to the [documenatation of the used library *jsonpath-ng*](https://github.com/h2non/jsonpath-ng#jsonpath-syntax). 37 | 38 | ### Number Of Commits 39 | 40 | Note that by default GitOps CLI will create a separate commit for every value change: 41 | 42 | ``` 43 | commit 0dcaa136b4c5249576bb1f40b942bff6ac718144 44 | Author: GitOpsCLI 45 | Date: Thu Mar 12 15:30:32 2020 +0100 46 | 47 | changed 'backend.env[?name=='TEST'].value' to 'bar' in example/values.yaml 48 | 49 | commit d98913ad8fecf571d5f8c3635f8070b05c43a9ca 50 | Author: GitOpsCLI 51 | Date: Thu Mar 12 15:30:32 2020 +0100 52 | 53 | changed 'backend.tag' to '1.1.0' in example/values.yaml 54 | 55 | commit 649bc72fe798891244c11809afc9fae83309772a 56 | Author: GitOpsCLI 57 | Date: Thu Mar 12 15:30:32 2020 +0100 58 | 59 | changed 'frontend.tag' to '1.1.0' in example/values.yaml 60 | ``` 61 | 62 | If you prefer to create a single commit for all changes add `--single-commit` to the command: 63 | 64 | ``` 65 | commit 3b96839e90c35b8decf89f34a65ab6d66c8bab28 66 | Author: GitOpsCLI 67 | Date: Thu Mar 12 15:30:00 2020 +0100 68 | 69 | updated 3 values in example/values.yaml 70 | 71 | frontend.tag: '1.1.0' 72 | backend.tag: '1.1.0' 73 | 'backend.env[?name==''TEST''].value': 'bar' 74 | ``` 75 | 76 | ### Specific Commit Message 77 | 78 | If you want to specify the commit message of the deployment then you can use the following param: 79 | 80 | `--commit-message` 81 | 82 | ```bash 83 | gitopscli deploy \ 84 | --git-provider-url https://bitbucket.baloise.dev \ 85 | --username $GIT_USERNAME \ 86 | --password $GIT_PASSWORD \ 87 | --git-user "GitOps CLI" \ 88 | --git-email "gitopscli@baloise.dev" \ 89 | --organisation "deployment" \ 90 | --repository-name "myapp-non-prod" \ 91 | --commit-message "test commit message" \ 92 | --file "example/values.yaml" \ 93 | --values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env[?name==''TEST''].value': bar}" 94 | ``` 95 | 96 | This will end up in one single commit with your specified commit-message. 97 | 98 | ### Create Pull Request 99 | 100 | In some cases you might want to create a pull request for your updates. You can achieve this by adding `--create-pr` to the command. The pull request can be left open or merged directly with `--auto-merge`. 101 | 102 | ```bash 103 | gitopscli deploy \ 104 | --git-provider-url https://bitbucket.baloise.dev \ 105 | --username $GIT_USERNAME \ 106 | --password $GIT_PASSWORD \ 107 | --git-user "GitOps CLI" \ 108 | --git-email "gitopscli@baloise.dev" \ 109 | --organisation "deployment" \ 110 | --repository-name "myapp-non-prod" \ 111 | --file "example/values.yaml" \ 112 | --values "{frontend.tag: 1.1.0, backend.tag: 1.1.0, 'backend.env[?name==''TEST''].value': bar}" \ 113 | --create-pr \ 114 | --auto-merge 115 | ``` 116 | 117 | ![Example PR Overview](../assets/images/screenshots/example-pr-overview.png?raw=true "Example of the created PR") 118 | ![Example PR Diff](../assets/images/screenshots/example-pr-diff.png?raw=true "Example of the PR file diff") 119 | ![Example PR Commits](../assets/images/screenshots/example-pr-commits.png?raw=true "Example of a PR commits") 120 | 121 | 122 | ## Usage 123 | ``` 124 | usage: gitopscli deploy [-h] --file FILE --values VALUES 125 | [--single-commit [SINGLE_COMMIT]] 126 | [--commit-message COMMIT_MESSAGE] --username USERNAME 127 | --password PASSWORD [--git-user GIT_USER] 128 | [--git-email GIT_EMAIL] 129 | [--git-author-name GIT_AUTHOR_NAME] 130 | [--git-author-email GIT_AUTHOR_EMAIL] 131 | --organisation ORGANISATION --repository-name 132 | REPOSITORY_NAME [--git-provider GIT_PROVIDER] 133 | [--git-provider-url GIT_PROVIDER_URL] 134 | [--create-pr [CREATE_PR]] [--auto-merge [AUTO_MERGE]] 135 | [--merge-method MERGE_METHOD] [--json [JSON]] 136 | [--pr-labels PR_LABELS] 137 | [--merge-parameters MERGE_PARAMETERS] [-v [VERBOSE]] 138 | 139 | options: 140 | -h, --help show this help message and exit 141 | --file FILE YAML file path 142 | --values VALUES YAML/JSON object with the YAML path as key and the 143 | desired value as value 144 | --single-commit [SINGLE_COMMIT] 145 | Create only single commit for all updates 146 | --commit-message COMMIT_MESSAGE 147 | Specify exact commit message of deployment commit 148 | --username USERNAME Git username (alternative: GITOPSCLI_USERNAME env 149 | variable) 150 | --password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD 151 | env variable) 152 | --git-user GIT_USER Git Username 153 | --git-email GIT_EMAIL 154 | Git User Email 155 | --git-author-name GIT_AUTHOR_NAME 156 | Git Author Name 157 | --git-author-email GIT_AUTHOR_EMAIL 158 | Git Author Email 159 | --organisation ORGANISATION 160 | Apps Git organisation/projectKey 161 | --repository-name REPOSITORY_NAME 162 | Git repository name (not the URL, e.g. my-repo) 163 | --git-provider GIT_PROVIDER 164 | Git server provider 165 | --git-provider-url GIT_PROVIDER_URL 166 | Git provider base API URL (e.g. https://bitbucket.example.tld) 167 | --create-pr [CREATE_PR] 168 | Creates a Pull Request 169 | --auto-merge [AUTO_MERGE] 170 | Automatically merge the created PR (only valid with --create-pr) 171 | --merge-method MERGE_METHOD 172 | Merge Method (e.g., 'squash', 'rebase', 'merge') (default: merge) 173 | --json [JSON] Print a JSON object containing deployment information 174 | --pr-labels PR_LABELS 175 | JSON array pr labels (Gitlab, Github supported) 176 | --merge-parameters MERGE_PARAMETERS 177 | JSON object pr parameters (only Gitlab supported) 178 | -v [VERBOSE], --verbose [VERBOSE] 179 | Verbose exception logging 180 | 181 | ``` 182 | -------------------------------------------------------------------------------- /docs/commands/sync-apps.md: -------------------------------------------------------------------------------- 1 | # sync-apps 2 | 3 | The `sync-apps` command can be used to keep a *root config repository* in sync with several *app config repositories*. You can use this command if your config repositories are structured in the following (opinionated) way: 4 | 5 | ## Repository Structure 6 | 7 | ### App Config Repositories 8 | 9 | You have `1..n` config repositories for the deployment configurations of your applications (e.g. one per team). Every *app config repository* can contain `0..n` directories (e.g. containing [Helm](https://helm.sh/) charts). Directories starting with a dot will be ignored. Example: 10 | 11 | ``` 12 | team-1-app-config-repo/ 13 | ├── .this-will-be-ignored 14 | ├── app-xy-production 15 | ├── app-xy-staging 16 | └── app-xy-test 17 | ``` 18 | 19 | ### Root Config Repository 20 | 21 | The *root config repository* acts as a single entrypoint for your GitOps continous delivery tool (e.g. [Argo CD](https://argoproj.github.io/argo-cd/)). Here you define all applications in your cluster and link to the *app config repositories* with their deployment configurations. It is structured in the following way: 22 | 23 | ``` 24 | root-config-repo/ 25 | ├── apps 26 | │   ├── team-a.yaml 27 | │   └── team-b.yaml 28 | └── bootstrap 29 | └── values.yaml 30 | ``` 31 | ### app specific values 32 | app specific values may be set using a .config.yaml file directly in the app directory. gitopscli will process these values and add them under customAppConfig parameter of application 33 | **tenantrepo.git/app1/app_value_file.yaml** 34 | ```yaml 35 | customvalue: test 36 | ``` 37 | **rootrepo.git/apps/tenantrepo.yaml** 38 | ```yaml 39 | config: 40 | repository: https://tenantrepo.git 41 | applications: 42 | app1: 43 | customAppConfig: 44 | customvalue: test 45 | app2: {} 46 | ``` 47 | 48 | **bootstrap/values.yaml** 49 | ```yaml 50 | bootstrap: 51 | - name: team-a # <- every entry links to a YAML file in the `apps/` directory 52 | - name: team-b 53 | ``` 54 | Alternative, when using a Chart as dependency with an alias 'config': 55 | ```yaml 56 | config: 57 | bootstrap: 58 | - name: team-a # <- every entry links to a YAML file in the `apps/` directory 59 | - name: team-b 60 | ``` 61 | 62 | **apps/team-a.yaml** 63 | ```yaml 64 | repository: https://github.com/company-deployments/team-1-app-config-repo.git # link to your apps root repository 65 | 66 | # The applications that are synced by the `sync-app` command: 67 | applications: 68 | app-xy-production: # <- every entry corresponds to a directory in the apps root repository 69 | app-xy-staging: 70 | app-xy-test: 71 | ``` 72 | or 73 | 74 | ```yaml 75 | config: 76 | repository: https://github.com/company-deployments/team-1-app-config-repo.git # link to your apps root repository 77 | 78 | # The applications that are synced by the `sync-app` command: 79 | applications: 80 | app-xy-production: # <- every entry corresponds to a directory in the apps root repository 81 | app-xy-staging: 82 | app-xy-test: 83 | ``` 84 | 85 | ## Example 86 | 87 | ```bash 88 | gitopscli sync-apps \ 89 | --git-provider-url github \ 90 | --username $GIT_USERNAME \ 91 | --password $GIT_PASSWORD \ 92 | --git-user "GitOps CLI" \ 93 | --git-email "gitopscli@baloise.dev" \ 94 | --organisation "company-deployments" \ 95 | --repository-name "team-1-app-config-repo" \ 96 | --root-organisation "company-deployments" \ 97 | --root-repository-name "root-config-repo" 98 | ``` 99 | 100 | ## Usage 101 | ``` 102 | usage: gitopscli sync-apps [-h] --username USERNAME --password PASSWORD 103 | [--git-user GIT_USER] [--git-email GIT_EMAIL] 104 | [--git-author-name GIT_AUTHOR_NAME] 105 | [--git-author-email GIT_AUTHOR_EMAIL] 106 | --organisation ORGANISATION --repository-name 107 | REPOSITORY_NAME [--git-provider GIT_PROVIDER] 108 | [--git-provider-url GIT_PROVIDER_URL] 109 | [-v [VERBOSE]] --root-organisation 110 | ROOT_ORGANISATION --root-repository-name 111 | ROOT_REPOSITORY_NAME 112 | 113 | options: 114 | -h, --help show this help message and exit 115 | --username USERNAME Git username (alternative: GITOPSCLI_USERNAME env 116 | variable) 117 | --password PASSWORD Git password or token (alternative: GITOPSCLI_PASSWORD 118 | env variable) 119 | --git-user GIT_USER Git Username 120 | --git-email GIT_EMAIL 121 | Git User Email 122 | --git-author-name GIT_AUTHOR_NAME 123 | Git Author Name 124 | --git-author-email GIT_AUTHOR_EMAIL 125 | Git Author Email 126 | --organisation ORGANISATION 127 | Apps Git organisation/projectKey 128 | --repository-name REPOSITORY_NAME 129 | Git repository name (not the URL, e.g. my-repo) 130 | --git-provider GIT_PROVIDER 131 | Git server provider 132 | --git-provider-url GIT_PROVIDER_URL 133 | Git provider base API URL (e.g. 134 | https://bitbucket.example.tld) 135 | -v [VERBOSE], --verbose [VERBOSE] 136 | Verbose exception logging 137 | --root-organisation ROOT_ORGANISATION 138 | Root config repository organisation 139 | --root-repository-name ROOT_REPOSITORY_NAME 140 | Root config repository name 141 | ``` 142 | -------------------------------------------------------------------------------- /docs/commands/version.md: -------------------------------------------------------------------------------- 1 | # version 2 | 3 | The `version` command shows the GitOps CLI version information. 4 | 5 | ## Example 6 | 7 | ```bash 8 | gitopscli version 9 | ``` 10 | 11 | ## Usage 12 | ``` 13 | usage: gitopscli version [-h] 14 | 15 | options: 16 | -h, --help show this help message and exit 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ../CONTRIBUTING.md -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | The GitOps CLI provides several commands which can be used to perform typical operations on GitOps managed infrastructure repositories. You can print a help page listing all available commands with `gitopscli --help`: 4 | 5 | ``` 6 | usage: gitopscli [-h] 7 | {deploy,sync-apps,add-pr-comment,create-preview,delete-preview,version} 8 | ... 9 | 10 | GitOps CLI 11 | 12 | options: 13 | -h, --help show this help message and exit 14 | 15 | commands: 16 | {deploy,sync-apps,add-pr-comment,create-preview,delete-preview,version} 17 | deploy Trigger a new deployment by changing YAML values 18 | sync-apps Synchronize applications (= every directory) from apps 19 | config repository to apps root config 20 | add-pr-comment Create a comment on the pull request 21 | create-preview Create a preview environment 22 | delete-preview Delete a preview environment 23 | version Show the GitOps CLI version information 24 | ``` 25 | 26 | A detailed description of the individual commands including some examples can be found in the [CLI Commands](/gitopscli/commands/add-pr-comment/) section. 27 | -------------------------------------------------------------------------------- /docs/includes/preview-configuration.md: -------------------------------------------------------------------------------- 1 | ## Configuration 2 | ### Preview Templates 3 | 4 | You have to provide a folder with the deployment configuration templates for every application you want to use this command for. By default it is assumed that this folder is located in your *deployment config repository* under the top-level folder `.preview-templates`. For example `.preview-templates/app-xy` for your app `app-xy`. The `create-preview` command simply copies this directory to the root of your *deployment config repository* and replaces e.g. image tag and route host which are specific to this preview. 5 | 6 | ``` 7 | deployment-config-repo/ 8 | ├── .preview-templates 9 | │   └── app-xy <- Can contain any files and folders 10 | │   ├── values.yaml 11 | │   └── some-more-config-files-or-folders 12 | ├── app-xy-production 13 | ├── app-xy-staging 14 | ├── app-xy-test 15 | └── app-xy-my-branch-c7003101-preview <- This is how a created preview looks by default 16 | ├── values.yaml <- e.g. image tag and route host are replaced in this one 17 |    └── some-more-config-files-or-folders 18 | ``` 19 | 20 | ### .gitops.config.yaml 21 | 22 | Make sure that your *app repository* contains a `.gitops.config.yaml` file. This file provides all information to 23 | 24 | 1. find repository, branch, and folder containing the template 25 | 2. templates for host and namespace name 26 | 3. replace values in template files (see [`deploy` command](/gitopscli/commands/deploy/) for details on the key syntax) 27 | 4. find repository and branch where the preview should be created (i.e. your *deployment config repository*) 28 | 5. message templates used to comment your pull request 29 | 30 | ```yaml 31 | apiVersion: v2 32 | applicationName: app-xy 33 | # messages: # optional section 34 | # previewEnvCreated: "Created preview at revision ${GIT_HASH}. You can access it here: https://${PREVIEW_HOST}/some-fancy-path" # optional (default: "New preview environment created for version `${GIT_HASH}`. Access it here: https://${PREVIEW_HOST}") 35 | # previewEnvUpdated: "Updated preview to revision ${GIT_HASH}. You can access it here: https://${PREVIEW_HOST}/some-fancy-path" # optional (default: "Preview environment updated to version `${GIT_HASH}`. Access it here: https://${PREVIEW_HOST}") 36 | # previewEnvAlreadyUpToDate: "Your preview is already up-to-date with revision ${GIT_HASH}." # optional (default: "The version `${GIT_HASH}` has already been deployed. Access it here: https://${PREVIEW_HOST}") 37 | previewConfig: 38 | host: ${PREVIEW_NAMESPACE}.example.tld 39 | # template: # optional section 40 | # organisation: templates # optional (default: target.organisation) 41 | # repository: template-repo # optional (default: target.repository) 42 | # branch: master # optional (default: target.branch) 43 | # path: custom/${APPLICATION_NAME} # optional (default: '.preview-templates/${APPLICATION_NAME}') 44 | target: 45 | organisation: deployments 46 | repository: deployment-config-repo 47 | # branch: master # optional (defaults to repo's default branch) 48 | # namespace: ${APPLICATION_NAME}-${PREVIEW_ID_HASH}-preview' # optional (default: '${APPLICATION_NAME}-${PREVIEW_ID}-${PREVIEW_ID_HASH_SHORT}-preview', 49 | # Invalid characters in PREVIEW_ID will be replaced. PREVIEW_ID will be 50 | # truncated if max namespace length exceeds `maxNamespaceLength` chars.) 51 | # maxNamespaceLength: 63 # optional (default: 53) 52 | replace: 53 | Chart.yaml: 54 | - path: name 55 | value: ${PREVIEW_NAMESPACE} 56 | values.yaml: 57 | - path: app.image 58 | value: registry.example.tld/my-app:${GIT_HASH} 59 | - path: route.host 60 | value: ${PREVIEW_HOST} 61 | ``` 62 | 63 | !!! info 64 | If you currently use the _old_ `.gitops.config.yaml` format (_v0_) you may find this [online converter](https://christiansiegel.github.io/gitopscli-config-converter/) helpful to transition to the current `apiVersion v2`. 65 | 66 | !!! warning 67 | The _old_ (_v0_) version and `apiVersion v1` are marked deprecated and will be removed in `gitopscli` version 6.0.0. 68 | 69 | Equivalent example: 70 | 71 | ```yaml 72 | # old 'v0' format 73 | deploymentConfig: 74 | org: deployments 75 | repository: deployment-config-repo 76 | applicationName: app-xy 77 | previewConfig: 78 | route: 79 | host: 80 | template: app-xy-{SHA256_8CHAR_BRANCH_HASH}.example.tld 81 | replace: 82 | - path: image.tag 83 | variable: GIT_COMMIT 84 | - path: route.host 85 | variable: ROUTE_HOST 86 | ``` 87 | 88 | ```yaml 89 | # v2 format 90 | apiVersion: v2 91 | applicationName: app-xy 92 | previewConfig: 93 | host: ${PREVIEW_NAMESPACE}.example.tld 94 | target: 95 | organisation: deployments 96 | repository: deployment-config-repo 97 | namespace: ${APPLICATION_NAME}-${PREVIEW_ID_HASH}-preview 98 | replace: 99 | Chart.yaml: 100 | - path: name 101 | value: ${PREVIEW_NAMESPACE} 102 | values.yaml: 103 | - path: image.tag 104 | value: ${GIT_HASH} 105 | - path: route.host 106 | value: ${PREVIEW_HOST} 107 | ``` 108 | 109 | #### Variables 110 | - `APPLICATION_NAME`: value from `applicationName` 111 | - `GIT_HASH`: 112 | - `create-preview`: The CLI provided `--git-hash` 113 | - `create-pr-preview`: The git hash of the *app repository* commit that will be deployed 114 | - `PREVIEW_ID`: 115 | - `create-preview`: The CLI provided `--preview-id` 116 | - `create-pr-preview`: The branch name in the *app repository* 117 | - `PREVIEW_ID_HASH`: The first 8 characters of the SHA256 hash of `PREVIEW_ID` 118 | - `PREVIEW_ID_HASH_SHORT`: The first 3 characters of the SHA256 hash of `PREVIEW_ID` 119 | - `PREVIEW_NAMESPACE`: The resulting value of `previewConfig.target.namespace` 120 | - `PREVIEW_HOST`: The resulting value of `previewConfig.host` 121 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # GitOps CLI 2 | 3 | A command line interface to perform operations on GitOps managed infrastructure repositories. 4 | 5 | ![GitOps CLI Teaser](assets/images/teaser.png){: .center} 6 | 7 | ## Features 8 | - Update YAML values in config repository to e.g. deploy an application 9 | - Add pull request comments 10 | - Create and delete preview environments in the config repository for a pull request in an app repository 11 | - Update root config repository with all apps from child config repositories -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | [Apache-2.0](https://choosealicense.com/licenses/apache-2.0/) 4 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | Currently there are two different ways to setup and use the GitOps CLI. 4 | 5 | ## Docker 6 | 7 | The official GitOps CLI Docker image comes with all dependencies pre-installed and ready-to-use. Pull it with: 8 | ```bash 9 | docker pull baloise/gitopscli 10 | ``` 11 | Start the CLI and the print the help page with: 12 | ```bash 13 | docker run --rm -it baloise/gitopscli --help 14 | ``` 15 | 16 | ## From Source With Virtualenv 17 | 18 | Use this for developement and if you want to prevent dependency clashes with other programs in a user installation. 19 | 20 | Clone the repository and install the GitOps CLI on your machine: 21 | ```bash 22 | git clone https://github.com/baloise/gitopscli.git 23 | cd gitopscli/ 24 | poetry install 25 | ``` 26 | You can now use it from the command line: 27 | ```bash 28 | poetry run gitopscli --help 29 | ``` 30 | If you don't need the CLI anymore, you can uninstall it with 31 | ```bash 32 | poetry env remove --all 33 | ``` 34 | 35 | Note: if your poetry is not up to date to handle the files you can use a locally updated version. 36 | Execute the following command in your cloned gitopscli directory to use an updated poetry without changing your system installation: 37 | ```bash 38 | python3 -m venv .venv 39 | source .venv/bin/activate 40 | pip3 install poetry # installs it in the venv 41 | ``` 42 | 43 | ## From Source Into User Installation 44 | 45 | Clone the repository and install the GitOps CLI on your machine: 46 | ```bash 47 | git clone https://github.com/baloise/gitopscli.git 48 | pip3 install gitopscli/ 49 | ``` 50 | You can now use it from the command line: 51 | ```bash 52 | gitopscli --help 53 | ``` 54 | If you don't need the CLI anymore, you can uninstall it with 55 | ```bash 56 | pip3 uninstall gitopscli 57 | ``` -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | .center { 2 | display: block; 3 | margin: 0 auto; 4 | } -------------------------------------------------------------------------------- /gitopscli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/gitopscli/__init__.py -------------------------------------------------------------------------------- /gitopscli/__main__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | from gitopscli.cliparser import parse_args 5 | from gitopscli.commands import CommandFactory 6 | from gitopscli.gitops_exception import GitOpsException 7 | 8 | 9 | def main() -> None: 10 | logging.basicConfig(level=logging.INFO, format="%(levelname)-2s %(funcName)s: %(message)s") 11 | verbose, args = parse_args(sys.argv[1:]) 12 | command = CommandFactory.create(args) 13 | try: 14 | command.execute() 15 | except GitOpsException as ex: 16 | if verbose: 17 | logging.exception(ex) # noqa: TRY401 18 | else: 19 | logging.error(ex) # noqa: TRY400 20 | logging.error("Provide verbose flag '-v' for more error details...") # noqa: TRY400 21 | sys.exit(1) 22 | 23 | 24 | if __name__ == "__main__": 25 | main() 26 | -------------------------------------------------------------------------------- /gitopscli/appconfig_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/gitopscli/appconfig_api/__init__.py -------------------------------------------------------------------------------- /gitopscli/appconfig_api/app_tenant_config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from dataclasses import dataclass, field 4 | from pathlib import Path 5 | from typing import Any 6 | 7 | from gitopscli.git_api import GitRepo 8 | from gitopscli.gitops_exception import GitOpsException 9 | from gitopscli.io_api.yaml_util import yaml_file_load, yaml_load 10 | 11 | 12 | @dataclass 13 | class AppTenantConfig: 14 | yaml: dict[str, dict[str, Any]] 15 | tenant_config: dict[str, dict[str, Any]] = field(default_factory=dict) 16 | repo_url: str = "" 17 | file_path: str = "" 18 | dirty: bool = False 19 | 20 | def __post_init__(self) -> None: 21 | self.tenant_config = self.yaml.get("config", self.yaml) 22 | if "repository" not in self.tenant_config: 23 | raise GitOpsException("Cannot find key 'repository' in " + self.file_path) 24 | self.repo_url = str(self.tenant_config["repository"]) 25 | 26 | def list_apps(self) -> dict[str, dict[str, Any]]: 27 | return dict(self.tenant_config["applications"]) 28 | 29 | def merge_applications(self, desired_tenant_config: "AppTenantConfig") -> None: 30 | desired_apps = desired_tenant_config.list_apps() 31 | self.__delete_removed_applications(desired_apps) 32 | self.__add_new_applications(desired_apps) 33 | self.__update_custom_app_config(desired_apps) 34 | 35 | def __update_custom_app_config(self, desired_apps: dict[str, dict[str, Any]]) -> None: 36 | for desired_app_name, desired_app_value in desired_apps.items(): 37 | if desired_app_name in self.list_apps(): 38 | existing_application_value = self.list_apps()[desired_app_name] 39 | if "customAppConfig" not in desired_app_value: 40 | if existing_application_value and "customAppConfig" in existing_application_value: 41 | logging.info( 42 | "Removing customAppConfig in for %s in %s applications", 43 | existing_application_value, 44 | self.file_path, 45 | ) 46 | del existing_application_value["customAppConfig"] 47 | self.__set_dirty() 48 | elif ( 49 | "customAppConfig" not in existing_application_value 50 | or existing_application_value["customAppConfig"] != desired_app_value["customAppConfig"] 51 | ): 52 | logging.info( 53 | "Updating customAppConfig in for %s in %s applications", 54 | existing_application_value, 55 | self.file_path, 56 | ) 57 | existing_application_value["customAppConfig"] = desired_app_value["customAppConfig"] 58 | self.__set_dirty() 59 | 60 | def __add_new_applications(self, desired_apps: dict[str, Any]) -> None: 61 | for desired_app_name, desired_app_value in desired_apps.items(): 62 | if desired_app_name not in self.list_apps(): 63 | logging.info("Adding %s in %s applications", desired_app_name, self.file_path) 64 | self.tenant_config["applications"][desired_app_name] = desired_app_value 65 | self.__set_dirty() 66 | 67 | def __delete_removed_applications(self, desired_apps: dict[str, Any]) -> None: 68 | for current_app in self.list_apps(): 69 | if current_app not in desired_apps: 70 | logging.info("Removing %s from %s applications", current_app, self.file_path) 71 | del self.tenant_config["applications"][current_app] 72 | self.__set_dirty() 73 | 74 | def __set_dirty(self) -> None: 75 | self.dirty = True 76 | 77 | 78 | def __generate_config_from_tenant_repo( 79 | tenant_repo: GitRepo, 80 | ) -> Any: 81 | tenant_app_dirs = __get_all_tenant_applications_dirs(tenant_repo) 82 | tenant_config_template = f""" 83 | config: 84 | repository: {tenant_repo.get_clone_url()} 85 | applications: {{}} 86 | """ 87 | yaml = yaml_load(tenant_config_template) 88 | for app_dir in tenant_app_dirs: 89 | tenant_application_template = f""" 90 | {app_dir}: {{}} 91 | """ 92 | tenant_applications_yaml = yaml_load(tenant_application_template) 93 | # dict path hardcoded as object generated will always be in v2 or later 94 | yaml["config"]["applications"].update(tenant_applications_yaml) 95 | custom_app_config = __get_custom_config(app_dir, tenant_repo) 96 | if custom_app_config: 97 | yaml["config"]["applications"][app_dir]["customAppConfig"] = custom_app_config 98 | return yaml 99 | 100 | 101 | def __get_all_tenant_applications_dirs(tenant_repo: GitRepo) -> set[str]: 102 | repo_dir = tenant_repo.get_full_file_path(".") 103 | return {name for name in os.listdir(repo_dir) if (Path(repo_dir) / name).is_dir() and not name.startswith(".")} 104 | 105 | 106 | def __get_custom_config(appname: str, tenant_config_git_repo: GitRepo) -> Any: 107 | custom_config_path = tenant_config_git_repo.get_full_file_path(f"{appname}/.config.yaml") 108 | if Path(custom_config_path).exists(): 109 | return yaml_file_load(custom_config_path) 110 | return {} 111 | 112 | 113 | def create_app_tenant_config_from_repo( 114 | tenant_repo: GitRepo, 115 | ) -> "AppTenantConfig": 116 | tenant_repo.clone() 117 | tenant_config_yaml = __generate_config_from_tenant_repo(tenant_repo) 118 | return AppTenantConfig(yaml=tenant_config_yaml) 119 | -------------------------------------------------------------------------------- /gitopscli/appconfig_api/root_repo.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any 3 | 4 | from gitopscli.appconfig_api.app_tenant_config import AppTenantConfig 5 | from gitopscli.git_api import GitRepo 6 | from gitopscli.gitops_exception import GitOpsException 7 | from gitopscli.io_api.yaml_util import yaml_file_load 8 | 9 | 10 | @dataclass 11 | class RootRepo: 12 | tenants: dict[str, AppTenantConfig] 13 | 14 | def list_tenants(self) -> list[str]: 15 | return list(self.tenants.keys()) 16 | 17 | def get_tenant_by_repo_url(self, repo_url: str) -> AppTenantConfig | None: 18 | for tenant in self.tenants.values(): 19 | if tenant.repo_url == repo_url: 20 | return tenant 21 | return None 22 | 23 | def get_all_applications(self) -> list[str]: 24 | apps: list[str] = [] 25 | for tenant in self.tenants.values(): 26 | apps.extend(tenant.list_apps().keys()) 27 | return apps 28 | 29 | def validate_tenant(self, tenant_config: AppTenantConfig) -> None: 30 | apps_from_other_tenants: list[str] = [] 31 | for tenant in self.tenants.values(): 32 | if tenant.repo_url != tenant_config.repo_url: 33 | apps_from_other_tenants.extend(tenant.list_apps().keys()) 34 | for app_name in tenant_config.list_apps(): 35 | if app_name in apps_from_other_tenants: 36 | raise GitOpsException(f"Application '{app_name}' already exists in a different repository") 37 | 38 | 39 | def __load_tenants_from_bootstrap_values(root_repo: GitRepo) -> dict[str, AppTenantConfig]: 40 | boostrap_tenant_list = __get_bootstrap_tenant_list(root_repo) 41 | tenants = {} 42 | for bootstrap_tenant in boostrap_tenant_list: 43 | try: 44 | tenant_name = bootstrap_tenant["name"] 45 | absolute_tenant_file_path = root_repo.get_full_file_path("apps/" + tenant_name + ".yaml") 46 | yaml = yaml_file_load(absolute_tenant_file_path) 47 | tenants[tenant_name] = AppTenantConfig( 48 | yaml=yaml, 49 | file_path=absolute_tenant_file_path, 50 | ) 51 | except FileNotFoundError as ex: 52 | raise GitOpsException(f"File '{absolute_tenant_file_path}' not found in root repository.") from ex 53 | return tenants 54 | 55 | 56 | def __get_bootstrap_tenant_list(root_repo: GitRepo) -> list[Any]: 57 | root_repo.clone() 58 | try: 59 | boostrap_values_path = root_repo.get_full_file_path("bootstrap/values.yaml") 60 | bootstrap_yaml = yaml_file_load(boostrap_values_path) 61 | except FileNotFoundError as ex: 62 | raise GitOpsException("File 'bootstrap/values.yaml' not found in root repository.") from ex 63 | bootstrap_tenants = [] 64 | if "bootstrap" in bootstrap_yaml: 65 | bootstrap_tenants = list(bootstrap_yaml["bootstrap"]) 66 | if "config" in bootstrap_yaml and "bootstrap" in bootstrap_yaml["config"]: 67 | bootstrap_tenants = list(bootstrap_yaml["config"]["bootstrap"]) 68 | __validate_bootstrap_tenants(bootstrap_tenants) 69 | return bootstrap_tenants 70 | 71 | 72 | def __validate_bootstrap_tenants(bootstrap_entries: list[Any] | None) -> None: 73 | if not bootstrap_entries: 74 | raise GitOpsException("Cannot find key 'bootstrap' or 'config.bootstrap' in 'bootstrap/values.yaml'") 75 | for bootstrap_entry in bootstrap_entries: 76 | if "name" not in bootstrap_entry: 77 | raise GitOpsException("Every bootstrap entry must have a 'name' property.") 78 | 79 | 80 | def create_root_repo(root_repo: GitRepo) -> "RootRepo": 81 | root_repo_tenants = __load_tenants_from_bootstrap_values(root_repo) 82 | return RootRepo(root_repo_tenants) 83 | -------------------------------------------------------------------------------- /gitopscli/cliparser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from argparse import ArgumentParser, ArgumentTypeError 4 | from collections.abc import Callable 5 | from typing import Any, NoReturn 6 | 7 | from gitopscli.commands import ( 8 | AddPrCommentCommand, 9 | CommandArgs, 10 | CreatePreviewCommand, 11 | CreatePrPreviewCommand, 12 | DeletePreviewCommand, 13 | DeletePrPreviewCommand, 14 | DeployCommand, 15 | SyncAppsCommand, 16 | VersionCommand, 17 | ) 18 | from gitopscli.git_api import GitProvider 19 | from gitopscli.io_api.yaml_util import YAMLException, yaml_load 20 | 21 | 22 | def parse_args(raw_args: list[str]) -> tuple[bool, CommandArgs]: 23 | parser = __create_parser() 24 | 25 | if len(raw_args) == 0: 26 | __print_help_and_exit(parser) 27 | 28 | args = vars(parser.parse_args(raw_args)) 29 | args = __deduce_empty_git_provider_from_git_provider_url(args, parser.error) 30 | 31 | verbose = args.pop("verbose", False) 32 | command_args = __create_command_args(args) 33 | 34 | return verbose, command_args 35 | 36 | 37 | def __create_parser() -> ArgumentParser: 38 | parser = ArgumentParser(prog="gitopscli", description="GitOps CLI") 39 | subparsers = parser.add_subparsers(title="commands", dest="command") 40 | subparsers.add_parser( 41 | "deploy", 42 | help="Trigger a new deployment by changing YAML values", 43 | parents=[__create_deploy_parser()], 44 | ) 45 | subparsers.add_parser( 46 | "sync-apps", 47 | help="Synchronize applications (= every directory) from apps config repository to apps root config", 48 | parents=[__create_sync_apps_parser()], 49 | ) 50 | subparsers.add_parser( 51 | "add-pr-comment", 52 | help="Create a comment on the pull request", 53 | parents=[__create_add_pr_comment_parser()], 54 | ) 55 | subparsers.add_parser( 56 | "create-preview", 57 | help="Create a preview environment", 58 | parents=[__create_create_preview_parser()], 59 | ) 60 | subparsers.add_parser( 61 | "create-pr-preview", 62 | help="Create a preview environment", 63 | parents=[__create_create_pr_preview_parser()], 64 | ) 65 | subparsers.add_parser( 66 | "delete-preview", 67 | help="Delete a preview environment", 68 | parents=[__create_delete_preview_parser()], 69 | ) 70 | subparsers.add_parser( 71 | "delete-pr-preview", 72 | help="Delete a pr preview environment", 73 | parents=[__create_delete_pr_preview_parser()], 74 | ) 75 | subparsers.add_parser( 76 | "version", 77 | help="Show the GitOps CLI version information", 78 | parents=[__create_version_parser()], 79 | ) 80 | return parser 81 | 82 | 83 | def __create_deploy_parser() -> ArgumentParser: 84 | parser = ArgumentParser(add_help=False) 85 | parser.add_argument("--file", help="YAML file path", required=True) 86 | parser.add_argument( 87 | "--values", 88 | help="YAML/JSON object with the YAML path as key and the desired value as value", 89 | type=__parse_yaml, 90 | required=True, 91 | ) 92 | parser.add_argument( 93 | "--single-commit", 94 | help="Create only single commit for all updates", 95 | type=__parse_bool, 96 | nargs="?", 97 | const=True, 98 | default=False, 99 | ) 100 | parser.add_argument( 101 | "--commit-message", 102 | help="Specify exact commit message of deployment commit", 103 | type=str, 104 | default=None, 105 | ) 106 | __add_git_credentials_args(parser) 107 | __add_git_commit_user_args(parser) 108 | __add_git_org_and_repo_args(parser) 109 | __add_git_provider_args(parser) 110 | parser.add_argument( 111 | "--create-pr", 112 | help="Creates a Pull Request", 113 | type=__parse_bool, 114 | nargs="?", 115 | const=True, 116 | default=False, 117 | ) 118 | parser.add_argument( 119 | "--auto-merge", 120 | help="Automatically merge the created PR (only valid with --create-pr)", 121 | type=__parse_bool, 122 | nargs="?", 123 | const=True, 124 | default=False, 125 | ) 126 | parser.add_argument( 127 | "--merge-method", 128 | help="Merge Method (e.g., 'squash', 'rebase', 'merge') (default: merge)", 129 | type=str, 130 | default="merge", 131 | ) 132 | parser.add_argument( 133 | "--json", 134 | help="Print a JSON object containing deployment information", 135 | nargs="?", 136 | type=__parse_bool, 137 | default=False, 138 | ) 139 | parser.add_argument( 140 | "--pr-labels", 141 | help="JSON array pr labels (Gitlab, Github supported)", 142 | type=__parse_yaml, 143 | default=None, 144 | ) 145 | parser.add_argument( 146 | "--merge-parameters", 147 | help="JSON object pr parameters (only Gitlab supported)", 148 | type=__parse_yaml, 149 | default=None, 150 | ) 151 | __add_verbose_arg(parser) 152 | return parser 153 | 154 | 155 | def __create_sync_apps_parser() -> ArgumentParser: 156 | parser = ArgumentParser(add_help=False) 157 | __add_git_credentials_args(parser) 158 | __add_git_commit_user_args(parser) 159 | __add_git_org_and_repo_args(parser) 160 | __add_git_provider_args(parser) 161 | __add_verbose_arg(parser) 162 | parser.add_argument("--root-organisation", help="Root config repository organisation", required=True) 163 | parser.add_argument("--root-repository-name", help="Root config repository name", required=True) 164 | return parser 165 | 166 | 167 | def __create_add_pr_comment_parser() -> ArgumentParser: 168 | parser = ArgumentParser(add_help=False) 169 | __add_git_credentials_args(parser) 170 | __add_git_org_and_repo_args(parser) 171 | __add_git_provider_args(parser) 172 | __add_pr_id_arg(parser) 173 | __add_parent_id_arg(parser) 174 | __add_verbose_arg(parser) 175 | parser.add_argument("--text", help="the text of the comment", required=True) 176 | return parser 177 | 178 | 179 | def __create_create_preview_parser() -> ArgumentParser: 180 | parser = ArgumentParser(add_help=False) 181 | __add_git_credentials_args(parser) 182 | __add_git_commit_user_args(parser) 183 | __add_git_org_and_repo_args(parser) 184 | __add_git_provider_args(parser) 185 | parser.add_argument("--git-hash", help="the git hash which should be deployed", type=str, required=True) 186 | __add_preview_id_arg(parser) 187 | __add_verbose_arg(parser) 188 | return parser 189 | 190 | 191 | def __create_create_pr_preview_parser() -> ArgumentParser: 192 | parser = ArgumentParser(add_help=False) 193 | __add_git_credentials_args(parser) 194 | __add_git_commit_user_args(parser) 195 | __add_git_org_and_repo_args(parser) 196 | __add_git_provider_args(parser) 197 | __add_pr_id_arg(parser) 198 | __add_parent_id_arg(parser) 199 | __add_verbose_arg(parser) 200 | return parser 201 | 202 | 203 | def __create_delete_preview_parser() -> ArgumentParser: 204 | parser = ArgumentParser(add_help=False) 205 | __add_git_credentials_args(parser) 206 | __add_git_commit_user_args(parser) 207 | __add_git_org_and_repo_args(parser) 208 | __add_git_provider_args(parser) 209 | __add_preview_id_arg(parser) 210 | __add_expect_preview_exists_arg(parser) 211 | __add_verbose_arg(parser) 212 | return parser 213 | 214 | 215 | def __create_delete_pr_preview_parser() -> ArgumentParser: 216 | parser = ArgumentParser(add_help=False) 217 | __add_git_credentials_args(parser) 218 | __add_git_commit_user_args(parser) 219 | __add_git_org_and_repo_args(parser) 220 | __add_git_provider_args(parser) 221 | parser.add_argument("--branch", help="The branch for which the preview was created for", required=True) 222 | __add_expect_preview_exists_arg(parser) 223 | __add_verbose_arg(parser) 224 | return parser 225 | 226 | 227 | def __create_version_parser() -> ArgumentParser: 228 | return ArgumentParser(add_help=False) 229 | 230 | 231 | def __add_git_credentials_args(deploy_p: ArgumentParser) -> None: 232 | deploy_p.add_argument( 233 | "--username", 234 | help="Git username (alternative: GITOPSCLI_USERNAME env variable)", 235 | required="GITOPSCLI_USERNAME" not in os.environ, 236 | default=os.environ.get("GITOPSCLI_USERNAME"), 237 | ) 238 | deploy_p.add_argument( 239 | "--password", 240 | help="Git password or token (alternative: GITOPSCLI_PASSWORD env variable)", 241 | required="GITOPSCLI_PASSWORD" not in os.environ, 242 | default=os.environ.get("GITOPSCLI_PASSWORD"), 243 | ) 244 | 245 | 246 | def __add_git_commit_user_args(deploy_p: ArgumentParser) -> None: 247 | deploy_p.add_argument("--git-user", help="Git Username", default="GitOpsCLI") 248 | deploy_p.add_argument("--git-email", help="Git User Email", default="gitopscli@baloise.dev") 249 | deploy_p.add_argument("--git-author-name", help="Git Author Name") 250 | deploy_p.add_argument("--git-author-email", help="Git Author Email") 251 | 252 | 253 | def __add_git_org_and_repo_args(deploy_p: ArgumentParser) -> None: 254 | deploy_p.add_argument("--organisation", help="Apps Git organisation/projectKey", required=True) 255 | deploy_p.add_argument("--repository-name", help="Git repository name (not the URL, e.g. my-repo)", required=True) 256 | 257 | 258 | def __add_git_provider_args(deploy_p: ArgumentParser) -> None: 259 | deploy_p.add_argument("--git-provider", help="Git server provider", type=__parse_git_provider) 260 | deploy_p.add_argument("--git-provider-url", help="Git provider base API URL (e.g. https://bitbucket.example.tld)") 261 | 262 | 263 | def __add_pr_id_arg(parser: ArgumentParser) -> None: 264 | parser.add_argument("--pr-id", help="the id of the pull request", type=int, required=True) 265 | 266 | 267 | def __add_parent_id_arg(parser: ArgumentParser) -> None: 268 | parser.add_argument("--parent-id", help="the id of the parent comment, in case of a reply", type=int) 269 | 270 | 271 | def __add_preview_id_arg(parser: ArgumentParser) -> None: 272 | parser.add_argument("--preview-id", help="The user-defined preview ID", type=str, required=True) 273 | 274 | 275 | def __add_expect_preview_exists_arg(parser: ArgumentParser) -> None: 276 | parser.add_argument( 277 | "--expect-preview-exists", 278 | help="Fail if preview does not exist", 279 | type=__parse_bool, 280 | nargs="?", 281 | const=True, 282 | default=False, 283 | ) 284 | 285 | 286 | def __add_verbose_arg(parser: ArgumentParser) -> None: 287 | parser.add_argument( 288 | "-v", 289 | "--verbose", 290 | help="Verbose exception logging", 291 | type=__parse_bool, 292 | nargs="?", 293 | const=True, 294 | default=False, 295 | ) 296 | 297 | 298 | def __parse_bool(value: str) -> bool: 299 | lowercase_value = value.lower() 300 | if lowercase_value in ("yes", "true", "t", "y", "1"): 301 | return True 302 | if lowercase_value in ("no", "false", "f", "n", "0"): 303 | return False 304 | raise ArgumentTypeError(f"invalid bool value: '{value}'") 305 | 306 | 307 | def __parse_yaml(value: str) -> Any: 308 | try: 309 | return yaml_load(value) 310 | except YAMLException as ex: 311 | raise ArgumentTypeError(f"invalid YAML value: '{value}'") from ex 312 | 313 | 314 | def __parse_git_provider(value: str) -> GitProvider: 315 | mapping = {"github": GitProvider.GITHUB, "bitbucket-server": GitProvider.BITBUCKET, "gitlab": GitProvider.GITLAB} 316 | assert set(mapping.values()) == set(GitProvider), "git provider mapping not exhaustive" 317 | lowercase_stripped_value = value.lower().strip() 318 | if lowercase_stripped_value not in mapping: 319 | raise ArgumentTypeError(f"invalid git provider value: '{value}'") 320 | return mapping[lowercase_stripped_value] 321 | 322 | 323 | def __print_help_and_exit(parser: ArgumentParser) -> NoReturn: 324 | parser.print_help(sys.stderr) 325 | parser.exit(2) 326 | 327 | 328 | def __deduce_empty_git_provider_from_git_provider_url( 329 | args: dict[str, Any], 330 | error: Callable[[str], NoReturn], 331 | ) -> dict[str, Any]: 332 | if "git_provider" not in args or args["git_provider"] is not None: 333 | return args 334 | git_provider_url = args["git_provider_url"] 335 | updated_args = dict(args) 336 | if git_provider_url is None: 337 | error("please provide either --git-provider or --git-provider-url") 338 | elif "github" in git_provider_url.lower(): 339 | updated_args["git_provider"] = GitProvider.GITHUB 340 | elif "bitbucket" in git_provider_url.lower(): 341 | updated_args["git_provider"] = GitProvider.BITBUCKET 342 | elif "gitlab" in git_provider_url.lower(): 343 | updated_args["git_provider"] = GitProvider.GITLAB 344 | else: 345 | error("Cannot deduce git provider from --git-provider-url. Please provide --git-provider") 346 | return updated_args 347 | 348 | 349 | def __create_command_args(args: dict[str, Any]) -> CommandArgs: 350 | args = dict(args) 351 | command = args.pop("command") 352 | 353 | command_args: CommandArgs 354 | if command == "deploy": 355 | command_args = DeployCommand.Args(**args) 356 | elif command == "sync-apps": 357 | command_args = SyncAppsCommand.Args(**args) 358 | elif command == "add-pr-comment": 359 | command_args = AddPrCommentCommand.Args(**args) 360 | elif command == "create-preview": 361 | command_args = CreatePreviewCommand.Args(**args) 362 | elif command == "create-pr-preview": 363 | command_args = CreatePrPreviewCommand.Args(**args) 364 | elif command == "delete-preview": 365 | command_args = DeletePreviewCommand.Args(**args) 366 | elif command == "delete-pr-preview": 367 | command_args = DeletePrPreviewCommand.Args(**args) 368 | elif command == "version": 369 | command_args = VersionCommand.Args() 370 | else: 371 | raise RuntimeError(f"Unknown command: {command}") 372 | return command_args 373 | -------------------------------------------------------------------------------- /gitopscli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .add_pr_comment import AddPrCommentCommand 2 | from .command import Command 3 | from .command_factory import CommandArgs, CommandFactory 4 | from .create_pr_preview import CreatePrPreviewCommand 5 | from .create_preview import CreatePreviewCommand 6 | from .delete_pr_preview import DeletePrPreviewCommand 7 | from .delete_preview import DeletePreviewCommand 8 | from .deploy import DeployCommand 9 | from .sync_apps import SyncAppsCommand 10 | from .version import VersionCommand 11 | -------------------------------------------------------------------------------- /gitopscli/commands/add_pr_comment.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from gitopscli.git_api import GitApiConfig, GitRepoApiFactory 4 | 5 | from .command import Command 6 | 7 | 8 | class AddPrCommentCommand(Command): 9 | @dataclass(frozen=True) 10 | class Args(GitApiConfig): 11 | organisation: str 12 | repository_name: str 13 | 14 | pr_id: int 15 | parent_id: int | None 16 | text: str 17 | 18 | def __init__(self, args: Args) -> None: 19 | self.__args = args 20 | 21 | def execute(self) -> None: 22 | args = self.__args 23 | git_repo_api = GitRepoApiFactory.create(args, args.organisation, args.repository_name) 24 | git_repo_api.add_pull_request_comment(args.pr_id, args.text, args.parent_id) 25 | -------------------------------------------------------------------------------- /gitopscli/commands/command.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | 3 | 4 | class Command(metaclass=ABCMeta): 5 | @abstractmethod 6 | def execute(self) -> None: 7 | ... 8 | -------------------------------------------------------------------------------- /gitopscli/commands/command_factory.py: -------------------------------------------------------------------------------- 1 | from .add_pr_comment import AddPrCommentCommand 2 | from .command import Command 3 | from .create_pr_preview import CreatePrPreviewCommand 4 | from .create_preview import CreatePreviewCommand 5 | from .delete_pr_preview import DeletePrPreviewCommand 6 | from .delete_preview import DeletePreviewCommand 7 | from .deploy import DeployCommand 8 | from .sync_apps import SyncAppsCommand 9 | from .version import VersionCommand 10 | 11 | CommandArgs = ( 12 | DeployCommand.Args 13 | | AddPrCommentCommand.Args 14 | | CreatePreviewCommand.Args 15 | | CreatePrPreviewCommand.Args 16 | | DeletePreviewCommand.Args 17 | | DeletePrPreviewCommand.Args 18 | | SyncAppsCommand.Args 19 | | VersionCommand.Args 20 | ) 21 | 22 | 23 | class CommandFactory: 24 | @staticmethod 25 | def create(args: CommandArgs) -> Command: 26 | command: Command | None 27 | if isinstance(args, DeployCommand.Args): 28 | command = DeployCommand(args) 29 | elif isinstance(args, SyncAppsCommand.Args): 30 | command = SyncAppsCommand(args) 31 | elif isinstance(args, AddPrCommentCommand.Args): 32 | command = AddPrCommentCommand(args) 33 | elif isinstance(args, CreatePreviewCommand.Args): 34 | command = CreatePreviewCommand(args) 35 | elif isinstance(args, CreatePrPreviewCommand.Args): 36 | command = CreatePrPreviewCommand(args) 37 | elif isinstance(args, DeletePreviewCommand.Args): 38 | command = DeletePreviewCommand(args) 39 | elif isinstance(args, DeletePrPreviewCommand.Args): 40 | command = DeletePrPreviewCommand(args) 41 | elif isinstance(args, VersionCommand.Args): 42 | command = VersionCommand(args) 43 | return command 44 | -------------------------------------------------------------------------------- /gitopscli/commands/common/__init__.py: -------------------------------------------------------------------------------- 1 | from .gitops_config_loader import load_gitops_config 2 | -------------------------------------------------------------------------------- /gitopscli/commands/common/gitops_config_loader.py: -------------------------------------------------------------------------------- 1 | from gitopscli.git_api import GitApiConfig, GitRepo, GitRepoApiFactory 2 | from gitopscli.gitops_config import GitOpsConfig 3 | from gitopscli.gitops_exception import GitOpsException 4 | from gitopscli.io_api.yaml_util import yaml_file_load 5 | 6 | 7 | def load_gitops_config(git_api_config: GitApiConfig, organisation: str, repository_name: str) -> GitOpsConfig: 8 | git_repo_api = GitRepoApiFactory.create(git_api_config, organisation, repository_name) 9 | with GitRepo(git_repo_api) as git_repo: 10 | git_repo.clone() 11 | gitops_config_file_path = git_repo.get_full_file_path(".gitops.config.yaml") 12 | try: 13 | gitops_config_yaml = yaml_file_load(gitops_config_file_path) 14 | except FileNotFoundError as ex: 15 | raise GitOpsException("No such file: .gitops.config.yaml") from ex 16 | return GitOpsConfig.from_yaml(gitops_config_yaml) 17 | -------------------------------------------------------------------------------- /gitopscli/commands/create_pr_preview.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from gitopscli.git_api import GitApiConfig, GitRepoApiFactory 4 | 5 | from .command import Command 6 | from .create_preview import CreatePreviewCommand 7 | 8 | 9 | class CreatePrPreviewCommand(Command): 10 | @dataclass(frozen=True) 11 | class Args(GitApiConfig): 12 | git_user: str 13 | git_email: str 14 | 15 | git_author_name: str | None 16 | git_author_email: str | None 17 | 18 | organisation: str 19 | repository_name: str 20 | 21 | pr_id: int 22 | parent_id: int | None 23 | 24 | def __init__(self, args: Args) -> None: 25 | self.__args = args 26 | 27 | def execute(self) -> None: 28 | args = self.__args 29 | git_repo_api = GitRepoApiFactory.create(args, args.organisation, args.repository_name) 30 | 31 | pr_branch = git_repo_api.get_pull_request_branch(args.pr_id) 32 | git_hash = git_repo_api.get_branch_head_hash(pr_branch) 33 | 34 | def add_pr_comment(comment: str) -> None: 35 | return git_repo_api.add_pull_request_comment(args.pr_id, comment, args.parent_id) 36 | 37 | create_preview_command = CreatePreviewCommand( 38 | CreatePreviewCommand.Args( 39 | username=args.username, 40 | password=args.password, 41 | git_user=args.git_user, 42 | git_email=args.git_email, 43 | git_author_name=args.git_author_name, 44 | git_author_email=args.git_author_email, 45 | organisation=args.organisation, 46 | repository_name=args.repository_name, 47 | git_provider=args.git_provider, 48 | git_provider_url=args.git_provider_url, 49 | git_hash=git_hash, 50 | preview_id=pr_branch, # use pr_branch as preview id 51 | ), 52 | ) 53 | create_preview_command.register_callbacks( 54 | deployment_already_up_to_date_callback=add_pr_comment, 55 | deployment_updated_callback=add_pr_comment, 56 | deployment_created_callback=add_pr_comment, 57 | ) 58 | create_preview_command.execute() 59 | -------------------------------------------------------------------------------- /gitopscli/commands/create_preview.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | from collections.abc import Callable 4 | from dataclasses import dataclass 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | from gitopscli.git_api import GitApiConfig, GitRepo, GitRepoApi, GitRepoApiFactory 9 | from gitopscli.gitops_config import GitOpsConfig 10 | from gitopscli.gitops_exception import GitOpsException 11 | from gitopscli.io_api.yaml_util import YAMLException, update_yaml_file, yaml_file_dump 12 | 13 | from .command import Command 14 | from .common import load_gitops_config 15 | 16 | 17 | class CreatePreviewCommand(Command): 18 | @dataclass(frozen=True) 19 | class Args(GitApiConfig): 20 | git_user: str 21 | git_email: str 22 | 23 | git_author_name: str | None 24 | git_author_email: str | None 25 | 26 | organisation: str 27 | repository_name: str 28 | 29 | git_hash: str 30 | preview_id: str 31 | 32 | def __init__(self, args: Args) -> None: 33 | self.__args = args 34 | self.__deployment_already_up_to_date_callback: Callable[[str], None] = lambda _: None 35 | self.__deployment_updated_callback: Callable[[str], None] = lambda _: None 36 | self.__deployment_created_callback: Callable[[str], None] = lambda _: None 37 | 38 | def register_callbacks( 39 | self, 40 | deployment_already_up_to_date_callback: Callable[[str], None], 41 | deployment_updated_callback: Callable[[str], None], 42 | deployment_created_callback: Callable[[str], None], 43 | ) -> None: 44 | self.__deployment_already_up_to_date_callback = deployment_already_up_to_date_callback 45 | self.__deployment_updated_callback = deployment_updated_callback 46 | self.__deployment_created_callback = deployment_created_callback 47 | 48 | def execute(self) -> None: 49 | gitops_config = self.__get_gitops_config() 50 | self.__create_preview_info_file(gitops_config) 51 | 52 | preview_target_git_repo_api = self.__create_preview_target_git_repo_api(gitops_config) 53 | with GitRepo(preview_target_git_repo_api) as preview_target_git_repo: 54 | preview_target_git_repo.clone(gitops_config.preview_target_branch) 55 | 56 | if gitops_config.is_preview_template_equal_target(): 57 | preview_template_repo = preview_target_git_repo 58 | created_new_preview = self.__create_preview_from_template_if_not_existing( 59 | preview_template_repo, 60 | preview_target_git_repo, 61 | gitops_config, 62 | ) 63 | else: 64 | preview_template_git_repo_api = self.__create_preview_template_git_repo_api(gitops_config) 65 | with GitRepo(preview_template_git_repo_api) as preview_template_repo: 66 | preview_template_repo.clone(gitops_config.preview_template_branch) 67 | created_new_preview = self.__create_preview_from_template_if_not_existing( 68 | preview_template_repo, 69 | preview_target_git_repo, 70 | gitops_config, 71 | ) 72 | 73 | any_values_replaced = self.__replace_values(preview_target_git_repo, gitops_config) 74 | context = GitOpsConfig.Replacement.PreviewContext( 75 | gitops_config, 76 | self.__args.preview_id, 77 | self.__args.git_hash, 78 | ) 79 | 80 | if not created_new_preview and not any_values_replaced: 81 | self.__deployment_already_up_to_date_callback(gitops_config.get_uptodate_message(context)) 82 | logging.info("The preview is already up-to-date. I'm done here.") 83 | return 84 | 85 | self.__commit_and_push( 86 | preview_target_git_repo, 87 | f"{'Create new' if created_new_preview else 'Update'} preview environment for " 88 | f"'{gitops_config.application_name}' and git hash '{self.__args.git_hash}'.", 89 | ) 90 | 91 | if created_new_preview: 92 | self.__deployment_created_callback(gitops_config.get_created_message(context)) 93 | else: 94 | self.__deployment_updated_callback(gitops_config.get_updated_message(context)) 95 | 96 | def __commit_and_push(self, git_repo: GitRepo, message: str) -> None: 97 | git_repo.commit( 98 | self.__args.git_user, 99 | self.__args.git_email, 100 | self.__args.git_author_name, 101 | self.__args.git_author_email, 102 | message, 103 | ) 104 | git_repo.pull_rebase() 105 | git_repo.push() 106 | 107 | def __get_gitops_config(self) -> GitOpsConfig: 108 | return load_gitops_config(self.__args, self.__args.organisation, self.__args.repository_name) 109 | 110 | def __create_preview_template_git_repo_api(self, gitops_config: GitOpsConfig) -> GitRepoApi: 111 | return GitRepoApiFactory.create( 112 | self.__args, 113 | gitops_config.preview_template_organisation, 114 | gitops_config.preview_template_repository, 115 | ) 116 | 117 | def __create_preview_target_git_repo_api(self, gitops_config: GitOpsConfig) -> GitRepoApi: 118 | return GitRepoApiFactory.create( 119 | self.__args, 120 | gitops_config.preview_target_organisation, 121 | gitops_config.preview_target_repository, 122 | ) 123 | 124 | def __create_preview_from_template_if_not_existing( 125 | self, 126 | template_git_repo: GitRepo, 127 | target_git_repo: GitRepo, 128 | gitops_config: GitOpsConfig, 129 | ) -> bool: 130 | preview_namespace = gitops_config.get_preview_namespace(self.__args.preview_id) 131 | full_preview_folder_path = target_git_repo.get_full_file_path(preview_namespace) 132 | preview_env_already_exist = Path(full_preview_folder_path).is_dir() 133 | if preview_env_already_exist: 134 | logging.info("Use existing folder for preview: %s", preview_namespace) 135 | return False 136 | logging.info("Create new folder for preview: %s", preview_namespace) 137 | full_preview_template_folder_path = template_git_repo.get_full_file_path(gitops_config.preview_template_path) 138 | if not Path(full_preview_template_folder_path).is_dir(): 139 | raise GitOpsException(f"The preview template folder does not exist: {gitops_config.preview_template_path}") 140 | logging.info("Using the preview template folder: %s", gitops_config.preview_template_path) 141 | shutil.copytree(full_preview_template_folder_path, full_preview_folder_path) 142 | return True 143 | 144 | def __replace_values(self, git_repo: GitRepo, gitops_config: GitOpsConfig) -> bool: 145 | preview_id = self.__args.preview_id 146 | preview_folder_name = gitops_config.get_preview_namespace(self.__args.preview_id) 147 | context = GitOpsConfig.Replacement.PreviewContext(gitops_config, preview_id, self.__args.git_hash) 148 | any_value_replaced = False 149 | for file, replacements in gitops_config.replacements.items(): 150 | for replacement in replacements: 151 | replacement_value = replacement.get_value(context) 152 | value_replaced = self.__update_yaml_file( 153 | git_repo, 154 | f"{preview_folder_name}/{file}", 155 | replacement.path, 156 | replacement_value, 157 | ) 158 | if value_replaced: 159 | any_value_replaced = True 160 | logging.info( 161 | "Replaced property '%s' in '%s' with value: %s", 162 | replacement.path, 163 | file, 164 | replacement_value, 165 | ) 166 | else: 167 | logging.info("Keep property '%s' in '%s' value: %s", replacement.path, file, replacement_value) 168 | return any_value_replaced 169 | 170 | def __create_preview_info_file(self, gitops_config: GitOpsConfig) -> None: 171 | preview_id = self.__args.preview_id 172 | yaml_file_dump( 173 | { 174 | "previewId": preview_id, 175 | "previewIdHash": gitops_config.create_preview_id_hash(preview_id), 176 | "routeHost": gitops_config.get_preview_host(preview_id), 177 | "namespace": gitops_config.get_preview_namespace(preview_id), 178 | }, 179 | "/tmp/gitopscli-preview-info.yaml", # noqa: S108 180 | ) 181 | 182 | @staticmethod 183 | def __update_yaml_file(git_repo: GitRepo, file_path: str, key: str, value: Any) -> bool: 184 | full_file_path = git_repo.get_full_file_path(file_path) 185 | try: 186 | return update_yaml_file(full_file_path, key, value) 187 | except (FileNotFoundError, IsADirectoryError) as ex: 188 | raise GitOpsException(f"No such file: {file_path}") from ex 189 | except YAMLException as ex: 190 | raise GitOpsException(f"Error loading file: {file_path}") from ex 191 | except KeyError as ex: 192 | raise GitOpsException(f"Key '{key}' not found in file: {file_path}") from ex 193 | -------------------------------------------------------------------------------- /gitopscli/commands/delete_pr_preview.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from gitopscli.git_api import GitApiConfig 4 | 5 | from .command import Command 6 | from .delete_preview import DeletePreviewCommand 7 | 8 | 9 | class DeletePrPreviewCommand(Command): 10 | @dataclass(frozen=True) 11 | class Args(GitApiConfig): 12 | git_user: str 13 | git_email: str 14 | 15 | git_author_name: str | None 16 | git_author_email: str | None 17 | 18 | organisation: str 19 | repository_name: str 20 | 21 | branch: str 22 | expect_preview_exists: bool 23 | 24 | def __init__(self, args: Args) -> None: 25 | self.__args = args 26 | 27 | def execute(self) -> None: 28 | args = self.__args 29 | DeletePreviewCommand( 30 | DeletePreviewCommand.Args( 31 | username=args.username, 32 | password=args.password, 33 | git_user=args.git_user, 34 | git_email=args.git_email, 35 | git_author_name=args.git_author_name, 36 | git_author_email=args.git_author_email, 37 | organisation=args.organisation, 38 | repository_name=args.repository_name, 39 | git_provider=args.git_provider, 40 | git_provider_url=args.git_provider_url, 41 | preview_id=args.branch, # use branch as preview id 42 | expect_preview_exists=args.expect_preview_exists, 43 | ), 44 | ).execute() 45 | -------------------------------------------------------------------------------- /gitopscli/commands/delete_preview.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | 6 | from gitopscli.git_api import GitApiConfig, GitRepo, GitRepoApi, GitRepoApiFactory 7 | from gitopscli.gitops_config import GitOpsConfig 8 | from gitopscli.gitops_exception import GitOpsException 9 | 10 | from .command import Command 11 | from .common import load_gitops_config 12 | 13 | 14 | class DeletePreviewCommand(Command): 15 | @dataclass(frozen=True) 16 | class Args(GitApiConfig): 17 | git_user: str 18 | git_email: str 19 | 20 | git_author_name: str | None 21 | git_author_email: str | None 22 | 23 | organisation: str 24 | repository_name: str 25 | 26 | preview_id: str 27 | expect_preview_exists: bool 28 | 29 | def __init__(self, args: Args) -> None: 30 | self.__args = args 31 | 32 | def execute(self) -> None: 33 | gitops_config = self.__get_gitops_config() 34 | preview_id = self.__args.preview_id 35 | 36 | preview_target_git_repo_api = self.__create_preview_target_git_repo_api(gitops_config) 37 | with GitRepo(preview_target_git_repo_api) as preview_target_git_repo: 38 | preview_target_git_repo.clone(gitops_config.preview_target_branch) 39 | 40 | preview_namespace = gitops_config.get_preview_namespace(preview_id) 41 | logging.info("Preview folder name: %s", preview_namespace) 42 | 43 | preview_folder_exists = self.__delete_folder_if_exists(preview_target_git_repo, preview_namespace) 44 | if not preview_folder_exists: 45 | if self.__args.expect_preview_exists: 46 | raise GitOpsException(f"There was no preview with name: {preview_namespace}") 47 | logging.info( 48 | "No preview environment for '%s' and preview id '%s'. I'm done here.", 49 | gitops_config.application_name, 50 | preview_id, 51 | ) 52 | return 53 | 54 | self.__commit_and_push( 55 | preview_target_git_repo, 56 | f"Delete preview environment for '{gitops_config.application_name}' and preview id '{preview_id}'.", 57 | ) 58 | 59 | def __get_gitops_config(self) -> GitOpsConfig: 60 | return load_gitops_config(self.__args, self.__args.organisation, self.__args.repository_name) 61 | 62 | def __create_preview_target_git_repo_api(self, gitops_config: GitOpsConfig) -> GitRepoApi: 63 | return GitRepoApiFactory.create( 64 | self.__args, 65 | gitops_config.preview_target_organisation, 66 | gitops_config.preview_target_repository, 67 | ) 68 | 69 | def __commit_and_push(self, git_repo: GitRepo, message: str) -> None: 70 | git_repo.commit( 71 | self.__args.git_user, 72 | self.__args.git_email, 73 | self.__args.git_author_name, 74 | self.__args.git_author_email, 75 | message, 76 | ) 77 | git_repo.pull_rebase() 78 | git_repo.push() 79 | 80 | @staticmethod 81 | def __delete_folder_if_exists(git_repo: GitRepo, folder_name: str) -> bool: 82 | folder_full_path = git_repo.get_full_file_path(folder_name) 83 | if not Path(folder_full_path).exists(): 84 | return False 85 | shutil.rmtree(folder_full_path, ignore_errors=True) 86 | return True 87 | -------------------------------------------------------------------------------- /gitopscli/commands/deploy.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import uuid 4 | from dataclasses import dataclass 5 | from typing import Any, Literal 6 | 7 | from gitopscli.git_api import GitApiConfig, GitRepo, GitRepoApi, GitRepoApiFactory 8 | from gitopscli.gitops_exception import GitOpsException 9 | from gitopscli.io_api.yaml_util import YAMLException, update_yaml_file, yaml_dump 10 | 11 | from .command import Command 12 | 13 | 14 | class DeployCommand(Command): 15 | @dataclass(frozen=True) 16 | class Args(GitApiConfig): 17 | git_user: str 18 | git_email: str 19 | 20 | git_author_name: str | None 21 | git_author_email: str | None 22 | 23 | organisation: str 24 | repository_name: str 25 | 26 | file: str 27 | values: Any 28 | 29 | single_commit: bool 30 | commit_message: str | None 31 | 32 | create_pr: bool 33 | auto_merge: bool 34 | json: bool 35 | 36 | pr_labels: list[str] | None 37 | merge_parameters: Any | None 38 | merge_method: Literal["squash", "rebase", "merge"] = "merge" 39 | 40 | def __init__(self, args: Args) -> None: 41 | self.__args = args 42 | self.__commit_hashes: list[str] = [] 43 | 44 | def execute(self) -> None: 45 | git_repo_api = self.__create_git_repo_api() 46 | with GitRepo(git_repo_api) as git_repo: 47 | git_repo.clone() 48 | 49 | if self.__args.create_pr: 50 | pr_branch = f"gitopscli-deploy-{str(uuid.uuid4())[:8]}" 51 | git_repo.new_branch(pr_branch) 52 | 53 | updated_values = self.__update_values(git_repo) 54 | if not updated_values: 55 | logging.info("All values already up-to-date. I'm done here.") 56 | return 57 | 58 | git_repo.pull_rebase() 59 | git_repo.push() 60 | 61 | if self.__args.create_pr: 62 | title, description = self.__create_pull_request_title_and_description(updated_values) 63 | pr_id = git_repo_api.create_pull_request_to_default_branch(pr_branch, title, description).pr_id 64 | if self.__args.pr_labels: 65 | git_repo_api.add_pull_request_label(pr_id, self.__args.pr_labels) 66 | if self.__args.auto_merge: 67 | if self.__args.merge_parameters: 68 | git_repo_api.merge_pull_request(pr_id, self.__args.merge_method, self.__args.merge_parameters) 69 | else: 70 | git_repo_api.merge_pull_request(pr_id, self.__args.merge_method) 71 | git_repo_api.delete_branch(pr_branch) 72 | 73 | if self.__args.json: 74 | print(json.dumps({"commits": [{"hash": h} for h in self.__commit_hashes]}, indent=4)) # noqa: T201 75 | 76 | def __create_git_repo_api(self) -> GitRepoApi: 77 | return GitRepoApiFactory.create(self.__args, self.__args.organisation, self.__args.repository_name) 78 | 79 | def __update_values(self, git_repo: GitRepo) -> dict[str, Any]: 80 | args = self.__args 81 | single_commit = args.single_commit or args.commit_message 82 | full_file_path = git_repo.get_full_file_path(args.file) 83 | updated_values = {} 84 | for key, value in args.values.items(): 85 | try: 86 | updated_value = update_yaml_file(full_file_path, key, value) 87 | except (FileNotFoundError, IsADirectoryError) as ex: 88 | raise GitOpsException(f"No such file: {args.file}") from ex 89 | except YAMLException as ex: 90 | raise GitOpsException(f"Error loading file: {args.file}") from ex 91 | except KeyError as ex: 92 | raise GitOpsException(str(ex)) from ex 93 | 94 | if not updated_value: 95 | logging.info("Yaml property %s already up-to-date", key) 96 | continue 97 | 98 | logging.info("Updated yaml property %s to %s", key, value) 99 | updated_values[key] = value 100 | 101 | if not single_commit: 102 | self.__commit(git_repo, f"changed '{key}' to '{value}' in {args.file}") 103 | 104 | if single_commit and updated_values: 105 | if args.commit_message: 106 | message = args.commit_message 107 | elif len(updated_values) == 1: 108 | key, value = next(iter(updated_values.items())) 109 | message = f"changed '{key}' to '{value}' in {args.file}" 110 | else: 111 | updates_count = len(updated_values) 112 | message = f"updated {updates_count} value{'s' if updates_count > 1 else ''} in {args.file}" 113 | message += f"\n\n{yaml_dump(updated_values)}" 114 | self.__commit(git_repo, message) 115 | 116 | return updated_values 117 | 118 | def __create_pull_request_title_and_description(self, updated_values: dict[str, Any]) -> tuple[str, str]: 119 | updated_file_name = self.__args.file 120 | updates_count = len(updated_values) 121 | value_or_values = "values" if updates_count > 1 else "value" 122 | title = f"Updated {value_or_values} in {updated_file_name}" 123 | description = f"Updated {updates_count} {value_or_values} in `{updated_file_name}`:\n" 124 | description += f"```yaml\n{yaml_dump(updated_values)}\n```\n" 125 | return title, description 126 | 127 | def __commit(self, git_repo: GitRepo, message: str) -> None: 128 | commit_hash = git_repo.commit( 129 | self.__args.git_user, 130 | self.__args.git_email, 131 | self.__args.git_author_name, 132 | self.__args.git_author_email, 133 | message, 134 | ) 135 | if commit_hash: 136 | self.__commit_hashes.append(commit_hash) 137 | -------------------------------------------------------------------------------- /gitopscli/commands/sync_apps.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from gitopscli.appconfig_api.app_tenant_config import create_app_tenant_config_from_repo 5 | from gitopscli.appconfig_api.root_repo import create_root_repo 6 | from gitopscli.commands.command import Command 7 | from gitopscli.git_api import GitApiConfig, GitRepo, GitRepoApiFactory 8 | from gitopscli.gitops_exception import GitOpsException 9 | from gitopscli.io_api.yaml_util import yaml_file_dump 10 | 11 | 12 | class SyncAppsCommand(Command): 13 | @dataclass(frozen=True) 14 | class Args(GitApiConfig): 15 | git_user: str 16 | git_email: str 17 | 18 | git_author_name: str | None 19 | git_author_email: str | None 20 | 21 | organisation: str 22 | repository_name: str 23 | 24 | root_organisation: str 25 | root_repository_name: str 26 | 27 | def __init__(self, args: Args) -> None: 28 | self.__args = args 29 | 30 | def execute(self) -> None: 31 | _sync_apps_command(self.__args) 32 | 33 | 34 | def _sync_apps_command(args: SyncAppsCommand.Args) -> None: 35 | team_config_git_repo_api = GitRepoApiFactory.create(args, args.organisation, args.repository_name) 36 | root_config_git_repo_api = GitRepoApiFactory.create(args, args.root_organisation, args.root_repository_name) 37 | with GitRepo(team_config_git_repo_api) as team_config_git_repo, GitRepo( 38 | root_config_git_repo_api 39 | ) as root_config_git_repo: 40 | __sync_apps( 41 | team_config_git_repo, 42 | root_config_git_repo, 43 | args.git_user, 44 | args.git_email, 45 | args.git_author_name, 46 | args.git_author_email, 47 | ) 48 | 49 | 50 | def __sync_apps( 51 | tenant_git_repo: GitRepo, 52 | root_git_repo: GitRepo, 53 | git_user: str, 54 | git_email: str, 55 | git_author_name: str | None, 56 | git_author_email: str | None, 57 | ) -> None: 58 | logging.info("Team config repository: %s", tenant_git_repo.get_clone_url()) 59 | logging.info("Root config repository: %s", root_git_repo.get_clone_url()) 60 | root_repo = create_root_repo(root_repo=root_git_repo) 61 | root_repo_tenant = root_repo.get_tenant_by_repo_url(tenant_git_repo.get_clone_url()) 62 | if root_repo_tenant is None: 63 | raise GitOpsException("Couldn't find config file for apps repository in root repository's 'apps/' directory") 64 | tenant_from_repo = create_app_tenant_config_from_repo(tenant_repo=tenant_git_repo) 65 | logging.info( 66 | "Found %s app(s) in apps repository: %s", 67 | len(tenant_from_repo.list_apps().keys()), 68 | ", ".join(tenant_from_repo.list_apps().keys()), 69 | ) 70 | root_repo.validate_tenant(tenant_from_repo) 71 | root_repo_tenant.merge_applications(tenant_from_repo) 72 | if root_repo_tenant.dirty: 73 | logging.info("Appling changes to: %s", root_repo_tenant.file_path) 74 | yaml_file_dump(root_repo_tenant.yaml, root_repo_tenant.file_path) 75 | logging.info("Commiting and pushing changes to %s", root_git_repo.get_clone_url()) 76 | __commit_and_push( 77 | tenant_git_repo, 78 | root_git_repo, 79 | git_user, 80 | git_email, 81 | git_author_name, 82 | git_author_email, 83 | root_repo_tenant.file_path, 84 | ) 85 | else: 86 | logging.info("No changes applied to %s", root_repo_tenant.file_path) 87 | 88 | 89 | def __commit_and_push( 90 | team_config_git_repo: GitRepo, 91 | root_config_git_repo: GitRepo, 92 | git_user: str, 93 | git_email: str, 94 | git_author_name: str | None, 95 | git_author_email: str | None, 96 | app_file_name: str, 97 | ) -> None: 98 | author = team_config_git_repo.get_author_from_last_commit() 99 | root_config_git_repo.commit( 100 | git_user, 101 | git_email, 102 | git_author_name, 103 | git_author_email, 104 | f"{author} updated " + app_file_name, 105 | ) 106 | root_config_git_repo.pull_rebase() 107 | root_config_git_repo.push() 108 | -------------------------------------------------------------------------------- /gitopscli/commands/version.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | from dataclasses import dataclass 3 | 4 | from .command import Command 5 | 6 | 7 | class VersionCommand(Command): 8 | @dataclass(frozen=True) 9 | class Args: 10 | pass 11 | 12 | def __init__(self, args: Args) -> None: 13 | pass 14 | 15 | def execute(self) -> None: 16 | try: 17 | version = importlib.metadata.version("gitopscli") 18 | except importlib.metadata.PackageNotFoundError: 19 | # Handle the case where "gitopscli" is not installed 20 | version = None 21 | print(f"GitOps CLI version {version}") # noqa: T201 22 | -------------------------------------------------------------------------------- /gitopscli/git_api/__init__.py: -------------------------------------------------------------------------------- 1 | from .git_api_config import GitApiConfig 2 | from .git_provider import GitProvider 3 | from .git_repo import GitRepo 4 | from .git_repo_api import GitRepoApi 5 | from .git_repo_api_factory import GitRepoApiFactory 6 | -------------------------------------------------------------------------------- /gitopscli/git_api/bitbucket_git_repo_api_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | import requests 4 | from atlassian import Bitbucket 5 | 6 | from gitopscli.gitops_exception import GitOpsException 7 | 8 | from .git_repo_api import GitRepoApi 9 | 10 | 11 | class BitbucketGitRepoApiAdapter(GitRepoApi): 12 | def __init__( 13 | self, 14 | git_provider_url: str, 15 | username: str | None, 16 | password: str | None, 17 | organisation: str, 18 | repository_name: str, 19 | ) -> None: 20 | self.__bitbucket = Bitbucket(git_provider_url, username, password) 21 | self.__git_provider_url = git_provider_url 22 | self.__organisation = organisation 23 | self.__repository_name = repository_name 24 | 25 | def get_username(self) -> str | None: 26 | return str(self.__bitbucket.username) 27 | 28 | def get_password(self) -> str | None: 29 | return str(self.__bitbucket.password) 30 | 31 | def get_clone_url(self) -> str: 32 | try: 33 | repo = self.__bitbucket.get_repo(self.__organisation, self.__repository_name) 34 | except requests.exceptions.ConnectionError as ex: 35 | raise GitOpsException(f"Error connecting to '{self.__git_provider_url}''") from ex 36 | if "errors" in repo: 37 | for error in repo["errors"]: 38 | raise self.__map_clone_error(error) 39 | if "links" not in repo: 40 | raise GitOpsException(f"Repository '{self.__organisation}/{self.__repository_name}' does not exist") 41 | for clone_link in repo["links"]["clone"]: 42 | if clone_link["name"] == "http": 43 | repo_url = clone_link["href"] 44 | if not repo_url: 45 | raise GitOpsException("Couldn't determine repository URL.") 46 | return str(repo_url) 47 | 48 | def __map_clone_error(self, error: dict[str, str]) -> GitOpsException: 49 | exception = error["exceptionName"] 50 | if exception == "com.atlassian.bitbucket.auth.IncorrectPasswordAuthenticationException": 51 | return GitOpsException("Bad credentials") 52 | if exception == "com.atlassian.bitbucket.project.NoSuchProjectException": 53 | return GitOpsException(f"Organisation '{self.__organisation}' does not exist") 54 | if exception == "com.atlassian.bitbucket.repository.NoSuchRepositoryException": 55 | return GitOpsException(f"Repository '{self.__organisation}/{self.__repository_name}' does not exist") 56 | return GitOpsException(error["message"]) 57 | 58 | def create_pull_request_to_default_branch( 59 | self, 60 | from_branch: str, 61 | title: str, 62 | description: str, 63 | ) -> GitRepoApi.PullRequestIdAndUrl: 64 | to_branch = self.__get_default_branch() 65 | return self.create_pull_request(from_branch, to_branch, title, description) 66 | 67 | def create_pull_request( 68 | self, 69 | from_branch: str, 70 | to_branch: str, 71 | title: str, 72 | description: str, 73 | ) -> GitRepoApi.PullRequestIdAndUrl: 74 | pull_request = self.__bitbucket.open_pull_request( 75 | self.__organisation, 76 | self.__repository_name, 77 | self.__organisation, 78 | self.__repository_name, 79 | from_branch, 80 | to_branch, 81 | title, 82 | description, 83 | ) 84 | if "errors" in pull_request: 85 | raise GitOpsException(pull_request["errors"][0]["message"]) 86 | return GitRepoApi.PullRequestIdAndUrl(pr_id=pull_request["id"], url=pull_request["links"]["self"][0]["href"]) 87 | 88 | def merge_pull_request( 89 | self, 90 | pr_id: int, 91 | _merge_method: Literal["squash", "rebase", "merge"] = "merge", 92 | _merge_parameters: dict[str, Any] | None = None, 93 | ) -> None: 94 | pull_request = self.__bitbucket.get_pull_request(self.__organisation, self.__repository_name, pr_id) 95 | self.__bitbucket.merge_pull_request( 96 | self.__organisation, 97 | self.__repository_name, 98 | pull_request["id"], 99 | pull_request["version"], 100 | ) 101 | 102 | def add_pull_request_comment(self, pr_id: int, text: str, parent_id: int | None = None) -> None: 103 | pull_request_comment = self.__bitbucket.add_pull_request_comment( 104 | self.__organisation, 105 | self.__repository_name, 106 | pr_id, 107 | text, 108 | parent_id, 109 | ) 110 | if "errors" in pull_request_comment: 111 | raise GitOpsException(pull_request_comment["errors"][0]["message"]) 112 | 113 | def delete_branch(self, branch: str) -> None: 114 | branch_hash = self.get_branch_head_hash(branch) 115 | result = self.__bitbucket.delete_branch(self.__organisation, self.__repository_name, branch, branch_hash) 116 | if result and "errors" in result: 117 | raise GitOpsException(result["errors"][0]["message"]) 118 | 119 | def get_branch_head_hash(self, branch: str) -> str: 120 | branches = list( 121 | self.__bitbucket.get_branches(self.__organisation, self.__repository_name, filter=branch, limit=1), 122 | ) 123 | if not branches: 124 | raise GitOpsException(f"Branch '{branch}' not found'") 125 | return str(branches[0]["latestCommit"]) 126 | 127 | def get_pull_request_branch(self, pr_id: int) -> str: 128 | pull_request = self.__bitbucket.get_pull_request(self.__organisation, self.__repository_name, pr_id) 129 | if "errors" in pull_request: 130 | raise GitOpsException(pull_request["errors"][0]["message"]) 131 | return str(pull_request["fromRef"]["displayId"]) 132 | 133 | def __get_default_branch(self) -> str: 134 | default_branch = self.__bitbucket.get_default_branch(self.__organisation, self.__repository_name) 135 | return str(default_branch["id"]) 136 | 137 | def add_pull_request_label(self, pr_id: int, pr_labels: list[str]) -> None: 138 | pass 139 | -------------------------------------------------------------------------------- /gitopscli/git_api/git_api_config.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from .git_provider import GitProvider 4 | 5 | 6 | @dataclass(frozen=True) 7 | class GitApiConfig: 8 | username: str 9 | password: str 10 | git_provider: GitProvider 11 | git_provider_url: str | None 12 | -------------------------------------------------------------------------------- /gitopscli/git_api/git_provider.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class GitProvider(Enum): 5 | GITHUB = auto() 6 | BITBUCKET = auto() 7 | GITLAB = auto() 8 | -------------------------------------------------------------------------------- /gitopscli/git_api/git_repo.py: -------------------------------------------------------------------------------- 1 | import locale 2 | import logging 3 | from pathlib import Path 4 | from types import TracebackType 5 | from typing import Literal 6 | 7 | from git import GitCommandError, GitError, Repo 8 | 9 | from gitopscli.gitops_exception import GitOpsException 10 | from gitopscli.io_api.tmp_dir import create_tmp_dir, delete_tmp_dir 11 | 12 | from .git_repo_api import GitRepoApi 13 | 14 | 15 | class GitRepo: 16 | def __init__(self, git_repo_api: GitRepoApi) -> None: 17 | self.__api = git_repo_api 18 | self.__repo: Repo | None = None 19 | self.__tmp_dir: str | None = None 20 | 21 | def __enter__(self) -> "GitRepo": 22 | return self 23 | 24 | def __exit__( 25 | self, 26 | exc_type: type[BaseException] | None, 27 | exc_value: BaseException | None, 28 | traceback: TracebackType | None, 29 | ) -> Literal[False]: 30 | self.finalize() 31 | return False 32 | 33 | def finalize(self) -> None: 34 | self.__delete_tmp_dir() 35 | 36 | def get_full_file_path(self, relative_path: str) -> str: 37 | repo = self.__get_repo() 38 | return str(Path(repo.working_dir) / relative_path) 39 | 40 | def get_clone_url(self) -> str: 41 | return self.__api.get_clone_url() 42 | 43 | def clone(self, branch: str | None = None) -> None: 44 | self.__delete_tmp_dir() 45 | self.__tmp_dir = create_tmp_dir() 46 | git_options = [] 47 | url = self.get_clone_url() 48 | if branch: 49 | logging.info("Cloning repository: %s (branch: %s)", url, branch) 50 | else: 51 | logging.info("Cloning repository: %s", url) 52 | username = self.__api.get_username() 53 | password = self.__api.get_password() 54 | try: 55 | if username is not None and password is not None: 56 | credentials_file = self.__create_credentials_file(username, password) 57 | git_options.append(f"--config credential.helper={credentials_file}") 58 | if branch: 59 | git_options.append(f"--branch {branch}") 60 | self.__repo = Repo.clone_from( 61 | url=url, 62 | to_path=f"{self.__tmp_dir}/repo", 63 | multi_options=git_options, 64 | allow_unsafe_options=True, 65 | ) 66 | except GitError as ex: 67 | if branch: 68 | raise GitOpsException(f"Error cloning branch '{branch}' of '{url}'") from ex 69 | raise GitOpsException(f"Error cloning '{url}'") from ex 70 | 71 | def new_branch(self, branch: str) -> None: 72 | logging.info("Creating new branch: %s", branch) 73 | repo = self.__get_repo() 74 | try: 75 | repo.git.checkout("-b", branch) 76 | except GitError as ex: 77 | raise GitOpsException(f"Error creating new branch '{branch}'.") from ex 78 | 79 | def commit( 80 | self, 81 | git_user: str, 82 | git_email: str, 83 | git_author_name: str | None, 84 | git_author_email: str | None, 85 | message: str, 86 | ) -> str | None: 87 | self.__validate_git_author(git_author_name, git_author_email) 88 | repo = self.__get_repo() 89 | try: 90 | repo.git.add("--all") 91 | if repo.index.diff("HEAD"): 92 | logging.info("Creating commit with message: %s", message) 93 | repo.config_writer().set_value("user", "name", git_user).release() 94 | repo.config_writer().set_value("user", "email", git_email).release() 95 | if not git_author_name or not git_author_email: 96 | git_author_name = git_user 97 | git_author_email = git_email 98 | repo.git.commit("-m", message, "--author", f"{git_author_name} <{git_author_email}>") 99 | return str(repo.head.commit.hexsha) 100 | except GitError as ex: 101 | raise GitOpsException("Error creating commit.") from ex 102 | return None 103 | 104 | def __validate_git_author(self, name: str | None, email: str | None) -> None: 105 | if (name and not email) or (not name and email): 106 | raise GitOpsException("Please provide the name and email address of the Git author or provide neither!") 107 | 108 | def pull_rebase(self) -> None: 109 | repo = self.__get_repo() 110 | branch = repo.git.branch("--show-current") 111 | if not self.__remote_branch_exists(branch): 112 | return 113 | logging.info("Pull and rebase: %s", branch) 114 | repo.git.pull("--rebase") 115 | 116 | def push(self, branch: str | None = None) -> None: 117 | repo = self.__get_repo() 118 | if not branch: 119 | branch = repo.git.branch("--show-current") 120 | logging.info("Pushing branch: %s", branch) 121 | try: 122 | repo.git.push("--set-upstream", "origin", branch) 123 | except GitCommandError as ex: 124 | raise GitOpsException(f"Error pushing branch '{branch}' to origin: {ex.stderr}") from ex 125 | except GitError as ex: 126 | raise GitOpsException(f"Error pushing branch '{branch}' to origin.") from ex 127 | 128 | def get_author_from_last_commit(self) -> str: 129 | repo = self.__get_repo() 130 | last_commit = repo.head.commit 131 | return str(repo.git.show("-s", "--format=%an <%ae>", last_commit.hexsha)) 132 | 133 | def __remote_branch_exists(self, branch: str) -> bool: 134 | repo = self.__get_repo() 135 | return bool(repo.git.ls_remote("--heads", "origin", f"refs/heads/{branch}").strip() != "") 136 | 137 | def __delete_tmp_dir(self) -> None: 138 | if self.__tmp_dir: 139 | delete_tmp_dir(self.__tmp_dir) 140 | 141 | def __get_repo(self) -> Repo: 142 | if self.__repo: 143 | return self.__repo 144 | raise GitOpsException("Repository not cloned yet!") 145 | 146 | def __create_credentials_file(self, username: str, password: str) -> str: 147 | file_path = Path(f"{self.__tmp_dir}/credentials.sh") 148 | with file_path.open("w", encoding=locale.getpreferredencoding(do_setlocale=False)) as text_file: 149 | text_file.write("#!/bin/sh\n") 150 | text_file.write(f"echo username='{username}'\n") 151 | text_file.write(f"echo password='{password}'\n") 152 | file_path.chmod(0o700) 153 | return str(file_path) 154 | -------------------------------------------------------------------------------- /gitopscli/git_api/git_repo_api.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from typing import Any, Literal, NamedTuple 3 | 4 | 5 | class GitRepoApi(metaclass=ABCMeta): 6 | class PullRequestIdAndUrl(NamedTuple): 7 | pr_id: int 8 | url: str 9 | 10 | @abstractmethod 11 | def get_username(self) -> str | None: 12 | ... 13 | 14 | @abstractmethod 15 | def get_password(self) -> str | None: 16 | ... 17 | 18 | @abstractmethod 19 | def get_clone_url(self) -> str: 20 | ... 21 | 22 | @abstractmethod 23 | def create_pull_request_to_default_branch( 24 | self, 25 | from_branch: str, 26 | title: str, 27 | description: str, 28 | ) -> "PullRequestIdAndUrl": 29 | ... 30 | 31 | @abstractmethod 32 | def create_pull_request( 33 | self, 34 | from_branch: str, 35 | to_branch: str, 36 | title: str, 37 | description: str, 38 | ) -> "PullRequestIdAndUrl": 39 | ... 40 | 41 | @abstractmethod 42 | def merge_pull_request( 43 | self, 44 | pr_id: int, 45 | merge_method: Literal["squash", "rebase", "merge"] = "merge", 46 | merge_parameters: dict[str, Any] | None = None, 47 | ) -> None: 48 | ... 49 | 50 | @abstractmethod 51 | def add_pull_request_comment(self, pr_id: int, text: str, parent_id: int | None = None) -> None: 52 | ... 53 | 54 | @abstractmethod 55 | def delete_branch(self, branch: str) -> None: 56 | ... 57 | 58 | @abstractmethod 59 | def get_branch_head_hash(self, branch: str) -> str: 60 | ... 61 | 62 | @abstractmethod 63 | def get_pull_request_branch(self, pr_id: int) -> str: 64 | ... 65 | 66 | @abstractmethod 67 | def add_pull_request_label(self, pr_id: int, pr_labels: list[str]) -> None: 68 | ... 69 | -------------------------------------------------------------------------------- /gitopscli/git_api/git_repo_api_factory.py: -------------------------------------------------------------------------------- 1 | from gitopscli.gitops_exception import GitOpsException 2 | 3 | from .bitbucket_git_repo_api_adapter import BitbucketGitRepoApiAdapter 4 | from .git_api_config import GitApiConfig 5 | from .git_provider import GitProvider 6 | from .git_repo_api import GitRepoApi 7 | from .git_repo_api_logging_proxy import GitRepoApiLoggingProxy 8 | from .github_git_repo_api_adapter import GithubGitRepoApiAdapter 9 | from .gitlab_git_repo_api_adapter import GitlabGitRepoApiAdapter 10 | 11 | 12 | class GitRepoApiFactory: 13 | @staticmethod 14 | def create(config: GitApiConfig, organisation: str, repository_name: str) -> GitRepoApi: 15 | git_repo_api: GitRepoApi | None 16 | if config.git_provider is GitProvider.GITHUB: 17 | git_repo_api = GithubGitRepoApiAdapter( 18 | username=config.username, 19 | password=config.password, 20 | organisation=organisation, 21 | repository_name=repository_name, 22 | ) 23 | elif config.git_provider is GitProvider.BITBUCKET: 24 | if not config.git_provider_url: 25 | raise GitOpsException("Please provide url for Bitbucket!") 26 | git_repo_api = BitbucketGitRepoApiAdapter( 27 | git_provider_url=config.git_provider_url, 28 | username=config.username, 29 | password=config.password, 30 | organisation=organisation, 31 | repository_name=repository_name, 32 | ) 33 | elif config.git_provider is GitProvider.GITLAB: 34 | provider_url = config.git_provider_url 35 | if not provider_url: 36 | provider_url = "https://www.gitlab.com" 37 | git_repo_api = GitlabGitRepoApiAdapter( 38 | git_provider_url=provider_url, 39 | username=config.username, 40 | password=config.password, 41 | organisation=organisation, 42 | repository_name=repository_name, 43 | ) 44 | return GitRepoApiLoggingProxy(git_repo_api) 45 | -------------------------------------------------------------------------------- /gitopscli/git_api/git_repo_api_logging_proxy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Literal 3 | 4 | from .git_repo_api import GitRepoApi 5 | 6 | 7 | class GitRepoApiLoggingProxy(GitRepoApi): 8 | def __init__(self, git_repo_api: GitRepoApi) -> None: 9 | self.__api = git_repo_api 10 | 11 | def get_username(self) -> str | None: 12 | return self.__api.get_username() 13 | 14 | def get_password(self) -> str | None: 15 | return self.__api.get_password() 16 | 17 | def get_clone_url(self) -> str: 18 | return self.__api.get_clone_url() 19 | 20 | def create_pull_request_to_default_branch( 21 | self, 22 | from_branch: str, 23 | title: str, 24 | description: str, 25 | ) -> GitRepoApi.PullRequestIdAndUrl: 26 | logging.info("Creating pull request from '%s' to default branch with title: %s", from_branch, title) 27 | return self.__api.create_pull_request_to_default_branch(from_branch, title, description) 28 | 29 | def create_pull_request( 30 | self, 31 | from_branch: str, 32 | to_branch: str, 33 | title: str, 34 | description: str, 35 | ) -> GitRepoApi.PullRequestIdAndUrl: 36 | logging.info("Creating pull request from '%s' to '%s' with title: %s", from_branch, to_branch, title) 37 | return self.__api.create_pull_request(from_branch, to_branch, title, description) 38 | 39 | def merge_pull_request( 40 | self, 41 | pr_id: int, 42 | merge_method: Literal["squash", "rebase", "merge"] = "merge", 43 | _merge_parameters: dict[str, Any] | None = None, 44 | ) -> None: 45 | logging.info("Merging pull request %s", pr_id) 46 | self.__api.merge_pull_request(pr_id, merge_method=merge_method) 47 | 48 | def add_pull_request_comment(self, pr_id: int, text: str, parent_id: int | None = None) -> None: 49 | if parent_id: 50 | logging.info( 51 | "Creating comment for pull request %s as reply to comment %s with content: %s", 52 | pr_id, 53 | parent_id, 54 | text, 55 | ) 56 | else: 57 | logging.info("Creating comment for pull request %s with content: %s", pr_id, text) 58 | self.__api.add_pull_request_comment(pr_id, text, parent_id) 59 | 60 | def delete_branch(self, branch: str) -> None: 61 | logging.info("Deleting branch '%s'", branch) 62 | self.__api.delete_branch(branch) 63 | 64 | def get_branch_head_hash(self, branch: str) -> str: 65 | return self.__api.get_branch_head_hash(branch) 66 | 67 | def get_pull_request_branch(self, pr_id: int) -> str: 68 | return self.__api.get_pull_request_branch(pr_id) 69 | 70 | def add_pull_request_label(self, pr_id: int, pr_labels: list[str]) -> None: 71 | logging.info("Adding labels for pull request %s with content: %s", pr_id, pr_labels) 72 | self.__api.add_pull_request_label(pr_id, pr_labels) 73 | -------------------------------------------------------------------------------- /gitopscli/git_api/github_git_repo_api_adapter.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal 2 | 3 | from github import ( 4 | BadCredentialsException, 5 | Github, 6 | GitRef, 7 | PullRequest, 8 | Repository, 9 | UnknownObjectException, 10 | ) 11 | 12 | from gitopscli.gitops_exception import GitOpsException 13 | 14 | from .git_repo_api import GitRepoApi 15 | 16 | 17 | class GithubGitRepoApiAdapter(GitRepoApi): 18 | def __init__( 19 | self, 20 | username: str | None, 21 | password: str | None, 22 | organisation: str, 23 | repository_name: str, 24 | ) -> None: 25 | self.__github = Github(username, password) 26 | self.__username = username 27 | self.__password = password 28 | self.__organisation = organisation 29 | self.__repository_name = repository_name 30 | 31 | def get_username(self) -> str | None: 32 | return self.__username 33 | 34 | def get_password(self) -> str | None: 35 | return self.__password 36 | 37 | def get_clone_url(self) -> str: 38 | return self.__get_repo().clone_url 39 | 40 | def create_pull_request_to_default_branch( 41 | self, 42 | from_branch: str, 43 | title: str, 44 | description: str, 45 | ) -> GitRepoApi.PullRequestIdAndUrl: 46 | to_branch = self.__get_repo().default_branch 47 | return self.create_pull_request(from_branch, to_branch, title, description) 48 | 49 | def create_pull_request( 50 | self, 51 | from_branch: str, 52 | to_branch: str, 53 | title: str, 54 | description: str, 55 | ) -> GitRepoApi.PullRequestIdAndUrl: 56 | repo = self.__get_repo() 57 | pull_request = repo.create_pull(title=title, body=description, head=from_branch, base=to_branch) 58 | return GitRepoApi.PullRequestIdAndUrl(pr_id=pull_request.number, url=pull_request.html_url) 59 | 60 | def merge_pull_request( 61 | self, 62 | pr_id: int, 63 | merge_method: Literal["squash", "rebase", "merge"] = "merge", 64 | _merge_parameters: dict[str, Any] | None = None, 65 | ) -> None: 66 | pull_request = self.__get_pull_request(pr_id) 67 | pull_request.merge(merge_method=merge_method) 68 | 69 | def add_pull_request_comment( 70 | self, 71 | pr_id: int, 72 | text: str, 73 | _parent_id: int | None = None, 74 | ) -> None: 75 | pull_request = self.__get_pull_request(pr_id) 76 | pull_request.create_issue_comment(text) 77 | 78 | def delete_branch(self, branch: str) -> None: 79 | git_ref = self.__get_branch_ref(branch) 80 | git_ref.delete() 81 | 82 | def get_branch_head_hash(self, branch: str) -> str: 83 | git_ref = self.__get_branch_ref(branch) 84 | return git_ref.object.sha 85 | 86 | def get_pull_request_branch(self, pr_id: int) -> str: 87 | pull_request = self.__get_pull_request(pr_id) 88 | return pull_request.head.ref 89 | 90 | def __get_branch_ref(self, branch: str) -> GitRef.GitRef: 91 | repo = self.__get_repo() 92 | try: 93 | return repo.get_git_ref(f"heads/{branch}") 94 | except UnknownObjectException as ex: 95 | raise GitOpsException(f"Branch '{branch}' does not exist.") from ex 96 | 97 | def __get_pull_request(self, pr_id: int) -> PullRequest.PullRequest: 98 | repo = self.__get_repo() 99 | try: 100 | return repo.get_pull(pr_id) 101 | except UnknownObjectException as ex: 102 | raise GitOpsException(f"Pull request with ID '{pr_id}' does not exist.") from ex 103 | 104 | def __get_repo(self) -> Repository.Repository: 105 | try: 106 | return self.__github.get_repo(f"{self.__organisation}/{self.__repository_name}") 107 | except BadCredentialsException as ex: 108 | raise GitOpsException("Bad credentials") from ex 109 | except UnknownObjectException as ex: 110 | raise GitOpsException( 111 | f"Repository '{self.__organisation}/{self.__repository_name}' does not exist.", 112 | ) from ex 113 | 114 | def add_pull_request_label(self, pr_id: int, pr_labels: str | Any) -> None: 115 | pull_request = self.__get_pull_request(pr_id) 116 | pull_request.set_labels(pr_labels) 117 | -------------------------------------------------------------------------------- /gitopscli/git_api/gitlab_git_repo_api_adapter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from http import HTTPStatus 4 | from typing import Any, Literal 5 | 6 | import gitlab 7 | import requests 8 | 9 | from gitopscli.gitops_exception import GitOpsException 10 | 11 | from .git_repo_api import GitRepoApi 12 | 13 | MAX_MERGE_RETRIES = 5 14 | 15 | 16 | class GitlabGitRepoApiAdapter(GitRepoApi): 17 | def __init__( 18 | self, 19 | git_provider_url: str, 20 | username: str | None, 21 | password: str | None, 22 | organisation: str, 23 | repository_name: str, 24 | ) -> None: 25 | try: 26 | self.__gitlab = gitlab.Gitlab(git_provider_url, private_token=password) 27 | project = self.__gitlab.projects.get(f"{organisation}/{repository_name}") 28 | except requests.exceptions.ConnectionError as ex: 29 | raise GitOpsException(f"Error connecting to '{git_provider_url}''") from ex 30 | except gitlab.exceptions.GitlabAuthenticationError as ex: 31 | raise GitOpsException("Bad Personal Access Token") from ex 32 | except gitlab.exceptions.GitlabGetError as ex: 33 | if ex.response_code == HTTPStatus.NOT_FOUND: 34 | raise GitOpsException(f"Repository '{organisation}/{repository_name}' does not exist") from ex 35 | raise GitOpsException(f"Error getting repository: '{ex.error_message}'") from ex 36 | 37 | self.__token_name = username 38 | self.__access_token = password 39 | self.__project = project 40 | 41 | def get_username(self) -> str | None: 42 | return self.__token_name 43 | 44 | def get_password(self) -> str | None: 45 | return self.__access_token 46 | 47 | def get_clone_url(self) -> str: 48 | return str(self.__project.http_url_to_repo) 49 | 50 | def create_pull_request_to_default_branch( 51 | self, 52 | from_branch: str, 53 | title: str, 54 | description: str, 55 | ) -> GitRepoApi.PullRequestIdAndUrl: 56 | to_branch = self.__get_default_branch() 57 | return self.create_pull_request(from_branch, to_branch, title, description) 58 | 59 | def create_pull_request( 60 | self, 61 | from_branch: str, 62 | to_branch: str, 63 | title: str, 64 | description: str, 65 | ) -> GitRepoApi.PullRequestIdAndUrl: 66 | merge_request = self.__project.mergerequests.create( 67 | {"source_branch": from_branch, "target_branch": to_branch, "title": title, "description": description}, 68 | ) 69 | return GitRepoApi.PullRequestIdAndUrl(pr_id=merge_request.iid, url=merge_request.web_url) 70 | 71 | def merge_pull_request( 72 | self, 73 | pr_id: int, 74 | merge_method: Literal["squash", "rebase", "merge"] = "merge", 75 | merge_parameters: dict[str, Any] | None = None, 76 | ) -> None: 77 | merge_request = self.__project.mergerequests.get(pr_id) 78 | 79 | max_retries = MAX_MERGE_RETRIES 80 | while max_retries > 0: 81 | try: 82 | if merge_method == "rebase": 83 | merge_request.rebase(merge_parameters) 84 | return 85 | merge_request.merge(merge_parameters) 86 | except gitlab.exceptions.GitlabMRClosedError as ex: 87 | # "Branch cannot be merged" error can occur if the server 88 | # is still processing the merge request internally 89 | max_retries -= 1 90 | logging.warning( 91 | "Retry merging pull request. Attempts: (%s/%s)", 92 | MAX_MERGE_RETRIES - max_retries, 93 | MAX_MERGE_RETRIES, 94 | ) 95 | if max_retries == 0: 96 | raise GitOpsException("Error merging pull request: 'Branch cannot be merged'") from ex 97 | time.sleep(2.5) 98 | else: 99 | return 100 | 101 | def add_pull_request_comment( 102 | self, 103 | pr_id: int, 104 | text: str, 105 | _parent_id: int | None = None, 106 | ) -> None: 107 | merge_request = self.__project.mergerequests.get(pr_id) 108 | merge_request.notes.create({"body": text}) 109 | 110 | def delete_branch(self, branch: str) -> None: 111 | self.__project.branches.delete(branch) 112 | 113 | def get_branch_head_hash(self, branch: str) -> str: 114 | branch_instance = self.__project.branches.get(branch) 115 | return str(branch_instance.commit["id"]) 116 | 117 | def get_pull_request_branch(self, pr_id: int) -> str: 118 | merge_request = self.__project.mergerequests.get(pr_id) 119 | return str(merge_request.source_branch) 120 | 121 | def __get_default_branch(self) -> str: 122 | branches = self.__project.branches.list(all=True) 123 | default_branch = next(filter(lambda x: x.default, branches), None) 124 | if default_branch is None: 125 | raise GitOpsException("Default branch does not exist") 126 | return str(default_branch.name) 127 | 128 | def add_pull_request_label(self, pr_id: int, pr_labels: list[str]) -> None: 129 | merge_request = self.__project.mergerequests.get(pr_id) 130 | merge_request.labels = pr_labels 131 | merge_request.save() 132 | -------------------------------------------------------------------------------- /gitopscli/gitops_exception.py: -------------------------------------------------------------------------------- 1 | class GitOpsException(Exception): # noqa: N818 2 | pass 3 | -------------------------------------------------------------------------------- /gitopscli/io_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/gitopscli/io_api/__init__.py -------------------------------------------------------------------------------- /gitopscli/io_api/tmp_dir.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import uuid 3 | from pathlib import Path 4 | 5 | 6 | def create_tmp_dir() -> str: 7 | tmp_dir = f"/tmp/gitopscli/{uuid.uuid4()}" # noqa: S108 8 | Path(tmp_dir).mkdir(parents=True) 9 | return tmp_dir 10 | 11 | 12 | def delete_tmp_dir(tmp_dir: str) -> None: 13 | shutil.rmtree(tmp_dir, ignore_errors=True) 14 | -------------------------------------------------------------------------------- /gitopscli/io_api/yaml_util.py: -------------------------------------------------------------------------------- 1 | import locale 2 | from io import StringIO 3 | from pathlib import Path 4 | from typing import Any 5 | 6 | from jsonpath_ng.exceptions import JSONPathError 7 | from jsonpath_ng.ext import parse 8 | from ruamel.yaml import YAML, YAMLError 9 | 10 | YAML_INSTANCE = YAML() 11 | YAML_INSTANCE.preserve_quotes = True 12 | 13 | 14 | class YAMLException(Exception): # noqa: N818 15 | pass 16 | 17 | 18 | def yaml_file_load(file_path: str) -> Any: 19 | with Path(file_path).open(encoding=locale.getpreferredencoding(do_setlocale=False)) as stream: 20 | try: 21 | return YAML_INSTANCE.load(stream) 22 | except YAMLError as ex: 23 | raise YAMLException(f"Error parsing YAML file: {file_path}") from ex 24 | 25 | 26 | def yaml_file_dump(yaml: Any, file_path: str) -> None: 27 | with Path(file_path).open("w+", encoding=locale.getpreferredencoding(do_setlocale=False)) as stream: 28 | YAML_INSTANCE.dump(yaml, stream) 29 | 30 | 31 | def yaml_load(yaml_str: str) -> Any: 32 | try: 33 | return YAML_INSTANCE.load(yaml_str) 34 | except YAMLError as ex: 35 | raise YAMLException(f"Error parsing YAML string '{yaml_str}'") from ex 36 | 37 | 38 | def yaml_dump(yaml: Any) -> str: 39 | stream = StringIO() 40 | YAML_INSTANCE.dump(yaml, stream) 41 | return stream.getvalue().rstrip() 42 | 43 | 44 | def update_yaml_file(file_path: str, key: str, value: Any) -> bool: 45 | if not key: 46 | raise KeyError("Empty key!") 47 | content = yaml_file_load(file_path) 48 | try: 49 | jsonpath_expr = parse(key) 50 | except JSONPathError as ex: 51 | raise KeyError(f"Key '{key}' is invalid JSONPath expression: {ex}!") from ex 52 | matches = jsonpath_expr.find(content) 53 | if not matches: 54 | raise KeyError(f"Key '{key}' not found in YAML!") 55 | if all(match.value == value for match in matches): 56 | return False # nothing to update 57 | try: 58 | jsonpath_expr.update(content, value) 59 | except TypeError as ex: 60 | raise KeyError(f"Key '{key}' cannot be updated: {ex}!") from ex 61 | yaml_file_dump(content, file_path) 62 | return True 63 | 64 | 65 | def merge_yaml_element(file_path: str, element_path: str, desired_value: Any) -> None: 66 | yaml_file_content = yaml_file_load(file_path) 67 | work_path = yaml_file_content 68 | 69 | if element_path != ".": 70 | path_list = element_path.split(".") 71 | for key in path_list: 72 | if work_path[key] is None: 73 | work_path[key] = {} 74 | work_path = work_path[key] 75 | 76 | for key, value in desired_value.items(): 77 | tmp_value = value 78 | if key in work_path and work_path[key] is not None: 79 | tmp_value = {**work_path[key], **tmp_value} 80 | work_path[key] = tmp_value 81 | 82 | # delete missing key: 83 | current = work_path.copy().items() 84 | for key, _ in current: 85 | if key not in desired_value: 86 | del work_path[key] 87 | 88 | yaml_file_dump(yaml_file_content, file_path) 89 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # Project information 2 | site_name: 'GitOps CLI' 3 | site_description: 'A command line interface to perform operations on GitOps managed infrastructure repositories.' 4 | site_url: 'https://baloise.github.io/gitopscli/' 5 | 6 | # Repository 7 | repo_name: 'baloise/gitopscli' 8 | repo_url: 'https://github.com/baloise/gitopscli' 9 | 10 | # Navigation 11 | nav: 12 | - Home: index.md 13 | - Setup: setup.md 14 | - Getting started: getting-started.md 15 | - CLI Commands: 16 | - add-pr-comment: commands/add-pr-comment.md 17 | - create-preview: commands/create-preview.md 18 | - create-pr-preview: commands/create-pr-preview.md 19 | - delete-preview: commands/delete-preview.md 20 | - delete-pr-preview: commands/delete-pr-preview.md 21 | - deploy: commands/deploy.md 22 | - sync-apps: commands/sync-apps.md 23 | - version: commands/version.md 24 | - Changelog: changelog.md 25 | - Contributing: contributing.md 26 | - License: license.md 27 | 28 | # Configuration 29 | theme: 30 | name: 'material' 31 | language: 'en' 32 | palette: 33 | primary: 'indigo' 34 | accent: 'indigo' 35 | font: 36 | text: 'Roboto' 37 | code: 'Roboto Mono' 38 | feature: 39 | tabs: true 40 | logo: 'assets/images/logo-white.svg' 41 | favicon: 'assets/images/favicon.ico' 42 | 43 | # Custom CSS 44 | extra_css: 45 | - 'stylesheets/extra.css' 46 | 47 | # Extensions 48 | markdown_extensions: 49 | - markdown.extensions.attr_list 50 | - pymdownx.superfences 51 | - admonition 52 | - codehilite: 53 | guess_lang: false 54 | - toc: 55 | permalink: true 56 | - pymdownx.emoji 57 | - markdown_include.include: 58 | base_path: docs/includes 59 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | disallow_untyped_calls = True 3 | disallow_untyped_defs = True 4 | disallow_incomplete_defs = True 5 | disallow_untyped_decorators = True 6 | disallow_any_generics = True 7 | disallow_subclassing_any = True 8 | 9 | warn_return_any = True 10 | warn_redundant_casts = True 11 | warn_unused_ignores = True 12 | warn_unused_configs = True 13 | 14 | ignore_missing_imports = True 15 | follow_imports = silent 16 | exclude = (?x)( 17 | build/lib/*/* 18 | | tests/* 19 | | venv/Scripts/* 20 | | venv/bin/* 21 | ) 22 | 23 | 24 | 25 | [mypy-tests.*] 26 | ignore_errors = True 27 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | create = true 3 | in-project = true 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "gitopscli" 3 | version = "0.0.0" 4 | description = "GitOps CLI is a command line interface (CLI) to perform operations on GitOps managed infrastructure repositories, including updates in YAML files." 5 | authors = ["Christian Siegel "] 6 | readme = "README.md" 7 | repository = "https://github.com/baloise/gitopscli" 8 | 9 | [tool.poetry.scripts] 10 | gitopscli = "gitopscli.__main__:main" 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.10" 14 | gitpython = "*" 15 | "ruamel.yaml" = "*" 16 | jsonpath-ng = "*" 17 | atlassian-python-api = "*" 18 | pygithub = "*" 19 | python-gitlab = "^2.6.0" 20 | 21 | [tool.poetry.group.test.dependencies] 22 | ruff = "*" 23 | coverage = "*" 24 | pytest = "*" 25 | mypy = "*" 26 | typeguard = "^2.13.3" 27 | pre-commit = "*" 28 | 29 | [tool.poetry.group.docs.dependencies] 30 | mkdocs = "*" 31 | mkdocs-material = "*" 32 | markdown-include = "*" 33 | pymdown-extensions = "*" 34 | Markdown = "*" 35 | 36 | [build-system] 37 | requires = ["poetry-core>=1.0.0"] 38 | build-backend = "poetry.core.masonry.api" 39 | 40 | [virtualenvs] 41 | in-project = true 42 | 43 | [tool.ruff] 44 | line-length = 120 45 | target-version = "py311" 46 | 47 | [tool.ruff.lint] 48 | select = ["ALL"] 49 | ignore = [ 50 | "ANN101", # https://docs.astral.sh/ruff/rules/missing-type-self/ 51 | "ANN401", # https://docs.astral.sh/ruff/rules/any-type/ 52 | "C901", # https://docs.astral.sh/ruff/rules/complex-structure/ 53 | "PLR0913", # https://docs.astral.sh/ruff/rules/too-many-arguments/ 54 | "D", # https://docs.astral.sh/ruff/rules/#pydocstyle-d 55 | "COM812", # https://docs.astral.sh/ruff/rules/missing-trailing-comma/ (clashes with formatter) 56 | "EM101", # https://docs.astral.sh/ruff/rules/raw-string-in-exception/ 57 | "EM102", # https://docs.astral.sh/ruff/rules/f-string-in-exception/ 58 | "S101", # https://docs.astral.sh/ruff/rules/assert/ 59 | "TRY003", # https://docs.astral.sh/ruff/rules/raise-vanilla-args/ 60 | "PD", # https://docs.astral.sh/ruff/rules/#pandas-vet-pd (false positives) 61 | ] 62 | [tool.ruff.per-file-ignores] 63 | "**/__init__.py" = ["F401"] 64 | "tests/**/*.py" = [ 65 | "S106", # https://docs.astral.sh/ruff/rules/hardcoded-password-func-arg/ 66 | "S108", # https://docs.astral.sh/ruff/rules/hardcoded-temp-file/ 67 | "ANN", # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann 68 | "PT009" # https://docs.astral.sh/ruff/rules/pytest-unittest-assertion/ 69 | ] 70 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/tests/__init__.py -------------------------------------------------------------------------------- /tests/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/tests/commands/__init__.py -------------------------------------------------------------------------------- /tests/commands/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/tests/commands/common/__init__.py -------------------------------------------------------------------------------- /tests/commands/common/test_gitops_config_loader.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import call 3 | 4 | import pytest 5 | 6 | from gitopscli.commands.common.gitops_config_loader import load_gitops_config 7 | from gitopscli.git_api import GitApiConfig, GitProvider, GitRepo, GitRepoApi, GitRepoApiFactory 8 | from gitopscli.gitops_config import GitOpsConfig 9 | from gitopscli.gitops_exception import GitOpsException 10 | from gitopscli.io_api.yaml_util import yaml_file_load 11 | from tests.commands.mock_mixin import MockMixin 12 | 13 | 14 | class GitOpsConfigLoaderTest(MockMixin, unittest.TestCase): 15 | git_api_config = GitApiConfig( 16 | username="USERNAME", password="PASSWORD", git_provider=GitProvider.GITHUB, git_provider_url=None 17 | ) 18 | 19 | def setUp(self): 20 | self.init_mock_manager(load_gitops_config) 21 | 22 | self.gitops_config_mock = self.monkey_patch(GitOpsConfig) 23 | self.gitops_config_mock.from_yaml.return_value = self.gitops_config_mock 24 | 25 | self.yaml_file_load_mock = self.monkey_patch(yaml_file_load) 26 | self.yaml_file_load_mock.return_value = {"dummy": "gitopsconfig"} 27 | 28 | self.git_repo_api_mock = self.create_mock(GitRepoApi) 29 | 30 | self.git_repo_api_factory_mock = self.monkey_patch(GitRepoApiFactory) 31 | self.git_repo_api_factory_mock.create.return_value = self.git_repo_api_mock 32 | 33 | self.git_repo_mock = self.monkey_patch(GitRepo) 34 | self.git_repo_mock.return_value = self.git_repo_mock 35 | self.git_repo_mock.__enter__.return_value = self.git_repo_mock 36 | self.git_repo_mock.__exit__.return_value = False 37 | self.git_repo_mock.clone.return_value = None 38 | self.git_repo_mock.get_full_file_path.side_effect = lambda x: f"/repo-dir/{x}" 39 | 40 | self.seal_mocks() 41 | 42 | def test_happy_flow(self): 43 | gitops_config = load_gitops_config( 44 | git_api_config=self.git_api_config, organisation="ORGA", repository_name="REPO" 45 | ) 46 | 47 | assert gitops_config == self.gitops_config_mock 48 | 49 | assert self.mock_manager.method_calls == [ 50 | call.GitRepoApiFactory.create(self.git_api_config, "ORGA", "REPO"), 51 | call.GitRepo(self.git_repo_api_mock), 52 | call.GitRepo.clone(), 53 | call.GitRepo.get_full_file_path(".gitops.config.yaml"), 54 | call.yaml_file_load("/repo-dir/.gitops.config.yaml"), 55 | call.GitOpsConfig.from_yaml({"dummy": "gitopsconfig"}), 56 | ] 57 | 58 | def test_file_not_found(self): 59 | self.yaml_file_load_mock.side_effect = FileNotFoundError("file not found") 60 | 61 | with pytest.raises(GitOpsException) as ex: 62 | load_gitops_config(git_api_config=self.git_api_config, organisation="ORGA", repository_name="REPO") 63 | 64 | self.assertEqual(str(ex.value), "No such file: .gitops.config.yaml") 65 | 66 | assert self.mock_manager.method_calls == [ 67 | call.GitRepoApiFactory.create(self.git_api_config, "ORGA", "REPO"), 68 | call.GitRepo(self.git_repo_api_mock), 69 | call.GitRepo.clone(), 70 | call.GitRepo.get_full_file_path(".gitops.config.yaml"), 71 | call.yaml_file_load("/repo-dir/.gitops.config.yaml"), 72 | ] 73 | -------------------------------------------------------------------------------- /tests/commands/mock_mixin.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch, seal 2 | 3 | 4 | class MockMixin: 5 | def init_mock_manager(self, command_class: type) -> None: 6 | self.command_class = command_class 7 | self.mock_manager = MagicMock() 8 | 9 | def seal_mocks(self) -> None: 10 | seal(self.mock_manager) 11 | 12 | def monkey_patch(self, target: type, custom_name: str | None = None) -> MagicMock: 13 | name = custom_name or target.__name__ 14 | target_str = f"{self.command_class.__module__}.{name}" 15 | patcher = patch(target_str, spec_set=target) 16 | self.addCleanup(patcher.stop) 17 | mock = patcher.start() 18 | self.mock_manager.attach_mock(mock, name) 19 | return mock 20 | 21 | def create_mock(self, spec_set: type, custom_name: str | None = None) -> MagicMock: 22 | mock = MagicMock(spec_set=spec_set) 23 | self.mock_manager.attach_mock(mock, custom_name or spec_set.__name__) 24 | return mock 25 | -------------------------------------------------------------------------------- /tests/commands/test_add_pr_comment.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import call 3 | 4 | from gitopscli.commands.add_pr_comment import AddPrCommentCommand 5 | from gitopscli.git_api import GitProvider, GitRepoApi, GitRepoApiFactory 6 | 7 | from .mock_mixin import MockMixin 8 | 9 | 10 | class AddPrCommentCommandTest(MockMixin, unittest.TestCase): 11 | def setUp(self): 12 | self.init_mock_manager(AddPrCommentCommand) 13 | 14 | git_repo_api_mock = self.create_mock(GitRepoApi) 15 | git_repo_api_mock.add_pull_request_comment.return_value = None 16 | 17 | git_repo_api_factory_mock = self.monkey_patch(GitRepoApiFactory) 18 | git_repo_api_factory_mock.create.return_value = git_repo_api_mock 19 | 20 | self.seal_mocks() 21 | 22 | def test_with_parent_id(self): 23 | args = AddPrCommentCommand.Args( 24 | text="Hello World!", 25 | username="USERNAME", 26 | password="PASSWORD", 27 | parent_id=4711, 28 | pr_id=42, 29 | organisation="ORGA", 30 | repository_name="REPO", 31 | git_provider=GitProvider.GITHUB, 32 | git_provider_url=None, 33 | ) 34 | AddPrCommentCommand(args).execute() 35 | 36 | assert self.mock_manager.mock_calls == [ 37 | call.GitRepoApiFactory.create(args, "ORGA", "REPO"), 38 | call.GitRepoApi.add_pull_request_comment(42, "Hello World!", 4711), 39 | ] 40 | 41 | def test_without_parent_id(self): 42 | args = AddPrCommentCommand.Args( 43 | text="Hello World!", 44 | username="USERNAME", 45 | password="PASSWORD", 46 | parent_id=None, 47 | pr_id=42, 48 | organisation="ORGA", 49 | repository_name="REPO", 50 | git_provider=GitProvider.GITHUB, 51 | git_provider_url=None, 52 | ) 53 | AddPrCommentCommand(args).execute() 54 | 55 | assert self.mock_manager.mock_calls == [ 56 | call.GitRepoApiFactory.create(args, "ORGA", "REPO"), 57 | call.GitRepoApi.add_pull_request_comment(42, "Hello World!", None), 58 | ] 59 | -------------------------------------------------------------------------------- /tests/commands/test_command_factory.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | 4 | from gitopscli.commands.add_pr_comment import AddPrCommentCommand 5 | from gitopscli.commands.command_factory import CommandFactory 6 | from gitopscli.commands.create_pr_preview import CreatePrPreviewCommand 7 | from gitopscli.commands.create_preview import CreatePreviewCommand 8 | from gitopscli.commands.delete_pr_preview import DeletePrPreviewCommand 9 | from gitopscli.commands.delete_preview import DeletePreviewCommand 10 | from gitopscli.commands.deploy import DeployCommand 11 | from gitopscli.commands.sync_apps import SyncAppsCommand 12 | from gitopscli.commands.version import VersionCommand 13 | 14 | 15 | class CommandFactoryTest(unittest.TestCase): 16 | def test_create_deploy_command(self): 17 | args = Mock(spec=DeployCommand.Args) 18 | command = CommandFactory.create(args) 19 | self.assertEqual(DeployCommand, type(command)) 20 | 21 | def test_create_sync_apps_command(self): 22 | args = Mock(spec=SyncAppsCommand.Args) 23 | command = CommandFactory.create(args) 24 | self.assertEqual(SyncAppsCommand, type(command)) 25 | 26 | def test_create_create_preview_command(self): 27 | args = Mock(spec=CreatePreviewCommand.Args) 28 | command = CommandFactory.create(args) 29 | self.assertEqual(CreatePreviewCommand, type(command)) 30 | 31 | def test_create_create_pr_preview_command(self): 32 | args = Mock(spec=CreatePrPreviewCommand.Args) 33 | command = CommandFactory.create(args) 34 | self.assertEqual(CreatePrPreviewCommand, type(command)) 35 | 36 | def test_create_delete_preview_command(self): 37 | args = Mock(spec=DeletePreviewCommand.Args) 38 | command = CommandFactory.create(args) 39 | self.assertEqual(DeletePreviewCommand, type(command)) 40 | 41 | def test_create_delete_pr_preview_command(self): 42 | args = Mock(spec=DeletePrPreviewCommand.Args) 43 | command = CommandFactory.create(args) 44 | self.assertEqual(DeletePrPreviewCommand, type(command)) 45 | 46 | def test_create_add_pr_comment_command(self): 47 | args = Mock(spec=AddPrCommentCommand.Args) 48 | command = CommandFactory.create(args) 49 | self.assertEqual(AddPrCommentCommand, type(command)) 50 | 51 | def test_create_version_command(self): 52 | args = Mock(spec=VersionCommand.Args) 53 | command = CommandFactory.create(args) 54 | self.assertEqual(VersionCommand, type(command)) 55 | -------------------------------------------------------------------------------- /tests/commands/test_create_pr_preview.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import call 3 | 4 | from gitopscli.commands.create_pr_preview import CreatePreviewCommand, CreatePrPreviewCommand 5 | from gitopscli.git_api import GitProvider, GitRepoApi, GitRepoApiFactory 6 | 7 | from .mock_mixin import MockMixin 8 | 9 | DUMMY_GIT_HASH = "5f65cfa04c66444fcb756d6d7f39304d1c18b199" 10 | 11 | 12 | class CreatePrPreviewCommandTest(MockMixin, unittest.TestCase): 13 | def setUp(self): 14 | self.init_mock_manager(CreatePrPreviewCommand) 15 | 16 | self.create_preview_command_mock = self.monkey_patch(CreatePreviewCommand) 17 | self.create_preview_command_mock.Args = CreatePreviewCommand.Args 18 | self.create_preview_command_mock.return_value = self.create_preview_command_mock 19 | self.create_preview_command_mock.register_callbacks.return_value = None 20 | self.create_preview_command_mock.execute.return_value = None 21 | 22 | self.git_repo_api_mock = self.create_mock(GitRepoApi) 23 | self.git_repo_api_mock.get_pull_request_branch.side_effect = lambda pr_id: f"BRANCH_OF_PR_{pr_id}" 24 | self.git_repo_api_mock.get_branch_head_hash.return_value = DUMMY_GIT_HASH 25 | self.git_repo_api_mock.add_pull_request_comment.return_value = None 26 | 27 | self.git_repo_api_factory_mock = self.monkey_patch(GitRepoApiFactory) 28 | self.git_repo_api_factory_mock.create.return_value = self.git_repo_api_mock 29 | 30 | self.seal_mocks() 31 | 32 | def test_create_pr_preview(self): 33 | args = CreatePrPreviewCommand.Args( 34 | username="USERNAME", 35 | password="PASSWORD", 36 | git_user="GIT_USER", 37 | git_email="GIT_EMAIL", 38 | git_author_name=None, 39 | git_author_email=None, 40 | organisation="ORGA", 41 | repository_name="REPO", 42 | git_provider=GitProvider.GITHUB, 43 | git_provider_url="URL", 44 | pr_id=4711, 45 | parent_id=42, 46 | ) 47 | CreatePrPreviewCommand(args).execute() 48 | 49 | callbacks = self.create_preview_command_mock.register_callbacks.call_args.kwargs 50 | deployment_already_up_to_date_callback = callbacks["deployment_already_up_to_date_callback"] 51 | deployment_updated_callback = callbacks["deployment_updated_callback"] 52 | deployment_created_callback = callbacks["deployment_created_callback"] 53 | 54 | assert self.mock_manager.method_calls == [ 55 | call.GitRepoApiFactory.create(args, "ORGA", "REPO"), 56 | call.GitRepoApi.get_pull_request_branch(4711), 57 | call.GitRepoApi.get_branch_head_hash("BRANCH_OF_PR_4711"), 58 | call.CreatePreviewCommand( 59 | CreatePreviewCommand.Args( 60 | username="USERNAME", 61 | password="PASSWORD", 62 | git_user="GIT_USER", 63 | git_email="GIT_EMAIL", 64 | git_author_name=None, 65 | git_author_email=None, 66 | organisation="ORGA", 67 | repository_name="REPO", 68 | git_provider=GitProvider.GITHUB, 69 | git_provider_url="URL", 70 | git_hash=DUMMY_GIT_HASH, 71 | preview_id="BRANCH_OF_PR_4711", 72 | ) 73 | ), 74 | call.CreatePreviewCommand.register_callbacks( 75 | deployment_already_up_to_date_callback=deployment_already_up_to_date_callback, 76 | deployment_updated_callback=deployment_updated_callback, 77 | deployment_created_callback=deployment_created_callback, 78 | ), 79 | call.CreatePreviewCommand.execute(), 80 | ] 81 | 82 | self.mock_manager.reset_mock() 83 | deployment_already_up_to_date_callback( 84 | "The version `5f65cfa04c66444fcb756d6d7f39304d1c18b199` has already been deployed. Access it here: https://my-route.baloise.com" 85 | ) 86 | assert self.mock_manager.method_calls == [ 87 | call.GitRepoApi.add_pull_request_comment( 88 | 4711, 89 | f"The version `{DUMMY_GIT_HASH}` has already been deployed. " 90 | "Access it here: https://my-route.baloise.com", 91 | 42, 92 | ) 93 | ] 94 | 95 | self.mock_manager.reset_mock() 96 | deployment_updated_callback( 97 | "Preview environment updated to version `5f65cfa04c66444fcb756d6d7f39304d1c18b199`. Access it here: https://my-route.baloise.com" 98 | ) 99 | assert self.mock_manager.method_calls == [ 100 | call.GitRepoApi.add_pull_request_comment( 101 | 4711, 102 | f"Preview environment updated to version `{DUMMY_GIT_HASH}`. " 103 | "Access it here: https://my-route.baloise.com", 104 | 42, 105 | ) 106 | ] 107 | 108 | self.mock_manager.reset_mock() 109 | deployment_created_callback( 110 | "New preview environment created for version `5f65cfa04c66444fcb756d6d7f39304d1c18b199`. Access it here: https://my-route.baloise.com" 111 | ) 112 | assert self.mock_manager.method_calls == [ 113 | call.GitRepoApi.add_pull_request_comment( 114 | 4711, 115 | f"New preview environment created for version `{DUMMY_GIT_HASH}`. " 116 | "Access it here: https://my-route.baloise.com", 117 | 42, 118 | ) 119 | ] 120 | -------------------------------------------------------------------------------- /tests/commands/test_delete_pr_preview.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from gitopscli.commands.delete_pr_preview import DeletePreviewCommand, DeletePrPreviewCommand 4 | from gitopscli.git_api import GitProvider 5 | 6 | from .mock_mixin import MockMixin 7 | 8 | 9 | class DeletePrPreviewCommandTest(MockMixin, unittest.TestCase): 10 | def setUp(self): 11 | self.init_mock_manager(DeletePrPreviewCommand) 12 | 13 | self.delete_preview_command_mock = self.monkey_patch(DeletePreviewCommand) 14 | self.delete_preview_command_mock.Args = DeletePreviewCommand.Args 15 | self.delete_preview_command_mock.return_value = self.delete_preview_command_mock 16 | self.delete_preview_command_mock.execute.return_value = None 17 | 18 | self.seal_mocks() 19 | 20 | def test_delete_pr_preview(self): 21 | DeletePrPreviewCommand( 22 | DeletePrPreviewCommand.Args( 23 | username="USERNAME", 24 | password="PASSWORD", 25 | git_user="GIT_USER", 26 | git_email="GIT_EMAIL", 27 | git_author_name=None, 28 | git_author_email=None, 29 | organisation="ORGA", 30 | repository_name="REPO", 31 | git_provider=GitProvider.GITHUB, 32 | git_provider_url="URL", 33 | branch="some/branch", 34 | expect_preview_exists=True, 35 | ) 36 | ).execute() 37 | 38 | self.delete_preview_command_mock.assert_called_once_with( 39 | DeletePreviewCommand.Args( 40 | username="USERNAME", 41 | password="PASSWORD", 42 | git_user="GIT_USER", 43 | git_email="GIT_EMAIL", 44 | git_author_name=None, 45 | git_author_email=None, 46 | organisation="ORGA", 47 | repository_name="REPO", 48 | git_provider=GitProvider.GITHUB, 49 | git_provider_url="URL", 50 | preview_id="some/branch", # call DeletePreviewCommand with branch as preview_id 51 | expect_preview_exists=True, 52 | ) 53 | ) 54 | self.delete_preview_command_mock.execute.assert_called_once() 55 | -------------------------------------------------------------------------------- /tests/commands/test_delete_preview.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | import unittest 4 | from pathlib import Path 5 | from unittest.mock import call 6 | 7 | import pytest 8 | 9 | from gitopscli.commands.delete_preview import DeletePreviewCommand, load_gitops_config 10 | from gitopscli.git_api import GitProvider, GitRepo, GitRepoApi, GitRepoApiFactory 11 | from gitopscli.gitops_config import GitOpsConfig 12 | from gitopscli.gitops_exception import GitOpsException 13 | 14 | from .mock_mixin import MockMixin 15 | 16 | 17 | class DeletePreviewCommandTest(MockMixin, unittest.TestCase): 18 | def setUp(self): 19 | self.init_mock_manager(DeletePreviewCommand) 20 | 21 | self.path_mock = self.monkey_patch(Path) 22 | self.path_mock.return_value = self.path_mock 23 | self.path_mock.exists.return_value = True 24 | 25 | self.shutil_mock = self.monkey_patch(shutil) 26 | self.shutil_mock.rmtree.return_value = None 27 | 28 | self.logging_mock = self.monkey_patch(logging) 29 | self.logging_mock.info.return_value = None 30 | 31 | self.load_gitops_config_mock = self.monkey_patch(load_gitops_config) 32 | self.load_gitops_config_mock.return_value = GitOpsConfig( 33 | api_version=0, 34 | application_name="APP", 35 | messages_created_template="created template ${PREVIEW_ID_HASH}", 36 | messages_updated_template="updated template ${PREVIEW_ID_HASH}", 37 | messages_uptodate_template="uptodate template ${PREVIEW_ID_HASH}", 38 | preview_host_template="www.foo.bar", 39 | preview_template_organisation="PREVIEW_TEMPLATE_ORG", 40 | preview_template_repository="PREVIEW_TEMPLATE_REPO", 41 | preview_template_path_template=".preview-templates/my-app", 42 | preview_template_branch="template-branch", 43 | preview_target_organisation="PREVIEW_TARGET_ORG", 44 | preview_target_repository="PREVIEW_TARGET_REPO", 45 | preview_target_branch="target-branch", 46 | preview_target_namespace_template="APP-${PREVIEW_ID_HASH}-preview", 47 | preview_target_max_namespace_length=50, 48 | replacements={}, 49 | ) 50 | 51 | self.git_repo_api_mock = self.create_mock(GitRepoApi) 52 | self.git_repo_api_mock.create_pull_request.return_value = GitRepoApi.PullRequestIdAndUrl( 53 | 42, "" 54 | ) 55 | 56 | self.git_repo_api_factory_mock = self.monkey_patch(GitRepoApiFactory) 57 | self.git_repo_api_factory_mock.create.return_value = self.git_repo_api_mock 58 | 59 | self.git_repo_mock = self.monkey_patch(GitRepo) 60 | self.git_repo_mock.return_value = self.git_repo_mock 61 | self.git_repo_mock.__enter__.return_value = self.git_repo_mock 62 | self.git_repo_mock.__exit__.return_value = False 63 | self.git_repo_mock.get_full_file_path.side_effect = lambda x: f"/tmp/created-tmp-dir/{x}" 64 | self.git_repo_mock.clone.return_value = None 65 | self.git_repo_mock.commit.return_value = None 66 | self.git_repo_mock.pull_rebase.return_value = None 67 | self.git_repo_mock.push.return_value = None 68 | 69 | self.seal_mocks() 70 | 71 | def test_delete_existing_happy_flow(self): 72 | args = DeletePreviewCommand.Args( 73 | username="USERNAME", 74 | password="PASSWORD", 75 | git_user="GIT_USER", 76 | git_email="GIT_EMAIL", 77 | git_author_name="GIT_AUTHOR_NAME", 78 | git_author_email="GIT_AUTHOR_EMAIL", 79 | organisation="ORGA", 80 | repository_name="REPO", 81 | git_provider=GitProvider.GITHUB, 82 | git_provider_url=None, 83 | preview_id="PREVIEW_ID", 84 | expect_preview_exists=False, 85 | ) 86 | DeletePreviewCommand(args).execute() 87 | assert self.mock_manager.method_calls == [ 88 | call.load_gitops_config(args, "ORGA", "REPO"), 89 | call.GitRepoApiFactory.create(args, "PREVIEW_TARGET_ORG", "PREVIEW_TARGET_REPO"), 90 | call.GitRepo(self.git_repo_api_mock), 91 | call.GitRepo.clone("target-branch"), 92 | call.logging.info("Preview folder name: %s", "app-685912d3-preview"), 93 | call.GitRepo.get_full_file_path("app-685912d3-preview"), 94 | call.Path("/tmp/created-tmp-dir/app-685912d3-preview"), 95 | call.Path.exists(), 96 | call.shutil.rmtree("/tmp/created-tmp-dir/app-685912d3-preview", ignore_errors=True), 97 | call.GitRepo.commit( 98 | "GIT_USER", 99 | "GIT_EMAIL", 100 | "GIT_AUTHOR_NAME", 101 | "GIT_AUTHOR_EMAIL", 102 | "Delete preview environment for 'APP' and preview id 'PREVIEW_ID'.", 103 | ), 104 | call.GitRepo.pull_rebase(), 105 | call.GitRepo.push(), 106 | ] 107 | 108 | def test_delete_missing_happy_flow(self): 109 | self.path_mock.exists.return_value = False 110 | 111 | args = DeletePreviewCommand.Args( 112 | username="USERNAME", 113 | password="PASSWORD", 114 | git_user="GIT_USER", 115 | git_email="GIT_EMAIL", 116 | git_author_name=None, 117 | git_author_email=None, 118 | organisation="ORGA", 119 | repository_name="REPO", 120 | git_provider=GitProvider.GITHUB, 121 | git_provider_url=None, 122 | preview_id="PREVIEW_ID", 123 | expect_preview_exists=False, 124 | ) 125 | DeletePreviewCommand(args).execute() 126 | assert self.mock_manager.method_calls == [ 127 | call.load_gitops_config(args, "ORGA", "REPO"), 128 | call.GitRepoApiFactory.create(args, "PREVIEW_TARGET_ORG", "PREVIEW_TARGET_REPO"), 129 | call.GitRepo(self.git_repo_api_mock), 130 | call.GitRepo.clone("target-branch"), 131 | call.logging.info("Preview folder name: %s", "app-685912d3-preview"), 132 | call.GitRepo.get_full_file_path("app-685912d3-preview"), 133 | call.Path("/tmp/created-tmp-dir/app-685912d3-preview"), 134 | call.Path.exists(), 135 | call.logging.info( 136 | "No preview environment for '%s' and preview id '%s'. I'm done here.", "APP", "PREVIEW_ID" 137 | ), 138 | ] 139 | 140 | def test_delete_missing_but_expected_error(self): 141 | self.path_mock.exists.return_value = False 142 | 143 | args = DeletePreviewCommand.Args( 144 | username="USERNAME", 145 | password="PASSWORD", 146 | git_user="GIT_USER", 147 | git_email="GIT_EMAIL", 148 | git_author_name=None, 149 | git_author_email=None, 150 | organisation="ORGA", 151 | repository_name="REPO", 152 | git_provider=GitProvider.GITHUB, 153 | git_provider_url=None, 154 | preview_id="PREVIEW_ID", 155 | expect_preview_exists=True, # we expect an existing preview 156 | ) 157 | with pytest.raises(GitOpsException) as ex: 158 | DeletePreviewCommand(args).execute() 159 | self.assertEqual(str(ex.value), "There was no preview with name: app-685912d3-preview") 160 | 161 | assert self.mock_manager.method_calls == [ 162 | call.load_gitops_config(args, "ORGA", "REPO"), 163 | call.GitRepoApiFactory.create(args, "PREVIEW_TARGET_ORG", "PREVIEW_TARGET_REPO"), 164 | call.GitRepo(self.git_repo_api_mock), 165 | call.GitRepo.clone("target-branch"), 166 | call.logging.info("Preview folder name: %s", "app-685912d3-preview"), 167 | call.GitRepo.get_full_file_path("app-685912d3-preview"), 168 | call.Path("/tmp/created-tmp-dir/app-685912d3-preview"), 169 | call.Path.exists(), 170 | ] 171 | 172 | def test_missing_gitops_config_yaml_error(self): 173 | self.load_gitops_config_mock.side_effect = GitOpsException() 174 | 175 | args = DeletePreviewCommand.Args( 176 | username="USERNAME", 177 | password="PASSWORD", 178 | git_user="GIT_USER", 179 | git_email="GIT_EMAIL", 180 | git_author_name=None, 181 | git_author_email=None, 182 | organisation="ORGA", 183 | repository_name="REPO", 184 | git_provider=GitProvider.GITHUB, 185 | git_provider_url=None, 186 | preview_id="PREVIEW_ID", 187 | expect_preview_exists=True, # we expect an existing preview 188 | ) 189 | with pytest.raises(GitOpsException): 190 | DeletePreviewCommand(args).execute() 191 | assert self.mock_manager.method_calls == [ 192 | call.load_gitops_config(args, "ORGA", "REPO"), 193 | ] 194 | -------------------------------------------------------------------------------- /tests/commands/test_version.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import unittest 4 | from contextlib import contextmanager 5 | from io import StringIO 6 | 7 | from gitopscli.commands.version import VersionCommand 8 | 9 | 10 | @contextmanager 11 | def captured_output(): 12 | new_out = StringIO() 13 | old_out = sys.stdout 14 | try: 15 | sys.stdout = new_out 16 | yield sys.stdout 17 | finally: 18 | sys.stdout = old_out 19 | 20 | 21 | class VersionCommandTest(unittest.TestCase): 22 | def test_output(self): 23 | with captured_output() as stdout: 24 | VersionCommand(VersionCommand.Args()).execute() 25 | assert re.match(r"^GitOps CLI version \d+\.\d+\.\d+\n$", stdout.getvalue()) 26 | -------------------------------------------------------------------------------- /tests/git_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/tests/git_api/__init__.py -------------------------------------------------------------------------------- /tests/git_api/test_git_repo_api_logging_proxy.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, patch 3 | 4 | from gitopscli.git_api import GitRepoApi 5 | from gitopscli.git_api.git_repo_api_logging_proxy import GitRepoApiLoggingProxy 6 | 7 | 8 | class GitRepoApiLoggingProxyTest(unittest.TestCase): 9 | def setUp(self): 10 | self.__mock_repo_api: GitRepoApi = MagicMock() 11 | self.__testee = GitRepoApiLoggingProxy(self.__mock_repo_api) 12 | 13 | def test_get_username(self): 14 | expected_return_value = "" 15 | self.__mock_repo_api.get_username.return_value = expected_return_value 16 | 17 | actual_return_value = self.__testee.get_username() 18 | 19 | self.assertEqual(actual_return_value, expected_return_value) 20 | self.__mock_repo_api.get_username.assert_called_once_with() 21 | 22 | def test_get_password(self): 23 | expected_return_value = "" 24 | self.__mock_repo_api.get_password.return_value = expected_return_value 25 | 26 | actual_return_value = self.__testee.get_password() 27 | 28 | self.assertEqual(actual_return_value, expected_return_value) 29 | self.__mock_repo_api.get_password.assert_called_once_with() 30 | 31 | def test_get_clone_url(self): 32 | expected_return_value = "" 33 | self.__mock_repo_api.get_clone_url.return_value = expected_return_value 34 | 35 | actual_return_value = self.__testee.get_clone_url() 36 | 37 | self.assertEqual(actual_return_value, expected_return_value) 38 | self.__mock_repo_api.get_clone_url.assert_called_once_with() 39 | 40 | @patch("gitopscli.git_api.git_repo_api_logging_proxy.logging") 41 | def test_create_pull_request(self, logging_mock): 42 | expected_return_value = GitRepoApi.PullRequestIdAndUrl(42, "") 43 | self.__mock_repo_api.create_pull_request.return_value = expected_return_value 44 | 45 | actual_return_value = self.__testee.create_pull_request( 46 | from_branch="", to_branch="", title="", description="<description>" 47 | ) 48 | 49 | self.assertEqual(actual_return_value, expected_return_value) 50 | self.__mock_repo_api.create_pull_request.assert_called_once_with( 51 | "<from branch>", "<to branch>", "<title>", "<description>" 52 | ) 53 | logging_mock.info.assert_called_once_with( 54 | "Creating pull request from '%s' to '%s' with title: %s", "<from branch>", "<to branch>", "<title>" 55 | ) 56 | 57 | @patch("gitopscli.git_api.git_repo_api_logging_proxy.logging") 58 | def test_create_pull_request_to_default_branch(self, logging_mock): 59 | expected_return_value = GitRepoApi.PullRequestIdAndUrl(42, "<url>") 60 | self.__mock_repo_api.create_pull_request_to_default_branch.return_value = expected_return_value 61 | 62 | actual_return_value = self.__testee.create_pull_request_to_default_branch( 63 | from_branch="<from branch>", title="<title>", description="<description>" 64 | ) 65 | 66 | self.assertEqual(actual_return_value, expected_return_value) 67 | self.__mock_repo_api.create_pull_request_to_default_branch.assert_called_once_with( 68 | "<from branch>", "<title>", "<description>" 69 | ) 70 | logging_mock.info.assert_called_once_with( 71 | "Creating pull request from '%s' to default branch with title: %s", "<from branch>", "<title>" 72 | ) 73 | 74 | @patch("gitopscli.git_api.git_repo_api_logging_proxy.logging") 75 | def test_merge_pull_request(self, logging_mock): 76 | self.__testee.merge_pull_request(pr_id=42) 77 | self.__mock_repo_api.merge_pull_request.assert_called_once_with(42, merge_method="merge") 78 | logging_mock.info.assert_called_once_with("Merging pull request %s", 42) 79 | 80 | @patch("gitopscli.git_api.git_repo_api_logging_proxy.logging") 81 | def test_add_pull_request_comment(self, logging_mock): 82 | self.__testee.add_pull_request_comment(pr_id=42, text="<text>", parent_id=4711) 83 | self.__mock_repo_api.add_pull_request_comment.assert_called_once_with(42, "<text>", 4711) 84 | logging_mock.info.assert_called_once_with( 85 | "Creating comment for pull request %s as reply to comment %s with content: %s", 42, 4711, "<text>" 86 | ) 87 | 88 | @patch("gitopscli.git_api.git_repo_api_logging_proxy.logging") 89 | def test_add_pull_request_comment_without_parent_id(self, logging_mock): 90 | self.__testee.add_pull_request_comment(pr_id=42, text="<text>", parent_id=None) 91 | self.__mock_repo_api.add_pull_request_comment.assert_called_once_with(42, "<text>", None) 92 | logging_mock.info.assert_called_once_with("Creating comment for pull request %s with content: %s", 42, "<text>") 93 | 94 | @patch("gitopscli.git_api.git_repo_api_logging_proxy.logging") 95 | def test_delete_branch(self, logging_mock): 96 | self.__testee.delete_branch("<branch>") 97 | self.__mock_repo_api.delete_branch.assert_called_once_with("<branch>") 98 | logging_mock.info.assert_called_once_with("Deleting branch '%s'", "<branch>") 99 | 100 | def test_get_branch_head_hash(self): 101 | expected_return_value = "<hash>" 102 | self.__mock_repo_api.get_branch_head_hash.return_value = expected_return_value 103 | 104 | actual_return_value = self.__testee.get_branch_head_hash("<branch>") 105 | 106 | self.assertEqual(actual_return_value, expected_return_value) 107 | self.__mock_repo_api.get_branch_head_hash.assert_called_once_with("<branch>") 108 | 109 | def test_get_pull_request_branch(self): 110 | expected_return_value = "<hash>" 111 | self.__mock_repo_api.get_pull_request_branch.return_value = expected_return_value 112 | 113 | actual_return_value = self.__testee.get_pull_request_branch(42) 114 | 115 | self.assertEqual(actual_return_value, expected_return_value) 116 | self.__mock_repo_api.get_pull_request_branch.assert_called_once_with(42) 117 | -------------------------------------------------------------------------------- /tests/git_api/test_repo_api_factory.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, patch 3 | 4 | from gitopscli.git_api import GitApiConfig, GitProvider, GitRepoApiFactory 5 | from gitopscli.gitops_exception import GitOpsException 6 | 7 | 8 | class GitRepoApiFactoryTest(unittest.TestCase): 9 | @patch("gitopscli.git_api.git_repo_api_factory.GitRepoApiLoggingProxy") 10 | @patch("gitopscli.git_api.git_repo_api_factory.GithubGitRepoApiAdapter") 11 | def test_create_github(self, mock_github_adapter_constructor, mock_logging_proxy_constructor): 12 | mock_github_adapter = MagicMock() 13 | mock_github_adapter_constructor.return_value = mock_github_adapter 14 | 15 | mock_logging_proxy = MagicMock() 16 | mock_logging_proxy_constructor.return_value = mock_logging_proxy 17 | 18 | git_repo_api = GitRepoApiFactory.create( 19 | config=GitApiConfig( 20 | username="USER", password="PASS", git_provider=GitProvider.GITHUB, git_provider_url=None 21 | ), 22 | organisation="ORG", 23 | repository_name="REPO", 24 | ) 25 | 26 | self.assertEqual(git_repo_api, mock_logging_proxy) 27 | 28 | mock_github_adapter_constructor.assert_called_with( 29 | username="USER", password="PASS", organisation="ORG", repository_name="REPO" 30 | ) 31 | mock_logging_proxy_constructor.assert_called_with(mock_github_adapter) 32 | 33 | @patch("gitopscli.git_api.git_repo_api_factory.GitRepoApiLoggingProxy") 34 | @patch("gitopscli.git_api.git_repo_api_factory.BitbucketGitRepoApiAdapter") 35 | def test_create_bitbucket(self, mock_bitbucket_adapter_constructor, mock_logging_proxy_constructor): 36 | mock_bitbucket_adapter = MagicMock() 37 | mock_bitbucket_adapter_constructor.return_value = mock_bitbucket_adapter 38 | 39 | mock_logging_proxy = MagicMock() 40 | mock_logging_proxy_constructor.return_value = mock_logging_proxy 41 | 42 | git_repo_api = GitRepoApiFactory.create( 43 | config=GitApiConfig( 44 | username="USER", password="PASS", git_provider=GitProvider.BITBUCKET, git_provider_url="PROVIDER_URL" 45 | ), 46 | organisation="ORG", 47 | repository_name="REPO", 48 | ) 49 | 50 | self.assertEqual(git_repo_api, mock_logging_proxy) 51 | 52 | mock_bitbucket_adapter_constructor.assert_called_with( 53 | git_provider_url="PROVIDER_URL", 54 | username="USER", 55 | password="PASS", 56 | organisation="ORG", 57 | repository_name="REPO", 58 | ) 59 | mock_logging_proxy_constructor.assert_called_with(mock_bitbucket_adapter) 60 | 61 | def test_create_bitbucket_missing_url(self): 62 | try: 63 | GitRepoApiFactory.create( 64 | config=GitApiConfig( 65 | username="USER", password="PASS", git_provider=GitProvider.BITBUCKET, git_provider_url=None 66 | ), 67 | organisation="ORG", 68 | repository_name="REPO", 69 | ) 70 | self.fail("Expected a GitOpsException") 71 | except GitOpsException as ex: 72 | self.assertEqual("Please provide url for Bitbucket!", str(ex)) 73 | 74 | @patch("gitopscli.git_api.git_repo_api_factory.GitRepoApiLoggingProxy") 75 | @patch("gitopscli.git_api.git_repo_api_factory.GitlabGitRepoApiAdapter") 76 | def test_create_gitlab(self, mock_gitlab_adapter_constructor, mock_logging_proxy_constructor): 77 | mock_gitlab_adapter = MagicMock() 78 | mock_gitlab_adapter_constructor.return_value = mock_gitlab_adapter 79 | 80 | mock_logging_proxy = MagicMock() 81 | mock_logging_proxy_constructor.return_value = mock_logging_proxy 82 | 83 | git_repo_api = GitRepoApiFactory.create( 84 | config=GitApiConfig( 85 | username="USER", password="PASS", git_provider=GitProvider.GITLAB, git_provider_url="PROVIDER_URL" 86 | ), 87 | organisation="ORG", 88 | repository_name="REPO", 89 | ) 90 | 91 | self.assertEqual(git_repo_api, mock_logging_proxy) 92 | 93 | mock_gitlab_adapter_constructor.assert_called_with( 94 | git_provider_url="PROVIDER_URL", 95 | username="USER", 96 | password="PASS", 97 | organisation="ORG", 98 | repository_name="REPO", 99 | ) 100 | mock_logging_proxy_constructor.assert_called_with(mock_gitlab_adapter) 101 | 102 | @patch("gitopscli.git_api.git_repo_api_factory.GitRepoApiLoggingProxy") 103 | @patch("gitopscli.git_api.git_repo_api_factory.GitlabGitRepoApiAdapter") 104 | def test_create_gitlab_default_provider_url(self, mock_gitlab_adapter_constructor, mock_logging_proxy_constructor): 105 | mock_gitlab_adapter = MagicMock() 106 | mock_gitlab_adapter_constructor.return_value = mock_gitlab_adapter 107 | 108 | mock_logging_proxy = MagicMock() 109 | mock_logging_proxy_constructor.return_value = mock_logging_proxy 110 | 111 | git_repo_api = GitRepoApiFactory.create( 112 | config=GitApiConfig( 113 | username="USER", password="PASS", git_provider=GitProvider.GITLAB, git_provider_url=None 114 | ), 115 | organisation="ORG", 116 | repository_name="REPO", 117 | ) 118 | 119 | self.assertEqual(git_repo_api, mock_logging_proxy) 120 | 121 | mock_gitlab_adapter_constructor.assert_called_with( 122 | git_provider_url="https://www.gitlab.com", 123 | username="USER", 124 | password="PASS", 125 | organisation="ORG", 126 | repository_name="REPO", 127 | ) 128 | mock_logging_proxy_constructor.assert_called_with(mock_gitlab_adapter) 129 | -------------------------------------------------------------------------------- /tests/io_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baloise/gitopscli/769830bca12884f131ba8a44bcaa053385206e29/tests/io_api/__init__.py -------------------------------------------------------------------------------- /tests/io_api/test_tmp_dir.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import unittest 3 | import uuid 4 | from pathlib import Path 5 | 6 | from gitopscli.io_api.tmp_dir import create_tmp_dir, delete_tmp_dir 7 | 8 | 9 | class TmpDirTest(unittest.TestCase): 10 | tmp_dir = None 11 | 12 | def tearDown(self): 13 | if self.tmp_dir: 14 | shutil.rmtree(self.tmp_dir, ignore_errors=True) 15 | 16 | def test_create_tmp_dir(self): 17 | self.tmp_dir = create_tmp_dir() 18 | self.assertRegex( 19 | self.tmp_dir, r"^/tmp/gitopscli/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" 20 | ) 21 | self.assertTrue(Path(self.tmp_dir).is_dir()) 22 | 23 | def test_delete_tmp_dir(self): 24 | self.tmp_dir = f"/tmp/gitopscli/{uuid.uuid4()}" 25 | Path(self.tmp_dir).mkdir(parents=True) 26 | self.assertTrue(Path(self.tmp_dir).is_dir()) 27 | delete_tmp_dir(self.tmp_dir) 28 | self.assertFalse(Path(self.tmp_dir).is_dir()) 29 | self.tmp_dir = None 30 | -------------------------------------------------------------------------------- /tests/io_api/test_yaml_util.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import unittest 3 | import uuid 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | from gitopscli.io_api.yaml_util import ( 9 | YAMLException, 10 | merge_yaml_element, 11 | update_yaml_file, 12 | yaml_dump, 13 | yaml_file_dump, 14 | yaml_file_load, 15 | yaml_load, 16 | ) 17 | 18 | 19 | class YamlUtilTest(unittest.TestCase): 20 | maxDiff = None 21 | 22 | @classmethod 23 | def setUpClass(cls): 24 | cls.tmp_dir = f"/tmp/gitopscli-test-{uuid.uuid4()}" 25 | Path(cls.tmp_dir).mkdir(parents=True) 26 | 27 | @classmethod 28 | def tearDownClass(cls): 29 | shutil.rmtree(cls.tmp_dir, ignore_errors=True) 30 | 31 | def _create_tmp_file_path(self): 32 | return f"{self.tmp_dir}/{uuid.uuid4()}" 33 | 34 | def _create_file(self, content): 35 | path = self._create_tmp_file_path() 36 | with Path(path).open("w") as stream: 37 | stream.write(content) 38 | return path 39 | 40 | def _read_file(self, path): 41 | with Path(path).open() as stream: 42 | return stream.read() 43 | 44 | def test_yaml_file_load(self): 45 | path = self._create_file("answer: #comment\n is: '42'\n") 46 | self.assertEqual(yaml_file_load(path), {"answer": {"is": "42"}}) 47 | 48 | def test_yaml_file_load_file_not_found(self): 49 | try: 50 | yaml_file_load("unknown") 51 | self.fail() 52 | except FileNotFoundError: 53 | pass 54 | 55 | def test_yaml_file_load_yaml_exception(self): 56 | path = self._create_file("{ INVALID YAML") 57 | try: 58 | yaml_file_load(path) 59 | self.fail() 60 | except YAMLException as ex: 61 | self.assertEqual(f"Error parsing YAML file: {path}", str(ex)) 62 | 63 | def test_yaml_file_dump(self): 64 | path = self._create_tmp_file_path() 65 | yaml_file_dump({"answer": {"is": "42"}}, path) 66 | yaml_content = self._read_file(path) 67 | self.assertEqual(yaml_content, "answer:\n is: '42'\n") 68 | 69 | def test_yaml_file_dump_unknown_directory(self): 70 | try: 71 | yaml_file_dump({"answer": {"is": "42"}}, "/unknown-dir/foo") 72 | self.fail() 73 | except FileNotFoundError: 74 | pass 75 | 76 | def test_yaml_file_load_and_dump_roundtrip(self): 77 | input_content = "answer: #comment\n is: '42'\n" # comment should be preserved 78 | input_path = self._create_file(input_content) 79 | yaml = yaml_file_load(input_path) 80 | output_path = self._create_tmp_file_path() 81 | yaml_file_dump(yaml, output_path) 82 | output_content = self._read_file(output_path) 83 | self.assertEqual(output_content, input_content) 84 | 85 | def test_yaml_load(self): 86 | self.assertEqual(yaml_load("{answer: '42'}"), {"answer": "42"}) 87 | self.assertEqual(yaml_load("{answer: 42}"), {"answer": 42}) 88 | self.assertEqual(yaml_load("answer: 42"), {"answer": 42}) 89 | 90 | def test_yaml_load_yaml_exception(self): 91 | try: 92 | yaml_load("{ INVALID YAML") 93 | self.fail() 94 | except YAMLException as ex: 95 | self.assertEqual("Error parsing YAML string '{ INVALID YAML'", str(ex)) 96 | 97 | def test_yaml_dump(self): 98 | self.assertEqual(yaml_dump({"answer": "42"}), "answer: '42'") 99 | self.assertEqual(yaml_dump({"answer": 42}), "answer: 42") 100 | self.assertEqual( 101 | yaml_dump({"answer": "42", "universe": ["and", "everything"]}), 102 | """\ 103 | answer: '42' 104 | universe: 105 | - and 106 | - everything""", 107 | ) 108 | 109 | def test_update_yaml_file(self): 110 | test_file = self._create_file( 111 | """\ 112 | a: # comment 1 113 | # comment 2 114 | b: 115 | d: 1 # comment 3 116 | c: 2 # comment 4 117 | e: "expect quotes are preserved" 118 | e: 119 | - f: 3 # comment 5 120 | g: 4 # comment 6 121 | - [hello, world] # comment 7 122 | - foo: # comment 8 123 | bar # comment 9 124 | - list: # comment 10 125 | - key: k1 # comment 11 126 | value: v1 # comment 12 127 | - key: k2 # comment 13 128 | value: v2 # comment 14 129 | - {key: k3+4, value: v3} # comment 15 130 | - key: k3+4 # comment 16 131 | value: v4 # comment 17""" 132 | ) 133 | 134 | self.assertTrue(update_yaml_file(test_file, "a.b.c", "2")) 135 | self.assertFalse(update_yaml_file(test_file, "a.b.c", "2")) # already updated 136 | 137 | self.assertTrue(update_yaml_file(test_file, "a.e.[0].g", 42)) 138 | self.assertFalse(update_yaml_file(test_file, "a.e.[0].g", 42)) # already updated 139 | 140 | self.assertTrue(update_yaml_file(test_file, "a.e.[1].[1]", "tester")) 141 | self.assertFalse(update_yaml_file(test_file, "a.e.[1].[1]", "tester")) # already updated 142 | 143 | self.assertTrue(update_yaml_file(test_file, "a.e.[2]", "replaced object")) 144 | self.assertFalse(update_yaml_file(test_file, "a.e.[2]", "replaced object")) # already updated 145 | 146 | self.assertTrue(update_yaml_file(test_file, "a.e.[*].list[?key=='k3+4'].value", "replaced v3 and v4")) 147 | self.assertFalse( 148 | update_yaml_file(test_file, "a.e.[*].list[?key=='k3+4'].value", "replaced v3 and v4") 149 | ) # already updated 150 | 151 | expected = """\ 152 | a: # comment 1 153 | # comment 2 154 | b: 155 | d: 1 # comment 3 156 | c: '2' # comment 4 157 | e: "expect quotes are preserved" 158 | e: 159 | - f: 3 # comment 5 160 | g: 42 # comment 6 161 | - [hello, tester] # comment 7 162 | - replaced object 163 | - list: # comment 10 164 | - key: k1 # comment 11 165 | value: v1 # comment 12 166 | - key: k2 # comment 13 167 | value: v2 # comment 14 168 | - {key: k3+4, value: replaced v3 and v4} # comment 15 169 | - key: k3+4 # comment 16 170 | value: replaced v3 and v4 # comment 17 171 | """ 172 | actual = self._read_file(test_file) 173 | self.assertEqual(expected, actual) 174 | 175 | with pytest.raises(KeyError) as ex: 176 | update_yaml_file(test_file, "x.y", "foo") 177 | self.assertEqual("\"Key 'x.y' not found in YAML!\"", str(ex.value)) 178 | 179 | with pytest.raises(KeyError) as ex: 180 | update_yaml_file(test_file, "[42].y", "foo") 181 | self.assertEqual("\"Key '[42].y' not found in YAML!\"", str(ex.value)) 182 | 183 | with pytest.raises(KeyError) as ex: 184 | update_yaml_file(test_file, "a.x", "foo") 185 | self.assertEqual("\"Key 'a.x' not found in YAML!\"", str(ex.value)) 186 | 187 | with pytest.raises(KeyError) as ex: 188 | update_yaml_file(test_file, "a.[42]", "foo") 189 | self.assertEqual("\"Key 'a.[42]' not found in YAML!\"", str(ex.value)) 190 | 191 | with pytest.raises(KeyError) as ex: 192 | update_yaml_file(test_file, "a.e.[100]", "foo") 193 | self.assertEqual("\"Key 'a.e.[100]' not found in YAML!\"", str(ex.value)) 194 | 195 | with pytest.raises(KeyError) as ex: 196 | update_yaml_file(test_file, "a.e.[*].list[?key=='foo'].value", "foo") 197 | self.assertEqual("\"Key 'a.e.[*].list[?key=='foo'].value' not found in YAML!\"", str(ex.value)) 198 | 199 | with pytest.raises(KeyError) as ex: 200 | update_yaml_file(test_file, "a.e.[2].[2]", "foo") 201 | self.assertEqual( 202 | "\"Key 'a.e.[2].[2]' cannot be updated: 'str' object does not support item assignment!\"", str(ex.value) 203 | ) 204 | 205 | with pytest.raises(KeyError) as ex: 206 | update_yaml_file(test_file, "invalid JSONPath", "foo") 207 | self.assertEqual( 208 | "\"Key 'invalid JSONPath' is invalid JSONPath expression: Parse error at 1:8 near token JSONPath (ID)!\"", 209 | str(ex.value), 210 | ) 211 | 212 | with pytest.raises(KeyError) as ex: 213 | update_yaml_file(test_file, "", "foo") 214 | self.assertEqual("'Empty key!'", str(ex.value)) 215 | 216 | actual = self._read_file(test_file) 217 | self.assertEqual(expected, actual) 218 | 219 | def test_update_yaml_file_not_found_error(self): 220 | try: 221 | update_yaml_file("/some-unknown-dir/some-random-unknown-file", "a.b", "foo") 222 | self.fail() 223 | except FileNotFoundError: 224 | pass 225 | 226 | def test_update_yaml_file_is_a_directory_error(self): 227 | try: 228 | update_yaml_file("/tmp", "a.b", "foo") 229 | self.fail() 230 | except IsADirectoryError: 231 | pass 232 | 233 | def test_merge_yaml_element(self): 234 | test_file = self._create_file( 235 | """\ 236 | # Kept comment 237 | applications: 238 | app1: # Lost comment 239 | app2: 240 | key: value # Lost comment 241 | """ 242 | ) 243 | 244 | value = {"app2": {"key2": "value"}, "app3": None} 245 | merge_yaml_element(test_file, "applications", value) 246 | 247 | expected = """\ 248 | # Kept comment 249 | applications: 250 | app2: 251 | key: value 252 | key2: value 253 | app3: 254 | """ 255 | actual = self._read_file(test_file) 256 | self.assertEqual(expected, actual) 257 | 258 | def test_merge_yaml_element_create(self): 259 | test_file = self._create_file( 260 | """\ 261 | # Kept comment 262 | applications: null 263 | """ 264 | ) 265 | 266 | value = {"app2": {"key2": "value"}, "app3": None} 267 | merge_yaml_element(test_file, "applications", value) 268 | 269 | expected = """\ 270 | # Kept comment 271 | applications: 272 | app2: 273 | key2: value 274 | app3: 275 | """ 276 | actual = self._read_file(test_file) 277 | self.assertEqual(expected, actual) 278 | 279 | def test_merge_yaml_element_root_dir(self): 280 | test_file = self._create_file( 281 | """\ 282 | applications: 283 | app1: # Lost comment 284 | app2: # Lost comment 285 | key: value # Lost comment 286 | """ 287 | ) 288 | 289 | value = {"applications": {"app2": {"key2": "value"}, "app3": None}} 290 | merge_yaml_element(test_file, ".", value) 291 | 292 | expected = """\ 293 | applications: 294 | app1: 295 | app2: 296 | key2: value 297 | app3: 298 | """ 299 | actual = self._read_file(test_file) 300 | self.assertEqual(expected, actual) 301 | -------------------------------------------------------------------------------- /tests/test_gitops_config_v0.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import pytest 4 | 5 | from gitopscli.gitops_config import GitOpsConfig 6 | from gitopscli.gitops_exception import GitOpsException 7 | 8 | 9 | class GitOpsConfigV0Test(unittest.TestCase): 10 | def setUp(self): 11 | self.yaml = { 12 | "deploymentConfig": {"applicationName": "my-app", "org": "my-org", "repository": "my-repo"}, 13 | "previewConfig": { 14 | "route": {"host": {"template": "my-{SHA256_8CHAR_BRANCH_HASH}-host-template"}}, 15 | "replace": [{"path": "a.b", "variable": "ROUTE_HOST"}, {"path": "c.d", "variable": "GIT_COMMIT"}], 16 | }, 17 | } 18 | 19 | def load(self) -> GitOpsConfig: 20 | return GitOpsConfig.from_yaml(self.yaml) 21 | 22 | def assert_load_error(self, error_msg: str) -> None: 23 | with pytest.raises(GitOpsException) as ex: 24 | self.load() 25 | self.assertEqual(error_msg, str(ex.value)) 26 | 27 | def test_application_name(self): 28 | config = self.load() 29 | self.assertEqual(config.application_name, "my-app") 30 | 31 | def test_application_name_missing(self): 32 | del self.yaml["deploymentConfig"]["applicationName"] 33 | self.assert_load_error("Key 'deploymentConfig.applicationName' not found in GitOps config!") 34 | 35 | def test_application_name_not_a_string(self): 36 | self.yaml["deploymentConfig"]["applicationName"] = 1 37 | self.assert_load_error("Item 'deploymentConfig.applicationName' should be a string in GitOps config!") 38 | 39 | def test_team_config_org(self): 40 | config = self.load() 41 | self.assertEqual(config.preview_template_organisation, "my-org") 42 | self.assertEqual(config.preview_target_organisation, "my-org") 43 | self.assertTrue(config.is_preview_template_equal_target()) 44 | 45 | def test_deployment_config_org_missing(self): 46 | del self.yaml["deploymentConfig"]["org"] 47 | self.assert_load_error("Key 'deploymentConfig.org' not found in GitOps config!") 48 | 49 | def test_deployment_config_org_not_a_string(self): 50 | self.yaml["deploymentConfig"]["org"] = True 51 | self.assert_load_error("Item 'deploymentConfig.org' should be a string in GitOps config!") 52 | 53 | def test_deployment_config_repo(self): 54 | config = self.load() 55 | self.assertEqual(config.preview_template_repository, "my-repo") 56 | self.assertEqual(config.preview_target_repository, "my-repo") 57 | self.assertTrue(config.is_preview_template_equal_target()) 58 | 59 | def test_deployment_config_repo_missing(self): 60 | del self.yaml["deploymentConfig"]["repository"] 61 | self.assert_load_error("Key 'deploymentConfig.repository' not found in GitOps config!") 62 | 63 | def test_deployment_config_repo_not_a_string(self): 64 | self.yaml["deploymentConfig"]["repository"] = [] 65 | self.assert_load_error("Item 'deploymentConfig.repository' should be a string in GitOps config!") 66 | 67 | def test_preview_template_branch_is_none(self): 68 | config = self.load() 69 | self.assertIsNone(config.preview_template_branch) 70 | 71 | def test_preview_target_branch_is_none(self): 72 | config = self.load() 73 | self.assertIsNone(config.preview_target_branch) 74 | 75 | def test_route_host_template(self): 76 | config = self.load() 77 | self.assertEqual(config.preview_host_template, "my-${PREVIEW_ID_HASH}-host-template") 78 | 79 | def test_route_host(self): 80 | config = self.load() 81 | self.assertEqual(config.get_preview_host("preview-1"), "my-3e355b4a-host-template") 82 | 83 | def test_route_missing(self): 84 | del self.yaml["previewConfig"]["route"] 85 | self.assert_load_error("Key 'previewConfig.route.host.template' not found in GitOps config!") 86 | 87 | def test_route_host_missing(self): 88 | del self.yaml["previewConfig"]["route"]["host"] 89 | self.assert_load_error("Key 'previewConfig.route.host.template' not found in GitOps config!") 90 | 91 | def test_route_host_template_missing(self): 92 | del self.yaml["previewConfig"]["route"]["host"]["template"] 93 | self.assert_load_error("Key 'previewConfig.route.host.template' not found in GitOps config!") 94 | 95 | def test_route_host_template_not_a_string(self): 96 | self.yaml["previewConfig"]["route"]["host"]["template"] = [] 97 | self.assert_load_error("Item 'previewConfig.route.host.template' should be a string in GitOps config!") 98 | 99 | def test_namespace_template(self): 100 | config = self.load() 101 | self.assertEqual(config.preview_target_namespace_template, "${APPLICATION_NAME}-${PREVIEW_ID_HASH}-preview") 102 | 103 | def test_namespace(self): 104 | config = self.load() 105 | self.assertEqual(config.get_preview_namespace("preview-1"), "my-app-3e355b4a-preview") 106 | 107 | def test_replacements(self): 108 | config = self.load() 109 | self.assertEqual(config.replacements.keys(), {"Chart.yaml", "values.yaml"}) 110 | 111 | self.assertEqual(len(config.replacements["Chart.yaml"]), 1) 112 | self.assertEqual(config.replacements["Chart.yaml"][0].path, "name") 113 | self.assertEqual(config.replacements["Chart.yaml"][0].value_template, "${PREVIEW_NAMESPACE}") 114 | 115 | self.assertEqual(len(config.replacements["values.yaml"]), 2) 116 | self.assertEqual(config.replacements["values.yaml"][0].path, "a.b") 117 | self.assertEqual(config.replacements["values.yaml"][0].value_template, "${PREVIEW_HOST}") 118 | self.assertEqual(config.replacements["values.yaml"][1].path, "c.d") 119 | self.assertEqual(config.replacements["values.yaml"][1].value_template, "${GIT_HASH}") 120 | 121 | def test_replacements_missing(self): 122 | del self.yaml["previewConfig"]["replace"] 123 | self.assert_load_error("Key 'previewConfig.replace' not found in GitOps config!") 124 | 125 | def test_replacements_not_a_list(self): 126 | self.yaml["previewConfig"]["replace"] = "foo" 127 | self.assert_load_error("Item 'previewConfig.replace' should be a list in GitOps config!") 128 | 129 | def test_replacements_invalid_list(self): 130 | self.yaml["previewConfig"]["replace"] = ["foo"] 131 | self.assert_load_error("Item 'previewConfig.replace.[0]' should be an object in GitOps config!") 132 | 133 | def test_replacements_invalid_list_items_missing_path(self): 134 | del self.yaml["previewConfig"]["replace"][1]["path"] 135 | self.assert_load_error("Key 'previewConfig.replace.[1].path' not found in GitOps config!") 136 | 137 | def test_replacements_invalid_list_items_missing_variable(self): 138 | del self.yaml["previewConfig"]["replace"][0]["variable"] 139 | self.assert_load_error("Key 'previewConfig.replace.[0].variable' not found in GitOps config!") 140 | 141 | def test_replacements_invalid_list_items_path_not_a_string(self): 142 | self.yaml["previewConfig"]["replace"][0]["path"] = 42 143 | self.assert_load_error("Item 'previewConfig.replace.[0].path' should be a string in GitOps config!") 144 | 145 | def test_replacements_invalid_list_items_variable_not_a_string(self): 146 | self.yaml["previewConfig"]["replace"][0]["variable"] = [] 147 | self.assert_load_error("Item 'previewConfig.replace.[0].variable' should be a string in GitOps config!") 148 | 149 | def test_replacements_invalid_list_items_unknown_variable(self): 150 | self.yaml["previewConfig"]["replace"][0]["variable"] = "FOO" 151 | self.assert_load_error("Replacement value '${FOO}' for path 'a.b' contains invalid variable: FOO") 152 | 153 | def test_replacements_invalid_list_items_invalid_variable(self): 154 | self.yaml["previewConfig"]["replace"][0]["variable"] = "{FOO" 155 | self.assert_load_error("Item 'previewConfig.replace.[0].variable' must not contain '{' or '}'!") 156 | --------------------------------------------------------------------------------