gitops 1.10.1__tar.gz → 1.11.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 (96) hide show
  1. {gitops-1.10.1 → gitops-1.11.0}/.github/workflows/release-please.yaml +1 -1
  2. {gitops-1.10.1 → gitops-1.11.0}/.mise.toml +1 -1
  3. gitops-1.11.0/.release-please-manifest.json +5 -0
  4. {gitops-1.10.1 → gitops-1.11.0}/CHANGELOG.md +22 -0
  5. {gitops-1.10.1 → gitops-1.11.0}/Dockerfile +1 -1
  6. {gitops-1.10.1 → gitops-1.11.0}/PKG-INFO +3 -1
  7. {gitops-1.10.1 → gitops-1.11.0}/README.md +2 -0
  8. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/CHANGELOG.md +8 -0
  9. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/Chart.yaml +1 -1
  10. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/deployment.yml +9 -0
  11. {gitops-1.10.1 → gitops-1.11.0}/gitops/__init__.py +1 -1
  12. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/app.py +11 -6
  13. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/settings.py +8 -0
  14. gitops-1.11.0/gitops_server/version.py +1 -0
  15. gitops-1.11.0/gitops_server/workers/deployer/disruption.py +97 -0
  16. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/workers/deployer/worker.py +9 -0
  17. {gitops-1.10.1 → gitops-1.11.0}/pyproject.toml +1 -1
  18. gitops-1.11.0/tests/test_disruption_guard.py +114 -0
  19. {gitops-1.10.1 → gitops-1.11.0}/uv.lock +1 -1
  20. gitops-1.10.1/.release-please-manifest.json +0 -5
  21. gitops-1.10.1/gitops_server/version.py +0 -1
  22. {gitops-1.10.1 → gitops-1.11.0}/.dockerignore +0 -0
  23. {gitops-1.10.1 → gitops-1.11.0}/.github/CODEOWNERS +0 -0
  24. {gitops-1.10.1 → gitops-1.11.0}/.github/workflows/test.yml +0 -0
  25. {gitops-1.10.1 → gitops-1.11.0}/.gitignore +0 -0
  26. {gitops-1.10.1 → gitops-1.11.0}/.pre-commit-config.yaml +0 -0
  27. {gitops-1.10.1 → gitops-1.11.0}/CLAUDE.md +0 -0
  28. {gitops-1.10.1 → gitops-1.11.0}/LICENSE +0 -0
  29. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/.helmignore +0 -0
  30. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/README.md +0 -0
  31. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/_helpers.tpl +0 -0
  32. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/certificate.yml +0 -0
  33. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/environment.yml +0 -0
  34. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/ingress.yml +0 -0
  35. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/issuer.yml +0 -0
  36. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/pdb.yml +0 -0
  37. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/secrets.yml +0 -0
  38. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/service.yml +0 -0
  39. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/templates/serviceaccount.yaml +0 -0
  40. {gitops-1.10.1 → gitops-1.11.0}/charts/gitops/values.yaml +0 -0
  41. {gitops-1.10.1 → gitops-1.11.0}/docker-compose.yml +0 -0
  42. {gitops-1.10.1 → gitops-1.11.0}/docs/plans/2026-03-13-kms-encryption-design.md +0 -0
  43. {gitops-1.10.1 → gitops-1.11.0}/gitops/common/__init__.py +0 -0
  44. {gitops-1.10.1 → gitops-1.11.0}/gitops/common/app.py +0 -0
  45. {gitops-1.10.1 → gitops-1.11.0}/gitops/common/utils.py +0 -0
  46. {gitops-1.10.1 → gitops-1.11.0}/gitops/core.py +0 -0
  47. {gitops-1.10.1 → gitops-1.11.0}/gitops/main.py +0 -0
  48. {gitops-1.10.1 → gitops-1.11.0}/gitops/py.typed +0 -0
  49. {gitops-1.10.1 → gitops-1.11.0}/gitops/settings.py +0 -0
  50. {gitops-1.10.1 → gitops-1.11.0}/gitops/shorthands.py +0 -0
  51. {gitops-1.10.1 → gitops-1.11.0}/gitops/utils/__init__.py +0 -0
  52. {gitops-1.10.1 → gitops-1.11.0}/gitops/utils/apps.py +0 -0
  53. {gitops-1.10.1 → gitops-1.11.0}/gitops/utils/async_runner.py +0 -0
  54. {gitops-1.10.1 → gitops-1.11.0}/gitops/utils/cli.py +0 -0
  55. {gitops-1.10.1 → gitops-1.11.0}/gitops/utils/exceptions.py +0 -0
  56. {gitops-1.10.1 → gitops-1.11.0}/gitops/utils/images.py +0 -0
  57. {gitops-1.10.1 → gitops-1.11.0}/gitops/utils/kube.py +0 -0
  58. {gitops-1.10.1 → gitops-1.11.0}/gitops/utils/tags.py +0 -0
  59. {gitops-1.10.1 → gitops-1.11.0}/gitops/utils/yaml.py +0 -0
  60. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/CHANGELOG.md +0 -0
  61. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/README.md +0 -0
  62. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/__init__.py +0 -0
  63. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/main.py +0 -0
  64. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/types.py +0 -0
  65. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/utils/__init__.py +0 -0
  66. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/utils/git.py +0 -0
  67. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/utils/github.py +0 -0
  68. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/utils/github_auth.py +0 -0
  69. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/utils/slack.py +0 -0
  70. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/workers/__init__.py +0 -0
  71. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/workers/deployer/__init__.py +0 -0
  72. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/workers/deployer/deploy.py +0 -0
  73. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/workers/deployer/hooks.py +0 -0
  74. {gitops-1.10.1 → gitops-1.11.0}/gitops_server/workers/deployer/semaphore_manager.py +0 -0
  75. {gitops-1.10.1 → gitops-1.11.0}/release-please-config.json +0 -0
  76. {gitops-1.10.1 → gitops-1.11.0}/release.py +0 -0
  77. {gitops-1.10.1 → gitops-1.11.0}/renovate.json +0 -0
  78. {gitops-1.10.1 → gitops-1.11.0}/ruff.toml +0 -0
  79. {gitops-1.10.1 → gitops-1.11.0}/secrets.example.env +0 -0
  80. {gitops-1.10.1 → gitops-1.11.0}/setup.cfg +0 -0
  81. {gitops-1.10.1 → gitops-1.11.0}/tests/__init__.py +0 -0
  82. {gitops-1.10.1 → gitops-1.11.0}/tests/conftest.py +0 -0
  83. {gitops-1.10.1 → gitops-1.11.0}/tests/sample_data.py +0 -0
  84. {gitops-1.10.1 → gitops-1.11.0}/tests/test_app.py +0 -0
  85. {gitops-1.10.1 → gitops-1.11.0}/tests/test_clone_credentials.py +0 -0
  86. {gitops-1.10.1 → gitops-1.11.0}/tests/test_core.py +0 -0
  87. {gitops-1.10.1 → gitops-1.11.0}/tests/test_deploy.py +0 -0
  88. {gitops-1.10.1 → gitops-1.11.0}/tests/test_deploy_concurrent.py +0 -0
  89. {gitops-1.10.1 → gitops-1.11.0}/tests/test_git.py +0 -0
  90. {gitops-1.10.1 → gitops-1.11.0}/tests/test_github_auth.py +0 -0
  91. {gitops-1.10.1 → gitops-1.11.0}/tests/test_gitops_server.py +0 -0
  92. {gitops-1.10.1 → gitops-1.11.0}/tests/test_load_app_definitions.py +0 -0
  93. {gitops-1.10.1 → gitops-1.11.0}/tests/test_utils.py +0 -0
  94. {gitops-1.10.1 → gitops-1.11.0}/tests/test_worker_concurrency.py +0 -0
  95. {gitops-1.10.1 → gitops-1.11.0}/tests/utils.py +0 -0
  96. {gitops-1.10.1 → gitops-1.11.0}/tests/webhook_sample_data.py +0 -0
@@ -84,7 +84,7 @@ jobs:
84
84
  shell: bash
85
85
 
86
86
  - name: Publish Pypi Package
87
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # ratchet:pypa/gh-action-pypi-publish@release/v1
87
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # ratchet:pypa/gh-action-pypi-publish@release/v1
88
88
 
89
89
  publish_docker_image:
90
90
  name: Build and Push Docker Image
@@ -1,6 +1,6 @@
1
1
  [tools]
2
2
  python = { version = "3.12.13", venv = ".venv" }
3
- uv = { version = "0.11.26"}
3
+ uv = { version = "0.13.4"}
4
4
 
5
5
  [env]
6
6
  _.python.venv = { path = "{{config_root}}/.venv", create = true }
@@ -0,0 +1,5 @@
1
+ {
2
+ ".": "1.11.0",
3
+ "gitops_server": "0.15.1",
4
+ "charts/gitops": "1.5.0"
5
+ }
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.11.0](https://github.com/uptick/gitops/compare/gitops-v1.10.2...gitops-v1.11.0) (2026-10-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * **server:** hold karpenter.sh/do-not-disrupt while deploying ([b977e5f](https://github.com/uptick/gitops/commit/b977e5f949b237c5b458f6b9e3029f02592e55a8))
9
+ * **server:** hold karpenter.sh/do-not-disrupt while deploying ([c14359f](https://github.com/uptick/gitops/commit/c14359f39ba354432378bdb2ad9963e442fc9f66))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **server:** clear a leftover do-not-disrupt annotation on startup ([7c53772](https://github.com/uptick/gitops/commit/7c53772e40aa94a22c9268d926a2ca8f9122f607))
15
+
16
+ ## [1.10.2](https://github.com/uptick/gitops/compare/gitops-v1.10.1...gitops-v1.10.2) (2026-10-22)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **ci:** upgrade pypa/gh-action-pypi-publish to v1.14.3 ([384631c](https://github.com/uptick/gitops/commit/384631cc0f931dcb82056969fcc9512b329f08a4))
22
+ * **deps:** upgrade uv to 0.13.4 ([1f63116](https://github.com/uptick/gitops/commit/1f631162fc7b40b55bbdef6486a25d364666b637))
23
+ * **deps:** upgrade uv to 0.13.4 and the PyPI publish action to v1.14.3 ([f20a90b](https://github.com/uptick/gitops/commit/f20a90bec048a682e730f393afd686493a5d91fc))
24
+
3
25
  ## [1.10.1](https://github.com/uptick/gitops/compare/gitops-v1.10.0...gitops-v1.10.1) (2026-10-21)
4
26
 
5
27
 
@@ -2,7 +2,7 @@
2
2
  # This is a multi-stage build to use uv for dependency management and is necessary to ensure the the image version we pull is for the right architecture.
3
3
  # This is automatically set by the buildx builder. when using --platoform linux/amd64,linux/arm64 argument and then
4
4
  # using FROM in the Dockerfile.
5
- FROM ghcr.io/astral-sh/uv:0.11.26 AS uv_extractor
5
+ FROM ghcr.io/astral-sh/uv:0.13.4 AS uv_extractor
6
6
 
7
7
 
8
8
  # Stage 2: The main application build stage
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gitops
3
- Version: 1.10.1
3
+ Version: 1.11.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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0](https://github.com/uptick/gitops/compare/helm-v1.4.1...helm-v1.5.0) (2026-10-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * **server:** hold karpenter.sh/do-not-disrupt while deploying ([b977e5f](https://github.com/uptick/gitops/commit/b977e5f949b237c5b458f6b9e3029f02592e55a8))
9
+ * **server:** hold karpenter.sh/do-not-disrupt while deploying ([c14359f](https://github.com/uptick/gitops/commit/c14359f39ba354432378bdb2ad9963e442fc9f66))
10
+
3
11
  ## [1.4.1](https://github.com/uptick/gitops/compare/helm-v1.4.0...helm-v1.4.1) (2026-10-21)
4
12
 
5
13
 
@@ -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.5.0
@@ -78,6 +78,15 @@ spec:
78
78
  valueFrom:
79
79
  fieldRef:
80
80
  fieldPath: status.hostIP
81
+ # The server annotates its own pod while deploying.
82
+ - name: POD_NAME
83
+ valueFrom:
84
+ fieldRef:
85
+ fieldPath: metadata.name
86
+ - name: POD_NAMESPACE
87
+ valueFrom:
88
+ fieldRef:
89
+ fieldPath: metadata.namespace
81
90
  - name: OTEL_EXPORTER_OTLP_ENDPOINT
82
91
  value: "http://$(NODE_IP):4317"
83
92
  - name: OTEL_PYTHON_LOG_CORRELATION
@@ -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.1"
9
+ __version__ = "1.11.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,13 +26,17 @@ 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
+ tasks = [asyncio.ensure_future(deploy_queue_worker.run(), loop=loop)]
30
+ if guard := build_disruption_guard(deploy_queue_worker.is_busy):
31
+ deploy_queue_worker.on_activity = guard.poke
32
+ tasks.append(asyncio.ensure_future(guard.run(), loop=loop))
29
33
  yield
30
- deploy_queue_worker_task.cancel()
31
- try:
32
- await deploy_queue_worker_task
33
- except asyncio.CancelledError:
34
- pass
34
+ for task in tasks:
35
+ task.cancel()
36
+ try:
37
+ await task
38
+ except asyncio.CancelledError:
39
+ pass
35
40
 
36
41
 
37
42
  app = FastAPI(lifespan=lifespan)
@@ -22,6 +22,14 @@ 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
+
25
33
  # Pass --disable-openapi-validation to helm. Off by default: helm keeps checking
26
34
  # manifests against the cluster schema before applying them, which costs each
27
35
  # helm process a download and parse of that schema. The API server still
@@ -0,0 +1 @@
1
+ __version__ = "1.11.0"
@@ -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,5 +1,6 @@
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
@@ -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.
@@ -96,6 +104,7 @@ class DeployQueueWorker:
96
104
  await self._process_deployment(work, ref)
97
105
  finally:
98
106
  self.deployment_semaphore.release()
107
+ self.on_activity()
99
108
 
100
109
  async def _process_deployment(self, work: Any, ref: str | None) -> None:
101
110
  if ref == "refs/heads/master":
@@ -1,7 +1,7 @@
1
1
 
2
2
  [project]
3
3
  name = "gitops"
4
- version = "1.10.1"
4
+ version = "1.11.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()
@@ -536,7 +536,7 @@ wheels = [
536
536
 
537
537
  [[package]]
538
538
  name = "gitops"
539
- version = "1.10.1"
539
+ version = "1.11.0"
540
540
  source = { editable = "." }
541
541
  dependencies = [
542
542
  { name = "boto3" },
@@ -1,5 +0,0 @@
1
- {
2
- ".": "1.10.1",
3
- "gitops_server": "0.15.1",
4
- "charts/gitops": "1.4.1"
5
- }
@@ -1 +0,0 @@
1
- __version__ = "1.10.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes