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