gitops 1.8.0__tar.gz → 1.9.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.9.0/.release-please-manifest.json +5 -0
- {gitops-1.8.0 → gitops-1.9.0}/CHANGELOG.md +18 -0
- {gitops-1.8.0 → gitops-1.9.0}/Dockerfile +1 -7
- {gitops-1.8.0 → gitops-1.9.0}/PKG-INFO +2 -1
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/CHANGELOG.md +13 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/Chart.yaml +1 -1
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/deployment.yml +5 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/secrets.yml +2 -1
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/values.yaml +5 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/__init__.py +1 -1
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/app.py +8 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/utils/__init__.py +15 -2
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/utils/git.py +20 -5
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/utils/github.py +5 -7
- gitops-1.9.0/gitops_server/utils/github_auth.py +133 -0
- gitops-1.9.0/gitops_server/version.py +1 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/workers/deployer/hooks.py +1 -1
- {gitops-1.8.0 → gitops-1.9.0}/pyproject.toml +3 -1
- gitops-1.9.0/tests/test_clone_credentials.py +109 -0
- gitops-1.9.0/tests/test_github_auth.py +106 -0
- {gitops-1.8.0 → gitops-1.9.0}/uv.lock +162 -2
- gitops-1.8.0/.release-please-manifest.json +0 -5
- gitops-1.8.0/gitops_server/version.py +0 -1
- gitops-1.8.0/tests/test_clone_credentials.py +0 -32
- {gitops-1.8.0 → gitops-1.9.0}/.dockerignore +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/.github/CODEOWNERS +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/.github/workflows/release-please.yaml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/.github/workflows/test.yml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/.gitignore +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/.mise.toml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/.pre-commit-config.yaml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/CLAUDE.md +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/LICENSE +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/README.md +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/.helmignore +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/README.md +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/_helpers.tpl +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/certificate.yml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/environment.yml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/ingress.yml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/issuer.yml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/pdb.yml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/service.yml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/charts/gitops/templates/serviceaccount.yaml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/docker-compose.yml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/docs/plans/2026-03-13-kms-encryption-design.md +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/common/__init__.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/common/app.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/common/utils.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/core.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/main.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/py.typed +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/settings.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/shorthands.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/utils/__init__.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/utils/apps.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/utils/async_runner.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/utils/cli.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/utils/exceptions.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/utils/images.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/utils/kube.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/utils/tags.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops/utils/yaml.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/CHANGELOG.md +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/README.md +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/__init__.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/main.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/settings.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/types.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/utils/slack.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/workers/__init__.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/workers/deployer/__init__.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/workers/deployer/deploy.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/workers/deployer/semaphore_manager.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/gitops_server/workers/deployer/worker.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/release-please-config.json +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/release.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/renovate.json +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/ruff.toml +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/secrets.example.env +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/setup.cfg +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/__init__.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/conftest.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/sample_data.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/test_app.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/test_core.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/test_deploy.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/test_deploy_concurrent.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/test_git.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/test_gitops_server.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/test_load_app_definitions.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/test_utils.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/test_worker_concurrency.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/utils.py +0 -0
- {gitops-1.8.0 → gitops-1.9.0}/tests/webhook_sample_data.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.9.0](https://github.com/uptick/gitops/compare/gitops-v1.8.0...gitops-v1.9.0) (2026-09-09)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* authenticate to GitHub as an App ([51ae678](https://github.com/uptick/gitops/commit/51ae678a874cf205b0f612404426c7444611f96c))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* authenticate the repo-cache fetch, and tighten the mint ([3c1100b](https://github.com/uptick/gitops/commit/3c1100b936a3fb83eef28d3ed2732d0afaf8d607))
|
|
14
|
+
* scope the credential helper to github.com ([7068133](https://github.com/uptick/gitops/commit/7068133b1a3796ffa85de191549097d2ad724f6d))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* cut comments back to what is not derivable from the code ([4d41784](https://github.com/uptick/gitops/commit/4d4178478da4efa37b924e3cab3da430e8ec68bd))
|
|
20
|
+
|
|
3
21
|
## [1.8.0](https://github.com/uptick/gitops/compare/gitops-v1.7.1...gitops-v1.8.0) (2026-08-21)
|
|
4
22
|
|
|
5
23
|
|
|
@@ -56,13 +56,7 @@ 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
|
-
|
|
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'
|
|
59
|
+
RUN git config --global advice.detachedHead false
|
|
66
60
|
|
|
67
61
|
COPY cluster.key /app/
|
|
68
62
|
COPY gitops /app/gitops/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitops
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.9.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
|
|
@@ -22,6 +22,7 @@ Requires-Dist: httpx>=0.27.0; extra == 'server'
|
|
|
22
22
|
Requires-Dist: idna>=3.15; extra == 'server'
|
|
23
23
|
Requires-Dist: kubernetes-asyncio>=25.1.2; extra == 'server'
|
|
24
24
|
Requires-Dist: protobuf>=5.29.6; extra == 'server'
|
|
25
|
+
Requires-Dist: pyjwt[crypto]>=2.10.0; extra == 'server'
|
|
25
26
|
Requires-Dist: requests>=2.33.0; extra == 'server'
|
|
26
27
|
Requires-Dist: sentry-sdk>=1.3.0; extra == 'server'
|
|
27
28
|
Requires-Dist: slack-sdk>=3.33.5; extra == 'server'
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.3.0](https://github.com/uptick/gitops/compare/helm-v1.2.2...helm-v1.3.0) (2026-09-09)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* authenticate to GitHub as an App ([51ae678](https://github.com/uptick/gitops/commit/51ae678a874cf205b0f612404426c7444611f96c))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* authenticate the repo-cache fetch, and tighten the mint ([3c1100b](https://github.com/uptick/gitops/commit/3c1100b936a3fb83eef28d3ed2732d0afaf8d607))
|
|
14
|
+
* scope the credential helper to github.com ([7068133](https://github.com/uptick/gitops/commit/7068133b1a3796ffa85de191549097d2ad724f6d))
|
|
15
|
+
|
|
3
16
|
## [1.2.2](https://github.com/uptick/gitops/compare/helm-v1.2.1...helm-v1.2.2) (2026-05-28)
|
|
4
17
|
|
|
5
18
|
|
|
@@ -65,6 +65,11 @@ spec:
|
|
|
65
65
|
secretKeyRef:
|
|
66
66
|
name: {{ .Release.Name }}-secret
|
|
67
67
|
key: GITHUB_OAUTH_TOKEN
|
|
68
|
+
- name: GITHUB_APP_PRIVATE_KEY
|
|
69
|
+
valueFrom:
|
|
70
|
+
secretKeyRef:
|
|
71
|
+
name: {{ .Release.Name }}-secret
|
|
72
|
+
key: GITHUB_APP_PRIVATE_KEY
|
|
68
73
|
- name: GITHUB_WEBHOOK_KEY
|
|
69
74
|
valueFrom:
|
|
70
75
|
secretKeyRef:
|
|
@@ -10,4 +10,5 @@ stringData:
|
|
|
10
10
|
SENTRY_DSN: {{ .Values.secrets.SENTRY_DSN| quote}}
|
|
11
11
|
SLACK_TOKEN: {{ .Values.secrets.SLACK_TOKEN| quote}}
|
|
12
12
|
data:
|
|
13
|
-
GIT_CRYPT_KEY: {{ .Values.secrets.GIT_CRYPT_KEY | quote}}
|
|
13
|
+
GIT_CRYPT_KEY: {{ .Values.secrets.GIT_CRYPT_KEY | quote}}
|
|
14
|
+
GITHUB_APP_PRIVATE_KEY: {{ .Values.secrets.GITHUB_APP_PRIVATE_KEY | quote}}
|
|
@@ -9,11 +9,16 @@ environment:
|
|
|
9
9
|
TZ: "UTC"
|
|
10
10
|
# The channel id to send slack messages to
|
|
11
11
|
SLACK_CHANNEL_ID: ""
|
|
12
|
+
# GitHub App identifiers. Empty when authenticating with a PAT.
|
|
13
|
+
GITHUB_APP_ID: ""
|
|
14
|
+
GITHUB_INSTALLATION_ID: ""
|
|
12
15
|
|
|
13
16
|
secrets:
|
|
14
17
|
ACCOUNT_ID: ""
|
|
15
18
|
SLACK_TOKEN: ""
|
|
16
19
|
GITHUB_OAUTH_TOKEN: ""
|
|
20
|
+
# base64-encoded PEM. Empty when authenticating with a PAT.
|
|
21
|
+
GITHUB_APP_PRIVATE_KEY: ""
|
|
17
22
|
GITHUB_WEBHOOK_KEY: ""
|
|
18
23
|
GIT_CRYPT_KEY: ""
|
|
19
24
|
SENTRY_DSN: ""
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import logging
|
|
2
3
|
import os
|
|
3
4
|
from collections.abc import AsyncIterator
|
|
4
5
|
from contextlib import asynccontextmanager
|
|
@@ -9,12 +10,19 @@ from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
|
|
|
9
10
|
|
|
10
11
|
from . import settings
|
|
11
12
|
|
|
13
|
+
logger = logging.getLogger("gitops")
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
@asynccontextmanager
|
|
14
17
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
15
18
|
"""Manage application lifespan - start and stop background workers."""
|
|
19
|
+
from .utils.github_auth import credentials, validate_credentials
|
|
16
20
|
from .workers import DeployQueueWorker
|
|
17
21
|
|
|
22
|
+
# Fail here rather than on the first push, where hooks.py only logs a warning.
|
|
23
|
+
validate_credentials()
|
|
24
|
+
logger.info("GitHub authentication: %s", credentials.describe())
|
|
25
|
+
|
|
18
26
|
loop = asyncio.get_running_loop()
|
|
19
27
|
deploy_queue_worker = DeployQueueWorker.get_worker()
|
|
20
28
|
deploy_queue_worker_task = asyncio.ensure_future(deploy_queue_worker.run(), loop=loop)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import logging
|
|
3
|
+
import os
|
|
3
4
|
from collections.abc import Callable
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
@@ -19,16 +20,28 @@ async def _read_stream(stream: asyncio.StreamReader | None, cb: Callable[[bytes]
|
|
|
19
20
|
break
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
async def run(
|
|
23
|
+
async def run(
|
|
24
|
+
command: str,
|
|
25
|
+
suppress_errors: bool = False,
|
|
26
|
+
cwd: str | Path | None = None,
|
|
27
|
+
env: dict[str, str] | None = None,
|
|
28
|
+
) -> RunOutput:
|
|
23
29
|
"""Run a shell command.
|
|
24
30
|
|
|
25
31
|
Runs the command in an asyncio executor to keep things async. Will
|
|
26
32
|
optionally prevent raising an exception on failure with `suppress_errors`.
|
|
33
|
+
|
|
34
|
+
`env` is overlaid on the current environment, so a caller can hand one command
|
|
35
|
+
a credential without putting it in argv.
|
|
27
36
|
"""
|
|
28
37
|
exit_code = 0
|
|
29
38
|
logger.info(f'Running "{command}".')
|
|
30
39
|
proc = await asyncio.create_subprocess_shell(
|
|
31
|
-
command,
|
|
40
|
+
command,
|
|
41
|
+
stdout=asyncio.subprocess.PIPE,
|
|
42
|
+
stderr=asyncio.subprocess.PIPE,
|
|
43
|
+
cwd=cwd,
|
|
44
|
+
env={**os.environ, **env} if env else None,
|
|
32
45
|
)
|
|
33
46
|
stdout, stderr = b"", b""
|
|
34
47
|
|
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
from opentelemetry.trace import get_tracer
|
|
11
11
|
|
|
12
12
|
from . import run
|
|
13
|
+
from .github_auth import credentials
|
|
13
14
|
|
|
14
15
|
tracer = get_tracer(__name__)
|
|
15
16
|
logger = logging.getLogger("gitops")
|
|
@@ -26,17 +27,29 @@ GIT_CRYPT_MAGIC = b"\0GITCRYPT\0"
|
|
|
26
27
|
decrypt_semaphore = asyncio.Semaphore(16)
|
|
27
28
|
|
|
28
29
|
|
|
30
|
+
async def credential_env(git_repo_url: str) -> dict[str, str] | None:
|
|
31
|
+
"""Credentials for a git subprocess, or None for a remote that needs none.
|
|
32
|
+
|
|
33
|
+
Local paths in tests are not GitHub, and asking would mint a token for nothing.
|
|
34
|
+
"""
|
|
35
|
+
if "://" not in git_repo_url:
|
|
36
|
+
return None
|
|
37
|
+
return await credentials.git_env()
|
|
38
|
+
|
|
39
|
+
|
|
29
40
|
async def clone_repo(git_repo_url: str, path: str, sha: str | None = None, branch: str | None = None) -> None:
|
|
30
41
|
"""Shallow Clones a git repo url to path and git-crypt unlocks all encrypted files"""
|
|
31
42
|
logger.info(f'Cloning "{git_repo_url}".')
|
|
32
43
|
|
|
44
|
+
env = await credential_env(git_repo_url)
|
|
45
|
+
|
|
33
46
|
with tracer.start_as_current_span("tempo_repo.clone_repo"):
|
|
34
47
|
if sha:
|
|
35
|
-
await run(f"git clone --depth 100 {git_repo_url} {path}; cd {path}; git checkout {sha}")
|
|
48
|
+
await run(f"git clone --depth 100 {git_repo_url} {path}; cd {path}; git checkout {sha}", env=env)
|
|
36
49
|
elif branch:
|
|
37
|
-
await run(f"git clone --depth 1 {git_repo_url} {path} --branch {branch}")
|
|
50
|
+
await run(f"git clone --depth 1 {git_repo_url} {path} --branch {branch}", env=env)
|
|
38
51
|
else:
|
|
39
|
-
await run(f"git clone --depth 100 {git_repo_url} {path};")
|
|
52
|
+
await run(f"git clone --depth 100 {git_repo_url} {path};", env=env)
|
|
40
53
|
|
|
41
54
|
if GIT_CRYPT_KEY_FILE := os.environ.get("GIT_CRYPT_KEY_FILE"):
|
|
42
55
|
with tracer.start_as_current_span("temp_repo.git_crypt_unlock"):
|
|
@@ -74,7 +87,7 @@ async def temp_repo(git_repo_url: str, ref: str | None) -> AsyncGenerator[str, N
|
|
|
74
87
|
yield temporary_folder_path
|
|
75
88
|
|
|
76
89
|
|
|
77
|
-
async def _git(repo: str, *args: str, stdin: bytes | None = None) -> bytes:
|
|
90
|
+
async def _git(repo: str, *args: str, stdin: bytes | None = None, env: dict[str, str] | None = None) -> bytes:
|
|
78
91
|
"""Run git in `repo`, returning raw stdout.
|
|
79
92
|
|
|
80
93
|
Not `run()`: blobs are binary, and `run()` decodes and logs every line.
|
|
@@ -87,6 +100,7 @@ async def _git(repo: str, *args: str, stdin: bytes | None = None) -> bytes:
|
|
|
87
100
|
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
|
|
88
101
|
stdout=asyncio.subprocess.PIPE,
|
|
89
102
|
stderr=asyncio.subprocess.PIPE,
|
|
103
|
+
env={**os.environ, **env} if env else None,
|
|
90
104
|
)
|
|
91
105
|
stdout, stderr = await proc.communicate(stdin)
|
|
92
106
|
if proc.returncode != 0:
|
|
@@ -110,7 +124,8 @@ async def ensure_repo_cache(git_repo_url: str, ref: str) -> str:
|
|
|
110
124
|
await _git(str(cache_path), "cat-file", "-e", f"{ref}^{{commit}}")
|
|
111
125
|
except Exception:
|
|
112
126
|
logger.info("Ref %s not in cache, fetching", ref)
|
|
113
|
-
await
|
|
127
|
+
env = await credential_env(git_repo_url)
|
|
128
|
+
await _git(str(cache_path), "fetch", "--quiet", "origin", env=env)
|
|
114
129
|
return str(cache_path)
|
|
115
130
|
|
|
116
131
|
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
import os
|
|
3
2
|
|
|
4
3
|
import httpx
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
from gitops_server.utils.github_auth import credentials
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
logger = logging.getLogger("github")
|
|
9
8
|
|
|
10
9
|
|
|
11
10
|
class IssueNotFound(Exception): ...
|
|
@@ -19,10 +18,9 @@ class STATUSES:
|
|
|
19
18
|
error = "error" # when error during helm deploy
|
|
20
19
|
|
|
21
20
|
|
|
22
|
-
def get_headers() -> dict[str, str]:
|
|
21
|
+
async def get_headers() -> dict[str, str]:
|
|
23
22
|
return {
|
|
24
|
-
|
|
25
|
-
"Content-Type": "application/json",
|
|
23
|
+
**await credentials.headers(),
|
|
26
24
|
"Accept": ("application/vnd.github.flash-preview+json, application/vnd.github.ant-man-preview+json"),
|
|
27
25
|
}
|
|
28
26
|
|
|
@@ -43,7 +41,7 @@ async def update_deployment(deployment_url: str, status: str, description: str,
|
|
|
43
41
|
response = await client.post(
|
|
44
42
|
status_url,
|
|
45
43
|
json=data,
|
|
46
|
-
headers=get_headers(),
|
|
44
|
+
headers=await get_headers(),
|
|
47
45
|
)
|
|
48
46
|
if response.status_code >= 300 and response.status_code != 404:
|
|
49
47
|
try:
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""GitHub credentials: a PAT in GITHUB_OAUTH_TOKEN when set, otherwise an App
|
|
2
|
+
installation token minted from the private key and cached until near expiry.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import jwt
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("github_auth")
|
|
15
|
+
|
|
16
|
+
GITHUB_API = "https://api.github.com"
|
|
17
|
+
# Refresh with this much left, so a slow deploy never loses its token mid-flight.
|
|
18
|
+
MIN_REMAINING_SECONDS = 30 * 60
|
|
19
|
+
# GitHub rejects an App JWT that lives longer than this.
|
|
20
|
+
APP_JWT_TTL_SECONDS = 10 * 60
|
|
21
|
+
GIT_CREDENTIAL_ENV_VAR = "GITHUB_OAUTH_TOKEN"
|
|
22
|
+
|
|
23
|
+
# The credential helper travels with the token rather than living in the image, so
|
|
24
|
+
# it cannot drift from the code that supplies it. Scoped to github.com: an unscoped
|
|
25
|
+
# helper answers for every host git reaches, including across a redirect.
|
|
26
|
+
_CREDENTIAL_HELPER = f'!f() {{ printf "username=x-access-token\\npassword=%s\\n" "${GIT_CREDENTIAL_ENV_VAR}"; }}; f'
|
|
27
|
+
_HELPER_CONFIG = {
|
|
28
|
+
"GIT_CONFIG_COUNT": "2",
|
|
29
|
+
# An empty value resets the helper list. Without it git keeps whatever helper the
|
|
30
|
+
# environment already configures -- macOS ships credential.helper=osxkeychain in
|
|
31
|
+
# the Command Line Tools gitconfig -- and takes that answer instead of ours.
|
|
32
|
+
"GIT_CONFIG_KEY_0": "credential.helper",
|
|
33
|
+
"GIT_CONFIG_VALUE_0": "",
|
|
34
|
+
"GIT_CONFIG_KEY_1": "credential.https://github.com.helper",
|
|
35
|
+
"GIT_CONFIG_VALUE_1": _CREDENTIAL_HELPER,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GitHubCredentials:
|
|
40
|
+
"""Supplies a token for API calls and git clones, refreshing as needed.
|
|
41
|
+
|
|
42
|
+
Reads the environment on use, not at import, so a deployment can be swapped
|
|
43
|
+
between the PAT and the App without a code change or a restart.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._token: str | None = None
|
|
48
|
+
self._expires_at: float = 0.0
|
|
49
|
+
# Without this two callers both find the cache stale and both mint.
|
|
50
|
+
self._lock = asyncio.Lock()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def uses_app(self) -> bool:
|
|
54
|
+
return not os.environ.get("GITHUB_OAUTH_TOKEN", "").strip()
|
|
55
|
+
|
|
56
|
+
def describe(self) -> str:
|
|
57
|
+
if not self.uses_app:
|
|
58
|
+
return "GITHUB_OAUTH_TOKEN (personal access token)"
|
|
59
|
+
return (
|
|
60
|
+
f"GitHub App {os.environ.get('GITHUB_APP_ID', '')} "
|
|
61
|
+
f"installation {os.environ.get('GITHUB_INSTALLATION_ID', '')}"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def _app_jwt(self) -> str:
|
|
65
|
+
now = int(time.time())
|
|
66
|
+
return jwt.encode(
|
|
67
|
+
{"iat": now - 60, "exp": now + APP_JWT_TTL_SECONDS, "iss": os.environ.get("GITHUB_APP_ID", "")},
|
|
68
|
+
os.environ.get("GITHUB_APP_PRIVATE_KEY", ""),
|
|
69
|
+
algorithm="RS256",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _is_fresh(self) -> bool:
|
|
73
|
+
return bool(self._token) and time.time() < (self._expires_at - MIN_REMAINING_SECONDS)
|
|
74
|
+
|
|
75
|
+
async def token(self) -> str:
|
|
76
|
+
if not self.uses_app:
|
|
77
|
+
return os.environ["GITHUB_OAUTH_TOKEN"].strip()
|
|
78
|
+
if self._is_fresh():
|
|
79
|
+
assert self._token is not None
|
|
80
|
+
return self._token
|
|
81
|
+
|
|
82
|
+
async with self._lock:
|
|
83
|
+
# Another caller may have minted while we waited.
|
|
84
|
+
if self._is_fresh():
|
|
85
|
+
assert self._token is not None
|
|
86
|
+
return self._token
|
|
87
|
+
|
|
88
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
89
|
+
response = await client.post(
|
|
90
|
+
f"{GITHUB_API}/app/installations/{os.environ.get('GITHUB_INSTALLATION_ID', '')}/access_tokens",
|
|
91
|
+
headers={
|
|
92
|
+
"Authorization": f"Bearer {self._app_jwt()}",
|
|
93
|
+
"Accept": "application/vnd.github+json",
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
data = response.json()
|
|
98
|
+
|
|
99
|
+
token = str(data["token"])
|
|
100
|
+
self._token = token
|
|
101
|
+
self._expires_at = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")).timestamp()
|
|
102
|
+
logger.info("GitHub installation token refreshed, expires at %s", data["expires_at"])
|
|
103
|
+
return token
|
|
104
|
+
|
|
105
|
+
async def headers(self) -> dict[str, str]:
|
|
106
|
+
return {
|
|
107
|
+
"Authorization": f"Bearer {await self.token()}",
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async def git_env(self) -> dict[str, str]:
|
|
112
|
+
"""Minted per command, so a long-lived process never pins an expiring token."""
|
|
113
|
+
return {GIT_CREDENTIAL_ENV_VAR: await self.token(), **_HELPER_CONFIG}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_credentials() -> None:
|
|
117
|
+
if os.environ.get("GITHUB_OAUTH_TOKEN", "").strip():
|
|
118
|
+
return
|
|
119
|
+
required = ("GITHUB_APP_ID", "GITHUB_INSTALLATION_ID", "GITHUB_APP_PRIVATE_KEY")
|
|
120
|
+
if missing := [name for name in required if not os.environ.get(name)]:
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
"No GitHub credential configured: set GITHUB_OAUTH_TOKEN, or all of "
|
|
123
|
+
f"{', '.join(required)} (missing: {', '.join(missing)})"
|
|
124
|
+
)
|
|
125
|
+
# Signing parses the key, so an unusable one fails here rather than on the
|
|
126
|
+
# first deploy. A revoked key or a wrong id still only surfaces on first use.
|
|
127
|
+
try:
|
|
128
|
+
credentials._app_jwt()
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
raise RuntimeError(f"GITHUB_APP_PRIVATE_KEY cannot sign a JWT: {exc}") from exc
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
credentials = GitHubCredentials()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.9.0"
|
|
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
|
|
|
21
21
|
|
|
22
22
|
async def update_issue_from_deployment_url(app: App, deployment_url: str, **kwargs: Any) -> None:
|
|
23
23
|
async with httpx.AsyncClient() as client:
|
|
24
|
-
headers = github.get_headers()
|
|
24
|
+
headers = await github.get_headers()
|
|
25
25
|
deployment_response = await client.get(deployment_url, headers=headers)
|
|
26
26
|
try:
|
|
27
27
|
sha = deployment_response.json().get("sha", "")
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
[project]
|
|
3
3
|
name = "gitops"
|
|
4
|
-
version = "1.
|
|
4
|
+
version = "1.9.0"
|
|
5
5
|
description = "Manage multiple apps across one or more k8s clusters."
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
readme = "README.md"
|
|
@@ -32,6 +32,8 @@ server = [
|
|
|
32
32
|
"sentry-sdk>=1.3.0",
|
|
33
33
|
"uptick-observability[fastapi]>=0.2.2",
|
|
34
34
|
"slack-sdk>=3.33.5",
|
|
35
|
+
# Signs the JWT exchanged for a GitHub App installation token
|
|
36
|
+
"pyjwt[crypto]>=2.10.0",
|
|
35
37
|
# Transitive security pins
|
|
36
38
|
"h11>=0.16.0",
|
|
37
39
|
"aiohttp>=3.14.0",
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import AsyncMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import gitops_server.utils.git as git
|
|
9
|
+
from gitops_server.utils.github_auth import credentials
|
|
10
|
+
|
|
11
|
+
TOKEN = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" # noqa: S105
|
|
12
|
+
URL = "https://github.com/uptick/uptick-cluster.git"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.asyncio
|
|
16
|
+
@pytest.mark.parametrize("kwargs", [{}, {"sha": "a" * 40}, {"branch": "master"}])
|
|
17
|
+
async def test_clone_command_never_contains_the_token(monkeypatch, kwargs):
|
|
18
|
+
"""Credentials reach git through a helper supplied in the environment, so
|
|
19
|
+
nothing run() logs, raises or returns can carry the token."""
|
|
20
|
+
monkeypatch.setenv("GITHUB_OAUTH_TOKEN", TOKEN)
|
|
21
|
+
monkeypatch.delenv("GIT_CRYPT_KEY_FILE", raising=False)
|
|
22
|
+
with patch.object(git, "run", new=AsyncMock()) as run_mock:
|
|
23
|
+
await git.clone_repo(URL, path="/tmp/x", **kwargs)
|
|
24
|
+
|
|
25
|
+
# run() logs only the command, so only the command must stay clean.
|
|
26
|
+
commands = " ".join(str(call.args) for call in run_mock.call_args_list)
|
|
27
|
+
assert TOKEN not in commands
|
|
28
|
+
assert URL in commands
|
|
29
|
+
|
|
30
|
+
# It arrives the one way that is not logged: the environment.
|
|
31
|
+
# What that environment configures is asserted against real git below.
|
|
32
|
+
for call in run_mock.call_args_list:
|
|
33
|
+
assert call.kwargs["env"]["GITHUB_OAUTH_TOKEN"] == TOKEN
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_local_path_clone_needs_no_credential(monkeypatch):
|
|
38
|
+
"""A local path is not GitHub; asking for a credential would mint one needlessly."""
|
|
39
|
+
monkeypatch.delenv("GITHUB_OAUTH_TOKEN", raising=False)
|
|
40
|
+
monkeypatch.delenv("GIT_CRYPT_KEY_FILE", raising=False)
|
|
41
|
+
with patch.object(git, "run", new=AsyncMock()) as run_mock:
|
|
42
|
+
await git.clone_repo("/tmp/some/local/repo", path="/tmp/x")
|
|
43
|
+
assert all(call.kwargs["env"] is None for call in run_mock.call_args_list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_the_image_holds_no_credential():
|
|
47
|
+
"""The helper ships with the token now, so nothing about it is baked in."""
|
|
48
|
+
dockerfile = (Path(__file__).parent.parent / "Dockerfile").read_text()
|
|
49
|
+
assert "credential" not in dockerfile
|
|
50
|
+
assert "GITHUB_OAUTH_TOKEN" not in dockerfile
|
|
51
|
+
assert "ghp_" not in dockerfile
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_cache_fetch_authenticates(monkeypatch, tmp_path):
|
|
56
|
+
"""The shared repo cache fetches new refs, and that fetch hits the remote."""
|
|
57
|
+
monkeypatch.setenv("GITHUB_OAUTH_TOKEN", TOKEN)
|
|
58
|
+
monkeypatch.setattr(git, "REPO_CACHE_DIR", tmp_path)
|
|
59
|
+
(tmp_path / "uptick-cluster" / ".git").mkdir(parents=True)
|
|
60
|
+
|
|
61
|
+
async def fake_git(repo, *args, **kwargs):
|
|
62
|
+
if args[0] == "cat-file":
|
|
63
|
+
raise Exception("ref not in cache")
|
|
64
|
+
return b""
|
|
65
|
+
|
|
66
|
+
with patch.object(git, "_git", new=AsyncMock(side_effect=fake_git)) as git_mock:
|
|
67
|
+
await git.ensure_repo_cache(URL, ref="a" * 40)
|
|
68
|
+
|
|
69
|
+
fetch = next(c for c in git_mock.call_args_list if c.args[1] == "fetch")
|
|
70
|
+
assert fetch.kwargs["env"]["GITHUB_OAUTH_TOKEN"] == TOKEN
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def git_credential_fill(env: dict[str, str], host: str) -> dict[str, str]:
|
|
74
|
+
"""What real git would use for `host`, given this environment.
|
|
75
|
+
|
|
76
|
+
Asserting on the config strings only proves we wrote what we meant to write.
|
|
77
|
+
This proves git parses it and answers with it.
|
|
78
|
+
"""
|
|
79
|
+
proc = subprocess.run( # noqa: S603
|
|
80
|
+
["git", "credential", "fill"], # noqa: S607
|
|
81
|
+
input=f"protocol=https\nhost={host}\n\n",
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
env={
|
|
85
|
+
**os.environ,
|
|
86
|
+
**env,
|
|
87
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
88
|
+
},
|
|
89
|
+
check=False,
|
|
90
|
+
)
|
|
91
|
+
return dict(line.split("=", 1) for line in proc.stdout.splitlines() if "=" in line)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.asyncio
|
|
95
|
+
async def test_git_accepts_the_helper_we_ship(monkeypatch):
|
|
96
|
+
monkeypatch.setenv("GITHUB_OAUTH_TOKEN", TOKEN)
|
|
97
|
+
answer = git_credential_fill(await credentials.git_env(), "github.com")
|
|
98
|
+
|
|
99
|
+
assert answer["username"] == "x-access-token"
|
|
100
|
+
assert answer["password"] == TOKEN
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_git_offers_the_token_to_no_other_host(monkeypatch):
|
|
105
|
+
"""The helper is keyed on github.com, so a redirect elsewhere gets nothing."""
|
|
106
|
+
monkeypatch.setenv("GITHUB_OAUTH_TOKEN", TOKEN)
|
|
107
|
+
answer = git_credential_fill(await credentials.git_env(), "not-github.example.com")
|
|
108
|
+
|
|
109
|
+
assert answer.get("password") != TOKEN
|