""" Unit tests for the Deis api app. Run the tests with "./manage.py test api" """ import json from django.contrib.auth.models import User from django.core.cache import cache from unittest import mock from rest_framework.authtoken.models import Token from test.support import EnvironmentVarGuard from api.models import App, Build, Release from scheduler import KubeException from api.tests import adapter, mock_port, DeisTransactionTestCase import requests_mock @requests_mock.Mocker(real_http=True, adapter=adapter) @mock.patch('api.models.release.publish_release', lambda *args: None) @mock.patch('api.models.release.docker_get_port', mock_port) class PodTest(DeisTransactionTestCase): """Tests creation of pods on nodes""" fixtures = ['tests.json'] def setUp(self): self.user = User.objects.get(username='autotest') self.token = Token.objects.get(user=self.user).key self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token) def tearDown(self): # make sure every test has a clean slate for k8s mocking cache.clear() def test_container_api_heroku(self, mock_requests): app_id = self.create_app() # should start with zero url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 0) # post a new build url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': { 'web': 'node server.js', 'worker': 'node worker.js' } } response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) # scale up url = "/v2/apps/{app_id}/scale".format(**locals()) # test setting one proc type at a time body = {'web': 4} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) body = {'worker': 2} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 6) url = "/v2/apps/{app_id}".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) # ensure the structure field is up-to-date self.assertEqual(response.data['structure']['web'], 4) self.assertEqual(response.data['structure']['worker'], 2) # test listing/retrieving container info url = "/v2/apps/{app_id}/pods/web".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data['count'], 4) self.assertEqual(len(response.data['results']), 4) name = response.data['results'][0]['name'] url = "/v2/apps/{app_id}/pods/web/{name}".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(response.data['count'], 1) self.assertEqual(response.data['results'][0]['name'], name) # scale down url = "/v2/apps/{app_id}/scale".format(**locals()) # test setting two proc types at a time body = {'web': 2, 'worker': 1} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 3) url = "/v2/apps/{app_id}".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) # ensure the structure field is up-to-date self.assertEqual(response.data['structure']['web'], 2) self.assertEqual(response.data['structure']['worker'], 1) # scale down to 0 url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 0, 'worker': 0} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 0) url = "/v2/apps/{app_id}".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) def test_container_api_docker(self, mock_requests): app_id = self.create_app() # should start with zero url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 0) # post a new build url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'dockerfile': "FROM busybox\nCMD /bin/true" } response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) # scale up url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'cmd': 6} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 6) url = "/v2/apps/{app_id}".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) # test listing/retrieving container info url = "/v2/apps/{app_id}/pods/cmd".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 6) # scale down url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'cmd': 3} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 3) url = "/v2/apps/{app_id}".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) # scale down to 0 url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'cmd': 0} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 0) url = "/v2/apps/{app_id}".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) def test_release(self, mock_requests): app_id = self.create_app() # should start with zero url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 0) # post a new build url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': { 'web': 'node server.js', 'worker': 'node worker.js' } } response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) # scale up url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 1} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 1) self.assertEqual(response.data['results'][0]['release'], 'v2') # post a new build url = "/v2/apps/{app_id}/builds".format(**locals()) # a web proctype must exist on the second build or else the container will be removed body = { 'image': 'autotest/example', 'procfile': { 'web': 'echo hi' } } response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) self.assertEqual(response.data['image'], body['image']) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 1) self.assertEqual(response.data['results'][0]['release'], 'v3') # post new config url = "/v2/apps/{app_id}/config".format(**locals()) body = {'values': json.dumps({'KEY': 'value'})} response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 1) self.assertEqual(response.data['results'][0]['release'], 'v4') def test_container_errors(self, mock_requests): app_id = self.create_app() # create a release so we can scale app = App.objects.get(id=app_id) user = User.objects.get(username='autotest') build = Build.objects.create(owner=user, app=app, image="qwerty") # create an initial release Release.objects.create( version=2, owner=user, app=app, config=app.config_set.latest(), build=build ) url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 'not_an_int'} response = self.client.post(url, body) self.assertEqual(response.status_code, 400, response.data) self.assertEqual(response.data, {'detail': "Invalid scaling format: invalid literal for " "int() with base 10: 'not_an_int'"}) body = {'invalid': 1} response = self.client.post(url, body) self.assertContains(response, 'Container type invalid', status_code=404) def test_container_str(self, mock_requests): """Test the text representation of a container.""" app_id = self.create_app() # post a new build url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': { 'web': 'node server.js', 'worker': 'node worker.js' } } response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) # scale up url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 4, 'worker': 2} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) # should start with zero url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 6) pods = response.data['results'] for pod in pods: self.assertIn(pod['type'], ['web', 'worker']) self.assertEqual(pod['release'], 'v2') # pod name is auto generated so use regex self.assertRegex(pod['name'], app_id + '-(worker|web)-[0-9]{8,10}-[a-z0-9]{5}') def test_pod_command_format(self, mock_requests): # regression test for https://github.com/deis/deis/pull/1285 app_id = self.create_app() # post a new build url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': { 'web': 'node server.js', 'worker': 'node worker.js' } } response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) # scale up url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 1} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) # verify that the app._get_command property got formatted self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 1) pod = response.data['results'][0] self.assertEqual(pod['type'], 'web') self.assertEqual(pod['release'], 'v2') # pod name is auto generated so use regex self.assertRegex(pod['name'], app_id + '-web-[0-9]{8,10}-[a-z0-9]{5}') # verify commands data = App.objects.get(id=app_id) self.assertNotIn('{c_type}', data._get_command('web')) def test_scale_errors(self, mock_requests): app_id = self.create_app() # should start with zero url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data['results']), 0) # post a new build url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': { 'web': 'node server.js', 'worker': 'node worker.js' } } response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) # scale to a negative number url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': -1} response = self.client.post(url, body) self.assertEqual(response.status_code, 400, response.data) self.assertEqual(response.data, {"detail": "Invalid scaling format: " "['Must be greater than or equal to zero']"}) # scale to something other than a number url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 'one'} response = self.client.post(url, body) self.assertEqual(response.status_code, 400, response.data) # scale to something other than a number url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': [1]} response = self.client.post(url, body) self.assertEqual(response.status_code, 400, response.data) # scale with a non-existent proc type url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'foo': 1} response = self.client.post(url, body) self.assertEqual(response.status_code, 404, response.data) # scale up to an integer as a sanity check url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 1} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) with mock.patch('scheduler.KubeHTTPClient.scale') as mock_kube: mock_kube.side_effect = KubeException('Boom!') url = "/v2/apps/{app_id}/scale".format(**locals()) response = self.client.post(url, {'web': 10}) self.assertEqual(response.status_code, 503, response.data) def test_admin_can_manage_other_pods(self, mock_requests): """If a non-admin user creates a container, an administrator should be able to manage it. """ user = User.objects.get(username='autotest2') token = Token.objects.get(user=user).key self.client.credentials(HTTP_AUTHORIZATION='Token ' + token) app_id = self.create_app() # post a new build url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': { 'web': 'node server.js', 'worker': 'node worker.js' } } response = self.client.post(url, body) self.assertEqual(response.status_code, 201, response.data) # login as admin, scale up url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 4, 'worker': 2} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) def test_scale_without_build_should_error(self, mock_requests): """A user should not be able to scale processes unless a build is present.""" app_id = 'autotest' url = '/v2/apps' body = {'cluster': 'autotest', 'id': app_id} response = self.client.post(url, body) url = '/v2/apps/{app_id}/scale'.format(**locals()) body = {'web': '1'} response = self.client.post(url, body) self.assertEqual(response.status_code, 400, response.data) self.assertEqual(response.data, {'detail': 'No build associated with this release'}) def test_command_good(self, mock_requests): """Test the default command for each container workflow""" app_id = self.create_app() app = App.objects.get(id=app_id) user = User.objects.get(username='autotest') # Heroku Buildpack app build = Build.objects.create( owner=user, app=app, image="qwerty", procfile={ 'web': 'node server.js', 'worker': 'node worker.js' }, sha='african-swallow', dockerfile='' ) # create an initial release Release.objects.create( version=2, owner=user, app=app, config=app.config_set.latest(), build=build ) # use `start web` for backwards compatibility with slugrunner self.assertEqual(app._get_command('web'), ['start', 'web']) self.assertEqual(app._get_command('worker'), ['start', 'worker']) # switch to docker image app build.sha = '' build.save() self.assertEqual(app._get_command('web'), ["node server.js"]) # switch to dockerfile app build.sha = 'european-swallow' build.dockerfile = 'dockerdockerdocker' build.save() self.assertEqual(app._get_command('web'), ["node server.js"]) self.assertEqual(app._get_command('cmd'), []) # ensure we can override the cmd process type in a Procfile build.procfile['cmd'] = 'node server.js' build.save() self.assertEqual(app._get_entrypoint('cmd'), []) self.assertEqual(app._get_command('cmd'), ["node", "server.js"]) self.assertEqual(app._get_entrypoint('worker'), ["/bin/bash", "-c"]) self.assertEqual(app._get_command('worker'), ["node worker.js"]) # for backwards compatibility if no Procfile is supplied build.procfile = {} build.save() self.assertEqual(app._get_command('worker'), ['start', 'worker']) def test_run_command_good(self, mock_requests): """Test the run command for each container workflow""" app_id = self.create_app() app = App.objects.get(id=app_id) # dockerfile + procfile worflow build = Build.objects.create( owner=self.user, app=app, image="qwerty", procfile={ 'web': 'node server.js', 'worker': 'node worker.js' }, dockerfile='foo', sha='somereallylongsha' ) # create an initial release Release.objects.create( version=2, owner=self.user, app=app, config=app.config_set.latest(), build=build ) # create a run pod url = "/v2/apps/{app_id}/run".format(**locals()) body = {'command': 'echo hi'} response = self.client.post(url, body) self.assertEqual(response.status_code, 200, response.data) app = App.objects.get(id=app_id) self.assertEqual(app._get_entrypoint('web'), ['/bin/bash', '-c']) # docker image workflow build.dockerfile = '' build.sha = '' build.save() url = "/v2/apps/{app_id}/run".format(**locals()) body = {'command': 'echo hi'} response = self.client.post(url, body) self.assertEqual(response.status_code, 200, response.data) app = App.objects.get(id=app_id) self.assertEqual(app._get_entrypoint('cmd'), []) # procfile workflow build.sha = 'somereallylongsha' build.save() url = "/v2/apps/{app_id}/run".format(**locals()) body = {'command': 'echo hi'} response = self.client.post(url, body) self.assertEqual(response.status_code, 200, response.data) app = App.objects.get(id=app_id) self.assertEqual(app._get_entrypoint('web'), ['/runner/init']) def test_run_not_fail_on_debug(self, mock_requests): """ do a run with DEIS_DEBUG on - https://github.com/deis/controller/issues/583 """ env = EnvironmentVarGuard() env['DEIS_DEBUG'] = 'true' app_id = self.create_app() app = App.objects.get(id=app_id) # dockerfile + procfile worflow build = Build.objects.create( owner=self.user, app=app, image="qwerty", procfile={ 'web': 'node server.js', 'worker': 'node worker.js' }, dockerfile='foo', sha='somereallylongsha' ) # create an initial release Release.objects.create( version=2, owner=self.user, app=app, config=app.config_set.latest(), build=build ) # create a run pod url = "/v2/apps/{app_id}/run".format(**locals()) body = {'command': 'echo hi'} response = self.client.post(url, body) self.assertEqual(response.status_code, 200, response.data) app = App.objects.get(id=app_id) self.assertEqual(app._get_entrypoint('web'), ['/bin/bash', '-c']) def test_scaling_does_not_add_run_proctypes_to_structure(self, mock_requests): """Test that app info doesn't show transient "run" proctypes.""" app_id = self.create_app() app = App.objects.get(id=app_id) user = User.objects.get(username='autotest') # dockerfile + procfile worflow build = Build.objects.create( owner=user, app=app, image="qwerty", procfile={ 'web': 'node server.js', 'worker': 'node worker.js' }, dockerfile='foo', sha='somereallylongsha' ) # create an initial release release = Release.objects.create( version=2, owner=user, app=app, config=app.config_set.latest(), build=build ) # create a run pod url = "/v2/apps/{app_id}/run".format(**locals()) body = {'command': 'echo hi'} response = self.client.post(url, body) self.assertEqual(response.status_code, 200, response.data) # scale up url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 3} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) # test that "run" proctype isn't in the app info returned url = "/v2/apps/{app_id}".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 200, response.data) self.assertNotIn('run', response.data['structure']) def test_scale_with_unauthorized_user_returns_403(self, mock_requests): """An unauthorized user should not be able to access an app's resources. If an unauthorized user is trying to scale an app he or she does not have access to, it should return a 403. """ app_id = self.create_app() # post a new build url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': {'web': 'node server.js', 'worker': 'node worker.js'} } response = self.client.post(url, body) unauthorized_user = User.objects.get(username='autotest2') unauthorized_token = Token.objects.get(user=unauthorized_user).key self.client.credentials(HTTP_AUTHORIZATION='Token ' + unauthorized_token) # scale up with unauthorized user url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 4} response = self.client.post(url, body) self.assertEqual(response.status_code, 403) def test_modified_procfile_from_build_removes_pods(self, mock_requests): """ When a new procfile is posted which removes a certain process type, deis should stop the existing pods. """ app_id = self.create_app() # post a new build build_url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': { 'web': 'node server.js', 'worker': 'node worker.js' } } response = self.client.post(build_url, body) url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 4} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': { 'worker': 'node worker.js' } } response = self.client.post(build_url, body) self.assertEqual(response.status_code, 201, response.data) # make sure no pods are web application = App.objects.get(id=app_id) pods = application.list_pods(type='web') self.assertEqual(len(pods), 0) def test_restart_pods(self, mock_requests): app_id = self.create_app() # post a new build build_url = "/v2/apps/{app_id}/builds".format(**locals()) body = { 'image': 'autotest/example', 'sha': 'a'*40, 'procfile': { 'web': 'node server.js', 'worker': 'node worker.js' } } response = self.client.post(build_url, body) url = "/v2/apps/{app_id}/scale".format(**locals()) body = {'web': 4, 'worker': 8} response = self.client.post(url, body) self.assertEqual(response.status_code, 204, response.data) # setup app object application = App.objects.get(id=app_id) # restart all pods response = self.client.post('/v2/apps/{}/pods/restart'.format(app_id)) self.assertEqual(response.status_code, 200, response.data) # Compare restarted pods to all pods self.assertEqual(len(response.data), 12) # restart only the workers response = self.client.post('/v2/apps/{}/pods/worker/restart'.format(app_id)) self.assertEqual(response.status_code, 200, response.data) # Compare restarted pods to only worker pods self.assertEqual(len(response.data), 8) # restart only the web response = self.client.post('/v2/apps/{}/pods/web/restart'.format(app_id)) self.assertEqual(response.status_code, 200, response.data) # Compare restarted pods to only worker pods self.assertEqual(len(response.data), 4) # restart only one of the web pods pods = application.list_pods(type='web') self.assertEqual(len(pods), 4) pod = pods.pop() response = self.client.post('/v2/apps/{}/pods/web/{}/restart'.format(app_id, pod['name'])) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data), 1) self.assertEqual(response.data[0]['type'], 'web') # restart only one web port but using the short name of web-asdfg name = 'web-' + pod['name'].split('-').pop() response = self.client.post('/v2/apps/{}/pods/web/{}/restart'.format(app_id, name)) self.assertEqual(response.status_code, 200, response.data) self.assertEqual(len(response.data), 1) self.assertEqual(response.data[0]['type'], 'web') def test_list_pods_failure(self, mock_requests): """ Listing all available pods exceptions """ app_id = self.create_app() with mock.patch('scheduler.resources.pod.Pod.get') as kube_pod: with mock.patch('scheduler.resources.pod.Pod.get') as kube_pods: kube_pod.side_effect = KubeException('boom!') kube_pods.side_effect = KubeException('boom!') url = "/v2/apps/{app_id}/pods".format(**locals()) response = self.client.get(url) self.assertEqual(response.status_code, 503, response.data)