diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 9d59b96..3aa3b26 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -3,5 +3,5 @@ name: py-kube-downscaler description: A Helm chart for deploying py-kube-downscaler type: application -version: 0.2.6 -appVersion: "24.8.1" +version: 0.2.7 +appVersion: "24.8.2" diff --git a/chart/templates/rbac.yaml b/chart/templates/rbac.yaml index 929c609..408a45a 100644 --- a/chart/templates/rbac.yaml +++ b/chart/templates/rbac.yaml @@ -63,6 +63,7 @@ rules: - batch resources: - cronjobs + - jobs verbs: - get - watch diff --git a/kube_downscaler/resources/keda.py b/kube_downscaler/resources/keda.py index 9a3caee..79095fb 100644 --- a/kube_downscaler/resources/keda.py +++ b/kube_downscaler/resources/keda.py @@ -15,9 +15,11 @@ class ScaledObject(NamespacedAPIObject): @property def replicas(self): if ScaledObject.keda_pause_annotation in self.annotations: - if self.annotations[ScaledObject.keda_pause_annotation] == "0": + if self.annotations[ScaledObject.keda_pause_annotation] is None: + replicas = 1 + elif self.annotations[ScaledObject.keda_pause_annotation] == "0": replicas = 0 - elif self.annotations[ScaledObject.keda_pause_annotation] != "0": + elif self.annotations[ScaledObject.keda_pause_annotation] != "0" and self.annotations[ScaledObject.keda_pause_annotation] is not None: replicas = int(self.annotations[ScaledObject.keda_pause_annotation]) else: replicas = 1 diff --git a/tests/test_autoscale_resource.py b/tests/test_autoscale_resource.py index c185c03..8c9b71c 100644 --- a/tests/test_autoscale_resource.py +++ b/tests/test_autoscale_resource.py @@ -935,7 +935,7 @@ def test_upscale_hpa_with_autoscaling(): assert hpa.obj["spec"]["minReplicas"] == 4 assert hpa.obj["metadata"]["annotations"][ORIGINAL_REPLICAS_ANNOTATION] is None - + def test_downscale_pdb_minavailable_with_autoscaling(): pdb = PodDisruptionBudget( None, @@ -1002,6 +1002,7 @@ def test_upscale_pdb_minavailable_with_autoscaling(): assert pdb.obj["spec"]["minAvailable"] == 4 assert pdb.obj["metadata"]["annotations"][ORIGINAL_REPLICAS_ANNOTATION] is None + def test_downscale_pdb_maxunavailable_with_autoscaling(): pdb = PodDisruptionBudget( None, @@ -1068,7 +1069,7 @@ def test_upscale_pdb_maxunavailable_with_autoscaling(): assert pdb.obj["spec"]["maxUnavailable"] == 4 assert pdb.obj["metadata"]["annotations"][ORIGINAL_REPLICAS_ANNOTATION] is None - + def test_downscale_daemonset_with_autoscaling(): ds = DaemonSet( None, @@ -1168,7 +1169,6 @@ def test_downscale_scaledobject_with_pause_annotation_already_present(): tzinfo=timezone.utc ) - autoscale_resource( so, upscale_target_only=False, @@ -1185,7 +1185,7 @@ def test_downscale_scaledobject_with_pause_annotation_already_present(): # Check if the annotations have been correctly updated assert so.annotations[ScaledObject.keda_pause_annotation] == "0" - assert so.annotations[ScaledObject.last_keda_pause_annotation_if_present] == "3" + assert so.replicas == 0 def test_upscale_scaledobject_with_pause_annotation_already_present(): @@ -1206,12 +1206,10 @@ def test_upscale_scaledobject_with_pause_annotation_already_present(): } ) - now = datetime.strptime("2023-08-21T10:30:00Z", "%Y-%m-%dT%H:%M:%SZ").replace( tzinfo=timezone.utc ) - autoscale_resource( so, upscale_target_only=False, @@ -1228,9 +1226,11 @@ def test_upscale_scaledobject_with_pause_annotation_already_present(): # Check if the annotations have been correctly updated for the upscale operation assert so.annotations[ScaledObject.keda_pause_annotation] == "3" + assert so.replicas == 3 assert so.annotations.get(ScaledObject.last_keda_pause_annotation_if_present) is None -def test_downscale_scaledobject_without_pause_annotation(): + +def test_downscale_scaledobject_without_keda_pause_annotation(): so = ScaledObject( None, { @@ -1238,8 +1238,8 @@ def test_downscale_scaledobject_without_pause_annotation(): "name": "scaledobject-1", "namespace": "default", "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": {} }, - "spec": {} } ) @@ -1263,9 +1263,11 @@ def test_downscale_scaledobject_without_pause_annotation(): # Check if the annotations have been correctly updated assert so.annotations[ScaledObject.keda_pause_annotation] == "0" + assert so.annotations.get(ScaledObject.last_keda_pause_annotation_if_present) is None + assert so.replicas == 0 -def test_upscale_scaledobject_without_pause_annotation(): +def test_upscale_scaledobject_without_keda_pause_annotation(): so = ScaledObject( None, { @@ -1275,20 +1277,16 @@ def test_upscale_scaledobject_without_pause_annotation(): "creationTimestamp": "2023-08-21T10:00:00Z", "annotations": { "autoscaling.keda.sh/paused-replicas": "0", - "downscaler/original-pause-replicas": "3", "downscaler/original-replicas": "3", } }, - "spec": {} } ) - now = datetime.strptime("2023-08-21T10:30:00Z", "%Y-%m-%dT%H:%M:%SZ").replace( tzinfo=timezone.utc ) - autoscale_resource( so, upscale_target_only=False, @@ -1304,5 +1302,6 @@ def test_upscale_scaledobject_without_pause_annotation(): ) # Check if the annotations have been correctly updated for the upscale operation - assert so.annotations[ScaledObject.keda_pause_annotation] == "3" - assert so.annotations.get(ScaledObject.last_keda_pause_annotation_if_present) is None \ No newline at end of file + assert so.annotations[ScaledObject.keda_pause_annotation] is None + assert so.annotations.get(ScaledObject.last_keda_pause_annotation_if_present) is None + assert so.replicas == 1 diff --git a/tests/test_scaler.py b/tests/test_scaler.py index 46b7e30..f8f2e69 100644 --- a/tests/test_scaler.py +++ b/tests/test_scaler.py @@ -2753,4 +2753,319 @@ def get(url, version, **kwargs): }, "spec": {"minAvailable": 1}, } - assert json.loads(api.patch.call_args[1]["data"]) == patch_data \ No newline at end of file + assert json.loads(api.patch.call_args[1]["data"]) == patch_data + +def test_scaler_downscale_keda_already_with_pause_annotation(monkeypatch): + api = MagicMock() + monkeypatch.setattr( + "kube_downscaler.scaler.helper.get_kube_api", MagicMock(return_value=api) + ) + monkeypatch.setattr( + "kube_downscaler.scaler.helper.add_event", MagicMock(return_value=None) + ) + + def get(url, version, **kwargs): + if url == "pods": + data = {"items": []} + elif url == "scaledobjects": + data = { + "items": [ + { + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": "2", + "downscaler/original-pause-replicas": "2" + } + } + }, + ] + } + elif url == "namespaces/default": + data = { + "metadata": { + } + } + else: + raise Exception(f"unexpected call: {url}, {version}, {kwargs}") + + response = MagicMock() + response.json.return_value = data + return response + + api.get = get + + include_resources = frozenset(["scaledobjects"]) + scale( + constrained_downscaler=False, + namespaces=[], + upscale_period="never", + downscale_period="never", + default_uptime="never", + default_downtime="always", + upscale_target_only=False, + include_resources=include_resources, + exclude_namespaces=[], + exclude_deployments=[], + admission_controller="", + dry_run=False, + grace_period=300, + downtime_replicas=0, + enable_events=True, + matching_labels=frozenset([re.compile("")]), + ) + + assert api.patch.call_count == 1 + assert api.patch.call_args[1]["url"] == "/scaledobjects/scaledobject-1" + + patch_data = { + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": "0", + "downscaler/original-pause-replicas": "2", + "downscaler/original-replicas": "2", + } + } + } + assert json.loads(api.patch.call_args[1]["data"]) == patch_data + +def test_scaler_upscale_keda_already_with_pause_annotation(monkeypatch): + api = MagicMock() + monkeypatch.setattr( + "kube_downscaler.scaler.helper.get_kube_api", MagicMock(return_value=api) + ) + monkeypatch.setattr( + "kube_downscaler.scaler.helper.add_event", MagicMock(return_value=None) + ) + + def get(url, version, **kwargs): + if url == "pods": + data = {"items": []} + elif url == "scaledobjects": + data = { + "items": [ + { + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": "0", + "downscaler/original-pause-replicas": "3", + "downscaler/original-replicas": "3", + } + } + }, + ] + } + elif url == "namespaces/default": + data = { + "metadata": { + } + } + else: + raise Exception(f"unexpected call: {url}, {version}, {kwargs}") + + response = MagicMock() + response.json.return_value = data + return response + + api.get = get + + include_resources = frozenset(["scaledobjects"]) + scale( + constrained_downscaler=False, + namespaces=[], + upscale_period="never", + downscale_period="never", + default_uptime="always", + default_downtime="never", + upscale_target_only=False, + include_resources=include_resources, + exclude_namespaces=[], + exclude_deployments=[], + admission_controller="", + dry_run=False, + grace_period=300, + downtime_replicas=0, + enable_events=True, + matching_labels=frozenset([re.compile("")]), + ) + + assert api.patch.call_count == 1 + assert api.patch.call_args[1]["url"] == "/scaledobjects/scaledobject-1" + + patch_data = { + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": "3", + "downscaler/original-pause-replicas": None, + "downscaler/original-replicas": None, + } + } + } + assert json.loads(api.patch.call_args[1]["data"]) == patch_data + + +def test_scaler_downscale_keda_without_pause_annotation(monkeypatch): + api = MagicMock() + monkeypatch.setattr( + "kube_downscaler.scaler.helper.get_kube_api", MagicMock(return_value=api) + ) + monkeypatch.setattr( + "kube_downscaler.scaler.helper.add_event", MagicMock(return_value=None) + ) + + def get(url, version, **kwargs): + if url == "pods": + data = {"items": []} + elif url == "scaledobjects": + data = { + "items": [ + { + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + } + } + }, + ] + } + elif url == "namespaces/default": + data = { + "metadata": { + } + } + else: + raise Exception(f"unexpected call: {url}, {version}, {kwargs}") + + response = MagicMock() + response.json.return_value = data + return response + + api.get = get + + include_resources = frozenset(["scaledobjects"]) + scale( + constrained_downscaler=False, + namespaces=[], + upscale_period="never", + downscale_period="never", + default_uptime="never", + default_downtime="always", + upscale_target_only=False, + include_resources=include_resources, + exclude_namespaces=[], + exclude_deployments=[], + admission_controller="", + dry_run=False, + grace_period=300, + downtime_replicas=0, + enable_events=True, + matching_labels=frozenset([re.compile("")]), + ) + + assert api.patch.call_count == 1 + assert api.patch.call_args[1]["url"] == "/scaledobjects/scaledobject-1" + + patch_data = { + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": "0", + "downscaler/original-replicas": "1" + } + } + } + assert json.loads(api.patch.call_args[1]["data"]) == patch_data + + +def test_scaler_upscale_keda_without_pause_annotation(monkeypatch): + api = MagicMock() + monkeypatch.setattr( + "kube_downscaler.scaler.helper.get_kube_api", MagicMock(return_value=api) + ) + monkeypatch.setattr( + "kube_downscaler.scaler.helper.add_event", MagicMock(return_value=None) + ) + + def get(url, version, **kwargs): + if url == "pods": + data = {"items": []} + elif url == "scaledobjects": + data = { + "items": [ + { + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": "0", + "downscaler/original-replicas": "1" + } + } + }, + ] + } + elif url == "namespaces/default": + data = { + "metadata": { + } + } + else: + raise Exception(f"unexpected call: {url}, {version}, {kwargs}") + + response = MagicMock() + response.json.return_value = data + return response + + api.get = get + + include_resources = frozenset(["scaledobjects"]) + scale( + constrained_downscaler=False, + namespaces=[], + upscale_period="never", + downscale_period="never", + default_uptime="always", + default_downtime="never", + upscale_target_only=False, + include_resources=include_resources, + exclude_namespaces=[], + exclude_deployments=[], + admission_controller="", + dry_run=False, + grace_period=300, + downtime_replicas=0, + enable_events=True, + matching_labels=frozenset([re.compile("")]), + ) + + assert api.patch.call_count == 1 + assert api.patch.call_args[1]["url"] == "/scaledobjects/scaledobject-1" + + patch_data = { + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": None, + "downscaler/original-replicas": None + } + } + } + assert json.loads(api.patch.call_args[1]["data"]) == patch_data