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