├── .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 .
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 .
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  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  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 | [](https://github.com/baloise/gitopscli/actions/workflows/release.yml)
2 | [](https://github.com/baloise/gitopscli/releases)
3 | [](https://hub.docker.com/r/baloise/gitopscli/tags)
4 | [](https://www.python.org/downloads/release/python-3108/)
5 | [](https://github.com/semantic-release/semantic-release)
6 | [](https://gitpod.io/#https://github.com/baloise/gitopscli)
7 | [](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 | 
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 | 
118 | 
119 | 
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 | {: .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=""
47 | )
48 |
49 | self.assertEqual(actual_return_value, expected_return_value)
50 | self.__mock_repo_api.create_pull_request.assert_called_once_with(
51 | "", "", "", ""
52 | )
53 | logging_mock.info.assert_called_once_with(
54 | "Creating pull request from '%s' to '%s' with title: %s", "", "", ""
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, "")
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="", title="", 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 | "", "", ""
69 | )
70 | logging_mock.info.assert_called_once_with(
71 | "Creating pull request from '%s' to default branch with title: %s", "", ""
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="", parent_id=4711)
83 | self.__mock_repo_api.add_pull_request_comment.assert_called_once_with(42, "", 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, ""
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="", parent_id=None)
91 | self.__mock_repo_api.add_pull_request_comment.assert_called_once_with(42, "", None)
92 | logging_mock.info.assert_called_once_with("Creating comment for pull request %s with content: %s", 42, "")
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("")
97 | self.__mock_repo_api.delete_branch.assert_called_once_with("")
98 | logging_mock.info.assert_called_once_with("Deleting branch '%s'", "")
99 |
100 | def test_get_branch_head_hash(self):
101 | expected_return_value = ""
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("")
105 |
106 | self.assertEqual(actual_return_value, expected_return_value)
107 | self.__mock_repo_api.get_branch_head_hash.assert_called_once_with("")
108 |
109 | def test_get_pull_request_branch(self):
110 | expected_return_value = ""
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 |
--------------------------------------------------------------------------------