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.
Files changed (92) hide show
  1. {gitops-1.6.3 → gitops-1.8.0}/.github/workflows/release-please.yaml +1 -1
  2. {gitops-1.6.3 → gitops-1.8.0}/.github/workflows/test.yml +8 -0
  3. {gitops-1.6.3 → gitops-1.8.0}/.release-please-manifest.json +1 -1
  4. {gitops-1.6.3 → gitops-1.8.0}/CHANGELOG.md +41 -0
  5. {gitops-1.6.3 → gitops-1.8.0}/Dockerfile +13 -1
  6. {gitops-1.6.3 → gitops-1.8.0}/PKG-INFO +13 -3
  7. {gitops-1.6.3 → gitops-1.8.0}/README.md +11 -1
  8. {gitops-1.6.3 → gitops-1.8.0}/gitops/__init__.py +1 -1
  9. {gitops-1.6.3 → gitops-1.8.0}/gitops/core.py +8 -2
  10. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/settings.py +5 -0
  11. gitops-1.8.0/gitops_server/utils/git.py +182 -0
  12. gitops-1.8.0/gitops_server/version.py +1 -0
  13. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/deploy.py +71 -4
  14. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/worker.py +22 -3
  15. {gitops-1.6.3 → gitops-1.8.0}/pyproject.toml +5 -5
  16. gitops-1.8.0/renovate.json +6 -0
  17. gitops-1.8.0/tests/test_clone_credentials.py +32 -0
  18. {gitops-1.6.3 → gitops-1.8.0}/tests/test_core.py +109 -1
  19. gitops-1.8.0/tests/test_load_app_definitions.py +145 -0
  20. gitops-1.8.0/tests/test_worker_concurrency.py +76 -0
  21. {gitops-1.6.3 → gitops-1.8.0}/uv.lock +20 -9
  22. gitops-1.6.3/gitops_server/utils/git.py +0 -88
  23. gitops-1.6.3/gitops_server/version.py +0 -1
  24. {gitops-1.6.3 → gitops-1.8.0}/.dockerignore +0 -0
  25. {gitops-1.6.3 → gitops-1.8.0}/.github/CODEOWNERS +0 -0
  26. {gitops-1.6.3 → gitops-1.8.0}/.gitignore +0 -0
  27. {gitops-1.6.3 → gitops-1.8.0}/.mise.toml +0 -0
  28. {gitops-1.6.3 → gitops-1.8.0}/.pre-commit-config.yaml +0 -0
  29. {gitops-1.6.3 → gitops-1.8.0}/CLAUDE.md +0 -0
  30. {gitops-1.6.3 → gitops-1.8.0}/LICENSE +0 -0
  31. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/.helmignore +0 -0
  32. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/CHANGELOG.md +0 -0
  33. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/Chart.yaml +0 -0
  34. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/README.md +0 -0
  35. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/_helpers.tpl +0 -0
  36. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/certificate.yml +0 -0
  37. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/deployment.yml +0 -0
  38. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/environment.yml +0 -0
  39. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/ingress.yml +0 -0
  40. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/issuer.yml +0 -0
  41. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/pdb.yml +0 -0
  42. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/secrets.yml +0 -0
  43. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/service.yml +0 -0
  44. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/templates/serviceaccount.yaml +0 -0
  45. {gitops-1.6.3 → gitops-1.8.0}/charts/gitops/values.yaml +0 -0
  46. {gitops-1.6.3 → gitops-1.8.0}/docker-compose.yml +0 -0
  47. {gitops-1.6.3 → gitops-1.8.0}/docs/plans/2026-03-13-kms-encryption-design.md +0 -0
  48. {gitops-1.6.3 → gitops-1.8.0}/gitops/common/__init__.py +0 -0
  49. {gitops-1.6.3 → gitops-1.8.0}/gitops/common/app.py +0 -0
  50. {gitops-1.6.3 → gitops-1.8.0}/gitops/common/utils.py +0 -0
  51. {gitops-1.6.3 → gitops-1.8.0}/gitops/main.py +0 -0
  52. {gitops-1.6.3 → gitops-1.8.0}/gitops/py.typed +0 -0
  53. {gitops-1.6.3 → gitops-1.8.0}/gitops/settings.py +0 -0
  54. {gitops-1.6.3 → gitops-1.8.0}/gitops/shorthands.py +0 -0
  55. {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/__init__.py +0 -0
  56. {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/apps.py +0 -0
  57. {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/async_runner.py +0 -0
  58. {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/cli.py +0 -0
  59. {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/exceptions.py +0 -0
  60. {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/images.py +0 -0
  61. {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/kube.py +0 -0
  62. {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/tags.py +0 -0
  63. {gitops-1.6.3 → gitops-1.8.0}/gitops/utils/yaml.py +0 -0
  64. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/CHANGELOG.md +0 -0
  65. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/README.md +0 -0
  66. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/__init__.py +0 -0
  67. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/app.py +0 -0
  68. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/main.py +0 -0
  69. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/types.py +0 -0
  70. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/utils/__init__.py +0 -0
  71. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/utils/github.py +0 -0
  72. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/utils/slack.py +0 -0
  73. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/__init__.py +0 -0
  74. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/__init__.py +0 -0
  75. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/hooks.py +0 -0
  76. {gitops-1.6.3 → gitops-1.8.0}/gitops_server/workers/deployer/semaphore_manager.py +0 -0
  77. {gitops-1.6.3 → gitops-1.8.0}/release-please-config.json +0 -0
  78. {gitops-1.6.3 → gitops-1.8.0}/release.py +0 -0
  79. {gitops-1.6.3 → gitops-1.8.0}/ruff.toml +0 -0
  80. {gitops-1.6.3 → gitops-1.8.0}/secrets.example.env +0 -0
  81. {gitops-1.6.3 → gitops-1.8.0}/setup.cfg +0 -0
  82. {gitops-1.6.3 → gitops-1.8.0}/tests/__init__.py +0 -0
  83. {gitops-1.6.3 → gitops-1.8.0}/tests/conftest.py +0 -0
  84. {gitops-1.6.3 → gitops-1.8.0}/tests/sample_data.py +0 -0
  85. {gitops-1.6.3 → gitops-1.8.0}/tests/test_app.py +0 -0
  86. {gitops-1.6.3 → gitops-1.8.0}/tests/test_deploy.py +0 -0
  87. {gitops-1.6.3 → gitops-1.8.0}/tests/test_deploy_concurrent.py +0 -0
  88. {gitops-1.6.3 → gitops-1.8.0}/tests/test_git.py +0 -0
  89. {gitops-1.6.3 → gitops-1.8.0}/tests/test_gitops_server.py +0 -0
  90. {gitops-1.6.3 → gitops-1.8.0}/tests/test_utils.py +0 -0
  91. {gitops-1.6.3 → gitops-1.8.0}/tests/utils.py +0 -0
  92. {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@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # ratchet:pypa/gh-action-pypi-publish@release/v1
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,5 @@
1
1
  {
2
- ".": "1.6.3",
2
+ ".": "1.8.0",
3
3
  "gitops_server": "0.15.1",
4
4
  "charts/gitops": "1.2.2"
5
5
  }
@@ -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
- RUN git config --global advice.detachedHead false
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: gitops
3
- Version: 1.6.3
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 with: `uv tool install gitops` and upgrade it with `uv tool upgrade gitops`.
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 with: `uv tool install gitops` and upgrade it with `uv tool upgrade gitops`.
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
 
@@ -6,7 +6,7 @@ from gitops.utils.apps import App, get_app_details, get_apps # type: ignore[att
6
6
 
7
7
  from .utils.cli import success, warning
8
8
 
9
- __version__ = "1.6.3"
9
+ __version__ = "1.8.0"
10
10
 
11
11
 
12
12
  # Checking gitops version matches cluster repo version.
@@ -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
- async with temp_repo(url, ref=sha) as repo:
48
- app_definitions = AppDefinitions(name=get_repo_name_from_url(url), path=repo)
49
- return app_definitions
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
- work = await self.queue.get()
61
- deployment_task = asyncio.create_task(self.process_deployment(work))
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.6.3"
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
- [tool.uv]
49
- dev-dependencies = [
50
- "pytest>=8.3.2",
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.24.0",
57
+ "pytest-asyncio>=1.0.0",
58
58
  "types-invoke>=2.0.0.10",
59
59
  ]
60
60
 
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
+ "extends": [
4
+ "local>uptick/renovate-config"
5
+ ]
6
+ }
@@ -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