gitops 1.7.1__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.7.1 → gitops-1.8.0}/.github/workflows/test.yml +6 -0
  2. {gitops-1.7.1 → gitops-1.8.0}/.release-please-manifest.json +1 -1
  3. {gitops-1.7.1 → gitops-1.8.0}/CHANGELOG.md +19 -0
  4. {gitops-1.7.1 → gitops-1.8.0}/Dockerfile +7 -1
  5. {gitops-1.7.1 → gitops-1.8.0}/PKG-INFO +1 -1
  6. {gitops-1.7.1 → gitops-1.8.0}/gitops/__init__.py +1 -1
  7. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/settings.py +5 -0
  8. gitops-1.8.0/gitops_server/utils/git.py +182 -0
  9. gitops-1.8.0/gitops_server/version.py +1 -0
  10. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/deploy.py +71 -4
  11. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/worker.py +22 -3
  12. {gitops-1.7.1 → gitops-1.8.0}/pyproject.toml +1 -1
  13. gitops-1.8.0/tests/test_clone_credentials.py +32 -0
  14. gitops-1.8.0/tests/test_load_app_definitions.py +145 -0
  15. gitops-1.8.0/tests/test_worker_concurrency.py +76 -0
  16. {gitops-1.7.1 → gitops-1.8.0}/uv.lock +1 -1
  17. gitops-1.7.1/gitops_server/utils/git.py +0 -88
  18. gitops-1.7.1/gitops_server/version.py +0 -1
  19. {gitops-1.7.1 → gitops-1.8.0}/.dockerignore +0 -0
  20. {gitops-1.7.1 → gitops-1.8.0}/.github/CODEOWNERS +0 -0
  21. {gitops-1.7.1 → gitops-1.8.0}/.github/workflows/release-please.yaml +0 -0
  22. {gitops-1.7.1 → gitops-1.8.0}/.gitignore +0 -0
  23. {gitops-1.7.1 → gitops-1.8.0}/.mise.toml +0 -0
  24. {gitops-1.7.1 → gitops-1.8.0}/.pre-commit-config.yaml +0 -0
  25. {gitops-1.7.1 → gitops-1.8.0}/CLAUDE.md +0 -0
  26. {gitops-1.7.1 → gitops-1.8.0}/LICENSE +0 -0
  27. {gitops-1.7.1 → gitops-1.8.0}/README.md +0 -0
  28. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/.helmignore +0 -0
  29. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/CHANGELOG.md +0 -0
  30. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/Chart.yaml +0 -0
  31. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/README.md +0 -0
  32. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/_helpers.tpl +0 -0
  33. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/certificate.yml +0 -0
  34. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/deployment.yml +0 -0
  35. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/environment.yml +0 -0
  36. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/ingress.yml +0 -0
  37. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/issuer.yml +0 -0
  38. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/pdb.yml +0 -0
  39. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/secrets.yml +0 -0
  40. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/service.yml +0 -0
  41. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/serviceaccount.yaml +0 -0
  42. {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/values.yaml +0 -0
  43. {gitops-1.7.1 → gitops-1.8.0}/docker-compose.yml +0 -0
  44. {gitops-1.7.1 → gitops-1.8.0}/docs/plans/2026-03-13-kms-encryption-design.md +0 -0
  45. {gitops-1.7.1 → gitops-1.8.0}/gitops/common/__init__.py +0 -0
  46. {gitops-1.7.1 → gitops-1.8.0}/gitops/common/app.py +0 -0
  47. {gitops-1.7.1 → gitops-1.8.0}/gitops/common/utils.py +0 -0
  48. {gitops-1.7.1 → gitops-1.8.0}/gitops/core.py +0 -0
  49. {gitops-1.7.1 → gitops-1.8.0}/gitops/main.py +0 -0
  50. {gitops-1.7.1 → gitops-1.8.0}/gitops/py.typed +0 -0
  51. {gitops-1.7.1 → gitops-1.8.0}/gitops/settings.py +0 -0
  52. {gitops-1.7.1 → gitops-1.8.0}/gitops/shorthands.py +0 -0
  53. {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/__init__.py +0 -0
  54. {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/apps.py +0 -0
  55. {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/async_runner.py +0 -0
  56. {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/cli.py +0 -0
  57. {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/exceptions.py +0 -0
  58. {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/images.py +0 -0
  59. {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/kube.py +0 -0
  60. {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/tags.py +0 -0
  61. {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/yaml.py +0 -0
  62. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/CHANGELOG.md +0 -0
  63. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/README.md +0 -0
  64. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/__init__.py +0 -0
  65. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/app.py +0 -0
  66. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/main.py +0 -0
  67. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/types.py +0 -0
  68. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/utils/__init__.py +0 -0
  69. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/utils/github.py +0 -0
  70. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/utils/slack.py +0 -0
  71. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/__init__.py +0 -0
  72. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/__init__.py +0 -0
  73. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/hooks.py +0 -0
  74. {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/semaphore_manager.py +0 -0
  75. {gitops-1.7.1 → gitops-1.8.0}/release-please-config.json +0 -0
  76. {gitops-1.7.1 → gitops-1.8.0}/release.py +0 -0
  77. {gitops-1.7.1 → gitops-1.8.0}/renovate.json +0 -0
  78. {gitops-1.7.1 → gitops-1.8.0}/ruff.toml +0 -0
  79. {gitops-1.7.1 → gitops-1.8.0}/secrets.example.env +0 -0
  80. {gitops-1.7.1 → gitops-1.8.0}/setup.cfg +0 -0
  81. {gitops-1.7.1 → gitops-1.8.0}/tests/__init__.py +0 -0
  82. {gitops-1.7.1 → gitops-1.8.0}/tests/conftest.py +0 -0
  83. {gitops-1.7.1 → gitops-1.8.0}/tests/sample_data.py +0 -0
  84. {gitops-1.7.1 → gitops-1.8.0}/tests/test_app.py +0 -0
  85. {gitops-1.7.1 → gitops-1.8.0}/tests/test_core.py +0 -0
  86. {gitops-1.7.1 → gitops-1.8.0}/tests/test_deploy.py +0 -0
  87. {gitops-1.7.1 → gitops-1.8.0}/tests/test_deploy_concurrent.py +0 -0
  88. {gitops-1.7.1 → gitops-1.8.0}/tests/test_git.py +0 -0
  89. {gitops-1.7.1 → gitops-1.8.0}/tests/test_gitops_server.py +0 -0
  90. {gitops-1.7.1 → gitops-1.8.0}/tests/test_utils.py +0 -0
  91. {gitops-1.7.1 → gitops-1.8.0}/tests/utils.py +0 -0
  92. {gitops-1.7.1 → gitops-1.8.0}/tests/webhook_sample_data.py +0 -0
@@ -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 }}
@@ -1,5 +1,5 @@
1
1
  {
2
- ".": "1.7.1",
2
+ ".": "1.8.0",
3
3
  "gitops_server": "0.15.1",
4
4
  "charts/gitops": "1.2.2"
5
5
  }
@@ -1,5 +1,24 @@
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
+
3
22
  ## [1.7.1](https://github.com/uptick/gitops/compare/gitops-v1.7.0...gitops-v1.7.1) (2026-08-17)
4
23
 
5
24
 
@@ -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/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitops
3
- Version: 1.7.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
@@ -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.7.1"
9
+ __version__ = "1.8.0"
10
10
 
11
11
 
12
12
  # Checking gitops version matches cluster repo version.
@@ -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.7.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"
@@ -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
@@ -0,0 +1,145 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+
5
+ import pytest
6
+ import yaml
7
+
8
+ import gitops_server.settings as settings
9
+ import gitops_server.utils.git as git_utils
10
+ from gitops_server.workers.deployer.deploy import load_app_definitions
11
+
12
+ URL = "https://github.com/uptick/mock-repo.git"
13
+
14
+ APPS = {
15
+ "ours": {"cluster": "test-cluster", "tags": ["a"]},
16
+ "ours-too": {"cluster": "test-cluster", "tags": []},
17
+ "theirs": {"cluster": "other-cluster", "tags": []},
18
+ "suspended": {"cluster": "test-cluster", "tags": ["suspended"]},
19
+ }
20
+
21
+
22
+ def _git(repo, *args) -> str:
23
+ """Run git against the fixture repo and return stdout."""
24
+ result = subprocess.run( # noqa: S603
25
+ ["git", "-C", str(repo), *args], # noqa: S607
26
+ check=True,
27
+ capture_output=True,
28
+ text=True,
29
+ )
30
+ return result.stdout.strip()
31
+
32
+
33
+ def _write(repo, path, data):
34
+ full = repo / path
35
+ full.parent.mkdir(parents=True, exist_ok=True)
36
+ full.write_text(yaml.dump(data))
37
+
38
+
39
+ def _make_repo(cache_root, encrypt=False):
40
+ """One flat apps/ directory holding several clusters' apps, as in the real repo."""
41
+ repo = cache_root / "mock-repo"
42
+ repo.mkdir()
43
+
44
+ _git(repo, "init", "-q", "-b", "main")
45
+ _git(repo, "config", "user.email", "test@example.com")
46
+ _git(repo, "config", "user.name", "test")
47
+
48
+ if encrypt:
49
+ # Must precede `git add` so the clean filter encrypts on the way in.
50
+ _git(repo, "crypt", "init")
51
+ (repo / ".gitattributes").write_text("**/secrets.yml filter=git-crypt diff=git-crypt\n")
52
+
53
+ # Shared parent that every app extends.
54
+ _write(repo, "apps/deployment.yml", {"chart": "https://github.com/some/chart", "namespace": "default"})
55
+ for name, values in APPS.items():
56
+ _write(repo, f"apps/{name}/deployment.yml", {"extends": "../deployment.yml", **values})
57
+ _write(repo, f"apps/{name}/secrets.yml", {"secrets": {"TOKEN": f"{name}-token"}})
58
+
59
+ _git(repo, "add", "-A")
60
+ _git(repo, "commit", "-q", "-m", "init")
61
+ return repo, _git(repo, "rev-parse", "HEAD")
62
+
63
+
64
+ @pytest.fixture
65
+ def repo(tmp_path, monkeypatch):
66
+ cache_root = tmp_path / "repocache"
67
+ cache_root.mkdir()
68
+ repo, sha = _make_repo(cache_root)
69
+ monkeypatch.setattr(git_utils, "REPO_CACHE_DIR", cache_root)
70
+ monkeypatch.setattr(settings, "CLUSTER_NAME", "test-cluster")
71
+ return repo, sha, cache_root
72
+
73
+
74
+ @pytest.fixture
75
+ def encrypted_repo(tmp_path, monkeypatch):
76
+ cache_root = tmp_path / "repocache"
77
+ cache_root.mkdir()
78
+ repo, sha = _make_repo(cache_root, encrypt=True)
79
+ monkeypatch.setattr(git_utils, "REPO_CACHE_DIR", cache_root)
80
+ monkeypatch.setattr(settings, "CLUSTER_NAME", "test-cluster")
81
+ return repo, sha, cache_root
82
+
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_returns_only_this_clusters_unsuspended_apps(repo):
86
+ _, sha, _ = repo
87
+ definitions = await load_app_definitions(URL, sha)
88
+ assert sorted(definitions.apps) == ["ours", "ours-too"]
89
+
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_resolves_extends_from_the_parent_definition(repo):
93
+ _, sha, _ = repo
94
+ definitions = await load_app_definitions(URL, sha)
95
+ # `namespace` and `chart` only exist on the parent.
96
+ assert definitions.apps["ours"].namespace == "default"
97
+ assert definitions.apps["ours"].values["tags"] == ["a"]
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_loads_secrets_for_kept_apps(repo):
102
+ _, sha, _ = repo
103
+ definitions = await load_app_definitions(URL, sha)
104
+ # App base64-encodes secrets by default.
105
+ assert definitions.apps["ours"].secrets == {"TOKEN": "b3Vycy10b2tlbg=="}
106
+
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_reads_a_past_sha_not_the_working_tree(repo):
110
+ """Blobs must resolve at the requested commit, not the checked-out one."""
111
+ repo_path, first_sha, _ = repo
112
+ _write(repo_path, "apps/ours/deployment.yml", {"extends": "../deployment.yml", "cluster": "other-cluster"})
113
+ _git(repo_path, "add", "-A")
114
+ _git(repo_path, "commit", "-q", "-m", "move ours to another cluster")
115
+ second_sha = _git(repo_path, "rev-parse", "HEAD")
116
+
117
+ assert sorted((await load_app_definitions(URL, first_sha)).apps) == ["ours", "ours-too"]
118
+ assert sorted((await load_app_definitions(URL, second_sha)).apps) == ["ours-too"]
119
+
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_does_not_materialise_a_working_copy(repo):
123
+ """The point of reading blobs: no per-load copy on disk."""
124
+ _, sha, cache_root = repo
125
+ before = set(os.listdir(cache_root))
126
+ await load_app_definitions(URL, sha)
127
+ assert set(os.listdir(cache_root)) == before
128
+
129
+
130
+ @pytest.mark.skipif(shutil.which("git-crypt") is None, reason="git-crypt is not installed")
131
+ @pytest.mark.asyncio
132
+ async def test_decrypts_git_crypt_secrets(encrypted_repo):
133
+ """Encrypted secrets must be run back through git-crypt."""
134
+ repo_path, sha, _ = encrypted_repo
135
+
136
+ # Prove the fixture is encrypted at rest, else this passes for the wrong reason.
137
+ raw = subprocess.run( # noqa: S603
138
+ ["git", "-C", str(repo_path), "cat-file", "blob", f"{sha}:apps/ours/secrets.yml"], # noqa: S607
139
+ check=True,
140
+ capture_output=True,
141
+ ).stdout
142
+ assert raw.startswith(git_utils.GIT_CRYPT_MAGIC)
143
+
144
+ definitions = await load_app_definitions(URL, sha)
145
+ assert definitions.apps["ours"].secrets == {"TOKEN": "b3Vycy10b2tlbg=="}
@@ -0,0 +1,76 @@
1
+ import asyncio
2
+ from unittest import mock
3
+
4
+ import pytest
5
+
6
+ from gitops_server.workers.deployer.worker import DeployQueueWorker
7
+
8
+
9
+ @pytest.mark.asyncio
10
+ async def test_concurrent_deployments_are_bounded():
11
+ """A burst of pushes must not put more than the limit in flight at once.
12
+
13
+ Everything before `helm_parallel_semaphore` -- loading app definitions for
14
+ two commits -- runs here, so an unbounded worker scales that with the
15
+ webhook arrival rate.
16
+ """
17
+ limit = 3
18
+ in_flight = 0
19
+ peak = 0
20
+ release = asyncio.Event()
21
+
22
+ async def slow_deployment(work, ref):
23
+ nonlocal in_flight, peak
24
+ in_flight += 1
25
+ peak = max(peak, in_flight)
26
+ try:
27
+ await release.wait()
28
+ finally:
29
+ in_flight -= 1
30
+
31
+ with mock.patch("gitops_server.settings.GITOPS_MAX_CONCURRENT_DEPLOYMENTS", str(limit)):
32
+ worker = DeployQueueWorker()
33
+ worker._process_deployment = slow_deployment # type: ignore[method-assign]
34
+
35
+ runner = asyncio.create_task(worker.run())
36
+ for _ in range(10):
37
+ await worker.enqueue({"ref": "refs/heads/master"})
38
+
39
+ # Let the worker take as much as it is willing to take.
40
+ for _ in range(20):
41
+ await asyncio.sleep(0)
42
+
43
+ assert peak == limit, f"expected at most {limit} in flight, saw {peak}"
44
+ assert worker.queue.qsize() > 0, "excess work should wait in the queue"
45
+
46
+ release.set()
47
+ for _ in range(50):
48
+ await asyncio.sleep(0)
49
+
50
+ assert peak == limit
51
+ runner.cancel()
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_slot_is_released_when_a_deployment_fails():
56
+ """A failing deployment must not permanently consume a slot."""
57
+ limit = 2
58
+ started = 0
59
+
60
+ async def failing_deployment(work, ref):
61
+ nonlocal started
62
+ started += 1
63
+ raise RuntimeError("boom")
64
+
65
+ with mock.patch("gitops_server.settings.GITOPS_MAX_CONCURRENT_DEPLOYMENTS", str(limit)):
66
+ worker = DeployQueueWorker()
67
+ worker._process_deployment = failing_deployment # type: ignore[method-assign]
68
+
69
+ runner = asyncio.create_task(worker.run())
70
+ for _ in range(6):
71
+ await worker.enqueue({"ref": "refs/heads/master"})
72
+ for _ in range(60):
73
+ await asyncio.sleep(0)
74
+
75
+ assert started == 6, f"slots leaked after failures: only {started} of 6 ran"
76
+ runner.cancel()
@@ -360,7 +360,7 @@ wheels = [
360
360
 
361
361
  [[package]]
362
362
  name = "gitops"
363
- version = "1.7.1"
363
+ version = "1.8.0"
364
364
  source = { editable = "." }
365
365
  dependencies = [
366
366
  { name = "boto3" },
@@ -1,88 +0,0 @@
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
-
22
- async def clone_repo(git_repo_url: str, path: str, sha: str | None = None, branch: str | None = None) -> None:
23
- """Shallow Clones a git repo url to path and git-crypt unlocks all encrypted files"""
24
- logger.info(f'Cloning "{git_repo_url}".')
25
-
26
- url_with_oauth_token = git_repo_url.replace("://", f"://{os.environ['GITHUB_OAUTH_TOKEN'].strip()}@")
27
-
28
- with tracer.start_as_current_span("tempo_repo.clone_repo"):
29
- if sha:
30
- await run(f"git clone --depth 100 {url_with_oauth_token} {path}; cd {path}; git checkout {sha}")
31
- elif branch:
32
- await run(f"git clone --depth 1 {url_with_oauth_token} {path} --branch {branch}")
33
- else:
34
- await run(f"git clone --depth 100 {url_with_oauth_token} {path};")
35
-
36
- if GIT_CRYPT_KEY_FILE := os.environ.get("GIT_CRYPT_KEY_FILE"):
37
- with tracer.start_as_current_span("temp_repo.git_crypt_unlock"):
38
- await run(f"cd {path}; git-crypt unlock {GIT_CRYPT_KEY_FILE}")
39
-
40
-
41
- def is_sha(sha_or_ref: str | None) -> bool:
42
- """Check if the given string is a valid SHA-1 hash or a short SHA."""
43
- if not sha_or_ref:
44
- return False
45
- return bool(re.fullmatch(r"[a-fA-F0-9]{4,40}", sha_or_ref))
46
-
47
-
48
- @asynccontextmanager
49
- async def temp_repo(git_repo_url: str, ref: str | None) -> AsyncGenerator[str, None]:
50
- """Checks out a git_repo_url to a temporary folder location. Returns temporary folder location"""
51
- # Someone passed us a branch name, not a sha
52
-
53
- if not is_sha(ref):
54
- async with temp_repo_branch(git_repo_url, branch=ref) as temp_repo_path:
55
- yield temp_repo_path
56
- return
57
-
58
- with tracer.start_as_current_span("checkout_temp_repo"):
59
- cache_path = REPO_CACHE_DIR / git_repo_url.split("/")[-1].split(".")[0]
60
-
61
- # Prep the repo cache
62
- if not (cache_path / ".git").exists():
63
- logger.info("Repo %s not in cache, cloning", git_repo_url)
64
- async with repo_lock:
65
- if cache_path.exists():
66
- await run(f"rm -rf {cache_path}", suppress_errors=True)
67
- if not cache_path.exists():
68
- cache_path.mkdir(parents=True)
69
- REPO_CACHE[git_repo_url] = cache_path
70
- await clone_repo(git_repo_url, path=str(cache_path))
71
-
72
- # Copy the repo cache to a temporary folder
73
- with tempfile.TemporaryDirectory() as temporary_folder_path:
74
- await run(f"rm -rf {temporary_folder_path}", suppress_errors=True)
75
- await run(f"cp -r {cache_path} {temporary_folder_path}")
76
- await run(f"cd {temporary_folder_path};git fetch; git checkout {ref}")
77
-
78
- yield temporary_folder_path
79
-
80
-
81
- @asynccontextmanager
82
- async def temp_repo_branch(git_repo_url: str, branch: str | None) -> AsyncGenerator[str, None]:
83
- """Checks out a git_repo_url to a temporary folder location. Returns temporary folder location"""
84
- with tracer.start_as_current_span("checkout_temp_repo_branch"):
85
- # Copy the repo cache to a temporary folder
86
- with tempfile.TemporaryDirectory() as temporary_folder_path:
87
- await clone_repo(git_repo_url, path=str(temporary_folder_path), branch=branch)
88
- yield temporary_folder_path
@@ -1 +0,0 @@
1
- __version__ = "1.7.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes