diff --git a/kube_downscaler/cmd.py b/kube_downscaler/cmd.py index 1624f8f..ff4c0a9 100644 --- a/kube_downscaler/cmd.py +++ b/kube_downscaler/cmd.py @@ -47,12 +47,10 @@ def get_parser(): parser.add_argument( "--upscale-target-only", help="Upscale only resource in target when waking up namespaces", - action="store_true" + action="store_true", ) parser.add_argument( - "--namespace", - help="Namespace", - default=os.getenv("NAMESPACE", "") + "--namespace", help="Namespace", default=os.getenv("NAMESPACE", "") ) parser.add_argument( "--include-resources", @@ -100,7 +98,9 @@ def get_parser(): parser.add_argument( "--exclude-deployments", help="Exclude specific deployments from downscaling. Despite its name, this option will match the name of any included resource type (Deployment, StatefulSet, CronJob, ..). (default: py-kube-downscaler,kube-downscaler,downscaler)", - default=os.getenv("EXCLUDE_DEPLOYMENTS", "py-kube-downscaler,kube-downscaler,downscaler"), + default=os.getenv( + "EXCLUDE_DEPLOYMENTS", "py-kube-downscaler,kube-downscaler,downscaler" + ), ) parser.add_argument( "--downtime-replicas", diff --git a/kube_downscaler/main.py b/kube_downscaler/main.py index f4c47c1..cb041b6 100755 --- a/kube_downscaler/main.py +++ b/kube_downscaler/main.py @@ -79,7 +79,9 @@ def run_loop( if len(namespaces) >= 1: constrained_downscaler = True - logging.info("Namespace argument is not empty, the downscaler will run in constrained mode") + logging.info( + "Namespace argument is not empty, the downscaler will run in constrained mode" + ) else: constrained_downscaler = False diff --git a/kube_downscaler/resources/constraint.py b/kube_downscaler/resources/constraint.py index 54427c2..d4c3fa9 100644 --- a/kube_downscaler/resources/constraint.py +++ b/kube_downscaler/resources/constraint.py @@ -2,7 +2,6 @@ class KubeDownscalerJobsConstraint(APIObject): - """Support the Gatakeeper Admission Controller Custom CRDs (https://open-policy-agent.github.io/gatekeeper/website/docs).""" version = "constraints.gatekeeper.sh/v1beta1" @@ -24,15 +23,9 @@ def create_job_constraint(resource_name): "kind": "KubeDownscalerJobsConstraint", "metadata": { "name": resource_name, - "labels": { - "origin": "kube-downscaler" - } + "labels": {"origin": "kube-downscaler"}, }, - "spec": { - "match": { - "namespaces": [resource_name] - } - } + "spec": {"match": {"namespaces": [resource_name]}}, } return obj diff --git a/kube_downscaler/resources/constrainttemplate.py b/kube_downscaler/resources/constrainttemplate.py index dc8ab4a..fb917e5 100644 --- a/kube_downscaler/resources/constrainttemplate.py +++ b/kube_downscaler/resources/constrainttemplate.py @@ -4,7 +4,6 @@ class ConstraintTemplate(APIObject): - """Support the Gatakeeper Admission Controller Custom CRDs (https://open-policy-agent.github.io/gatekeeper/website/docs).""" version = "templates.gatekeeper.sh/v1" @@ -13,8 +12,7 @@ class ConstraintTemplate(APIObject): @staticmethod def create_constraint_template_crd(excluded_jobs, matching_labels): - - excluded_jobs_regex = '^(' + '|'.join(excluded_jobs) + ')$' + excluded_jobs_regex = "^(" + "|".join(excluded_jobs) + ")$" # For backwards compatibility, if the matching_labels FrozenSet has an empty string as the first element, # we don't ignore anything @@ -22,7 +20,9 @@ def create_constraint_template_crd(excluded_jobs, matching_labels): first_element_str = first_element.pattern if first_element_str == "": - logger.debug("Matching_labels arg set to empty string: all resources are considered in the scaling process") + logger.debug( + "Matching_labels arg set to empty string: all resources are considered in the scaling process" + ) matching_labels_arg_is_present = False else: matching_labels_arg_is_present = True @@ -30,19 +30,29 @@ def create_constraint_template_crd(excluded_jobs, matching_labels): if matching_labels_arg_is_present: matching_labels_rego_string: str = "\n" for pattern in matching_labels: - matching_labels_rego_string = matching_labels_rego_string + " has_matched_labels(\"" + pattern.pattern + "\", input.review.object.metadata.labels)\n" + matching_labels_rego_string = ( + matching_labels_rego_string + + ' has_matched_labels("' + + pattern.pattern + + '", input.review.object.metadata.labels)\n' + ) else: matching_labels_rego_string: str = "" - rego = """ + rego = ( + """ package kubedownscalerjobsconstraint violation[{"msg": msg}] { input.review.kind.kind == "Job" not exist_owner_reference - not exact_match(\"""" + excluded_jobs_regex + """\", input.review.object.metadata.name) + not exact_match(\"""" + + excluded_jobs_regex + + """\", input.review.object.metadata.name) not has_exclude_annotation - not is_exclude_until_date_reached""" + matching_labels_rego_string + """ + not is_exclude_until_date_reached""" + + matching_labels_rego_string + + """ msg := "Job creation is not allowed in this namespace during a kube-downscaler downtime period." } @@ -73,6 +83,7 @@ def create_constraint_template_crd(excluded_jobs, matching_labels): regex.match(pattern, equals_value_contact) } """ + ) obj = { "apiVersion": "templates.gatekeeper.sh/v1", @@ -82,24 +93,13 @@ def create_constraint_template_crd(excluded_jobs, matching_labels): "annotations": { "metadata.gatekeeper.sh/title": "Kube Downscaler Jobs Constraint", "metadata.gatekeeper.sh/version": "1.0.0", - "description": "Policy to downscale jobs in certain namespaces." - } + "description": "Policy to downscale jobs in certain namespaces.", + }, }, "spec": { - "crd": { - "spec": { - "names": { - "kind": "KubeDownscalerJobsConstraint" - } - } - }, - "targets": [ - { - "target": "admission.k8s.gatekeeper.sh", - "rego": rego - } - ] - } + "crd": {"spec": {"names": {"kind": "KubeDownscalerJobsConstraint"}}}, + "targets": [{"target": "admission.k8s.gatekeeper.sh", "rego": rego}], + }, } return obj diff --git a/kube_downscaler/resources/keda.py b/kube_downscaler/resources/keda.py index e06e0ad..aa6c6cd 100644 --- a/kube_downscaler/resources/keda.py +++ b/kube_downscaler/resources/keda.py @@ -2,7 +2,6 @@ class ScaledObject(NamespacedAPIObject): - """Support the ScaledObject resource (https://keda.sh/docs/2.7/concepts/scaling-deployments/#scaledobject-spec).""" version = "keda.sh/v1alpha1" @@ -21,7 +20,10 @@ def replicas(self): replicas = -1 elif self.annotations[ScaledObject.keda_pause_annotation] == "0": replicas = 0 - elif self.annotations[ScaledObject.keda_pause_annotation] != "0" and self.annotations[ScaledObject.keda_pause_annotation] is not None: + 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/kube_downscaler/resources/policy.py b/kube_downscaler/resources/policy.py index f73ee9b..eda1f88 100644 --- a/kube_downscaler/resources/policy.py +++ b/kube_downscaler/resources/policy.py @@ -2,7 +2,6 @@ class KubeDownscalerJobsPolicy(NamespacedAPIObject): - """Support the Kyverno Admission Controller Custom CRDs (https://kyverno.io/docs/introduction/#quick-start).""" version = "kyverno.io/v1" @@ -19,29 +18,21 @@ def create_job_policy(namespace): "namespace": namespace, "labels": { "origin": "kube-downscaler", - "kube-downscaler/policy-type": "without-matching-labels" + "kube-downscaler/policy-type": "without-matching-labels", }, "annotations": { "policies.kyverno.io/title": "Kube Downscaler Jobs Policy", "policies.kyverno.io/severity": "medium", "policies.kyverno.io/subject": "Job", - "policies.kyverno.io/description": "Job creation is not allowed in this namespace during a kube-downscaler downtime period." - } + "policies.kyverno.io/description": "Job creation is not allowed in this namespace during a kube-downscaler downtime period.", + }, }, "spec": { "validationFailureAction": "Enforce", "rules": [ { "name": "kube-downscaler-jobs-policy", - "match": { - "any": [ - { - "resources": { - "kinds": ["Job"] - } - } - ] - }, + "match": {"any": [{"resources": {"kinds": ["Job"]}}]}, "validate": { "message": "Job creation is not allowed in this namespace during a kube-downscaler downtime period.", "deny": { @@ -50,25 +41,25 @@ def create_job_policy(namespace): { "key": "{{ request.object.metadata.ownerReferences || 'null'}}", "operator": "Equals", - "value": "null" + "value": "null", }, { "key": "{{request.object.metadata.annotations.\"downscaler/exclude\" || ''}}", "operator": "NotEquals", - "value": "true" + "value": "true", }, { "key": "{{ time_after('{{ time_now() }}','{{ request.object.metadata.annotations.\"downscaler/exclude-until\" || '1970-01-01T00:00:00Z' }}') }}", "operator": "Equals", - "value": True - } + "value": True, + }, ] } - } - } + }, + }, } - ] - } + ], + }, } return obj @@ -83,35 +74,27 @@ def create_job_policy_with_matching_labels(namespace, matching_labels): "namespace": namespace, "labels": { "origin": "kube-downscaler", - "kube-downscaler/policy-type": "with-matching-labels" + "kube-downscaler/policy-type": "with-matching-labels", }, "annotations": { "policies.kyverno.io/description": "Job creation is not allowed in this namespace during a kube-downscaler downtime period.", "policies.kyverno.io/severity": "medium", "policies.kyverno.io/subject": "Job", - "policies.kyverno.io/title": "Kube Downscaler Jobs Policy" - } + "policies.kyverno.io/title": "Kube Downscaler Jobs Policy", + }, }, "spec": { "validationFailureAction": "Enforce", "rules": [ { - "match": { - "any": [ - { - "resources": { - "kinds": ["Job"] - } - } - ] - }, + "match": {"any": [{"resources": {"kinds": ["Job"]}}]}, "name": "kube-downscaler-jobs-policy", "preconditions": { "all": [ { "key": "{{ request.object.metadata.labels || 'NoLabel'}}", "operator": "NotEquals", - "value": "NoLabel" + "value": "NoLabel", } ] }, @@ -120,8 +103,8 @@ def create_job_policy_with_matching_labels(namespace, matching_labels): "name": "labels", "variable": { "jmesPath": "items(request.object.metadata.labels, 'key', 'value')", - "default": [] - } + "default": [], + }, } ], "validate": { @@ -135,56 +118,63 @@ def create_job_policy_with_matching_labels(namespace, matching_labels): { "key": "{{ request.object.metadata.ownerReferences || 'null'}}", "operator": "Equals", - "value": "null" + "value": "null", }, { "key": "{{request.object.metadata.annotations.\"downscaler/exclude\" || ''}}", "operator": "NotEquals", - "value": "true" + "value": "true", }, { "key": "{{ time_after('{{ time_now() }}','{{ request.object.metadata.annotations.\"downscaler/exclude-until\" || '1970-01-01T00:00:00Z' }}') }}", "operator": "Equals", - "value": True - } + "value": True, + }, ] } - } + }, } - ] - } + ], + }, } - ] - } + ], + }, } for pattern in matching_labels: matching_labels_condition = { - "key": "{{ regex_match('" + pattern.pattern + "', '{{element.key}}={{element.value}}') }}", + "key": "{{ regex_match('" + + pattern.pattern + + "', '{{element.key}}={{element.value}}') }}", "operator": "Equals", - "value": True + "value": True, } - obj["spec"]["rules"][0]["validate"]["foreach"][0]["deny"]["conditions"]["all"].append( - matching_labels_condition) + obj["spec"]["rules"][0]["validate"]["foreach"][0]["deny"]["conditions"][ + "all" + ].append(matching_labels_condition) return obj @staticmethod def append_excluded_jobs_condition(obj, excluded_jobs, has_matching_labels_arg): - excluded_jobs_regex = f"^({'|'.join(excluded_jobs)})$" excluded_jobs_condition = { - "key": "{{ regex_match('" + excluded_jobs_regex + "', '{{request.object.metadata.name}}') }}", + "key": "{{ regex_match('" + + excluded_jobs_regex + + "', '{{request.object.metadata.name}}') }}", "operator": "NotEquals", - "value": True + "value": True, } if has_matching_labels_arg: - obj["spec"]["rules"][0]["validate"]["foreach"][0]["deny"]["conditions"]["all"].append( - excluded_jobs_condition) + obj["spec"]["rules"][0]["validate"]["foreach"][0]["deny"]["conditions"][ + "all" + ].append(excluded_jobs_condition) else: - obj["spec"]["rules"][0]["validate"]["deny"]["conditions"]["all"].append(excluded_jobs_condition) + obj["spec"]["rules"][0]["validate"]["deny"]["conditions"]["all"].append( + excluded_jobs_condition + ) return obj diff --git a/kube_downscaler/resources/rollout.py b/kube_downscaler/resources/rollout.py index da49686..1aa2ef2 100644 --- a/kube_downscaler/resources/rollout.py +++ b/kube_downscaler/resources/rollout.py @@ -2,7 +2,6 @@ class ArgoRollout(NamespacedAPIObject): - """Support the ArgoRollout resource (https://argoproj.github.io/argo-rollouts/features/specification/).""" version = "argoproj.io/v1alpha1" diff --git a/kube_downscaler/resources/stack.py b/kube_downscaler/resources/stack.py index 8739f30..97a9f78 100644 --- a/kube_downscaler/resources/stack.py +++ b/kube_downscaler/resources/stack.py @@ -3,7 +3,6 @@ class Stack(NamespacedAPIObject, ReplicatedMixin): - """Support the Stack resource (https://github.com/zalando-incubator/stackset-controller).""" version = "zalando.org/v1" diff --git a/kube_downscaler/scaler.py b/kube_downscaler/scaler.py index 58c87de..519eb00 100644 --- a/kube_downscaler/scaler.py +++ b/kube_downscaler/scaler.py @@ -40,7 +40,7 @@ UPTIME_ANNOTATION = "downscaler/uptime" DOWNTIME_ANNOTATION = "downscaler/downtime" DOWNTIME_REPLICAS_ANNOTATION = "downscaler/downtime-replicas" -GRACE_PERIOD_ANNOTATION="downscaler/grace-period" +GRACE_PERIOD_ANNOTATION = "downscaler/grace-period" # GoLang 32-bit signed integer max value + 1. The value was choosen because 2147483647 is the max allowed # for Deployment/StatefulSet.spec.template.replicas. This value is used to allow @@ -57,7 +57,7 @@ ScaledObject, DaemonSet, PodDisruptionBudget, - Job + Job, ] TIMESTAMP_FORMATS = [ @@ -67,10 +67,7 @@ "%Y-%m-%d", ] -ADMISSION_CONTROLLERS = [ - "gatekeeper", - "kyverno" -] +ADMISSION_CONTROLLERS = ["gatekeeper", "kyverno"] logger = logging.getLogger(__name__) @@ -117,7 +114,9 @@ def within_grace_period( grace_period_annotation = resource.annotations.get(GRACE_PERIOD_ANNOTATION, None) - if grace_period_annotation is not None and is_grace_period_annotation_integer(grace_period_annotation): + if grace_period_annotation is not None and is_grace_period_annotation_integer( + grace_period_annotation + ): grace_period_annotation_integer = int(grace_period_annotation) if grace_period_annotation_integer > 0: @@ -152,17 +151,20 @@ def within_grace_period( delta = now - update_time return delta.total_seconds() <= grace_period + def within_grace_period_namespace( - resource: APIObject, - grace_period: int, - now: datetime.datetime, - deployment_time_annotation: Optional[str] = None, + resource: APIObject, + grace_period: int, + now: datetime.datetime, + deployment_time_annotation: Optional[str] = None, ): update_time = parse_time(resource.metadata["creationTimestamp"]) grace_period_annotation = resource.annotations.get(GRACE_PERIOD_ANNOTATION, None) - if grace_period_annotation is not None and is_grace_period_annotation_integer(grace_period_annotation): + if grace_period_annotation is not None and is_grace_period_annotation_integer( + grace_period_annotation + ): grace_period_annotation_integer = int(grace_period_annotation) if grace_period_annotation_integer > 0: @@ -197,6 +199,7 @@ def within_grace_period_namespace( delta = now - update_time return delta.total_seconds() <= grace_period + def pods_force_uptime(api, namespace: FrozenSet[str]): """Return True if there are any running pods which require the deployments to be scaled back up.""" pods = get_pod_resources(api, namespace) @@ -209,6 +212,7 @@ def pods_force_uptime(api, namespace: FrozenSet[str]): return True return False + def get_pod_resources(api, namespaces: FrozenSet[str]): if len(namespaces) >= 1: pods = [] @@ -218,9 +222,7 @@ def get_pod_resources(api, namespaces: FrozenSet[str]): pods += pods_query_result except requests.HTTPError as e: if e.response.status_code == 404: - logger.debug( - f"No {kind.endpoint} found in namespace {namespace} (404)" - ) + logger.debug(f"No pods found in namespace {namespace} (404)") if e.response.status_code == 403: logger.warning( f"KubeDownscaler is not authorized to access the Namespace {namespace} (403). Please check your RBAC settings if you are using constrained mode. " @@ -235,12 +237,12 @@ def get_pod_resources(api, namespaces: FrozenSet[str]): except requests.HTTPError as e: if e.response.status_code == 403: logger.warning( - f"KubeDownscaler is not authorized to perform a cluster wide query to retrieve Pods (403)" + "KubeDownscaler is not authorized to perform a cluster wide query to retrieve Pods (403)" ) else: raise e - return pods; + return pods def create_excluded_namespaces_regex(namespaces: FrozenSet[str]): @@ -254,12 +256,15 @@ def create_excluded_namespaces_regex(namespaces: FrozenSet[str]): escaped_namespaces = [re.escape(ns) for ns in namespaces] # Combine the escaped names into a single alternation pattern - combined_pattern = '|'.join(escaped_namespaces) + combined_pattern = "|".join(escaped_namespaces) # Create a regex pattern that matches any string not exactly one of the namespaces - excluded_pattern = f'^(?!{combined_pattern}$).+' + excluded_pattern = f"^(?!{combined_pattern}$).+" - logging.info("--namespace arg is not empty the --exclude-namespaces argument was modified to the following regex pattern: " + excluded_pattern) + logging.info( + "--namespace arg is not empty the --exclude-namespaces argument was modified to the following regex pattern: " + + excluded_pattern + ) # Compile and return the regex pattern return [re.compile(excluded_pattern)] @@ -297,12 +302,15 @@ def get_resources(kind, api, namespaces: FrozenSet[str], excluded_namespaces): else: raise e - return resources, excluded_namespaces; + return resources, excluded_namespaces -def scale_jobs_without_admission_controller(plural, admission_controller, constrainted_downscaler): +def scale_jobs_without_admission_controller( + plural, admission_controller, constrainted_downscaler +): return (plural == "jobs" and admission_controller == "") or constrainted_downscaler + def is_stack_deployment(resource: NamespacedAPIObject) -> bool: if resource.kind == Deployment.kind and resource.version == Deployment.version: for owner_ref in resource.metadata.get("ownerReferences", []): @@ -317,7 +325,6 @@ def is_stack_deployment(resource: NamespacedAPIObject) -> bool: def ignore_if_labels_dont_match( resource: NamespacedAPIObject, labels: FrozenSet[Pattern] ) -> bool: - # For backwards compatibility, if there is no label filter, we don't ignore anything if not any(label.pattern for label in labels): return False @@ -396,7 +403,9 @@ def get_replicas( ) elif resource.kind == "DaemonSet": if "nodeSelector" in resource.obj["spec"]["template"]["spec"]: - kube_downscaler_node_selector_dict = resource.obj["spec"]["template"]["spec"]["nodeSelector"] + kube_downscaler_node_selector_dict = resource.obj["spec"]["template"][ + "spec" + ]["nodeSelector"] else: kube_downscaler_node_selector_dict = None if kube_downscaler_node_selector_dict is None: @@ -429,21 +438,24 @@ def get_replicas( ) return replicas + def scale_up_jobs( - api, - resource: NamespacedAPIObject, - uptime, - downtime, - admission_controller: str, - dry_run: bool, - enable_events: bool, + api, + resource: NamespacedAPIObject, + uptime, + downtime, + admission_controller: str, + dry_run: bool, + enable_events: bool, ) -> APIObject: policy: APIObject = None operation = "no_scale" event_message = "Scaling up jobs" if admission_controller == "gatekeeper": - policy = KubeDownscalerJobsConstraint.objects(api).get_or_none(name=resource.name) + policy = KubeDownscalerJobsConstraint.objects(api).get_or_none( + name=resource.name + ) if policy is not None: operation = "scale_up" logger.info( @@ -453,7 +465,11 @@ def scale_up_jobs( operation = "no_scale" if admission_controller == "kyverno": policy_name = "kube-downscaler-jobs-policy" - policy = KubeDownscalerJobsPolicy.objects(api).filter(namespace=resource.name).get_or_none(name=policy_name) + policy = ( + KubeDownscalerJobsPolicy.objects(api) + .filter(namespace=resource.name) + .get_or_none(name=policy_name) + ) if policy is not None: operation = "scale_up" logger.info( @@ -473,15 +489,15 @@ def scale_up_jobs( def scale_down_jobs( - api, - resource: NamespacedAPIObject, - uptime, - downtime, - admission_controller: str, - excluded_jobs: [str], - matching_labels: FrozenSet[Pattern], - dry_run: bool, - enable_events: bool, + api, + resource: NamespacedAPIObject, + uptime, + downtime, + admission_controller: str, + excluded_jobs: [str], + matching_labels: FrozenSet[Pattern], + dry_run: bool, + enable_events: bool, ) -> dict: policy: APIObject = None operation = "no_scale" @@ -489,7 +505,9 @@ def scale_down_jobs( event_message = "Scaling down jobs" if admission_controller == "gatekeeper": - policy = KubeDownscalerJobsConstraint.objects(api).get_or_none(name=resource.name) + policy = KubeDownscalerJobsConstraint.objects(api).get_or_none( + name=resource.name + ) if policy is None: obj = KubeDownscalerJobsConstraint.create_job_constraint(resource.name) operation = "scale_down" @@ -500,7 +518,6 @@ def scale_down_jobs( obj = policy operation = "no_scale" if admission_controller == "kyverno": - # if the matching_labels FrozenSet has an empty string as the first element, we create a different kyverno policy first_element = next(iter(matching_labels), None) first_element_str = first_element.pattern @@ -510,16 +527,24 @@ def scale_down_jobs( has_matching_labels_arg = True policy_name = "kube-downscaler-jobs-policy" - policy = KubeDownscalerJobsPolicy.objects(api).filter(namespace=resource.name).get_or_none(name=policy_name) + policy = ( + KubeDownscalerJobsPolicy.objects(api) + .filter(namespace=resource.name) + .get_or_none(name=policy_name) + ) if policy is None: if has_matching_labels_arg: - obj = KubeDownscalerJobsPolicy.create_job_policy_with_matching_labels(resource.name, matching_labels) + obj = KubeDownscalerJobsPolicy.create_job_policy_with_matching_labels( + resource.name, matching_labels + ) else: obj = KubeDownscalerJobsPolicy.create_job_policy(resource.name) if len(excluded_jobs) > 0: - obj = KubeDownscalerJobsPolicy.append_excluded_jobs_condition(obj, excluded_jobs, has_matching_labels_arg) + obj = KubeDownscalerJobsPolicy.append_excluded_jobs_condition( + obj, excluded_jobs, has_matching_labels_arg + ) operation = "scale_down" logger.info( f"Suspending jobs for {resource.kind}/{resource.name} (uptime: {uptime}, downtime: {downtime})" @@ -528,29 +553,47 @@ def scale_down_jobs( if has_matching_labels_arg and policy.type == "with-matching-labels": obj = policy operation = "no_scale" - logging.debug("No need to update kyverno policy, correctly found a policy with matching label") + logging.debug( + "No need to update kyverno policy, correctly found a policy with matching label" + ) elif has_matching_labels_arg and policy.type != "with-matching-labels": operation = "kyverno_update" - obj = KubeDownscalerJobsPolicy.create_job_policy_with_matching_labels(resource.name, matching_labels) + obj = KubeDownscalerJobsPolicy.create_job_policy_with_matching_labels( + resource.name, matching_labels + ) if len(excluded_jobs) > 0: - obj = KubeDownscalerJobsPolicy.append_excluded_jobs_condition(obj, excluded_jobs, - has_matching_labels_arg) - logging.debug("Update needed for kyverno policy, found a policy without matching label but need a policy with matching label") - elif not has_matching_labels_arg and policy.type == "without-matching-labels": + obj = KubeDownscalerJobsPolicy.append_excluded_jobs_condition( + obj, excluded_jobs, has_matching_labels_arg + ) + logging.debug( + "Update needed for kyverno policy, found a policy without matching label but need a policy with matching label" + ) + elif ( + not has_matching_labels_arg and policy.type == "without-matching-labels" + ): obj = policy operation = "no_scale" - logging.debug("No need to update kyverno policy, correctly found a policy without matching label") - elif not has_matching_labels_arg and policy.type != "without-matching-labels": + logging.debug( + "No need to update kyverno policy, correctly found a policy without matching label" + ) + elif ( + not has_matching_labels_arg and policy.type != "without-matching-labels" + ): operation = "kyverno_update" obj = KubeDownscalerJobsPolicy.create_job_policy(resource.name) if len(excluded_jobs) > 0: - obj = KubeDownscalerJobsPolicy.append_excluded_jobs_condition(obj, excluded_jobs, - has_matching_labels_arg) - logging.debug("Update needed for kyverno policy, found a policy with matching label but need a policy without matching label") + obj = KubeDownscalerJobsPolicy.append_excluded_jobs_condition( + obj, excluded_jobs, has_matching_labels_arg + ) + logging.debug( + "Update needed for kyverno policy, found a policy with matching label but need a policy without matching label" + ) else: obj = policy operation = "no_scale" - logging.debug("No Update Needed For Policy, all conditions were not met") + logging.debug( + "No Update Needed For Policy, all conditions were not met" + ) if enable_events: helper.add_event( resource, @@ -561,6 +604,7 @@ def scale_down_jobs( ) return obj, operation + def scale_up( resource: NamespacedAPIObject, replicas: int, @@ -572,7 +616,9 @@ def scale_up( ): event_message = "Scaling up replicas" if resource.kind == "DaemonSet": - resource.obj["spec"]["template"]["spec"]["nodeSelector"]["kube-downscaler-non-existent"] = None + resource.obj["spec"]["template"]["spec"]["nodeSelector"][ + "kube-downscaler-non-existent" + ] = None logger.info( f"Unsuspending {resource.kind} {resource.namespace}/{resource.name} (uptime: {uptime}, downtime: {downtime})" ) @@ -606,10 +652,19 @@ def scale_up( ) elif resource.kind == "ScaledObject": if ScaledObject.last_keda_pause_annotation_if_present in resource.annotations: - if resource.annotations[ScaledObject.last_keda_pause_annotation_if_present] is not None: - paused_replicas = resource.annotations[ScaledObject.last_keda_pause_annotation_if_present] - resource.annotations[ScaledObject.keda_pause_annotation] = paused_replicas - resource.annotations[ScaledObject.last_keda_pause_annotation_if_present] = None + if ( + resource.annotations[ScaledObject.last_keda_pause_annotation_if_present] + is not None + ): + paused_replicas = resource.annotations[ + ScaledObject.last_keda_pause_annotation_if_present + ] + resource.annotations[ScaledObject.keda_pause_annotation] = ( + paused_replicas + ) + resource.annotations[ + ScaledObject.last_keda_pause_annotation_if_present + ] = None else: resource.annotations[ScaledObject.keda_pause_annotation] = None logger.info( @@ -644,7 +699,9 @@ def scale_down( if resource.kind == "DaemonSet": if "nodeSelector" not in resource.obj["spec"]["template"]["spec"]: resource.obj["spec"]["template"]["spec"]["nodeSelector"] = {} - resource.obj["spec"]["template"]["spec"]["nodeSelector"]["kube-downscaler-non-existent"] = "true" + resource.obj["spec"]["template"]["spec"]["nodeSelector"][ + "kube-downscaler-non-existent" + ] = "true" logger.info( f"Suspending {resource.kind} {resource.namespace}/{resource.name} (uptime: {uptime}, downtime: {downtime})" ) @@ -679,8 +736,12 @@ def scale_down( elif resource.kind == "ScaledObject": if ScaledObject.keda_pause_annotation in resource.annotations: if resource.annotations[ScaledObject.keda_pause_annotation] is not None: - paused_replicas = resource.annotations[ScaledObject.keda_pause_annotation] - resource.annotations[ScaledObject.last_keda_pause_annotation_if_present] = paused_replicas + paused_replicas = resource.annotations[ + ScaledObject.keda_pause_annotation + ] + resource.annotations[ + ScaledObject.last_keda_pause_annotation_if_present + ] = paused_replicas resource.annotations[ScaledObject.keda_pause_annotation] = str(target_replicas) logger.info( f"Pausing {resource.kind} {resource.namespace}/{resource.name} (uptime: {uptime}, downtime: {downtime})" @@ -715,30 +776,28 @@ def get_annotation_value_as_int( f"Could not read annotation '{annotation_name}' as integer: {e}" ) + def autoscale_jobs_for_namespace( - api, - resource: NamespacedAPIObject, # resource here is a namespace object - upscale_period: str, - downscale_period: str, - default_uptime: str, - default_downtime: str, - forced_uptime: bool, - forced_downtime: bool, - matching_labels: FrozenSet[Pattern], - dry_run: bool, - now: datetime.datetime, - grace_period: int, - excluded_jobs: [str], - admission_controller: str, - deployment_time_annotation: Optional[str] = None, - namespace_excluded: bool = False, - enable_events: bool = False, + api, + resource: NamespacedAPIObject, # resource here is a namespace object + upscale_period: str, + downscale_period: str, + default_uptime: str, + default_downtime: str, + forced_uptime: bool, + forced_downtime: bool, + matching_labels: FrozenSet[Pattern], + dry_run: bool, + now: datetime.datetime, + grace_period: int, + excluded_jobs: [str], + admission_controller: str, + deployment_time_annotation: Optional[str] = None, + namespace_excluded: bool = False, + enable_events: bool = False, ): try: - - exclude = ( - namespace_excluded - ) + exclude = namespace_excluded if exclude: logger.debug( @@ -789,11 +848,7 @@ def autoscale_jobs_for_namespace( update_needed = False - if ( - not ignore - and is_uptime - ): - + if not ignore and is_uptime: policy, operation = scale_up_jobs( api, resource, @@ -804,18 +859,14 @@ def autoscale_jobs_for_namespace( enable_events=enable_events, ) update_needed = True - elif ( - not ignore - and not is_uptime - ): + elif not ignore and not is_uptime: if within_grace_period_namespace( - resource, grace_period, now, deployment_time_annotation + resource, grace_period, now, deployment_time_annotation ): logger.info( f"{resource.kind}/{resource.name} within grace period ({grace_period}s), not scaling down jobs (yet)" ) else: - policy, operation = scale_down_jobs( api, resource, @@ -835,10 +886,15 @@ def autoscale_jobs_for_namespace( f"**DRY-RUN**: would update {policy.kind}/{policy.name} for jobs scaling inside {resource.kind}/{resource.name}" ) else: - if operation == "scale_down" and admission_controller == "gatekeeper": + if ( + operation == "scale_down" + and admission_controller == "gatekeeper" + ): logger.debug("Creating KubeDownscalerJobsConstraint") KubeDownscalerJobsConstraint(api, policy).create() - elif operation == "scale_down" and admission_controller == "kyverno": + elif ( + operation == "scale_down" and admission_controller == "kyverno" + ): logger.debug("Creating KubeDownscalerJobsPolicy") KubeDownscalerJobsPolicy(api, policy).create() elif operation == "scale_up": @@ -849,12 +905,13 @@ def autoscale_jobs_for_namespace( elif operation == "no_scale": pass else: - logging.error(f"there was an error scaling scaling inside {resource.kind}/{resource.name}") + logging.error( + f"there was an error scaling scaling inside {resource.kind}/{resource.name}" + ) except Exception as e: - logger.exception( - f"Failed to process {resource.kind} {resource.name}: {e}" - ) + logger.exception(f"Failed to process {resource.kind} {resource.name}: {e}") + def autoscale_resource( resource: NamespacedAPIObject, @@ -889,7 +946,9 @@ def autoscale_resource( if downtime_replicas_from_annotation is not None: downtime_replicas = downtime_replicas_from_annotation - exclude_condition = define_scope(exclude, original_replicas, upscale_target_only) + exclude_condition = define_scope( + exclude, original_replicas, upscale_target_only + ) if exclude_condition: logger.debug( @@ -960,9 +1019,7 @@ def autoscale_resource( elif ( not ignore and not is_uptime - and (replicas > 0 - and replicas > downtime_replicas - or replicas == -1) + and (replicas > 0 and replicas > downtime_replicas or replicas == -1) ): if within_grace_period( resource, grace_period, now, deployment_time_annotation @@ -1017,7 +1074,9 @@ def autoscale_resources( enable_events: bool = False, ): resources_by_namespace = collections.defaultdict(list) - resources, exclude_namespaces = get_resources(kind, api, namespace, exclude_namespaces) + resources, exclude_namespaces = get_resources( + kind, api, namespace, exclude_namespaces + ) try: for resource in resources: @@ -1026,7 +1085,7 @@ def autoscale_resources( f"{resource.kind} {resource.namespace}/{resource.name} was excluded (name matches exclusion list)" ) continue - if resource.kind == 'Job' and 'ownerReferences' in resource.metadata: + if resource.kind == "Job" and "ownerReferences" in resource.metadata: logger.debug( f"{resource.kind} {resource.namespace}/{resource.name} was excluded (Job with ownerReferences)" ) @@ -1034,14 +1093,10 @@ def autoscale_resources( resources_by_namespace[resource.namespace].append(resource) except requests.HTTPError as e: if e.response.status_code == 404: - logger.debug( - f"No {kind.endpoint} found in namespace {namespace} (404)" - ) + logger.debug(f"No {kind.endpoint} found in namespace {namespace} (404)") else: raise e - - for current_namespace, resources in sorted(resources_by_namespace.items()): if any( [pattern.fullmatch(current_namespace) for pattern in exclude_namespaces] @@ -1126,19 +1181,29 @@ def autoscale_resources( matching_labels=matching_labels, ) + def apply_kubedownscalerjobsconstraint_crd(excluded_names, matching_labels, api): - kube_downscaler_jobs_constraint_crd = CustomResourceDefinition.objects(api).get_or_none( - name="kubedownscalerjobsconstraint.constraints.gatekeeper.sh") - obj = ConstraintTemplate.create_constraint_template_crd(excluded_names, matching_labels) + kube_downscaler_jobs_constraint_crd = CustomResourceDefinition.objects( + api + ).get_or_none(name="kubedownscalerjobsconstraint.constraints.gatekeeper.sh") + obj = ConstraintTemplate.create_constraint_template_crd( + excluded_names, matching_labels + ) if kube_downscaler_jobs_constraint_crd is not None: if obj == kube_downscaler_jobs_constraint_crd: - logger.debug("kubedownscalerjobsconstraint.constraints.gatekeeper.sh CRD already present") + logger.debug( + "kubedownscalerjobsconstraint.constraints.gatekeeper.sh CRD already present" + ) return else: - logger.debug("kubedownscalerjobsconstraint.constraints.gatekeeper.sh CRD updated") + logger.debug( + "kubedownscalerjobsconstraint.constraints.gatekeeper.sh CRD updated" + ) ConstraintTemplate(api, obj).update(obj) else: - logger.debug("kubedownscalerjobsconstraint.constraints.gatekeeper.sh CRD created") + logger.debug( + "kubedownscalerjobsconstraint.constraints.gatekeeper.sh CRD created" + ) ConstraintTemplate(api, obj).create() time.sleep(0.02) @@ -1146,98 +1211,148 @@ def apply_kubedownscalerjobsconstraint_crd(excluded_names, matching_labels, api) def gatekeeper_constraint_template_crd_exist() -> bool: api = helper.get_kube_api() constraint_template_crd = CustomResourceDefinition.objects(api).get_or_none( - name="constrainttemplates.templates.gatekeeper.sh") + name="constrainttemplates.templates.gatekeeper.sh" + ) if constraint_template_crd is None: - logging.error("constrainttemplates.templates.gatekeeper.sh CRD not found inside the cluster") + logging.error( + "constrainttemplates.templates.gatekeeper.sh CRD not found inside the cluster" + ) return False else: - logging.debug("constrainttemplates.templates.gatekeeper.sh CRD present inside the cluster") + logging.debug( + "constrainttemplates.templates.gatekeeper.sh CRD present inside the cluster" + ) return True def gatekeeper_healthy(api) -> bool: - gatekeeper_audit = Deployment.objects(api).filter(namespace="gatekeeper-system").get_or_none( - name="gatekeeper-audit") - gatekeeper_controller_manager = Deployment.objects(api).filter(namespace="gatekeeper-system").get_or_none( - name="gatekeeper-controller-manager") + gatekeeper_audit = ( + Deployment.objects(api) + .filter(namespace="gatekeeper-system") + .get_or_none(name="gatekeeper-audit") + ) + gatekeeper_controller_manager = ( + Deployment.objects(api) + .filter(namespace="gatekeeper-system") + .get_or_none(name="gatekeeper-controller-manager") + ) kubedownscalerjobsconstraint = CustomResourceDefinition.objects(api).get_or_none( - name="kubedownscalerjobsconstraint.constraints.gatekeeper.sh") + name="kubedownscalerjobsconstraint.constraints.gatekeeper.sh" + ) if gatekeeper_audit is None or gatekeeper_controller_manager is None: - logging.debug("Health Check: gatekeeper deployments not found inside the default \"gatekeeper-system\" " - "namespace. While this is not a problem, downscaling jobs policy may not be enforced unless " - "gatekeeper is installed and healthy inside another namespace") + logging.debug( + 'Health Check: gatekeeper deployments not found inside the default "gatekeeper-system" ' + "namespace. While this is not a problem, downscaling jobs policy may not be enforced unless " + "gatekeeper is installed and healthy inside another namespace" + ) else: - if gatekeeper_audit.obj["spec"]["replicas"] > 0 and gatekeeper_controller_manager.obj["spec"]["replicas"] > 0: - logging.debug("Health Check: gatekeeper deployments are healthy inside the \"gatekeeper-system\" namespace") + if ( + gatekeeper_audit.obj["spec"]["replicas"] > 0 + and gatekeeper_controller_manager.obj["spec"]["replicas"] > 0 + ): + logging.debug( + 'Health Check: gatekeeper deployments are healthy inside the "gatekeeper-system" namespace' + ) else: logging.debug( - "Health Check: gatekeeper deployments are not healthy inside the \"gatekeeper-system\" namespace " - "downscaling jobs policy may not be enforced") + 'Health Check: gatekeeper deployments are not healthy inside the "gatekeeper-system" namespace ' + "downscaling jobs policy may not be enforced" + ) if kubedownscalerjobsconstraint is None: - logging.error("kubedownscalerjobsconstraint.constraints.gatekeeper.sh CRD not found inside the cluster") + logging.error( + "kubedownscalerjobsconstraint.constraints.gatekeeper.sh CRD not found inside the cluster" + ) return False else: return True def kyverno_healthy(api): - kyverno_admission_controller = Deployment.objects(api).filter(namespace="kyverno").get_or_none( - name="kyverno-admission-controller").obj - kyverno_background_controller = Deployment.objects(api).filter(namespace="kyverno").get_or_none( - name="kyverno-background-controller").obj - kyverno_policy_crd = CustomResourceDefinition.objects(api).get_or_none(name="policies.kyverno.io") + kyverno_admission_controller = ( + Deployment.objects(api) + .filter(namespace="kyverno") + .get_or_none(name="kyverno-admission-controller") + .obj + ) + kyverno_background_controller = ( + Deployment.objects(api) + .filter(namespace="kyverno") + .get_or_none(name="kyverno-background-controller") + .obj + ) + kyverno_policy_crd = CustomResourceDefinition.objects(api).get_or_none( + name="policies.kyverno.io" + ) if kyverno_admission_controller is None or kyverno_background_controller is None: - logging.debug("Health Check: kyverno deployments not found inside the default \"kyverno\" " - "namespace. While this is not a problem, downscaling jobs policy may not be enforced unless " - "kyverno is installed and healthy inside another namespace") + logging.debug( + 'Health Check: kyverno deployments not found inside the default "kyverno" ' + "namespace. While this is not a problem, downscaling jobs policy may not be enforced unless " + "kyverno is installed and healthy inside another namespace" + ) else: - if kyverno_admission_controller["spec"]["replicas"] > 0 and kyverno_background_controller["spec"][ - "replicas"] > 0: - logging.debug("Health Check: kyverno deployments are healthy inside the \"kyverno\" namespace") + if ( + kyverno_admission_controller["spec"]["replicas"] > 0 + and kyverno_background_controller["spec"]["replicas"] > 0 + ): + logging.debug( + 'Health Check: kyverno deployments are healthy inside the "kyverno" namespace' + ) else: - logging.debug("Health Check: kyverno deployments are not healthy inside the \"kyverno\" namespace " - "downscaling jobs policy may not be enforced") + logging.debug( + 'Health Check: kyverno deployments are not healthy inside the "kyverno" namespace ' + "downscaling jobs policy may not be enforced" + ) if kyverno_policy_crd is None: logging.error("policies.kyverno.io CRD not found inside the cluster") return False else: return True + + def autoscale_jobs( - api, - namespaces: FrozenSet[str], - exclude_namespaces: FrozenSet[Pattern], - upscale_period: str, - downscale_period: str, - default_uptime: str, - default_downtime: str, - forced_uptime: bool, - matching_labels: FrozenSet[Pattern], - dry_run: bool, - now: datetime.datetime, - grace_period: int, - admission_controller: str, - exclude_names: FrozenSet[str], - deployment_time_annotation: Optional[str] = None, - enable_events: bool = False, + api, + namespaces: FrozenSet[str], + exclude_namespaces: FrozenSet[Pattern], + upscale_period: str, + downscale_period: str, + default_uptime: str, + default_downtime: str, + forced_uptime: bool, + matching_labels: FrozenSet[Pattern], + dry_run: bool, + now: datetime.datetime, + grace_period: int, + admission_controller: str, + exclude_names: FrozenSet[str], + deployment_time_annotation: Optional[str] = None, + enable_events: bool = False, ): if admission_controller != "" and admission_controller in ADMISSION_CONTROLLERS: - - if admission_controller == "gatekeeper" and gatekeeper_constraint_template_crd_exist(): + if ( + admission_controller == "gatekeeper" + and gatekeeper_constraint_template_crd_exist() + ): apply_kubedownscalerjobsconstraint_crd(exclude_names, matching_labels, api) if admission_controller == "gatekeeper" and not gatekeeper_healthy(api): - logging.error("unable to scale jobs, there was a problem applying kubedownscalerjobsconstraint crd or it was deleted" - " from the cluster. The crd will be automatically re-applied") + logging.error( + "unable to scale jobs, there was a problem applying kubedownscalerjobsconstraint crd or it was deleted" + " from the cluster. The crd will be automatically re-applied" + ) return - elif admission_controller == "gatekeeper" and not gatekeeper_constraint_template_crd_exist(): + elif ( + admission_controller == "gatekeeper" + and not gatekeeper_constraint_template_crd_exist() + ): logging.warning( "unable to scale jobs with gatekeeper until you install constrainttemplates.templates.gatekeeper.sh " - "CRD") + "CRD" + ) return if admission_controller == "kyverno" and not kyverno_healthy(api): @@ -1255,18 +1370,18 @@ def autoscale_jobs( excluded_jobs.append(name) for current_namespace in namespaces: - if any( - [pattern.fullmatch(current_namespace.name) for pattern in exclude_namespaces] + [ + pattern.fullmatch(current_namespace.name) + for pattern in exclude_namespaces + ] ): logger.debug( f"Namespace {current_namespace.name} was excluded from job scaling (exclusion list regex matches)" ) continue - logger.debug( - f"Processing {current_namespace.name} for job scaling.." - ) + logger.debug(f"Processing {current_namespace.name} for job scaling..") excluded = ignore_resource(current_namespace, now) @@ -1284,7 +1399,9 @@ def autoscale_jobs( DOWNSCALE_PERIOD_ANNOTATION, downscale_period ) forced_uptime_value_for_namespace = str( - current_namespace.annotations.get(FORCE_UPTIME_ANNOTATION, forced_uptime) + current_namespace.annotations.get( + FORCE_UPTIME_ANNOTATION, forced_uptime + ) ) forced_downtime_value_for_namespace = str( current_namespace.annotations.get(FORCE_DOWNTIME_ANNOTATION, False) @@ -1332,12 +1449,15 @@ def autoscale_jobs( ) else: if admission_controller == "": - logger.warning("admission controller arg was not specified, unable to scale jobs") + logger.warning( + "admission controller arg was not specified, unable to scale jobs" + ) else: logger.warning( "admission controller arg is not written correctly or not supported" ) + def scale( namespaces: FrozenSet[str], upscale_period: str, @@ -1362,12 +1482,17 @@ def scale( now = datetime.datetime.now(datetime.timezone.utc) forced_uptime = pods_force_uptime(api, namespaces) - pykube.http.DEFAULT_HTTP_TIMEOUT=api_server_timeout + pykube.http.DEFAULT_HTTP_TIMEOUT = api_server_timeout for clazz in RESOURCE_CLASSES: plural = clazz.endpoint if plural in include_resources: - if scale_jobs_without_admission_controller(plural, admission_controller, constrained_downscaler) or plural != "jobs": + if ( + scale_jobs_without_admission_controller( + plural, admission_controller, constrained_downscaler + ) + or plural != "jobs" + ): autoscale_resources( api, clazz, diff --git a/tests/test_autoscale_resource.py b/tests/test_autoscale_resource.py index e451e86..9c1b7f6 100644 --- a/tests/test_autoscale_resource.py +++ b/tests/test_autoscale_resource.py @@ -1081,12 +1081,8 @@ def test_downscale_daemonset_with_autoscaling(): "namespace": "default", "creationTimestamp": "2018-10-23T21:55:00Z", }, - "spec": { - "template": { - "spec": {} - } - } - } + "spec": {"template": {"spec": {}}}, + }, ) now = datetime.strptime("2018-10-23T22:56:00Z", "%Y-%m-%dT%H:%M:%SZ").replace( tzinfo=timezone.utc @@ -1105,7 +1101,12 @@ def test_downscale_daemonset_with_autoscaling(): matching_labels=frozenset([re.compile("")]), ) - assert ds.obj["spec"]["template"]["spec"]["nodeSelector"]["kube-downscaler-non-existent"] == "true" + assert ( + ds.obj["spec"]["template"]["spec"]["nodeSelector"][ + "kube-downscaler-non-existent" + ] + == "true" + ) def test_upscale_daemonset_with_autoscaling(): @@ -1116,18 +1117,14 @@ def test_upscale_daemonset_with_autoscaling(): "name": "daemonset-1", "namespace": "default", "creationTimestamp": "2018-10-23T21:55:00Z", - "annotations": {ORIGINAL_REPLICAS_ANNOTATION: "1"} + "annotations": {ORIGINAL_REPLICAS_ANNOTATION: "1"}, }, "spec": { "template": { - "spec": { - "nodeSelector": { - "kube-downscaler-non-existent": "true" - } - } + "spec": {"nodeSelector": {"kube-downscaler-non-existent": "true"}} } - } - } + }, + }, ) print("\n" + str(ds.obj) + "\n") now = datetime.strptime("2018-10-23T22:25:00Z", "%Y-%m-%dT%H:%M:%SZ").replace( @@ -1147,7 +1144,12 @@ def test_upscale_daemonset_with_autoscaling(): matching_labels=frozenset([re.compile("")]), ) - assert ds.obj["spec"]["template"]["spec"]["nodeSelector"]["kube-downscaler-non-existent"] == None + assert ( + ds.obj["spec"]["template"]["spec"]["nodeSelector"][ + "kube-downscaler-non-existent" + ] + is None + ) def test_downscale_scaledobject_with_pause_annotation_already_present(): @@ -1159,12 +1161,10 @@ def test_downscale_scaledobject_with_pause_annotation_already_present(): "name": "scaledobject-1", "namespace": "default", "creationTimestamp": "2023-08-21T10:00:00Z", - "annotations": { - "autoscaling.keda.sh/paused-replicas": "3" - } + "annotations": {"autoscaling.keda.sh/paused-replicas": "3"}, }, - "spec": {} - } + "spec": {}, + }, ) now = datetime.strptime("2023-08-21T10:30:00Z", "%Y-%m-%dT%H:%M:%SZ").replace( @@ -1202,10 +1202,10 @@ def test_upscale_scaledobject_with_pause_annotation_already_present(): "autoscaling.keda.sh/paused-replicas": "0", # Paused replicas "downscaler/original-pause-replicas": "3", # Original replicas before pause "downscaler/original-replicas": "3", # Keeping track of original replicas - } + }, }, - "spec": {} - } + "spec": {}, + }, ) now = datetime.strptime("2023-08-21T10:30:00Z", "%Y-%m-%dT%H:%M:%SZ").replace( @@ -1229,7 +1229,9 @@ 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 + assert ( + so.annotations.get(ScaledObject.last_keda_pause_annotation_if_present) is None + ) def test_downscale_scaledobject_without_keda_pause_annotation(): @@ -1240,9 +1242,9 @@ def test_downscale_scaledobject_without_keda_pause_annotation(): "name": "scaledobject-1", "namespace": "default", "creationTimestamp": "2023-08-21T10:00:00Z", - "annotations": {} + "annotations": {}, }, - } + }, ) now = datetime.strptime("2023-08-21T10:30:00Z", "%Y-%m-%dT%H:%M:%SZ").replace( @@ -1265,7 +1267,9 @@ def test_downscale_scaledobject_without_keda_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.annotations.get(ScaledObject.last_keda_pause_annotation_if_present) is None + ) assert so.replicas == 0 @@ -1280,9 +1284,9 @@ def test_upscale_scaledobject_without_keda_pause_annotation(): "annotations": { "autoscaling.keda.sh/paused-replicas": "0", "downscaler/original-replicas": "3", - } + }, }, - } + }, ) now = datetime.strptime("2023-08-21T10:30:00Z", "%Y-%m-%dT%H:%M:%SZ").replace( @@ -1305,5 +1309,7 @@ def test_upscale_scaledobject_without_keda_pause_annotation(): # Check if the annotations have been correctly updated for the upscale operation assert so.annotations[ScaledObject.keda_pause_annotation] is None - assert so.annotations.get(ScaledObject.last_keda_pause_annotation_if_present) is None + assert ( + so.annotations.get(ScaledObject.last_keda_pause_annotation_if_present) is None + ) assert so.replicas == -1 diff --git a/tests/test_grace_period.py b/tests/test_grace_period.py index 0b24ede..8a85a28 100644 --- a/tests/test_grace_period.py +++ b/tests/test_grace_period.py @@ -7,7 +7,7 @@ from kube_downscaler.scaler import within_grace_period ANNOTATION_NAME = "my-deployment-time" -GRACE_PERIOD_ANNOTATION="downscaler/grace-period" +GRACE_PERIOD_ANNOTATION = "downscaler/grace-period" def test_within_grace_period_creation_time(): @@ -19,48 +19,45 @@ def test_within_grace_period_creation_time(): assert within_grace_period(deploy, 900, now) assert not within_grace_period(deploy, 180, now) + def test_within_grace_period_override_annotation(): now = datetime.now(timezone.utc) ts = now - timedelta(minutes=2) deploy = Deployment( None, { - "metadata": - { - "name": "grace-period-test-deployment", - "namespace": "test-namespace", - "creationTimestamp": ts.strftime("%Y-%m-%dT%H:%M:%SZ"), - "annotations": { - GRACE_PERIOD_ANNOTATION: "300" - } - } - } + "metadata": { + "name": "grace-period-test-deployment", + "namespace": "test-namespace", + "creationTimestamp": ts.strftime("%Y-%m-%dT%H:%M:%SZ"), + "annotations": {GRACE_PERIOD_ANNOTATION: "300"}, + } + }, ) assert within_grace_period(deploy, 900, now) assert not within_grace_period(deploy, 119, now) assert within_grace_period(deploy, 123, now) + def test_within_grace_period_override_wrong_annotation_value(): now = datetime.now(timezone.utc) ts = now - timedelta(minutes=5) deploy = Deployment( None, { - "metadata": - { - "name": "grace-period-test-deployment", - "namespace": "test-namespace", - "creationTimestamp": ts.strftime("%Y-%m-%dT%H:%M:%SZ"), - "annotations": { - GRACE_PERIOD_ANNOTATION: "wrong" - } - } - } + "metadata": { + "name": "grace-period-test-deployment", + "namespace": "test-namespace", + "creationTimestamp": ts.strftime("%Y-%m-%dT%H:%M:%SZ"), + "annotations": {GRACE_PERIOD_ANNOTATION: "wrong"}, + } + }, ) assert within_grace_period(deploy, 900, now) assert not within_grace_period(deploy, 180, now) + def test_within_grace_period_deployment_time_annotation(): now = datetime.now(timezone.utc) creation_time = now - timedelta(days=7) diff --git a/tests/test_ignore_if_labels_dont_match.py b/tests/test_ignore_if_labels_dont_match.py index 775bc2f..5f443b8 100644 --- a/tests/test_ignore_if_labels_dont_match.py +++ b/tests/test_ignore_if_labels_dont_match.py @@ -16,6 +16,7 @@ def resource(): deployment = Deployment(api_mock, labels_mock) yield deployment + def test_dont_ignore_if_no_labels(resource): # for backwards compatibility, if no labels are specified, don't ignore the resource assert not ignore_if_labels_dont_match(resource, frozenset([re.compile("")])) diff --git a/tests/test_resources.py b/tests/test_resources.py index 51d7c74..ac605d4 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -56,24 +56,28 @@ def test_scaledobject(): d.annotations[ScaledObject.keda_pause_annotation] = "0" assert d.replicas == 0 + def test_kubedownscalerjobsconstraint(): api_mock = MagicMock(spec=APIObject, name="APIMock") api_mock.obj = MagicMock(name="APIObjMock") d = KubeDownscalerJobsConstraint.create_job_constraint("constraint") - assert d['metadata']['name'] == "constraint" - assert d['spec']['match']['namespaces'][0] == "constraint" + assert d["metadata"]["name"] == "constraint" + assert d["spec"]["match"]["namespaces"][0] == "constraint" + def test_gatekeeper_crd(): api_mock = MagicMock(spec=NamespacedAPIObject, name="APIMock") api_mock.obj = MagicMock(name="APIObjMock") - d = ConstraintTemplate.create_constraint_template_crd(["kube-downscaler, downscaler"], matching_labels=frozenset([re.compile("")])) - assert d['metadata']['name'] == "kubedownscalerjobsconstraint" - assert "\"^(kube-downscaler, downscaler)$\"" in d['spec']['targets'][0]['rego'] + d = ConstraintTemplate.create_constraint_template_crd( + ["kube-downscaler, downscaler"], matching_labels=frozenset([re.compile("")]) + ) + assert d["metadata"]["name"] == "kubedownscalerjobsconstraint" + assert '"^(kube-downscaler, downscaler)$"' in d["spec"]["targets"][0]["rego"] def test_kubedownscalerjobspolicy(): api_mock = MagicMock(spec=NamespacedAPIObject, name="APIMock") api_mock.obj = MagicMock(name="APIObjMock") d = KubeDownscalerJobsPolicy.create_job_policy("policy") - assert d['metadata']['name'] == "kube-downscaler-jobs-policy" - assert d['metadata']['namespace'] == "policy" + assert d["metadata"]["name"] == "kube-downscaler-jobs-policy" + assert d["metadata"]["namespace"] == "policy" diff --git a/tests/test_scaler.py b/tests/test_scaler.py index df116ee..0811e04 100644 --- a/tests/test_scaler.py +++ b/tests/test_scaler.py @@ -5,7 +5,6 @@ from unittest.mock import patch from unittest.mock import PropertyMock -from kube_downscaler.resources.policy import KubeDownscalerJobsPolicy from kube_downscaler.scaler import autoscale_jobs from kube_downscaler.scaler import DOWNTIME_REPLICAS_ANNOTATION from kube_downscaler.scaler import EXCLUDE_ANNOTATION @@ -72,6 +71,7 @@ def get(url, version, **kwargs): api.patch.assert_not_called() + def test_scaler_namespace_included(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -534,6 +534,7 @@ def get(url, version, **kwargs): ORIGINAL_REPLICAS_ANNOTATION ] + def test_scaler_no_upscale_on_exclude_with_upscale_target_only(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -594,6 +595,7 @@ def get(url, version, **kwargs): assert api.patch.call_count == 0 + def test_scaler_no_upscale_on_exclude_namespace_with_upscale_target_only(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -653,6 +655,7 @@ def get(url, version, **kwargs): assert api.patch.call_count == 0 + def test_scaler_no_upscale_on_exclude_without_upscale_target_only(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -721,7 +724,10 @@ def get(url, version, **kwargs): ORIGINAL_REPLICAS_ANNOTATION ] -def test_scaler_no_upscale_on_exclude_namespace_without_upscale_target_only(monkeypatch): + +def test_scaler_no_upscale_on_exclude_namespace_without_upscale_target_only( + monkeypatch, +): api = MagicMock() monkeypatch.setattr( "kube_downscaler.scaler.helper.get_kube_api", MagicMock(return_value=api) @@ -788,6 +794,7 @@ def get(url, version, **kwargs): ORIGINAL_REPLICAS_ANNOTATION ] + def test_scaler_always_upscale(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -907,6 +914,7 @@ def get(url, version, **kwargs): assert api.patch.call_args[1]["url"] == "/deployments/deploy-1" assert json.loads(api.patch.call_args[1]["data"])["spec"]["replicas"] == SCALE_TO + def test_scaler_daemonset_suspend(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -928,12 +936,7 @@ def get(url, version, **kwargs): "namespace": "default", "creationTimestamp": "2024-02-03T16:38:00Z", }, - "spec": { - "template": { - "spec": { - } - } - } + "spec": {"template": {"spec": {}}}, }, ] } @@ -981,16 +984,13 @@ def get(url, version, **kwargs): }, "spec": { "template": { - "spec": { - "nodeSelector": { - "kube-downscaler-non-existent": "true" - } - } + "spec": {"nodeSelector": {"kube-downscaler-non-existent": "true"}} } - } + }, } assert json.loads(api.patch.call_args[1]["data"]) == patch_data + def test_scaler_daemonset_unsuspend(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -1021,7 +1021,7 @@ def get(url, version, **kwargs): } } } - } + }, }, ] } @@ -1072,15 +1072,11 @@ def get(url, version, **kwargs): "name": "daemonset-1", "namespace": "default", "creationTimestamp": "2024-02-03T16:38:00Z", - "annotations": {ORIGINAL_REPLICAS_ANNOTATION: None} + "annotations": {ORIGINAL_REPLICAS_ANNOTATION: None}, }, "spec": { "template": { - "spec": { - "nodeSelector": { - "kube-downscaler-non-existent": None - } - } + "spec": {"nodeSelector": {"kube-downscaler-non-existent": None}} } }, } @@ -1240,6 +1236,7 @@ def get(url, version, **kwargs): } assert json.loads(api.patch.call_args[1]["data"]) == patch_data + def test_scaler_job_suspend_without_admission_controller(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -1312,7 +1309,10 @@ def get(url, version, **kwargs): } assert json.loads(api.patch.call_args[1]["data"]) == patch_data -def test_scaler_job_suspend_without_admission_controller_with_owner_reference(monkeypatch): + +def test_scaler_job_suspend_without_admission_controller_with_owner_reference( + monkeypatch, +): api = MagicMock() monkeypatch.setattr( "kube_downscaler.scaler.helper.get_kube_api", MagicMock(return_value=api) @@ -1332,7 +1332,7 @@ def get(url, version, **kwargs): "name": "job-1", "namespace": "default", "creationTimestamp": "2019-03-01T16:38:00Z", - "ownerReferences": "cron-job-1" + "ownerReferences": "cron-job-1", }, "spec": {"suspend": False}, }, @@ -1373,6 +1373,7 @@ def get(url, version, **kwargs): assert api.patch.call_count == 0 + def test_scaler_job_unsuspend_without_admission_controller(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -1453,6 +1454,7 @@ def get(url, version, **kwargs): } assert json.loads(api.patch.call_args[1]["data"]) == patch_data + def test_scaler_downscale_period_no_error(monkeypatch, caplog): api = MagicMock() monkeypatch.setattr( @@ -2276,11 +2278,15 @@ def get(url, version, **kwargs): assert api.patch.call_args[1]["url"] == "/deployments/deploy-2" assert json.loads(api.patch.call_args[1]["data"])["spec"]["replicas"] == 0 + @patch("kube_downscaler.scaler.autoscale_jobs_for_namespace") @patch("kube_downscaler.scaler.Namespace") -@patch("kube_downscaler.scaler.gatekeeper_constraint_template_crd_exist", return_value=False) +@patch( + "kube_downscaler.scaler.gatekeeper_constraint_template_crd_exist", + return_value=False, +) def test_autoscale_jobs_gatekeeper_not_installed( - mock_gatekeeper_exist, mock_namespace, mock_autoscale_jobs_for_namespace + mock_gatekeeper_exist, mock_namespace, mock_autoscale_jobs_for_namespace ): mock_namespace_instance = MagicMock() mock_namespace_instance.name = "test-namespace" @@ -2311,7 +2317,7 @@ def test_autoscale_jobs_gatekeeper_not_installed( @patch("kube_downscaler.scaler.autoscale_jobs_for_namespace") @patch("kube_downscaler.scaler.Namespace") def test_autoscale_jobs_invented_admission_controller( - mock_namespace, mock_autoscale_jobs_for_namespace + mock_namespace, mock_autoscale_jobs_for_namespace ): # Mock the Namespace instance mock_namespace_instance = MagicMock() @@ -2339,23 +2345,26 @@ def test_autoscale_jobs_invented_admission_controller( mock_autoscale_jobs_for_namespace.assert_not_called() -@patch('kube_downscaler.scaler.KubeDownscalerJobsConstraint.objects', autospec=True) +@patch("kube_downscaler.scaler.KubeDownscalerJobsConstraint.objects", autospec=True) def test_scale_up_jobs_gatekeeper_policy_not_none(objects_mock): - api = MagicMock() objects_instance_mock = objects_mock.return_value objects_instance_mock.get_or_none.return_value = "Not None" policy, operation = scale_up_jobs( - MagicMock(), MagicMock(), "uptime_value", "downtime_value", - "gatekeeper", False, True + MagicMock(), + MagicMock(), + "uptime_value", + "downtime_value", + "gatekeeper", + False, + True, ) assert operation == "scale_up" -@patch('kube_downscaler.scaler.KubeDownscalerJobsPolicy.objects', autospec=True) +@patch("kube_downscaler.scaler.KubeDownscalerJobsPolicy.objects", autospec=True) def test_scale_up_jobs_kyverno_policy_not_none(objects_mock): - api = MagicMock() filter_instance_mock = objects_mock.return_value.filter.return_value filter_instance_mock.get_or_none.return_value = "Not None" @@ -2366,15 +2375,14 @@ def test_scale_up_jobs_kyverno_policy_not_none(objects_mock): "downtime_value", "kyverno", False, - True + True, ) assert operation == "scale_up" -@patch('kube_downscaler.scaler.KubeDownscalerJobsConstraint.objects', autospec=True) +@patch("kube_downscaler.scaler.KubeDownscalerJobsConstraint.objects", autospec=True) def test_scale_up_jobs_gatekeeper_policy_none(objects_mock): - api = MagicMock() objects_instance_mock = objects_mock.return_value objects_instance_mock.get_or_none.return_value = None @@ -2385,14 +2393,14 @@ def test_scale_up_jobs_gatekeeper_policy_none(objects_mock): "downtime_value", "gatekeeper", False, - True + True, ) assert operation == "no_scale" -@patch('kube_downscaler.scaler.KubeDownscalerJobsPolicy.objects', autospec=True) + +@patch("kube_downscaler.scaler.KubeDownscalerJobsPolicy.objects", autospec=True) def test_scale_up_jobs_kyverno_policy_none(objects_mock): - api = MagicMock() filter_instance_mock = objects_mock.return_value.filter.return_value filter_instance_mock.get_or_none.return_value = None @@ -2403,14 +2411,14 @@ def test_scale_up_jobs_kyverno_policy_none(objects_mock): "downtime_value", "kyverno", False, - True + True, ) assert operation == "no_scale" -@patch('kube_downscaler.scaler.KubeDownscalerJobsConstraint.objects', autospec=True) + +@patch("kube_downscaler.scaler.KubeDownscalerJobsConstraint.objects", autospec=True) def test_scale_down_jobs_gatekeeper_policy_not_none(objects_mock): - api = MagicMock() objects_instance_mock = objects_mock.return_value objects_instance_mock.get_or_none.return_value = "Not None" @@ -2423,15 +2431,14 @@ def test_scale_down_jobs_gatekeeper_policy_not_none(objects_mock): [], frozenset([re.compile("")]), False, - True + True, ) assert operation == "no_scale" -@patch('kube_downscaler.scaler.KubeDownscalerJobsPolicy.objects', autospec=True) +@patch("kube_downscaler.scaler.KubeDownscalerJobsPolicy.objects", autospec=True) def test_scale_down_jobs_kyverno_policy_not_none(objects_mock): - api = MagicMock() mock_obj = MagicMock() type(mock_obj).type = PropertyMock(return_value="with-matching-labels") filter_instance_mock = objects_mock.return_value.filter.return_value @@ -2446,15 +2453,14 @@ def test_scale_down_jobs_kyverno_policy_not_none(objects_mock): [], frozenset([re.compile(".*")]), False, - True + True, ) assert operation == "no_scale" -@patch('kube_downscaler.scaler.KubeDownscalerJobsConstraint.objects', autospec=True) +@patch("kube_downscaler.scaler.KubeDownscalerJobsConstraint.objects", autospec=True) def test_scale_down_jobs_gatekeeper_policy_none(objects_mock): - api = MagicMock() objects_instance_mock = objects_mock.return_value objects_instance_mock.get_or_none.return_value = None @@ -2467,14 +2473,14 @@ def test_scale_down_jobs_gatekeeper_policy_none(objects_mock): [], frozenset([re.compile("")]), False, - True + True, ) assert operation == "scale_down" -@patch('kube_downscaler.scaler.KubeDownscalerJobsPolicy.objects', autospec=True) + +@patch("kube_downscaler.scaler.KubeDownscalerJobsPolicy.objects", autospec=True) def test_scale_down_jobs_kyverno_policy_none(objects_mock): - api = MagicMock() filter_instance_mock = objects_mock.return_value.filter.return_value filter_instance_mock.get_or_none.return_value = None @@ -2487,11 +2493,12 @@ def test_scale_down_jobs_kyverno_policy_none(objects_mock): [], frozenset([re.compile("")]), False, - True + True, ) assert operation == "scale_down" + def test_scaler_pdb_suspend_max_unavailable(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -2513,7 +2520,7 @@ def get(url, version, **kwargs): "namespace": "default", "creationTimestamp": "2024-02-03T16:38:00Z", }, - "spec": {"maxUnavailable": 1} + "spec": {"maxUnavailable": 1}, }, ] } @@ -2563,6 +2570,7 @@ def get(url, version, **kwargs): } assert json.loads(api.patch.call_args[1]["data"]) == patch_data + def test_scaler_pdb_unsuspend_max_unavailable(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -2585,7 +2593,7 @@ def get(url, version, **kwargs): "creationTimestamp": "2024-02-03T16:38:00Z", "annotations": {ORIGINAL_REPLICAS_ANNOTATION: "1"}, }, - "spec": {"maxUnavailable": 0} + "spec": {"maxUnavailable": 0}, }, ] } @@ -2636,12 +2644,13 @@ def get(url, version, **kwargs): "name": "pdb-1", "namespace": "default", "creationTimestamp": "2024-02-03T16:38:00Z", - "annotations": {ORIGINAL_REPLICAS_ANNOTATION: None} + "annotations": {ORIGINAL_REPLICAS_ANNOTATION: None}, }, "spec": {"maxUnavailable": 1}, } assert json.loads(api.patch.call_args[1]["data"]) == patch_data + def test_scaler_pdb_suspend_min_available(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -2663,7 +2672,7 @@ def get(url, version, **kwargs): "namespace": "default", "creationTimestamp": "2024-02-03T16:38:00Z", }, - "spec": {"minAvailable": 1} + "spec": {"minAvailable": 1}, }, ] } @@ -2713,6 +2722,7 @@ def get(url, version, **kwargs): } assert json.loads(api.patch.call_args[1]["data"]) == patch_data + def test_scaler_pdb_unsuspend_min_available(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -2735,7 +2745,7 @@ def get(url, version, **kwargs): "creationTimestamp": "2024-02-03T16:38:00Z", "annotations": {ORIGINAL_REPLICAS_ANNOTATION: "1"}, }, - "spec": {"minAvailable": 0} + "spec": {"minAvailable": 0}, }, ] } @@ -2786,12 +2796,13 @@ def get(url, version, **kwargs): "name": "pdb-1", "namespace": "default", "creationTimestamp": "2024-02-03T16:38:00Z", - "annotations": {ORIGINAL_REPLICAS_ANNOTATION: None} + "annotations": {ORIGINAL_REPLICAS_ANNOTATION: None}, }, "spec": {"minAvailable": 1}, } 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( @@ -2814,16 +2825,13 @@ def get(url, version, **kwargs): "creationTimestamp": "2023-08-21T10:00:00Z", "annotations": { "autoscaling.keda.sh/paused-replicas": "2", - } + }, } }, ] } elif url == "namespaces/default": - data = { - "metadata": { - } - } + data = {"metadata": {}} else: raise Exception(f"unexpected call: {url}, {version}, {kwargs}") @@ -2858,19 +2866,20 @@ def get(url, version, **kwargs): 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", - } - } + "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( @@ -2895,16 +2904,13 @@ def get(url, version, **kwargs): "autoscaling.keda.sh/paused-replicas": "0", "downscaler/original-pause-replicas": "3", "downscaler/original-replicas": "3", - } + }, } }, ] } elif url == "namespaces/default": - data = { - "metadata": { - } - } + data = {"metadata": {}} else: raise Exception(f"unexpected call: {url}, {version}, {kwargs}") @@ -2939,16 +2945,16 @@ def get(url, version, **kwargs): 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, - } - } + "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 @@ -2973,17 +2979,13 @@ def get(url, version, **kwargs): "name": "scaledobject-1", "namespace": "default", "creationTimestamp": "2023-08-21T10:00:00Z", - "annotations": { - } + "annotations": {}, } }, ] } elif url == "namespaces/default": - data = { - "metadata": { - } - } + data = {"metadata": {}} else: raise Exception(f"unexpected call: {url}, {version}, {kwargs}") @@ -3018,15 +3020,15 @@ def get(url, version, **kwargs): 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" - } - } + "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 @@ -3053,17 +3055,14 @@ def get(url, version, **kwargs): "creationTimestamp": "2023-08-21T10:00:00Z", "annotations": { "autoscaling.keda.sh/paused-replicas": "0", - "downscaler/original-replicas": "1" - } + "downscaler/original-replicas": "1", + }, } }, ] } elif url == "namespaces/default": - data = { - "metadata": { - } - } + data = {"metadata": {}} else: raise Exception(f"unexpected call: {url}, {version}, {kwargs}") @@ -3098,18 +3097,19 @@ def get(url, version, **kwargs): 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 - } - } + "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 + def test_scaler_downscale_keda_with_downscale_replicas_annotation(monkeypatch): api = MagicMock() monkeypatch.setattr( @@ -3130,18 +3130,13 @@ def get(url, version, **kwargs): "name": "scaledobject-1", "namespace": "default", "creationTimestamp": "2023-08-21T10:00:00Z", - "annotations": { - "downscaler/downtime-replicas": "1" - } + "annotations": {"downscaler/downtime-replicas": "1"}, } }, ] } elif url == "namespaces/default": - data = { - "metadata": { - } - } + data = {"metadata": {}} else: raise Exception(f"unexpected call: {url}, {version}, {kwargs}") @@ -3176,16 +3171,16 @@ def get(url, version, **kwargs): 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": "1", - 'downscaler/downtime-replicas': '1', - 'downscaler/original-replicas': '-1' - } - } + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": "1", + "downscaler/downtime-replicas": "1", + "downscaler/original-replicas": "-1", + }, + } } assert json.loads(api.patch.call_args[1]["data"]) == patch_data @@ -3213,17 +3208,14 @@ def get(url, version, **kwargs): "annotations": { "autoscaling.keda.sh/paused-replicas": "1", "downscaler/downtime-replicas": "1", - "downscaler/original-replicas": "-1" - } + "downscaler/original-replicas": "-1", + }, } }, ] } elif url == "namespaces/default": - data = { - "metadata": { - } - } + data = {"metadata": {}} else: raise Exception(f"unexpected call: {url}, {version}, {kwargs}") @@ -3258,20 +3250,23 @@ def get(url, version, **kwargs): 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, - "downscaler/downtime-replicas": "1" - } - } + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": None, + "downscaler/original-replicas": None, + "downscaler/downtime-replicas": "1", + }, + } } assert json.loads(api.patch.call_args[1]["data"]) == patch_data -def test_scaler_downscale_keda_already_with_pause_annotation_and_downtime_replicas(monkeypatch): + +def test_scaler_downscale_keda_already_with_pause_annotation_and_downtime_replicas( + monkeypatch, +): api = MagicMock() monkeypatch.setattr( "kube_downscaler.scaler.helper.get_kube_api", MagicMock(return_value=api) @@ -3293,17 +3288,14 @@ def get(url, version, **kwargs): "creationTimestamp": "2023-08-21T10:00:00Z", "annotations": { "autoscaling.keda.sh/paused-replicas": "2", - "downscaler/downtime-replicas": "1" - } + "downscaler/downtime-replicas": "1", + }, } }, ] } elif url == "namespaces/default": - data = { - "metadata": { - } - } + data = {"metadata": {}} else: raise Exception(f"unexpected call: {url}, {version}, {kwargs}") @@ -3338,21 +3330,24 @@ def get(url, version, **kwargs): 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": "1", - "downscaler/original-pause-replicas": "2", - "downscaler/downtime-replicas": "1", - "downscaler/original-replicas": "2", - } - } + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": "1", + "downscaler/original-pause-replicas": "2", + "downscaler/downtime-replicas": "1", + "downscaler/original-replicas": "2", + }, + } } assert json.loads(api.patch.call_args[1]["data"]) == patch_data -def test_scaler_upscale_keda_already_with_pause_annotation_and_downtime_replicas(monkeypatch): + +def test_scaler_upscale_keda_already_with_pause_annotation_and_downtime_replicas( + monkeypatch, +): api = MagicMock() monkeypatch.setattr( "kube_downscaler.scaler.helper.get_kube_api", MagicMock(return_value=api) @@ -3377,16 +3372,13 @@ def get(url, version, **kwargs): "downscaler/original-pause-replicas": "2", "downscaler/downtime-replicas": "1", "downscaler/original-replicas": "2", - } + }, } }, ] } elif url == "namespaces/default": - data = { - "metadata": { - } - } + data = {"metadata": {}} else: raise Exception(f"unexpected call: {url}, {version}, {kwargs}") @@ -3421,16 +3413,16 @@ def get(url, version, **kwargs): 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": "2", - "downscaler/original-pause-replicas": None, - "downscaler/original-replicas": None, - "downscaler/downtime-replicas": "1" - } - } + "metadata": { + "name": "scaledobject-1", + "namespace": "default", + "creationTimestamp": "2023-08-21T10:00:00Z", + "annotations": { + "autoscaling.keda.sh/paused-replicas": "2", + "downscaler/original-pause-replicas": None, + "downscaler/original-replicas": None, + "downscaler/downtime-replicas": "1", + }, + } } assert json.loads(api.patch.call_args[1]["data"]) == patch_data