From 3a9c5a4d4a7251aa3548d8ffce6741cf412036d8 Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo1991@outlook.com>
Date: Tue, 10 Dec 2024 18:54:40 +0800
Subject: [PATCH 1/6] chore(controller): mount should not release

---
 rootfs/api/models/app.py          | 17 ++++++++++-------
 rootfs/api/settings/production.py |  2 +-
 rootfs/api/views.py               |  2 +-
 3 files changed, 12 insertions(+), 9 deletions(-)

diff --git a/rootfs/api/models/app.py b/rootfs/api/models/app.py
index 429304f6..1ddae6db 100644
--- a/rootfs/api/models/app.py
+++ b/rootfs/api/models/app.py
@@ -270,7 +270,7 @@ def restart(self, **kwargs):  # noqa
     def scale(self, user, structure):
         err_msg = None
         release = Release.latest(self)
-        if (PTYPE_RUN in structure or release.build is None):
+        if (PTYPE_RUN in structure or release is None or release.build is None):
             if PTYPE_RUN in structure:
                 err_msg = 'Cannot set scale for reserved types, procfile type is: run'
             else:
@@ -283,7 +283,7 @@ def scale(self, user, structure):
     def pipeline(self, release, ptypes, force_deploy=False):
         prefix = f"[pipeline] release {release.version_name}"
         try:
-            if release.build is not None:
+            if release is not None and release.build is not None:
                 if release.build.dryccfile:
                     for run in release.get_runners(ptypes):
                         self.log(f"{prefix} starts running pipeline.run {run['image']}")
@@ -323,7 +323,7 @@ def deploy(self, release, ptypes=None, force_deploy=False, rollback_on_failure=T
 
         force_deploy can be used when a deployment is broken, such as for Rollback
         """
-        if release.build is None:
+        if release is None or release.build is None:
             raise DryccException('No build associated with this release')
         # use create to make sure minimum resources are created
         self.create()
@@ -352,7 +352,7 @@ def mount(self, user, volume, structure=None):
         if release is None or release.build is None:
             raise DryccException('No build associated with this release')
         app_settings = self.appsettings_set.latest()
-        self._mount(user, volume, release, app_settings, structure=structure)
+        self._mount(user, volume, app_settings, structure=structure)
 
     def clean(self, release=None, ptypes=None):
         release = release if release else self.release_set.latest()
@@ -741,11 +741,14 @@ def _clean_app_logs(self):
             err = 'Error deleting existing application logs: {}'.format(e)
             self.log(err, logging.WARNING)
 
-    def _mount(self, user, volume, release, app_settings, structure=None):
+    def _mount(self, user, volume, app_settings, structure=None):
         volumes = Volume.objects.filter(app=self)
         tasks = []
         for scale_type, replicas in structure.items() if structure else self.structure.items():
             if scale_type != PTYPE_RUN:
+                release = self.release_set.filter(
+                    deployed_ptypes__contains=scale_type,
+                    failed=False).latest()
                 replicas = self.structure.get(scale_type, 0)
                 scale_type_volumes = [
                     volume for volume in volumes if scale_type in volume.path.keys()]
@@ -810,7 +813,7 @@ def _deploy(self, deploys, ptypes, prev_release,
             except KubeException as e:
                 # Don't rollback if the previous release doesn't have a build which means
                 # this is the first build and all the previous releases are just config changes.
-                if (rollback_on_failure and prev_release.build is not None):
+                if rollback_on_failure and prev_release is not None and prev_release.build is not None:  # noqa
                     err = 'There was a problem deploying {}. Rolling back to release {}.'.format(
                         release.version_name, prev_release.version_name)
                     # This goes in the log before the rollback starts
@@ -1098,7 +1101,7 @@ def _build_env_vars(self, release, ptype):
         Build a dict of env vars, setting default vars based on app type
         and then combining with the user set ones
         """
-        if release.build is None:
+        if release is None or release.build is None:
             raise DryccException('No build associated with this release to run this command')
 
         # mix in default environment information drycc may require
diff --git a/rootfs/api/settings/production.py b/rootfs/api/settings/production.py
index 876c90c3..1b793e2d 100644
--- a/rootfs/api/settings/production.py
+++ b/rootfs/api/settings/production.py
@@ -189,7 +189,7 @@
             'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s'
         },
         'simple': {
-            'format': '%(levelname)s %(message)s'
+            'format': '%(levelname)s %(asctime)s %(message)s'
         },
     },
     'filters': {
diff --git a/rootfs/api/views.py b/rootfs/api/views.py
index 88720dac..37674347 100644
--- a/rootfs/api/views.py
+++ b/rootfs/api/views.py
@@ -322,7 +322,7 @@ def run(self, request, **kwargs):
         if not command:
             raise DryccException('command is a required field, or it can be defined in Procfile')
         release = models.release.Release.latest(app)
-        if release.build is None:
+        if release is None or release.build is None:
             raise DryccException('no build available, please deploy a release')
         volumes = request.data.get('volumes', None)
         if volumes:

From e0d34eb8f8a88c3beaa3722c0acd58ae7d310513 Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo1991@outlook.com>
Date: Wed, 11 Dec 2024 15:01:53 +0800
Subject: [PATCH 2/6] fix(controller): app destroy error

---
 rootfs/api/models/gateway.py | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/rootfs/api/models/gateway.py b/rootfs/api/models/gateway.py
index 9312f63b..4743f8e8 100644
--- a/rootfs/api/models/gateway.py
+++ b/rootfs/api/models/gateway.py
@@ -7,10 +7,12 @@
 from django.http import Http404
 
 from api.exceptions import ServiceUnavailable
+from api.models.service import Service
 from scheduler import KubeException
 
 from .base import AuditedModel, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT
 
+
 User = get_user_model()
 logger = logging.getLogger(__name__)
 
@@ -188,8 +190,9 @@ def services(self):
             for rule in self.rules:
                 for backend in rule['backendRefs']:
                     service_names.add(backend['name'])
+            services = list(Service.objects.using("default").filter(app=self.app))
             setattr(self.CACHE, key,
-                    [s for s in self.app.service_set.all() if s.name in service_names])
+                    [s for s in services if s.name in service_names])
         return getattr(self.CACHE, key)
 
     @property

From 4a31ba129e0c78e620f881dbc5ad6ac19ec024da Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo1991@outlook.com>
Date: Wed, 11 Dec 2024 19:21:55 +0800
Subject: [PATCH 3/6] chore(controller): support image pull secrets by ptype

---
 rootfs/api/models/app.py     | 10 +++++-----
 rootfs/api/tests/test_app.py | 16 ++++++++--------
 2 files changed, 13 insertions(+), 13 deletions(-)

diff --git a/rootfs/api/models/app.py b/rootfs/api/models/app.py
index 1ddae6db..208139b9 100644
--- a/rootfs/api/models/app.py
+++ b/rootfs/api/models/app.py
@@ -626,12 +626,12 @@ def autoscale(self, proc_type, autoscale):
                 # let the user know about any other errors
                 raise ServiceUnavailable(str(e)) from e
 
-    def image_pull_secret(self, namespace, registry, image):
+    def image_pull_secret(self, namespace, ptype, registry, image):
         """
         Take registry information and set as an imagePullSecret for an RC / Deployment
         http://kubernetes.io/docs/user-guide/images/#specifying-imagepullsecrets-on-a-pod
         """
-        docker_config, name, create = self._get_private_registry_config(image, registry)
+        docker_config, name, create = self._get_private_registry_config(ptype, image, registry)
         if create is None:
             return
         elif create:
@@ -1122,8 +1122,8 @@ def _build_env_vars(self, release, ptype):
             default_env['PORT'] = port
         return default_env
 
-    def _get_private_registry_config(self, image, registry=None):
-        name = settings.REGISTRY_SECRET_PREFIX
+    def _get_private_registry_config(self, ptype, image, registry=None):
+        name = settings.REGISTRY_SECRET_PREFIX + '-' + ptype
         if registry:
             # try to get the hostname information
             hostname = registry.get('hostname', None)
@@ -1209,7 +1209,7 @@ def _gather_app_settings(self, release, app_settings, ptype, replicas, volumes=N
         registry = config.registry.get(ptype, {})
         # create image pull secret if needed
         image_pull_secret_name = self.image_pull_secret(
-            self.id, registry, release.get_deploy_image(ptype))
+            self.id, ptype, registry, release.get_deploy_image(ptype))
 
         # only web is routable
         # https://www.drycc.cc/applications/managing-app-processes/#default-process-types
diff --git a/rootfs/api/tests/test_app.py b/rootfs/api/tests/test_app.py
index 370f0537..77ffa6ae 100644
--- a/rootfs/api/tests/test_app.py
+++ b/rootfs/api/tests/test_app.py
@@ -543,24 +543,24 @@ def test_list_ordering(self, mock_requests):
         self.assertEqual(apps[3]['id'], 'zulu')
 
     def test_get_private_registry_config(self, mock_requests):
-        registry = {'username': 'test', 'password': 'test'}
+        registry = {"web": {'username': 'test', 'password': 'test'}}
         auth = bytes('{}:{}'.format("test", "test"), 'UTF-8')
         encAuth = base64.b64encode(auth).decode(encoding='UTF-8')
         image = 'test/test'
 
-        docker_config, name, create = App()._get_private_registry_config(image, registry)
+        docker_config, name, create = App()._get_private_registry_config("web", image, registry.get("web", {}))  # noqa
         dockerConfig = json.loads(docker_config)
         expected = {"https://index.docker.io/v1/": {"auth": encAuth}}
         self.assertEqual(dockerConfig.get('auths'), expected)
-        self.assertEqual(name, "private-registry")
+        self.assertEqual(name, "private-registry-web")
         self.assertEqual(create, True)
 
         image = "quay.io/test/test"
-        docker_config, name, create = App()._get_private_registry_config(image, registry)
+        docker_config, name, create = App()._get_private_registry_config("web", image, registry.get("web", {}))  # noqa
         dockerConfig = json.loads(docker_config)
         expected = {"quay.io": {"auth": encAuth}}
         self.assertEqual(dockerConfig.get('auths'), expected)
-        self.assertEqual(name, "private-registry")
+        self.assertEqual(name, "private-registry-web")
         self.assertEqual(create, True)
 
     @override_settings(REGISTRY_LOCATION="off-cluster")
@@ -569,20 +569,20 @@ def test_get_private_registry_config_off_cluster(self, mock_requests):
         auth = bytes('{}:{}'.format("test", "test"), 'UTF-8')
         encAuth = base64.b64encode(auth).decode(encoding='UTF-8')
         image = "test.com/test/test"
-        docker_config, name, create = App()._get_private_registry_config(image, registry)
+        docker_config, name, create = App()._get_private_registry_config("web", image, registry.get("web", {}))  # noqa
         dockerConfig = json.loads(docker_config)
         expected = {"https://index.docker.io/v1/": {
             "auth": encAuth
         }}
         self.assertEqual(dockerConfig.get('auths'), expected)
-        self.assertEqual(name, "private-registry-off-cluster")
+        self.assertEqual(name, "private-registry-web-off-cluster")
         self.assertEqual(create, True)
 
     @override_settings(REGISTRY_LOCATION="ecra")
     def test_get_private_registry_config_bad_registry_location(self, mock_requests):
         registry = {}
         image = "test.com/test/test"
-        docker_config, name, create = App()._get_private_registry_config(image, registry)
+        docker_config, name, create = App()._get_private_registry_config("web", image, registry)
         self.assertEqual(docker_config, None)
         self.assertEqual(name, None)
         self.assertEqual(create, None)

From 97c944ba3f68045cc6d4d7a0a69449af09d49c36 Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo1991@outlook.com>
Date: Thu, 12 Dec 2024 15:14:57 +0800
Subject: [PATCH 4/6] chore(controller): list pod when pending

---
 rootfs/api/models/app.py | 31 +++++++++++++++++++++----------
 1 file changed, 21 insertions(+), 10 deletions(-)

diff --git a/rootfs/api/models/app.py b/rootfs/api/models/app.py
index 208139b9..e9c4ec9a 100644
--- a/rootfs/api/models/app.py
+++ b/rootfs/api/models/app.py
@@ -431,17 +431,21 @@ def get_command_and_args(pod, container_name):
         result = []
         try:
             pod = self.scheduler().pod.get(self.id, pod_name).json()
-            for status in pod["status"]["containerStatuses"]:
+            if pod["status"]['phase'] == 'Pending':
+                statuses = pod["spec"]["containers"]
+            else:
+                statuses = pod["status"]["containerStatuses"]
+            for status in statuses:
                 command, args = get_command_and_args(pod, status["name"])
                 result.append({
                     "container": status["name"],
                     "image": status["image"],
                     "command": command,
                     "args": args,
-                    "state": status["state"],
-                    "lastState": status["lastState"],
-                    "ready": status["ready"],
-                    "restartCount": status["restartCount"],
+                    "state": status.get("state", {}),
+                    "lastState": status.get("lastState", {}),
+                    "ready": status.get("ready", False),
+                    "restartCount": status.get("restartCount", 0),
                     "status": pod["status"].get("phase", ""),
                     "reason": pod["status"].get("reason", ""),
                     "message": pod["status"].get("message", "")
@@ -470,15 +474,22 @@ def list_pods(self, *args, **kwargs):
                 else:
                     started = str(
                         datetime.now(timezone.utc).strftime(settings.DRYCC_DATETIME_FORMAT))
+                state = str(self.scheduler().pod.state(p))
+                if p['status']['phase'] != 'Pending':
+                    ready = len([1 for s in p["status"]["containerStatuses"] if s['ready']])
+                    restarts = sum([s['restartCount'] for s in p["status"]["containerStatuses"]])
+                else:
+                    restarts = 0
+                    ready = 0
                 item = {
-                    'name': p['metadata']['name'], 'state': str(self.scheduler().pod.state(p)),
+                    'name': p['metadata']['name'],
+                    'state': state,
                     'release': labels['version'], 'type': labels['type'], 'started': started,
                     'ready': "%s/%s" % (
-                        len([1 for s in p["status"]["containerStatuses"] if s['ready']]),
-                        len(p["status"]["containerStatuses"]),
+                        ready,
+                        len(p["spec"]["containers"]),
                     ),
-                    'restarts': sum(
-                        [s['restartCount'] for s in p["status"]["containerStatuses"]]),
+                    'restarts': restarts
                 }
                 data.append(item)
             # sorting so latest start date is first

From 0e3b4cd329c38eacca458b521505b9f6bdcaeb87 Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo1991@outlook.com>
Date: Fri, 13 Dec 2024 11:29:34 +0800
Subject: [PATCH 5/6] chore(controller): modify clean ptype check

---
 rootfs/api/tests/test_build.py | 25 +++++++++++++++++++++++--
 rootfs/api/views.py            |  7 ++++++-
 2 files changed, 29 insertions(+), 3 deletions(-)

diff --git a/rootfs/api/tests/test_build.py b/rootfs/api/tests/test_build.py
index a7ba312b..515fdb48 100644
--- a/rootfs/api/tests/test_build.py
+++ b/rootfs/api/tests/test_build.py
@@ -351,7 +351,17 @@ def test_build_no_remove_process(self, mock_requests):
         self.assertEqual(response.status_code, 200, response.data)
         self.assertEqual(response.json()['structure'], {'web': 1, 'task': 1, 'worker': 1})
 
-        # clean worker
+        # clean worker, repicas 1 not allow
+        response = self.client.post('/v2/apps/{}/ptypes/clean'.format(app_id), {"ptypes": "task"})
+        self.assertEqual(response.status_code, 400, response.data)
+
+        # scale task
+        url = f"/v2/apps/{app_id}/ptypes/scale"
+        body = {'task': 0}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 204, response.data)
+
+        # clean worker, repicas 0 allow
         response = self.client.post('/v2/apps/{}/ptypes/clean'.format(app_id), {"ptypes": "task"})
         self.assertEqual(response.status_code, 204, response.data)
 
@@ -361,8 +371,19 @@ def test_build_no_remove_process(self, mock_requests):
         self.assertEqual(response.status_code, 200, response.data)
         self.assertEqual(response.json()['structure'], {'web': 1, 'worker': 1})
 
-        # clean all worker
+        # ptypes is a required field
         response = self.client.post('/v2/apps/{}/ptypes/clean'.format(app_id))
+        self.assertEqual(response.status_code, 400, response.data)
+
+        url = f"/v2/apps/{app_id}/ptypes/scale"
+        body = {'worker': 0}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 204, response.data)
+
+        # clean worker
+        response = self.client.post(
+            '/v2/apps/{}/ptypes/clean'.format(app_id),
+            {"ptypes": "worker"})
         self.assertEqual(response.status_code, 204, response.data)
 
         # look at the app structure
diff --git a/rootfs/api/views.py b/rootfs/api/views.py
index 37674347..9e4681cb 100644
--- a/rootfs/api/views.py
+++ b/rootfs/api/views.py
@@ -491,7 +491,12 @@ def clean(self, request, *args, **kwargs):
         app = self.get_app()
         ptypes = set(
             [ptype for ptype in request.data.get("ptypes", "").split(",") if ptype])
-        ptypes = app.check_ptypes(ptypes)
+        if not ptypes:
+            raise DryccException("ptypes is a required field")
+        latest_ptypes = [k for k, v in app.structure.items() if v != 0]
+        not_allow = [ptype for ptype in ptypes if ptype in latest_ptypes]
+        if not_allow:
+            raise DryccException(f'ptype {",".join(not_allow)} should not garbage.')
         app.clean(ptypes=ptypes)
         return Response(status=status.HTTP_204_NO_CONTENT)
 

From e0a6db6b596f09db12801d530b2e3606853ddd16 Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo1991@outlook.com>
Date: Fri, 13 Dec 2024 16:53:43 +0800
Subject: [PATCH 6/6] chore(controller): request check name parameter

---
 charts/controller/templates/_helpers.tpl      |  7 +++
 .../templates/controller-cronjob-daily.yaml   |  6 ++
 .../templates/controller-cronjob-hourly.yaml  |  5 +-
 .../templates/controller-job-upgrade.yaml     |  2 +
 rootfs/api/models/gateway.py                  |  4 +-
 rootfs/api/serializers/__init__.py            | 24 +++++++-
 rootfs/api/settings/production.py             |  6 +-
 rootfs/api/tests/test_app_settings.py         |  6 +-
 rootfs/api/tests/test_gateway.py              | 57 +++++++++++++++++++
 rootfs/api/tests/test_resource.py             | 20 +++++++
 rootfs/api/tests/test_volume.py               | 32 +++++++++++
 rootfs/api/urls.py                            | 45 +++++++++------
 12 files changed, 190 insertions(+), 24 deletions(-)

diff --git a/charts/controller/templates/_helpers.tpl b/charts/controller/templates/_helpers.tpl
index 42b9979a..6a0af900 100644
--- a/charts/controller/templates/_helpers.tpl
+++ b/charts/controller/templates/_helpers.tpl
@@ -177,6 +177,13 @@ env:
 {{- end }}
 {{- end }}
 
+
+{{- define "controller-job.envs" }}
+env:
+- name: DRYCC_DATABASE_ROUTERS
+  value: api.routers.DefaultReplicaRouter
+{{- end }}
+
 {{/* Generate controller deployment limits */}}
 {{- define "controller.limits" -}}
 {{- if or (.Values.limitsCpu) (.Values.limitsMemory) }}
diff --git a/charts/controller/templates/controller-cronjob-daily.yaml b/charts/controller/templates/controller-cronjob-daily.yaml
index 365149f9..9265732d 100644
--- a/charts/controller/templates/controller-cronjob-daily.yaml
+++ b/charts/controller/templates/controller-cronjob-daily.yaml
@@ -33,6 +33,7 @@ spec:
               - -a
               - $(DRYCC_CONTROLLER_API_SERVICE_HOST):$(DRYCC_CONTROLLER_API_SERVICE_PORT)
             {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
           containers:
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.pull_policy}}
@@ -47,6 +48,7 @@ spec:
             - python -u /workspace/manage.py clearsocial
             {{- end }}
             {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.imagePullPolicy}}
             name: drycc-controller-load-db-state-to-k8s
@@ -60,6 +62,7 @@ spec:
               - python /workspace/manage.py load_db_state_to_k8s
             {{- end }}
             {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.pull_policy}}
             name: drycc-controller-measure-apps
@@ -73,6 +76,7 @@ spec:
               - python -u /workspace/manage.py measure_apps
             {{- end }}
             {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.pull_policy}}
             name: drycc-controller-measure-resources
@@ -86,6 +90,7 @@ spec:
             - python -u /workspace/manage.py measure_resources
             {{- end }}
             {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.pull_policy}}
             name: drycc-controller-measure-volumes
@@ -99,3 +104,4 @@ spec:
             - python -u /workspace/manage.py measure_volumes
             {{- end }}
             {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
diff --git a/charts/controller/templates/controller-cronjob-hourly.yaml b/charts/controller/templates/controller-cronjob-hourly.yaml
index 4bec8c2d..3fa6f22f 100644
--- a/charts/controller/templates/controller-cronjob-hourly.yaml
+++ b/charts/controller/templates/controller-cronjob-hourly.yaml
@@ -33,6 +33,7 @@ spec:
               - -a
               - $(DRYCC_CONTROLLER_API_SERVICE_HOST):$(DRYCC_CONTROLLER_API_SERVICE_PORT)
             {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
           containers:
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.pull_policy}}
@@ -47,6 +48,7 @@ spec:
             - python -u /workspace/manage.py measure_networks
             {{- end }}
             {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.pull_policy}}
             name: drycc-controller-measure-loadbalancer
@@ -59,4 +61,5 @@ spec:
             - -c
             - python -u /workspace/manage.py measure_loadbalancers
             {{- end }}
-            {{- include "controller.envs" . | indent 12 }}
\ No newline at end of file
+            {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
\ No newline at end of file
diff --git a/charts/controller/templates/controller-job-upgrade.yaml b/charts/controller/templates/controller-job-upgrade.yaml
index af78741e..d0103152 100644
--- a/charts/controller/templates/controller-job-upgrade.yaml
+++ b/charts/controller/templates/controller-job-upgrade.yaml
@@ -20,6 +20,7 @@ spec:
         - -u
         - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
         {{- include "controller.envs" . | indent 8 }}
+        {{- include "controller-job.envs" . | indent 12 }}
       containers:
       - name: drycc-controller-job-upgrade
         image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
@@ -38,6 +39,7 @@ spec:
           python -u /workspace/manage.py loaddata /etc/controller/limit-plans.json
         {{- end }}
         {{- include "controller.envs" . | indent 8 }}
+        {{- include "controller-job.envs" . | indent 12 }}
         volumeMounts:
         - name: controller-config
           readOnly: false
diff --git a/rootfs/api/models/gateway.py b/rootfs/api/models/gateway.py
index 4743f8e8..af1cad24 100644
--- a/rootfs/api/models/gateway.py
+++ b/rootfs/api/models/gateway.py
@@ -7,7 +7,6 @@
 from django.http import Http404
 
 from api.exceptions import ServiceUnavailable
-from api.models.service import Service
 from scheduler import KubeException
 
 from .base import AuditedModel, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT
@@ -190,9 +189,8 @@ def services(self):
             for rule in self.rules:
                 for backend in rule['backendRefs']:
                     service_names.add(backend['name'])
-            services = list(Service.objects.using("default").filter(app=self.app))
             setattr(self.CACHE, key,
-                    [s for s in services if s.name in service_names])
+                    [s for s in self.app.service_set.all() if s.name in service_names])
         return getattr(self.CACHE, key)
 
     @property
diff --git a/rootfs/api/serializers/__init__.py b/rootfs/api/serializers/__init__.py
index ad9a0fe2..60964aff 100644
--- a/rootfs/api/serializers/__init__.py
+++ b/rootfs/api/serializers/__init__.py
@@ -80,6 +80,19 @@ def validate_ptype(value):
     return value
 
 
+def validate_name(value):
+    NAME_REGEX = r'^(?P<name>{})$'.format(settings.NAME_REGEX)
+    NAME_MATCH = re.compile(NAME_REGEX)
+    NAME_MISMATCH_MSG = "name can only supports: %s" % NAME_MATCH.pattern
+    if not re.match(NAME_REGEX, value):
+        raise serializers.ValidationError(NAME_MISMATCH_MSG)
+    if len(value) < PTYPE_MIN_LENGTH or len(value) > PTYPE_MAX_LENGTH:
+        raise serializers.ValidationError(
+            "The length of name must be between {} and {}".format(
+                PTYPE_MIN_LENGTH, PTYPE_MAX_LENGTH))
+    return value
+
+
 class JSONFieldSerializer(serializers.JSONField):
     def __init__(self, *args, **kwargs):
         self.convert_to_str = kwargs.pop('convert_to_str', True)
@@ -585,7 +598,8 @@ class Meta:
 
     @staticmethod
     def validate_autoscale(data):
-        for _, autoscale in data.items():
+        for ptype, autoscale in data.items():
+            validate_ptype(ptype)
             if autoscale is None:
                 continue
             validate_json(autoscale, AUTOSCALE_SCHEMA, serializers.ValidationError)
@@ -634,6 +648,8 @@ def validate_size(self, data):
             raise serializers.ValidationError(VOLUME_SIZE_MISMATCH_MSG)
         return data.upper()
 
+    validate_name = staticmethod(validate_name)
+
     @staticmethod
     def validate_path(data):
         logger.debug(f"mount validate_path data: {data}")
@@ -691,6 +707,8 @@ def update(self, instance, validated_data):
         instance.save()
         return instance
 
+    validate_name = staticmethod(validate_name)
+
 
 class MetricSerializer(serializers.Serializer):
     start = serializers.IntegerField(
@@ -733,6 +751,8 @@ def to_representation(self, instance):
         representation['addresses'] = instance.addresses
         return representation
 
+    validate_name = staticmethod(validate_name)
+
     @staticmethod
     def validate_protocol(value):
         if not re.match(GATEWAY_PROTOCOL_MATCH, value):
@@ -756,6 +776,8 @@ class Meta:
         model = models.gateway.Route
         fields = '__all__'
 
+    validate_name = staticmethod(validate_name)
+
     @staticmethod
     def validate_kind(value):
         if not re.match(ROUTE_PROTOCOL_MATCH, value):
diff --git a/rootfs/api/settings/production.py b/rootfs/api/settings/production.py
index 1b793e2d..d8ce0c0e 100644
--- a/rootfs/api/settings/production.py
+++ b/rootfs/api/settings/production.py
@@ -372,12 +372,16 @@
     DATABASES["replica"] = dj_database_url.config(default=DRYCC_DATABASE_REPLICA_URL)
 
 # database routers
-DATABASE_ROUTERS = ['api.routers.DefaultReplicaRouter', ]
+# Implements: 'api.routers.DefaultReplicaRouter'
+DATABASE_ROUTERS = [r for r in os.environ.get('DRYCC_DATABASE_ROUTERS', '').split(',') if r]
+
 
 APP_URL_REGEX = '[a-z0-9-]+'
 
 DOMAIN_URL_REGEX = r'\**\.?[-\._\w]+'
 
+NAME_REGEX = r'[a-z0-9]+(\-[a-z0-9]+)*'
+
 # Oauth settings
 
 DRYCC_PASSPORT_URL = os.environ.get('DRYCC_PASSPORT_URL', 'https://127.0.0.1:8000')
diff --git a/rootfs/api/tests/test_app_settings.py b/rootfs/api/tests/test_app_settings.py
index 92cb3a9a..794d7700 100644
--- a/rootfs/api/tests/test_app_settings.py
+++ b/rootfs/api/tests/test_app_settings.py
@@ -136,8 +136,12 @@ def test_autoscale(self, mock_requests):
         # check that config fails if trying to unset non-existing proc type
         response = self.client.post(
             f'/v2/apps/{app_id}/settings',
-            {'autoscale': {'invalid_proctype': None}})
+            {'autoscale': {'invalid-proctype': None}})
         self.assertEqual(response.status_code, 422, response.data)
+        response = self.client.post(
+            f'/v2/apps/{app_id}/settings',
+            {'autoscale': {'invalid_proctype': None}})
+        self.assertEqual(response.status_code, 400, response.data)
 
         # remove a proc type
         response = self.client.post(
diff --git a/rootfs/api/tests/test_gateway.py b/rootfs/api/tests/test_gateway.py
index f0904fea..1bd42d31 100644
--- a/rootfs/api/tests/test_gateway.py
+++ b/rootfs/api/tests/test_gateway.py
@@ -71,6 +71,27 @@ def create_gateway(self, app_id, name, port, protocol):
         )
         self.assertEqual(response.status_code, 201)
 
+    def create_gateway_name(self):
+        app_id = self.create_app()
+        name1 = 'gatway_'
+        response = self.client.post(
+            f'/v2/apps/{app_id}/gateways/',
+            {'name': name1, 'port': 80, 'protocol': 'HTTP'}
+        )
+        self.assertEqual(response.status_code, 400)
+        name2 = '-gatway'
+        response = self.client.post(
+            f'/v2/apps/{app_id}/gateways/',
+            {'name': name2, 'port': 80, 'protocol': 'HTTP'}
+        )
+        self.assertEqual(response.status_code, 400)
+        name3 = 'test.gatway'
+        response = self.client.post(
+            f'/v2/apps/{app_id}/gateways/',
+            {'name': name3, 'port': 80, 'protocol': 'HTTP'}
+        )
+        self.assertEqual(response.status_code, 400)
+
     def create_tls_domain(self, app_id):
         cert_url = f'/v2/apps/{app_id}/certs'
         secret_name = ''.join(random.choice(string.ascii_lowercase) for _ in range(23))
@@ -463,6 +484,42 @@ def test_create_route(self):
         self.assertEqual(response.data["count"], 1)
         self.assertEqual(len(response.data["results"][0]["rules"]), 1)
 
+        # name regex format
+        name1 = "test-route_"
+        response = self.client.post(
+            '/v2/apps/{}/routes/'.format(app_id),
+            {
+                "port": 5000,
+                "ptype": "no-exists",
+                "kind": "HTTPRoute",
+                "name": name1,
+            }
+        )
+        self.assertEqual(response.status_code, 400)
+        # name regex format
+        name2 = "-test-route"
+        response = self.client.post(
+            '/v2/apps/{}/routes/'.format(app_id),
+            {
+                "port": 5000,
+                "ptype": "no-exists",
+                "kind": "HTTPRoute",
+                "name": name2,
+            }
+        )
+        self.assertEqual(response.status_code, 400)
+        name3 = "test.route"
+        response = self.client.post(
+            '/v2/apps/{}/routes/'.format(app_id),
+            {
+                "port": 5000,
+                "ptype": "no-exists",
+                "kind": "HTTPRoute",
+                "name": name3,
+            }
+        )
+        self.assertEqual(response.status_code, 400)
+
     def test_hostnames(self):
         app_id = self.create_app()
         ptype, port, route_name = self.create_route(app_id)
diff --git a/rootfs/api/tests/test_resource.py b/rootfs/api/tests/test_resource.py
index 3d6590be..1c591d9d 100644
--- a/rootfs/api/tests/test_resource.py
+++ b/rootfs/api/tests/test_resource.py
@@ -33,6 +33,26 @@ def test_resources_create(self, mock_requests):
         """Test that the serialized response contains only relevant data."""
         app_id = self.create_app()
 
+        # name regex error
+        name1 = 'mysql_'
+        response = self.client.post(
+            '/v2/apps/{}/resources'.format(app_id),
+            data={'name': name1, 'plan': 'mysql:5.6'}
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        name2 = '-mysql'
+        response = self.client.post(
+            '/v2/apps/{}/resources'.format(app_id),
+            data={'name': name2, 'plan': 'mysql:5.6'}
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        name3 = 'mysql.8'
+        response = self.client.post(
+            '/v2/apps/{}/resources'.format(app_id),
+            data={'name': name3, 'plan': 'mysql:5.6'}
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+
         response = self.client.post(
             '/v2/apps/{}/resources'.format(app_id),
             data={'name': 'mysql', 'plan': 'mysql:5.6'}
diff --git a/rootfs/api/tests/test_volume.py b/rootfs/api/tests/test_volume.py
index e3d18059..d95301a5 100644
--- a/rootfs/api/tests/test_volume.py
+++ b/rootfs/api/tests/test_volume.py
@@ -51,6 +51,38 @@ def test_volumecreate(self, mock_requests):
         )
         self.assertEqual(response.status_code, 400, response.data)
 
+        # name regex error
+        name1 = 'myvolume_'
+        response = self.client.post(
+            '/v2/apps/{}/volumes'.format(app_id),
+            data={
+                'name': name1,
+                'type': 'nfs',
+                'parameters': {'nfs': {'path': '/'}}
+            }
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        name2 = '-myvolume'
+        response = self.client.post(
+            '/v2/apps/{}/volumes'.format(app_id),
+            data={
+                'name': name2,
+                'type': 'nfs',
+                'parameters': {'nfs': {'path': '/'}}
+            }
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        name3 = 'my.volume'
+        response = self.client.post(
+            '/v2/apps/{}/volumes'.format(app_id),
+            data={
+                'name': name3,
+                'type': 'nfs',
+                'parameters': {'nfs': {'path': '/'}}
+            }
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+
         response = self.client.post(
             '/v2/apps/{}/volumes'.format(app_id),
             data={
diff --git a/rootfs/api/urls.py b/rootfs/api/urls.py
index ddf3cfa6..698722b9 100644
--- a/rootfs/api/urls.py
+++ b/rootfs/api/urls.py
@@ -51,7 +51,8 @@
         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),
+        r"^apps/(?P<id>{})/pods/(?P<name>{})/describe/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.PodViewSet.as_view({'get': 'describe'})),
     # restart deployment/ptype's pods
     re_path(
@@ -71,7 +72,8 @@
         views.PtypeViewSet.as_view({'get': 'list'})),
     # describe ptypes
     re_path(
-        r"^apps/(?P<id>{})/ptypes/(?P<name>[-_\w]+)/describe/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/ptypes/(?P<name>{})/describe/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.PtypeViewSet.as_view({'get': 'describe'})),
     # list events
     re_path(
@@ -107,20 +109,22 @@
         r"^apps/(?P<id>{})/volumes/?$".format(settings.APP_URL_REGEX),
         views.AppVolumesViewSet.as_view({'get': 'list', 'post': 'create'})),
     re_path(
-        r"^apps/(?P<id>{})/volumes/(?P<name>[-_\w]+)/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/volumes/(?P<name>{})/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppVolumesViewSet.as_view(
             {'get': 'retrieve', 'patch': 'expand', 'delete': 'destroy'})),
     re_path(
-        r"^apps/(?P<id>{})/volumes/(?P<name>[-_\w]+)/path/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/volumes/(?P<name>{})/path/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppVolumesViewSet.as_view({'patch': 'path'})),
     # application filer
     re_path(
-        r"^apps/(?P<id>{})/volumes/(?P<name>[-_\w]+)/client/?$".format(
-            settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/volumes/(?P<name>{})/client/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppFilerClientViewSet.as_view({'get': 'list', 'post': 'create'})),
     re_path(
-        r"^apps/(?P<id>{})/volumes/(?P<name>[-_\w]+)/client/(?P<path>[\S]+)$".format(
-            settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/volumes/(?P<name>{})/client/(?P<path>[\S]+)$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppFilerClientViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'})),
     # application resources
     re_path(r"^resources/services/?$", views.AppResourcesViewSet.as_view({'get': 'services'})),
@@ -131,19 +135,22 @@
         r"^apps/(?P<id>{})/resources/?$".format(settings.APP_URL_REGEX),
         views.AppResourcesViewSet.as_view({'get': 'list', 'post': 'create'})),
     re_path(
-        r"^apps/(?P<id>{})/resources/(?P<name>[-_\w]+)/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/resources/(?P<name>{})/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppSingleResourceViewSet.as_view(
             {'get': 'retrieve', 'delete': 'destroy', 'put': 'update'})),
     re_path(
-        r"^apps/(?P<id>{})/resources/(?P<name>[-_\w]+)/binding/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/resources/(?P<name>{})/binding/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.AppResourceBindingViewSet.as_view({'patch': 'binding'})),
     # certificates
     re_path(
-        r'^apps/(?P<id>{})/certs/(?P<name>[-_*.\w]+)/domain/(?P<domain>{})?/?$'.format(
-            settings.APP_URL_REGEX, settings.DOMAIN_URL_REGEX),
+        r'^apps/(?P<id>{})/certs/(?P<name>{})/domain/(?P<domain>{})?/?$'.format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX, settings.DOMAIN_URL_REGEX),
         views.CertificateViewSet.as_view({'delete': 'detach', 'post': 'attach'})),
     re_path(
-        r'^apps/(?P<id>{})/certs/(?P<name>[-_*.\w]+)/?$'.format(settings.APP_URL_REGEX),
+        r'^apps/(?P<id>{})/certs/(?P<name>{})/?$'.format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.CertificateViewSet.as_view({
             'get': 'retrieve',
             'delete': 'destroy'
@@ -192,17 +199,21 @@
             {'post': 'create_or_update', 'get': 'list', 'delete': 'delete'})),
     # routes
     re_path(
-        r"^apps/(?P<id>{})/routes/(?P<name>[-_\w]+)?/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/routes/(?P<name>{})?/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.RouteViewSet.as_view(
             {'post': 'create', 'get': 'list', 'delete': 'delete'})),
     re_path(
-        r"^apps/(?P<id>{})/routes/(?P<name>[-_\w]+)/attach/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/routes/(?P<name>{})/attach/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.RouteViewSet.as_view({'patch': 'attach'})),
     re_path(
-        r"^apps/(?P<id>{})/routes/(?P<name>[-_\w]+)/detach/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/routes/(?P<name>{})/detach/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.RouteViewSet.as_view({'patch': 'detach'})),
     re_path(
-        r"^apps/(?P<id>{})/routes/(?P<name>[-_\w]+)/rules/?$".format(settings.APP_URL_REGEX),
+        r"^apps/(?P<id>{})/routes/(?P<name>{})/rules/?$".format(
+            settings.APP_URL_REGEX, settings.NAME_REGEX),
         views.RouteViewSet.as_view({'get': 'get', 'put': 'set'})),
     # users
     re_path(r'^users/?$', views.UserView.as_view({'get': 'list'})),
