gitops 1.8.0__tar.gz → 1.10.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 (95) hide show
  1. gitops-1.10.0/.release-please-manifest.json +5 -0
  2. {gitops-1.8.0 → gitops-1.10.0}/CHANGELOG.md +33 -0
  3. {gitops-1.8.0 → gitops-1.10.0}/Dockerfile +3 -9
  4. {gitops-1.8.0 → gitops-1.10.0}/PKG-INFO +2 -1
  5. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/CHANGELOG.md +13 -0
  6. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/Chart.yaml +1 -1
  7. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/deployment.yml +5 -0
  8. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/secrets.yml +2 -1
  9. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/values.yaml +5 -0
  10. {gitops-1.8.0 → gitops-1.10.0}/gitops/__init__.py +1 -1
  11. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/app.py +8 -0
  12. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/settings.py +10 -0
  13. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/utils/__init__.py +15 -2
  14. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/utils/git.py +20 -5
  15. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/utils/github.py +5 -7
  16. gitops-1.10.0/gitops_server/utils/github_auth.py +133 -0
  17. gitops-1.10.0/gitops_server/version.py +1 -0
  18. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/workers/deployer/deploy.py +2 -0
  19. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/workers/deployer/hooks.py +1 -1
  20. {gitops-1.8.0 → gitops-1.10.0}/pyproject.toml +3 -1
  21. gitops-1.10.0/tests/test_clone_credentials.py +109 -0
  22. {gitops-1.8.0 → gitops-1.10.0}/tests/test_deploy.py +21 -0
  23. gitops-1.10.0/tests/test_github_auth.py +106 -0
  24. {gitops-1.8.0 → gitops-1.10.0}/uv.lock +173 -17
  25. gitops-1.8.0/.release-please-manifest.json +0 -5
  26. gitops-1.8.0/gitops_server/version.py +0 -1
  27. gitops-1.8.0/tests/test_clone_credentials.py +0 -32
  28. {gitops-1.8.0 → gitops-1.10.0}/.dockerignore +0 -0
  29. {gitops-1.8.0 → gitops-1.10.0}/.github/CODEOWNERS +0 -0
  30. {gitops-1.8.0 → gitops-1.10.0}/.github/workflows/release-please.yaml +0 -0
  31. {gitops-1.8.0 → gitops-1.10.0}/.github/workflows/test.yml +0 -0
  32. {gitops-1.8.0 → gitops-1.10.0}/.gitignore +0 -0
  33. {gitops-1.8.0 → gitops-1.10.0}/.mise.toml +0 -0
  34. {gitops-1.8.0 → gitops-1.10.0}/.pre-commit-config.yaml +0 -0
  35. {gitops-1.8.0 → gitops-1.10.0}/CLAUDE.md +0 -0
  36. {gitops-1.8.0 → gitops-1.10.0}/LICENSE +0 -0
  37. {gitops-1.8.0 → gitops-1.10.0}/README.md +0 -0
  38. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/.helmignore +0 -0
  39. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/README.md +0 -0
  40. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/_helpers.tpl +0 -0
  41. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/certificate.yml +0 -0
  42. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/environment.yml +0 -0
  43. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/ingress.yml +0 -0
  44. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/issuer.yml +0 -0
  45. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/pdb.yml +0 -0
  46. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/service.yml +0 -0
  47. {gitops-1.8.0 → gitops-1.10.0}/charts/gitops/templates/serviceaccount.yaml +0 -0
  48. {gitops-1.8.0 → gitops-1.10.0}/docker-compose.yml +0 -0
  49. {gitops-1.8.0 → gitops-1.10.0}/docs/plans/2026-03-13-kms-encryption-design.md +0 -0
  50. {gitops-1.8.0 → gitops-1.10.0}/gitops/common/__init__.py +0 -0
  51. {gitops-1.8.0 → gitops-1.10.0}/gitops/common/app.py +0 -0
  52. {gitops-1.8.0 → gitops-1.10.0}/gitops/common/utils.py +0 -0
  53. {gitops-1.8.0 → gitops-1.10.0}/gitops/core.py +0 -0
  54. {gitops-1.8.0 → gitops-1.10.0}/gitops/main.py +0 -0
  55. {gitops-1.8.0 → gitops-1.10.0}/gitops/py.typed +0 -0
  56. {gitops-1.8.0 → gitops-1.10.0}/gitops/settings.py +0 -0
  57. {gitops-1.8.0 → gitops-1.10.0}/gitops/shorthands.py +0 -0
  58. {gitops-1.8.0 → gitops-1.10.0}/gitops/utils/__init__.py +0 -0
  59. {gitops-1.8.0 → gitops-1.10.0}/gitops/utils/apps.py +0 -0
  60. {gitops-1.8.0 → gitops-1.10.0}/gitops/utils/async_runner.py +0 -0
  61. {gitops-1.8.0 → gitops-1.10.0}/gitops/utils/cli.py +0 -0
  62. {gitops-1.8.0 → gitops-1.10.0}/gitops/utils/exceptions.py +0 -0
  63. {gitops-1.8.0 → gitops-1.10.0}/gitops/utils/images.py +0 -0
  64. {gitops-1.8.0 → gitops-1.10.0}/gitops/utils/kube.py +0 -0
  65. {gitops-1.8.0 → gitops-1.10.0}/gitops/utils/tags.py +0 -0
  66. {gitops-1.8.0 → gitops-1.10.0}/gitops/utils/yaml.py +0 -0
  67. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/CHANGELOG.md +0 -0
  68. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/README.md +0 -0
  69. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/__init__.py +0 -0
  70. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/main.py +0 -0
  71. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/types.py +0 -0
  72. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/utils/slack.py +0 -0
  73. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/workers/__init__.py +0 -0
  74. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/workers/deployer/__init__.py +0 -0
  75. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/workers/deployer/semaphore_manager.py +0 -0
  76. {gitops-1.8.0 → gitops-1.10.0}/gitops_server/workers/deployer/worker.py +0 -0
  77. {gitops-1.8.0 → gitops-1.10.0}/release-please-config.json +0 -0
  78. {gitops-1.8.0 → gitops-1.10.0}/release.py +0 -0
  79. {gitops-1.8.0 → gitops-1.10.0}/renovate.json +0 -0
  80. {gitops-1.8.0 → gitops-1.10.0}/ruff.toml +0 -0
  81. {gitops-1.8.0 → gitops-1.10.0}/secrets.example.env +0 -0
  82. {gitops-1.8.0 → gitops-1.10.0}/setup.cfg +0 -0
  83. {gitops-1.8.0 → gitops-1.10.0}/tests/__init__.py +0 -0
  84. {gitops-1.8.0 → gitops-1.10.0}/tests/conftest.py +0 -0
  85. {gitops-1.8.0 → gitops-1.10.0}/tests/sample_data.py +0 -0
  86. {gitops-1.8.0 → gitops-1.10.0}/tests/test_app.py +0 -0
  87. {gitops-1.8.0 → gitops-1.10.0}/tests/test_core.py +0 -0
  88. {gitops-1.8.0 → gitops-1.10.0}/tests/test_deploy_concurrent.py +0 -0
  89. {gitops-1.8.0 → gitops-1.10.0}/tests/test_git.py +0 -0
  90. {gitops-1.8.0 → gitops-1.10.0}/tests/test_gitops_server.py +0 -0
  91. {gitops-1.8.0 → gitops-1.10.0}/tests/test_load_app_definitions.py +0 -0
  92. {gitops-1.8.0 → gitops-1.10.0}/tests/test_utils.py +0 -0
  93. {gitops-1.8.0 → gitops-1.10.0}/tests/test_worker_concurrency.py +0 -0
  94. {gitops-1.8.0 → gitops-1.10.0}/tests/utils.py +0 -0
  95. {gitops-1.8.0 → gitops-1.10.0}/tests/webhook_sample_data.py +0 -0
@@ -0,0 +1,5 @@
1
+ {
2
+ ".": "1.10.0",
3
+ "gitops_server": "0.15.1",
4
+ "charts/gitops": "1.3.0"
5
+ }
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.0](https://github.com/uptick/gitops/compare/gitops-v1.9.0...gitops-v1.10.0) (2026-09-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * **deploy:** setting to skip helm's client-side OpenAPI validation ([3671513](https://github.com/uptick/gitops/commit/36715132c8e04efeb3b7f389595f6dc49c80c3f6))
9
+ * helm 3.22.0, and a setting to skip its OpenAPI validation ([d66fe4a](https://github.com/uptick/gitops/commit/d66fe4a6362498ec7d85e19b57dbae5f6d8ddd6c))
10
+ * **image:** helm 3.22.0, with helm-secrets 4.7.7 to match ([2d871bf](https://github.com/uptick/gitops/commit/2d871bfefe85d1f25e2748519cea0155e7f499bd))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * upgrade urllib3 to 2.8.0 and anyio to the patched release ([dcd9699](https://github.com/uptick/gitops/commit/dcd96990444176abdddb0e70b8db8e5c0c8d55b1))
16
+ * upgrade urllib3 to 2.8.0 and anyio to the patched release ([14bad7c](https://github.com/uptick/gitops/commit/14bad7c39c6743ff20054c3101ae5802eac8fc9c))
17
+
18
+ ## [1.9.0](https://github.com/uptick/gitops/compare/gitops-v1.8.0...gitops-v1.9.0) (2026-09-09)
19
+
20
+
21
+ ### Features
22
+
23
+ * authenticate to GitHub as an App ([51ae678](https://github.com/uptick/gitops/commit/51ae678a874cf205b0f612404426c7444611f96c))
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * authenticate the repo-cache fetch, and tighten the mint ([3c1100b](https://github.com/uptick/gitops/commit/3c1100b936a3fb83eef28d3ed2732d0afaf8d607))
29
+ * scope the credential helper to github.com ([7068133](https://github.com/uptick/gitops/commit/7068133b1a3796ffa85de191549097d2ad724f6d))
30
+
31
+
32
+ ### Documentation
33
+
34
+ * cut comments back to what is not derivable from the code ([4d41784](https://github.com/uptick/gitops/commit/4d4178478da4efa37b924e3cab3da430e8ec68bd))
35
+
3
36
  ## [1.8.0](https://github.com/uptick/gitops/compare/gitops-v1.7.1...gitops-v1.8.0) (2026-08-21)
4
37
 
5
38
 
@@ -14,7 +14,7 @@ ARG TARGETARCH
14
14
  ## Install kubectl and dependencies.
15
15
  ##
16
16
  ENV KUBE_LATEST_VERSION="v1.21.3"
17
- ENV HELM_VERSION="v3.6.2" \
17
+ ENV HELM_VERSION="v3.22.0" \
18
18
  VIRTUAL_ENV="/app/.venv" \
19
19
  PATH="/app/.venv/bin:$PATH"
20
20
 
@@ -30,7 +30,7 @@ RUN apt-get update \
30
30
  && chmod +x /usr/local/bin/helm \
31
31
  \
32
32
  # Install helm-secrets plugin
33
- && helm plugin install https://github.com/jkroepke/helm-secrets --version v4.2.2 \
33
+ && helm plugin install https://github.com/jkroepke/helm-secrets --version v4.7.7 \
34
34
  \
35
35
  # Download sops
36
36
  && wget -q https://github.com/mozilla/sops/releases/download/v3.7.3/sops-v3.7.3.linux.${TARGETARCH} -O /usr/local/bin/sops \
@@ -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
- # 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'
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.8.0
3
+ Version: 1.10.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
 
@@ -2,4 +2,4 @@ apiVersion: v1
2
2
  appVersion: "1.0"
3
3
  description: GitOps Server Helm chart.
4
4
  name: gitops
5
- version: 1.2.2
5
+ version: 1.3.0
@@ -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: ""
@@ -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.8.0"
9
+ __version__ = "1.10.0"
10
10
 
11
11
 
12
12
  # Checking gitops version matches cluster repo version.
@@ -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)
@@ -21,3 +21,13 @@ GITOPS_MAX_PARALLEL_DEPLOYS = os.environ.get("GITOPS_MAX_PARALLEL_DEPLOYS", "5")
21
21
  # two commits before it reaches GITOPS_MAX_PARALLEL_DEPLOYS, so without a bound
22
22
  # here a burst of pushes is only limited by how fast they arrive.
23
23
  GITOPS_MAX_CONCURRENT_DEPLOYMENTS = os.environ.get("GITOPS_MAX_CONCURRENT_DEPLOYMENTS", "20")
24
+
25
+ # Pass --disable-openapi-validation to helm. Off by default: helm keeps checking
26
+ # manifests against the cluster schema before applying them, which costs each
27
+ # helm process a download and parse of that schema. The API server still
28
+ # rejects wrong types and missing fields either way.
29
+ GITOPS_DISABLE_OPENAPI_VALIDATION = os.environ.get("GITOPS_DISABLE_OPENAPI_VALIDATION", "").lower() in (
30
+ "1",
31
+ "true",
32
+ "yes",
33
+ )
@@ -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(command: str, suppress_errors: bool = False, cwd: str | Path | None = None) -> RunOutput:
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, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=cwd
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 _git(str(cache_path), "fetch", "--quiet", "origin")
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
- logger = logging.getLogger("github")
5
+ from gitops_server.utils.github_auth import credentials
7
6
 
8
- GITHUB_OAUTH_TOKEN = os.environ.get("GITHUB_OAUTH_TOKEN")
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
- "Authorization": f"token {GITHUB_OAUTH_TOKEN}",
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.10.0"
@@ -280,6 +280,7 @@ class Deployer:
280
280
  f" --history-max {MAX_HELM_HISTORY}"
281
281
  " --install"
282
282
  " --timeout=600s"
283
+ f"{' --disable-openapi-validation' if settings.GITOPS_DISABLE_OPENAPI_VALIDATION else ''}"
283
284
  f"{' --set skip_migrations=true' if self.skip_migrations else ''}"
284
285
  f" -f {cfg.name}"
285
286
  f" --namespace={app.namespace}"
@@ -312,6 +313,7 @@ class Deployer:
312
313
  f" --history-max {MAX_HELM_HISTORY}"
313
314
  " --install"
314
315
  " --timeout=600s"
316
+ f"{' --disable-openapi-validation' if settings.GITOPS_DISABLE_OPENAPI_VALIDATION else ''}"
315
317
  f"{' --set skip_migrations=true' if self.skip_migrations else ''}"
316
318
  f" -f {cfg.name}"
317
319
  f" --namespace={app.namespace}"
@@ -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.8.0"
4
+ version = "1.10.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