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.
- {gitops-1.7.1 → gitops-1.8.0}/.github/workflows/test.yml +6 -0
- {gitops-1.7.1 → gitops-1.8.0}/.release-please-manifest.json +1 -1
- {gitops-1.7.1 → gitops-1.8.0}/CHANGELOG.md +19 -0
- {gitops-1.7.1 → gitops-1.8.0}/Dockerfile +7 -1
- {gitops-1.7.1 → gitops-1.8.0}/PKG-INFO +1 -1
- {gitops-1.7.1 → gitops-1.8.0}/gitops/__init__.py +1 -1
- {gitops-1.7.1 → 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.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/deploy.py +71 -4
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/worker.py +22 -3
- {gitops-1.7.1 → gitops-1.8.0}/pyproject.toml +1 -1
- gitops-1.8.0/tests/test_clone_credentials.py +32 -0
- 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.7.1 → gitops-1.8.0}/uv.lock +1 -1
- gitops-1.7.1/gitops_server/utils/git.py +0 -88
- gitops-1.7.1/gitops_server/version.py +0 -1
- {gitops-1.7.1 → gitops-1.8.0}/.dockerignore +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/.github/CODEOWNERS +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/.github/workflows/release-please.yaml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/.gitignore +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/.mise.toml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/.pre-commit-config.yaml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/CLAUDE.md +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/LICENSE +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/README.md +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/.helmignore +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/CHANGELOG.md +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/Chart.yaml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/README.md +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/_helpers.tpl +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/certificate.yml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/deployment.yml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/environment.yml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/ingress.yml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/issuer.yml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/pdb.yml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/secrets.yml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/service.yml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/templates/serviceaccount.yaml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/charts/gitops/values.yaml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/docker-compose.yml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/docs/plans/2026-03-13-kms-encryption-design.md +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/common/__init__.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/common/app.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/common/utils.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/core.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/main.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/py.typed +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/settings.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/shorthands.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/__init__.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/apps.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/async_runner.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/cli.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/exceptions.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/images.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/kube.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/tags.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops/utils/yaml.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/CHANGELOG.md +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/README.md +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/__init__.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/app.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/main.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/types.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/utils/__init__.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/utils/github.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/utils/slack.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/__init__.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/__init__.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/hooks.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/gitops_server/workers/deployer/semaphore_manager.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/release-please-config.json +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/release.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/renovate.json +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/ruff.toml +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/secrets.example.env +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/setup.cfg +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/__init__.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/conftest.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/sample_data.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/test_app.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/test_core.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/test_deploy.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/test_deploy_concurrent.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/test_git.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/test_gitops_server.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/test_utils.py +0 -0
- {gitops-1.7.1 → gitops-1.8.0}/tests/utils.py +0 -0
- {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,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
|
-
|
|
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/
|
|
@@ -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:
|
|
@@ -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()
|
|
@@ -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
|
|
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
|