gitops 1.6.3__tar.gz → 1.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitops-1.6.3 → gitops-1.8.0}/.github/workflows/release-please.yaml +1 -1
- {gitops-1.6.3 → gitops-1.8.0}/.github/workflows/test.yml +8 -0
- {gitops-1.6.3 → gitops-1.8.0}/.release-please-manifest.json +1 -1
- {gitops-1.6.3 → gitops-1.8.0}/CHANGELOG.md +41 -0
- {gitops-1.6.3 → gitops-1.8.0}/Dockerfile +13 -1
- {gitops-1.6.3 → gitops-1.8.0}/PKG-INFO +13 -3
- {gitops-1.6.3 → gitops-1.8.0}/README.md +11 -1
- {gitops-1.6.3 → gitops-1.8.0}/gitops/__init__.py +1 -1
- {gitops-1.6.3 → gitops-1.8.0}/gitops/core.py +8 -2
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/settings.py +5 -0
- gitops-1.8.0/gitops_server/utils/git.py +182 -0
- gitops-1.8.0/gitops_server/version.py +1 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/deploy.py +71 -4
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/worker.py +22 -3
- {gitops-1.6.3 → gitops-1.8.0}/pyproject.toml +5 -5
- gitops-1.8.0/renovate.json +6 -0
- gitops-1.8.0/tests/test_clone_credentials.py +32 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/test_core.py +109 -1
- gitops-1.8.0/tests/test_load_app_definitions.py +145 -0
- gitops-1.8.0/tests/test_worker_concurrency.py +76 -0
- {gitops-1.6.3 → gitops-1.8.0}/uv.lock +20 -9
- gitops-1.6.3/gitops_server/utils/git.py +0 -88
- gitops-1.6.3/gitops_server/version.py +0 -1
- {gitops-1.6.3 → gitops-1.8.0}/.dockerignore +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/.github/CODEOWNERS +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/.gitignore +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/.mise.toml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/.pre-commit-config.yaml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/CLAUDE.md +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/LICENSE +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/.helmignore +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/CHANGELOG.md +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/Chart.yaml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/README.md +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/_helpers.tpl +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/certificate.yml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/deployment.yml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/environment.yml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/ingress.yml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/issuer.yml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/pdb.yml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/secrets.yml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/service.yml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/serviceaccount.yaml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/values.yaml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/docker-compose.yml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/docs/plans/2026-03-13-kms-encryption-design.md +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/common/__init__.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/common/app.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/common/utils.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/main.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/py.typed +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/settings.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/shorthands.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/__init__.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/apps.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/async_runner.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/cli.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/exceptions.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/images.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/kube.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/tags.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/yaml.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/CHANGELOG.md +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/README.md +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/__init__.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/app.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/main.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/types.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/utils/__init__.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/utils/github.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/utils/slack.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/__init__.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/__init__.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/hooks.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/semaphore_manager.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/release-please-config.json +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/release.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/ruff.toml +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/secrets.example.env +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/setup.cfg +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/__init__.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/conftest.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/sample_data.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/test_app.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/test_deploy.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/test_deploy_concurrent.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/test_git.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/test_gitops_server.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/test_utils.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/utils.py +0 -0
- {gitops-1.6.3 → gitops-1.8.0}/tests/webhook_sample_data.py +0 -0
|
@@ -59,7 +59,7 @@ jobs:
|
|
|
59
59
|
shell: bash
|
|
60
60
|
|
|
61
61
|
- name: Publish Pypi Package
|
|
62
|
-
uses: pypa/gh-action-pypi-publish@
|
|
62
|
+
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # ratchet:pypa/gh-action-pypi-publish@release/v1
|
|
63
63
|
|
|
64
64
|
publish_docker_image:
|
|
65
65
|
name: Build and Push Docker Image
|
|
@@ -36,6 +36,12 @@ jobs:
|
|
|
36
36
|
cache: true
|
|
37
37
|
experimental: true
|
|
38
38
|
|
|
39
|
+
# Secrets in the cluster repo are git-crypt encrypted, and the app
|
|
40
|
+
# definition loader decrypts them. Without this the coverage of that path
|
|
41
|
+
# silently skips.
|
|
42
|
+
- name: Install git-crypt
|
|
43
|
+
run: sudo apt-get update && sudo apt-get install -y git-crypt
|
|
44
|
+
|
|
39
45
|
- name: Run CI
|
|
40
46
|
env:
|
|
41
47
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -88,6 +94,8 @@ jobs:
|
|
|
88
94
|
provenance: false
|
|
89
95
|
platforms: linux/amd64,linux/arm64
|
|
90
96
|
push: true
|
|
97
|
+
build-args: |
|
|
98
|
+
BUILD_KIND=test
|
|
91
99
|
tags: |
|
|
92
100
|
610829907584.dkr.ecr.ap-southeast-2.amazonaws.com/gitops:test-${{ env.GIT_SHORT_HASH }}
|
|
93
101
|
|
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.8.0](https://github.com/uptick/gitops/compare/gitops-v1.7.1...gitops-v1.8.0) (2026-08-21)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **worker:** bound concurrent deployments ([956c630](https://github.com/uptick/gitops/commit/956c6309516ba831805aa1df90fd4fdb7314cb43))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **security:** stop embedding the github token in clone URLs ([fd88bc1](https://github.com/uptick/gitops/commit/fd88bc1176a51caf9dddca37aee9d4ecfc0b5c78))
|
|
14
|
+
* **security:** stop embedding the github token in clone URLs ([68b7225](https://github.com/uptick/gitops/commit/68b722547d83e2ab1560ef7af0bc925d893b7580))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Performance Improvements
|
|
18
|
+
|
|
19
|
+
* **deploy:** read app definitions from git instead of checking out the repo ([0b21790](https://github.com/uptick/gitops/commit/0b2179084e8920790de70363cdb369558342e895))
|
|
20
|
+
* **deploy:** read app definitions from git instead of checking out the repo ([f90bc3a](https://github.com/uptick/gitops/commit/f90bc3a60141423313574813adeb1313406f7168))
|
|
21
|
+
|
|
22
|
+
## [1.7.1](https://github.com/uptick/gitops/compare/gitops-v1.7.0...gitops-v1.7.1) (2026-08-17)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* install the CLI from GitHub over SSH ([754e387](https://github.com/uptick/gitops/commit/754e3871ebafa7baf8d76990dee9dfc8d45aa892))
|
|
28
|
+
* install the CLI from GitHub over SSH ([c76d41c](https://github.com/uptick/gitops/commit/c76d41cc3a274febded30bcc9661f8c6cefabd4a))
|
|
29
|
+
|
|
30
|
+
## [1.7.0](https://github.com/uptick/gitops/compare/gitops-v1.6.3...gitops-v1.7.0) (2026-08-17)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Features
|
|
34
|
+
|
|
35
|
+
* **cli:** add skip-deploy to setenv and unsetenv ([285ac87](https://github.com/uptick/gitops/commit/285ac872f94eba77636262f30998bca86b1c0d90))
|
|
36
|
+
* **cli:** add skip-deploy to setenv and unsetenv ([de4ecd9](https://github.com/uptick/gitops/commit/de4ecd90dcbe8dea4e0e5ea6b7f93a05e36eae4b))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
### Bug Fixes
|
|
40
|
+
|
|
41
|
+
* **build:** keep test and release images on distinct digests ([48c1c75](https://github.com/uptick/gitops/commit/48c1c750da2cc7f5fc86b8c52beee9abf07805db))
|
|
42
|
+
* **build:** keep test and release images on distinct digests ([2c8bc3c](https://github.com/uptick/gitops/commit/2c8bc3cf1eeadf26a1127762c89bff60aa121db1))
|
|
43
|
+
|
|
3
44
|
## [1.6.3](https://github.com/uptick/gitops/compare/gitops-v1.6.2...gitops-v1.6.3) (2026-08-05)
|
|
4
45
|
|
|
5
46
|
|
|
@@ -56,7 +56,13 @@ COPY --link=true pyproject.toml uv.lock /app/
|
|
|
56
56
|
RUN --mount=type=cache,target=/root/.cache/ \
|
|
57
57
|
(uv sync --frozen --no-install-project --extra server || uv sync --frozen --no-install-project --extra server)
|
|
58
58
|
# Install dependencies
|
|
59
|
-
|
|
59
|
+
# Give git the token from the environment when it asks, so it is never embedded
|
|
60
|
+
# in a clone URL, a command line, a log, or a repo's .git/config. The function
|
|
61
|
+
# wrapper is the documented idiom -- git appends the operation (get/store/erase)
|
|
62
|
+
# as an argument, which the wrapper discards. See gitcredentials(7).
|
|
63
|
+
RUN git config --global advice.detachedHead false \
|
|
64
|
+
&& git config --global credential.helper \
|
|
65
|
+
'!f() { printf "username=x-access-token\npassword=%s\n" "$GITHUB_OAUTH_TOKEN"; }; f'
|
|
60
66
|
|
|
61
67
|
COPY cluster.key /app/
|
|
62
68
|
COPY gitops /app/gitops/
|
|
@@ -66,4 +72,10 @@ ENV GIT_CRYPT_KEY_FILE=/app/cluster.key
|
|
|
66
72
|
ENV PYTHONPATH="$PYTHONPATH:/app"
|
|
67
73
|
ENV ACCESS_LOG=""
|
|
68
74
|
|
|
75
|
+
# Keeps test and release builds on distinct digests. They build from the same
|
|
76
|
+
# source and cache, so otherwise they dedupe into one manifest and a retention
|
|
77
|
+
# rule targeting test tags expires the release image too.
|
|
78
|
+
ARG BUILD_KIND=release
|
|
79
|
+
LABEL build-kind=$BUILD_KIND
|
|
80
|
+
|
|
69
81
|
CMD ["uvicorn", "--host", "0.0.0.0", "--port", "8000", "gitops_server.main:app"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: gitops
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.0
|
|
4
4
|
Summary: Manage multiple apps across one or more k8s clusters.
|
|
5
5
|
Author-email: Jarek Głowacki <jarekwg@gmail.com>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -69,7 +69,17 @@ This is a git repository that you set up, where you list out all of your applica
|
|
|
69
69
|
|
|
70
70
|
## Gitops CLI Installation
|
|
71
71
|
|
|
72
|
-
1. Install the CLI tool
|
|
72
|
+
1. Install the CLI tool from GitHub over SSH, which needs an SSH key registered
|
|
73
|
+
with GitHub. Add `--verbose` to see what it is doing:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
uv tool install git+ssh://git@github.com/uptick/gitops
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Upgrade it with `uv tool upgrade gitops`.
|
|
80
|
+
|
|
81
|
+
Installing from PyPI (`uv tool install gitops`) still works, but we are
|
|
82
|
+
moving away from it — use the git install.
|
|
73
83
|
|
|
74
84
|
2. Set up the environment variable `GITOPS_APPS_DIRECTORY` to invoke gitops from any directory.
|
|
75
85
|
|
|
@@ -37,7 +37,17 @@ This is a git repository that you set up, where you list out all of your applica
|
|
|
37
37
|
|
|
38
38
|
## Gitops CLI Installation
|
|
39
39
|
|
|
40
|
-
1. Install the CLI tool
|
|
40
|
+
1. Install the CLI tool from GitHub over SSH, which needs an SSH key registered
|
|
41
|
+
with GitHub. Add `--verbose` to see what it is doing:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv tool install git+ssh://git@github.com/uptick/gitops
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Upgrade it with `uv tool upgrade gitops`.
|
|
48
|
+
|
|
49
|
+
Installing from PyPI (`uv tool install gitops`) still works, but we are
|
|
50
|
+
moving away from it — use the git install.
|
|
41
51
|
|
|
42
52
|
2. Set up the environment variable `GITOPS_APPS_DIRECTORY` to invoke gitops from any directory.
|
|
43
53
|
|
|
@@ -340,13 +340,14 @@ def _sort_envs(envs: dict[str, Any]) -> dict[str, Any]:
|
|
|
340
340
|
|
|
341
341
|
|
|
342
342
|
@task
|
|
343
|
-
def setenv(ctx: Any, filter: str, values: str, exclude: str = "", limit: int = 0) -> None:
|
|
343
|
+
def setenv(ctx: Any, filter: str, values: str, exclude: str = "", limit: int = 0, skip_deploy: bool = False) -> None:
|
|
344
344
|
"""Set one or more env vars on selected app(s).
|
|
345
345
|
|
|
346
346
|
eg. inv setenv customer,sandbox BG_RUNNER=DRAMATIQ,BUMP=2
|
|
347
347
|
|
|
348
348
|
NOTE: More broad-reaching environment changes should be made at the chart level.
|
|
349
349
|
Provide `limit` to process only the first N matching apps.
|
|
350
|
+
Provide `skip_deploy` to skip deploying for non functional changes.
|
|
350
351
|
"""
|
|
351
352
|
splitenvs = values.split(",") # pardon the pun.
|
|
352
353
|
formatted_splitenvs = "\n".join(splitenvs)
|
|
@@ -380,18 +381,21 @@ def setenv(ctx: Any, filter: str, values: str, exclude: str = "", limit: int = 0
|
|
|
380
381
|
commit_message = f"Set env var(s) '{values}' on {filter}"
|
|
381
382
|
if exclude:
|
|
382
383
|
commit_message += f" (except {exclude})"
|
|
384
|
+
if skip_deploy:
|
|
385
|
+
commit_message += " --skip-deploy"
|
|
383
386
|
run(f'cd {get_apps_directory()}; git commit -am "{commit_message}."')
|
|
384
387
|
print(success("Done!"))
|
|
385
388
|
|
|
386
389
|
|
|
387
390
|
@task
|
|
388
|
-
def unsetenv(ctx: Any, filter: str, values: str, exclude: str = "", limit: int = 0) -> None:
|
|
391
|
+
def unsetenv(ctx: Any, filter: str, values: str, exclude: str = "", limit: int = 0, skip_deploy: bool = False) -> None:
|
|
389
392
|
"""Unset one or more env vars on selected app(s).
|
|
390
393
|
|
|
391
394
|
eg. inv unsetenv customer,sandbox BG_RUNNER,BUMP
|
|
392
395
|
|
|
393
396
|
NOTE: More broad-reaching environment changes should be made at the chart level.
|
|
394
397
|
Provide `limit` to process only the first N matching apps.
|
|
398
|
+
Provide `skip_deploy` to skip deploying for non functional changes.
|
|
395
399
|
"""
|
|
396
400
|
splitenvs = values.split(",") # pardon the pun.
|
|
397
401
|
formatted_splitenvs = "\n".join(splitenvs)
|
|
@@ -416,6 +420,8 @@ def unsetenv(ctx: Any, filter: str, values: str, exclude: str = "", limit: int =
|
|
|
416
420
|
commit_message = f"Unset env var(s) '{values}' on {filter}"
|
|
417
421
|
if exclude:
|
|
418
422
|
commit_message += f" (except {exclude})"
|
|
423
|
+
if skip_deploy:
|
|
424
|
+
commit_message += " --skip-deploy"
|
|
419
425
|
run(f'cd {get_apps_directory()}; git commit -am "{commit_message}."')
|
|
420
426
|
print(success("Done!"))
|
|
421
427
|
|
|
@@ -16,3 +16,8 @@ SLACK_ALERT_CHANNEL_ID = os.environ.get("SLACK_ALERT_CHANNEL_ID", "")
|
|
|
16
16
|
|
|
17
17
|
# How many parallel deploys can be done at once
|
|
18
18
|
GITOPS_MAX_PARALLEL_DEPLOYS = os.environ.get("GITOPS_MAX_PARALLEL_DEPLOYS", "5")
|
|
19
|
+
|
|
20
|
+
# How many pushes can be processed at once. Each one loads app definitions for
|
|
21
|
+
# two commits before it reaches GITOPS_MAX_PARALLEL_DEPLOYS, so without a bound
|
|
22
|
+
# here a burst of pushes is only limited by how fast they arrive.
|
|
23
|
+
GITOPS_MAX_CONCURRENT_DEPLOYMENTS = os.environ.get("GITOPS_MAX_CONCURRENT_DEPLOYMENTS", "20")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import AsyncGenerator
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from opentelemetry.trace import get_tracer
|
|
11
|
+
|
|
12
|
+
from . import run
|
|
13
|
+
|
|
14
|
+
tracer = get_tracer(__name__)
|
|
15
|
+
logger = logging.getLogger("gitops")
|
|
16
|
+
|
|
17
|
+
REPO_CACHE_DIR = Path("/tmp/gitops/repocache")
|
|
18
|
+
REPO_CACHE: dict[str, Path] = {}
|
|
19
|
+
repo_lock = asyncio.Semaphore(1)
|
|
20
|
+
|
|
21
|
+
# git-crypt prefixes every encrypted blob with this magic.
|
|
22
|
+
GIT_CRYPT_MAGIC = b"\0GITCRYPT\0"
|
|
23
|
+
|
|
24
|
+
# git-crypt has no batch mode: one process per file. Bounded so concurrent
|
|
25
|
+
# loads can't fork-storm.
|
|
26
|
+
decrypt_semaphore = asyncio.Semaphore(16)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def clone_repo(git_repo_url: str, path: str, sha: str | None = None, branch: str | None = None) -> None:
|
|
30
|
+
"""Shallow Clones a git repo url to path and git-crypt unlocks all encrypted files"""
|
|
31
|
+
logger.info(f'Cloning "{git_repo_url}".')
|
|
32
|
+
|
|
33
|
+
with tracer.start_as_current_span("tempo_repo.clone_repo"):
|
|
34
|
+
if sha:
|
|
35
|
+
await run(f"git clone --depth 100 {git_repo_url} {path}; cd {path}; git checkout {sha}")
|
|
36
|
+
elif branch:
|
|
37
|
+
await run(f"git clone --depth 1 {git_repo_url} {path} --branch {branch}")
|
|
38
|
+
else:
|
|
39
|
+
await run(f"git clone --depth 100 {git_repo_url} {path};")
|
|
40
|
+
|
|
41
|
+
if GIT_CRYPT_KEY_FILE := os.environ.get("GIT_CRYPT_KEY_FILE"):
|
|
42
|
+
with tracer.start_as_current_span("temp_repo.git_crypt_unlock"):
|
|
43
|
+
await run(f"cd {path}; git-crypt unlock {GIT_CRYPT_KEY_FILE}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_sha(sha_or_ref: str | None) -> bool:
|
|
47
|
+
"""Check if the given string is a valid SHA-1 hash or a short SHA."""
|
|
48
|
+
if not sha_or_ref:
|
|
49
|
+
return False
|
|
50
|
+
return bool(re.fullmatch(r"[a-fA-F0-9]{4,40}", sha_or_ref))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@asynccontextmanager
|
|
54
|
+
async def temp_repo(git_repo_url: str, ref: str | None) -> AsyncGenerator[str, None]:
|
|
55
|
+
"""Checks out a git_repo_url to a temporary folder location. Returns temporary folder location"""
|
|
56
|
+
# Someone passed us a branch name, not a sha
|
|
57
|
+
|
|
58
|
+
if not is_sha(ref):
|
|
59
|
+
async with temp_repo_branch(git_repo_url, branch=ref) as temp_repo_path:
|
|
60
|
+
yield temp_repo_path
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
assert ref is not None # is_sha() returns False for None
|
|
64
|
+
with tracer.start_as_current_span("checkout_temp_repo"):
|
|
65
|
+
cache_path = await ensure_repo_cache(git_repo_url, ref)
|
|
66
|
+
|
|
67
|
+
# Copy the repo cache to a temporary folder
|
|
68
|
+
with tempfile.TemporaryDirectory() as temporary_folder_path:
|
|
69
|
+
await run(f"rm -rf {temporary_folder_path}", suppress_errors=True)
|
|
70
|
+
await run(f"cp -r {cache_path} {temporary_folder_path}")
|
|
71
|
+
# ref is already in the cache, so no per-copy fetch.
|
|
72
|
+
await run(f"cd {temporary_folder_path};git checkout {ref}")
|
|
73
|
+
|
|
74
|
+
yield temporary_folder_path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _git(repo: str, *args: str, stdin: bytes | None = None) -> bytes:
|
|
78
|
+
"""Run git in `repo`, returning raw stdout.
|
|
79
|
+
|
|
80
|
+
Not `run()`: blobs are binary, and `run()` decodes and logs every line.
|
|
81
|
+
"""
|
|
82
|
+
proc = await asyncio.create_subprocess_exec(
|
|
83
|
+
"git",
|
|
84
|
+
"-C",
|
|
85
|
+
repo,
|
|
86
|
+
*args,
|
|
87
|
+
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
|
|
88
|
+
stdout=asyncio.subprocess.PIPE,
|
|
89
|
+
stderr=asyncio.subprocess.PIPE,
|
|
90
|
+
)
|
|
91
|
+
stdout, stderr = await proc.communicate(stdin)
|
|
92
|
+
if proc.returncode != 0:
|
|
93
|
+
raise Exception(f"git {' '.join(args)} failed in {repo}: {stderr.decode(errors='replace')}")
|
|
94
|
+
return stdout
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def ensure_repo_cache(git_repo_url: str, ref: str) -> str:
|
|
98
|
+
"""Clone the shared cache if absent and ensure `ref` is in it."""
|
|
99
|
+
cache_path = REPO_CACHE_DIR / git_repo_url.split("/")[-1].split(".")[0]
|
|
100
|
+
async with repo_lock:
|
|
101
|
+
if not (cache_path / ".git").exists():
|
|
102
|
+
logger.info("Repo %s not in cache, cloning", git_repo_url)
|
|
103
|
+
if cache_path.exists():
|
|
104
|
+
await run(f"rm -rf {cache_path}", suppress_errors=True)
|
|
105
|
+
cache_path.mkdir(parents=True)
|
|
106
|
+
REPO_CACHE[git_repo_url] = cache_path
|
|
107
|
+
await clone_repo(git_repo_url, path=str(cache_path))
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
await _git(str(cache_path), "cat-file", "-e", f"{ref}^{{commit}}")
|
|
111
|
+
except Exception:
|
|
112
|
+
logger.info("Ref %s not in cache, fetching", ref)
|
|
113
|
+
await _git(str(cache_path), "fetch", "--quiet", "origin")
|
|
114
|
+
return str(cache_path)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def list_tree_blobs(repo: str, sha: str, prefix: str) -> dict[str, str]:
|
|
118
|
+
"""Map path -> blob oid for every file under `prefix` at `sha`."""
|
|
119
|
+
out = await _git(repo, "ls-tree", "-r", sha, "--", prefix)
|
|
120
|
+
blobs: dict[str, str] = {}
|
|
121
|
+
for line in out.decode().splitlines():
|
|
122
|
+
meta, _, path = line.partition("\t")
|
|
123
|
+
parts = meta.split()
|
|
124
|
+
if len(parts) == 3 and parts[1] == "blob":
|
|
125
|
+
blobs[path] = parts[2]
|
|
126
|
+
return blobs
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def read_blobs(repo: str, oids: list[str]) -> dict[str, bytes]:
|
|
130
|
+
"""Read many blobs in a single `git cat-file --batch` process."""
|
|
131
|
+
if not oids:
|
|
132
|
+
return {}
|
|
133
|
+
out = await _git(repo, "cat-file", "--batch", stdin=("\n".join(oids) + "\n").encode())
|
|
134
|
+
|
|
135
|
+
contents: dict[str, bytes] = {}
|
|
136
|
+
pos = 0
|
|
137
|
+
while pos < len(out):
|
|
138
|
+
eol = out.find(b"\n", pos)
|
|
139
|
+
if eol == -1:
|
|
140
|
+
break
|
|
141
|
+
header = out[pos:eol].decode()
|
|
142
|
+
pos = eol + 1
|
|
143
|
+
parts = header.split()
|
|
144
|
+
if len(parts) != 3:
|
|
145
|
+
# "<oid> missing" — nothing to consume for this entry.
|
|
146
|
+
continue
|
|
147
|
+
oid, _, size = parts[0], parts[1], int(parts[2])
|
|
148
|
+
contents[oid] = out[pos : pos + size]
|
|
149
|
+
pos += size + 1 # trailing newline
|
|
150
|
+
return contents
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def decrypt_blob(repo: str, content: bytes) -> bytes:
|
|
154
|
+
"""Decrypt a git-crypt blob; unencrypted content passes through.
|
|
155
|
+
|
|
156
|
+
Reading a blob bypasses the smudge filter, so apply it explicitly.
|
|
157
|
+
"""
|
|
158
|
+
if not content.startswith(GIT_CRYPT_MAGIC):
|
|
159
|
+
return content
|
|
160
|
+
async with decrypt_semaphore:
|
|
161
|
+
proc = await asyncio.create_subprocess_exec(
|
|
162
|
+
"git-crypt",
|
|
163
|
+
"smudge",
|
|
164
|
+
cwd=repo,
|
|
165
|
+
stdin=asyncio.subprocess.PIPE,
|
|
166
|
+
stdout=asyncio.subprocess.PIPE,
|
|
167
|
+
stderr=asyncio.subprocess.PIPE,
|
|
168
|
+
)
|
|
169
|
+
stdout, stderr = await proc.communicate(content)
|
|
170
|
+
if proc.returncode != 0:
|
|
171
|
+
raise Exception(f"git-crypt smudge failed in {repo}: {stderr.decode(errors='replace')}")
|
|
172
|
+
return stdout
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@asynccontextmanager
|
|
176
|
+
async def temp_repo_branch(git_repo_url: str, branch: str | None) -> AsyncGenerator[str, None]:
|
|
177
|
+
"""Checks out a git_repo_url to a temporary folder location. Returns temporary folder location"""
|
|
178
|
+
with tracer.start_as_current_span("checkout_temp_repo_branch"):
|
|
179
|
+
# Copy the repo cache to a temporary folder
|
|
180
|
+
with tempfile.TemporaryDirectory() as temporary_folder_path:
|
|
181
|
+
await clone_repo(git_repo_url, path=str(temporary_folder_path), branch=branch)
|
|
182
|
+
yield temporary_folder_path
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.8.0"
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import copy
|
|
2
3
|
import json
|
|
3
4
|
import logging
|
|
4
5
|
import os
|
|
6
|
+
import posixpath
|
|
5
7
|
import tempfile
|
|
6
8
|
import time
|
|
7
9
|
import uuid
|
|
8
10
|
from typing import Any
|
|
9
11
|
|
|
12
|
+
import yaml
|
|
10
13
|
from opentelemetry import trace
|
|
11
14
|
|
|
12
15
|
from gitops.common.app import App
|
|
16
|
+
from gitops.common.utils import deep_merge
|
|
13
17
|
from gitops_server import settings
|
|
14
18
|
from gitops_server.types import AppDefinitions, RunOutput, UpdateAppResult
|
|
15
19
|
from gitops_server.utils import get_repo_name_from_url, github, run, slack
|
|
16
|
-
from gitops_server.utils.git import temp_repo
|
|
20
|
+
from gitops_server.utils.git import decrypt_blob, ensure_repo_cache, list_tree_blobs, read_blobs, temp_repo
|
|
17
21
|
from gitops_server.workers.deployer.semaphore_manager import AppSemaphoreManager
|
|
18
22
|
|
|
19
23
|
from .hooks import handle_failed_deploy, handle_successful_deploy
|
|
@@ -41,12 +45,75 @@ async def post_result_summary(source: str, results: list[UpdateAppResult]) -> No
|
|
|
41
45
|
)
|
|
42
46
|
|
|
43
47
|
|
|
48
|
+
APPS_PREFIX = "apps/"
|
|
49
|
+
# The shared definition every app `extends`.
|
|
50
|
+
ROOT_DEPLOYMENT = "apps/deployment.yml"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_extends(
|
|
54
|
+
values: dict[str, Any],
|
|
55
|
+
path: str,
|
|
56
|
+
definitions: dict[str, dict[str, Any]],
|
|
57
|
+
resolved: dict[str, dict[str, Any]],
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Resolve `extends:` against blobs, not the filesystem.
|
|
60
|
+
|
|
61
|
+
Mirrors resolve_values(), which reads a checkout we no longer produce.
|
|
62
|
+
"""
|
|
63
|
+
if "extends" not in values:
|
|
64
|
+
return values
|
|
65
|
+
parent_path = posixpath.normpath(posixpath.join(posixpath.dirname(path), values["extends"]))
|
|
66
|
+
if parent_path not in resolved:
|
|
67
|
+
parent = definitions.get(parent_path, {})
|
|
68
|
+
resolved[parent_path] = _resolve_extends(parent, parent_path, definitions, resolved)
|
|
69
|
+
# deep_merge mutates its first arg, and every app shares this parent.
|
|
70
|
+
return deep_merge(copy.deepcopy(resolved[parent_path]), values)
|
|
71
|
+
|
|
72
|
+
|
|
44
73
|
@tracer.start_as_current_span("load_app_definitions")
|
|
45
74
|
async def load_app_definitions(url: str, sha: str) -> AppDefinitions:
|
|
75
|
+
"""Build this cluster's app definitions from git blobs, with no checkout.
|
|
76
|
+
|
|
77
|
+
`temp_repo` copies ~38MB per call, twice per push, to read <1MB of YAML.
|
|
78
|
+
`deployment.yml` is plaintext in git, so other clusters' apps are dropped
|
|
79
|
+
before anything is decrypted.
|
|
80
|
+
"""
|
|
46
81
|
logger.info(f'Loading app definitions at "{sha}".')
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
82
|
+
repo = await ensure_repo_cache(url, sha)
|
|
83
|
+
blobs = await list_tree_blobs(repo, sha, APPS_PREFIX)
|
|
84
|
+
|
|
85
|
+
deployment_paths = {
|
|
86
|
+
path: oid for path, oid in blobs.items() if path == ROOT_DEPLOYMENT or path.endswith("/deployment.yml")
|
|
87
|
+
}
|
|
88
|
+
raw = await read_blobs(repo, list(set(deployment_paths.values())))
|
|
89
|
+
definitions = {path: (yaml.safe_load(raw.get(oid, b"")) or {}) for path, oid in deployment_paths.items()}
|
|
90
|
+
|
|
91
|
+
resolved: dict[str, dict[str, Any]] = {}
|
|
92
|
+
keep: dict[str, dict[str, Any]] = {}
|
|
93
|
+
for path, values in definitions.items():
|
|
94
|
+
# apps/<name>/deployment.yml; anything else is the shared parent.
|
|
95
|
+
parts = path.split("/")
|
|
96
|
+
if path == ROOT_DEPLOYMENT or len(parts) != 3:
|
|
97
|
+
continue
|
|
98
|
+
merged = _resolve_extends(values, path, definitions, resolved)
|
|
99
|
+
if merged.get("cluster") != settings.CLUSTER_NAME or "suspended" in (merged.get("tags") or []):
|
|
100
|
+
continue
|
|
101
|
+
keep[parts[1]] = merged
|
|
102
|
+
|
|
103
|
+
# Only our own apps pay for decryption.
|
|
104
|
+
secret_oids = {name: blobs.get(f"{APPS_PREFIX}{name}/secrets.yml") for name in keep}
|
|
105
|
+
raw_secrets = await read_blobs(repo, [oid for oid in secret_oids.values() if oid])
|
|
106
|
+
|
|
107
|
+
async def build(name: str, merged: dict[str, Any]) -> tuple[str, App]:
|
|
108
|
+
secrets: dict[str, str] = {}
|
|
109
|
+
oid = secret_oids.get(name)
|
|
110
|
+
if oid:
|
|
111
|
+
plaintext = await decrypt_blob(repo, raw_secrets.get(oid, b""))
|
|
112
|
+
secrets = (yaml.safe_load(plaintext) or {}).get("secrets", {})
|
|
113
|
+
return name, App(name, deployments=merged, secrets=secrets, account_id=settings.ACCOUNT_ID)
|
|
114
|
+
|
|
115
|
+
built = await asyncio.gather(*(build(name, merged) for name, merged in keep.items()))
|
|
116
|
+
return AppDefinitions(name=get_repo_name_from_url(url), apps=dict(built))
|
|
50
117
|
|
|
51
118
|
|
|
52
119
|
class Deployer:
|
|
@@ -4,6 +4,7 @@ from typing import Any
|
|
|
4
4
|
|
|
5
5
|
from opentelemetry import trace
|
|
6
6
|
|
|
7
|
+
from ... import settings
|
|
7
8
|
from .deploy import Deployer
|
|
8
9
|
from .semaphore_manager import AppSemaphoreManager
|
|
9
10
|
|
|
@@ -33,6 +34,10 @@ class DeployQueueWorker:
|
|
|
33
34
|
self.queue: asyncio.Queue[Any] = asyncio.Queue()
|
|
34
35
|
self.semaphore_manager = AppSemaphoreManager()
|
|
35
36
|
self.active_deployments: set[asyncio.Task[None]] = set()
|
|
37
|
+
# Bounds work in flight ahead of GITOPS_MAX_PARALLEL_DEPLOYS, which only
|
|
38
|
+
# gates the helm stage. Everything before it -- loading app definitions
|
|
39
|
+
# for two commits -- was previously limited only by webhook arrival rate.
|
|
40
|
+
self.deployment_semaphore = asyncio.Semaphore(int(settings.GITOPS_MAX_CONCURRENT_DEPLOYMENTS))
|
|
36
41
|
|
|
37
42
|
async def enqueue(self, work: Any) -> None:
|
|
38
43
|
"""Enqueue an item of work for future processing.
|
|
@@ -56,9 +61,17 @@ class DeployQueueWorker:
|
|
|
56
61
|
# Clean up completed tasks
|
|
57
62
|
self.active_deployments = {task for task in self.active_deployments if not task.done()}
|
|
58
63
|
|
|
59
|
-
# Process new work
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
# Process new work. Acquire before dequeuing so a burst backs up
|
|
65
|
+
# in the queue rather than as in-flight tasks. Once the task
|
|
66
|
+
# exists it owns the release; until then this frame does, or the
|
|
67
|
+
# slot is lost for the life of the process.
|
|
68
|
+
await self.deployment_semaphore.acquire()
|
|
69
|
+
try:
|
|
70
|
+
work = await self.queue.get()
|
|
71
|
+
deployment_task = asyncio.create_task(self.process_deployment(work))
|
|
72
|
+
except BaseException:
|
|
73
|
+
self.deployment_semaphore.release()
|
|
74
|
+
raise
|
|
62
75
|
self.active_deployments.add(deployment_task)
|
|
63
76
|
|
|
64
77
|
# Log current status
|
|
@@ -79,6 +92,12 @@ class DeployQueueWorker:
|
|
|
79
92
|
ref = work.get("ref")
|
|
80
93
|
logger.info(f'Processing deployment for push to "{ref}".')
|
|
81
94
|
|
|
95
|
+
try:
|
|
96
|
+
await self._process_deployment(work, ref)
|
|
97
|
+
finally:
|
|
98
|
+
self.deployment_semaphore.release()
|
|
99
|
+
|
|
100
|
+
async def _process_deployment(self, work: Any, ref: str | None) -> None:
|
|
82
101
|
if ref == "refs/heads/master":
|
|
83
102
|
with tracer.start_as_current_span("gitops_process_webhook") as current_span:
|
|
84
103
|
try:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
[project]
|
|
3
3
|
name = "gitops"
|
|
4
|
-
version = "1.
|
|
4
|
+
version = "1.8.0"
|
|
5
5
|
description = "Manage multiple apps across one or more k8s clusters."
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
readme = "README.md"
|
|
@@ -45,16 +45,16 @@ server = [
|
|
|
45
45
|
requires = ["hatchling"]
|
|
46
46
|
build-backend = "hatchling.build"
|
|
47
47
|
|
|
48
|
-
[
|
|
49
|
-
dev
|
|
50
|
-
"pytest>=
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"pytest>=9.0.3",
|
|
51
51
|
"ruff>=0.6.3",
|
|
52
52
|
"types-colorama>=0.4.15.20240311",
|
|
53
53
|
"mypy>=1.15",
|
|
54
54
|
"types-pyyaml>=6.0.12.20240808",
|
|
55
55
|
"types-tabulate>=0.9.0.20240106",
|
|
56
56
|
"boto3-stubs>=1.35.11",
|
|
57
|
-
"pytest-asyncio>=0.
|
|
57
|
+
"pytest-asyncio>=1.0.0",
|
|
58
58
|
"types-invoke>=2.0.0.10",
|
|
59
59
|
]
|
|
60
60
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import AsyncMock, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import gitops_server.utils.git as git
|
|
7
|
+
|
|
8
|
+
TOKEN = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" # noqa: S105
|
|
9
|
+
URL = "https://github.com/uptick/uptick-cluster.git"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.asyncio
|
|
13
|
+
@pytest.mark.parametrize("kwargs", [{}, {"sha": "a" * 40}, {"branch": "master"}])
|
|
14
|
+
async def test_clone_command_never_contains_the_token(monkeypatch, kwargs):
|
|
15
|
+
"""Credentials come from the git credential helper configured in the image,
|
|
16
|
+
so nothing run() logs, raises or returns can carry the token."""
|
|
17
|
+
monkeypatch.setenv("GITHUB_OAUTH_TOKEN", TOKEN)
|
|
18
|
+
monkeypatch.delenv("GIT_CRYPT_KEY_FILE", raising=False)
|
|
19
|
+
with patch.object(git, "run", new=AsyncMock()) as run_mock:
|
|
20
|
+
await git.clone_repo(URL, path="/tmp/x", **kwargs)
|
|
21
|
+
commands = " ".join(str(c) for c in run_mock.call_args_list)
|
|
22
|
+
assert TOKEN not in commands
|
|
23
|
+
assert URL in commands
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_image_configures_a_helper_holding_no_secret():
|
|
27
|
+
"""The Dockerfile is what makes the clean URLs above authenticate."""
|
|
28
|
+
dockerfile = (Path(__file__).parent.parent / "Dockerfile").read_text()
|
|
29
|
+
assert "credential.helper" in dockerfile
|
|
30
|
+
# A reference to the env var, never a value baked into the image.
|
|
31
|
+
assert "$GITHUB_OAUTH_TOKEN" in dockerfile
|
|
32
|
+
assert "ghp_" not in dockerfile
|