Skip to content

Commit 44a26cd

Browse files
committed
fix(autoscale): Fix for autoscale on k8s-1.9+ without breaking manual scaling
1 parent 4c5e5af commit 44a26cd

7 files changed

Lines changed: 138 additions & 79 deletions

File tree

charts/controller/templates/controller-clusterrole.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ rules:
4444
- apiGroups: ["extensions", "apps"]
4545
resources: ["deployments"]
4646
verbs: ["get", "list", "create", "update", "delete"]
47-
- apiGroups: ["extensions"]
47+
- apiGroups: ["extensions", "apps"]
4848
resources: ["deployments/scale", "replicasets/scale"]
4949
verbs: ["get", "update"]
5050
- apiGroups: ["extensions", "autoscaling"]

rootfs/api/models/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,10 @@ def autoscale(self, proc_type, autoscale):
10221022
"""
10231023
name = '{}-{}'.format(self.id, proc_type)
10241024
# basically fake out a Deployment object (only thing we use) to assign to the HPA
1025-
target = {'kind': 'Deployment', 'metadata': {'name': name}}
1025+
target = {
1026+
'apiVersion': 'extensions/v1beta1',
1027+
'kind': 'Deployment',
1028+
'metadata': {'name': name}}
10261029

10271030
try:
10281031
# get the target for autoscaler, in this case Deployment

rootfs/scheduler/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from collections import OrderedDict
22
from datetime import datetime
33
import logging
4-
from packaging.version import Version
4+
from packaging.version import Version, parse
55
import requests
66
import requests.exceptions
77
from requests_toolbelt import user_agent
8+
import re
89
import time
910
from urllib.parse import urljoin
1011

@@ -84,7 +85,9 @@ def version(self):
8485
raise KubeHTTPException(response, 'fetching Kubernetes version')
8586

8687
data = response.json()
87-
return Version('{}.{}'.format(data['major'], data['minor']))
88+
parsed_version = parse(
89+
re.sub("[^0-9\.]", '', str('{}.{}'.format(data['major'], data['minor']))))
90+
return Version('{}'.format(parsed_version))
8891

8992
@staticmethod
9093
def parse_date(date):

rootfs/scheduler/resources/deployment.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
from datetime import datetime, timedelta
22
import json
33
import time
4+
from packaging.version import parse
45
from scheduler.resources import Resource
56
from scheduler.exceptions import KubeException, KubeHTTPException
67

78

89
class Deployment(Resource):
910
api_prefix = 'apis'
10-
api_version = 'extensions/v1beta1'
11+
12+
@property
13+
def api_version(self):
14+
if self.version() >= parse("1.9.0"):
15+
return 'extensions/v1beta1'
16+
return 'extensions/v1beta1'
1117

1218
def get(self, namespace, name=None, **kwargs):
1319
"""
@@ -43,7 +49,7 @@ def manifest(self, namespace, name, image, entrypoint, command, spec_annotations
4349

4450
manifest = {
4551
'kind': 'Deployment',
46-
'apiVersion': 'extensions/v1beta1',
52+
'apiVersion': self.api_version,
4753
'metadata': {
4854
'name': name,
4955
'labels': labels,

rootfs/scheduler/resources/horizontalpodautoscaler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def manifest(self, namespace, name, app_type, target, **kwargs):
7575
manifest['spec']['targetCPUUtilizationPercentage'] = cpu_percent
7676

7777
manifest['spec']['scaleTargetRef'] = {
78+
'apiVersion': target['apiVersion'],
7879
# only works with Deployments, RS and RC
7980
'kind': target['kind'],
8081
'name': target['metadata']['name'],

rootfs/scheduler/tests/test_deployments.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
44
Run the tests with './manage.py test scheduler'
55
"""
6+
import copy
7+
from unittest import mock
8+
from packaging.version import parse, Version, InvalidVersion
69
from scheduler import KubeHTTPException, KubeException
710
from scheduler.tests import TestCase
811
from scheduler.utils import generate_random_name
@@ -73,6 +76,57 @@ def scale(self, namespace=None, name=generate_random_name(), **kwargs):
7376
self.scheduler.scale(namespace, name, **kwargs)
7477
return name
7578

79+
def test_good_init_api_version(self):
80+
try:
81+
data = "1.13"
82+
Version('{}'.format(data))
83+
except InvalidVersion:
84+
self.fail("Version {} raised InvalidVersion exception!".format(data))
85+
86+
def test_bad_init_api_version(self):
87+
data = "1.13+"
88+
with self.assertRaises(
89+
InvalidVersion,
90+
msg='packaging.version.InvalidVersion: Invalid version: {}'.format(data) # noqa
91+
):
92+
Version('{}'.format(data))
93+
94+
def test_deployment_api_version_1_9_and_up(self):
95+
cases = ['1.12', '1.11', '1.10', '1.9']
96+
97+
deployment = copy.copy(self.scheduler.deployment)
98+
99+
expected = 'extensions/v1beta1'
100+
101+
for canonical in cases:
102+
deployment.version = mock.MagicMock(return_value=parse(canonical))
103+
actual = deployment.api_version
104+
self.assertEqual(
105+
expected,
106+
actual,
107+
"{} breaks - expected {}, got {}".format(
108+
canonical,
109+
expected,
110+
actual))
111+
112+
def test_deployment_api_version_1_8_and_lower(self):
113+
cases = ['1.8', '1.7', '1.6', '1.5', '1.4', '1.3', '1.2']
114+
115+
deployment = copy.copy(self.scheduler.deployment)
116+
117+
expected = 'extensions/v1beta1'
118+
119+
for canonical in cases:
120+
deployment.version = mock.MagicMock(return_value=parse(canonical))
121+
actual = deployment.api_version
122+
self.assertEqual(
123+
expected,
124+
actual,
125+
"{} breaks - expected {}, got {}".format(
126+
canonical,
127+
expected,
128+
actual))
129+
76130
def test_create_failure(self):
77131
with self.assertRaises(
78132
KubeHTTPException,
Lines changed: 65 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,106 +1,98 @@
11
"""
2-
Unit tests for the Drycc scheduler module.
2+
Unit tests for the Deis scheduler module.
33
44
Run the tests with "./manage.py test scheduler"
55
"""
6-
import json
76
import requests
87
import requests_mock
98
from unittest import mock
9+
from packaging.version import parse
1010

11-
from django.conf import settings
1211
from django.test import TestCase
1312

1413
import scheduler
15-
from scheduler import exceptions
1614

1715

18-
def mock_session():
16+
def mock_session_for_version(blah=None):
1917
return requests.Session()
2018

2119

2220
def connection_refused_matcher(request):
2321
raise requests.ConnectionError("connection refused")
2422

2523

26-
@mock.patch('scheduler.get_session', mock_session)
24+
@mock.patch('scheduler.get_session', mock_session_for_version)
2725
class KubeHTTPClientTest(TestCase):
28-
"""Tests kubernetes HTTP client calls"""
26+
"""Tests kubernetes HTTP client version calls"""
2927

3028
def setUp(self):
3129
self.adapter = requests_mock.Adapter()
32-
self.path = '/foo'
33-
self.url = settings.SCHEDULER_URL + self.path
30+
self.url = 'http://versiontest.example.com'
31+
self.path = '/version'
32+
3433
# use the real scheduler client.
35-
self.scheduler = scheduler.KubeHTTPClient(settings.SCHEDULER_URL)
34+
self.scheduler = scheduler.KubeHTTPClient(self.url)
3635
self.scheduler.session.mount(self.url, self.adapter)
3736

38-
def test_head(self):
39-
"""
40-
Test that calling .http_head() uses the client session to make a HEAD request.
41-
"""
42-
self.adapter.register_uri('HEAD', self.url)
43-
response = self.scheduler.http_head(self.path)
44-
assert response is not None
45-
self.assertTrue(self.adapter.called)
46-
self.assertEqual(self.adapter.call_count, 1)
47-
# ensure that connection errors get raised as a KubeException
48-
self.adapter.add_matcher(connection_refused_matcher)
49-
with self.assertRaises(exceptions.KubeException):
50-
self.scheduler.http_head(self.path)
51-
52-
def test_get(self):
53-
"""
54-
Test that calling .http_get() uses the client session to make a GET request.
55-
"""
56-
self.adapter.register_uri('GET', self.url)
57-
response = self.scheduler.http_get(self.path)
58-
assert response is not None
59-
self.assertTrue(self.adapter.called)
60-
self.assertEqual(self.adapter.call_count, 1)
61-
# ensure that connection errors get raised as a KubeException
62-
self.adapter.add_matcher(connection_refused_matcher)
63-
with self.assertRaises(exceptions.KubeException):
64-
self.scheduler.http_get(self.path)
65-
66-
def test_post(self):
37+
def test_version_for_gke(self):
6738
"""
68-
Test that calling .http_post() uses the client session to make a POST request.
39+
Ensure that version() sanitizes info from GKE clusters
6940
"""
70-
self.adapter.register_uri('POST', self.url)
71-
response = self.scheduler.http_post(self.path, data=json.dumps({'hello': 'world'}))
72-
assert response is not None
73-
self.assertTrue(self.adapter.called)
74-
self.assertEqual(self.adapter.call_count, 1)
75-
# ensure that connection errors get raised as a KubeException
76-
self.adapter.add_matcher(connection_refused_matcher)
77-
with self.assertRaises(exceptions.KubeException):
78-
self.scheduler.http_post(self.path)
79-
80-
def test_put(self):
41+
42+
cases = {
43+
"1.12": {"major": "1", "minor": "12-gke"},
44+
"1.10": {"major": "1", "minor": "10-gke"},
45+
"1.9": {"major": "1", "minor": "9-gke"},
46+
"1.8": {"major": "1", "minor": "8-gke"},
47+
}
48+
49+
for canonical in cases:
50+
resp = cases[canonical]
51+
self.adapter.register_uri('GET', self.url + self.path, json=resp)
52+
53+
expected = parse(canonical)
54+
actual = self.scheduler.version()
55+
56+
self.assertEqual(expected, actual, "{} breaks".format(resp))
57+
58+
def test_version_for_eks(self):
8159
"""
82-
Test that calling .http_put() uses the client session to make a PUT request.
60+
Ensure that version() sanitizes info from EKS clusters
8361
"""
84-
self.adapter.register_uri('PUT', self.url)
85-
response = self.scheduler.http_put(self.path, data=json.dumps({'hello': 'world'}))
86-
assert response is not None
87-
self.assertTrue(self.adapter.called)
88-
self.assertEqual(self.adapter.call_count, 1)
89-
# ensure that connection errors get raised as a KubeException
90-
self.adapter.add_matcher(connection_refused_matcher)
91-
with self.assertRaises(exceptions.KubeException):
92-
self.scheduler.http_put(self.path)
93-
94-
def test_delete(self):
62+
63+
cases = {
64+
"1.12": {"major": "1", "minor": "12+"},
65+
"1.10": {"major": "1", "minor": "10+"},
66+
"1.9": {"major": "1", "minor": "9+"},
67+
"1.8": {"major": "1", "minor": "8+"},
68+
}
69+
70+
for canonical in cases:
71+
resp = cases[canonical]
72+
self.adapter.register_uri('GET', self.url + self.path, json=resp)
73+
74+
expected = parse(canonical)
75+
actual = self.scheduler.version()
76+
77+
self.assertEqual(expected, actual, "{} breaks".format(resp))
78+
79+
def test_version_vanilla(self):
9580
"""
96-
Test that calling .http_delete() uses the client session to make a DELETE request.
81+
Ensure that version() sanitizes info from vanilla k8s clusters
9782
"""
98-
self.adapter.register_uri('DELETE', self.url)
99-
response = self.scheduler.http_delete(self.path)
100-
assert response is not None
101-
self.assertTrue(self.adapter.called)
102-
self.assertEqual(self.adapter.call_count, 1)
103-
# ensure that connection errors get raised as a KubeException
104-
self.adapter.add_matcher(connection_refused_matcher)
105-
with self.assertRaises(exceptions.KubeException):
106-
self.scheduler.http_delete(self.path)
83+
84+
cases = {
85+
"1.12": {"major": "1", "minor": "12"},
86+
"1.10": {"major": "1", "minor": "10"},
87+
"1.9": {"major": "1", "minor": "9"},
88+
"1.8": {"major": "1", "minor": "8"},
89+
}
90+
91+
for canonical in cases:
92+
resp = cases[canonical]
93+
self.adapter.register_uri('GET', self.url + self.path, json=resp)
94+
95+
expected = parse(canonical)
96+
actual = self.scheduler.version()
97+
98+
self.assertEqual(expected, actual, "{} breaks".format(resp))

0 commit comments

Comments
 (0)