gitops 1.10.2__tar.gz → 1.12.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 (97) hide show
  1. gitops-1.12.0/.release-please-manifest.json +5 -0
  2. {gitops-1.10.2 → gitops-1.12.0}/CHANGELOG.md +28 -0
  3. {gitops-1.10.2 → gitops-1.12.0}/PKG-INFO +3 -1
  4. {gitops-1.10.2 → gitops-1.12.0}/README.md +2 -0
  5. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/CHANGELOG.md +16 -0
  6. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/Chart.yaml +1 -1
  7. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/deployment.yml +11 -0
  8. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/values.yaml +2 -0
  9. {gitops-1.10.2 → gitops-1.12.0}/gitops/__init__.py +1 -1
  10. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/app.py +15 -3
  11. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/settings.py +16 -0
  12. gitops-1.12.0/gitops_server/version.py +1 -0
  13. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/workers/deployer/deploy.py +14 -0
  14. gitops-1.12.0/gitops_server/workers/deployer/disruption.py +97 -0
  15. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/workers/deployer/worker.py +26 -1
  16. {gitops-1.10.2 → gitops-1.12.0}/pyproject.toml +1 -1
  17. gitops-1.12.0/tests/test_disruption_guard.py +114 -0
  18. gitops-1.12.0/tests/test_shutdown.py +78 -0
  19. {gitops-1.10.2 → gitops-1.12.0}/uv.lock +10 -10
  20. gitops-1.10.2/.release-please-manifest.json +0 -5
  21. gitops-1.10.2/gitops_server/version.py +0 -1
  22. {gitops-1.10.2 → gitops-1.12.0}/.dockerignore +0 -0
  23. {gitops-1.10.2 → gitops-1.12.0}/.github/CODEOWNERS +0 -0
  24. {gitops-1.10.2 → gitops-1.12.0}/.github/workflows/release-please.yaml +0 -0
  25. {gitops-1.10.2 → gitops-1.12.0}/.github/workflows/test.yml +0 -0
  26. {gitops-1.10.2 → gitops-1.12.0}/.gitignore +0 -0
  27. {gitops-1.10.2 → gitops-1.12.0}/.mise.toml +0 -0
  28. {gitops-1.10.2 → gitops-1.12.0}/.pre-commit-config.yaml +0 -0
  29. {gitops-1.10.2 → gitops-1.12.0}/CLAUDE.md +0 -0
  30. {gitops-1.10.2 → gitops-1.12.0}/Dockerfile +0 -0
  31. {gitops-1.10.2 → gitops-1.12.0}/LICENSE +0 -0
  32. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/.helmignore +0 -0
  33. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/README.md +0 -0
  34. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/_helpers.tpl +0 -0
  35. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/certificate.yml +0 -0
  36. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/environment.yml +0 -0
  37. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/ingress.yml +0 -0
  38. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/issuer.yml +0 -0
  39. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/pdb.yml +0 -0
  40. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/secrets.yml +0 -0
  41. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/service.yml +0 -0
  42. {gitops-1.10.2 → gitops-1.12.0}/charts/gitops/templates/serviceaccount.yaml +0 -0
  43. {gitops-1.10.2 → gitops-1.12.0}/docker-compose.yml +0 -0
  44. {gitops-1.10.2 → gitops-1.12.0}/docs/plans/2026-03-13-kms-encryption-design.md +0 -0
  45. {gitops-1.10.2 → gitops-1.12.0}/gitops/common/__init__.py +0 -0
  46. {gitops-1.10.2 → gitops-1.12.0}/gitops/common/app.py +0 -0
  47. {gitops-1.10.2 → gitops-1.12.0}/gitops/common/utils.py +0 -0
  48. {gitops-1.10.2 → gitops-1.12.0}/gitops/core.py +0 -0
  49. {gitops-1.10.2 → gitops-1.12.0}/gitops/main.py +0 -0
  50. {gitops-1.10.2 → gitops-1.12.0}/gitops/py.typed +0 -0
  51. {gitops-1.10.2 → gitops-1.12.0}/gitops/settings.py +0 -0
  52. {gitops-1.10.2 → gitops-1.12.0}/gitops/shorthands.py +0 -0
  53. {gitops-1.10.2 → gitops-1.12.0}/gitops/utils/__init__.py +0 -0
  54. {gitops-1.10.2 → gitops-1.12.0}/gitops/utils/apps.py +0 -0
  55. {gitops-1.10.2 → gitops-1.12.0}/gitops/utils/async_runner.py +0 -0
  56. {gitops-1.10.2 → gitops-1.12.0}/gitops/utils/cli.py +0 -0
  57. {gitops-1.10.2 → gitops-1.12.0}/gitops/utils/exceptions.py +0 -0
  58. {gitops-1.10.2 → gitops-1.12.0}/gitops/utils/images.py +0 -0
  59. {gitops-1.10.2 → gitops-1.12.0}/gitops/utils/kube.py +0 -0
  60. {gitops-1.10.2 → gitops-1.12.0}/gitops/utils/tags.py +0 -0
  61. {gitops-1.10.2 → gitops-1.12.0}/gitops/utils/yaml.py +0 -0
  62. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/CHANGELOG.md +0 -0
  63. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/README.md +0 -0
  64. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/__init__.py +0 -0
  65. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/main.py +0 -0
  66. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/types.py +0 -0
  67. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/utils/__init__.py +0 -0
  68. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/utils/git.py +0 -0
  69. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/utils/github.py +0 -0
  70. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/utils/github_auth.py +0 -0
  71. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/utils/slack.py +0 -0
  72. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/workers/__init__.py +0 -0
  73. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/workers/deployer/__init__.py +0 -0
  74. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/workers/deployer/hooks.py +0 -0
  75. {gitops-1.10.2 → gitops-1.12.0}/gitops_server/workers/deployer/semaphore_manager.py +0 -0
  76. {gitops-1.10.2 → gitops-1.12.0}/release-please-config.json +0 -0
  77. {gitops-1.10.2 → gitops-1.12.0}/release.py +0 -0
  78. {gitops-1.10.2 → gitops-1.12.0}/renovate.json +0 -0
  79. {gitops-1.10.2 → gitops-1.12.0}/ruff.toml +0 -0
  80. {gitops-1.10.2 → gitops-1.12.0}/secrets.example.env +0 -0
  81. {gitops-1.10.2 → gitops-1.12.0}/setup.cfg +0 -0
  82. {gitops-1.10.2 → gitops-1.12.0}/tests/__init__.py +0 -0
  83. {gitops-1.10.2 → gitops-1.12.0}/tests/conftest.py +0 -0
  84. {gitops-1.10.2 → gitops-1.12.0}/tests/sample_data.py +0 -0
  85. {gitops-1.10.2 → gitops-1.12.0}/tests/test_app.py +0 -0
  86. {gitops-1.10.2 → gitops-1.12.0}/tests/test_clone_credentials.py +0 -0
  87. {gitops-1.10.2 → gitops-1.12.0}/tests/test_core.py +0 -0
  88. {gitops-1.10.2 → gitops-1.12.0}/tests/test_deploy.py +0 -0
  89. {gitops-1.10.2 → gitops-1.12.0}/tests/test_deploy_concurrent.py +0 -0
  90. {gitops-1.10.2 → gitops-1.12.0}/tests/test_git.py +0 -0
  91. {gitops-1.10.2 → gitops-1.12.0}/tests/test_github_auth.py +0 -0
  92. {gitops-1.10.2 → gitops-1.12.0}/tests/test_gitops_server.py +0 -0
  93. {gitops-1.10.2 → gitops-1.12.0}/tests/test_load_app_definitions.py +0 -0
  94. {gitops-1.10.2 → gitops-1.12.0}/tests/test_utils.py +0 -0
  95. {gitops-1.10.2 → gitops-1.12.0}/tests/test_worker_concurrency.py +0 -0
  96. {gitops-1.10.2 → gitops-1.12.0}/tests/utils.py +0 -0
  97. {gitops-1.10.2 → gitops-1.12.0}/tests/webhook_sample_data.py +0 -0
@@ -0,0 +1,5 @@
1
+ {
2
+ ".": "1.12.0",
3
+ "gitops_server": "0.15.1",
4
+ "charts/gitops": "1.6.0"
5
+ }
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.12.0](https://github.com/uptick/gitops/compare/gitops-v1.11.0...gitops-v1.12.0) (2026-11-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **server:** finish running helm upgrades on shutdown ([92a7952](https://github.com/uptick/gitops/commit/92a7952f91b90543ed573d8c96d8853ff8f7caf5))
9
+ * **server:** finish running helm upgrades on shutdown ([ef16fd2](https://github.com/uptick/gitops/commit/ef16fd21ae53cba6987aecc4455ffdf6b254f4e1))
10
+ * **server:** report work dropped at shutdown, at ERROR by default ([eb2717b](https://github.com/uptick/gitops/commit/eb2717b3ead89abdcffe37e0b14cfd9688ed9994))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **deps:** upgrade botocore to 1.43.117 ([7d1e151](https://github.com/uptick/gitops/commit/7d1e1517770c5f7ffa66a883a6e860852405c011))
16
+ * **deps:** upgrade botocore to 1.43.117 ([77e7bcf](https://github.com/uptick/gitops/commit/77e7bcfbc1d7f46124d486f67cb35a9d3ac18467))
17
+
18
+ ## [1.11.0](https://github.com/uptick/gitops/compare/gitops-v1.10.2...gitops-v1.11.0) (2026-10-27)
19
+
20
+
21
+ ### Features
22
+
23
+ * **server:** hold karpenter.sh/do-not-disrupt while deploying ([b977e5f](https://github.com/uptick/gitops/commit/b977e5f949b237c5b458f6b9e3029f02592e55a8))
24
+ * **server:** hold karpenter.sh/do-not-disrupt while deploying ([c14359f](https://github.com/uptick/gitops/commit/c14359f39ba354432378bdb2ad9963e442fc9f66))
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * **server:** clear a leftover do-not-disrupt annotation on startup ([7c53772](https://github.com/uptick/gitops/commit/7c53772e40aa94a22c9268d926a2ca8f9122f607))
30
+
3
31
  ## [1.10.2](https://github.com/uptick/gitops/compare/gitops-v1.10.1...gitops-v1.10.2) (2026-10-22)
4
32
 
5
33
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitops
3
- Version: 1.10.2
3
+ Version: 1.12.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
@@ -106,6 +106,8 @@ subjects:
106
106
  namespace: gitops
107
107
  ```
108
108
 
109
+ It also needs `patch` on pods in its own namespace, to set `karpenter.sh/do-not-disrupt` on itself while deploying. The `admin` or `edit` ClusterRole covers this.
110
+
109
111
  ## Contribution
110
112
  We're using [releaseplease](https://github.com/googleapis/release-please), to publish a new version do the following:
111
113
  1. Checkout a feature branch and make the changes
@@ -73,6 +73,8 @@ subjects:
73
73
  namespace: gitops
74
74
  ```
75
75
 
76
+ It also needs `patch` on pods in its own namespace, to set `karpenter.sh/do-not-disrupt` on itself while deploying. The `admin` or `edit` ClusterRole covers this.
77
+
76
78
  ## Contribution
77
79
  We're using [releaseplease](https://github.com/googleapis/release-please), to publish a new version do the following:
78
80
  1. Checkout a feature branch and make the changes
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0](https://github.com/uptick/gitops/compare/helm-v1.5.0...helm-v1.6.0) (2026-11-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **server:** finish running helm upgrades on shutdown ([92a7952](https://github.com/uptick/gitops/commit/92a7952f91b90543ed573d8c96d8853ff8f7caf5))
9
+ * **server:** finish running helm upgrades on shutdown ([ef16fd2](https://github.com/uptick/gitops/commit/ef16fd21ae53cba6987aecc4455ffdf6b254f4e1))
10
+
11
+ ## [1.5.0](https://github.com/uptick/gitops/compare/helm-v1.4.1...helm-v1.5.0) (2026-10-27)
12
+
13
+
14
+ ### Features
15
+
16
+ * **server:** hold karpenter.sh/do-not-disrupt while deploying ([b977e5f](https://github.com/uptick/gitops/commit/b977e5f949b237c5b458f6b9e3029f02592e55a8))
17
+ * **server:** hold karpenter.sh/do-not-disrupt while deploying ([c14359f](https://github.com/uptick/gitops/commit/c14359f39ba354432378bdb2ad9963e442fc9f66))
18
+
3
19
  ## [1.4.1](https://github.com/uptick/gitops/compare/helm-v1.4.0...helm-v1.4.1) (2026-10-21)
4
20
 
5
21
 
@@ -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.4.1
5
+ version: 1.6.0
@@ -24,6 +24,8 @@ spec:
24
24
  spec:
25
25
  serviceAccountName: {{ include "uptick.serviceAccountName" . }}
26
26
  priorityClassName: system-node-critical
27
+ # Must exceed GITOPS_SHUTDOWN_TIMEOUT_SECONDS, or running helm upgrades are killed.
28
+ terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }}
27
29
  containers:
28
30
  - name: gitops
29
31
  image: {{ .Values.image }}
@@ -78,6 +80,15 @@ spec:
78
80
  valueFrom:
79
81
  fieldRef:
80
82
  fieldPath: status.hostIP
83
+ # The server annotates its own pod while deploying.
84
+ - name: POD_NAME
85
+ valueFrom:
86
+ fieldRef:
87
+ fieldPath: metadata.name
88
+ - name: POD_NAMESPACE
89
+ valueFrom:
90
+ fieldRef:
91
+ fieldPath: metadata.namespace
81
92
  - name: OTEL_EXPORTER_OTLP_ENDPOINT
82
93
  value: "http://$(NODE_IP):4317"
83
94
  - name: OTEL_PYTHON_LOG_CORRELATION
@@ -44,6 +44,8 @@ podDisruptionBudget: {}
44
44
  resources:
45
45
  requests:
46
46
  memory: 750Mi
47
+
48
+ terminationGracePeriodSeconds: 720
47
49
  serviceAccount:
48
50
  # Specifies whether a service account should be created
49
51
  create: true
@@ -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.10.2"
9
+ __version__ = "1.12.0"
10
10
 
11
11
 
12
12
  # Checking gitops version matches cluster repo version.
@@ -18,6 +18,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
18
18
  """Manage application lifespan - start and stop background workers."""
19
19
  from .utils.github_auth import credentials, validate_credentials
20
20
  from .workers import DeployQueueWorker
21
+ from .workers.deployer.disruption import build_disruption_guard
21
22
 
22
23
  # Fail here rather than on the first push, where hooks.py only logs a warning.
23
24
  validate_credentials()
@@ -25,11 +26,22 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
25
26
 
26
27
  loop = asyncio.get_running_loop()
27
28
  deploy_queue_worker = DeployQueueWorker.get_worker()
28
- deploy_queue_worker_task = asyncio.ensure_future(deploy_queue_worker.run(), loop=loop)
29
+ worker_task = asyncio.ensure_future(deploy_queue_worker.run(), loop=loop)
30
+ guard_task = None
31
+ if guard := build_disruption_guard(deploy_queue_worker.is_busy):
32
+ deploy_queue_worker.on_activity = guard.poke
33
+ guard_task = asyncio.ensure_future(guard.run(), loop=loop)
29
34
  yield
30
- deploy_queue_worker_task.cancel()
35
+ await _cancel(worker_task)
36
+ await deploy_queue_worker.drain(float(settings.GITOPS_SHUTDOWN_TIMEOUT_SECONDS))
37
+ if guard_task:
38
+ await _cancel(guard_task)
39
+
40
+
41
+ async def _cancel(task: asyncio.Future[None]) -> None:
42
+ task.cancel()
31
43
  try:
32
- await deploy_queue_worker_task
44
+ await task
33
45
  except asyncio.CancelledError:
34
46
  pass
35
47
 
@@ -22,6 +22,22 @@ GITOPS_MAX_PARALLEL_DEPLOYS = os.environ.get("GITOPS_MAX_PARALLEL_DEPLOYS", "5")
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
24
 
25
+ # Set by the chart from the downward API. Empty outside Kubernetes, which turns
26
+ # off managing karpenter.sh/do-not-disrupt.
27
+ POD_NAME = os.environ.get("POD_NAME", "")
28
+ POD_NAMESPACE = os.environ.get("POD_NAMESPACE", "")
29
+
30
+ # Report to Sentry once karpenter.sh/do-not-disrupt has been held this long without a break.
31
+ GITOPS_DO_NOT_DISRUPT_WARN_SECONDS = os.environ.get("GITOPS_DO_NOT_DISRUPT_WARN_SECONDS", "10800")
32
+
33
+ # How long shutdown waits for running helm upgrades. Covers helm's 600s --timeout;
34
+ # keep it below the chart's terminationGracePeriodSeconds.
35
+ GITOPS_SHUTDOWN_TIMEOUT_SECONDS = os.environ.get("GITOPS_SHUTDOWN_TIMEOUT_SECONDS", "660")
36
+
37
+ # Log level for work shutdown drops: skipped apps, queued pushes, upgrades still running.
38
+ # ERROR and above reach Sentry.
39
+ GITOPS_SHUTDOWN_DROPPED_LOG_LEVEL = os.environ.get("GITOPS_SHUTDOWN_DROPPED_LOG_LEVEL", "ERROR")
40
+
25
41
  # Pass --disable-openapi-validation to helm. Off by default: helm keeps checking
26
42
  # manifests against the cluster schema before applying them, which costs each
27
43
  # helm process a download and parse of that schema. The API server still
@@ -0,0 +1 @@
1
+ __version__ = "1.12.0"
@@ -33,6 +33,11 @@ MAX_HELM_HISTORY = 3
33
33
  # Kube api may rate limit otherwise
34
34
  helm_parallel_semaphore = asyncio.Semaphore(int(settings.GITOPS_MAX_PARALLEL_DEPLOYS))
35
35
 
36
+ # Set on SIGTERM. Apps that haven't started their helm upgrade are skipped, so the
37
+ # drain waits for at most one upgrade rather than a whole push.
38
+ shutting_down = asyncio.Event()
39
+ DROPPED_LOG_LEVEL = logging.getLevelNamesMapping()[settings.GITOPS_SHUTDOWN_DROPPED_LOG_LEVEL.upper()]
40
+
36
41
 
37
42
  @tracer.start_as_current_span("post_result_summary")
38
43
  async def post_result_summary(source: str, results: list[UpdateAppResult]) -> None:
@@ -138,6 +143,7 @@ class Deployer:
138
143
  # Track which ones we've deployed and which ones failed
139
144
  self.successful_apps: set[str] = set()
140
145
  self.failed_apps: set[str] = set()
146
+ self.skipped_on_shutdown: list[str] = []
141
147
 
142
148
  self.post_ts: str | None = None
143
149
  self.skip_deploy = "--skip-deploy" in commit_message
@@ -195,6 +201,11 @@ class Deployer:
195
201
  *[self.uninstall_app(self.previous_app_definitions.apps[app_name]) for app_name in self.removed_apps]
196
202
  )
197
203
  without_nones = [r for r in update_results + uninstall_results if r is not None]
204
+ if self.skipped_on_shutdown:
205
+ # Not posting the result summary: skipped apps would be missing from the counts.
206
+ # Args, not an f-string, so Sentry groups these into one issue.
207
+ logger.log(DROPPED_LOG_LEVEL, "Shutdown skipped apps: %s", sorted(self.skipped_on_shutdown))
208
+ return
198
209
  await post_result_summary(self.current_app_definitions.name, without_nones)
199
210
 
200
211
  async def uninstall_app(self, app: App) -> UpdateAppResult:
@@ -232,6 +243,9 @@ class Deployer:
232
243
  from_timestamp = time.time()
233
244
  async with self.semaphore_manager.app_semaphore(app.name):
234
245
  async with helm_parallel_semaphore:
246
+ if shutting_down.is_set():
247
+ self.skipped_on_shutdown.append(app.name)
248
+ return None
235
249
  try:
236
250
  return await self._update_app_deployment(app)
237
251
  except Exception as e:
@@ -0,0 +1,97 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ from kubernetes_asyncio import client, config # type: ignore[import-untyped]
7
+
8
+ from gitops_server import settings
9
+
10
+ logger = logging.getLogger("gitops_worker")
11
+
12
+ ANNOTATION = "karpenter.sh/do-not-disrupt"
13
+ RECONCILE_INTERVAL_SECONDS = 30
14
+
15
+ Patcher = Callable[[bool], Awaitable[None]]
16
+
17
+
18
+ class DisruptionGuard:
19
+ """Holds `karpenter.sh/do-not-disrupt` on this pod while deploys are in flight.
20
+
21
+ Karpenter won't drain the node for consolidation or drift while the annotation is
22
+ set, so a release isn't cut off mid helm upgrade.
23
+ """
24
+
25
+ def __init__(self, is_busy: Callable[[], bool], patch: Patcher, interval: float, warn_after: float) -> None:
26
+ self.is_busy = is_busy
27
+ self.patch = patch
28
+ self.interval = interval
29
+ self.warn_after = warn_after
30
+ # Unknown until the first patch: a container restart keeps the pod and any annotation on it.
31
+ self.applied: bool | None = None
32
+ self.busy_since: float | None = None
33
+ self.warned = False
34
+ self._changed = asyncio.Event()
35
+
36
+ def poke(self) -> None:
37
+ """Reconcile now instead of at the next interval."""
38
+ self._changed.set()
39
+
40
+ def check_hold_time(self, now: float) -> None:
41
+ if not self.is_busy():
42
+ self.busy_since = None
43
+ self.warned = False
44
+ return
45
+ if self.busy_since is None:
46
+ self.busy_since = now
47
+ # Error level so Sentry raises it: a stuck annotation pins the node.
48
+ if not self.warned and now - self.busy_since >= self.warn_after:
49
+ logger.error(f"{ANNOTATION} has been held for over {self.warn_after:.0f}s of continuous deploys.")
50
+ self.warned = True
51
+
52
+ async def reconcile(self) -> None:
53
+ self.check_hold_time(time.monotonic())
54
+ want = self.is_busy()
55
+ if want == self.applied:
56
+ return
57
+ try:
58
+ await self.patch(want)
59
+ except Exception:
60
+ # `applied` stays as it was, so the next pass retries.
61
+ logger.exception(f"Failed to {'set' if want else 'clear'} {ANNOTATION}.")
62
+ return
63
+ self.applied = want
64
+
65
+ async def run(self) -> None:
66
+ while True:
67
+ self._changed.clear()
68
+ await self.reconcile()
69
+ try:
70
+ await asyncio.wait_for(self._changed.wait(), timeout=self.interval)
71
+ except TimeoutError:
72
+ pass
73
+
74
+
75
+ def pod_annotation_patcher(pod_name: str, namespace: str) -> Patcher:
76
+ config.load_incluster_config()
77
+ api = client.CoreV1Api(client.ApiClient())
78
+
79
+ async def patch(hold: bool) -> None:
80
+ # In a merge patch, null deletes the key.
81
+ body = {"metadata": {"annotations": {ANNOTATION: "true" if hold else None}}}
82
+ await api.patch_namespaced_pod(pod_name, namespace, body, _content_type="application/merge-patch+json")
83
+ logger.info(f"{'Set' if hold else 'Cleared'} {ANNOTATION} on pod {pod_name!r}.")
84
+
85
+ return patch
86
+
87
+
88
+ def build_disruption_guard(is_busy: Callable[[], bool]) -> DisruptionGuard | None:
89
+ if not (settings.POD_NAME and settings.POD_NAMESPACE):
90
+ logger.info(f"POD_NAME or POD_NAMESPACE unset; not managing {ANNOTATION}.")
91
+ return None
92
+ return DisruptionGuard(
93
+ is_busy,
94
+ pod_annotation_patcher(settings.POD_NAME, settings.POD_NAMESPACE),
95
+ interval=RECONCILE_INTERVAL_SECONDS,
96
+ warn_after=float(settings.GITOPS_DO_NOT_DISRUPT_WARN_SECONDS),
97
+ )
@@ -1,11 +1,12 @@
1
1
  import asyncio
2
2
  import logging
3
+ from collections.abc import Callable
3
4
  from typing import Any
4
5
 
5
6
  from opentelemetry import trace
6
7
 
7
8
  from ... import settings
8
- from .deploy import Deployer
9
+ from .deploy import DROPPED_LOG_LEVEL, Deployer, shutting_down
9
10
  from .semaphore_manager import AppSemaphoreManager
10
11
 
11
12
  logger = logging.getLogger("gitops_worker")
@@ -38,6 +39,12 @@ class DeployQueueWorker:
38
39
  # gates the helm stage. Everything before it -- loading app definitions
39
40
  # for two commits -- was previously limited only by webhook arrival rate.
40
41
  self.deployment_semaphore = asyncio.Semaphore(int(settings.GITOPS_MAX_CONCURRENT_DEPLOYMENTS))
42
+ # Called when work is queued or a deployment finishes.
43
+ self.on_activity: Callable[[], None] = lambda: None
44
+
45
+ def is_busy(self) -> bool:
46
+ # active_deployments is only pruned on the next dequeue, so it can hold finished tasks.
47
+ return self.queue.qsize() > 0 or any(not task.done() for task in self.active_deployments)
41
48
 
42
49
  async def enqueue(self, work: Any) -> None:
43
50
  """Enqueue an item of work for future processing.
@@ -46,6 +53,7 @@ class DeployQueueWorker:
46
53
  """
47
54
  logger.info(f"Enqueued work, {self.queue.qsize() + 1} items in the queue.")
48
55
  await self.queue.put(work)
56
+ self.on_activity()
49
57
 
50
58
  async def run(self) -> None:
51
59
  """Run the worker.
@@ -83,6 +91,22 @@ class DeployQueueWorker:
83
91
  except Exception as e:
84
92
  logger.error(str(e), exc_info=True)
85
93
 
94
+ async def drain(self, timeout: float) -> None:
95
+ """Wait up to `timeout` for running deployments, skipping apps not yet started.
96
+
97
+ Cancel `run()` first, or it keeps dequeuing.
98
+ """
99
+ shutting_down.set()
100
+ if queued := self.queue.qsize():
101
+ logger.log(DROPPED_LOG_LEVEL, "Shutting down with %d pushes still queued.", queued)
102
+ running = [task for task in self.active_deployments if not task.done()]
103
+ if not running:
104
+ return
105
+ logger.info(f"Waiting up to {timeout:.0f}s for {len(running)} deployments to finish.")
106
+ _, pending = await asyncio.wait(running, timeout=timeout)
107
+ if pending:
108
+ logger.log(DROPPED_LOG_LEVEL, "%d deployments still running at shutdown.", len(pending))
109
+
86
110
  async def process_deployment(self, work: Any) -> None:
87
111
  """Process a single deployment in a separate task.
88
112
 
@@ -96,6 +120,7 @@ class DeployQueueWorker:
96
120
  await self._process_deployment(work, ref)
97
121
  finally:
98
122
  self.deployment_semaphore.release()
123
+ self.on_activity()
99
124
 
100
125
  async def _process_deployment(self, work: Any, ref: str | None) -> None:
101
126
  if ref == "refs/heads/master":
@@ -1,7 +1,7 @@
1
1
 
2
2
  [project]
3
3
  name = "gitops"
4
- version = "1.10.2"
4
+ version = "1.12.0"
5
5
  description = "Manage multiple apps across one or more k8s clusters."
6
6
  requires-python = ">=3.12"
7
7
  readme = "README.md"
@@ -0,0 +1,114 @@
1
+ import asyncio
2
+ from unittest import mock
3
+
4
+ import pytest
5
+
6
+ from gitops_server.workers.deployer.disruption import DisruptionGuard
7
+ from gitops_server.workers.deployer.worker import DeployQueueWorker
8
+
9
+
10
+ class FakePatcher:
11
+ def __init__(self) -> None:
12
+ self.calls: list[bool] = []
13
+ self.fail_next = False
14
+
15
+ async def __call__(self, hold: bool) -> None:
16
+ if self.fail_next:
17
+ self.fail_next = False
18
+ raise RuntimeError("apiserver unavailable")
19
+ self.calls.append(hold)
20
+
21
+
22
+ def make_guard(busy: list[bool], warn_after: float = 3600) -> tuple[DisruptionGuard, FakePatcher]:
23
+ patcher = FakePatcher()
24
+ guard = DisruptionGuard(lambda: busy[0], patcher, interval=30, warn_after=warn_after)
25
+ return guard, patcher
26
+
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_annotation_follows_busy_state():
30
+ busy = [False]
31
+ guard, patcher = make_guard(busy)
32
+
33
+ await guard.reconcile()
34
+ await guard.reconcile()
35
+ assert patcher.calls == [False], "first pass clears anything left by a previous process"
36
+
37
+ busy[0] = True
38
+ await guard.reconcile()
39
+ await guard.reconcile()
40
+ busy[0] = False
41
+ await guard.reconcile()
42
+
43
+ assert patcher.calls == [False, True, False]
44
+
45
+
46
+ @pytest.mark.asyncio
47
+ async def test_failed_patch_is_retried():
48
+ busy = [True]
49
+ guard, patcher = make_guard(busy)
50
+ patcher.fail_next = True
51
+
52
+ await guard.reconcile()
53
+ assert guard.applied is None
54
+ await guard.reconcile()
55
+
56
+ assert patcher.calls == [True]
57
+ assert guard.applied
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ async def test_long_hold_reported_once_per_run(caplog):
62
+ busy = [True]
63
+ guard, patcher = make_guard(busy, warn_after=60)
64
+ clock = "gitops_server.workers.deployer.disruption.time.monotonic"
65
+
66
+ def errors() -> int:
67
+ return sum(r.levelname == "ERROR" for r in caplog.records)
68
+
69
+ with mock.patch(clock, return_value=0):
70
+ await guard.reconcile()
71
+ with mock.patch(clock, return_value=61):
72
+ await guard.reconcile()
73
+ await guard.reconcile()
74
+ assert errors() == 1
75
+ assert patcher.calls == [True], "a long hold is reported, not released"
76
+
77
+ busy[0] = False
78
+ with mock.patch(clock, return_value=62):
79
+ await guard.reconcile()
80
+ busy[0] = True
81
+ with mock.patch(clock, return_value=63):
82
+ await guard.reconcile()
83
+ with mock.patch(clock, return_value=124):
84
+ await guard.reconcile()
85
+ assert errors() == 2, "a fresh run of deploys can report again"
86
+
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_worker_busy_until_deployment_finishes():
90
+ release = asyncio.Event()
91
+
92
+ async def slow_deployment(work, ref):
93
+ await release.wait()
94
+
95
+ worker = DeployQueueWorker()
96
+ worker._process_deployment = slow_deployment # type: ignore[method-assign]
97
+ activity = mock.Mock()
98
+ worker.on_activity = activity
99
+
100
+ assert not worker.is_busy()
101
+ runner = asyncio.create_task(worker.run())
102
+ await worker.enqueue({"ref": "refs/heads/master"})
103
+ for _ in range(5):
104
+ await asyncio.sleep(0)
105
+ assert worker.is_busy()
106
+
107
+ release.set()
108
+ for _ in range(5):
109
+ await asyncio.sleep(0)
110
+ # The finished task is still in active_deployments until the next dequeue.
111
+ assert worker.active_deployments
112
+ assert not worker.is_busy()
113
+ assert activity.call_count == 2, "once on enqueue, once on finish"
114
+ runner.cancel()
@@ -0,0 +1,78 @@
1
+ import asyncio
2
+ from unittest.mock import patch
3
+
4
+ import pytest
5
+
6
+ from gitops_server.workers.deployer import Deployer
7
+ from gitops_server.workers.deployer.deploy import shutting_down
8
+ from gitops_server.workers.deployer.semaphore_manager import AppSemaphoreManager
9
+ from gitops_server.workers.deployer.worker import DeployQueueWorker
10
+
11
+ from .sample_data import SAMPLE_GITHUB_PAYLOAD
12
+ from .utils import mock_load_app_definitions
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def reset_shutdown():
17
+ AppSemaphoreManager._instance = None
18
+ yield
19
+ shutting_down.clear()
20
+
21
+
22
+ async def start_worker(deployment) -> tuple[DeployQueueWorker, asyncio.Task]:
23
+ worker = DeployQueueWorker()
24
+ worker._process_deployment = deployment # type: ignore[method-assign]
25
+ runner = asyncio.create_task(worker.run())
26
+ await worker.enqueue({"ref": "refs/heads/master"})
27
+ for _ in range(5):
28
+ await asyncio.sleep(0)
29
+ return worker, runner
30
+
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_drain_waits_for_running_deployment():
34
+ finished = asyncio.Event()
35
+
36
+ async def deployment(work, ref):
37
+ await asyncio.sleep(0.05)
38
+ finished.set()
39
+
40
+ worker, runner = await start_worker(deployment)
41
+ runner.cancel()
42
+ await worker.drain(timeout=5)
43
+
44
+ assert finished.is_set()
45
+ assert shutting_down.is_set()
46
+
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_drain_gives_up_after_timeout():
50
+ async def deployment(work, ref):
51
+ await asyncio.Event().wait()
52
+
53
+ worker, runner = await start_worker(deployment)
54
+ runner.cancel()
55
+ await asyncio.wait_for(worker.drain(timeout=0.05), timeout=1)
56
+
57
+ assert worker.is_busy(), "the stuck deployment is left running"
58
+
59
+
60
+ @pytest.mark.asyncio
61
+ @patch("gitops_server.workers.deployer.deploy.run")
62
+ @patch("gitops_server.workers.deployer.deploy.post_result_summary")
63
+ @patch("gitops_server.utils.slack.post")
64
+ @patch("gitops_server.workers.deployer.deploy.load_app_definitions", mock_load_app_definitions)
65
+ @patch("gitops_server.workers.deployer.deploy.temp_repo")
66
+ async def test_apps_not_started_are_skipped_once_shutting_down(
67
+ temp_repo_mock, post_mock, summary_mock, run_mock, caplog
68
+ ):
69
+ deployer = await Deployer.from_push_event(SAMPLE_GITHUB_PAYLOAD, AppSemaphoreManager())
70
+ shutting_down.set()
71
+
72
+ await deployer.deploy()
73
+
74
+ run_mock.assert_not_called()
75
+ summary_mock.assert_not_called()
76
+ [record] = [r for r in caplog.records if r.levelname == "ERROR"]
77
+ assert record.args == (sorted(deployer.skipped_on_shutdown),)
78
+ assert deployer.skipped_on_shutdown, "the error names the apps to redeploy"
@@ -222,16 +222,16 @@ wheels = [
222
222
 
223
223
  [[package]]
224
224
  name = "boto3"
225
- version = "1.35.4"
225
+ version = "1.43.117"
226
226
  source = { registry = "https://pypi.org/simple" }
227
227
  dependencies = [
228
228
  { name = "botocore" },
229
229
  { name = "jmespath" },
230
230
  { name = "s3transfer" },
231
231
  ]
232
- sdist = { url = "https://files.pythonhosted.org/packages/ca/59/baeef26b73a6fd71602370b6e1ba3eb79d0d43dff6efcf552065548c49a0/boto3-1.35.4.tar.gz", hash = "sha256:d997b82c468bd5c2d5cd29810d47079b66b178d2b5ae021aebe262c4d78d4c94", size = 108591, upload-time = "2024-08-22T20:02:16.884Z" }
232
+ sdist = { url = "https://files.pythonhosted.org/packages/94/a7/61f0295f79dfdcebb5aa01f86c614fac7e1294faabbd66259d3609c50539/boto3-1.43.117.tar.gz", hash = "sha256:f3d314908d866b577ee6b74ed432c21e565c4c4dbd640ef3fd5e0da30fa13a98", size = 112677, upload-time = "2026-10-21T19:30:03.849Z" }
233
233
  wheels = [
234
- { url = "https://files.pythonhosted.org/packages/76/88/73608e1f1c4f4276213a0d8658cfe799a3b2d6e4189543676b8b852a788e/boto3-1.35.4-py3-none-any.whl", hash = "sha256:96c39593afb7b55ebb74d08c8e3201041d105b557c8c8536c9054c9f13da5f2a", size = 139142, upload-time = "2024-08-22T20:02:13.231Z" },
234
+ { url = "https://files.pythonhosted.org/packages/f3/3b/22b05a545fb95983733d84681364c6b0a36afb0710d832f77732d1fc4c0b/boto3-1.43.117-py3-none-any.whl", hash = "sha256:a5b6cd7e67653987fcf0e212187c16f7b58af2ae192e368f7212ddab7c05b25d", size = 140043, upload-time = "2026-10-21T19:30:02.114Z" },
235
235
  ]
236
236
 
237
237
  [[package]]
@@ -249,16 +249,16 @@ wheels = [
249
249
 
250
250
  [[package]]
251
251
  name = "botocore"
252
- version = "1.35.99"
252
+ version = "1.43.117"
253
253
  source = { registry = "https://pypi.org/simple" }
254
254
  dependencies = [
255
255
  { name = "jmespath" },
256
256
  { name = "python-dateutil" },
257
257
  { name = "urllib3" },
258
258
  ]
259
- sdist = { url = "https://files.pythonhosted.org/packages/7c/9c/1df6deceee17c88f7170bad8325aa91452529d683486273928eecfd946d8/botocore-1.35.99.tar.gz", hash = "sha256:1eab44e969c39c5f3d9a3104a0836c24715579a455f12b3979a31d7cde51b3c3", size = 13490969, upload-time = "2025-01-14T20:20:11.419Z" }
259
+ sdist = { url = "https://files.pythonhosted.org/packages/6e/a0/d0a5c44ad6a01f195681ddcb4ce203f06c712811daf1d15f8f8785e8a9f8/botocore-1.43.117.tar.gz", hash = "sha256:71993702832cc37c2495048b9945f2929d306fccb3fb767e9964e437e637fa6c", size = 16366888, upload-time = "2026-10-21T19:29:59.274Z" }
260
260
  wheels = [
261
- { url = "https://files.pythonhosted.org/packages/fc/dd/d87e2a145fad9e08d0ec6edcf9d71f838ccc7acdd919acc4c0d4a93515f8/botocore-1.35.99-py3-none-any.whl", hash = "sha256:b22d27b6b617fc2d7342090d6129000af2efd20174215948c0d7ae2da0fab445", size = 13293216, upload-time = "2025-01-14T20:20:06.427Z" },
261
+ { url = "https://files.pythonhosted.org/packages/10/94/050e35c9febf6dd2fce4a76be587ae4a323964865700467d9d7b082f7133/botocore-1.43.117-py3-none-any.whl", hash = "sha256:5920ef9b3002e1361846549078549d9154a6e0004c22853c01ce7b279d05d495", size = 16061765, upload-time = "2026-10-21T19:29:56.001Z" },
262
262
  ]
263
263
 
264
264
  [[package]]
@@ -536,7 +536,7 @@ wheels = [
536
536
 
537
537
  [[package]]
538
538
  name = "gitops"
539
- version = "1.10.2"
539
+ version = "1.12.0"
540
540
  source = { editable = "." }
541
541
  dependencies = [
542
542
  { name = "boto3" },
@@ -1532,14 +1532,14 @@ wheels = [
1532
1532
 
1533
1533
  [[package]]
1534
1534
  name = "s3transfer"
1535
- version = "0.10.4"
1535
+ version = "0.19.3"
1536
1536
  source = { registry = "https://pypi.org/simple" }
1537
1537
  dependencies = [
1538
1538
  { name = "botocore" },
1539
1539
  ]
1540
- sdist = { url = "https://files.pythonhosted.org/packages/c0/0a/1cdbabf9edd0ea7747efdf6c9ab4e7061b085aa7f9bfc36bb1601563b069/s3transfer-0.10.4.tar.gz", hash = "sha256:29edc09801743c21eb5ecbc617a152df41d3c287f67b615f73e5f750583666a7", size = 145287, upload-time = "2024-11-20T21:06:05.981Z" }
1540
+ sdist = { url = "https://files.pythonhosted.org/packages/49/c5/0a5ce655109cb96b8dfad36908a4e108d15f5dc2eff8fbc3146c4312e704/s3transfer-0.19.3.tar.gz", hash = "sha256:8326acc3d056d7f7b8a37110c7d2aea41f67e83f6ae79cd3b5fbcca34c726406", size = 166087, upload-time = "2026-10-19T19:19:49.751Z" }
1541
1541
  wheels = [
1542
- { url = "https://files.pythonhosted.org/packages/66/05/7957af15543b8c9799209506df4660cba7afc4cf94bfb60513827e96bed6/s3transfer-0.10.4-py3-none-any.whl", hash = "sha256:244a76a24355363a68164241438de1b72f8781664920260c48465896b712a41e", size = 83175, upload-time = "2024-11-20T21:06:03.961Z" },
1542
+ { url = "https://files.pythonhosted.org/packages/e2/a4/88f9e10498759121060b8050a1554753dfc04df39bbbcc7aad9ecc8d9440/s3transfer-0.19.3-py3-none-any.whl", hash = "sha256:24eb7535804306355e31c952a90022bf5b2f9d760f9a11062c7493a217c08553", size = 90252, upload-time = "2026-10-19T19:19:48.309Z" },
1543
1543
  ]
1544
1544
 
1545
1545
  [[package]]
@@ -1,5 +0,0 @@
1
- {
2
- ".": "1.10.2",
3
- "gitops_server": "0.15.1",
4
- "charts/gitops": "1.4.1"
5
- }
@@ -1 +0,0 @@
1
- __version__ = "1.10.2"
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