From 7b438d189c5f5186f6cca483fcbbc56f4c3d0fe5 Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo1991@outlook.com>
Date: Wed, 3 Jul 2024 15:05:18 +0800
Subject: [PATCH] feat(controller): add fetch ptypes and events interface and
 support delete pods

---
 .../templates/controller-clusterrole.yaml     |   2 +-
 rootfs/api/models/app.py                      | 101 +++++++++++++++
 rootfs/api/serializers/__init__.py            |  21 ++++
 rootfs/api/tasks.py                           |  17 +++
 rootfs/api/tests/test_build.py                |  10 +-
 rootfs/api/tests/test_config.py               |   2 +-
 rootfs/api/tests/test_event.py                |  77 ++++++++++++
 rootfs/api/tests/test_pods.py                 |  95 +++++---------
 rootfs/api/tests/test_ptypes.py               | 118 ++++++++++++++++++
 rootfs/api/urls.py                            |  31 +++--
 rootfs/api/views.py                           |  87 ++++++++++---
 rootfs/scheduler/mock.py                      |   7 +-
 rootfs/scheduler/resources/deployment.py      |  15 +--
 rootfs/scheduler/resources/events.py          |  28 +++--
 rootfs/scheduler/resources/namespace.py       |   8 --
 rootfs/scheduler/resources/pod.py             |  15 ++-
 rootfs/scheduler/tests/test_deployments.py    |  14 +--
 rootfs/scheduler/tests/test_events.py         |  38 ++++++
 18 files changed, 548 insertions(+), 138 deletions(-)
 create mode 100644 rootfs/api/tests/test_event.py
 create mode 100644 rootfs/api/tests/test_ptypes.py
 create mode 100644 rootfs/scheduler/tests/test_events.py

diff --git a/charts/controller/templates/controller-clusterrole.yaml b/charts/controller/templates/controller-clusterrole.yaml
index 5835b4d39..34f521743 100644
--- a/charts/controller/templates/controller-clusterrole.yaml
+++ b/charts/controller/templates/controller-clusterrole.yaml
@@ -16,7 +16,7 @@ rules:
 - apiGroups: [""]
   resources: ["nodes"]
   verbs: ["get", "list"]
-- apiGroups: [""]
+- apiGroups: ["events.k8s.io"]
   resources: ["events"]
   verbs: ["list", "create"]
 - apiGroups: [""]
diff --git a/rootfs/api/models/app.py b/rootfs/api/models/app.py
index 576df08ba..627b83d55 100644
--- a/rootfs/api/models/app.py
+++ b/rootfs/api/models/app.py
@@ -427,6 +427,107 @@ def list_pods(self, *args, **kwargs):
             self.log(err, logging.ERROR)
             raise ServiceUnavailable(err) from e
 
+    def delete_pod(self, **kwargs):
+        """Used to list basic information about pods running for a given application"""
+        pod_name = kwargs.get('pod_name')
+        try:
+            # make sure the pod is manageed by drycc
+            pod = self.scheduler().pod.get(self.id, pod_name).json()
+            if pod['metadata']['labels'].get("heritage") == "drycc":
+                self.scheduler().pod.delete(self.id, pod_name)
+        except KubeHTTPException as e:
+            # Sometimes k8s will manage to remove the pod from under us
+            if e.response.status_code != 404:
+                raise e
+
+    def describe_deployment(self, deployment_name):
+        result = []
+        try:
+            deployment = self.scheduler().deployment.get(self.id, deployment_name).json()
+            for container in deployment["spec"]["template"]['spec']["containers"]:
+                limits = container.get("resources", {}).get("limits", {})
+                result.append({
+                    "container": container["name"],
+                    "image": container["image"],
+                    "command": container.get("command", []),
+                    "args": container.get("args", []),
+                    "liveness_probe": container.get("livenessProbe", {}),
+                    "readiness_probe": container.get("readinessProbe", {}),
+                    "limits": limits,
+                    "volume_mounts": container.get("volumeMounts", []),
+                })
+        except KubeHTTPException as e:
+            if e.response.status_code != 404:
+                raise e
+        return result
+
+    def list_deployments(self, *args, **kwargs):
+        """Used to list basic information about deployments running for a given application"""
+        try:
+            labels = self._scheduler_filter(**kwargs)
+            # in case a singular deployment is requested
+            if 'name' in kwargs:
+                deployments = [self.scheduler().deployment.get(self.id, kwargs['name']).json()]
+            else:
+                deployments = self.scheduler().deployment.get(self.id, labels=labels).json()['items']  # noqa
+                if not deployments:
+                    deployments = []
+            data = []
+            for p in deployments:
+                labels = p['spec']['template']['metadata']['labels']
+                if p['metadata']['creationTimestamp']:
+                    started = p['metadata']['creationTimestamp']
+                else:
+                    started = str(
+                        datetime.now(timezone.utc).strftime(settings.DRYCC_DATETIME_FORMAT))
+                item = {
+                    'name': labels['type'],
+                    'release': labels['version'],
+                    'ready': "%s/%s" % (
+                        p["status"]["readyReplicas"],
+                        p["status"]["replicas"],
+                    ),
+                    'up_to_date': p["status"]["updatedReplicas"],
+                    'available_replicas': p["status"]["availableReplicas"],
+                    'started': started
+                }
+                data.append(item)
+            # sorting so latest start date is first
+            data.sort(key=lambda x: x['started'], reverse=True)
+            return data
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = '(list deployments): {}'.format(e)
+            self.log(err, logging.ERROR)
+            raise ServiceUnavailable(err) from e
+
+    def list_events(self, ref_kind, ref_name, *args, **kwargs):
+        try:
+            fields = {
+                "regarding.kind": ref_kind,
+                "regarding.name": ref_name
+            }
+            kwargs["fields"] = fields
+            events = self.scheduler().events.get(self.id, **kwargs).json()['items']  # noqa
+            data = []
+            for e in events:
+                item = {
+                    'reason': e['reason'],
+                    'message': e['note'],
+                    'created': e['metadata']['creationTimestamp']
+                }
+                data.append(item)
+            # sorting so latest start date is first
+            data.sort(key=lambda x: x['created'], reverse=False)
+            return data
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = '(list event): {}'.format(e)
+            self.log(err, logging.ERROR)
+            raise ServiceUnavailable(err) from e
+
     def autoscale(self, proc_type, autoscale):
         """
         Set autoscale rules for the application
diff --git a/rootfs/api/serializers/__init__.py b/rootfs/api/serializers/__init__.py
index b0ee8dafc..19b44de96 100644
--- a/rootfs/api/serializers/__init__.py
+++ b/rootfs/api/serializers/__init__.py
@@ -540,6 +540,27 @@ def to_representation(self, obj):
         return obj
 
 
+class EventSerializer(serializers.BaseSerializer):
+    reason = serializers.CharField()
+    message = serializers.CharField()
+    created = serializers.DateTimeField(required=False)
+
+    def to_representation(self, obj):
+        return obj
+
+
+class PtypesSerializer(serializers.BaseSerializer):
+    name = serializers.CharField(required=False)
+    release = serializers.CharField()
+    ready = serializers.CharField()
+    up_to_date = serializers.CharField()
+    available_replicas = serializers.CharField(required=False)
+    started = serializers.DateTimeField(required=False)
+
+    def to_representation(self, obj):
+        return obj
+
+
 class AppSettingsSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.appsettings.AppSettings` model."""
 
diff --git a/rootfs/api/tasks.py b/rootfs/api/tasks.py
index 1d198923b..73bf0154c 100644
--- a/rootfs/api/tasks.py
+++ b/rootfs/api/tasks.py
@@ -79,6 +79,23 @@ def restart_app(app, **kwargs):
         signals.request_finished.send(sender=task_id)
 
 
+@shared_task(
+    autoretry_for=(ServiceUnavailable, ),
+    retry_jitter=True,
+    retry_kwargs={'max_retries': 3}
+)
+def delete_pod(app, **kwargs):
+    task_id = uuid.uuid4().hex
+    signals.request_started.send(sender=task_id)
+    try:
+        app.delete_pod(**kwargs)
+    except Exception as e:
+        signals.got_request_exception.send(sender=task_id)
+        raise e
+    else:
+        signals.request_finished.send(sender=task_id)
+
+
 @shared_task(
     autoretry_for=(ServiceUnavailable, ),
     retry_jitter=True,
diff --git a/rootfs/api/tests/test_build.py b/rootfs/api/tests/test_build.py
index 898cbf045..e06cb9d1f 100644
--- a/rootfs/api/tests/test_build.py
+++ b/rootfs/api/tests/test_build.py
@@ -244,7 +244,7 @@ def test_build_forgotten_procfile(self, mock_requests):
         self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
 
         # scale worker
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'worker': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -304,7 +304,7 @@ def test_build_no_remove_process(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale worker
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'worker': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -340,7 +340,7 @@ def test_build_no_remove_process(self, mock_requests):
         self.assertEqual(response.json()['structure'], {'web': 1, 'worker': 1})
 
         # scale worker to make sure no info was lost
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'worker': 2}  # bump from 1 to 2
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -376,7 +376,7 @@ def test_build_forgotten_procfile_reject(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale worker
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'worker': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -472,7 +472,7 @@ def test_new_build_does_not_scale_up_automatically(self, mock_requests):
         self.assertPodContains(response.data['results'], app_id, 'web', "v2", "up")
 
         # scale to zero
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 0}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
diff --git a/rootfs/api/tests/test_config.py b/rootfs/api/tests/test_config.py
index f44d45405..b87642168 100644
--- a/rootfs/api/tests/test_config.py
+++ b/rootfs/api/tests/test_config.py
@@ -507,7 +507,7 @@ def test_unset_limits_error(self, mock_requests):
         self.assertEqual(str(response.data["detail"]), "no-exists does not exist under limits")
         # scale up
         body = {'web': 3}
-        response = self.client.post(f"/v2/apps/{app_id}/scale", body)
+        response = self.client.post(f"/v2/apps/{app_id}/ptypes/scale", body)
         self.assertEqual(response.status_code, 204, response.data)
 
         body = {
diff --git a/rootfs/api/tests/test_event.py b/rootfs/api/tests/test_event.py
new file mode 100644
index 000000000..93eb3f864
--- /dev/null
+++ b/rootfs/api/tests/test_event.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+"""
+Unit tests for the Drycc api app.
+
+Run the tests with "./manage.py test api"
+"""
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+from django.conf import settings
+
+from api.models.app import App
+
+from api.tests import adapter, DryccTransactionTestCase
+import requests_mock
+
+User = get_user_model()
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class EventTest(DryccTransactionTestCase):
+    """Tests setting and updating config values"""
+
+    fixtures = ['tests.json']
+
+    def setUp(self):
+        self.user = User.objects.get(username='autotest')
+        self.token = self.get_or_create_token(self.user)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+
+        url = '/v2/apps'
+        response = self.client.post(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
+        self.assertEqual(response.status_code, 201, response.data)
+        self.app = App.objects.all()[0]
+
+    def tearDown(self):
+        # Restore default tags to empty string
+        settings.DRYCC_DEFAULT_CONFIG_TAGS = ''
+        # make sure every test has a clean slate for k8s mocking
+        cache.clear()
+
+    def build_deploy(self, app_id):
+        # post a new build with procfile
+        url = "/v2/apps/{app_id}/builds".format(app_id=app_id)
+        body = {
+            'image': 'autotest/example',
+            'sha': 'a'*40,
+            'stack': 'heroku-18',
+            'procfile': {
+                'web': 'node server.js',
+                'worker': 'node worker.js'
+            }
+        }
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+
+    def test_events(self, mock_requests):
+        """
+        Test that config is auto-created for a new app and that
+        config can be updated using a PATCH
+        """
+        app_id = self.create_app()
+        self.build_deploy(app_id)
+
+        # list events of pod
+        url = f"/v2/apps/{app_id}/pods"
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+        pod_name = response.data['results'][0]["name"]
+        response = self.client.get(f"/v2/apps/{app_id}/pods/{pod_name}/describe/")
+        url = f"/v2/apps/{app_id}/events"
+        response = self.client.get(url, {"pod_name": pod_name})
+        self.assertEqual(response.status_code, 200, response.data)
+
+        # list events of deployment
+        url = f"/v2/apps/{app_id}/events"
+        response = self.client.get(url, {"ptype_name": f"{app_id}-web"})
+        self.assertEqual(response.status_code, 200, response.data)
diff --git a/rootfs/api/tests/test_pods.py b/rootfs/api/tests/test_pods.py
index a5d53fb1f..62ac53594 100644
--- a/rootfs/api/tests/test_pods.py
+++ b/rootfs/api/tests/test_pods.py
@@ -60,7 +60,7 @@ def test_container_api_heroku(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         # test setting one proc type at a time
         body = {'web': 4}
         response = self.client.post(url, body)
@@ -83,7 +83,7 @@ def test_container_api_heroku(self, mock_requests):
         self.assertEqual(response.data['structure']['worker'], 2)
 
         # scale down
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         # test setting two proc types at a time
         body = {'web': 2, 'worker': 1}
         response = self.client.post(url, body)
@@ -102,7 +102,7 @@ def test_container_api_heroku(self, mock_requests):
         self.assertEqual(response.data['structure']['worker'], 1)
 
         # scale down to 0
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 0, 'worker': 0}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -136,7 +136,7 @@ def test_container_api(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 6}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -151,7 +151,7 @@ def test_container_api(self, mock_requests):
         self.assertEqual(response.status_code, 200, response.data)
 
         # scale down
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 3}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -166,7 +166,7 @@ def test_container_api(self, mock_requests):
         self.assertEqual(response.status_code, 200, response.data)
 
         # scale down to 0
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 0}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -204,7 +204,7 @@ def test_release(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -243,9 +243,20 @@ def test_release(self, mock_requests):
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertPodContains(response.data['results'], app_id, "web", 'v4')
+
+        # describe pod
         pod_name = response.data['results'][0]["name"]
         response = self.client.get(f"/v2/apps/{app_id}/pods/{pod_name}/describe/")
         self.assertEqual(response.status_code, 200, response.data)
+
+        # delete pods
+        response = self.client.delete(
+            f"/v2/apps/{app_id}/pods/",
+            {'pod_ids': pod_name}
+        )
+        self.assertEqual(response.status_code, 200, response.data)
+
+        # describe no exists pod
         pod_name = "no-exists-pod-name"
         response = self.client.get(f"/v2/apps/{app_id}/pods/{pod_name}/describe/")
         self.assertEqual(response.status_code, 404, response.data)
@@ -268,7 +279,7 @@ def test_container_errors(self, mock_requests):
         )
         # deploy
         app.pipeline(release)
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 'not_an_int'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -298,7 +309,7 @@ def test_container_str(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 4, 'worker': 2}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -336,7 +347,7 @@ def test_pod_command_format(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -382,7 +393,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # scale to a negative number
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': -1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -390,7 +401,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual(app.structure["web"], 1)
 
         # scale to something other than a number
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 'one'}
         response = self.client.post(url, body)
         app = App.objects.get(id=app_id)
@@ -398,7 +409,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual(app.structure["web"], 1)
 
         # scale to something other than a number
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': [1]}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -406,7 +417,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual(app.structure["web"], 1)
 
         # scale with a non-existent proc type
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'foo': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -414,7 +425,7 @@ def test_scale_errors(self, mock_requests):
         self.assertEqual("foo" in app.structure, False)
 
         # scale up to an integer as a sanity check
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 1}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -423,7 +434,7 @@ def test_scale_errors(self, mock_requests):
 
         with mock.patch('scheduler.KubeHTTPClient.scale') as mock_kube:
             mock_kube.side_effect = KubeException('Boom!')
-            url = f"/v2/apps/{app_id}/scale"
+            url = f"/v2/apps/{app_id}/ptypes/scale"
             response = self.client.post(url, {'web': 10})
             self.assertEqual(response.status_code, 204, response.data)
             app = App.objects.get(id=app_id)
@@ -454,7 +465,7 @@ def test_admin_can_manage_other_pods(self, mock_requests):
         self.assertEqual(response.status_code, 201, response.data)
 
         # login as admin, scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 4, 'worker': 2}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -466,7 +477,7 @@ def test_scale_without_build_should_error(self, mock_requests):
         body = {'cluster': 'autotest', 'id': app_id}
         response = self.client.post(url, body)
 
-        url = f'/v2/apps/{app_id}/scale'
+        url = f'/v2/apps/{app_id}/ptypes/scale'
         body = {'web': '1'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -667,7 +678,7 @@ def test_scaling_does_not_add_run_proctypes_to_structure(self, mock_requests):
         self.assertEqual(response.status_code, 204, response.data)
 
         # scale up
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 3}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -699,7 +710,7 @@ def test_scale_with_unauthorized_user_returns_403(self, mock_requests):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + unauthorized_token)
 
         # scale up with unauthorized user
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 4}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 403)
@@ -724,7 +735,7 @@ def test_modified_procfile_from_build_removes_pods(self, mock_requests):
         }
         response = self.client.post(build_url, body)
 
-        url = f"/v2/apps/{app_id}/scale"
+        url = f"/v2/apps/{app_id}/ptypes/scale"
         body = {'web': 4}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 204, response.data)
@@ -745,48 +756,6 @@ def test_modified_procfile_from_build_removes_pods(self, mock_requests):
         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 = f"/v2/apps/{app_id}/builds"
-        body = {
-            'image': 'autotest/example',
-            'stack': 'heroku-18',
-            'sha': 'a'*40,
-            'procfile': {
-                'web': 'node server.js',
-                'worker': 'node worker.js'
-            }
-        }
-        response = self.client.post(build_url, body)
-
-        url = f"/v2/apps/{app_id}/scale"
-        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, 204, response.data)
-
-        # restart web and workers pods
-        body = {"types": "web,worker"}
-        response = self.client.post('/v2/apps/{}/pods/restart'.format(app_id), body)
-        self.assertEqual(response.status_code, 204, response.data)
-
-        # restart invalid ptypes
-        body = {"types": "web1"}
-        response = self.client.post('/v2/apps/{}/pods/restart'.format(app_id), body)
-        self.assertEqual(response.status_code, 400, response.data)
-
-        # restart only one of the web pods
-        pods = application.list_pods(type='web')
-        self.assertEqual(len(pods), 4)
-
     def test_list_pods_failure(self, mock_requests):
         """
         Listing all available pods exceptions
diff --git a/rootfs/api/tests/test_ptypes.py b/rootfs/api/tests/test_ptypes.py
new file mode 100644
index 000000000..dd1c5785c
--- /dev/null
+++ b/rootfs/api/tests/test_ptypes.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+"""
+Unit tests for the Drycc api app.
+
+Run the tests with "./manage.py test api"
+"""
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+from django.conf import settings
+
+from api.models.app import App
+
+from api.tests import adapter, DryccTransactionTestCase
+import requests_mock
+
+User = get_user_model()
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class PtypesTest(DryccTransactionTestCase):
+    """Tests setting and updating config values"""
+
+    fixtures = ['tests.json']
+
+    def setUp(self):
+        self.user = User.objects.get(username='autotest')
+        self.token = self.get_or_create_token(self.user)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+
+        url = '/v2/apps'
+        response = self.client.post(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
+        self.assertEqual(response.status_code, 201, response.data)
+        self.app = App.objects.all()[0]
+
+    def tearDown(self):
+        # Restore default tags to empty string
+        settings.DRYCC_DEFAULT_CONFIG_TAGS = ''
+        # make sure every test has a clean slate for k8s mocking
+        cache.clear()
+
+    def build_deploy(self, app_id):
+        # post a new build with procfile
+        url = "/v2/apps/{app_id}/builds".format(app_id=app_id)
+        body = {
+            'image': 'autotest/example',
+            'sha': 'a'*40,
+            'stack': 'heroku-18',
+            'procfile': {
+                'web': 'node server.js',
+                'worker': 'node worker.js'
+            }
+        }
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+
+    def test_ptype(self, mock_requests):
+        """
+        Test that config is auto-created for a new app and that
+        config can be updated using a PATCH
+        """
+        app_id = self.create_app()
+        self.build_deploy(app_id)
+
+        # list ptypes deployment
+        url = f"/v2/apps/{app_id}/ptypes"
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+
+        # describe ptype deployment
+        url = f"/v2/apps/{app_id}/ptypes/{app_id}-web/describe"
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+
+        ptype_name = "no-exists-ptype-name"
+        response = self.client.get(f"/v2/apps/{app_id}/ptypes/{ptype_name}/describe/")
+        self.assertEqual(response.status_code, 404, response.data)
+
+    def test_restart_ptypes(self, mock_requests):
+        app_id = self.create_app()
+
+        # post a new build
+        build_url = f"/v2/apps/{app_id}/builds"
+        body = {
+            'image': 'autotest/example',
+            'stack': 'heroku-18',
+            'sha': 'a'*40,
+            'procfile': {
+                'web': 'node server.js',
+                'worker': 'node worker.js'
+            }
+        }
+        response = self.client.post(build_url, body)
+
+        url = f"/v2/apps/{app_id}/ptypes/scale"
+        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 ptypes deployments
+        response = self.client.post('/v2/apps/{}/ptypes/restart'.format(app_id))
+        self.assertEqual(response.status_code, 204, response.data)
+
+        # restart web and workers ptype deployments
+        body = {"types": "web,worker"}
+        response = self.client.post('/v2/apps/{}/ptypes/restart'.format(app_id), body)
+        self.assertEqual(response.status_code, 204, response.data)
+
+        # restart invalid ptypes
+        body = {"types": "web1"}
+        response = self.client.post('/v2/apps/{}/ptypes/restart'.format(app_id), body)
+        self.assertEqual(response.status_code, 400, response.data)
+
+        # restart only one of the web ptype deployments
+        pods = application.list_pods(type='web')
+        self.assertEqual(len(pods), 4)
diff --git a/rootfs/api/urls.py b/rootfs/api/urls.py
index 9eb09bab1..69fd4b291 100644
--- a/rootfs/api/urls.py
+++ b/rootfs/api/urls.py
@@ -45,18 +45,34 @@
     re_path(
         r"^apps/(?P<id>{})/releases/?$".format(settings.APP_URL_REGEX),
         views.ReleaseViewSet.as_view({'get': 'list'})),
-    # restart pods
-    re_path(
-        r"^apps/(?P<id>{})/pods/restart/?$".format(settings.APP_URL_REGEX),
-        views.PodViewSet.as_view({'post': 'restart'})),
-    # list pods
+    # list/delete pods
     re_path(
         r"^apps/(?P<id>{})/pods/?$".format(settings.APP_URL_REGEX),
-        views.PodViewSet.as_view({'get': 'list'})),
+        views.PodViewSet.as_view({'get': 'list', 'delete': 'delete'})),
     # describe pod
     re_path(
         r"^apps/(?P<id>{})/pods/(?P<name>[-_\w]+)/describe/?$".format(settings.APP_URL_REGEX),
         views.PodViewSet.as_view({'get': 'describe'})),
+    # restart deployment/ptype's pods
+    re_path(
+        r"^apps/(?P<id>{})/ptypes/restart/?$".format(settings.APP_URL_REGEX),
+        views.PtypesViewSet.as_view({'post': 'restart'})),
+    # scale ptype replcas
+    re_path(
+        r"^apps/(?P<id>{})/ptypes/scale/?$".format(settings.APP_URL_REGEX),
+        views.PtypesViewSet.as_view({'post': 'scale'})),
+    # list ptypes
+    re_path(
+        r"^apps/(?P<id>{})/ptypes/?$".format(settings.APP_URL_REGEX),
+        views.PtypesViewSet.as_view({'get': 'list'})),
+    # describe ptypes
+    re_path(
+        r"^apps/(?P<id>{})/ptypes/(?P<name>[-_\w]+)/describe/?$".format(settings.APP_URL_REGEX),  # noqa
+        views.PtypesViewSet.as_view({'get': 'describe'})),
+    # list events
+    re_path(
+        r"^apps/(?P<id>{})/events/?$".format(settings.APP_URL_REGEX),  # noqa
+        views.EventViewSet.as_view({'get': 'list'})),
     # application domains
     re_path(
         r"^apps/(?P<id>{})/domains/(?P<domain>\**\.?[-\._\w]+)/?$".format(settings.APP_URL_REGEX),
@@ -70,9 +86,6 @@
         views.ServiceViewSet.as_view({'post': 'create_or_update',
                                      'get': 'list', 'delete': 'delete'})),
     # application actions
-    re_path(
-        r"^apps/(?P<id>{})/scale/?$".format(settings.APP_URL_REGEX),
-        views.AppViewSet.as_view({'post': 'scale'})),
     re_path(
         r"^apps/(?P<id>{})/run/?$".format(settings.APP_URL_REGEX),
         views.AppViewSet.as_view({'post': 'run'})),
diff --git a/rootfs/api/views.py b/rootfs/api/views.py
index 38f8265da..88ef7d34b 100644
--- a/rootfs/api/views.py
+++ b/rootfs/api/views.py
@@ -26,7 +26,8 @@
 from rest_framework.parsers import MultiPartParser
 
 from api import monitor, models, permissions, serializers, viewsets, authentication
-from api.tasks import scale_app, restart_app, mount_app, downstream_model_owner
+from api.tasks import scale_app, restart_app, mount_app, downstream_model_owner, \
+    delete_pod
 from api.exceptions import AlreadyExists, ServiceUnavailable, DryccException
 
 from django.views.decorators.cache import never_cache
@@ -304,14 +305,6 @@ def list(self, request, *args, **kwargs):
         serializer = self.get_serializer(instance, many=True)
         return Response(serializer.data)
 
-    def scale(self, request, **kwargs):
-        app = self.get_object()
-        latest_release = app.release_set.filter(failed=False).latest()
-        if latest_release.build is not None and latest_release.state == "created":
-            raise DryccException('There is an executing pipeline, please wait')
-        scale_app.delay(self.get_object(), request.user, request.data)
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
     def run(self, request, **kwargs):
         app = self.get_object()
         command = request.data.get('command', '').split()
@@ -429,19 +422,59 @@ def post_save(self, config):
             raise DryccException(str(e)) from e
 
 
-class PodViewSet(AppResourceViewSet):
+class PodViewSet(BaseDryccViewSet):
     model = models.app.App
     serializer_class = serializers.PodSerializer
 
     def list(self, *args, **kwargs):
-        pods = self.get_app().list_pods(*args, **kwargs)
+        pods = self.get_object().list_pods(*args, **kwargs)
         data = self.get_serializer(pods, many=True).data
         # fake out pagination for now
         pagination = {'results': data, 'count': len(data)}
         return Response(pagination, status=status.HTTP_200_OK)
 
+    def describe(self, *args, **kwargs):
+        pod_name = kwargs["name"]
+        data = self.get_object().describe_pod(pod_name)
+        if len(data) == 0:
+            return Response(status=status.HTTP_404_NOT_FOUND)
+        # fake out pagination for now
+        pagination = {'results': data, 'count': len(data)}
+        return Response(pagination, status=status.HTTP_200_OK)
+
+    def delete(self, request, **kwargs):
+        pod_names = request.data.get("pod_ids")
+        pod_names = pod_names.split(",")
+        for pod_name in set(pod_names):
+            delete_pod.delay(self.get_object(), **{"pod_name": pod_name})
+        return Response(status=status.HTTP_200_OK)
+
+
+class PtypesViewSet(BaseDryccViewSet):
+    model = models.app.App
+    serializer_class = serializers.PtypesSerializer
+
+    def get_queryset(self, *args, **kwargs):
+        return self.model.objects.all(*args, **kwargs)
+
+    def list(self, *args, **kwargs):
+        deploys = self.get_object().list_deployments(*args, **kwargs)
+        data = self.get_serializer(deploys, many=True).data
+        # fake out pagination for now
+        pagination = {'results': data, 'count': len(data)}
+        return Response(pagination, status=status.HTTP_200_OK)
+
+    def describe(self, *args, **kwargs):
+        deployment_name = kwargs["name"]
+        data = self.get_object().describe_deployment(deployment_name)
+        if len(data) == 0:
+            return Response(status=status.HTTP_404_NOT_FOUND)
+        # fake out pagination for now
+        pagination = {'results': data, 'count': len(data)}
+        return Response(pagination, status=status.HTTP_200_OK)
+
     def restart(self, request, *args, **kwargs):
-        app = self.get_app()
+        app = self.get_object()
         ptypes = []
         types = request.data.get("types", "").split(",")
         types = [ptype for ptype in set(types) if ptype != ""]
@@ -455,14 +488,32 @@ def restart(self, request, *args, **kwargs):
                 raise DryccException("process type {} is not included in procfile".
                                      format(','.join(invalid_ptypes)))
         for ptype in set(ptypes):
-            restart_app.delay(self.get_app(), **{"type": ptype})
+            restart_app.delay(app, **{"type": ptype})
         return Response(status=status.HTTP_204_NO_CONTENT)
 
-    def describe(self, *args, **kwargs):
-        pod_name = kwargs["name"]
-        data = self.get_app().describe_pod(pod_name)
-        if len(data) == 0:
-            return Response(status=status.HTTP_404_NOT_FOUND)
+    def scale(self, request, **kwargs):
+        app = self.get_object()
+        latest_release = app.release_set.filter(failed=False).latest()
+        if latest_release.build is not None and latest_release.state == "created":
+            raise DryccException('There is an executing pipeline, please wait')
+        scale_app.delay(app, request.user, request.data)
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class EventViewSet(AppResourceViewSet):
+    model = models.app.App
+    serializer_class = serializers.EventSerializer
+
+    def list(self, request, **kwargs):
+        ptype_name = request.query_params.get("ptype_name", None)
+        pod_name = request.query_params.get("pod_name", None)
+        if not any([ptype_name, pod_name]):
+            data = []
+        else:
+            ref_kind, ref_name = ("Pod", pod_name) if pod_name else \
+                ("Deployment", ptype_name)
+            events = self.get_app().list_events(ref_kind, ref_name)
+            data = self.get_serializer(events, many=True).data
         # fake out pagination for now
         pagination = {'results': data, 'count': len(data)}
         return Response(pagination, status=status.HTTP_200_OK)
diff --git a/rootfs/scheduler/mock.py b/rootfs/scheduler/mock.py
index 4dfae673c..12e28a685 100644
--- a/rootfs/scheduler/mock.py
+++ b/rootfs/scheduler/mock.py
@@ -726,7 +726,9 @@ def post(request, context):
     # Handle RC / RS / Deployments
     if resource_type in ['replicationcontrollers', 'replicasets', 'deployments']:
         data['status'] = {
-            'observedGeneration': 1
+            'observedGeneration': 1,
+            'readyReplicas': 1,
+            'replicas': 1
         }
         data['metadata']['generation'] = 1
 
@@ -992,7 +994,8 @@ def add_cache_item(url, resource_type, data):
 
     # Keep track of what resources exist under other resources (mostly namespace)
     # sneaky way of getting data up to the resource type without too much magic
-    cache_url = ''.join(url.partition(resource_type)[0:2])
+    index = url.rfind(resource_type)
+    cache_url = url[:index + len(resource_type)] if index != -1 else url
     items = cache.get(cache_url, [])
     if url not in items:
         items.append(url)
diff --git a/rootfs/scheduler/resources/deployment.py b/rootfs/scheduler/resources/deployment.py
index 21745ba40..0d59f5d85 100644
--- a/rootfs/scheduler/resources/deployment.py
+++ b/rootfs/scheduler/resources/deployment.py
@@ -439,12 +439,12 @@ def _check_for_failed_events(self, namespace, labels):
         response = self.rs.get(namespace, labels=labels)
         data = response.json()
         fields = {
-            'involvedObject.kind': 'ReplicaSet',
-            'involvedObject.name': data['items'][0]['metadata']['name'],
-            'involvedObject.namespace': namespace,
-            'involvedObject.uid': data['items'][0]['metadata']['uid'],
+            'regarding.kind': 'ReplicaSet',
+            'regarding.name': data['items'][0]['metadata']['name'],
+            'regarding.namespace': namespace,
+            'regarding.uid': data['items'][0]['metadata']['uid'],
         }
-        events_list = self.ns.events(namespace, fields=fields).json()
+        events_list = self.ev.get(namespace, fields=fields).json()
         events = events_list.get('items', [])
         if events is not None and len(events) != 0:
             for event in events:
@@ -458,10 +458,11 @@ def _get_formatted_messages(events):
         """
         Format each event by string and join all events to one string
         """
-        message_format = 'Message:{message}, lastTimestamp:{lastTimestamp}, reason: {reason}, count: {count}'  # noqa
+        message_format = 'Message: {note}, creationTimestamp: {creation_timestamp}, reason: {reason}'  # noqa
         output = []
         for event in events:
-            output.append(message_format.format(**event))
+            creation_timestamp = event["metadata"]["creationTimestamp"]
+            output.append(message_format.format(creation_timestamp=creation_timestamp, **event))
         return '\n'.join(output)
 
     def _get_deploy_steps(self, batches, tags):
diff --git a/rootfs/scheduler/resources/events.py b/rootfs/scheduler/resources/events.py
index 3187defab..749098ac8 100644
--- a/rootfs/scheduler/resources/events.py
+++ b/rootfs/scheduler/resources/events.py
@@ -9,8 +9,9 @@
 class Events(Resource):
     """
     Events resource.
-    Warning! Used ONLY for testing purposes
     """
+    api_prefix = 'apis'
+    api_version = 'events.k8s.io/v1'
     short_name = 'ev'
 
     def create(self, namespace, name, message, **kwargs):
@@ -18,7 +19,6 @@ def create(self, namespace, name, message, **kwargs):
         data = {
             'kind': 'Event',
             'apiVersion': self.api_version,
-            'count': kwargs.get('count', 1),
             'metadata': {
                 'creationTimestamp': datetime.now().strftime(DATETIME_FORMAT),
                 'namespace': namespace,
@@ -26,15 +26,10 @@ def create(self, namespace, name, message, **kwargs):
                 'resourceVersion': kwargs.get('resourceVersion', ''),
                 'uid': str(uuid.uuid4()),
             },
-            'message': message,
+            'note': message,
             'type': kwargs.get('type', 'Normal'),
-            'firstTimestamp': datetime.now().strftime(DATETIME_FORMAT),
-            'lastTimestamp': datetime.now().strftime(DATETIME_FORMAT),
             'reason': kwargs.get('reason', ''),
-            'source': {
-                'component': kwargs.get('component', ''),
-            },
-            'involvedObject': kwargs.get('involvedObject', {})
+            'regarding': kwargs.get('regarding', {})
         }
 
         response = self.http_post(url, json=data)
@@ -42,3 +37,18 @@ def create(self, namespace, name, message, **kwargs):
             raise KubeHTTPException(response, 'create Event for namespace {}'.format(namespace))  # noqa
 
         return response
+
+    def get(self, namespace, ignore_exception=False, **kwargs):
+        """
+        Fetch Events
+        """
+        url = '/namespaces/{}/events'
+        args = [namespace]
+        message = 'get Events in Namespace "{}"'
+
+        url = self.api(url, *args)
+        response = self.http_get(url, params=self.query_params(**kwargs))
+        if self.unhealthy(response.status_code) and not ignore_exception:
+            args.reverse()  # error msg is in reverse order
+            raise KubeHTTPException(response, message, *args)
+        return response
diff --git a/rootfs/scheduler/resources/namespace.py b/rootfs/scheduler/resources/namespace.py
index ed420b9c5..120c9ee34 100644
--- a/rootfs/scheduler/resources/namespace.py
+++ b/rootfs/scheduler/resources/namespace.py
@@ -52,11 +52,3 @@ def delete(self, namespace):
             raise KubeHTTPException(response, 'delete Namespace "{}"', namespace)
 
         return response
-
-    def events(self, namespace, **kwargs):
-        url = self.api("/namespaces/{}/events", namespace)
-        response = self.http_get(url, params=self.query_params(**kwargs))
-        if self.unhealthy(response.status_code):
-            raise KubeHTTPException(response, "get Events in Namespace {}", namespace)
-
-        return response
diff --git a/rootfs/scheduler/resources/pod.py b/rootfs/scheduler/resources/pod.py
index 3b68a88db..510a1890f 100644
--- a/rootfs/scheduler/resources/pod.py
+++ b/rootfs/scheduler/resources/pod.py
@@ -484,7 +484,7 @@ def pending_status(self, pod):
                     return reason, message
 
                 event = events.pop()
-                return event['reason'], event['message']
+                return event['reason'], event['note']
 
             return reason, message
 
@@ -495,17 +495,16 @@ def events(self, pod):
         """Process events for a given Pod to find if Pulling is happening, among other events"""
         # fetch all events for this pod
         fields = {
-            'involvedObject.name': pod['metadata']['name'],
-            'involvedObject.namespace': pod['metadata']['namespace'],
-            'involvedObject.uid': pod['metadata']['uid']
+            'regarding.name': pod['metadata']['name'],
+            'regarding.namespace': pod['metadata']['namespace'],
+            'regarding.uid': pod['metadata']['uid']
         }
-        events = self.ns.events(
+        events = self.ev.get(
             pod['metadata']['namespace'], fields=fields).json()['items']
         if not events:
             events = []
         # make sure that events are sorted
-        events.sort(
-            key=lambda x: x['lastTimestamp'] if x['lastTimestamp'] else "")
+        events.sort(key=lambda x: x['metadata']['creationTimestamp'] if x['metadata']['creationTimestamp'] else "")  # noqa
         return events
 
     def _handle_pod_errors(self, pod, reason, message):
@@ -547,7 +546,7 @@ def _handle_pod_errors(self, pod, reason, message):
                     # only show a given error once
                     event_errors.pop(event['reason'])
                     # strip out whitespaces on either side
-                    message = "\n".join([x.strip() for x in event['message'].split("\n")])
+                    message = "\n".join([x.strip() for x in event['note'].split("\n")])
                     messages.append(message)
 
         if messages:
diff --git a/rootfs/scheduler/tests/test_deployments.py b/rootfs/scheduler/tests/test_deployments.py
index 40cb6eb53..c465f9835 100644
--- a/rootfs/scheduler/tests/test_deployments.py
+++ b/rootfs/scheduler/tests/test_deployments.py
@@ -342,20 +342,20 @@ def test_check_for_failed_events(self):
         deployment = self.scheduler.deployment.get(self.namespace, deploy_name).json()
         response = self.scheduler.rs.get(self.namespace, labels=deployment['metadata']['labels'])
         rs = response.json()
-        involved_object = {
-            'involvedObject.kind': 'ReplicaSet',
-            'involvedObject.name': rs['items'][0]['metadata']['name'],
-            'involvedObject.namespace': self.namespace,
-            'involvedObject.uid': rs['items'][0]['metadata']['uid'],
+        regarding = {
+            'regarding.kind': 'ReplicaSet',
+            'regarding.name': rs['items'][0]['metadata']['name'],
+            'regarding.namespace': self.namespace,
+            'regarding.uid': rs['items'][0]['metadata']['uid'],
         }
         message = 'Quota exeeded'
         self.scheduler.ev.create(self.namespace,
                                  '{}'.format(generate_random_name()),
                                  message,
                                  type='Warning',
-                                 involved_object=involved_object,
+                                 regarding=regarding,
                                  reason='FailedCreate')
         with self.assertRaisesRegex(KubeException,
-                                    'Message:{}.*'.format(message)):
+                                    'Message: {}.*'.format(message)):
             self.scheduler.deployment._check_for_failed_events(self.namespace,
                                                                labels=deployment['metadata']['labels'])  # noqa
diff --git a/rootfs/scheduler/tests/test_events.py b/rootfs/scheduler/tests/test_events.py
new file mode 100644
index 000000000..89b17e276
--- /dev/null
+++ b/rootfs/scheduler/tests/test_events.py
@@ -0,0 +1,38 @@
+"""
+Unit tests for the Drycc scheduler module.
+
+Run the tests with './manage.py test scheduler'
+"""
+from scheduler.tests import TestCase
+
+
+class EventTest(TestCase):
+    """Tests scheduler pod calls"""
+
+    def create_event(self, namespace, name, **kwargs):
+        """
+        Helper function to create and verify a events on the namespace
+        """
+        self.scheduler.ns.create(namespace)
+
+        message = "Scaled down replica set test-869947c55f to 1 from 2"
+        # these are all required even if it is kwargs...
+        kwargs = {
+            'reason': 'ScalingReplicaSet',
+            'type': 'Normal'
+        }
+        return self.scheduler.events.create(
+            namespace,
+            name,
+            message,
+            **kwargs
+        )
+
+    def test_create(self):
+        response = self.create_event("test-event", "test-event")
+        self.assertEqual(response.status_code, 201)
+
+    def test_get_events(self):
+        # test success
+        response = self.scheduler.events.get("test-event", "test-event")
+        self.assertEqual(response.status_code, 200)
