Skip to content
This repository was archived by the owner on Oct 3, 2020. It is now read-only.

Commit 123686d

Browse files
Jason Gwartzhjacobs
authored andcommitted
Scale back up if exclude annotation is added while downscaled (#45)
* Scale back up if exclude annotation is added while downscaled * Scales back up when namespace is excluded * Adds log message before continue (so that it's tracked in test coverage)
1 parent 54c9741 commit 123686d

File tree

2 files changed

+92
-11
lines changed

2 files changed

+92
-11
lines changed

kube_downscaler/scaler.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,19 @@ def pods_force_uptime(api, namespace: str):
3535

3636
def autoscale_resource(resource: pykube.objects.NamespacedAPIObject,
3737
default_uptime: str, default_downtime: str, forced_uptime: bool, dry_run: bool,
38-
now: datetime.datetime, grace_period: int, downtime_replicas: int):
38+
now: datetime.datetime, grace_period: int, downtime_replicas: int, namespace_excluded=False):
3939
try:
4040
# any value different from "false" will ignore the resource (to be on the safe side)
41-
exclude = resource.annotations.get(EXCLUDE_ANNOTATION, 'false').lower() != 'false'
42-
if exclude:
41+
exclude = namespace_excluded or (resource.annotations.get(EXCLUDE_ANNOTATION, 'false').lower() != 'false')
42+
original_replicas = resource.annotations.get(ORIGINAL_REPLICAS_ANNOTATION)
43+
downtime_replicas = resource.annotations.get(DOWNTIME_REPLICAS_ANNOTATION, downtime_replicas)
44+
45+
if exclude and not original_replicas:
4346
logger.debug('%s %s/%s was excluded', resource.kind, resource.namespace, resource.name)
4447
else:
4548
replicas = resource.replicas
4649

47-
if forced_uptime:
50+
if forced_uptime or (exclude and original_replicas):
4851
uptime = "forced"
4952
downtime = "ignored"
5053
is_uptime = True
@@ -53,8 +56,6 @@ def autoscale_resource(resource: pykube.objects.NamespacedAPIObject,
5356
downtime = resource.annotations.get(DOWNTIME_ANNOTATION, default_downtime)
5457
is_uptime = helper.matches_time_spec(now, uptime) and not helper.matches_time_spec(now, downtime)
5558

56-
original_replicas = resource.annotations.get(ORIGINAL_REPLICAS_ANNOTATION)
57-
downtime_replicas = resource.annotations.get(DOWNTIME_REPLICAS_ANNOTATION, downtime_replicas)
5859
logger.debug('%s %s/%s has %s replicas (original: %s, uptime: %s)',
5960
resource.kind, resource.namespace, resource.name, replicas, original_replicas, uptime)
6061
update_needed = False
@@ -94,21 +95,20 @@ def autoscale_resources(api, kind, namespace: str,
9495
now: datetime.datetime, grace_period: int, downtime_replicas: int):
9596
for resource in kind.objects(api, namespace=(namespace or pykube.all)):
9697
if resource.namespace in exclude_namespaces or resource.name in exclude_names:
98+
logger.debug('Resource %s was excluded (either resource itself or namespace %s are excluded)', resource.name, namespace)
9799
continue
98100

99101
# Override defaults with (optional) annotations from Namespace
100102
namespace_obj = pykube.Namespace.objects(api).get_by_name(resource.namespace)
101103

102-
if namespace_obj.annotations.get(EXCLUDE_ANNOTATION, 'false').lower() != 'false':
103-
logger.debug('Namespace %s was excluded (because of namespace annotation)', namespace)
104-
continue
104+
excluded = namespace_obj.annotations.get(EXCLUDE_ANNOTATION, 'false').lower() != 'false'
105105

106106
default_uptime_for_namespace = namespace_obj.annotations.get(UPTIME_ANNOTATION, default_uptime)
107107
default_downtime_for_namespace = namespace_obj.annotations.get(DOWNTIME_ANNOTATION, default_downtime)
108108
forced_uptime_for_namespace = namespace_obj.annotations.get(FORCE_UPTIME_ANNOTATION, forced_uptime)
109109

110110
autoscale_resource(resource, default_uptime_for_namespace, default_downtime_for_namespace,
111-
forced_uptime_for_namespace, dry_run, now, grace_period, downtime_replicas)
111+
forced_uptime_for_namespace, dry_run, now, grace_period, downtime_replicas, namespace_excluded=excluded)
112112

113113

114114
def scale(namespace: str, default_uptime: str, default_downtime: str, kinds: FrozenSet[str],

tests/test_scaler.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
from unittest.mock import MagicMock
33

4-
from kube_downscaler.scaler import scale, ORIGINAL_REPLICAS_ANNOTATION, DOWNTIME_REPLICAS_ANNOTATION
4+
from kube_downscaler.scaler import scale, ORIGINAL_REPLICAS_ANNOTATION, EXCLUDE_ANNOTATION, DOWNTIME_REPLICAS_ANNOTATION
55

66

77
def test_scaler_always_up(monkeypatch):
@@ -186,3 +186,84 @@ def get(url, version, **kwargs):
186186
assert api.patch.call_args[1]['url'] == 'deployments/deploy-1'
187187
assert json.loads(api.patch.call_args[1]['data'])['spec']['replicas'] == ORIGINAL
188188
assert not json.loads(api.patch.call_args[1]['data'])['metadata']['annotations'][ORIGINAL_REPLICAS_ANNOTATION]
189+
190+
191+
def test_scaler_upscale_on_exclude(monkeypatch):
192+
api = MagicMock()
193+
monkeypatch.setattr('kube_downscaler.scaler.helper.get_kube_api', MagicMock(return_value=api))
194+
ORIGINAL_REPLICAS = 2
195+
196+
def get(url, version, **kwargs):
197+
if url == 'pods':
198+
data = {'items': []}
199+
elif url == 'deployments':
200+
data = {'items': [
201+
{
202+
'metadata': {
203+
'name': 'deploy-1', 'namespace': 'default',
204+
'annotations': {
205+
EXCLUDE_ANNOTATION: 'true',
206+
ORIGINAL_REPLICAS_ANNOTATION: ORIGINAL_REPLICAS,
207+
}
208+
},
209+
'spec': {'replicas': 0}},
210+
]}
211+
elif url == 'namespaces/default':
212+
data = {'metadata': {}}
213+
else:
214+
raise Exception(f'unexpected call: {url}, {version}, {kwargs}')
215+
216+
response = MagicMock()
217+
response.json.return_value = data
218+
return response
219+
220+
api.get = get
221+
222+
kinds = frozenset(['deployment'])
223+
scale(namespace=None, default_uptime='never', default_downtime='always', kinds=kinds,
224+
exclude_namespaces=[], exclude_deployments=[], exclude_statefulsets=[], dry_run=False, grace_period=300, downtime_replicas=0)
225+
226+
assert api.patch.call_count == 1
227+
assert api.patch.call_args[1]['url'] == 'deployments/deploy-1'
228+
assert json.loads(api.patch.call_args[1]['data'])["spec"]["replicas"] == ORIGINAL_REPLICAS
229+
assert not json.loads(api.patch.call_args[1]['data'])["metadata"]["annotations"][ORIGINAL_REPLICAS_ANNOTATION]
230+
231+
232+
def test_scaler_upscale_on_exclude_namespace(monkeypatch):
233+
api = MagicMock()
234+
monkeypatch.setattr('kube_downscaler.scaler.helper.get_kube_api', MagicMock(return_value=api))
235+
ORIGINAL_REPLICAS = 2
236+
237+
def get(url, version, **kwargs):
238+
if url == 'pods':
239+
data = {'items': []}
240+
elif url == 'deployments':
241+
data = {'items': [
242+
{
243+
'metadata': {
244+
'name': 'deploy-1', 'namespace': 'default',
245+
"annotations": {
246+
ORIGINAL_REPLICAS_ANNOTATION: ORIGINAL_REPLICAS,
247+
}
248+
},
249+
'spec': {'replicas': 0}},
250+
]}
251+
elif url == 'namespaces/default':
252+
data = {'metadata': {'annotations': {EXCLUDE_ANNOTATION: 'true'}}}
253+
else:
254+
raise Exception(f'unexpected call: {url}, {version}, {kwargs}')
255+
256+
response = MagicMock()
257+
response.json.return_value = data
258+
return response
259+
260+
api.get = get
261+
262+
kinds = frozenset(['deployment'])
263+
scale(namespace=None, default_uptime='never', default_downtime='always', kinds=kinds,
264+
exclude_namespaces=[], exclude_deployments=[], exclude_statefulsets=[], dry_run=False, grace_period=300, downtime_replicas=0)
265+
266+
assert api.patch.call_count == 1
267+
assert api.patch.call_args[1]['url'] == 'deployments/deploy-1'
268+
assert json.loads(api.patch.call_args[1]['data'])["spec"]["replicas"] == ORIGINAL_REPLICAS
269+
assert not json.loads(api.patch.call_args[1]['data'])["metadata"]["annotations"][ORIGINAL_REPLICAS_ANNOTATION]

0 commit comments

Comments
 (0)