From 0b503a1428c12c56b44d01168021ef273bfe1cfb Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo@zhumengyuan.com>
Date: Sun, 6 Sep 2020 14:39:38 +0800
Subject: [PATCH] feat(controller):add drycc resource cmd

---
 .../templates/controller-clusterrole.yaml     |   6 +
 rootfs/api/migrations/0001_initial.py         |  21 ++
 rootfs/api/models/__init__.py                 |   2 +
 rootfs/api/models/resource.py                 | 245 ++++++++++++++++++
 rootfs/api/models/volume.py                   |  11 +
 rootfs/api/serializers.py                     |  26 ++
 rootfs/api/tests/test_resource.py             | 143 ++++++++++
 rootfs/api/urls.py                            |  11 +
 rootfs/api/views.py                           |  59 +++++
 rootfs/scheduler/mock.py                      |   6 +-
 rootfs/scheduler/resources/servicecatalog.py  | 147 +++++++++++
 rootfs/scheduler/tests/test_servicecatalog.py | 137 ++++++++++
 12 files changed, 812 insertions(+), 2 deletions(-)
 create mode 100644 rootfs/api/models/resource.py
 create mode 100644 rootfs/api/tests/test_resource.py
 create mode 100644 rootfs/scheduler/resources/servicecatalog.py
 create mode 100644 rootfs/scheduler/tests/test_servicecatalog.py

diff --git a/charts/controller/templates/controller-clusterrole.yaml b/charts/controller/templates/controller-clusterrole.yaml
index 4792a2c0c..80906ae1e 100644
--- a/charts/controller/templates/controller-clusterrole.yaml
+++ b/charts/controller/templates/controller-clusterrole.yaml
@@ -65,5 +65,11 @@ rules:
 - apiGroups: [""]
   resources: ["persistentvolumeclaims"]
   verbs: ["get", "list", "watch", "create", "delete", "patch", "update"]
+- apiGroups: ["servicecatalog.k8s.io"]
+  resources: ["serviceinstances"]
+  verbs: ["get", "list", "watch", "create", "delete", "update"]
+- apiGroups: ["servicecatalog.k8s.io"]
+  resources: ["serviceinstances"]
+  verbs: ["get", "list", "watch", "create", "delete", "update"]
 {{- end -}}
 {{- end -}}
diff --git a/rootfs/api/migrations/0001_initial.py b/rootfs/api/migrations/0001_initial.py
index 586554ecc..9dd73ce60 100644
--- a/rootfs/api/migrations/0001_initial.py
+++ b/rootfs/api/migrations/0001_initial.py
@@ -251,4 +251,25 @@ class Migration(migrations.Migration):
                 'unique_together': {('app', 'name')},
             },
         ),
+        migrations.CreateModel(
+            name='Resource',
+            fields=[
+                ('uuid', models.UUIDField(auto_created=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='UUID')),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('updated', models.DateTimeField(auto_now=True)),
+                ('name', models.CharField(max_length=63, validators=[api.models.validate_label])),
+                ('plan', models.CharField(max_length=128)),
+                ('data', jsonfield.fields.JSONField(blank=True, default={})),
+                ('status', models.CharField(max_length=32, null=True)),
+                ('binding', models.CharField(max_length=32, null=True)),
+                ('options', jsonfield.fields.JSONField(blank=True, default={})),
+                ('app', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.App')),
+                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'ordering': ['-created'],
+                'get_latest_by': 'created',
+                'unique_together': {('app', 'name')},
+            },
+        ),
     ]
diff --git a/rootfs/api/models/__init__.py b/rootfs/api/models/__init__.py
index 499e93757..180d3cb07 100644
--- a/rootfs/api/models/__init__.py
+++ b/rootfs/api/models/__init__.py
@@ -151,6 +151,7 @@ class Meta:
 from .release import Release  # noqa
 from .tls import TLS  # noqa
 from .volume import Volume  # noqa
+from .resource import Resource  # noqa
 
 # define update/delete callbacks for synchronizing
 # models with the configuration management backend
@@ -248,6 +249,7 @@ def _hook_release_created(**kwargs):
 post_delete.connect(_log_instance_removed, sender=Domain, dispatch_uid='api.models.log')
 post_delete.connect(_log_instance_removed, sender=TLS, dispatch_uid='api.models.log')
 post_delete.connect(_log_instance_removed, sender=Volume, dispatch_uid='api.models.log')
+post_delete.connect(_log_instance_removed, sender=Resource, dispatch_uid='api.models.log')
 
 
 # automatically generate a new token on creation
diff --git a/rootfs/api/models/resource.py b/rootfs/api/models/resource.py
new file mode 100644
index 000000000..b45184cc5
--- /dev/null
+++ b/rootfs/api/models/resource.py
@@ -0,0 +1,245 @@
+import logging
+import uuid
+import time
+from django.core import signals
+from django.conf import settings
+from django.db import models, transaction
+from jsonfield import JSONField
+from api.exceptions import DryccException, AlreadyExists, ServiceUnavailable
+from api.models import UuidAuditedModel, validate_label
+from scheduler import KubeException
+from tasks import task, apply_async
+
+logger = logging.getLogger(__name__)
+
+
+class Resource(UuidAuditedModel):
+    owner = models.ForeignKey(settings.AUTH_USER_MODEL,
+                              on_delete=models.PROTECT)
+    app = models.ForeignKey('App', on_delete=models.CASCADE)
+    name = models.CharField(max_length=63, validators=[validate_label])
+    plan = models.CharField(max_length=128)
+    data = JSONField(default={}, blank=True)
+    status = models.CharField(max_length=32, null=True)
+    binding = models.CharField(max_length=32, null=True)
+    options = JSONField(default={}, blank=True)
+
+    class Meta:
+        get_latest_by = 'created'
+        unique_together = (('app', 'name'),)
+        ordering = ['-created']
+
+    def __str__(self):
+        return self.name
+
+    @transaction.atomic
+    def save(self, *args, **kwargs):
+        # Attach ServiceInstance, updates k8s
+        if self.created == self.updated:
+            self.attach(*args, **kwargs)
+        # Save to DB
+        return super(Resource, self).save(*args, **kwargs)
+
+    def attach(self, *args, **kwargs):
+        try:
+            self._scheduler.servicecatalog.get_instance(self.app.id, self.name)
+            err = "Resource {} already exists in this namespace".format(self.name)  # noqa
+            self.log(err, logging.INFO)
+            raise AlreadyExists(err)
+        except KubeException as e:
+            logger.info(e)
+            try:
+                instance = self.plan.split(":")
+                kwargs = {
+                    "instance_class": instance[0],
+                    "instance_plan": ":".join(instance[1:]),
+                    "parameters": self.options,
+                }
+                self._scheduler.servicecatalog.create_instance(
+                    self.app.id, self.name, **kwargs
+                )
+                # create/patch/put  retrieve_task
+                data = {
+                    "task_id": uuid.uuid4().hex,
+                    "resource_id": str(self.uuid),
+                }
+                apply_async(retrieve_task, delay=30000, args=(data, ))
+            except KubeException as e:
+                msg = 'There was a problem creating the resource ' \
+                      '{} for {}'.format(self.name, self.app_id)
+                raise ServiceUnavailable(msg) from e
+
+    @transaction.atomic
+    def delete(self, *args, **kwargs):
+        if self.binding and self.binding == "Ready":
+            raise DryccException("the plan is still binding")
+        # Deatch ServiceInstance, updates k8s
+        self.detach(*args, **kwargs)
+        # Delete from DB
+        return super(Resource, self).delete(*args, **kwargs)
+
+    def detach(self, *args, **kwargs):
+        try:
+            # We raise an exception when a resource doesn't exist
+            self._scheduler.servicecatalog.get_instance(self.app.id, self.name)
+            self._scheduler.servicecatalog.delete_instance(self.app.id, self.name)
+        except KubeException as e:
+            raise ServiceUnavailable("Could not delete volume {} for application {}".format(name, self.app_id)) from e  # noqa
+
+    def log(self, message, level=logging.INFO):
+        """Logs a message in the context of this service.
+
+        This prefixes log messages with an application "tag" that the customized
+        drycc-logspout will be on the lookout for.  When it's seen, the message-- usually
+        an application event of some sort like releasing or scaling, will be considered
+        as "belonging" to the application instead of the controller and will be handled
+        accordingly.
+        """
+        logger.log(level, "[{}]: {}".format(self.id, message))
+
+    def bind(self, *args, **kwargs):
+        if self.status != "Ready":
+            raise DryccException("the resource is not ready")
+        if self.binding == "Ready":
+            raise DryccException("the resource is binding")
+        try:
+            self._scheduler.servicecatalog.get_binding(self.app.id, self.name)
+            err = "Resource {} is binding".format(self.name)
+            self.log(err, logging.INFO)
+            raise AlreadyExists(err)
+        except KubeException as e:
+            logger.info(e)
+            try:
+                self._scheduler.servicecatalog.create_binding(
+                    self.app.id, self.name, **kwargs)
+                # create/patch/put  retrieve_task
+                data = {
+                    "task_id": uuid.uuid4().hex,
+                    "resource_id": str(self.uuid),
+                }
+                apply_async(retrieve_task, delay=30000, args=(data, ))
+            except KubeException as e:
+                msg = 'There was a problem binding the resource ' \
+                      '{} for {}'.format(self.name, self.app_id)
+                raise ServiceUnavailable(msg) from e
+
+    def unbind(self, *args, **kwargs):
+        if self.binding != "Ready":
+            raise DryccException("the resource is not binding")
+        try:
+            # We raise an exception when a resource doesn't exist
+            self._scheduler.servicecatalog.get_binding(self.app.id, self.name)
+            self._scheduler.servicecatalog.delete_binding(self.app.id, self.name)
+        except KubeException as e:
+            raise ServiceUnavailable("Could not unbind resource {} for application {}".format(self.name, self.app_id)) from e  # noqa
+
+    def attach_update(self, *args, **kwargs):
+        try:
+            data = self._scheduler.servicecatalog.get_instance(
+                self.app.id, self.name).json()
+        except KubeException as e:
+            self.log("certificate {} does not exist".format(self.app.id),
+                     level=logging.INFO)
+            data = None
+            logger.info(e)
+        try:
+            version = data["metadata"]["resourceVersion"]
+            instance = self.plan.split(":")
+            kwargs = {
+                "instance_class": instance[0],
+                "instance_plan": ":".join(instance[1:]),
+                "parameters": self.options,
+            }
+            self._scheduler.servicecatalog.put_instance(
+                self.app.id, self.name, version, **kwargs
+            )
+            # create/patch/put  retrieve_task
+            data = {
+                "task_id": uuid.uuid4().hex,
+                "resource_id": str(self.uuid),
+            }
+            apply_async(retrieve_task, delay=30000, args=(data, ))
+        except KubeException as e:
+            msg = 'There was a problem update the resource ' \
+                  '{} for {}'.format(self.name, self.app_id)
+            raise ServiceUnavailable(msg) from e
+
+    def retrieve(self, *args, **kwargs):
+        update_flag = False
+        if self.status != "Ready":
+            try:
+                resp_i = self._scheduler.servicecatalog.get_instance(
+                    self.app.id, self.name).json()
+                self.status = resp_i.get('status', {}).\
+                    get('lastConditionState', '').lower()
+                update_flag = True
+            except KubeException as e:
+                logger.info("retrieve instance info error: {}".format(e))
+        if self.binding != "Ready":
+            try:
+                # We raise an exception when a resource doesn't exist
+                resp_b = self._scheduler.servicecatalog.get_binding(
+                    self.app.id, self.name).json()
+                self.binding = resp_b.get('status', {}).\
+                    get('lastConditionState', '').lower()
+                self.options = resp_b.get('spec', {}).get('parameters', {})
+                update_flag = True
+                secret_name = resp_b.get('spec', {}).get('secretName')
+                if secret_name:
+                    resp_s = self._scheduler.secret.get(
+                        self.app.id, secret_name).json()
+                    self.data = resp_s.get('data', {})
+                    update_flag = True
+            except KubeException as e:
+                logger.info("retrieve binding info error: {}".format(e))
+        if update_flag is True:
+            self.save()
+        if self.status and self.binding:
+            return True
+        else:
+            return False
+
+    def detach_resource(self, *args, **kwargs):
+        if self.binding != "Ready":
+            try:
+                resp_b = self._scheduler.servicecatalog.get_binding(
+                    self.app.id, self.name).json()
+                secret_name = resp_b.get('spec', {}).get('secretName')
+                if secret_name:
+                    self._scheduler.secret.delete(self.app.id, secret_name)
+                self._scheduler.servicecatalog.delete_binding(
+                    self.app.id, self.name)
+            except KubeException as e:
+                logger.info("delete binding info error: {}".format(e))
+            self.binding = None
+
+        if (self.status != "Ready") or (self.binding is None):
+            try:
+                self._scheduler.servicecatalog.delete_instance(
+                    self.app.id, self.name)
+            except KubeException as e:
+                logger.info("retrieve instance info error: {}".format(e))
+
+
+@task
+def retrieve_task(data):
+    try:
+        signals.request_started.send(sender=data['task_id'])
+        try:
+            resource = Resource.objects.get(uuid=data['resource_id'])
+        except Resource.DoesNotExist:
+            logger.info("retrieve task not found resource: {}".format(data['resource_id']))  # noqa
+            return True
+        _ = resource.retrieve()
+        if _:
+            return True
+        else:
+            t = time.time() - resource.created.timestamps()
+            if t < 3600:
+                apply_async(retrieve_task, delay=30000, args=(data, ))
+            elif t < 3600 * 12:
+                apply_async(retrieve_task, delay=1800000, args=(data, ))
+            else:
+                resource.detach_resource()
+    finally:
+        signals.request_finished.send(sender=data['task_id'])
diff --git a/rootfs/api/models/volume.py b/rootfs/api/models/volume.py
index d0150f712..a15473c47 100644
--- a/rootfs/api/models/volume.py
+++ b/rootfs/api/models/volume.py
@@ -68,3 +68,14 @@ def detach(self, *args, **kwargs):
         except KubeException as e:
             raise ServiceUnavailable("Could not delete volume {} for application \
                 {}".format(name, self.app_id)) from e  # noqa
+
+    def log(self, message, level=logging.INFO):
+        """Logs a message in the context of this service.
+
+        This prefixes log messages with an application "tag" that the customized
+        drycc-logspout will be on the lookout for.  When it's seen, the message-- usually
+        an application event of some sort like releasing or scaling, will be considered
+        as "belonging" to the application instead of the controller and will be handled
+        accordingly.
+        """
+        logger.log(level, "[{}]: {}".format(self.id, message))
diff --git a/rootfs/api/serializers.py b/rootfs/api/serializers.py
index e103dde1d..4b1c55b2d 100644
--- a/rootfs/api/serializers.py
+++ b/rootfs/api/serializers.py
@@ -19,6 +19,8 @@
 
 # proc type name is lowercase alphanumeric
 # https://docs-v2.readthedocs.io/en/latest/using-workflow/process-types-and-the-procfile/#declaring-process-types
+from api.exceptions import DryccException
+
 PROCTYPE_MATCH = re.compile(r'^(?P<type>[a-z0-9]+(\-[a-z0-9]+)*)$')
 PROCTYPE_MISMATCH_MSG = "Process types can only contain lowercase alphanumeric characters"
 MEMLIMIT_MATCH = re.compile(
@@ -649,3 +651,27 @@ def validate_path(self, data):
                 raise serializers.ValidationError(
                     "Volume path format: /path")
         return data
+
+
+class ResourceSerializer(serializers.ModelSerializer):
+    """Serialize a :class:`~api.models.Resource` model."""
+    app = serializers.SlugRelatedField(slug_field='id', queryset=models.App.objects.all())
+    owner = serializers.ReadOnlyField(source='owner.username')
+    name = serializers.CharField(max_length=63, required=True)
+    plan = serializers.CharField(max_length=128, required=True)
+    options = JSONFieldSerializer(required=False, binary=True)
+
+    class Meta:
+        """Metadata options for a :class:`ResourceSerializer`."""
+        model = models.Resource
+        fields = '__all__'
+
+    def update(self, instance, validated_data):
+        if instance.plan.split(':')[0] != validated_data.get('plan', '').split(':')[0]:  # noqa
+            raise DryccException("the resource cann't changed")
+        instance.plan = validated_data.get('plan')
+        if validated_data.get('options'):
+            instance.options = validated_data.get('options')
+        instance.attach_update()
+        instance.save()
+        return instance
diff --git a/rootfs/api/tests/test_resource.py b/rootfs/api/tests/test_resource.py
new file mode 100644
index 000000000..554c09d84
--- /dev/null
+++ b/rootfs/api/tests/test_resource.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+"""
+Unit tests for the Drycc api app.
+
+Run the tests with "./manage.py test api"
+"""
+from django.contrib.auth.models import User
+from django.core.cache import cache
+from django.conf import settings
+from rest_framework.authtoken.models import Token
+from api.tests import adapter, DryccTransactionTestCase
+import requests_mock
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class ResourceTest(DryccTransactionTestCase):
+    """Tests setting and updating config values"""
+
+    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)
+        self.app_id = self.create_app()
+
+    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 test_resources_create(self, mock_requests):
+        """Test that the serialized response contains only relevant data."""
+        app_id = self.create_app()
+
+        response = self.client.post(
+            '/v2/apps/{}/resources'.format(app_id),
+            data={'name': 'mysql', 'plan': 'mysql:5.6'}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+
+        for key in response.data:
+            self.assertIn(key,
+                          ['uuid', 'owner', 'created', 'updated', 'app', 'plan',
+                           'options', 'data', 'status', 'binding', 'name'])
+
+        expected = {
+            'owner': self.user.username,
+            'app': app_id,
+            'name': 'mysql',
+            'plan': 'mysql:5.6'
+        }
+        self.assertDictContainsSubset(expected, response.data)
+
+    def test_resources_list(self, mock_requests):
+        """
+        Test that list resources from a app
+        """
+        # create
+        app_id = self.create_app()
+        data = [
+            {'name': 'mysql', 'plan': 'mysql:5.6'}
+        ]
+        for _ in data:
+            self.client.post('/v2/apps/{}/resources'.format(app_id), data=_)
+        # Fetch
+        url = '/v2/apps/{app_id}/resources'.format(app_id=app_id)
+        response = self.client.get(url)
+        expected = [res['name'] for res in response.data['results']]
+        self.assertEqual(sorted([_['name'] for _ in data]), sorted(expected))
+
+    def test_resource_get(self, mock_requests):
+        """
+        Test that resource is get detail from a app
+        """
+        # create
+        app_id = self.create_app()
+        data = {'name': 'mysql', 'plan': 'mysql:5.6'}
+        self.client.post('/v2/apps/{}/resources/'.format(app_id), data=data)
+        # Get
+        url = '/v2/apps/{app_id}/resources/{name}/'.format(app_id=app_id,
+                                                           name='mysql')
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+    def test_resource_delete(self, mock_requests):
+        """
+        Test that resource is delete from a app
+        """
+        # create
+        app_id = self.create_app()
+        data = [
+            {'name': 'mysql', 'plan': 'mysql:5.6'}
+        ]
+        for _ in data:
+            self.client.post('/v2/apps/{}/resources'.format(app_id), data=_)
+
+        # Delete
+        url = '/v2/apps/{app_id}/resources/{name}'.format(app_id=app_id,
+                                                          name='mysql')
+        response = self.client.delete(url)
+        self.assertEqual(response.status_code, 204)
+
+    def test_resource_update(self, mock_requests):
+        """
+        Test that resource is delete from a app
+        """
+        # create
+        app_id = self.create_app()
+        data = {'name': 'mysql', 'plan': 'mysql:5.6'}
+        self.client.post('/v2/apps/{}/resources'.format(app_id), data=data)
+
+        # Update
+        url = '/v2/apps/{app_id}/resources/{name}'.format(app_id=app_id,
+                                                          name='mysql')
+        data = {'plan': 'mysql:5.7'}
+        response = self.client.put(url, data=data)
+        self.assertEqual(response.status_code, 200)
+
+    def test_resource_bind(self, mock_requests):
+        # create
+        app_id = self.create_app()
+        data = {'name': 'mysql', 'plan': 'mysql:5.6'}
+        self.client.post('/v2/apps/{}/resources'.format(app_id), data=data)
+        # bind
+        url = '/v2/apps/{app_id}/resources/mysql/binding/'.format(app_id=app_id)
+        data = {"bind_action": "bind"}
+        response = self.client.patch(url, data=data)
+        # expected = response.data['path']
+        self.assertEqual(response.status_code, 400)
+
+    def test_resource_unbind(self, mock_requests):
+        # create
+        app_id = self.create_app()
+        data = {'name': 'mysql', 'plan': 'mysql:5.6'}
+        self.client.post('/v2/apps/{}/resources'.format(app_id), data=data)
+        # unbind
+        url = '/v2/apps/{app_id}/resources/mysql/binding/'.format(app_id=app_id)
+        data = {"bind_action": "unbind"}
+        response = self.client.patch(url, data=data)
+        # expected = response.data['path']
+        self.assertEqual(response.status_code, 400)
diff --git a/rootfs/api/urls.py b/rootfs/api/urls.py
index 936e214f1..855e15dde 100644
--- a/rootfs/api/urls.py
+++ b/rootfs/api/urls.py
@@ -82,6 +82,17 @@
         views.AppVolumesViewSet.as_view({'delete': 'destroy'})),
     url(r"^apps/(?P<id>{})/volumes/(?P<name>[-_\w]+)/path/?$".format(settings.APP_URL_REGEX),
         views.AppVolumeMountPathViewSet.as_view({'patch': 'path'})),
+    # application resources
+    url(r"^apps/(?P<id>{})/resources/?$".format(settings.APP_URL_REGEX),
+        views.AppResourcesViewSet.as_view({'get': 'list', 'post': 'create'})),
+    url(r"^apps/(?P<id>{})/resources/(?P<name>[-_\w]+)/?$".format(settings.APP_URL_REGEX),
+        views.AppSingleResourceViewSet.as_view({
+            'get': 'retrieve',
+            'delete': 'destroy',
+            'put': 'update'
+        })),
+    url(r"^apps/(?P<id>{})/resources/(?P<name>[-_\w]+)/binding/?$".format(settings.APP_URL_REGEX),
+        views.AppResourceBindingViewSet.as_view({'patch': 'binding'})),
     # apps base endpoint
     url(r"^apps/(?P<id>{})/?$".format(settings.APP_URL_REGEX),
         views.AppViewSet.as_view({'get': 'retrieve', 'post': 'update', 'delete': 'destroy'})),
diff --git a/rootfs/api/views.py b/rootfs/api/views.py
index 77e322cae..89e974124 100644
--- a/rootfs/api/views.py
+++ b/rootfs/api/views.py
@@ -741,6 +741,65 @@ def deploy(self, volume, pre_mount_path):
             raise DryccException(str(e)) from e
 
 
+class AppResourcesViewSet(AppResourceViewSet):
+    """RESTful views for resources apps with collaborators."""
+    model = models.Resource
+    serializer_class = serializers.ResourceSerializer
+
+
+class AppSingleResourceViewSet(AppResourceViewSet):
+    """RESTful views for resource apps with collaborators."""
+    model = models.Resource
+    serializer_class = serializers.ResourceSerializer
+
+    def get_object(self):
+        return get_object_or_404(models.Resource,
+                                 app__id=self.kwargs['id'],
+                                 name=self.kwargs['name'])
+
+    def retrieve(self, request, *args, **kwargs):
+        resource = self.get_object()
+        resource.retrieve(request)
+        return super(AppSingleResourceViewSet, self).retrieve(request, *args, **kwargs)  # noqa
+
+    def destroy(self, request, *args, **kwargs):
+        resource = self.get_object()
+        resource.delete()
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+    def update(self, request, *args, **kwargs):
+        resource = self.get_object()
+        resource = serializers.ResourceSerializer(data=request.data,
+                                                  instance=resource,
+                                                  partial=True)
+        if resource.is_valid():
+            resource.save()
+        return Response(resource.data)
+
+
+class AppResourceBindingViewSet(AppResourceViewSet):
+    model = models.Resource
+    serializer_class = serializers.ResourceSerializer
+
+    def get_object(self):
+        return get_object_or_404(models.Resource,
+                                 app__id=self.kwargs['id'],
+                                 name=self.kwargs['name'])
+
+    def binding(self, request, *args, **kwargs):
+        resource = self.get_object()
+        # {"bind_action":bind/unbind}
+        bind_action = self.request.data.get('bind_action', '').lower()
+        if bind_action == 'bind':
+            resource.bind()
+            return HttpResponse(status=status.HTTP_202_ACCEPTED)
+        elif bind_action == 'unbind':
+            resource.unbind()
+            return HttpResponse(status=status.HTTP_202_ACCEPTED)
+        else:
+            return Http404("unknown action")
+
+
 class AdminPermsViewSet(BaseDryccViewSet):
     """RESTful views for sharing admin permissions with other users."""
 
diff --git a/rootfs/scheduler/mock.py b/rootfs/scheduler/mock.py
index d734116ee..602881e73 100644
--- a/rootfs/scheduler/mock.py
+++ b/rootfs/scheduler/mock.py
@@ -80,7 +80,8 @@ def _acquire(self):
 resources = [
     'namespaces', 'nodes', 'pods', 'replicationcontrollers',
     'secrets', 'services', 'events', 'deployments', 'replicasets',
-    'horizontalpodautoscalers', 'scale', 'resourcequotas', 'ingresses', 'persistentvolumeclaims'
+    'horizontalpodautoscalers', 'scale', 'resourcequotas', 'ingresses',
+    'persistentvolumeclaims', "serviceinstances", "servicebindings"
 ]
 
 
@@ -122,7 +123,8 @@ def get_type(key):
 def replace_api_version(old, new='api_v1'):
     return old.replace('apis_autoscaling_v1', new)\
         .replace('apis_apps_v1', new)\
-        .replace('apis_networking.k8s.io_v1beta1', new)
+        .replace('apis_networking.k8s.io_v1beta1', new)\
+        .replace('apis_servicecatalog.k8s.io_v1beta1', new)
 
 
 def get_namespace(url, resource_type):
diff --git a/rootfs/scheduler/resources/servicecatalog.py b/rootfs/scheduler/resources/servicecatalog.py
new file mode 100644
index 000000000..3d5d8ee44
--- /dev/null
+++ b/rootfs/scheduler/resources/servicecatalog.py
@@ -0,0 +1,147 @@
+from scheduler.resources import Resource
+from scheduler.exceptions import KubeHTTPException
+
+
+class ServiceCatalog(Resource):
+    api_version = 'servicecatalog.k8s.io/v1beta1'
+    api_prefix = 'apis'
+
+    @staticmethod
+    def service_instance_manifest(namespace, name, version=None, **kwargs):
+        labels = {
+            'heritage': 'drycc',
+        }
+        data = {
+            "apiVersion": 'servicecatalog.k8s.io/v1beta1',
+            "kind": "ServiceInstance",
+            "metadata": {
+                "name": name,
+                "namespace": namespace,
+                'labels': labels
+            },
+            "spec": {
+                "clusterServiceClassExternalName": kwargs.get('instance_class'),
+                "clusterServicePlanExternalName": kwargs.get('instance_plan'),
+                #{"param - 1": "value - 1","param - 2": "value - 2"}  # noqa
+                "parameters": kwargs.get('parameters'),
+            }
+        }
+        if version:
+            data["metadata"]["resourceVersion"] = version
+        return data
+
+    def get_instance(self, namespace, name=None):
+        """
+        Fetch a single serviceinstance or a list of serviceinstances
+        """
+        if name is not None:
+            url = self.api('/namespaces/{}/serviceinstances/{}',
+                           namespace, name)
+            message = 'get serviceinstances ' + name
+        else:
+            url = self.api('/namespaces/{}/serviceinstances', namespace)
+            message = 'get serviceinstances'
+        response = self.http_get(url)
+        if self.unhealthy(response.status_code):
+            raise KubeHTTPException(response, message)
+        return response
+
+    def create_instance(self, namespace, name, **kwargs):
+        """
+        Create serviceinstances
+        """
+        url = self.api('/namespaces/{}/serviceinstances', namespace)
+        data = self.service_instance_manifest(namespace, name, **kwargs)
+        response = self.http_post(url, json=data)
+        if not response.status_code == 201:
+            raise KubeHTTPException(
+                response,
+                "create serviceinstances {}".format(namespace))
+        return response
+
+    def put_instance(self, namespace, name, version, **kwargs):
+        """
+        update serviceinstances
+        """
+        url = self.api('/namespaces/{}/serviceinstances/{}', namespace, name)
+        data = self.service_instance_manifest(namespace, name, version, **kwargs)
+        response = self.http_put(url, json=data)
+        if not response.status_code == 200:
+            raise KubeHTTPException(
+                response,
+                "update serviceinstances {}".format(namespace))
+        return response
+
+    def delete_instance(self, namespace, name):
+        """
+        Delete serviceinstances
+        """
+        url = self.api('/namespaces/{}/serviceinstances/{}', namespace,
+                       name)
+        response = self.http_delete(url)
+        if self.unhealthy(response.status_code):
+            raise KubeHTTPException(response, 'delete serviceinstance ' + name)
+        return response
+
+    @staticmethod
+    def service_binding_manifest(namespace, name, version=None, **kwargs):
+        labels = {
+            'heritage': 'drycc',
+        }
+        data = {
+            "apiVersion": 'servicecatalog.k8s.io/v1beta1',
+            "kind": "ServiceBinding",
+            "metadata": {
+                "name": name,
+                "namespace": namespace,
+                'labels': labels
+            },
+            "spec": {
+                "instanceRef": {
+                    "name": name
+                }
+            }
+        }
+        if version:
+            data["metadata"]["resourceVersion"] = version
+        return data
+
+    def get_binding(self, namespace, name=None):
+        """
+        Fetch a single servicebinding or a list of servicebindings
+        """
+        if name is not None:
+            url = self.api('/namespaces/{}/servicebindings/{}',
+                           namespace, name)
+            message = 'get servicebindings ' + name
+        else:
+            url = self.api('/namespaces/{}/servicebindings', namespace)
+            message = 'get servicebindings'
+        response = self.http_get(url)
+        if self.unhealthy(response.status_code):
+            raise KubeHTTPException(response, message)
+        return response
+
+    def create_binding(self, namespace, name, **kwargs):
+        """
+        Create servicebindings
+        """
+        url = self.api('/namespaces/{}/servicebindings', namespace)
+        data = self.service_binding_manifest(namespace, name, **kwargs)
+        response = self.http_post(url, json=data)
+        if not response.status_code == 201:
+            raise KubeHTTPException(
+                response,
+                "create servicebindings {}".format(namespace))
+        return response
+
+    def delete_binding(self, namespace, name):
+        """
+        Delete servicebindings
+        """
+        url = self.api('/namespaces/{}/servicebindings/{}', namespace, name)
+        response = self.http_delete(url)
+        if self.unhealthy(response.status_code):
+            raise KubeHTTPException(response,
+                                    'delete servicebindings ' + name)
+        return response
diff --git a/rootfs/scheduler/tests/test_servicecatalog.py b/rootfs/scheduler/tests/test_servicecatalog.py
new file mode 100644
index 000000000..f8e9fcdcd
--- /dev/null
+++ b/rootfs/scheduler/tests/test_servicecatalog.py
@@ -0,0 +1,137 @@
+"""
+Unit tests for the Drycc scheduler module.
+
+Run the tests with './manage.py test scheduler'
+"""
+from scheduler import KubeHTTPException
+from scheduler.tests import TestCase
+from scheduler.utils import generate_random_name
+
+
+class ServiceCatalogTest(TestCase):
+
+    def create_instince(self, namespace=None, name=generate_random_name(),
+                        **kwargs):
+        """
+        Helper function to create and verify a serviceinstances on the namespace
+        """
+        namespace = self.namespace if namespace is None else namespace
+        # these are all required even if it is kwargs...
+        kwargs = {
+            "instance_class": "server",
+            "instance_plan": "1-1",
+            "parameters": {
+                "param-1": "value-1",
+                "param-2": "value-2"
+            }
+        }
+        instance = self.scheduler.servicecatalog.create_instance(namespace,
+                                                                 name, **kwargs)
+        self.assertEqual(instance.status_code, 201, instance.json())
+        return name
+
+    def test_create_instince_failure(self):
+        with self.assertRaises(
+            KubeHTTPException,
+            msg='failed to create serviceinstances doesnotexist in Namespace \
+            {}: 404 Not Found'.format(
+                self.namespace)  # noqa
+        ):
+            self.create_instince('doesnotexist', 'doesnotexist')
+
+    def test_create_instance(self):
+        self.scheduler.ns.create("test-serviceinstance")
+        self.create_instince(namespace="test-serviceinstance")
+
+    def test_delete_instance_failure(self):
+        # test failure
+        with self.assertRaises(
+            KubeHTTPException,
+            msg='failed to delete serviceinstance foo in Namespace {}: 404 Not Found'.format(self.namespace)  # noqa
+        ):
+            self.scheduler.servicecatalog.delete_instance(self.namespace, 'foo')
+
+    def test_instince_delete(self):
+        # test success
+        name = self.create_instince()
+        response = self.scheduler.servicecatalog.delete_instance(self.namespace,
+                                                                 name)
+        data = response.json()
+        self.assertEqual(response.status_code, 200, data)
+
+    def test_get_serviceinstances(self):
+        # test success
+        name = self.create_instince()
+        response = self.scheduler.servicecatalog.get_instance(self.namespace)
+        data = response.json()
+        self.assertEqual(response.status_code, 200, data)
+        self.assertIn('items', data)
+        self.assertEqual(1, len(data['items']), data['items'])
+        # simple verify of data
+        self.assertEqual(data['items'][0]['metadata']['name'], name, data)
+
+    def test_get_serviceinstance(self):
+        # test success
+        name = self.create_instince()
+        response = self.scheduler.servicecatalog.get_instance(self.namespace, name)
+        data = response.json()
+        self.assertEqual(response.status_code, 200, data)
+        # simple verify of data
+        self.assertEqual(data['metadata']['name'], name, data)
+
+    def test_get_serviceinstances_failure(self):
+        # test failure
+        with self.assertRaises(
+            KubeHTTPException,
+            msg='failed to get Pod doesnotexist in Namespace {}: 404 Not Found'.format(self.namespace)  # noqa
+        ):
+            self.scheduler.servicecatalog.get_instance(self.namespace,
+                                                       'doesnotexist')
+
+    def create_binding(self, namespace=None, name=generate_random_name(),
+                       **kwargs):
+        name = self.create_instince()
+        # these are all required even if it is kwargs...
+        instance = self.scheduler.servicecatalog.create_binding(self.namespace,
+                                                                name)
+        self.assertEqual(instance.status_code, 201, instance.json())
+        return name
+
+    def test_create_binding(self):
+        self.scheduler.ns.create("test-serviceinstance")
+        self.create_binding(namespace="test-serviceinstance")
+
+    # def test_create_binding_failure(self):
+    #     with self.assertRaises(
+    #         KubeHTTPException,
+    #         msg='failed to create servicebindings doesnotexist in Namespace {}: 404 Not Found'.format(self.namespace) # noqa
+    #     ):
+    #         self.create_binding(self.namespace, 'doesnotexist')
+
+    def test_binding_delete(self):
+        # test success
+        name = self.create_binding(namespace="test-serviceinstance")
+        response = self.scheduler.servicecatalog.delete_binding(self.namespace,
+                                                                name)
+        data = response.json()
+        self.assertEqual(response.status_code, 200, data)
+
+    def test_get_servicebindings(self):
+        # test success
+        name = self.create_binding()
+        response = self.scheduler.servicecatalog.get_binding(self.namespace)
+        data = response.json()
+        self.assertEqual(response.status_code, 200, data)
+        self.assertIn('items', data)
+        self.assertEqual(1, len(data['items']), data['items'])
+        # simple verify of data
+        self.assertEqual(data['items'][0]['metadata']['name'], name, data)
+
+    def test_get_servicebindings_failure(self):
+        # test failure
+        with self.assertRaises(
+            KubeHTTPException,
+            msg='failed to get Pod doesnotexist in Namespace {}: 404 Not Found'.format(self.namespace)  # noqa
+        ):
+            self.scheduler.servicecatalog.get_binding(self.namespace,
+                                                      'doesnotexist')
