chartreuse 7.0.0.dev0__tar.gz → 7.0.1__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 (24) hide show
  1. {chartreuse-7.0.0.dev0/src/chartreuse.egg-info → chartreuse-7.0.1}/PKG-INFO +8 -5
  2. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/README.md +1 -1
  3. chartreuse-7.0.1/VERSION +1 -0
  4. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/pyproject.toml +10 -5
  5. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse/chartreuse.py +3 -6
  6. chartreuse-7.0.1/src/chartreuse/chartreuse_restore.py +36 -0
  7. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse/chartreuse_upgrade.py +3 -4
  8. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse/config_loader.py +3 -3
  9. chartreuse-7.0.1/src/chartreuse/kubernetes_helper.py +424 -0
  10. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse/utils/alembic_migration_helper.py +2 -1
  11. chartreuse-7.0.1/src/chartreuse/utils/command.py +40 -0
  12. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1/src/chartreuse.egg-info}/PKG-INFO +8 -5
  13. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse.egg-info/SOURCES.txt +4 -1
  14. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse.egg-info/entry_points.txt +1 -0
  15. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse.egg-info/requires.txt +2 -2
  16. chartreuse-7.0.0.dev0/VERSION +0 -1
  17. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/LICENCE.md +0 -0
  18. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/MANIFEST.in +0 -0
  19. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/setup.cfg +0 -0
  20. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse/__init__.py +0 -0
  21. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse/utils/__init__.py +0 -0
  22. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse.egg-info/dependency_links.txt +0 -0
  23. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse.egg-info/top_level.txt +0 -0
  24. {chartreuse-7.0.0.dev0 → chartreuse-7.0.1}/src/chartreuse.egg-info/zip-safe +0 -0
@@ -1,18 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartreuse
3
- Version: 7.0.0.dev0
3
+ Version: 7.0.1
4
4
  Summary: Helper for Alembic migrations within Kubernetes.
5
5
  Author-email: wiremind <dev@wiremind.io>
6
6
  License-Expression: LGPL-3.0-or-later
7
7
  Project-URL: github, https://github.com/wiremind/chartreuse
8
- Requires-Python: >=3.11.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Python: >=3.13.0
9
12
  Description-Content-Type: text/markdown
10
13
  License-File: LICENCE.md
11
14
  Requires-Dist: alembic
12
- Requires-Dist: psycopg[binary]
15
+ Requires-Dist: kubernetes
16
+ Requires-Dist: psycopg2
13
17
  Requires-Dist: pydantic>=2.0.0
14
18
  Requires-Dist: PyYAML
15
- Requires-Dist: wiremind-kubernetes~=7.0
16
19
  Provides-Extra: test
17
20
  Requires-Dist: mock; extra == "test"
18
21
  Requires-Dist: pytest; extra == "test"
@@ -40,7 +43,7 @@ Chartreuse leverages [Helm Hooks](https://helm.sh/docs/topics/charts_hooks/), th
40
43
 
41
44
  ### Requirements
42
45
 
43
- - Python >= 3.7
46
+ - Python >= 3.13
44
47
  - Using Helm to deploy you application
45
48
  - This Python package requires the `expecteddeploymentscales.wiremind.io` Kubernetes `Custom Resource Definition`:
46
49
 
@@ -11,7 +11,7 @@ Chartreuse leverages [Helm Hooks](https://helm.sh/docs/topics/charts_hooks/), th
11
11
 
12
12
  ### Requirements
13
13
 
14
- - Python >= 3.7
14
+ - Python >= 3.13
15
15
  - Using Helm to deploy you application
16
16
  - This Python package requires the `expecteddeploymentscales.wiremind.io` Kubernetes `Custom Resource Definition`:
17
17
 
@@ -0,0 +1 @@
1
+ 7.0.1
@@ -6,15 +6,20 @@ readme = "README.md"
6
6
  authors = [{ name = "wiremind", email = "dev@wiremind.io" }]
7
7
  license="LGPL-3.0-or-later"
8
8
  urls = { github = "https://github.com/wiremind/chartreuse"}
9
- scripts = {chartreuse-upgrade = "chartreuse.chartreuse_upgrade:main"}
10
- requires-python = ">=3.11.0"
9
+ scripts = {chartreuse-upgrade = "chartreuse.chartreuse_upgrade:main", chartreuse-restore = "chartreuse.chartreuse_restore:main"}
10
+ requires-python = ">=3.13.0"
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.13",
14
+ "Programming Language :: Python :: 3.14",
15
+ ]
11
16
 
12
17
  dependencies = [
13
18
  "alembic",
14
- "psycopg[binary]",
19
+ "kubernetes",
20
+ "psycopg2",
15
21
  "pydantic>=2.0.0",
16
22
  "PyYAML",
17
- "wiremind-kubernetes~=7.0",
18
23
  ]
19
24
 
20
25
  [build-system]
@@ -50,7 +55,7 @@ dev = [
50
55
 
51
56
  [tool.ruff]
52
57
  line-length = 120
53
- target-version = "py311"
58
+ target-version = "py313"
54
59
 
55
60
  [tool.ruff.lint]
56
61
  select = [
@@ -1,8 +1,7 @@
1
1
  import logging
2
2
 
3
- import wiremind_kubernetes.kubernetes_helper
4
-
5
3
  from .config_loader import DatabaseConfig
4
+ from .kubernetes_helper import KubernetesDeploymentManager
6
5
  from .utils import AlembicMigrationHelper
7
6
 
8
7
  logger = logging.getLogger(__name__)
@@ -23,7 +22,7 @@ class Chartreuse:
23
22
  self,
24
23
  databases_config: dict[str, DatabaseConfig],
25
24
  release_name: str,
26
- kubernetes_helper: wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager | None = None,
25
+ kubernetes_helper: KubernetesDeploymentManager | None = None,
27
26
  ):
28
27
  configure_logging()
29
28
 
@@ -56,9 +55,7 @@ class Chartreuse:
56
55
  if kubernetes_helper:
57
56
  self.kubernetes_helper = kubernetes_helper
58
57
  else:
59
- self.kubernetes_helper = wiremind_kubernetes.kubernetes_helper.KubernetesDeploymentManager(
60
- use_kubeconfig=None, release_name=release_name
61
- )
58
+ self.kubernetes_helper = KubernetesDeploymentManager(use_kubeconfig=None, release_name=release_name)
62
59
 
63
60
  @property
64
61
  def is_migration_needed(self) -> bool:
@@ -0,0 +1,36 @@
1
+ import logging
2
+ import os
3
+
4
+ from .chartreuse import configure_logging
5
+ from .chartreuse_upgrade import ensure_safe_run
6
+ from .kubernetes_helper import KubernetesDeploymentManager
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def main() -> None:
12
+ """
13
+ Restore Deployments that a pre-deployment migration (CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT)
14
+ stopped and that the deployment itself could not scale back up.
15
+
16
+ On that path chartreuse-upgrade intentionally skips start_pods() and relies on the
17
+ deployment that follows to restore replicas. That works for Deployments whose chart sets
18
+ spec.replicas, but HPA-managed ones omit it: they stay at 0 replicas and an HPA whose
19
+ target is at 0 with minReplicas >= 1 is ScalingDisabled and never scales them back up.
20
+
21
+ Runs as a post-deployment (e.g. ArgoCD PostSync) hook: the new pod template is already
22
+ applied, so scaling up only ever starts new-version pods. Only Deployments still carrying
23
+ the annotation set by stop_pods() are touched. If this program fails, the deployment is
24
+ considered as failed: stranded workers must be visible, not a log line.
25
+ """
26
+ configure_logging()
27
+ ensure_safe_run()
28
+
29
+ release_name: str = os.environ["CHARTREUSE_RELEASE_NAME"]
30
+
31
+ deployment_manager = KubernetesDeploymentManager(release_name=release_name, use_kubeconfig=None)
32
+ deployment_manager.restore_stopped_pods()
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
@@ -1,12 +1,11 @@
1
1
  import logging
2
2
  import os
3
3
 
4
- from wiremind_kubernetes import KubernetesDeploymentManager
5
-
6
4
  from chartreuse import get_version
7
5
 
8
6
  from .chartreuse import Chartreuse
9
7
  from .config_loader import load_multi_database_config
8
+ from .kubernetes_helper import KubernetesDeploymentManager
10
9
 
11
10
  logger = logging.getLogger(__name__)
12
11
 
@@ -20,10 +19,10 @@ def ensure_safe_run() -> None:
20
19
  # Get "1.2" from "1.2.3"
21
20
  package_v_major_minor: list[str] = package_v.split(".", 2)[:2]
22
21
 
23
- helm_chart_v: str = os.getenv("HELM_CHART_VERSION", "")
22
+ helm_chart_v: str = os.getenv("HELM_CHART_APP_VERSION", "")
24
23
  if not helm_chart_v:
25
24
  raise ValueError(
26
- "Couldn't get the Chartreuse's Helm Chart version from the env var HELM_CHART_VERSION,"
25
+ "Couldn't get the Chartreuse's Helm Chart appVersion from the env var HELM_CHART_APP_VERSION,"
27
26
  " couldn't make sure that the package is of a compatible version, ABORTING!"
28
27
  )
29
28
  helm_chart_v_major_minor: list[str] = helm_chart_v.split(".", 2)[:2]
@@ -39,7 +39,7 @@ class DatabaseConfig(BaseModel):
39
39
  @classmethod
40
40
  def validate_dialect(cls, v: str) -> str:
41
41
  """Validate database dialect."""
42
- supported_dialects = ["postgresql+psycopg", "mysql", "sqlite", "oracle", "mssql", "clickhouse"]
42
+ supported_dialects = ["postgresql", "mysql", "sqlite", "oracle", "mssql", "clickhouse"]
43
43
  if v.lower() not in supported_dialects:
44
44
  logger.warning("Dialect '%s' might not be supported. Supported dialects: %s", v, supported_dialects)
45
45
  return v
@@ -86,7 +86,7 @@ def load_multi_database_config(config_path: str) -> dict[str, DatabaseConfig]:
86
86
  ```yaml
87
87
  databases:
88
88
  main: # Database name/identifier
89
- dialect: postgresql+psycopg
89
+ dialect: postgresql
90
90
  user: app_user
91
91
  password: app_password
92
92
  host: postgres-main
@@ -99,7 +99,7 @@ def load_multi_database_config(config_path: str) -> dict[str, DatabaseConfig]:
99
99
 
100
100
  # For single database setups, just include one database:
101
101
  # analytics:
102
- # dialect: postgresql+psycopg
102
+ # dialect: postgresql
103
103
  # user: analytics_user
104
104
  # password: analytics_password
105
105
  # host: postgres-analytics
@@ -0,0 +1,424 @@
1
+ """
2
+ Kubernetes helpers to stop, start and restore the application's Deployments around a database
3
+ migration.
4
+
5
+ Trimmed port of wiremind_kubernetes.KubernetesDeploymentManager (a library being
6
+ decommissioned): only what chartreuse uses survived — ExpectedDeploymentScale-driven
7
+ scale down/up with HPA disabling, plus the stopped-by annotation and restore logic.
8
+ Depends only on the official kubernetes client.
9
+ """
10
+
11
+ import functools
12
+ import json
13
+ import logging
14
+ import os
15
+ import pprint
16
+ import time
17
+ from collections.abc import Callable, Generator
18
+ from typing import Any
19
+
20
+ import kubernetes
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Target-rename prefix used by the pre-7.0.1 HPA disable mechanism. Only the repair path
25
+ # (re_enable_hpa) remains, for HPAs left renamed by an older chartreuse.
26
+ # TODO: remove in 8.0 together with re_enable_hpa.
27
+ HPA_ID_PREFIX = "wm--disabled--kube"
28
+ # Set by stop_pods() on every Deployment it scales down, cleared by start_pods() and
29
+ # restore_stopped_pods(). Allows telling "stopped by us" apart from "deliberately scaled to 0".
30
+ STOPPED_ANNOTATION = "wiremind.io/stopped-by"
31
+ # Set by pause_hpa() on the HPA itself; stores the pre-pause spec.behavior as JSON ("" when
32
+ # absent) so resume_hpa() can restore it exactly. Presence of the annotation == "paused by us".
33
+ PAUSED_ANNOTATION = "wiremind.io/pre-pause-scale-behavior"
34
+
35
+
36
+ def load_kubernetes_config(use_kubeconfig: bool | None = None) -> None:
37
+ """
38
+ Load kubernetes configuration in memory, either from incluster method or from kubeconfig.
39
+ :param use_kubeconfig:
40
+ If True: Use ~/.kube/config file to authenticate.
41
+ If False: use kubernetes built-in incluster mechanism.
42
+ If None, will try to load built-in incluster mechanism, then try config file.
43
+ Defaults to None.
44
+ """
45
+ if os.environ.get("CLASSIC_K8S_CONFIG"):
46
+ # We are in a Kind cluster for E2E test! Never use in cluster config.
47
+ kubernetes.config.load_kube_config()
48
+ return
49
+
50
+ if use_kubeconfig is True:
51
+ kubernetes.config.load_kube_config()
52
+ elif use_kubeconfig is False:
53
+ kubernetes.config.load_incluster_config()
54
+ elif use_kubeconfig is None:
55
+ if os.path.exists(kubernetes.config.incluster_config.SERVICE_TOKEN_FILENAME):
56
+ kubernetes.config.load_incluster_config()
57
+ else:
58
+ kubernetes.config.load_kube_config()
59
+ logger.debug("Kubernetes configuration successfully set.")
60
+
61
+
62
+ def retry_kubernetes_request(function: Callable) -> Callable:
63
+ """
64
+ Decorator that retries a failed Kubernetes API request if needed and ignores 404
65
+ """
66
+
67
+ @functools.wraps(function)
68
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
69
+ try:
70
+ return function(*args, **kwargs)
71
+ except kubernetes.client.rest.ApiException as e:
72
+ if e.status == 404:
73
+ logger.warning("Not found, ignoring.")
74
+ return
75
+ logger.error(e)
76
+ logger.info("Retrying in 5 seconds...")
77
+ time.sleep(5)
78
+ return function(*args, **kwargs)
79
+
80
+ return wrapper
81
+
82
+
83
+ def retry_kubernetes_request_no_ignore(function: Callable) -> Callable:
84
+ """
85
+ Decorator that retries a failed Kubernetes API request if needed and do NOT ignore 404 (raise if 404)
86
+ """
87
+
88
+ @functools.wraps(function)
89
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
90
+ try:
91
+ return function(*args, **kwargs)
92
+ except kubernetes.client.rest.ApiException as e:
93
+ if e.status == 404:
94
+ raise
95
+ logger.error(e)
96
+ logger.info("Retrying in 5 seconds...")
97
+ time.sleep(5)
98
+ return function(*args, **kwargs)
99
+
100
+ return wrapper
101
+
102
+
103
+ def _get_namespace_from_kube() -> str:
104
+ return open("/var/run/secrets/kubernetes.io/serviceaccount/namespace").read()
105
+
106
+
107
+ class KubernetesDeploymentManager:
108
+ """
109
+ Scale down/up all Deployments that should be stopped/started when doing database
110
+ migration/maintenance (alembic, dump, etc). Each managed Deployment must define an
111
+ ExpectedDeploymentScale.
112
+
113
+ Usage:
114
+ manager = KubernetesDeploymentManager(release_name="my-release", use_kubeconfig=None)
115
+ manager.stop_pods()
116
+ do_something('wololo')
117
+ manager.start_pods()
118
+ """
119
+
120
+ SCALE_DOWN_MAX_WAIT_TIME: int = 3600
121
+
122
+ def __init__(
123
+ self,
124
+ release_name: str,
125
+ use_kubeconfig: bool | None = False,
126
+ namespace: str | None = None,
127
+ should_load_kubernetes_config: bool = True,
128
+ ):
129
+ if should_load_kubernetes_config:
130
+ load_kubernetes_config(use_kubeconfig=use_kubeconfig)
131
+ self.release_name = release_name
132
+ self.namespace = namespace if namespace else _get_namespace_from_kube()
133
+ self.client_appsv1_api = kubernetes.client.AppsV1Api()
134
+ self.client_corev1_api = kubernetes.client.CoreV1Api()
135
+ self.client_autoscalingv2_api = kubernetes.client.AutoscalingV2Api()
136
+ self.client_custom_objects_api = kubernetes.client.CustomObjectsApi()
137
+
138
+ def get_deployment_scale(self, deployment_name: str) -> kubernetes.client.V1Scale:
139
+ logger.debug("Getting deployment scale for %s", deployment_name)
140
+ return self.client_appsv1_api.read_namespaced_deployment_scale(deployment_name, self.namespace)
141
+
142
+ @retry_kubernetes_request
143
+ def scale_down_deployment(self, deployment_name: str) -> None:
144
+ body = self.get_deployment_scale(deployment_name)
145
+ logger.debug("Deleting all Pods for %s", deployment_name)
146
+ body.spec.replicas = 0
147
+ self.client_appsv1_api.patch_namespaced_deployment_scale(deployment_name, self.namespace, body)
148
+ logger.debug("Done deleting.")
149
+
150
+ @retry_kubernetes_request
151
+ def scale_up_deployment(self, deployment_name: str, pod_amount: int) -> None:
152
+ body = self.get_deployment_scale(deployment_name)
153
+ logger.debug("Recreating backend Pods for %s", deployment_name)
154
+ body.spec.replicas = pod_amount
155
+ self.client_appsv1_api.patch_namespaced_deployment_scale(deployment_name, self.namespace, body)
156
+ logger.debug("Done recreating.")
157
+
158
+ @retry_kubernetes_request_no_ignore
159
+ def _get_pods_from_deployment(self, deployment_name: str) -> list:
160
+ logger.debug("Asking if Deployment %s is stopped", deployment_name)
161
+ labels = self.client_appsv1_api.read_namespaced_deployment(
162
+ deployment_name, self.namespace
163
+ ).spec.selector.match_labels
164
+
165
+ try:
166
+ return self.client_corev1_api.list_namespaced_pod(
167
+ namespace=self.namespace,
168
+ label_selector=",".join(["{}={}".format(*kv) for kv in labels.items()]),
169
+ ).items
170
+ except kubernetes.client.rest.ApiException as e:
171
+ if e.status == 404:
172
+ return []
173
+ else:
174
+ raise
175
+
176
+ def is_deployment_stopped(self, deployment_name: str) -> bool:
177
+ try:
178
+ pod_list: list = self._get_pods_from_deployment(deployment_name)
179
+ except kubernetes.client.rest.ApiException as e:
180
+ if e.status == 404:
181
+ logger.warning("Not found, ignoring.")
182
+ return True
183
+ raise
184
+
185
+ current_scale = 0
186
+ for pod in pod_list:
187
+ if pod.status.phase not in ("Failed"):
188
+ current_scale += 1
189
+
190
+ if current_scale > 0:
191
+ logger.info("%s Deployment has %s living replicas", deployment_name, current_scale)
192
+ return False
193
+ return True
194
+
195
+ def get_deployment_hpa(self, *, deployment_name: str) -> Generator:
196
+ for hpa in self.client_autoscalingv2_api.list_namespaced_horizontal_pod_autoscaler(self.namespace).items:
197
+ if hpa.spec.scale_target_ref.kind == "Deployment" and hpa.spec.scale_target_ref.name == deployment_name:
198
+ yield hpa
199
+
200
+ def patch_deployment_hpa(self, *, hpa_name: str, body: Any) -> None:
201
+ self.client_autoscalingv2_api.patch_namespaced_horizontal_pod_autoscaler(
202
+ name=hpa_name, namespace=self.namespace, body=body
203
+ )
204
+
205
+ @retry_kubernetes_request
206
+ def annotate_deployment(self, deployment_name: str, annotations: dict[str, str | None]) -> None:
207
+ """
208
+ Add or update annotations on a Deployment. A None value removes the annotation.
209
+ """
210
+ body = {"metadata": {"annotations": annotations}}
211
+ self.client_appsv1_api.patch_namespaced_deployment(deployment_name, self.namespace, body)
212
+
213
+ @retry_kubernetes_request_no_ignore
214
+ def _get_expected_deployment_scale_dict(self) -> dict[int, dict[str, int]]:
215
+ """
216
+ Return a dict of expected deployment scale:
217
+ {
218
+ 0: { # priority
219
+ # key: Deployment name, only if it has an associated eds
220
+ # value: expected Deployment Scale (replicas)
221
+ "my-deployment": 3,
222
+ "my-other-deployment": 42
223
+ },
224
+ 1: {
225
+ "my-third-deployment": 17,
226
+ },
227
+ }
228
+ """
229
+ logger.debug("Getting Expected Deployment Scale list")
230
+ eds_list: list[dict[str, Any]] = []
231
+ release_label_keys = ["app.kubernetes.io/instance", "release"]
232
+
233
+ for release_label_key in release_label_keys:
234
+ logger.debug(f"Getting Expected Deployment Scale list with the release label key {release_label_key}")
235
+ try:
236
+ eds_list.extend(
237
+ self.client_custom_objects_api.list_namespaced_custom_object(
238
+ namespace=self.namespace,
239
+ group="wiremind.io",
240
+ version="v1",
241
+ plural="expecteddeploymentscales",
242
+ label_selector=f"{release_label_key}={self.release_name}",
243
+ )["items"]
244
+ )
245
+ except kubernetes.client.rest.ApiException as e:
246
+ if e.status != 404:
247
+ raise
248
+
249
+ eds_dict: dict[int, dict[str, int]] = {}
250
+ for eds in eds_list:
251
+ deployment_name: str = eds["spec"]["deploymentName"]
252
+ expected_scale: int = eds["spec"]["expectedScale"]
253
+ priority: int = eds["spec"].get("priority", 0)
254
+
255
+ if priority not in eds_dict:
256
+ eds_dict[priority] = {}
257
+
258
+ eds_dict[priority][deployment_name] = expected_scale
259
+
260
+ logger.debug("Deployments are %s", pprint.pformat(eds_dict))
261
+ return eds_dict
262
+
263
+ @retry_kubernetes_request
264
+ def pause_hpa(self, *, deployment_name: str) -> None:
265
+ """
266
+ Forbid the Deployment's HPAs from scaling up (spec.behavior.scaleUp.selectPolicy:
267
+ Disabled) so they can't start pods mid-migration. Unlike breaking the scaleTargetRef,
268
+ the HPA stays Healthy: no FailedGetScale events, no Degraded ArgoCD application.
269
+
270
+ The pre-pause spec.behavior is saved in PAUSED_ANNOTATION so resume_hpa() can restore
271
+ it exactly. Idempotent: an already-paused HPA is left alone (never overwrite the
272
+ saved original with a paused state).
273
+ """
274
+ for hpa in self.get_deployment_hpa(deployment_name=deployment_name):
275
+ if PAUSED_ANNOTATION in (hpa.metadata.annotations or {}):
276
+ continue
277
+ original_behavior = self.client_autoscalingv2_api.api_client.sanitize_for_serialization(hpa.spec.behavior)
278
+ saved = json.dumps(original_behavior) if original_behavior else ""
279
+ body = {
280
+ "metadata": {"annotations": {PAUSED_ANNOTATION: saved}},
281
+ "spec": {"behavior": {"scaleUp": {"selectPolicy": "Disabled"}}},
282
+ }
283
+ self.patch_deployment_hpa(hpa_name=hpa.metadata.name, body=body)
284
+
285
+ @retry_kubernetes_request
286
+ def resume_hpa(self, *, deployment_name: str) -> None:
287
+ """
288
+ Undo pause_hpa(): restore the exact pre-pause spec.behavior (or clear it when there
289
+ was none) and drop PAUSED_ANNOTATION. No-op on HPAs that don't carry the annotation,
290
+ so it is always safe to call.
291
+ """
292
+ for hpa in self.get_deployment_hpa(deployment_name=deployment_name):
293
+ saved = (hpa.metadata.annotations or {}).get(PAUSED_ANNOTATION)
294
+ if saved is None:
295
+ continue
296
+ body = {
297
+ "metadata": {"annotations": {PAUSED_ANNOTATION: None}},
298
+ "spec": {"behavior": json.loads(saved) if saved else None},
299
+ }
300
+ self.patch_deployment_hpa(hpa_name=hpa.metadata.name, body=body)
301
+
302
+ @retry_kubernetes_request
303
+ def re_enable_hpa(self, *, deployment_name: str) -> None:
304
+ """
305
+ Repair an HPA left pointing at a wm--disabled--kube- target by a pre-7.0.1 chartreuse
306
+ whose run crashed before re-enabling. 7.0.1+ never creates such renames.
307
+ TODO: remove in 8.0.
308
+ """
309
+ for hpa in self.get_deployment_hpa(deployment_name=f"{HPA_ID_PREFIX}-{deployment_name}"):
310
+ hpa.spec.scale_target_ref.name = deployment_name
311
+ self.patch_deployment_hpa(hpa_name=hpa.metadata.name, body=hpa)
312
+
313
+ def _are_deployments_stopped(self, deployment_dict: dict[str, int]) -> bool:
314
+ for deployment_name in deployment_dict:
315
+ if not self.is_deployment_stopped(deployment_name):
316
+ return False
317
+ return True
318
+
319
+ def _stop_deployments(self, deployment_dict: dict[str, int]) -> None:
320
+ """
321
+ Scale down a dict (deployment_name, expected_scale) of Deployments.
322
+ """
323
+ for deployment_name in deployment_dict:
324
+ self.annotate_deployment(deployment_name, {STOPPED_ANNOTATION: self.release_name})
325
+ self.pause_hpa(deployment_name=deployment_name)
326
+ for _ in range(self.SCALE_DOWN_MAX_WAIT_TIME):
327
+ for deployment_name in deployment_dict:
328
+ self.scale_down_deployment(deployment_name)
329
+ if self._are_deployments_stopped(deployment_dict):
330
+ break
331
+ time.sleep(1)
332
+ else:
333
+ raise Exception("Timed out waiting for pods to be deleted: aborting.")
334
+
335
+ def stop_pods(self) -> None:
336
+ """
337
+ Scale to 0 all deployments for which an ExpectedDeploymentScale links to.
338
+ stop all deployments, then wait for actual stop, by priority (descending order):
339
+ Example: stop all deployments with priority 1, then all deployments with priority 0
340
+ """
341
+ expected_deployment_scale_dict: dict[int, dict[str, int]] = self._get_expected_deployment_scale_dict()
342
+
343
+ logger.info("Scaling down application Deployments...")
344
+ if not expected_deployment_scale_dict:
345
+ logger.info("No Deployments to scale down")
346
+ return
347
+
348
+ priorities: list[int] = sorted(expected_deployment_scale_dict, reverse=True)
349
+ for priority in priorities:
350
+ priority_dict: dict[str, int] = expected_deployment_scale_dict[priority]
351
+ if len(priority_dict):
352
+ self._stop_deployments(priority_dict)
353
+ logger.info("Done scaling down application Deployments.")
354
+
355
+ def start_pods(self) -> None:
356
+ """
357
+ Start all Pods that should be started
358
+ """
359
+ expected_deployment_scale_dict: dict[int, dict[str, int]] = self._get_expected_deployment_scale_dict()
360
+
361
+ logger.info("Scaling up application Deployments...")
362
+ if not expected_deployment_scale_dict:
363
+ logger.info("No Deployments to scale up")
364
+ return
365
+
366
+ scaled: bool = False
367
+ for priority_dict in expected_deployment_scale_dict.values():
368
+ if len(priority_dict):
369
+ scaled = True
370
+ for name, expected_scale in priority_dict.items():
371
+ self.resume_hpa(deployment_name=name)
372
+ self.re_enable_hpa(deployment_name=name)
373
+ self.scale_up_deployment(name, expected_scale)
374
+ self.annotate_deployment(name, {STOPPED_ANNOTATION: None})
375
+ if scaled:
376
+ logger.info("Done scaling up application Deployments")
377
+ else:
378
+ logger.info("No Deployments to scale up")
379
+
380
+ def restore_stopped_pods(self) -> None:
381
+ """
382
+ Restore Deployments that stop_pods() scaled down and that nothing scaled back up since.
383
+
384
+ Only the replica restore is gated on STOPPED_ANNOTATION, so a Deployment deliberately
385
+ scaled to zero outside of stop_pods() is left alone. HPA resume/repair on the other
386
+ hand runs for EVERY tracked Deployment: a pause left behind by a crashed run is not
387
+ self-healing (ArgoCD ignores the live-only spec.behavior field), and resuming an
388
+ unpaused HPA is a no-op.
389
+
390
+ Meant to run after the deployment that follows a stop_pods() without start_pods()
391
+ (CHARTREUSE_UPGRADE_BEFORE_DEPLOYMENT): that deployment restores spec.replicas of
392
+ Deployments that define it in their chart, but HPA-managed ones omit it, and an HPA
393
+ whose target is at 0 replicas with minReplicas >= 1 is ScalingDisabled and will never
394
+ scale it back up.
395
+ """
396
+ expected_deployment_scale_dict: dict[int, dict[str, int]] = self._get_expected_deployment_scale_dict()
397
+
398
+ logger.info("Restoring Deployments left stopped...")
399
+ restored: list[str] = []
400
+ for priority in sorted(expected_deployment_scale_dict):
401
+ for name, expected_scale in expected_deployment_scale_dict[priority].items():
402
+ self.resume_hpa(deployment_name=name)
403
+ self.re_enable_hpa(deployment_name=name)
404
+ try:
405
+ deployment = self.client_appsv1_api.read_namespaced_deployment(name, self.namespace)
406
+ except kubernetes.client.rest.ApiException as e:
407
+ if e.status == 404:
408
+ continue
409
+ raise
410
+ if STOPPED_ANNOTATION not in (deployment.metadata.annotations or {}):
411
+ continue
412
+ if not deployment.spec.replicas:
413
+ target_scale = expected_scale
414
+ if target_scale < 1 and any(self.get_deployment_hpa(deployment_name=name)):
415
+ # Any scale >= 1 re-arms the HPA, which then enforces its own minReplicas
416
+ target_scale = 1
417
+ if target_scale > 0:
418
+ self.scale_up_deployment(name, target_scale)
419
+ self.annotate_deployment(name, {STOPPED_ANNOTATION: None})
420
+ restored.append(name)
421
+ if restored:
422
+ logger.info("Done restoring stopped Deployments: %s", ", ".join(restored))
423
+ else:
424
+ logger.info("No stopped Deployments to restore")
@@ -5,7 +5,8 @@ from subprocess import SubprocessError
5
5
 
6
6
  import sqlalchemy
7
7
  from sqlalchemy import inspect
8
- from wiremind_kubernetes.utils import run_command
8
+
9
+ from .command import run_command
9
10
 
10
11
  logger = logging.getLogger(__name__)
11
12
 
@@ -0,0 +1,40 @@
1
+ import logging
2
+ import shlex
3
+ import subprocess
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ def run_command(command: list | str, return_result: bool = False, **kw_args) -> tuple[str, str, int]:
9
+ """
10
+ Run command, print stdout/stderr, check that command exited correctly, return stdout/err
11
+ """
12
+ logger.debug(f"Running {command}")
13
+
14
+ interpreted_command: list[str]
15
+ if isinstance(command, str):
16
+ interpreted_command = shlex.split(command)
17
+ else:
18
+ interpreted_command = command
19
+
20
+ process = subprocess.Popen(
21
+ interpreted_command,
22
+ stdout=subprocess.PIPE,
23
+ stderr=subprocess.STDOUT,
24
+ universal_newlines=True,
25
+ **kw_args,
26
+ )
27
+
28
+ if return_result:
29
+ out, err = process.communicate()
30
+ return (out, err, process.returncode)
31
+
32
+ if process.stdout:
33
+ for line in iter(process.stdout.readline, ""):
34
+ logger.info(line.strip())
35
+ process.wait()
36
+
37
+ if process.returncode:
38
+ raise subprocess.CalledProcessError(process.returncode, command)
39
+
40
+ return "", "", 0
@@ -1,18 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartreuse
3
- Version: 7.0.0.dev0
3
+ Version: 7.0.1
4
4
  Summary: Helper for Alembic migrations within Kubernetes.
5
5
  Author-email: wiremind <dev@wiremind.io>
6
6
  License-Expression: LGPL-3.0-or-later
7
7
  Project-URL: github, https://github.com/wiremind/chartreuse
8
- Requires-Python: >=3.11.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Requires-Python: >=3.13.0
9
12
  Description-Content-Type: text/markdown
10
13
  License-File: LICENCE.md
11
14
  Requires-Dist: alembic
12
- Requires-Dist: psycopg[binary]
15
+ Requires-Dist: kubernetes
16
+ Requires-Dist: psycopg2
13
17
  Requires-Dist: pydantic>=2.0.0
14
18
  Requires-Dist: PyYAML
15
- Requires-Dist: wiremind-kubernetes~=7.0
16
19
  Provides-Extra: test
17
20
  Requires-Dist: mock; extra == "test"
18
21
  Requires-Dist: pytest; extra == "test"
@@ -40,7 +43,7 @@ Chartreuse leverages [Helm Hooks](https://helm.sh/docs/topics/charts_hooks/), th
40
43
 
41
44
  ### Requirements
42
45
 
43
- - Python >= 3.7
46
+ - Python >= 3.13
44
47
  - Using Helm to deploy you application
45
48
  - This Python package requires the `expecteddeploymentscales.wiremind.io` Kubernetes `Custom Resource Definition`:
46
49
 
@@ -5,8 +5,10 @@ VERSION
5
5
  pyproject.toml
6
6
  src/chartreuse/__init__.py
7
7
  src/chartreuse/chartreuse.py
8
+ src/chartreuse/chartreuse_restore.py
8
9
  src/chartreuse/chartreuse_upgrade.py
9
10
  src/chartreuse/config_loader.py
11
+ src/chartreuse/kubernetes_helper.py
10
12
  src/chartreuse.egg-info/PKG-INFO
11
13
  src/chartreuse.egg-info/SOURCES.txt
12
14
  src/chartreuse.egg-info/dependency_links.txt
@@ -15,4 +17,5 @@ src/chartreuse.egg-info/requires.txt
15
17
  src/chartreuse.egg-info/top_level.txt
16
18
  src/chartreuse.egg-info/zip-safe
17
19
  src/chartreuse/utils/__init__.py
18
- src/chartreuse/utils/alembic_migration_helper.py
20
+ src/chartreuse/utils/alembic_migration_helper.py
21
+ src/chartreuse/utils/command.py
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
+ chartreuse-restore = chartreuse.chartreuse_restore:main
2
3
  chartreuse-upgrade = chartreuse.chartreuse_upgrade:main
@@ -1,8 +1,8 @@
1
1
  alembic
2
- psycopg[binary]
2
+ kubernetes
3
+ psycopg2
3
4
  pydantic>=2.0.0
4
5
  PyYAML
5
- wiremind-kubernetes~=7.0
6
6
 
7
7
  [dev]
8
8
  ruff
@@ -1 +0,0 @@
1
- 7.0.0-dev0
File without changes
File without changes
File without changes