diff --git a/.gitignore b/.gitignore
index 1eb305349..2d9ec853d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ var
 sdist
 develop-eggs
 .installed.cfg
+.venv
 lib
 lib64
 
@@ -44,4 +45,4 @@ vendor/
 # generated bintray scripts during ci
 client/_scripts/ci/bintray-ci.json
 .idea
-.vscode/
\ No newline at end of file
+.vscode/
diff --git a/.woodpecker/chart.yaml b/.woodpecker/chart.yaml
index cfcd3ecbd..63eb318c5 100644
--- a/.woodpecker/chart.yaml
+++ b/.woodpecker/chart.yaml
@@ -10,6 +10,7 @@ steps:
   - export IMAGE_TAG=$([ ! -z $CI_COMMIT_TAG ] && echo \"$VERSION\" || echo \"canary\")
   - export APP_VERSION=$([ -z $CI_COMMIT_TAG ] && echo $CI_COMMIT_SHA || echo $VERSION)
   - export CHART_VERSION=$([ -z $CI_COMMIT_TAG ] && echo 1.0.0 || echo $VERSION)
+  - _scripts/util/update_filer.sh
   - sed -i "s/imageTag:\ \"canary\"/imageTag:\ $IMAGE_TAG/g" charts/$${CI_REPO_NAME}/values.yaml
   - helm package -u charts/$${CI_REPO_NAME} --version $CHART_VERSION --app-version $APP_VERSION
   - echo $CONTAINER_PASSWORD | helm registry login $DRYCC_REGISTRY -u $CONTAINER_USERNAME --password-stdin
diff --git a/_scripts/util/update_filer.sh b/_scripts/util/update_filer.sh
new file mode 100755
index 000000000..885b28a58
--- /dev/null
+++ b/_scripts/util/update_filer.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+
+if [ -n "${CI_COMMIT_TAG}" ]; then
+    version=$(curl -Ls https://github.com/drycc/filer/releases|grep /drycc/filer/releases/tag/ | sed -E 's/.*\/drycc\/filer\/releases\/tag\/(v[0-9\.]{1,}(-rc.[0-9]{1,})?)".*/\1/g' | head -1)
+    sed -i "s|registry.drycc.cc/drycc/filer:canary|registry.drycc.cc/drycc/filer:${version#v}|g" charts/controller/values.yaml
+fi
\ No newline at end of file
diff --git a/charts/controller/files/limit-plans.json b/charts/controller/files/limit-plans.json
new file mode 100644
index 000000000..2b9a7fe29
--- /dev/null
+++ b/charts/controller/files/limit-plans.json
@@ -0,0 +1,982 @@
+[
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 1,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 1,
+        "ephemeral-storage": "4Gi",
+        "memory": "1Gi"
+      },
+      "memory": 1,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 10100,
+      "requests": {
+        "cpu": 0.25,
+        "ephemeral-storage": "2Gi",
+        "memory": "0.5Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c1m1"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 1,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 1,
+        "ephemeral-storage": "4Gi",
+        "memory": "2Gi"
+      },
+      "memory": 2,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 10200,
+      "requests": {
+        "cpu": 0.25,
+        "ephemeral-storage": "2Gi",
+        "memory": "1Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c1m2"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 1,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 1,
+        "ephemeral-storage": "4Gi",
+        "memory": "4Gi"
+      },
+      "memory": 4,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 10300,
+      "requests": {
+        "cpu": 0.25,
+        "ephemeral-storage": "2Gi",
+        "memory": "2Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c1m4"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 1,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 1,
+        "ephemeral-storage": "4Gi",
+        "memory": "8Gi"
+      },
+      "memory": 8,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 10400,
+      "requests": {
+        "cpu": 0.25,
+        "ephemeral-storage": "2Gi",
+        "memory": "4Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c1m8"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 2,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 2,
+        "ephemeral-storage": "4Gi",
+        "memory": "2Gi"
+      },
+      "memory": 2,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 10500,
+      "requests": {
+        "cpu": 0.5,
+        "ephemeral-storage": "2Gi",
+        "memory": "1Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c2m2"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 2,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 2,
+        "ephemeral-storage": "4Gi",
+        "memory": "4Gi"
+      },
+      "memory": 4,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 10600,
+      "requests": {
+        "cpu": 0.5,
+        "ephemeral-storage": "2Gi",
+        "memory": "2Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c2m4"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 2,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 2,
+        "ephemeral-storage": "4Gi",
+        "memory": "8Gi"
+      },
+      "memory": 8,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 10700,
+      "requests": {
+        "cpu": 0.5,
+        "ephemeral-storage": "2Gi",
+        "memory": "4Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c2m8"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 2,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 2,
+        "ephemeral-storage": "4Gi",
+        "memory": "16Gi"
+      },
+      "memory": 16,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 10800,
+      "requests": {
+        "cpu": 0.5,
+        "ephemeral-storage": "2Gi",
+        "memory": "8Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c2m16"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 4,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 4,
+        "ephemeral-storage": "4Gi",
+        "memory": "4Gi"
+      },
+      "memory": 4,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 10900,
+      "requests": {
+        "cpu": 1,
+        "ephemeral-storage": "2Gi",
+        "memory": "2Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c4m4"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 4,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 4,
+        "ephemeral-storage": "4Gi",
+        "memory": "8Gi"
+      },
+      "memory": 8,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11000,
+      "requests": {
+        "cpu": 1,
+        "ephemeral-storage": "2Gi",
+        "memory": "4Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c4m8"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 4,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 4,
+        "ephemeral-storage": "4Gi",
+        "memory": "16Gi"
+      },
+      "memory": 16,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11100,
+      "requests": {
+        "cpu": 1,
+        "ephemeral-storage": "2Gi",
+        "memory": "8Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c4m16"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 4,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 4,
+        "ephemeral-storage": "4Gi",
+        "memory": "32Gi"
+      },
+      "memory": 32,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11200,
+      "requests": {
+        "cpu": 1,
+        "ephemeral-storage": "2Gi",
+        "memory": "16Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c4m32"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 8,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 8,
+        "ephemeral-storage": "4Gi",
+        "memory": "8Gi"
+      },
+      "memory": 8,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11300,
+      "requests": {
+        "cpu": 2,
+        "ephemeral-storage": "2Gi",
+        "memory": "4Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c8m8"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 8,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 8,
+        "ephemeral-storage": "4Gi",
+        "memory": "16Gi"
+      },
+      "memory": 16,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11400,
+      "requests": {
+        "cpu": 2,
+        "ephemeral-storage": "2Gi",
+        "memory": "8Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c8m16"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 8,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 8,
+        "ephemeral-storage": "4Gi",
+        "memory": "32Gi"
+      },
+      "memory": 32,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11500,
+      "requests": {
+        "cpu": 2,
+        "ephemeral-storage": "2Gi",
+        "memory": "16Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c8m32"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 8,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 8,
+        "ephemeral-storage": "4Gi",
+        "memory": "64Gi"
+      },
+      "memory": 64,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11600,
+      "requests": {
+        "cpu": 2,
+        "ephemeral-storage": "2Gi",
+        "memory": "32Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c8m64"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 16,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 16,
+        "ephemeral-storage": "4Gi",
+        "memory": "16Gi"
+      },
+      "memory": 16,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11700,
+      "requests": {
+        "cpu": 4,
+        "ephemeral-storage": "2Gi",
+        "memory": "8Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c16m16"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 16,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 16,
+        "ephemeral-storage": "4Gi",
+        "memory": "32Gi"
+      },
+      "memory": 32,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11800,
+      "requests": {
+        "cpu": 4,
+        "ephemeral-storage": "2Gi",
+        "memory": "16Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c16m32"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 16,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 16,
+        "ephemeral-storage": "4Gi",
+        "memory": "64Gi"
+      },
+      "memory": 64,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 11900,
+      "requests": {
+        "cpu": 4,
+        "ephemeral-storage": "2Gi",
+        "memory": "32Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c16m64"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 16,
+      "created": "2025-09-16T05:05:37.136Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 16,
+        "ephemeral-storage": "4Gi",
+        "memory": "128Gi"
+      },
+      "memory": 128,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 12000,
+      "requests": {
+        "cpu": 4,
+        "ephemeral-storage": "2Gi",
+        "memory": "64Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.136Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c16m128"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 32,
+      "created": "2025-09-16T05:05:37.137Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 32,
+        "ephemeral-storage": "4Gi",
+        "memory": "32Gi"
+      },
+      "memory": 32,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 12100,
+      "requests": {
+        "cpu": 8,
+        "ephemeral-storage": "2Gi",
+        "memory": "16Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.137Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c32m32"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 32,
+      "created": "2025-09-16T05:05:37.137Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 32,
+        "ephemeral-storage": "4Gi",
+        "memory": "64Gi"
+      },
+      "memory": 64,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 12200,
+      "requests": {
+        "cpu": 8,
+        "ephemeral-storage": "2Gi",
+        "memory": "32Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.137Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c32m64"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 32,
+      "created": "2025-09-16T05:05:37.137Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 32,
+        "ephemeral-storage": "4Gi",
+        "memory": "128Gi"
+      },
+      "memory": 128,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 12300,
+      "requests": {
+        "cpu": 8,
+        "ephemeral-storage": "2Gi",
+        "memory": "64Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.137Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c32m128"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 32,
+      "created": "2025-09-16T05:05:37.137Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 32,
+        "ephemeral-storage": "4Gi",
+        "memory": "256Gi"
+      },
+      "memory": 256,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 12400,
+      "requests": {
+        "cpu": 8,
+        "ephemeral-storage": "2Gi",
+        "memory": "128Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.137Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c32m256"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 64,
+      "created": "2025-09-16T05:05:37.137Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 64,
+        "ephemeral-storage": "4Gi",
+        "memory": "64Gi"
+      },
+      "memory": 64,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 12500,
+      "requests": {
+        "cpu": 16,
+        "ephemeral-storage": "2Gi",
+        "memory": "32Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.137Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c64m64"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 64,
+      "created": "2025-09-16T05:05:37.137Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 64,
+        "ephemeral-storage": "4Gi",
+        "memory": "128Gi"
+      },
+      "memory": 128,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 12600,
+      "requests": {
+        "cpu": 16,
+        "ephemeral-storage": "2Gi",
+        "memory": "64Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.137Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c64m128"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 64,
+      "created": "2025-09-16T05:05:37.137Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 64,
+        "ephemeral-storage": "4Gi",
+        "memory": "256Gi"
+      },
+      "memory": 256,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 12700,
+      "requests": {
+        "cpu": 16,
+        "ephemeral-storage": "2Gi",
+        "memory": "128Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.137Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c64m256"
+  },
+  {
+    "fields": {
+      "annotations": {
+        "kubernetes.io/egress-bandwidth": "100M",
+        "kubernetes.io/ingress-bandwidth": "100M"
+      },
+      "container_security_context": {},
+      "cpu": 64,
+      "created": "2025-09-16T05:05:37.137Z",
+      "disabled": false,
+      "features": {
+        "gpu": 1,
+        "network": 1
+      },
+      "limits": {
+        "cpu": 64,
+        "ephemeral-storage": "4Gi",
+        "memory": "512Gi"
+      },
+      "memory": 512,
+      "node_selector": {},
+      "pod_security_context": {},
+      "priority": 12800,
+      "requests": {
+        "cpu": 16,
+        "ephemeral-storage": "2Gi",
+        "memory": "256Gi"
+      },
+      "runtime_class_name": "",
+      "spec_id": "std1",
+      "updated": "2025-09-16T05:05:37.137Z"
+    },
+    "model": "api.limitplan",
+    "pk": "std1.large.c64m512"
+  }
+]
diff --git a/charts/controller/files/limit-specs.json b/charts/controller/files/limit-specs.json
new file mode 100644
index 000000000..732a70e2d
--- /dev/null
+++ b/charts/controller/files/limit-specs.json
@@ -0,0 +1,38 @@
+[
+  {
+    "fields": {
+      "cpu": {
+        "boost": "3700MHZ",
+        "clock": "3100MHZ",
+        "cores": 32,
+        "name": "Universal CPU",
+        "threads": 64
+      },
+      "created": "2025-09-16T05:05:37.135Z",
+      "disabled": false,
+      "features": {
+        "gpu": {
+          "cores": 128,
+          "memory": {
+            "size": "shared",
+            "type": "shared"
+          },
+          "name": "Integrated GPU",
+          "rops": 1,
+          "tmus": 1
+        },
+        "network": "10G"
+      },
+      "keywords": [
+        "unknown"
+      ],
+      "memory": {
+        "size": "64GB",
+        "type": "DDR4-ECC"
+      },
+      "updated": "2025-09-16T05:05:37.135Z"
+    },
+    "model": "api.limitspec",
+    "pk": "std1"
+  }
+]
diff --git a/charts/controller/files/metrics.json b/charts/controller/files/metrics.json
new file mode 100644
index 000000000..e620eeec2
--- /dev/null
+++ b/charts/controller/files/metrics.json
@@ -0,0 +1,466 @@
+{
+  "container_cpu_load_average_10s": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_cpu_system_seconds_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_cpu_usage_seconds_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_cpu_user_seconds_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_cpu_cfs_periods_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_cpu_cfs_throttled_periods_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_cpu_cfs_throttled_seconds_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_inodes_free": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_usage_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_inodes_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_io_current": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_io_time_seconds_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_io_time_weighted_seconds_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_limit_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_reads_bytes_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_read_seconds_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_reads_merged_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_reads_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_sector_reads_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_sector_writes_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_writes_bytes_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_write_seconds_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_writes_merged_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_fs_writes_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_blkio_device_usage_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_memory_failures_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_memory_failcnt": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_memory_cache": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_memory_mapped_file": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_memory_max_usage_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_memory_rss": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_memory_swap": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_memory_usage_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_memory_working_set_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_network_receive_bytes_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_network_receive_errors_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_network_receive_packets_dropped_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_network_receive_packets_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_network_transmit_bytes_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_network_transmit_errors_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_network_transmit_packets_dropped_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_network_transmit_packets_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_oom_events_total": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_processes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_sockets": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_file_descriptors": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_threads": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_threads_max": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_ulimits_soft": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_spec_cpu_quota": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_spec_cpu_period": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_spec_cpu_shares": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_spec_memory_limit_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_spec_memory_reservation_limit_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_spec_memory_swap_limit_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_start_time_seconds": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_tasks_state": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_last_seen": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_accelerator_memory_used_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_accelerator_memory_total_bytes": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "container_accelerator_duty_cycle": [
+    "instance",
+    "namespace",
+    "pod",
+    "container"
+  ],
+  "kube_pod_ips": [
+    "ip",
+    "ip_family",
+    "namespace",
+    "node",
+    "pod"
+  ],
+  "kube_pod_container_status_running": [
+    "container",
+    "namespace",
+    "node",
+    "pod"
+  ],
+  "kube_pod_container_status_ready": [
+    "container",
+    "namespace",
+    "node",
+    "pod"
+  ],
+  "kube_pod_container_status_terminated": [
+    "container",
+    "namespace",
+    "node",
+    "pod"
+  ],
+  "kube_pod_container_status_waiting": [
+    "container",
+    "namespace",
+    "node",
+    "pod"
+  ],
+  "kube_pod_container_status_restarts_total": [
+    "container",
+    "namespace",
+    "node",
+    "pod"
+  ],
+  "kube_pod_spec_volumes_persistentvolumeclaims_info": [
+    "namespace",
+    "pod",
+    "persistentvolumeclaim"
+  ],
+  "kubelet_volume_stats_used_bytes": [
+    "namespace",
+    "persistentvolumeclaim"
+  ],
+  "kubelet_volume_stats_available_bytes": [
+    "namespace",
+    "persistentvolumeclaim"
+  ],
+  "kubelet_volume_stats_capacity_bytes": [
+    "namespace",
+    "persistentvolumeclaim"
+  ],
+  "kubelet_volume_stats_inodes": [
+    "namespace",
+    "persistentvolumeclaim"
+  ],
+  "kubelet_volume_stats_inodes_free": [
+    "namespace",
+    "persistentvolumeclaim"
+  ],
+  "kubelet_volume_stats_inodes_used": [
+    "namespace",
+    "persistentvolumeclaim"
+  ],
+  "istio_requests_total": [
+    "source_workload_namespace",
+    "reporter",
+    "destination_service",
+    "response_code",
+    "connection_security_policy",
+    "source_workload"
+  ],
+  "istio_request_duration_milliseconds_bucket": [
+    "source_workload_namespace",
+    "reporter",
+    "destination_service",
+    "connection_security_policy",
+    "source_workload",
+    "le"
+  ],
+  "istio_request_bytes_bucket": [
+    "source_workload_namespace",
+    "reporter",
+    "destination_service",
+    "connection_security_policy",
+    "source_workload",
+    "le"
+  ],
+  "istio_response_bytes_bucket": [
+    "source_workload_namespace",
+    "reporter",
+    "destination_service",
+    "connection_security_policy",
+    "source_workload",
+    "le"
+  ]
+}
\ No newline at end of file
diff --git a/charts/controller/files/network-usage-template.promql b/charts/controller/files/network-usage-template.promql
new file mode 100644
index 000000000..07a608de1
--- /dev/null
+++ b/charts/controller/files/network-usage-template.promql
@@ -0,0 +1,9 @@
+label_replace(
+  sum(increase(istio_request_bytes_sum{namespace=~"{{namespaces}}"}[{{duration}}])) by (namespace,pod),
+  "direction", "rx", "", ""
+)
+or
+label_replace(
+  sum(increase(istio_response_bytes_sum{namespace=~"{{namespaces}}"}[{{duration}}])) by (namespace,pod),
+  "direction", "tx", "", ""
+)
diff --git a/charts/controller/files/reserved-name-patterns.txt b/charts/controller/files/reserved-name-patterns.txt
new file mode 100644
index 000000000..1b6ca23bc
--- /dev/null
+++ b/charts/controller/files/reserved-name-patterns.txt
@@ -0,0 +1,13 @@
+^backup$
+^catalog$
+^cert-manager$
+^default$
+^drycc(?:-[\w-]+)?$
+^istio(?:-[\w-]+)?$
+^kube(?:-[\w-]+)?$
+^longhorn-system$
+^metallb$
+^mount-s3$
+^topolvm$
+^rook-ceph$
+^personal$
\ No newline at end of file
diff --git a/charts/controller/files/volume-claim-template.json b/charts/controller/files/volume-claim-template.json
new file mode 100644
index 000000000..be8133bfa
--- /dev/null
+++ b/charts/controller/files/volume-claim-template.json
@@ -0,0 +1,61 @@
+{% if type == 'csi' %}
+{
+  "metadata": {
+    "annotations": {
+      "billing.drycc.cc/type": "usage"
+    }
+  },
+  "spec": {
+    "accessModes": [
+      "ReadWriteMany"
+    ],
+    "storageClassName": "{{storage_class}}",
+    "resources": {
+      "requests": {
+        "storage": "{{size}}"
+      }
+    },
+    "volumeMode": "Filesystem"
+  }
+}
+{% elif type == 'nfs' %}
+{
+  "metadata": {
+    "annotations": {
+      "billing.drycc.cc/type": "basic"
+    }
+  },
+  "spec": {
+    "accessModes": [
+      "ReadWriteMany"
+    ],
+    "storageClassName": "",
+    "resources": {
+      "requests": {
+        "storage": "{{size}}"
+      }
+    },
+    "volumeName": "{{volume_name}}"
+  }
+}
+{% elif type == 'oss' %}
+{
+  "metadata": {
+    "annotations": {
+      "billing.drycc.cc/type": "basic"
+    }
+  },
+  "spec": {
+    "accessModes": [
+      "ReadWriteMany"
+    ],
+    "storageClassName": "",
+    "resources": {
+      "requests": {
+        "storage": "{{size}}"
+      }
+    },
+    "volumeName": "{{volume_name}}"
+  }
+}
+{% endif %}
diff --git a/charts/controller/files/volume-template.json b/charts/controller/files/volume-template.json
new file mode 100644
index 000000000..b4a54f132
--- /dev/null
+++ b/charts/controller/files/volume-template.json
@@ -0,0 +1,55 @@
+{% if type == 'nfs' %}
+{
+  "spec": {
+    "capacity": {
+      "storage": "65535Gi"
+    },
+    "accessModes": [
+      "ReadWriteMany"
+    ],
+    "claimRef": {
+      "namespace": "{{namespace}}",
+      "name": "{{volume_claim_name}}"
+    },
+    "nfs": {
+      "server": "{{server}}",
+      "path": "{{path}}"
+    },
+    "mountOptions": [
+      "nfsvers=4.2"
+    ]
+  }
+}
+{% elif type == 'oss' %}
+{
+  "spec": {
+    "capacity": {
+      "storage": "65535Gi"
+    },
+    "accessModes": [
+      "ReadWriteMany"
+    ],
+    "storageClassName": "",
+    "claimRef": {
+      "namespace": "{{namespace}}",
+      "name": "{{volume_claim_name}}"
+    },
+    "mountOptions": [
+      "allow-delete",
+      {% if path_style %}
+      "force-path-style",
+      {% endif %}
+      "aws-access-key-id {{access_key}}",
+      "aws-secret-access-key {{secret_key}}",
+      "endpoint-url {{server}}"
+    ],
+    "csi": {
+      "driver": "s3.csi.aws.com",
+      "volumeHandle": "{{volume_handle}}",
+      "volumeAttributes": {
+        "bucketName": "{{bucket}}"
+      }
+    }
+  }
+}
+{% endif %}
diff --git a/charts/controller/files/volume-usage-template.promql b/charts/controller/files/volume-usage-template.promql
new file mode 100644
index 000000000..fec7f1913
--- /dev/null
+++ b/charts/controller/files/volume-usage-template.promql
@@ -0,0 +1,12 @@
+max by(namespace, persistentvolumeclaim, storageclass) (
+  kube_persistentvolumeclaim_resource_requests_storage_bytes{
+    namespace=~"{{namespaces}}"
+  }
+  * on(namespace, persistentvolumeclaim) group_left(storageclass)
+  kube_persistentvolumeclaim_info{namespace=~"{{namespaces}}"}
+  * on(namespace, persistentvolumeclaim) group_left()
+  kube_persistentvolumeclaim_annotations{
+    namespace=~"{{namespaces}}",
+    annotation_billing_drycc_cc_type="usage"
+  }
+)
diff --git a/charts/controller/templates/_helpers.tpl b/charts/controller/templates/_helpers.tpl
index 9a9774d8f..80902a4d2 100644
--- a/charts/controller/templates/_helpers.tpl
+++ b/charts/controller/templates/_helpers.tpl
@@ -5,23 +5,20 @@ env:
   value: {{ .Chart.AppVersion }}
 - name: REGISTRATION_MODE
   value: {{ .Values.registrationMode }}
-# Environmental variable value for $GATEWAY_CLASS
-- name: "DRYCC_GATEWAY_CLASS"
-  value: "{{ .Values.global.gatewayClass }}"
 - name: "K8S_API_VERIFY_TLS"
   value: "{{ .Values.k8sApiVerifyTls }}"
 - name: "DRYCC_REGISTRY_LOCATION"
-  value: "{{ .Values.global.registryLocation }}"
+  value: {{ ternary "on-cluster" "off-cluster" .Values.registry.enabled }}
 - name: "DRYCC_REGISTRY_SECRET_PREFIX"
-  value: "{{ .Values.global.registrySecretPrefix }}"
+  value: "{{ .Values.registrySecretPrefix }}"
 - name: "IMAGE_PULL_POLICY"
   value: "{{ .Values.appImagePullPolicy }}"
 - name: "DRYCC_FILER_IMAGE"
   value: "{{ (tpl .Values.filerImage .) }}"
 - name: "DRYCC_FILER_IMAGE_PULL_POLICY"
   value: "{{ (tpl .Values.filerImagePullPolicy .) }}"
-- name: "KUBERNETES_CLUSTER_DOMAIN"
-  value: "{{ .Values.global.clusterDomain }}"
+- name: "DRYCC_APP_GATEWAY_CLASS"
+  value: "{{ .Values.appGatewayClass }}"
 {{- if (.Values.appStorageClass) }}
 - name: "DRYCC_APP_STORAGE_CLASS"
   value: "{{ (tpl .Values.appStorageClass .) }}"
@@ -52,25 +49,25 @@ env:
     secretKeyRef:
       name: controller-creds
       key: django-secret-key
-- name: DRYCC_BUILDER_KEY
+- name: DRYCC_SERVICE_KEY
   valueFrom:
     secretKeyRef:
-      name: builder-key-auth
-      key: builder-key
+      name: controller-creds
+      key: service-key
 {{- if (.Values.valkeyUrl) }}
 - name: DRYCC_VALKEY_URL
   valueFrom:
     secretKeyRef:
       name: controller-creds
       key: valkey-url
-{{- else if eq .Values.global.valkeyLocation "on-cluster"  }}
+{{- else if .Values.valkey.enabled }}
 - name: VALKEY_PASSWORD
   valueFrom:
     secretKeyRef:
       name: valkey-creds
       key: password
 - name: DRYCC_VALKEY_URL
-  value: "redis://:$(VALKEY_PASSWORD)@drycc-valkey.{{.Release.Namespace}}.svc.{{.Values.global.clusterDomain}}:16379/0"
+  value: "redis://:$(VALKEY_PASSWORD)@drycc-valkey:16379/0"
 {{- end }}
 {{- if (.Values.databaseReplicaUrl) }}
 - name: DRYCC_DATABASE_REPLICA_URL
@@ -85,7 +82,7 @@ env:
     secretKeyRef:
       name: controller-creds
       key: database-url
-{{- else if eq .Values.global.databaseLocation "on-cluster"  }}
+{{- else if .Values.database.enabled }}
 - name: DRYCC_PG_USER
   valueFrom:
     secretKeyRef:
@@ -97,9 +94,9 @@ env:
       name: database-creds
       key: password
 - name: DRYCC_DATABASE_URL
-  value: "postgres://$(DRYCC_PG_USER):$(DRYCC_PG_PASSWORD)@drycc-database.{{.Release.Namespace}}.svc.{{.Values.global.clusterDomain}}:5432/controller"
+  value: "postgres://$(DRYCC_PG_USER):$(DRYCC_PG_PASSWORD)@drycc-database:5432/controller"
 - name: DRYCC_DATABASE_REPLICA_URL
-  value: "postgres://$(DRYCC_PG_USER):$(DRYCC_PG_PASSWORD)@drycc-database-replica.{{.Release.Namespace}}.svc.{{.Values.global.clusterDomain}}:5432/controller"
+  value: "postgres://$(DRYCC_PG_USER):$(DRYCC_PG_PASSWORD)@drycc-database-replica:5432/controller"
 {{- end }}
 {{- if (.Values.workflowManagerUrl) }}
 - name: WORKFLOW_MANAGER_URL
@@ -109,31 +106,29 @@ env:
 - name: WORKFLOW_MANAGER_SECRET_KEY
   value: "{{ .Values.workflowManagerSecretKey }}"
 {{- end }}
+- name: POD_IP
+  valueFrom:
+    fieldRef:
+      fieldPath: status.podIP
+- name: POD_NAME
+  valueFrom:
+    fieldRef:
+      fieldPath: metadata.name
 - name: WORKFLOW_NAMESPACE
   valueFrom:
     fieldRef:
       fieldPath: metadata.namespace
-{{- if (.Values.prometheusUrl) }}
-- name: "DRYCC_PROMETHEUS_URL"
+{{- if (.Values.victoriametricsUrl) }}
+- name: "DRYCC_VICTORIAMETRICS_URL"
   valueFrom:
     secretKeyRef:
       name: controller-creds
-      key: prometheus-url
-{{- else if eq .Values.global.prometheusLocation "on-cluster" }}
-- name: "DRYCC_PROMETHEUS_USERNAME"
-  valueFrom:
-    secretKeyRef:
-      name: prometheus-creds
-      key: username
-- name: "DRYCC_PROMETHEUS_PASSWORD"
-  valueFrom:
-    secretKeyRef:
-      name: prometheus-creds
-      key: password
-- name: "DRYCC_PROMETHEUS_URL"
-  value: "http://$(DRYCC_PROMETHEUS_USERNAME):$(DRYCC_PROMETHEUS_PASSWORD)@drycc-prometheus.{{$.Release.Namespace}}.svc.{{$.Values.global.clusterDomain}}:9090"
+      key: victoriametrics-url
+{{- else if .Values.victoriametrics.enabled }}
+- name: "DRYCC_VICTORIAMETRICS_URL"
+  value: "http://drycc-victoriametrics-vmselect:8481/select/multitenant/prometheus"
 {{- end }}
-{{- if eq .Values.global.passportLocation "on-cluster"}}
+{{- if .Values.passport.enabled }}
 - name: "DRYCC_PASSPORT_URL"
 {{- if .Values.global.certManagerEnabled }}
   value: https://drycc-passport.{{ .Values.global.platformDomain }}
@@ -167,260 +162,38 @@ env:
       name: controller-creds
       key: passport-secret
 {{- end }}
+- name: QUICKWIT_INDEXER_URL
+  value: http://drycc-quickwit-indexer:7280
+- name: QUICKWIT_SEARCHER_URL
+  value: http://drycc-quickwit-searcher:7280
+- name: QUICKWIT_LOG_INDEX_PREFIX
+  value: {{ .Values.quickwit.logIndexPrefix }}
 {{- range $key, $value := .Values.environment }}
 - name: {{ $key }}
   value: {{ $value | quote }}
 {{- end }}
 {{- end }}
 
-
 {{- define "controller-job.envs" }}
 {{- include "controller.envs" . }}
 - name: DRYCC_DATABASE_ROUTERS
   value: api.routers.DefaultReplicaRouter
 {{- end }}
 
-{{/* Generate controller deployment limits */}}
-{{- define "controller.limits" -}}
-{{- if or (.Values.limitsCpu) (.Values.limitsMemory) }}
-resources:
-  limits:
-{{- if (.Values.limitsCpu) }}
-    cpu: {{.Values.limitsCpu}}
-{{- end }}
-{{- if (.Values.limitsMemory) }}
-    memory: {{.Values.limitsMemory}}
-{{- end }}
-{{- end }}
-{{- end }}
-
-{{/* Generate controller config default limit specs */}}
-{{ define "controller.config.defaultLimitSpecs" }}
-- model: api.limitspec
-  pk: std1
-  fields:
-    cpu:
-      name: Universal CPU
-      cores: 32
-      clock: 3100MHZ
-      boost: 3700MHZ
-      threads: 64
-    memory:
-      size: 64GB
-      type: DDR4-ECC
-    features:
-      gpu:
-        name: Integrated GPU
-        tmus: 1
-        rops: 1
-        cores: 128
-        memory:
-          size: shared
-          type: shared
-      network: 10G
-    keywords:
-    - unknown
-    disabled: false
-    created: {{ now | date "2006-01-02T15:04:05.000Z" }}
-    updated: {{ now | date "2006-01-02T15:04:05.000Z" }}
-{{- end }}
-
-{{/* Generate controller config default limit plans */}}
-{{ define "controller.config.defaultLimitPlans" }}
-{{- $index := 0 }}
-{{- $cpus := tuple 1 2 4 8 16 32 64 }}
-{{- $scales := tuple 1 2 4 8 }}
-{{- range $cpu := $cpus }}
-{{- range $scale := $scales }}
-{{- $memory := mul $cpu $scale }}
-- model: api.limitplan
-  pk: std1.large.c{{ $cpu }}m{{ $memory }}
-  fields:
-    spec_id: std1
-    cpu: {{ $cpu }}
-    memory: {{ $memory }}
-    features:
-      gpu: 1
-      network: 1
-    disabled: false
-    priority: {{ add 10000 (mul (add1 $index) 100) }}
-    limits:
-      cpu: {{ $cpu }}
-      memory: {{ $memory }}Gi
-      ephemeral-storage: 4Gi
-    requests:
-      cpu: {{ divf $cpu 4 }}
-      memory: {{ divf $memory 2 }}Gi
-      ephemeral-storage: 2Gi
-    annotations:
-      kubernetes.io/egress-bandwidth: 100M
-      kubernetes.io/ingress-bandwidth: 100M
-    node_selector: {}
-    runtime_class_name: ""
-    pod_security_context: {}
-    container_security_context: {}
-    created: {{ now | date "2006-01-02T15:04:05.000Z" }}
-    updated: {{ now | date "2006-01-02T15:04:05.000Z" }}
-{{- $index = (add1 $index) }}
-{{- end }}
-{{- end }}
-{{- end }}
-
-{{/* Generate controller config default metrics */}}
-{{ define "controller.config.defaultMetrics" }}
-container_cpu_load_average_10s: [instance, namespace, pod, container]
-container_cpu_system_seconds_total: [instance, namespace, pod, container]
-container_cpu_usage_seconds_total: [instance, namespace, pod, container]
-container_cpu_user_seconds_total: [instance, namespace, pod, container]
-container_cpu_cfs_periods_total: [instance, namespace, pod, container]
-container_cpu_cfs_throttled_periods_total: [instance, namespace, pod, container]
-container_cpu_cfs_throttled_seconds_total: [instance, namespace, pod, container]
-container_fs_inodes_free: [instance, namespace, pod, container]
-container_fs_usage_bytes: [instance, namespace, pod, container]
-container_fs_inodes_total: [instance, namespace, pod, container]
-container_fs_io_current: [instance, namespace, pod, container]
-container_fs_io_time_seconds_total: [instance, namespace, pod, container]
-container_fs_io_time_weighted_seconds_total: [instance, namespace, pod, container]
-container_fs_limit_bytes: [instance, namespace, pod, container]
-container_fs_reads_bytes_total: [instance, namespace, pod, container]
-container_fs_read_seconds_total: [instance, namespace, pod, container]
-container_fs_reads_merged_total: [instance, namespace, pod, container]
-container_fs_reads_total: [instance, namespace, pod, container]
-container_fs_sector_reads_total: [instance, namespace, pod, container]
-container_fs_sector_writes_total: [instance, namespace, pod, container]
-container_fs_writes_bytes_total: [instance, namespace, pod, container]
-container_fs_write_seconds_total: [instance, namespace, pod, container]
-container_fs_writes_merged_total: [instance, namespace, pod, container]
-container_fs_writes_total: [instance, namespace, pod, container]
-container_blkio_device_usage_total: [instance, namespace, pod, container]
-container_memory_failures_total: [instance, namespace, pod, container]
-container_memory_failcnt: [instance, namespace, pod, container]
-container_memory_cache: [instance, namespace, pod, container]
-container_memory_mapped_file: [instance, namespace, pod, container]
-container_memory_max_usage_bytes: [instance, namespace, pod, container]
-container_memory_rss: [instance, namespace, pod, container]
-container_memory_swap: [instance, namespace, pod, container]
-container_memory_usage_bytes: [instance, namespace, pod, container]
-container_memory_working_set_bytes: [instance, namespace, pod, container]
-container_network_receive_bytes_total: [instance, namespace, pod, container]
-container_network_receive_errors_total: [instance, namespace, pod, container]
-container_network_receive_packets_dropped_total: [instance, namespace, pod, container]
-container_network_receive_packets_total: [instance, namespace, pod, container]
-container_network_transmit_bytes_total: [instance, namespace, pod, container]
-container_network_transmit_errors_total: [instance, namespace, pod, container]
-container_network_transmit_packets_dropped_total: [instance, namespace, pod, container]
-container_network_transmit_packets_total: [instance, namespace, pod, container]
-container_oom_events_total: [instance, namespace, pod, container]
-container_processes: [instance, namespace, pod, container]
-container_sockets: [instance, namespace, pod, container]
-container_file_descriptors: [instance, namespace, pod, container]
-container_threads: [instance, namespace, pod, container]
-container_threads_max: [instance, namespace, pod, container]
-container_ulimits_soft: [instance, namespace, pod, container]
-container_spec_cpu_quota: [instance, namespace, pod, container]
-container_spec_cpu_period: [instance, namespace, pod, container]
-container_spec_cpu_shares: [instance, namespace, pod, container]
-container_spec_memory_limit_bytes: [instance, namespace, pod, container]
-container_spec_memory_reservation_limit_bytes: [instance, namespace, pod, container]
-container_spec_memory_swap_limit_bytes: [instance, namespace, pod, container]
-container_start_time_seconds: [instance, namespace, pod, container]
-container_tasks_state: [instance, namespace, pod, container]
-container_last_seen: [instance, namespace, pod, container]
-container_accelerator_memory_used_bytes: [instance, namespace, pod, container]
-container_accelerator_memory_total_bytes: [instance, namespace, pod, container]
-container_accelerator_duty_cycle: [instance, namespace, pod, container]
-kube_pod_ips: [ip, ip_family, namespace, node, pod]
-kube_pod_container_status_running: [container, namespace, node, pod]
-kube_pod_container_status_ready: [container, namespace, node, pod]
-kube_pod_container_status_terminated: [container, namespace, node, pod]
-kube_pod_container_status_waiting: [container, namespace, node, pod]
-kube_pod_container_status_restarts_total: [container, namespace, node, pod]
-kube_pod_spec_volumes_persistentvolumeclaims_info: [namespace, pod, persistentvolumeclaim]
-kubelet_volume_stats_used_bytes: [namespace, persistentvolumeclaim, job]
-kubelet_volume_stats_available_bytes: [namespace, persistentvolumeclaim, job]
-kubelet_volume_stats_capacity_bytes: [namespace, persistentvolumeclaim, job]
-kubelet_volume_stats_inodes: [namespace, persistentvolumeclaim, job]
-kubelet_volume_stats_inodes_free: [namespace, persistentvolumeclaim, job]
-kubelet_volume_stats_inodes_used: [namespace, persistentvolumeclaim, job]
-{{- end }}
-
-
-{{/* Generate controller config default secrets template */}}
-{{ define "controller.config.defaultSecretTemplate" }}
-oss:
-  data:
-    key_id: $access_key
-    access_key: $secret_key
+{{- define "controller-api.envs" }}
+{{- include "controller.envs" . }}
+- name: DRYCC_CONTROLLER_RUNNER
+  value: api
 {{- end }}
 
-{{/* Generate controller config default volume template */}}
-{{ define "controller.config.defaultVolumeTemplate" }}
-nfs:
-  spec:
-    capacity:
-      storage: 65535Gi
-    accessModes:
-    - ReadWriteMany
-    claimRef:
-      namespace: $namespace
-      name: $volume_claim_name
-    nfs:
-      server: $server
-      path: $path
-    mountOptions:
-    - nfsvers=4.2
-oss:
-  spec:
-    capacity:
-      storage: 65535Gi
-    accessModes:
-    - ReadWriteMany
-    storageClassName: ''
-    claimRef:
-      namespace: $namespace
-      name: $volume_claim_name
-    mountOptions:
-    - allow-delete
-    - force-path-style
-    - endpoint-url $server
-    csi:
-      driver: s3.csi.aws.com
-      volumeHandle: $volume_handle
-      volumeAttributes:
-        bucketName: $bucket
-        authenticationSource: secrets
-      nodePublishSecretRef:
-        name: $secret_name
-        namespace: $namespace
+{{- define "controller-metric.envs" }}
+{{- include "controller.envs" . }}
+- name: DRYCC_CONTROLLER_RUNNER
+  value: metric
 {{- end }}
 
-{{/* Generate controller config default volume claim template */}}
-{{ define "controller.config.defaultVolumeClaimTemplate" }}
-csi:
-  spec:
-    accessModes:
-    - ReadWriteMany
-    storageClassName: $storage_class
-    resources:
-      requests:
-        storage: $size
-    volumeMode: Filesystem
-nfs:
-  spec:
-    accessModes:
-    - ReadWriteMany
-    storageClassName: ''
-    resources:
-      requests:
-        storage: $size
-    volumeName: $volume_name
-oss:
-  spec:
-    accessModes:
-    - ReadWriteMany
-    storageClassName: ''
-    resources:
-      requests:
-        storage: $size
-    volumeName: $volume_name
+{{- define "controller-mutate.envs" }}
+{{- include "controller.envs" . }}
+- name: DRYCC_CONTROLLER_RUNNER
+  value: mutate
 {{- end }}
diff --git a/charts/controller/templates/controller-api-deployment.yaml b/charts/controller/templates/api/controller-api-deployment.yaml
similarity index 84%
rename from charts/controller/templates/controller-api-deployment.yaml
rename to charts/controller/templates/api/controller-api-deployment.yaml
index d5b1971f4..c8fdf45f6 100644
--- a/charts/controller/templates/controller-api-deployment.yaml
+++ b/charts/controller/templates/api/controller-api-deployment.yaml
@@ -1,13 +1,13 @@
 apiVersion: apps/v1
 kind: Deployment
 metadata:
-  name: drycc-controller
+  name: drycc-controller-api
   labels:
     heritage: drycc
   annotations:
     component.drycc.cc/version: {{ .Values.imageTag }}
 spec:
-  replicas: {{ .Values.apiReplicas }}
+  replicas: {{ .Values.api.replicas }}
   strategy:
     rollingUpdate:
       maxSurge: 1
@@ -15,11 +15,13 @@ spec:
     type: RollingUpdate
   selector:
     matchLabels:
-      app: drycc-controller-api
+      app: drycc-controller
+      component: drycc-controller-api
   template:
     metadata:
       labels: {{- include "common.labels.standard" . | nindent 8 }}
-        app: drycc-controller-api
+        app: drycc-controller
+        component: drycc-controller-api
     spec:
       affinity:
         podAffinity: {{- include "common.affinities.pods" (dict "type" .Values.api.podAffinityPreset.type "component" "" "extraMatchLabels" .Values.api.podAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }}
@@ -27,7 +29,7 @@ spec:
         nodeAffinity: {{- include "common.affinities.nodes" (dict "type" .Values.api.nodeAffinityPreset.type "key" .Values.api.nodeAffinityPreset.key "values" .Values.api.nodeAffinityPreset.values ) | nindent 10 }}
       serviceAccount: drycc-controller
       initContainers:
-      - name: drycc-controller-init
+      - name: drycc-controller-api-init
         image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/python-dev:latest
         imagePullPolicy: {{.Values.imagePullPolicy}}
         args:
@@ -35,9 +37,9 @@ spec:
         - -v
         - -u
         - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
-        {{- include "controller.envs" . | indent 8 }}
+        {{- include "controller-api.envs" . | indent 8 }}
       containers:
-      - name: drycc-controller
+      - name: drycc-controller-api
         image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
         imagePullPolicy: {{.Values.imagePullPolicy}}
         {{- if .Values.diagnosticMode.enabled }}
@@ -70,8 +72,11 @@ spec:
         ports:
           - containerPort: 8000
             name: http
-        {{- include "controller.limits" . | indent 8 }}
-        {{- include "controller.envs" . | indent 8 }}
+        {{- with index .Values "api" "resources" }}
+        resources:
+          {{- toYaml . | nindent 10 }}
+        {{- end }}
+        {{- include "controller-api.envs" . | indent 8 }}
         volumeMounts:
         - name: controller-config
           readOnly: false
diff --git a/charts/controller/templates/controller-api-service.yaml b/charts/controller/templates/api/controller-api-service.yaml
similarity index 69%
rename from charts/controller/templates/controller-api-service.yaml
rename to charts/controller/templates/api/controller-api-service.yaml
index 82208897a..189f8d36e 100644
--- a/charts/controller/templates/controller-api-service.yaml
+++ b/charts/controller/templates/api/controller-api-service.yaml
@@ -7,11 +7,14 @@ metadata:
     {{- toYaml . | nindent 4 }}
     {{- end }}
   labels:
+    app: drycc-controller
     heritage: drycc
+    component: drycc-controller-api
 spec:
   ports:
     - name: http
       port: 80
       targetPort: 8000
   selector:
-    app: drycc-controller-api
+    app: drycc-controller
+    component: drycc-controller-api
diff --git a/charts/controller/templates/controller-celery-deloyment.yaml b/charts/controller/templates/celery/controller-celery-deloyment.yaml
similarity index 78%
rename from charts/controller/templates/controller-celery-deloyment.yaml
rename to charts/controller/templates/celery/controller-celery-deloyment.yaml
index 10972d934..ea9211efe 100644
--- a/charts/controller/templates/controller-celery-deloyment.yaml
+++ b/charts/controller/templates/celery/controller-celery-deloyment.yaml
@@ -7,7 +7,7 @@ metadata:
   annotations:
     component.drycc.cc/version: {{ .Values.imageTag }}
 spec:
-  replicas: {{ .Values.celeryReplicas }}
+  replicas: {{ .Values.celery.replicas }}
   strategy:
     rollingUpdate:
       maxSurge: 1
@@ -15,11 +15,13 @@ spec:
     type: RollingUpdate
   selector:
     matchLabels:
-      app: drycc-controller-celery
+      app: drycc-controller
+      component: drycc-controller-celery
   template:
     metadata:
       labels: {{- include "common.labels.standard" . | nindent 8 }}
-        app: drycc-controller-celery
+        app: drycc-controller
+        component: drycc-controller-celery
     spec:
       affinity:
         podAffinity: {{- include "common.affinities.pods" (dict "type" .Values.celery.podAffinityPreset.type "component" "" "extraMatchLabels" .Values.celery.podAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }}
@@ -34,12 +36,11 @@ spec:
           - netcat
           - -v
           - -u
-          - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
-          - -a
-          - $(DRYCC_CONTROLLER_API_SERVICE_HOST):$(DRYCC_CONTROLLER_API_SERVICE_PORT)
+          - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL),http://drycc-controller-api
         {{- include "controller.envs" . | indent 8 }}
       containers:
-      - name: drycc-controller-celery
+      {{- range $key := (list "low" "middle" "high") }}
+      - name: drycc-controller-celery-{{$key}}
         image: {{$.Values.imageRegistry}}/{{$.Values.imageOrg}}/controller:{{$.Values.imageTag}}
         imagePullPolicy: {{$.Values.imagePullPolicy}}
         {{- if $.Values.diagnosticMode.enabled }}
@@ -49,15 +50,19 @@ spec:
         args:
           - /bin/bash
           - -c
-          - celery --app api worker --queues controller.low,controller.middle,controller.high --autoscale=32,1 --loglevel=WARNING
+          - celery --app api worker -n {{uuidv4}}@%h --queues controller.{{$key}} --autoscale=32,1 --loglevel=WARNING
+        {{- end }}
+        {{- with index $.Values "celery" "resources" }}
+        resources:
+          {{- toYaml . | nindent 10 }}
         {{- end }}
-        {{- include "controller.limits" $ | indent 8 }}
         {{- include "controller.envs" $ | indent 8 }}
         volumeMounts:
         - name: controller-config
           readOnly: false
           mountPath: /etc/controller
+      {{- end }}
       volumes:
       - name: controller-config
         configMap:
-          name: controller-config
\ No newline at end of file
+          name: controller-config
diff --git a/charts/controller/templates/controller-clusterrole.yaml b/charts/controller/templates/controller-clusterrole.yaml
index 503e0c61c..3809127e3 100644
--- a/charts/controller/templates/controller-clusterrole.yaml
+++ b/charts/controller/templates/controller-clusterrole.yaml
@@ -1,4 +1,3 @@
-{{- if (.Values.global.rbac) -}}
 kind: ClusterRole
 apiVersion: rbac.authorization.k8s.io/v1
 metadata:
@@ -52,21 +51,18 @@ rules:
 - apiGroups: ["extensions", "apps"]
   resources: ["deployments/scale", "replicasets/scale"]
   verbs: ["get", "update"]
+- apiGroups: ["apps"]
+  resources: ["statefulsets"]
+  verbs: ["get", "list", "patch"]
+- apiGroups: ["extensions", "apps"]
+  resources: ["daemonsets"]
+  verbs: ["get", "list", "patch"]
 - apiGroups: ["extensions", "autoscaling"]
   resources: ["horizontalpodautoscalers"]
   verbs: ["get", "list", "create", "update", "delete"]
-- apiGroups: ["extensions"]
-  resources: ["ingresses"]
-  verbs: ["get", "list", "watch", "create", "update", "delete"]
 - apiGroups: ["cert-manager.io"]
   resources: ["certificates", "certificaterequest", "issuers"]
   verbs: ["get", "list", "watch", "create", "update", "delete"]
-- apiGroups: ["networking.k8s.io"]
-  resources: ["ingresses"]
-  verbs: ["get", "list", "create", "delete", "deletecollection", "patch", "update"]
-- apiGroups: ["networking.k8s.io"]
-  resources: ["ingressclasses"]
-  verbs: ["get"]
 - apiGroups: ["apps"]
   resources: ["replicasets"]
   verbs: ["get", "list", "watch", "delete"]
@@ -88,5 +84,3 @@ rules:
 - apiGroups: ["batch"]
   resources: ["jobs"]
   verbs: ["create"]
-{{- end -}}
-
diff --git a/charts/controller/templates/controller-clusterrolebinding.yaml b/charts/controller/templates/controller-clusterrolebinding.yaml
index bf6517e94..23ee8d184 100644
--- a/charts/controller/templates/controller-clusterrolebinding.yaml
+++ b/charts/controller/templates/controller-clusterrolebinding.yaml
@@ -1,4 +1,3 @@
-{{- if (.Values.global.rbac) -}}
 kind: ClusterRoleBinding
 apiVersion: rbac.authorization.k8s.io/v1
 metadata:
@@ -14,4 +13,3 @@ subjects:
 - kind: ServiceAccount
   name: drycc-controller
   namespace: {{ .Release.Namespace }}
-{{- end -}}
diff --git a/charts/controller/templates/controller-configmap.yaml b/charts/controller/templates/controller-configmap.yaml
index 542cba653..18493da9a 100644
--- a/charts/controller/templates/controller-configmap.yaml
+++ b/charts/controller/templates/controller-configmap.yaml
@@ -9,35 +9,47 @@ data:
     {{- if .Values.config.metrics }}
     {{- (tpl .Values.config.metrics $) | nindent 4 }}
     {{- else}}
-    {{- include "controller.config.defaultMetrics" . | fromYaml | toPrettyJson | nindent 4 }}
+    {{- .Files.Get "files/metrics.json" | nindent 4 }}
     {{- end }}
   limit-specs.json: |
     {{- if .Values.config.limitSpecs }}
     {{- (tpl .Values.config.limitSpecs $)  | nindent 4 }}
     {{- else}}
-    {{- include "controller.config.defaultLimitSpecs" . | fromYamlArray | toPrettyJson | nindent 4 }}
+    {{- .Files.Get "files/limit-specs.json" | nindent 4 }}
     {{- end }}
   limit-plans.json: |
     {{- if .Values.config.limitPlans }}
     {{- (tpl .Values.config.limitPlans $)  | nindent 4 }}
     {{- else}}
-    {{- include "controller.config.defaultLimitPlans" . | fromYamlArray | toPrettyJson | nindent 4 }}
-    {{- end }}
-  secret-template.json: |
-    {{- if .Values.config.secretTemplate }}
-    {{- (tpl .Values.config.secretTemplate $)  | nindent 4 }}
-    {{- else}}
-    {{- include "controller.config.defaultSecretTemplate" . | fromYaml | toPrettyJson | nindent 4 }}
+    {{- .Files.Get "files/limit-plans.json" | nindent 4 }}
     {{- end }}
   volume-template.json: |
     {{- if .Values.config.volumeTemplate }}
     {{- (tpl .Values.config.volumeTemplate $)  | nindent 4 }}
     {{- else}}
-    {{- include "controller.config.defaultVolumeTemplate" . | fromYaml | toPrettyJson | nindent 4 }}
+    {{- .Files.Get "files/volume-template.json" | nindent 4 }}
+    {{- end }}
+  reserved-name-patterns.txt: |-
+    {{- if .Values.config.reservedNames }}
+    {{- (tpl .Values.config.reservedNames $)  | nindent 4 }}
+    {{- else}}
+    {{- .Files.Get "files/reserved-name-patterns.txt" | nindent 4 }}
     {{- end }}
   volume-claim-template.json: |
     {{- if .Values.config.volumeClaimTemplate }}
     {{- (tpl .Values.config.volumeClaimTemplate $)  | nindent 4 }}
     {{- else}}
-    {{- include "controller.config.defaultVolumeClaimTemplate" . | fromYaml | toPrettyJson | nindent 4 }}
+    {{-  .Files.Get "files/volume-claim-template.json" | nindent 4 }}
+    {{- end }}
+  volume-usage-template.promql: |
+    {{- if .Values.config.volumeUsageTemplate }}
+    {{- (tpl .Values.config.volumeUsageTemplate $)  | nindent 4 }}
+    {{- else}}
+    {{- .Files.Get "files/volume-usage-template.promql" | nindent 4 }}
+    {{- end }}
+  network-usage-template.promql: |
+    {{- if .Values.config.networkUsageTemplate }}
+    {{- (tpl .Values.config.networkUsageTemplate $)  | nindent 4 }}
+    {{- else}}
+    {{- .Files.Get "files/network-usage-template.promql" | nindent 4 }}
     {{- end }}
diff --git a/charts/controller/templates/controller-cronjob-daily.yaml b/charts/controller/templates/controller-cronjob-daily.yaml
index 5766e0290..6146c9300 100644
--- a/charts/controller/templates/controller-cronjob-daily.yaml
+++ b/charts/controller/templates/controller-cronjob-daily.yaml
@@ -17,7 +17,8 @@ spec:
       template:
         metadata:
           labels: {{- include "common.labels.standard" . | nindent 12 }}
-            app: drycc-controller-conjob
+            app: drycc-controller
+            component: drycc-controller-conjob-daily
         spec:
           restartPolicy: OnFailure
           serviceAccount: drycc-controller
@@ -29,9 +30,7 @@ spec:
               - netcat
               - -v
               - -u
-              - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
-              - -a
-              - $(DRYCC_CONTROLLER_API_SERVICE_HOST):$(DRYCC_CONTROLLER_API_SERVICE_PORT)
+              - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL),http://drycc-controller-api
             {{- include "controller-job.envs" . | indent 12 }}
           containers:
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
@@ -48,6 +47,10 @@ spec:
             {{- end }}
             {{- include "controller.envs" . | indent 12 }}
             {{- include "controller-job.envs" . | indent 12 }}
+            volumeMounts:
+            - name: controller-config
+              readOnly: false
+              mountPath: /etc/controller
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.imagePullPolicy}}
             name: drycc-controller-load-db-state-to-k8s
@@ -62,45 +65,11 @@ spec:
             {{- 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
-            {{- if .Values.diagnosticMode.enabled }}
-            command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 14 }}
-            args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 14 }}
-            {{- else }}
-            args:
-              - /bin/bash
-              - -c
-              - 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
-            {{- if .Values.diagnosticMode.enabled }}
-            command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 14 }}
-            args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 14 }}
-            {{- else }}
-            args:
-            - /bin/bash
-            - -c
-            - 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
-            {{- if .Values.diagnosticMode.enabled }}
-            command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 14 }}
-            args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 14 }}
-            {{- else }}
-            args:
-            - /bin/bash
-            - -c
-            - python -u /workspace/manage.py measure_volumes
-            {{- end }}
-            {{- include "controller.envs" . | indent 12 }}
-            {{- include "controller-job.envs" . | indent 12 }}
+            volumeMounts:
+            - name: controller-config
+              readOnly: false
+              mountPath: /etc/controller
+          volumes:
+          - name: controller-config
+            configMap:
+              name: controller-config
diff --git a/charts/controller/templates/controller-cronjob-hourly.yaml b/charts/controller/templates/controller-cronjob-hourly.yaml
index 0760503da..610cdd803 100644
--- a/charts/controller/templates/controller-cronjob-hourly.yaml
+++ b/charts/controller/templates/controller-cronjob-hourly.yaml
@@ -17,7 +17,8 @@ spec:
       template:
         metadata:
           labels: {{- include "common.labels.standard" . | nindent 12 }}
-            app: drycc-controller-conjob
+            app: drycc-controller
+            component: drycc-controller-conjob-hourly
         spec:
           restartPolicy: OnFailure
           serviceAccount: drycc-controller
@@ -30,13 +31,67 @@ spec:
               - -v
               - -u
               - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
-              - -a
-              - $(DRYCC_CONTROLLER_API_SERVICE_HOST):$(DRYCC_CONTROLLER_API_SERVICE_PORT)
+              - -u
+              - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL),http://drycc-controller-api
             {{- include "controller-job.envs" . | indent 12 }}
           containers:
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.pull_policy}}
-            name: drycc-controller-measure-networks
+            name: drycc-controller-upload-network-usage
+            {{- if .Values.diagnosticMode.enabled }}
+            command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 14 }}
+            args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 14 }}
+            {{- else }}
+            args:
+            - /bin/bash
+            - -c
+            - python -u /workspace/manage.py upload_network_usage
+            {{- end }}
+            {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
+            volumeMounts:
+            - name: controller-config
+              readOnly: false
+              mountPath: /etc/controller
+          - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
+            imagePullPolicy: {{.Values.pull_policy}}
+            name: drycc-controller-upload-gateway-usage
+            {{- if .Values.diagnosticMode.enabled }}
+            command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 14 }}
+            args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 14 }}
+            {{- else }}
+            args:
+            - /bin/bash
+            - -c
+            - python -u /workspace/manage.py upload_gateway_usage
+            {{- end }}
+            {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
+            volumeMounts:
+            - name: controller-config
+              readOnly: false
+              mountPath: /etc/controller
+          - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
+            imagePullPolicy: {{.Values.pull_policy}}
+            name: drycc-controller-upload-app-usage
+            {{- if .Values.diagnosticMode.enabled }}
+            command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 14 }}
+            args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 14 }}
+            {{- else }}
+            args:
+              - /bin/bash
+              - -c
+              - python -u /workspace/manage.py upload_app_usage
+            {{- end }}
+            {{- include "controller.envs" . | indent 12 }}
+            {{- include "controller-job.envs" . | indent 12 }}
+            volumeMounts:
+            - name: controller-config
+              readOnly: false
+              mountPath: /etc/controller
+          - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
+            imagePullPolicy: {{.Values.pull_policy}}
+            name: drycc-controller-upload-resources-usage
             {{- if .Values.diagnosticMode.enabled }}
             command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 14 }}
             args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 14 }}
@@ -44,13 +99,17 @@ spec:
             args:
             - /bin/bash
             - -c
-            - python -u /workspace/manage.py measure_networks
+            - python -u /workspace/manage.py upload_resource_usage
             {{- end }}
             {{- include "controller.envs" . | indent 12 }}
             {{- include "controller-job.envs" . | indent 12 }}
+            volumeMounts:
+            - name: controller-config
+              readOnly: false
+              mountPath: /etc/controller
           - image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
             imagePullPolicy: {{.Values.pull_policy}}
-            name: drycc-controller-measure-loadbalancer
+            name: drycc-controller-upload-volume-usage
             {{- if .Values.diagnosticMode.enabled }}
             command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 14 }}
             args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 14 }}
@@ -58,7 +117,15 @@ spec:
             args:
             - /bin/bash
             - -c
-            - python -u /workspace/manage.py measure_loadbalancers
+            - python -u /workspace/manage.py upload_volume_usage
             {{- end }}
             {{- include "controller.envs" . | indent 12 }}
-            {{- include "controller-job.envs" . | indent 12 }}
\ No newline at end of file
+            {{- include "controller-job.envs" . | indent 12 }}
+            volumeMounts:
+            - name: controller-config
+              readOnly: false
+              mountPath: /etc/controller
+          volumes:
+          - name: controller-config
+            configMap:
+              name: controller-config
diff --git a/charts/controller/templates/controller-mutate-service.yaml b/charts/controller/templates/controller-mutate-service.yaml
deleted file mode 100644
index 0621263ef..000000000
--- a/charts/controller/templates/controller-mutate-service.yaml
+++ /dev/null
@@ -1,11 +0,0 @@
-kind: Service
-apiVersion: v1
-metadata:
-  name: drycc-controller-mutate
-spec:
-  selector:
-    component: drycc-controller-mutate
-  ports:
-  - name: https
-    port: 443
-    targetPort: 8443
\ No newline at end of file
diff --git a/charts/controller/templates/controller-secret-creds.yaml b/charts/controller/templates/controller-secret-creds.yaml
index 79684193f..0e78d51c6 100644
--- a/charts/controller/templates/controller-secret-creds.yaml
+++ b/charts/controller/templates/controller-secret-creds.yaml
@@ -14,8 +14,8 @@ data:
   {{- if (.Values.databaseReplicaUrl) }}
   database-replica-url: {{ .Values.databaseReplicaUrl | b64enc }}
   {{- end }}
-  {{- if (.Values.prometheusUrl) }}
-  prometheus-url: {{ .Values.prometheusUrl | b64enc }}
+  {{- if (.Values.victoriametricsUrl) }}
+  victoriametrics-url: {{ .Values.victoriametricsUrl | b64enc }}
   {{- end }}
   {{- if (.Values.passportUrl) }}
   passport-url: {{ .Values.passportUrl | b64enc }}
@@ -26,5 +26,11 @@ data:
   {{- if (.Values.passportSecret) }}
   passport-secret: {{ .Values.passportSecret | b64enc }}
   {{- end }}
-  django-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "django-secret-key" "defaultValue" (randAscii 64) "context" $)) }}
-  deploy-hook-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "deploy-hook-secret-key" "defaultValue" (randAscii 64) "context" $)) }}
+  {{- if (.Values.registryHost) }}
+  registry-host: {{ .Values.registryHost | b64enc }}
+  registry-username: {{ .Values.registryUsername | b64enc }}
+  registry-password: {{ .Values.registryPassword | b64enc }}
+  {{- end }}
+  service-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "service-key" "defaultValue" (randAlphaNum 64) "context" $)) }}
+  django-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "django-secret-key" "defaultValue" (randAlphaNum 64) "context" $)) }}
+  deploy-hook-secret-key: {{ (include "common.secrets.lookup" (dict "secret" "controller-creds" "key" "deploy-hook-secret-key" "defaultValue" (randAlphaNum 64) "context" $)) }}
diff --git a/charts/controller/templates/metric/controller-metric-deployment.yaml b/charts/controller/templates/metric/controller-metric-deployment.yaml
new file mode 100644
index 000000000..7687889f9
--- /dev/null
+++ b/charts/controller/templates/metric/controller-metric-deployment.yaml
@@ -0,0 +1,87 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: drycc-controller-metric
+  labels:
+    heritage: drycc
+  annotations:
+    component.drycc.cc/version: {{ .Values.imageTag }}
+spec:
+  replicas: {{ .Values.metric.replicas }}
+  strategy:
+    rollingUpdate:
+      maxSurge: 1
+      maxUnavailable: 0
+    type: RollingUpdate
+  selector:
+    matchLabels:
+      app: drycc-controller
+      component: drycc-controller-metric
+  template:
+    metadata:
+      labels: {{- include "common.labels.standard" . | nindent 8 }}
+        app: drycc-controller
+        component: drycc-controller-metric
+    spec:
+      affinity:
+        podAffinity: {{- include "common.affinities.pods" (dict "type" .Values.metric.podAffinityPreset.type "component" "" "extraMatchLabels" .Values.metric.podAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }}
+        podAntiAffinity: {{- include "common.affinities.pods" (dict "type" .Values.metric.podAntiAffinityPreset.type "component" "" "extraMatchLabels" .Values.metric.podAntiAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }}
+        nodeAffinity: {{- include "common.affinities.nodes" (dict "type" .Values.metric.nodeAffinityPreset.type "key" .Values.metric.nodeAffinityPreset.key "values" .Values.metric.nodeAffinityPreset.values ) | nindent 10 }}
+      serviceAccount: drycc-controller
+      initContainers:
+      - name: drycc-controller-metric-init
+        image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/python-dev:latest
+        imagePullPolicy: {{.Values.imagePullPolicy}}
+        args:
+        - netcat
+        - -v
+        - -u
+        - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
+        {{- include "controller-metric.envs" . | indent 8 }}
+      containers:
+      - name: drycc-controller-metric
+        image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
+        imagePullPolicy: {{.Values.imagePullPolicy}}
+        {{- if .Values.diagnosticMode.enabled }}
+        command: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.command "context" $) | nindent 10 }}
+        args: {{- include "common.tplvalues.render" (dict "value" .Values.diagnosticMode.args "context" $) | nindent 10 }}
+        {{- end }}
+        {{- if not .Values.diagnosticMode.enabled }}
+        startupProbe:
+          tcpSocket:
+            port: 8000
+          initialDelaySeconds: 30
+          periodSeconds: 60
+          timeoutSeconds: 1
+          successThreshold: 1
+          failureThreshold: 10
+        livenessProbe:
+          httpGet:
+            path: /healthz
+            port: 8000
+          initialDelaySeconds: 30
+          timeoutSeconds: 10
+        readinessProbe:
+          httpGet:
+            path: /readiness
+            port: 8000
+          initialDelaySeconds: 30
+          timeoutSeconds: 10
+          periodSeconds: 5
+        {{- end }}
+        ports:
+          - containerPort: 8000
+            name: http
+        {{- with index .Values "metric" "resources" }}
+        resources:
+          {{- toYaml . | nindent 10 }}
+        {{- end }}
+        {{- include "controller-metric.envs" . | indent 8 }}
+        volumeMounts:
+        - name: controller-config
+          readOnly: false
+          mountPath: /etc/controller
+      volumes:
+      - name: controller-config
+        configMap:
+          name: controller-config
\ No newline at end of file
diff --git a/charts/controller/templates/metric/controller-metric-networkpolicy.yaml b/charts/controller/templates/metric/controller-metric-networkpolicy.yaml
new file mode 100644
index 000000000..5ee13a7e3
--- /dev/null
+++ b/charts/controller/templates/metric/controller-metric-networkpolicy.yaml
@@ -0,0 +1,19 @@
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  name: drycc-controller-metric
+spec:
+  podSelector:
+    matchLabels:
+      app: drycc-controller
+      component: drycc-controller-metric
+  policyTypes:
+  - Ingress
+  ingress:
+  - from:
+    - namespaceSelector:
+        matchLabels:
+          kubernetes.io/metadata.name: {{.Release.Namespace}}
+      podSelector:
+        matchLabels:
+          app: drycc-victoriametrics
diff --git a/charts/controller/templates/metric/controller-metric-service.yaml b/charts/controller/templates/metric/controller-metric-service.yaml
new file mode 100644
index 000000000..8151a0cb7
--- /dev/null
+++ b/charts/controller/templates/metric/controller-metric-service.yaml
@@ -0,0 +1,21 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: drycc-controller-metric
+  annotations:
+    {{- with .Values.service.annotations }}
+    {{- toYaml . | nindent 4 }}
+    {{- end }}
+  labels:
+    app: drycc-controller
+    heritage: drycc
+    component: drycc-controller-metric
+spec:
+  clusterIP: None
+  ports:
+    - name: http
+      port: 8000
+      targetPort: 8000
+  selector:
+    app: drycc-controller
+    component: drycc-controller-metric
diff --git a/charts/controller/templates/controller-mutate-deloyment.yaml b/charts/controller/templates/mutate/controller-mutate-deloyment.yaml
similarity index 84%
rename from charts/controller/templates/controller-mutate-deloyment.yaml
rename to charts/controller/templates/mutate/controller-mutate-deloyment.yaml
index def3c3ce0..3004bd684 100644
--- a/charts/controller/templates/controller-mutate-deloyment.yaml
+++ b/charts/controller/templates/mutate/controller-mutate-deloyment.yaml
@@ -3,13 +3,15 @@ kind: Deployment
 metadata:
   name: drycc-controller-mutate
 spec:
-  replicas: {{ .Values.mutateReplicas }}
+  replicas: {{ .Values.mutate.replicas }}
   selector:
     matchLabels:
+      app: drycc-controller
       component: drycc-controller-mutate
   template:
     metadata:
       labels: {{- include "common.labels.standard" . | nindent 8 }}
+        app: drycc-controller
         component: drycc-controller-mutate
     spec:
       affinity:
@@ -17,19 +19,17 @@ spec:
         podAntiAffinity: {{- include "common.affinities.pods" (dict "type" .Values.mutate.podAntiAffinityPreset.type "component" "" "extraMatchLabels" .Values.mutate.podAntiAffinityPreset.extraMatchLabels "topologyKey" "" "context" $) | nindent 10 }}
         nodeAffinity: {{- include "common.affinities.nodes" (dict "type" .Values.mutate.nodeAffinityPreset.type "key" .Values.mutate.nodeAffinityPreset.key "values" .Values.mutate.nodeAffinityPreset.values ) | nindent 10 }}
       initContainers:
-      - name: drycc-controller-init
+      - name: drycc-controller-mutate-init
         image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/python-dev:latest
         imagePullPolicy: {{.Values.imagePullPolicy}}
         args:
         - netcat
         - -v
         - -u
-        - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL)
-        - -a
-        - $(DRYCC_CONTROLLER_API_SERVICE_HOST):$(DRYCC_CONTROLLER_API_SERVICE_PORT)
-        {{- include "controller.envs" . | indent 8 }}
+        - $(DRYCC_DATABASE_URL),$(DRYCC_VALKEY_URL),$(DRYCC_DATABASE_REPLICA_URL),http://drycc-controller-api
+        {{- include "controller-mutate.envs" . | indent 8 }}
       containers:
-      - name: drycc-controller
+      - name: drycc-controller-mutate
         image: {{.Values.imageRegistry}}/{{.Values.imageOrg}}/controller:{{.Values.imageTag}}
         imagePullPolicy: {{.Values.imagePullPolicy}}
         {{- if .Values.diagnosticMode.enabled }}
@@ -58,9 +58,12 @@ spec:
         {{- end }}
         volumeMounts:
         - name: controller-mutate-cert
-          mountPath: /etc/controller/mutate/cert
-        {{- include "controller.limits" . | indent 8 }}
-        {{- include "controller.envs" . | indent 8 }}
+          mountPath: /etc/controller/cert
+        {{- with index .Values "mutate" "resources" }}
+        resources:
+          {{- toYaml . | nindent 10 }}
+        {{- end }}
+        {{- include "controller-mutate.envs" . | indent 8 }}
       volumes:
       - name: controller-mutate-cert                                                                                                                                                                                               
         secret:                                                                                                                                                                                                                           
diff --git a/charts/controller/templates/mutate/controller-mutate-service.yaml b/charts/controller/templates/mutate/controller-mutate-service.yaml
new file mode 100644
index 000000000..120df9a6c
--- /dev/null
+++ b/charts/controller/templates/mutate/controller-mutate-service.yaml
@@ -0,0 +1,20 @@
+kind: Service
+apiVersion: v1
+metadata:
+  name: drycc-controller-mutate
+  annotations:
+    {{- with .Values.service.annotations }}
+    {{- toYaml . | nindent 4 }}
+    {{- end }}
+  labels:
+    app: drycc-controller
+    heritage: drycc
+    component: drycc-controller-mutate
+spec:
+  selector:
+    app: drycc-controller
+    component: drycc-controller-mutate
+  ports:
+  - name: https
+    port: 443
+    targetPort: 8443
\ No newline at end of file
diff --git a/charts/controller/templates/controller-mutate-webhook.yaml b/charts/controller/templates/mutate/controller-mutate-webhook.yaml
similarity index 100%
rename from charts/controller/templates/controller-mutate-webhook.yaml
rename to charts/controller/templates/mutate/controller-mutate-webhook.yaml
diff --git a/charts/controller/values.yaml b/charts/controller/values.yaml
index 795f7239f..2a93156ff 100644
--- a/charts/controller/values.yaml
+++ b/charts/controller/values.yaml
@@ -20,8 +20,6 @@ diagnosticMode:
 # See https://www.drycc.cc/managing-workflow/deploy-hooks/
 deployHookUrls: ""
 timezone: "UTC"
-# limitsCpu: "100m"
-# limitsMemory: "50Mi"
 # Possible values are:
 # enabled - allows for open registration
 # disabled - turns off open registration
@@ -34,8 +32,10 @@ appImagePullPolicy: "Always"
 # Filer settings image and image pull policy
 filerImage: "registry.drycc.cc/drycc/filer:canary"
 filerImagePullPolicy: "Always"
-# Set storageClassName. It is used for application.
-appStorageClass: "{{ .Values.storage.csi.storageClassName }}"
+# Set app gateway, It is used for application gateway.
+appGatewayClass: ""
+# Set storageClassName, It is used for application mount.
+appStorageClass: "longhorn"
 # Set runtimeClassName. It is used for application.
 appRuntimeClass: ""
 # Set appDNSPolicy. It is used for application.
@@ -43,12 +43,6 @@ appRuntimeClass: ""
 appDNSPolicy: ""
 # set timeout seconds. It is used for pod exec
 appPodExecTimeout: 3600
-# Set api replicas
-apiReplicas: 1
-# Set celery replicas
-celeryReplicas: 1
-# Set mutate replicas
-mutateReplicas: 1
 # Set cronjob concurrencyPolicy
 # Allow (default): The cron job allows concurrently running jobs
 # Forbid: The cron job does not allow concurrent runs; if it is time for a new job run and the previous job run hasn't finished yet, the cron job skips the new job run
@@ -63,19 +57,26 @@ databaseReplicaUrl: ""
 passportUrl: ""
 passportKey: ""
 passportSecret: ""
+# victoriametricsUrl is will no longer use the built-in victoriametrics component
+# format: http://victoriametrics-server/select/0/prometheus
+victoriametricsUrl: ""
 # Workflow-manager Configuration Options
 workflowManagerUrl: ""
 workflowManagerAccessKey: ""
 workflowManagerSecretKey: ""
-
+# Prefix for the imagepull secret created when using private registry
+registrySecretPrefix: "private-registry"
 # limit specs, plans config
 config:
   metrics: ""
   limitSpecs: ""
   limitPlans: ""
+  reservedNames: ""
   secretTemplate: ""
   volumeTemplate: ""
   volumeClaimTemplate: ""
+  volumeUsageTemplate: ""
+  networkUsageTemplate: ""
 
 # Service
 service:
@@ -85,10 +86,17 @@ service:
 # Any custom controller environment variables
 # can be specified as key-value pairs under environment
 # this is usually a non required setting.
-environment:
-  RESERVED_NAMES: "drycc, drycc-builder, drycc-grafana, drycc-passport, drycc-helmbroker, drycc-manager"
+environment: {}
 
 api:
+  replicas: 1
+  resources: {}
+    # limits:
+    #   cpu: 200m
+    #   memory: 50Mi
+    # requests:
+    #   cpu: 100m
+    #   memory: 30Mi
   nodeAffinityPreset:
     key: "drycc.cc/node"
     type: "soft"
@@ -104,6 +112,14 @@ api:
       app: "drycc-controller-api"
 
 celery:
+  replicas: 1
+  resources: {}
+    # limits:
+    #   cpu: 200m
+    #   memory: 50Mi
+    # requests:
+    #   cpu: 100m
+    #   memory: 30Mi
   nodeAffinityPreset:
     key: "drycc.cc/node"
     type: "soft"
@@ -118,7 +134,38 @@ celery:
     extraMatchLabels:
       app: "drycc-controller-celery"
 
+metric:
+  replicas: 1
+  resources: {}
+    # limits:
+    #   cpu: 200m
+    #   memory: 50Mi
+    # requests:
+    #   cpu: 100m
+    #   memory: 30Mi
+  nodeAffinityPreset:
+    key: "drycc.cc/node"
+    type: "soft"
+    values:
+    - "true"
+  podAffinityPreset:
+    type: ""
+    extraMatchLabels:
+      security: "drycc-security"
+  podAntiAffinityPreset:
+    type: "soft"
+    extraMatchLabels:
+      component: "drycc-controller-metric"
+
 mutate:
+  replicas: 1
+  resources: {}
+    # limits:
+    #   cpu: 200m
+    #   memory: 50Mi
+    # requests:
+    #   cpu: 100m
+    #   memory: 30Mi
   nodeAffinityPreset:
     key: "drycc.cc/node"
     type: "soft"
@@ -133,60 +180,30 @@ mutate:
     extraMatchLabels:
       component: "drycc-controller-mutate"
 
-storage:
-  csi:
-    storageClassName: drycc-storage
+valkey:
+  enabled: true
+
+database:
+  enabled: true
+
+quickwit:
+  logIndexPrefix: logs-
+
+registry:
+  enabled: true
+
+passport:
+  enabled: true
+
+victoriametrics:
+  enabled: true
 
 global:
   # Admin email, used for each component to send email to administrator
   email: "drycc@drycc.cc"
-  # Set the storage backend
-  #
-  # Valid values are:
-  # - s3: Store persistent data in AWS S3 (configure in S3 section)
-  # - azure: Store persistent data in Azure's object storage
-  # - gcs: Store persistent data in Google Cloud Storage
-  # - minio: Store persistent data on in-cluster Minio server
-  storage: minio
-  # Set the location of Workflow's PostgreSQL database
-  #
-  # Valid values are:
-  # - on-cluster: Run PostgreSQL within the Kubernetes cluster (credentials are generated
-  #   automatically; backups are sent to object storage
-  #   configured above)
-  # - off-cluster: Run PostgreSQL outside the Kubernetes cluster (configure in database section)
-  databaseLocation: "on-cluster"
-  # Set the location of Workflow's Registry
-  #
-  # Valid values are:
-  # - on-cluster: Run registry within the Kubernetes cluster
-  # - off-cluster: Use registry outside the Kubernetes cluster (example: drycc registry,self-hosted)
-  registryLocation: "on-cluster"
-  # The host port to which registry proxy binds to
-  registryProxyPort: 5555
-  # Prefix for the imagepull secret created when using private registry
-  registrySecretPrefix: "private-registry"
-  # Role-Based Access Control for Kubernetes >= 1.5
-  rbac: false
-  # GatewayClass is cluster-scoped resource defined by the infrastructure provider.
-  gatewayClass: ""
-  # Please check `kubernetes.io/ingress.class`
-  ingressClass: ""
-  # A domain name consists of one or more parts.
-  # Periods (.) are used to separate these parts.
-  # Each part must be 1 to 63 characters in length and can contain lowercase letters, digits, and hyphens (-).
-  # It must start and end with a lowercase letter or digit.
-  clusterDomain: "cluster.local"
   # The public resolvable hostname to build your cluster with.
   #
   # This will be the hostname that is used to build endpoints such as "drycc.$HOSTNAME"
   platformDomain: ""
   # Whether cert_manager is enabled to automatically generate controller certificates
   certManagerEnabled: true
-  passportLocation: "on-cluster"
-  # Set the location of Workflow's prometheus cluster
-  #
-  # Valid values are:
-  # - on-cluster: Run prometheus within the Kubernetes cluster
-  # - off-cluster: prometheus is running outside of the cluster and credentials and connection information will be provided.
-  prometheusLocation: "on-cluster"
\ No newline at end of file
diff --git a/rootfs/Dockerfile b/rootfs/Dockerfile
index 4f151964c..ae5a3b607 100644
--- a/rootfs/Dockerfile
+++ b/rootfs/Dockerfile
@@ -1,10 +1,10 @@
 ARG CODENAME
 FROM registry.drycc.cc/drycc/base:${CODENAME}
 
-ENV DRYCC_UID=1001 \
+ARG DRYCC_UID=1001 \
   DRYCC_GID=1001 \
   DRYCC_HOME_DIR=/workspace \
-  PYTHON_VERSION="3.13"
+  PYTHON_VERSION="3.14"
 
 RUN groupadd drycc --gid ${DRYCC_GID} \
   && useradd drycc -u ${DRYCC_UID} -g ${DRYCC_GID} -s /bin/bash -m -d ${DRYCC_HOME_DIR}
diff --git a/rootfs/Dockerfile.test b/rootfs/Dockerfile.test
index f8e6d67fb..74f4c8c91 100644
--- a/rootfs/Dockerfile.test
+++ b/rootfs/Dockerfile.test
@@ -1,16 +1,16 @@
 ARG CODENAME
 FROM registry.drycc.cc/drycc/base:${CODENAME}
 
-ARG DRYCC_HOME_DIR=/workspace
+ARG DRYCC_HOME_DIR=/workspace \
+  PYTHON_VERSION="3.14" \
+  VALKEY_VERSION="9.0.0" \
+  POSTGRES_VERSION="18.1" \
+  GOSU_VERSION="1.19"
 
 COPY requirements.txt ${DRYCC_HOME_DIR}/requirements.txt
 COPY dev_requirements.txt ${DRYCC_HOME_DIR}/dev_requirements.txt
 
-ENV PGDATA="/opt/drycc/postgresql/data" \
-  PYTHON_VERSION="3.13" \
-  VALKEY_VERSION="8.1.0" \
-  POSTGRES_VERSION="15.12" \
-  GOSU_VERSION="1.17"
+ENV PGDATA="/opt/drycc/postgresql/data"
 
 RUN buildDeps='gcc rustc cargo  libffi-dev musl-dev openssl'; \
   install-packages mercurial ca-certificates git inotify-tools $buildDeps \
diff --git a/rootfs/api/admin.py b/rootfs/api/admin.py
index c3710a944..199161af6 100644
--- a/rootfs/api/admin.py
+++ b/rootfs/api/admin.py
@@ -6,9 +6,7 @@
 
 
 from django.contrib import admin
-from guardian.admin import GuardedModelAdmin
 
-from .models import App
 from .models import Build
 from .models import Config
 from .models import Domain
@@ -16,18 +14,6 @@
 from .models import Release
 
 
-class AppAdmin(GuardedModelAdmin):
-    """Set presentation options for :class:`~api.models.App` models
-    in the Django admin.
-    """
-    date_hierarchy = 'created'
-    list_display = ('id', 'owner')
-    list_filter = ('owner',)
-
-
-admin.site.register(App, AppAdmin)
-
-
 class BuildAdmin(admin.ModelAdmin):
     """Set presentation options for :class:`~api.models.Build` models
     in the Django admin.
diff --git a/rootfs/api/apps_extra/social_core/actions.py b/rootfs/api/apps_extra/social_core/actions.py
index 2e37771a7..c86bac74b 100644
--- a/rootfs/api/apps_extra/social_core/actions.py
+++ b/rootfs/api/apps_extra/social_core/actions.py
@@ -39,7 +39,7 @@ def form2json(form_data):
         query = urlparse("?" + form_data).query
         params = parse_qs(query)
         return {key: params[key][0] for key in params}
-    from api.backend import OauthCacheManager
+    from api.apps_extra.social_core.backends import OauthCacheManager
     oauth_cache_manager = OauthCacheManager()
     oauth_cache_manager.set_state(data.get("key", ""), form2json(url).get("state"))
     return response
@@ -129,7 +129,7 @@ def do_complete(backend, login, user=None, redirect_name="next", *args, **kwargs
     if social_auth and social_auth.extra_data:
         extra_data = json.loads(social_auth.extra_data) if \
             isinstance(social_auth.extra_data, str) else social_auth.extra_data
-    from api.backend import OauthCacheManager
+    from api.apps_extra.social_core.backends import OauthCacheManager
     oauth_cache_manager = OauthCacheManager()
     oauth_cache_manager.set_token(data.get("state"), extra_data)
     return response
diff --git a/rootfs/api/backend.py b/rootfs/api/apps_extra/social_core/backends.py
similarity index 73%
rename from rootfs/api/backend.py
rename to rootfs/api/apps_extra/social_core/backends.py
index bfa91e871..aa73b9281 100644
--- a/rootfs/api/backend.py
+++ b/rootfs/api/apps_extra/social_core/backends.py
@@ -4,7 +4,6 @@
 from django.contrib.auth import get_user_model
 from django.utils.translation import gettext_lazy
 
-from social_core.utils import cache as social_cache
 from social_core.backends.open_id_connect import OpenIdConnectAuth
 from rest_framework import exceptions
 
@@ -16,12 +15,6 @@
 class DryccOIDC(OpenIdConnectAuth):
     """Drycc Openid Connect authentication backend"""
     name = 'drycc'
-    AUTHORIZATION_URL = settings.SOCIAL_AUTH_DRYCC_AUTHORIZATION_URL
-    ACCESS_TOKEN_URL = settings.SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL
-    USERINFO_URL = settings.SOCIAL_AUTH_DRYCC_USERINFO_URL
-    JWKS_URI = settings.SOCIAL_AUTH_DRYCC_JWKS_URI
-    OIDC_ENDPOINT = settings.SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT
-    DEFAULT_SCOPE = ['openid']
     EXTRA_DATA = [
         ('id', 'id'),
         ('access_token', 'access_token'),
@@ -32,26 +25,13 @@ class DryccOIDC(OpenIdConnectAuth):
         ('scope', 'scope'),
     ]
 
-    def __init__(self, *args, **kwargs):
-        self.timeout = 3  # request timeout
-        super().__init__(*args, **kwargs)
-
-    @social_cache(ttl=86400)
-    def oidc_config(self):
-        return self.get_json(
-            self.OIDC_ENDPOINT + '/.well-known/openid-configuration/',
-            timeout=self.timeout
-        )
-
     def get_user_data(self, access_token):
         """Loads user data from service"""
-        url = settings.SOCIAL_AUTH_DRYCC_USERINFO_URL
         response = self.get_json(
-            url,
+            self.userinfo_url(),
             headers={
                 'authorization': 'Bearer ' + access_token
             },
-            timeout=self.timeout,
         )
         return {
             'id': response.get('id'),
@@ -65,15 +45,15 @@ def get_user_data(self, access_token):
         }
 
     def refresh_token(self, refresh_token):
+        # Get token URL from OIDC discovery if not already cached
         return self.get_json(
-            settings.SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL,
+            self.access_token_url(),
             method='POST',
             data={
                 'grant_type': 'refresh_token',
-                'client_id': settings.SOCIAL_AUTH_DRYCC_KEY,
+                'client_id': self.get_key_and_secret()[0],
                 'refresh_token': refresh_token,
             },
-            timeout=self.timeout,
         )
 
 
diff --git a/rootfs/api/apps_extra/social_core/pipelines.py b/rootfs/api/apps_extra/social_core/pipelines.py
new file mode 100644
index 000000000..5eefe0624
--- /dev/null
+++ b/rootfs/api/apps_extra/social_core/pipelines.py
@@ -0,0 +1,6 @@
+from api.serializers import UserSerializer
+
+
+def update_or_create(backend, user, response, *args, **kwargs):
+    user, created = UserSerializer.update_or_create(response)
+    return {'is_new': created, 'user': user}
diff --git a/rootfs/api/asgi.py b/rootfs/api/asgi.py
index 64eb77edb..4f0063cf8 100644
--- a/rootfs/api/asgi.py
+++ b/rootfs/api/asgi.py
@@ -9,18 +9,24 @@
 
 import os
 
+from django.urls import re_path
 from django.core.asgi import get_asgi_application
 from channels.routing import ProtocolTypeRouter, URLRouter
 from channels.security.websocket import AllowedHostsOriginValidator
 
 
 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'api.settings.production')
-http = get_asgi_application()
-
-from .routing import websocket_urlpatterns  # noqa
-from .middleware import ChannelOAuthMiddleware  # noqa
-websocket = AllowedHostsOriginValidator(ChannelOAuthMiddleware(URLRouter(
-    websocket_urlpatterns
-)))
-
-application = ProtocolTypeRouter({"http": http, "websocket": websocket})
+django_asgi_app = get_asgi_application()
+
+from .routing import http_urlpatterns, websocket_urlpatterns  # noqa
+from .middleware import ChannelOAuthMiddleware, ChannelAPIVersionMiddleware  # noqa
+
+application = ProtocolTypeRouter({
+    "http": ChannelAPIVersionMiddleware(URLRouter([
+        *http_urlpatterns,
+        re_path(r'', django_asgi_app),
+    ])),
+    "websocket": AllowedHostsOriginValidator(ChannelOAuthMiddleware(URLRouter(
+        websocket_urlpatterns
+    )))
+})
diff --git a/rootfs/api/authentication.py b/rootfs/api/authentication.py
index 61a9c1f7a..63559903e 100644
--- a/rootfs/api/authentication.py
+++ b/rootfs/api/authentication.py
@@ -21,6 +21,7 @@ def authenticate(self, request):
 class DryccAuthentication(authentication.BaseAuthentication):
 
     keywords = ('token', 'bearer')
+    ignore_authentication_failed = False
 
     def parse_header(self, request):
         try:
@@ -44,14 +45,19 @@ def authenticate(self, request):
         token_type, token = self.parse_header(request)
         if token_type is None or token is None:
             return None
-        if token_type == 'bearer':  # drycc oauth access token
-            from api.backend import OauthCacheManager
-            return OauthCacheManager().get_user(token), token
-        # drycc token
-        user = cache.get(token, None)
-        if not user:
-            return self.authenticate_credentials(token)
-        return user, token
+        try:
+            if token_type == 'bearer':  # drycc oauth access token
+                from api.apps_extra.social_core.backends import OauthCacheManager
+                return OauthCacheManager().get_user(token), token
+            # drycc token
+            user = cache.get(token, None)
+            if not user:
+                return self.authenticate_credentials(token)
+            return user, token
+        except exceptions.AuthenticationFailed as e:
+            if not self.ignore_authentication_failed:
+                raise e
+        return None
 
     def authenticate_credentials(self, key):
         from api.models.base import Token
@@ -62,11 +68,14 @@ def authenticate_credentials(self, key):
         if not token.owner.is_active:
             raise exceptions.AuthenticationFailed(gettext_lazy('User inactive or deleted.'))
         if token.expires():
-            from api.backend import OauthCacheManager
-            token.refresh_token()
-            user = OauthCacheManager().get_user(token.oauth['access_token'])
-            cache.set(key, user, timeout=token.oauth['expires_in'])
-            return user, token.key
+            try:
+                from api.apps_extra.social_core.backends import OauthCacheManager
+                user = OauthCacheManager().get_user(token.oauth['access_token'])
+                cache.set(key, user, timeout=token.oauth['expires_in'])
+                token.refresh_token()
+                return user, token.key
+            except exceptions.AuthenticationFailed:
+                return None, None
         return (token.owner, token.key)
 
     def authenticate_header(self, request):
diff --git a/rootfs/api/consumers.py b/rootfs/api/consumers.py
index da36f4b05..47d680a83 100644
--- a/rootfs/api/consumers.py
+++ b/rootfs/api/consumers.py
@@ -4,6 +4,8 @@
 import ssl
 import aiohttp
 import asyncio
+from collections import namedtuple
+from urllib.parse import urljoin
 from django.conf import settings
 from django.core.cache import cache
 
@@ -15,38 +17,51 @@
 from kubernetes.stream import stream
 from kubernetes.stream.ws_client import STDOUT_CHANNEL, STDERR_CHANNEL, ERROR_CHANNEL
 
-from channels.db import database_sync_to_async
 from channels.exceptions import DenyConnection
+from channels.generic.http import AsyncHttpConsumer
 from channels.generic.websocket import AsyncWebsocketConsumer
 
 from .models.app import App
-from .permissions import has_app_permission
+from .models.volume import Volume
+from .permissions import IsAppUser, get_app_status
 
 
-class BaseAppConsumer(AsyncWebsocketConsumer):
+class AppPermChecker(object):
     timeout = 60 * 60
+    check_permission = IsAppUser().has_object_permission
+
+    def __init__(self, scope):
+        self.scope = scope
 
-    @database_sync_to_async
-    def has_perm(self):
+    async def has_perm(self):
         if self.scope["user"] is None:
             return False, "user not login"
-        key = f"permission:user:{self.scope["user"].id}:app:{self.id}"
-        permission = cache.get(key)
+        app_id = self.scope["url_route"]["kwargs"]["id"]
+        key = f"permission:user:{self.scope["user"].id}:app:{app_id}"
+        permission = await cache.aget(key)
         if permission is None:
             try:
-                app = App.objects.get(id=self.id)
-                permission = has_app_permission(self.scope["user"], app, "GET")
+                app = await App.objects.aget(id=app_id)
+                request = namedtuple("Request", ["user", "method"])(self.scope["user"], "GET")
+                if await sync_to_async(self.check_permission)(request, None, app):
+                    permission = await sync_to_async(get_app_status)(app)
+                else:
+                    permission = (False, "permission denied")
                 if permission[0]:
-                    cache.set(key, permission, timeout=self.timeout)
+                    await cache.aset(key, permission, timeout=self.timeout)
             except App.DoesNotExist:
-                permission = (False, "user not exists")
+                permission = (False, "app not exists")
         return permission
 
+
+class BaseAppConsumer(AsyncWebsocketConsumer):
+
     async def connect(self):
-        self.id = self.scope["url_route"]["kwargs"]["id"]
-        is_ok, message = await self.has_perm()
+        app_perm_checker = AppPermChecker(self.scope)
+        is_ok, message = await app_perm_checker.has_perm()
         if is_ok:
             await self.accept()
+            self.id = self.scope["url_route"]["kwargs"]["id"]
         else:
             raise DenyConnection(message)
 
@@ -66,47 +81,6 @@ def kubernetes(self):
         return core_v1_api.CoreV1Api()
 
 
-class AppLogsConsumer(BaseAppConsumer):
-
-    async def connect(self):
-        await super().connect()
-        self.session = None
-        self.running = False
-        self.conneted = True
-
-    async def task(self, **kwargs):
-        lines = kwargs.get("lines", 100)
-        follow = kwargs.get("follow", False)
-        timeout = kwargs.get("timeout", 300)
-        url = "http://{}:{}/logs/{}?log_lines={}&follow={}&timeout={}".format(
-            settings.LOGGER_HOST, settings.LOGGER_PORT, self.id, lines, follow, timeout,
-        )
-        try:
-            async with aiohttp.ClientSession() as session:
-                self.session = session
-                async with session.get(url) as response:
-                    async for data in response.content.iter_any():
-                        if not self.conneted:
-                            break
-                        await self.send(text_data=data)
-        except asyncio.TimeoutError:
-            pass
-        finally:
-            await self.close(code=1000)
-
-    async def receive(self, text_data=None, bytes_data=None):
-        if self.running:
-            return
-        self.running = True
-        kwargs = json.loads(text_data)
-        asyncio.create_task(self.task(**kwargs))
-
-    async def disconnect(self, close_code):
-        if self.session:
-            await self.session.close()
-        self.conneted = False
-
-
 class AppPodLogsConsumer(BaseK8sConsumer):
 
     async def connect(self):
@@ -116,7 +90,7 @@ async def connect(self):
         self.conneted = True
         self.buffer = b''
         self.delimiter = b"\r\n"
-        self.pod_id = self.scope["url_route"]["kwargs"]["pod_id"]
+        self.pod_name = self.scope["url_route"]["kwargs"]["name"]
 
     def reader(self, sock):
         self.buffer += sock.read()
@@ -140,7 +114,7 @@ async def receive(self, text_data=None, bytes_data=None):
             return
         self.running = True
         data = json.loads(text_data)
-        args = (self.pod_id, self.id)
+        args = (self.pod_name, self.id)
         lines = data.get("lines", 300)
         follow = data.get("follow", False)
         previous = data.get("previous", False)
@@ -176,7 +150,7 @@ async def connect(self):
         await super().connect()
         self.stream = None
         self.conneted = True
-        self.pod_id = self.scope["url_route"]["kwargs"]["pod_id"]
+        self.pod_name = self.scope["url_route"]["kwargs"]["name"]
 
     async def send(self, data, channel=STDOUT_CHANNEL):
         channel_prefix = chr(channel)
@@ -219,7 +193,7 @@ async def disconnect(self, close_code):
 
     async def receive(self, text_data=None, bytes_data=None):
         if self.stream is None and text_data is not None:
-            args = (self.kubernetes.connect_get_namespaced_pod_exec, self.pod_id, self.id)
+            args = (self.kubernetes.connect_get_namespaced_pod_exec, self.pod_name, self.id)
             kwargs = json.loads(text_data)
             kwargs.update({"stderr": True, "stdout": True, "_preload_content": False})
             self.stream = await sync_to_async(stream)(*args, **kwargs)
@@ -230,3 +204,96 @@ async def receive(self, text_data=None, bytes_data=None):
             await sync_to_async(self.stream.write_channel)(channel, data)
         else:
             raise ValueError("This operation is not supported!")
+
+
+class FilerProxyConsumer(AsyncHttpConsumer):
+    from .middleware import ChannelOAuthMiddleware
+    chunk_size = 64 * 1024
+    middleware = ChannelOAuthMiddleware(None)
+    SKIP_REQUEST_HEADERS = {
+        'host', 'connection', 'keep-alive', 'proxy-connection', 'te', 'trailers', 'upgrade',
+    }
+    SKIP_RESPONSE_HEADERS = {
+        'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers',
+        'transfer-encoding', 'upgrade',
+    }
+
+    async def handle(self, body: bytes):
+        path = self.scope["url_route"]["kwargs"]["path"]
+        client = await self._get_client(url_path=f"{self.scope["path"].removesuffix(path)}webdav/")
+        if not client:
+            await self.send_response(status=404, body=b'app or volume not found')
+            return
+        if path in ['_/ping', '_/bind']:  # need authentication
+            await self._handle_controller_request(client, path)
+            return
+        elif not path.startswith('webdav/') or (filer := await client.info()) is None:
+            await self.send_response(status=428, body=b'filer service unavailable')
+            return
+        filer_target_url = "{}?{}".format(
+            urljoin(filer["endpoint"], self.scope["path"]), self.scope.get("query_string"))
+        method = self.scope['method'].upper()
+        headers = {
+            name_bytes.decode('latin-1').lower(): value_bytes.decode('latin-1')
+            for name_bytes, value_bytes in self.scope.get('headers', [])
+            if name_bytes.decode('latin-1').lower() not in self.SKIP_REQUEST_HEADERS
+        }
+        await self._handle_proxy_request(filer_target_url, method, headers, body)
+
+    async def _get_client(self, url_path):
+        try:
+            from .filer import FilerClient
+            app = await App.objects.aget(id=self.scope["url_route"]["kwargs"]["id"])
+            volume = await Volume.objects.filter(
+                app=app, name=self.scope["url_route"]["kwargs"]["name"]).afirst()
+            if not volume:
+                return None
+            return FilerClient(app.id, volume, url_path)
+        except App.DoesNotExist:
+            return None
+
+    async def _forward_response(self, response: aiohttp.ClientResponse):
+        response_headers = [
+            [name.encode('latin-1'), value.encode('latin-1')]
+            for name, value in response.headers.items()
+            if name.lower() not in self.SKIP_RESPONSE_HEADERS
+        ]
+        await self.send_headers(status=response.status, headers=response_headers)
+        async for chunk in response.content.iter_chunked(self.chunk_size):
+            if chunk:
+                await self.send_body(chunk, more_body=True)
+        # Send final empty chunk to indicate end of body
+        await self.send_body(b'', more_body=False)
+
+    async def _handle_proxy_request(self, url, method: str, headers: dict[str, str], data: bytes):
+        async with aiohttp.ClientSession(
+            timeout=aiohttp.ClientTimeout(total=settings.DRYCC_FILER_DURATION)
+        ) as session:
+            try:
+                async with session.request(
+                    method=method, url=url, headers=headers, data=data, allow_redirects=False
+                ) as response:
+                    await self._forward_response(response)
+            except aiohttp.ClientError as e:
+                await self.send_response(502, f"proxy service unavailable: {e}".encode('utf-8'))
+            except asyncio.TimeoutError:
+                await self.send_response(504, b'proxy request to backend filer timeout')
+
+    async def _handle_controller_request(self, client, path: str):
+        await self.middleware.login(self.scope)
+        app_perm_checker = AppPermChecker(self.scope)
+        is_ok, message = await app_perm_checker.has_perm()
+        status, body = 200, b''
+        if not is_ok:
+            status, body = 403, message.encode('utf-8')
+        elif path == '_/ping':
+            if (filer := await client.info()) is not None:
+                body = b'pong'
+            else:
+                status, body = 503, b'filer service unavailable'
+        elif path == '_/bind':
+            filer = await client.bind()
+            body = json.dumps({
+                "username": filer["username"], "password": filer["password"],
+            }).encode('utf-8')
+        await self.send_response(status, body)
diff --git a/rootfs/api/exceptions.py b/rootfs/api/exceptions.py
index bf6ce99c5..38db7b9b9 100644
--- a/rootfs/api/exceptions.py
+++ b/rootfs/api/exceptions.py
@@ -42,6 +42,8 @@ def custom_exception_handler(exc, context):
     # No response means DRF couldn't handle it
     # Output a generic 500 in a JSON format
     if response is None:
+        import traceback
+        traceback.print_exc()
         logging.exception('Uncaught Exception', exc_info=exc)
         set_rollback()
         return Response({'detail': 'Server Error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
diff --git a/rootfs/api/filer.py b/rootfs/api/filer.py
index 0e7152ebc..5e2d092da 100644
--- a/rootfs/api/filer.py
+++ b/rootfs/api/filer.py
@@ -1,102 +1,111 @@
 import uuid
 import logging
-import requests
+import asyncio
+import aiohttp
 from django.conf import settings
 from django.core.cache import cache
-from requests.auth import HTTPBasicAuth
+from asgiref.sync import sync_to_async
 
-from .utils import random_string, get_session, CacheLock
+from .exceptions import DryccException
+from .tasks import send_app_log
+from .utils import random_string, CacheLock, get_scheduler
 
 logger = logging.getLogger(__name__)
 
 
 class FilerClient(object):
 
-    def __init__(self, app_id, volume, scheduler):
-        self.bind = ":9000"
+    def __init__(self, app_id, volume, url_path):
         self.path = "/data"
+        self.ports = (9000, 9100)
         self.app_id = app_id
         self.volume = volume
-        self.scheduler = scheduler
+        self.url_path = url_path
+        self.scheduler = get_scheduler()
 
     def log(self, message, level=logging.INFO):
         logger.log(level, "[{}]: {}".format(self.app_id, message))
+        send_app_log.delay(self.app_id, message, logging.INFO)
 
-    @property
-    def server(self):
+    async def bind(self):
         lock_key = f"filer:lock:{self.app_id}:{self.volume.name}"
         lock = CacheLock(lock_key)
         try:
-            lock.acquire()
-            _server = cache.get(self.cache_key, None)
-            if not _server or not self.health(_server):
-                _server = self.get_server()
-                cache.set(self.cache_key, _server, timeout=settings.DRYCC_FILER_DURATION)
+            await sync_to_async(lock.acquire)()
+            if _filer := await self.info():
+                return _filer
+            else:
+                _filer = await sync_to_async(self._filer_bind)()
+                await cache.aset(self._cache_key, _filer, timeout=settings.DRYCC_FILER_DURATION)
         finally:
-            lock.release()
-        return _server
+            await sync_to_async(lock.release)()
+        return _filer
+
+    async def info(self):
+        _filer = await cache.aget(self._cache_key, None)
+        if _filer and await self._check_health(_filer):
+            await cache.atouch(self._cache_key, timeout=settings.DRYCC_FILER_DURATION)
+            return _filer
+        return None
 
     @property
-    def cache_key(self):
+    def _cache_key(self):
         return f"filer:{self.app_id}:{self.volume.name}"
 
-    def get_server(self):
-        self.clean()  # clean old filer
-        pod_name = f"drycc-filer-{uuid.uuid4().hex}"
-        k8s_volume = {"name": self.volume.name}
+    def _filer_bind(self):
+        username, password = random_string(32), random_string(32)
+        filer = {"username": username, "password": password}
+        job_ip = self._get_job_ip(self._create_job(username, password))
+        filer.update({
+            "endpoint": f"http://{job_ip}:{self.ports[1]}",
+            "ping_url": f"http://{job_ip}:{self.ports[0]}/_/ping",
+        })
+        return filer
+
+    def _create_job(self, username, password: str) -> str:
+        job_name, k8s_volume = f"drycc-filer-{uuid.uuid4()}", {"name": self.volume.name}
         if self.volume.type == "csi":
             k8s_volume.update({"persistentVolumeClaim": {"claimName": self.volume.name}})
         else:
             k8s_volume.update(self.volume.parameters)
-        username, password = random_string(32), random_string(32)
-        self.scheduler.pod.create(self.app_id, pod_name, settings.DRYCC_FILER_IMAGE, **{
+        self.scheduler.job.create(self.app_id, job_name, settings.DRYCC_FILER_IMAGE, **{
+            "command": ["init-stack", "/usr/bin/pingguard"],
             "args": [
-                "filer",
-                "--bind", self.bind, "--path", self.path,
-                "--duration", f"{settings.DRYCC_FILER_DURATION}",
-                "--waittime", f"{settings.DRYCC_FILER_WAITTIME}",
-                "--username", f"{username}", "--password", f"{password}",
+                f"--bind=:{self.ports[0]}", f"--interval={settings.DRYCC_FILER_DURATION}s",
+                "--",
+                "rclone", "serve", "webdav", self.path, f"--addr=:{self.ports[1]}",
+                f"--user={username}", f"--pass={password}", f"--baseurl={self.url_path}",
             ],
-            "labels": {"app": self.app_id, "pod": pod_name, "volume": self.volume.name},
             "app_type": "filer", "replicas": 1, "deploy_timeout": 120, "volumes": [k8s_volume],
             "volume_mounts": [{"mountPath": self.path, "name": self.volume.name}],
             "restart_policy": "Never", "image_pull_policy": settings.DRYCC_FILER_IMAGE_PULL_POLICY,
             "pod_security_context": {"fsGroup": 1001, "runAsGroup": 1001, "runAsUser": 1001},
+            "active_deadline_seconds": 2 ** 32,
+            "ttl_seconds_after_finished": settings.DRYCC_FILER_DURATION,
         })
-        address = self.scheduler.pod.get(self.app_id, pod_name).json()["status"]["podIP"]
-        return {"address": address, "username": username, "password": password}
+        return job_name
 
-    def clean(self):
-        response = self.scheduler.pod.get(
-            self.app_id, labels={"app": self.app_id, "type": "filer"})
-        if response.status_code != 200:
-            self.log("clean up old filter errors")
-            return False
-        for item in response.json()["items"]:
-            if item['status']['phase'] in ('Succeeded', 'Failed'):
-                pod_name = item['metadata']['name']
-                self.scheduler.pod.delete(self.app_id, pod_name)
-        self.log("clean up old filter completed")
-        return True
+    def _get_job_ip(self, job_name: str) -> str:
+        state, labels = 'initializing', {'job-name': job_name}
+        for count, state in enumerate(self.scheduler.pod.watch(
+            self.app_id, labels, settings.DRYCC_PILELINE_RUN_TIMEOUT,
+            until_states=['up', 'down', 'crashed', 'error'],
+        )):
+            self.log(f"waiting for filer bind: {state} * {count}")
+        if state != 'up':
+            raise DryccException(f'filer startup failed, current status: {state}')
+        pods = self.scheduler.pod.get(self.app_id, labels=labels).json()
+        if not pods["items"]:
+            raise DryccException('filer pod not found after run completed')
+        return pods["items"][0]["status"]["podIP"]
 
-    def health(self, server):
+    async def _check_health(self, filer):
         try:
-            return self.request(
-                "OPTIONS", server, timeout=2).headers.get('server') == 'drycc-filer'
-        except requests.exceptions.Timeout:
+            async with aiohttp.ClientSession() as session:
+                async with session.get(
+                    filer["ping_url"],
+                    timeout=aiohttp.ClientTimeout(total=5),
+                ) as response:
+                    return response.status == 200
+        except (aiohttp.ClientError, asyncio.TimeoutError):
             return False
-
-    def request(self, method, server, path="/", **kwargs):
-        cache.touch(self.cache_key, timeout=settings.DRYCC_FILER_DURATION)
-        url = f"http://{server["address"]}:{self.bind.split(":")[1]}/{path}"
-        kwargs["auth"] = HTTPBasicAuth(server["username"], server["password"])
-        return get_session().request(method, url, **kwargs)
-
-    def get(self, path, **kwargs):
-        return self.request("GET", self.server, path, **kwargs)
-
-    def post(self, path, **kwargs):
-        return self.request("POST", self.server, path, **kwargs)
-
-    def delete(self, path, **kwargs):
-        return self.request("DELETE", self.server, path, **kwargs)
diff --git a/rootfs/api/management/commands/measure_apps.py b/rootfs/api/management/commands/measure_apps.py
deleted file mode 100644
index 8b677c361..000000000
--- a/rootfs/api/management/commands/measure_apps.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import uuid
-import time
-import logging
-from django.utils import timezone
-from django.core.management.base import BaseCommand
-from django.conf import settings
-from api.models.app import App
-from api.tasks import send_measurements
-
-logger = logging.getLogger(__name__)
-
-
-class Command(BaseCommand):
-    """Management command for push data to manager"""
-
-    def handle(self, *args, **options):
-        if settings.WORKFLOW_MANAGER_URL:
-            timestamp = time.time()
-            task_id = uuid.uuid4().hex
-            logger.info(f"pushing {task_id} resources to workflow_manager when {timezone.now()}")
-            app_list = []
-            for app in App.objects.all():
-                app_list.extend(app.to_measurements(timestamp))
-                if len(app_list) % 1000 == 0:
-                    send_measurements.delay(app_list)
-                    app_list = []
-            if len(app_list) > 0:
-                send_measurements.delay(app_list)
-            logger.info(f"pushed {task_id} resources to workflow_manager when {timezone.now()}")
-            self.stdout.write("done")
diff --git a/rootfs/api/management/commands/measure_loadbalancers.py b/rootfs/api/management/commands/measure_loadbalancers.py
deleted file mode 100644
index 277bdc6a8..000000000
--- a/rootfs/api/management/commands/measure_loadbalancers.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import uuid
-import time
-import logging
-import ipaddress
-from asgiref.sync import async_to_sync
-from django.utils import timezone
-from django.core.management.base import BaseCommand
-from django.conf import settings
-from api import monitor
-from api.models.app import App
-from api.tasks import send_measurements
-
-logger = logging.getLogger(__name__)
-
-
-class Command(BaseCommand):
-    """Management command for push data to manager"""
-
-    def _get_measure_name(self, ip):
-        address = ipaddress.ip_address(ip)
-        prefix = "intranet" if address.is_private else "internet"
-        return f"{prefix}:{address.version}"
-
-    def _measure_loadbalancers(self, app_map, timestamp):
-        stop = timestamp - (timestamp % 3600)
-        start = stop - 3600
-        loadbalancers = []
-        for metric, (_, value) in async_to_sync(monitor.query_loadbalancer(
-                app_map.keys(), start, stop)):
-            ip = metric["ip"]
-            namespace = metric["namespace"]
-            owner_id = app_map[namespace].owner_id
-            loadbalancers.append({
-                "app_id":  str(app_map[namespace].uuid),
-                "owner": owner_id,
-                "name": self._get_measure_name(ip),
-                "type": "loadbalancer",
-                "unit": "number",
-                "usage": value,
-                "kwargs": {
-                    "ip": ip,
-                    "node": metric["node"],
-                    "service": metric["service"],
-                    "instance": metric["instance"],
-                },
-                "timestamp": start
-            })
-        send_measurements.delay(loadbalancers)
-
-    def handle(self, *args, **options):
-        if settings.WORKFLOW_MANAGER_URL and settings.DRYCC_PROMETHEUS_URL:
-            timestamp = int(time.time())
-            task_id = uuid.uuid4().hex
-            logger.info(f"pushing {task_id} limits to workflow_manager when {timezone.now()}")
-            app_map = {}
-            for app in App.objects.all():
-                app_map[app.id] = app
-                if len(app_map) % 1000 == 0:
-                    self._measure_loadbalancers(app_map, timestamp)
-                    app_map = {}
-            if len(app_map) > 0:
-                self._measure_loadbalancers(app_map, timestamp)
-            logger.info(f"pushed {task_id} limits to workflow_manager when {timezone.now()}")
-            self.stdout.write("done")
diff --git a/rootfs/api/management/commands/measure_networks.py b/rootfs/api/management/commands/measure_networks.py
deleted file mode 100644
index 013577d77..000000000
--- a/rootfs/api/management/commands/measure_networks.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import uuid
-import time
-import logging
-from asgiref.sync import async_to_sync
-from django.utils import timezone
-from django.core.management.base import BaseCommand
-from django.conf import settings
-from api import monitor
-from api.models.app import App
-from api.tasks import send_measurements
-
-logger = logging.getLogger(__name__)
-
-
-class Command(BaseCommand):
-    """Management command for push data to manager"""
-
-    def _measure_networks(self, app_map, timestamp):
-        stop = timestamp - (timestamp % 3600)
-        start = stop - 3600
-        networks = []
-        for metric, (_, value) in async_to_sync(monitor.query_network_receive_flow(
-                app_map.keys(), start, stop)):
-            networks.append({
-                "app_id":  str(app_map[metric['namespace']].uuid),
-                "owner": app_map[metric['namespace']].owner_id,
-                "type": "network",
-                "unit": "bytes",
-                "name": "rx",
-                "usage": value,
-                "kwargs": {
-                    "pod": metric['pod'],
-                },
-                "timestamp": start
-            })
-        for metric, (_, value) in async_to_sync(monitor.query_network_transmit_flow(
-                app_map.keys(), start, stop)):
-            networks.append({
-                "app_id":  str(app_map[metric['namespace']].uuid),
-                "owner": app_map[metric['namespace']].owner_id,
-                "type": "network",
-                "unit": "bytes",
-                "name": "tx",
-                "usage": value,
-                "kwargs": {
-                    "pod": metric['pod'],
-                },
-                "timestamp": start
-            })
-        send_measurements.delay(networks)
-
-    def handle(self, *args, **options):
-        if settings.WORKFLOW_MANAGER_URL:
-            timestamp = int(time.time())
-            task_id = uuid.uuid4().hex
-            logger.info(f"pushing {task_id} limits to workflow_manager when {timezone.now()}")
-            app_map = {}
-            for app in App.objects.all():
-                app_map[app.id] = app
-                if len(app_map) % 1000 == 0:
-                    self._measure_networks(app_map, timestamp)
-                    app_map = {}
-            if len(app_map) > 0:
-                self._measure_networks(app_map, timestamp)
-            logger.info(f"pushed {task_id} limits to workflow_manager when {timezone.now()}")
-            self.stdout.write("done")
diff --git a/rootfs/api/management/commands/measure_volumes.py b/rootfs/api/management/commands/measure_volumes.py
deleted file mode 100644
index ecf14c524..000000000
--- a/rootfs/api/management/commands/measure_volumes.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import uuid
-import time
-import logging
-from django.utils import timezone
-from django.core.management.base import BaseCommand
-from django.conf import settings
-from api.models.volume import Volume
-from api.tasks import send_measurements
-
-logger = logging.getLogger(__name__)
-
-
-class Command(BaseCommand):
-    """Management command for push data to manager"""
-
-    def handle(self, *args, **options):
-        if settings.WORKFLOW_MANAGER_URL:
-            timestamp = time.time()
-            task_id = uuid.uuid4().hex
-            logger.info(f"pushing {task_id} volumes to workflow_manager when {timezone.now()}")
-            volume_list = []
-            for volume in Volume.objects.all():
-                volume_list.extend(volume.to_measurements(timestamp))
-                if len(volume_list) % 1000 == 0:
-                    send_measurements.delay(volume_list)
-                    volume_list = []
-            if len(volume_list) > 0:
-                send_measurements.delay(volume_list)
-            logger.info(f"pushed {task_id} volumes to workflow_manager when {timezone.now()}")
-            self.stdout.write("done")
diff --git a/rootfs/api/management/commands/upload_app_usage.py b/rootfs/api/management/commands/upload_app_usage.py
new file mode 100644
index 000000000..82028125c
--- /dev/null
+++ b/rootfs/api/management/commands/upload_app_usage.py
@@ -0,0 +1,39 @@
+import uuid
+import logging
+import random
+from datetime import timedelta
+from django.utils import timezone
+from django.core.management.base import BaseCommand
+from django.conf import settings
+from api.models.app import App
+from api.tasks import send_usage
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+    """Management command for push data to manager"""
+
+    def handle(self, *args, **options):
+        if settings.WORKFLOW_MANAGER_URL:
+            now = timezone.now()
+            start_time, timestamp, task_id = now, int(now.timestamp()), uuid.uuid4().hex
+            logger.info(f"pushing {task_id} resources to workflow_manager when {now}")
+            app_list = []
+            for app in App.objects.all():
+                app_list.extend(app.to_usages(timestamp))
+                if len(app_list) % 1000 == 0:
+                    logger.info(f"bulk sent: {len(app_list)}")
+                    send_usage.apply_async(
+                        args=(app_list,),
+                        eta=start_time + timedelta(seconds=random.randint(1, 3600))
+                    )
+                    app_list = []
+            if len(app_list) > 0:
+                logger.info(f"bulk sent: {len(app_list)}")
+                send_usage.apply_async(
+                    args=(app_list,),
+                    eta=start_time + timedelta(seconds=random.randint(1, 3600))
+                )
+            logger.info(f"pushed {task_id} resources to workflow_manager when {timezone.now()}")
+            self.stdout.write("done")
diff --git a/rootfs/api/management/commands/upload_gateway_usage.py b/rootfs/api/management/commands/upload_gateway_usage.py
new file mode 100644
index 000000000..aaf9b8ba6
--- /dev/null
+++ b/rootfs/api/management/commands/upload_gateway_usage.py
@@ -0,0 +1,39 @@
+import uuid
+import logging
+import random
+from datetime import timedelta
+from django.utils import timezone
+from django.core.management.base import BaseCommand
+from django.conf import settings
+from api.models.gateway import Gateway
+from api.tasks import send_usage
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+    """Management command for push data to manager"""
+
+    def handle(self, *args, **options):
+        if settings.WORKFLOW_MANAGER_URL:
+            now = timezone.now()
+            start_time, timestamp, task_id = now, int(now.timestamp()), uuid.uuid4().hex
+            logger.info(f"pushing {task_id} gateways to workflow_manager when {now}")
+            gateway_list = []
+            for gateway in Gateway.objects.all():
+                gateway_list.extend(gateway.to_usages(timestamp))
+                if len(gateway_list) % 1000 == 0:
+                    logger.info(f"bulk sent: {len(gateway_list)}")
+                    send_usage.apply_async(
+                        args=(gateway_list,),
+                        eta=start_time + timedelta(seconds=random.randint(1, 3600))
+                    )
+                    gateway_list = []
+            if len(gateway_list) > 0:
+                logger.info(f"bulk sent: {len(gateway_list)}")
+                send_usage.apply_async(
+                    args=(gateway_list,),
+                    eta=start_time + timedelta(seconds=random.randint(1, 3600))
+                )
+            logger.info(f"pushed {task_id} gateways to workflow_manager when {timezone.now()}")
+            self.stdout.write("done")
diff --git a/rootfs/api/management/commands/upload_network_usage.py b/rootfs/api/management/commands/upload_network_usage.py
new file mode 100644
index 000000000..fd80ea387
--- /dev/null
+++ b/rootfs/api/management/commands/upload_network_usage.py
@@ -0,0 +1,60 @@
+import uuid
+import hashlib
+import logging
+import random
+from datetime import timedelta
+from asgiref.sync import async_to_sync
+from django.utils import timezone
+from django.core.management.base import BaseCommand
+from django.conf import settings
+from api import monitor
+from api.models.app import App
+from api.tasks import send_usage
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+    """Management command for push data to manager"""
+
+    def _upload_network_usage(self, start_time, app_map, timestamp):
+        stop = timestamp - (timestamp % 3600)
+        start = stop - 3600
+        networks = []
+        for item in async_to_sync(monitor.query_network_usage)(app_map.keys(), start, stop):
+            metric = item["metric"]
+            _, value = item["value"]
+            pod_name = metric['pod']
+            networks.append({
+                "app_id":  str(app_map[metric['namespace']].uuid),
+                "workspace": app_map[metric['namespace']].workspace_id,
+                "type": "network",
+                "unit": "bytes",
+                "name": metric['direction'],
+                "usage": value,
+                "kwargs": {
+                    "pod": pod_name,
+                },
+                "timestamp": start,
+                "identifier": hashlib.md5(pod_name.encode("utf-8")).hexdigest(),
+            })
+        logger.info(f"bulk sent: {len(networks)}")
+        send_usage.apply_async(
+            args=(networks,), eta=start_time + timedelta(seconds=random.randint(1, 3600))
+        )
+
+    def handle(self, *args, **options):
+        if settings.WORKFLOW_MANAGER_URL:
+            now = timezone.now()
+            start_time, timestamp, task_id = now, int(now.timestamp()), uuid.uuid4().hex
+            logger.info(f"pushing {task_id} networks to workflow_manager when {now}")
+            app_map = {}
+            for app in App.objects.all():
+                app_map[app.id] = app
+                if len(app_map) % 1000 == 0:
+                    self._upload_network_usage(start_time, app_map, timestamp)
+                    app_map = {}
+            if len(app_map) > 0:
+                self._upload_network_usage(start_time, app_map, timestamp)
+            logger.info(f"pushed {task_id} networks to workflow_manager when {timezone.now()}")
+            self.stdout.write("done")
diff --git a/rootfs/api/management/commands/measure_resources.py b/rootfs/api/management/commands/upload_resource_usage.py
similarity index 50%
rename from rootfs/api/management/commands/measure_resources.py
rename to rootfs/api/management/commands/upload_resource_usage.py
index b1f9ceccb..e9b598912 100644
--- a/rootfs/api/management/commands/measure_resources.py
+++ b/rootfs/api/management/commands/upload_resource_usage.py
@@ -1,11 +1,12 @@
 import uuid
-import time
 import logging
+import random
+from datetime import timedelta
 from django.utils import timezone
 from django.core.management.base import BaseCommand
 from django.conf import settings
 from api.models.resource import Resource
-from api.tasks import send_measurements
+from api.tasks import send_usage
 
 logger = logging.getLogger(__name__)
 
@@ -15,16 +16,24 @@ class Command(BaseCommand):
 
     def handle(self, *args, **options):
         if settings.WORKFLOW_MANAGER_URL:
-            timestamp = time.time()
-            task_id = uuid.uuid4().hex
-            logger.info(f"pushing {task_id} resources to workflow_manager when {timezone.now()}")
+            now = timezone.now()
+            start_time, timestamp, task_id = now, int(now.timestamp()), uuid.uuid4().hex
+            logger.info(f"pushing {task_id} resources to workflow_manager when {now}")
             resource_list = []
             for resource in Resource.objects.filter(status="Ready"):
-                resource_list.extend(resource.to_measurements(timestamp))
+                resource_list.extend(resource.to_usages(timestamp))
                 if len(resource_list) % 1000 == 0:
-                    send_measurements.delay(resource_list)
+                    logger.info(f"bulk sent: {len(resource_list)}")
+                    send_usage.apply_async(
+                        args=(resource_list,),
+                        eta=start_time + timedelta(seconds=random.randint(1, 3600))
+                    )
                     resource_list = []
             if len(resource_list) > 0:
-                send_measurements.delay(resource_list)
+                logger.info(f"bulk sent: {len(resource_list)}")
+                send_usage.apply_async(
+                    args=(resource_list,),
+                    eta=start_time + timedelta(seconds=random.randint(1, 3600))
+                )
             logger.info(f"pushed {task_id} resources to workflow_manager when {timezone.now()}")
             self.stdout.write("done")
diff --git a/rootfs/api/management/commands/upload_volume_usage.py b/rootfs/api/management/commands/upload_volume_usage.py
new file mode 100644
index 000000000..9852e42ff
--- /dev/null
+++ b/rootfs/api/management/commands/upload_volume_usage.py
@@ -0,0 +1,60 @@
+import uuid
+import hashlib
+import logging
+import random
+from datetime import timedelta
+from asgiref.sync import async_to_sync
+from django.utils import timezone
+from django.core.management.base import BaseCommand
+from django.conf import settings
+from api import monitor
+from api.models.app import App
+from api.tasks import send_usage
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+    """Management command for push data to manager"""
+
+    def _upload_volume_usage(self, start_time, app_map, timestamp):
+        stop = timestamp - (timestamp % 3600)
+        start = stop - 3600
+        volumes = []
+        for item in async_to_sync(monitor.query_volume_usage)(app_map.keys(), start, stop):
+            metric = item["metric"]
+            _, value = item["value"]
+            pvc_name = metric['persistentvolumeclaim']
+            volumes.append({
+                "app_id":  str(app_map[metric['namespace']].uuid),
+                "workspace": app_map[metric['namespace']].workspace_id,
+                "type": "volume",
+                "unit": "bytes",
+                "name": metric["storageclass"],
+                "usage": value,
+                "kwargs": {
+                    "persistentvolumeclaim": pvc_name,
+                },
+                "timestamp": start,
+                "identifier": hashlib.md5(pvc_name.encode("utf-8")).hexdigest(),
+            })
+        logger.info(f"bulk sent: {len(volumes)}")
+        send_usage.apply_async(
+            args=(volumes,), eta=start_time + timedelta(seconds=random.randint(1, 3600))
+        )
+
+    def handle(self, *args, **options):
+        if settings.WORKFLOW_MANAGER_URL:
+            now = timezone.now()
+            start_time, timestamp, task_id = now, int(now.timestamp()), uuid.uuid4().hex
+            logger.info(f"pushing {task_id} volumes to workflow_manager when {now}")
+            app_map = {}
+            for app in App.objects.all():
+                app_map[app.id] = app
+                if len(app_map) % 1000 == 0:
+                    self._upload_volume_usage(start_time, app_map, timestamp)
+                    app_map = {}
+            if len(app_map) > 0:
+                self._upload_volume_usage(start_time, app_map, timestamp)
+            logger.info(f"pushed {task_id} volumes to workflow_manager when {timezone.now()}")
+            self.stdout.write("done")
diff --git a/rootfs/api/manager.py b/rootfs/api/manager.py
index 5b985aabd..53b22eb7f 100644
--- a/rootfs/api/manager.py
+++ b/rootfs/api/manager.py
@@ -54,23 +54,23 @@ def delete(self, url, **kwargs):
         return self.request('delete', url, **kwargs)
 
 
-class User(ManagerAPI):
+class WorkspaceAPI(ManagerAPI):
 
-    def get_status(self, id):
+    def get_status(self, workspace_id):
         """
         {
             "is_active": False,
             "message": "The user is in arrears"
         }
         """
-        key = f"user:status:{id}"
+        key = f"workspace:status:{workspace_id}"
         status = cache.get(key)
         if not status:
-            url = f"{settings.WORKFLOW_MANAGER_URL}/users/{id}/status/"
+            url = f"{settings.WORKFLOW_MANAGER_URL}/workspaces/{workspace_id}/status/"
             try:
                 status = self.get(url=url, timeout=self.timeout).json()
             except requests.exceptions.Timeout as ex:
-                msg = f"request user {id} timeout, skipping verification."
+                msg = f"request workspace {workspace_id} timeout, skipping verification."
                 status = {"is_active": True, "message": msg}
                 logger.error(msg)
                 logger.exception(ex)
@@ -78,21 +78,25 @@ def get_status(self, id):
         return status
 
 
-class Measurement(ManagerAPI):
+class UserAPI(WorkspaceAPI):
+    """Backward-compatible alias for legacy call sites."""
 
-    def post(self, measurements: List[Dict[str, str]]):
+
+class UsageAPI(ManagerAPI):
+
+    def post(self, usages: List[Dict[str, str]]):
         """
         [
             {
                 "app_id":  "test",
-                "owner": "test",
-                "name": web,
-                "type": "CPU",
-                "unit": "G"
+                "workspace": "test",
+                "name": "web",
+                "type": "limits",
+                "unit": "std1.large.c1m1",
                 "usage": "2",
                 "timestamp": "1609231998.9103732"
             }
         ]
         """
-        url = "%s/measurements/" % settings.WORKFLOW_MANAGER_URL
-        return super().post(url=url, json=measurements)
+        url = "%s/usages/" % settings.WORKFLOW_MANAGER_URL
+        return super().post(url=url, json=usages)
diff --git a/rootfs/api/middleware.py b/rootfs/api/middleware.py
index e897443d5..47cc095be 100644
--- a/rootfs/api/middleware.py
+++ b/rootfs/api/middleware.py
@@ -3,15 +3,18 @@
 
 See https://docs.djangoproject.com/en/1.11/topics/http/middleware/
 """
-from api import __version__
+
 from django.http.request import HttpRequest
 
 from channels.middleware import BaseMiddleware
 from channels.db import database_sync_to_async
-from api.authentication import DryccAuthentication
+
+from api import __version__
+
+from .authentication import DryccAuthentication
 
 
-class APIVersionMiddleware(object):
+class DjangoAPIVersionMiddleware(object):
     """
     Include that REST API version with each response.
     """
@@ -32,6 +35,27 @@ def __call__(self, request):
         return response
 
 
+class ChannelAPIVersionMiddleware(BaseMiddleware):
+    """
+    Channel middleware to include REST API version with each HTTP response.
+    """
+    async def __call__(self, scope, receive, send):
+        if scope["type"] == "http":
+            async def send_with_version(message):
+                if message["type"] == "http.response.start":
+                    headers = list(message.get("headers", []))
+                    # clients shouldn't care about the patch release
+                    version = __version__.rsplit('.', 1)[0]
+                    headers.append((b"DRYCC_API_VERSION", version.encode()))
+                    headers.append((b"DRYCC_PLATFORM_VERSION", __version__.encode()))
+                    message = message.copy()
+                    message["headers"] = headers
+                await send(message)
+            return await super().__call__(scope, receive, send_with_version)
+        else:
+            return await super().__call__(scope, receive, send)
+
+
 class ChannelOAuthMiddleware(BaseMiddleware):
     """
     Middleware which populates scope["user"] from a auth2 token.
@@ -39,7 +63,7 @@ class ChannelOAuthMiddleware(BaseMiddleware):
     authentication = DryccAuthentication()
 
     @database_sync_to_async
-    def get_user(self, scope):
+    def login(self, scope):
         headers = {}
         for header in scope["headers"]:
             if header[0] in (b"authorization", ):
@@ -49,10 +73,10 @@ def get_user(self, scope):
             return None
         request = HttpRequest()
         request.META = headers
-        user, _ = self.authentication.authenticate(request)
-        return user
+        scope["user"], _ = self.authentication.authenticate(request)
+        return scope["user"]
 
     async def __call__(self, scope, receive, send):
         scope = dict(scope)
-        scope["user"] = await self.get_user(scope)
+        await self.login(scope)
         return await super().__call__(scope, receive, send)
diff --git a/rootfs/api/migrations/0013_migration_permissions_and_certificates.py b/rootfs/api/migrations/0013_migration_permissions_and_certificates.py
index f5d4644e0..63e87c4ab 100644
--- a/rootfs/api/migrations/0013_migration_permissions_and_certificates.py
+++ b/rootfs/api/migrations/0013_migration_permissions_and_certificates.py
@@ -1,20 +1,10 @@
 # Generated by Django 4.2.15 on 2024-09-03 03:48
 
 from django.db import migrations
-from guardian.shortcuts import assign_perm, get_users_with_perms, remove_perm
-from api.models.app import App, VIEW_APP_PERMISSION, CHANGE_APP_PERMISSION
 from api.models.domain import Domain
 from api.models.certificate import Certificate
 
 
-def migration_permission(apps, schema_editor):
-    for app in App.objects.all():
-        for user in get_users_with_perms(app):
-            remove_perm('use_app', user, app)
-            assign_perm(VIEW_APP_PERMISSION.codename, user, app)
-            assign_perm(CHANGE_APP_PERMISSION.codename, user, app)
-
-
 def migration_certificate(apps, schema_editor):
     for domain in Domain.objects.all():
         if domain.certificate:
@@ -37,6 +27,5 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
-        migrations.RunPython(migration_permission),
         migrations.RunPython(migration_certificate),
     ]
diff --git a/rootfs/api/migrations/0024_limitplan_container_volume_mounts_and_more.py b/rootfs/api/migrations/0024_limitplan_container_volume_mounts_and_more.py
new file mode 100644
index 000000000..d6e8058e2
--- /dev/null
+++ b/rootfs/api/migrations/0024_limitplan_container_volume_mounts_and_more.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.22 on 2025-07-17 06:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0023_migration_k8s_alter_volume_type'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='limitplan',
+            name='container_volume_mounts',
+            field=models.JSONField(default=list),
+        ),
+        migrations.AddField(
+            model_name='limitplan',
+            name='pod_volumes',
+            field=models.JSONField(default=list),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0025_alter_app_id_alter_gateway_name.py b/rootfs/api/migrations/0025_alter_app_id_alter_gateway_name.py
new file mode 100644
index 000000000..eeb228016
--- /dev/null
+++ b/rootfs/api/migrations/0025_alter_app_id_alter_gateway_name.py
@@ -0,0 +1,25 @@
+# Generated by Django 4.2.22 on 2025-08-22 02:48
+
+import api.models.app
+import api.utils
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0024_limitplan_container_volume_mounts_and_more'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='app',
+            name='id',
+            field=models.SlugField(max_length=63, null=True, unique=True, validators=[api.models.app.validate_app_id]),
+        ),
+        migrations.AlterField(
+            model_name='gateway',
+            name='name',
+            field=models.CharField(db_index=True, max_length=63, validators=[api.utils.validate_label]),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0026_app_suspended_state.py b/rootfs/api/migrations/0026_app_suspended_state.py
new file mode 100644
index 000000000..a667b809f
--- /dev/null
+++ b/rootfs/api/migrations/0026_app_suspended_state.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.22 on 2025-08-22 02:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0025_alter_app_id_alter_gateway_name'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='app',
+            name='suspended_state',
+            field=models.JSONField(blank=True, default=dict),
+        ),
+    ]
diff --git a/rootfs/api/migrations/0027_rename_lifecycle_post_start_config_lifecycle_and_more.py b/rootfs/api/migrations/0027_rename_lifecycle_post_start_config_lifecycle_and_more.py
new file mode 100644
index 000000000..27cdfd3ff
--- /dev/null
+++ b/rootfs/api/migrations/0027_rename_lifecycle_post_start_config_lifecycle_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.2.8 on 2025-12-05 13:39
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0026_app_suspended_state'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='config',
+            old_name='lifecycle_post_start',
+            new_name='lifecycle',
+        ),
+        migrations.RemoveField(
+            model_name='config',
+            name='lifecycle_pre_stop',
+        ),
+    ]
diff --git a/rootfs/api/migrations/0028_workspace_remove_app_owner_remove_appsettings_owner_and_more.py b/rootfs/api/migrations/0028_workspace_remove_app_owner_remove_appsettings_owner_and_more.py
new file mode 100644
index 000000000..5dfb1ee61
--- /dev/null
+++ b/rootfs/api/migrations/0028_workspace_remove_app_owner_remove_appsettings_owner_and_more.py
@@ -0,0 +1,119 @@
+# Generated by Django 5.2.9 on 2026-03-23 05:42
+
+import api.models.workspace
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0027_rename_lifecycle_post_start_config_lifecycle_and_more'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Workspace',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.SlugField(max_length=150, unique=True, validators=[api.models.workspace.validate_workspace_name], verbose_name='workspace name')),
+                ('email', models.EmailField(max_length=254, verbose_name='email address')),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('updated', models.DateTimeField(auto_now=True)),
+            ],
+        ),
+        migrations.RemoveField(
+            model_name='app',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='appsettings',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='build',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='certificate',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='config',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='domain',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='gateway',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='release',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='route',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='resource',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='service',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='tls',
+            name='owner',
+        ),
+        migrations.RemoveField(
+            model_name='volume',
+            name='owner',
+        ),
+        migrations.AlterField(
+            model_name='blocklist',
+            name='type',
+            field=models.PositiveIntegerField(choices=[(1, 'app'), (2, 'workspace')]),
+        ),
+        migrations.AddField(
+            model_name='app',
+            name='workspace',
+            field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='api.workspace'),
+            preserve_default=False,
+        ),
+        migrations.CreateModel(
+            name='WorkspaceInvitation',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('email', models.EmailField(max_length=254, verbose_name='email address')),
+                ('token', models.CharField(max_length=128, unique=True)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('accepted', models.BooleanField(default=False, verbose_name='accepted')),
+                ('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+                ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.workspace')),
+            ],
+            options={
+                'unique_together': {('email', 'workspace')},
+            },
+        ),
+        migrations.CreateModel(
+            name='WorkspaceMember',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('role', models.CharField(choices=[('admin', 'Admin'), ('member', 'Member'), ('viewer', 'Viewer')], max_length=50)),
+                ('alerts', models.BooleanField(default=True)),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('updated', models.DateTimeField(auto_now=True)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+                ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='api.workspace')),
+            ],
+            options={
+                'unique_together': {('user', 'workspace')},
+            },
+        ),
+    ]
diff --git a/rootfs/api/migrations/0029_key_api_key_fingerp_4e77c5_idx_and_more.py b/rootfs/api/migrations/0029_key_api_key_fingerp_4e77c5_idx_and_more.py
new file mode 100644
index 000000000..454a81db8
--- /dev/null
+++ b/rootfs/api/migrations/0029_key_api_key_fingerp_4e77c5_idx_and_more.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.2.9 on 2026-03-26 08:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0028_workspace_remove_app_owner_remove_appsettings_owner_and_more'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='key',
+            index=models.Index(fields=['fingerprint'], name='api_key_fingerp_4e77c5_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='limitplan',
+            index=models.Index(fields=['spec', 'cpu', 'memory'], name='api_limitpl_spec_id_f95ef2_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='release',
+            index=models.Index(fields=['app', 'failed', 'state'], name='api_release_app_id_9849f2_idx'),
+        ),
+    ]
diff --git a/rootfs/api/models/app.py b/rootfs/api/models/app.py
index c3464127d..3e7914035 100644
--- a/rootfs/api/models/app.py
+++ b/rootfs/api/models/app.py
@@ -10,7 +10,7 @@
 import socket
 from contextlib import closing
 from urllib.parse import urljoin
-from collections import OrderedDict, namedtuple
+from collections import OrderedDict
 from datetime import datetime, timezone
 
 from docker import auth as docker_auth
@@ -20,9 +20,11 @@
 from django.contrib.auth import get_user_model
 from rest_framework.exceptions import ValidationError
 
-from api.utils import get_session, dict_diff
+from api.tasks import send_app_log
+from api.utils import get_httpclient, dict_diff
 from api.exceptions import AlreadyExists, DryccException, ServiceUnavailable
-from api.utils import CacheLock, DeployLock, generate_app_name, apply_tasks
+from api.utils import (
+    CacheLock, DeployLock, generate_app_name, apply_tasks, validate_reserved_names)
 from scheduler import KubeHTTPException, KubeException
 from .gateway import Gateway, Route
 from .limit import LimitPlan
@@ -36,47 +38,6 @@
 
 User = get_user_model()
 logger = logging.getLogger(__name__)
-AppPermission = namedtuple('AppPermission', ['shortname', 'codename', 'description'])
-VIEW_APP_PERMISSION = AppPermission("view", "view_app", "can view app")
-CHANGE_APP_PERMISSION = AppPermission("change", "change_app", "can change app")
-DELETE_APP_PERMISSION = AppPermission("delete", "delete_app", "can delete app")
-
-
-class AppPermissionRegistry(object):
-
-    def __init__(self):
-        self.tags = {}
-        self.permissions = set()
-
-    def get(self, q):
-        permissions = [
-            permission for permission in self.permissions
-            if q == permission.shortname or q == permission.codename
-        ]
-        permissions.extend([
-            permission for permission in self.permissions
-            if permission in self.tags and q in self.tags[permission]
-        ])
-        return permissions[0] if permissions else None
-
-    def register(self, permission, tags=None):
-        if tags:
-            self.tags[permission] = tags
-        self.permissions.add(permission)
-
-    @property
-    def codenames(self):
-        return [permission[1] for permission in self.permissions]
-
-    @property
-    def shortnames(self):
-        return [permission[0] for permission in self.permissions]
-
-
-app_permission_registry = AppPermissionRegistry()
-app_permission_registry.register(VIEW_APP_PERMISSION, ["GET", "HEAD", "OPTION"])
-app_permission_registry.register(CHANGE_APP_PERMISSION, ["POST", "PUT", "PATCH"])
-app_permission_registry.register(DELETE_APP_PERMISSION, ["DELETE"])
 
 
 # http://kubernetes.io/v1.1/docs/design/identifiers.html
@@ -84,38 +45,35 @@ def validate_app_id(value):
     """
     Check that the value follows the kubernetes name constraints
     """
-    match = re.match(r'[a-z]([a-z0-9-]*[a-z0-9])?$', value)
+    match = re.match(r'^[a-z]([a-z0-9-]{3,}[a-z0-9])$', value)
     if not match:
         raise ValidationError("App name must start with an alphabetic character, cannot end with a"
                               + " hyphen and can only contain a-z (lowercase), 0-9 and hyphens.")
+    validate_reserved_names(value)
 
 
 def validate_app_structure(value):
     """Error if the dict values aren't ints >= 0"""
     try:
-        if any(int(v) < 0 for v in value.values()):
-            raise ValueError("Must be greater than or equal to zero")
+        for k, v in value.items():
+            if int(v) < 0:
+                raise ValueError("Must be greater than or equal to zero")
+            validate_reserved_names(k)
     except ValueError as err:
         raise ValidationError(str(err))
 
 
-def validate_reserved_names(value):
-    """A value cannot use some reserved names."""
-    if value in settings.DRYCC_RESERVED_NAMES:
-        raise ValidationError('{} is a reserved name.'.format(value))
-
-
 class App(UuidAuditedModel):
     """
     Application used to service requests on behalf of end-users
     """
 
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     id = models.SlugField(max_length=63, unique=True, null=True,
-                          validators=[validate_app_id,
-                                      validate_reserved_names])
+                          validators=[validate_app_id])
+    workspace = models.ForeignKey('Workspace', on_delete=models.CASCADE)
     structure = models.JSONField(
         default=dict, blank=True, validators=[validate_app_structure])
+    suspended_state = models.JSONField(default=dict, blank=True)
 
     class Meta:
         verbose_name = 'Application'
@@ -133,7 +91,7 @@ def save(self, *args, **kwargs):
             self.release_set.latest()
         except Release.DoesNotExist:
             try:
-                if self.scheduler().ns.get(self.id).status_code == 200:
+                if self.scheduler.ns.get(self.id).status_code == 200:
                     # Namespace already exists
                     err = "{} already exists as a namespace in this kuberenetes setup".format(self.id)  # noqa
                     self.log(err, logging.INFO)
@@ -151,10 +109,28 @@ def save(self, *args, **kwargs):
     def lock(self):
         return CacheLock(f"app:lock:{self.id}")
 
+    def _default_actor(self):
+        member = self.workspace.workspacemember_set.select_related('user').first()
+        if member:
+            return member.user
+        raise DryccException("workspace has no members")
+
     @property
     def ptypes(self):
         return list(self.structure.keys())
 
+    @property
+    def scheduler(self):
+        """
+        Override @Base.AuditedModel.scheduler;
+        since the app itself doesn't have an app object context,
+        directly reference using ID instead.
+        """
+        scheduler = super(App, self).scheduler
+        scheduler.metadata["annotations"]["drycc.cc/app_id"] = str(self.id)
+        scheduler.metadata["annotations"]["drycc.cc/workspace_id"] = str(self.workspace_id)
+        return scheduler
+
     def check_ptypes(self, ptypes: set):
         """
         check available procfile types
@@ -177,6 +153,7 @@ def log(self, message, level=logging.INFO):
         as "belonging" to the application instead of the controller and will be handled
         accordingly.
         """
+        send_app_log.delay(self.id, message, level)
         logger.log(level, "[{}]: {}".format(self.id, message))
 
     def create(self, *args, **kwargs):  # noqa
@@ -190,7 +167,7 @@ def create(self, *args, **kwargs):  # noqa
             self.release_set.latest()
         except Release.DoesNotExist:
             Release.objects.create(
-                version=1, owner=self.owner, app=self,
+                version=1, app=self,
                 config=cfg, build=None
             )
 
@@ -199,36 +176,36 @@ def create(self, *args, **kwargs):  # noqa
         self.log('creating Namespace {} and services'.format(namespace), level=logging.DEBUG)
         # Create essential resources
         try:
-            self.scheduler().ns.get(namespace)
+            self.scheduler.ns.get(namespace)
         except KubeException:
             try:
-                self.scheduler().ns.create(namespace)
+                self.scheduler.ns.create(namespace)
             except KubeException as e:
                 raise ServiceUnavailable('Could not create the Namespace in Kubernetes') from e
         try:
             self.appsettings_set.latest()
         except AppSettings.DoesNotExist:
             AppSettings.objects.create(
-                owner=self.owner, app=self, routable=True, autodeploy=True, autorollback=True)
+                app=self, routable=True, autodeploy=True, autorollback=True)
         try:
             self.tls_set.latest()
         except TLS.DoesNotExist:
-            TLS.objects.create(owner=self.owner, app=self)
+            TLS.objects.create(app=self)
 
     def delete(self, *args, **kwargs):
         """Delete this application including all containers"""
         self.log("deleting environment")
         try:
             # check if namespace exists
-            self.scheduler().ns.get(self.id)
+            self.scheduler.ns.get(self.id)
 
             try:
-                self.scheduler().ns.delete(self.id)
+                self.scheduler.ns.delete(self.id)
 
                 # wait 30 seconds for termination
                 for _ in range(30):
                     try:
-                        self.scheduler().ns.get(self.id)
+                        self.scheduler.ns.get(self.id)
                     except KubeHTTPException as e:
                         # only break out on a 404
                         if e.response.status_code == 404:
@@ -239,8 +216,6 @@ def delete(self, *args, **kwargs):
         except KubeHTTPException:
             # it's fine if the namespace does not exist - delete app from the DB
             pass
-
-        self._clean_app_logs()
         return super(App, self).delete(*args, **kwargs)
 
     def restart(self, **kwargs):  # noqa
@@ -254,7 +229,7 @@ def restart(self, **kwargs):  # noqa
             tasks = [
                 (
                     functools.partial(
-                        self.scheduler().deployment.restart,
+                        self.scheduler.deployment.restart,
                         self.id,
                         deployment
                     ),
@@ -288,12 +263,12 @@ def pipeline(self, release, ptypes, force_deploy=False):
                     for run in release.get_runners(ptypes):
                         self.log(f"{prefix} starts running pipeline.run {run['image']}")
                         job_name = self.run(
-                            self.owner, run['image'], command=run['command'],
+                            self._default_actor(), run['image'], command=run['command'],
                             args=run['args'], timeout=run['timeout'], expires=run['timeout'],
                             envs=self._build_env_vars(release, run['ptype']),
                         )
                         state, labels = 'initializing', {'job-name': job_name}
-                        for count, state in enumerate(self.scheduler().pod.watch(
+                        for count, state in enumerate(self.scheduler.pod.watch(
                                 self.id, labels, settings.DRYCC_PILELINE_RUN_TIMEOUT)):
                             self.log(f"{prefix} waiting for pipeline.run: {state} * {count}")
                         if state != 'down':
@@ -303,16 +278,16 @@ def pipeline(self, release, ptypes, force_deploy=False):
                 if not rollback_on_failure:
                     self.log(f"{prefix} deploy do not rollback on failure")
                 self.deploy(release, ptypes, force_deploy, rollback_on_failure)
-            if release.state == "created":
-                release.state = "succeed"
+
+            release.state, release.failed = "succeed", False
             ptypes = list(ptypes) if ptypes is not None else ptypes
             release.add_condition(state="succeed", action="pipeline", ptypes=ptypes)
         except Exception as e:
-            release.failed, release.state = True, "crashed"
+            release.failed, release.state, msg = True, "crashed", f"{type(e).__name__}: {e}"
             ptypes = list(ptypes) if ptypes is not None else ptypes
             release.add_condition(
-                state="crashed", action="pipeline", ptypes=ptypes, exception=str(e))
-            self.log(f"{prefix} pipeline runtime error: {release.exception}", logging.ERROR)
+                state="crashed", action="pipeline", ptypes=ptypes, exception=msg)
+            self.log(f"{prefix} pipeline runtime error, {msg}", logging.ERROR)
         finally:
             DeployLock(self.pk).release(ptypes)  # release all locks
             release.save(update_fields=["state", "failed"])  # avoid overwriting other fields
@@ -365,13 +340,13 @@ def clean(self, release=None, ptypes=None):
                 if ptype not in release.ptypes and self.structure.get(ptype, 0) > 0:
                     if ptypes is None or ptype in ptypes:
                         removed[ptype] = 0
-            self.scale(self.owner, removed)
+            self.scale(self._default_actor(), removed)
         self._merge_structure(release, pre_release)
         # clean k8s resources, ptype not in structure
         labels = {'heritage': 'drycc'}
         if ptypes:
             labels["type__in"] = ptypes
-        resource_apis = [self.scheduler().deployments, self.scheduler().secret]
+        resource_apis = [self.scheduler.deployments, self.scheduler.secret]
         for api in resource_apis:
             resources = api.get(self.id, labels=labels).json()["items"]
             if resources is not None:
@@ -414,7 +389,7 @@ def pod_name(size=5, chars=string.ascii_lowercase + string.digits):
         try:
             # create application config and build the pod manifest
             self.set_application_config(release, PTYPE_RUN)
-            self.scheduler().job.create(self.id, name, image, command, args, **kwargs)
+            self.scheduler.job.create(self.id, name, image, command, args, **kwargs)
         except Exception as e:
             err = '{} ({}): {}'.format(name, PTYPE_RUN, e)
             raise ServiceUnavailable(err) from e
@@ -431,7 +406,7 @@ def get_command_and_args(pod, container_name):
             return command, args
         result = []
         try:
-            pod = self.scheduler().pod.get(self.id, pod_name).json()
+            pod = self.scheduler.pod.get(self.id, pod_name).json()
             if pod["status"]['phase'] == 'Pending':
                 statuses = pod["spec"]["containers"]
             else:
@@ -462,9 +437,9 @@ def list_pods(self, *args, **kwargs):
             labels = self._scheduler_filter(**kwargs)
             # in case a singular pod is requested
             if 'name' in kwargs:
-                pods = [self.scheduler().pod.get(self.id, kwargs['name']).json()]
+                pods = [self.scheduler.pod.get(self.id, kwargs['name']).json()]
             else:
-                pods = self.scheduler().pod.get(self.id, labels=labels).json()['items']
+                pods = self.scheduler.pod.get(self.id, labels=labels).json()['items']
                 if not pods:
                     pods = []
             data = []
@@ -475,7 +450,7 @@ 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))
+                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"]])
@@ -508,9 +483,9 @@ def delete_pod(self, **kwargs):
         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()
+            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)
+                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:
@@ -519,7 +494,7 @@ def delete_pod(self, **kwargs):
     def describe_deployment(self, deployment_name):
         result = []
         try:
-            deployment = self.scheduler().deployment.get(self.id, deployment_name).json()
+            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({
@@ -545,9 +520,9 @@ def list_deployments(self, *args, **kwargs):
             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()]
+                deployments = [self.scheduler.deployment.get(self.id, kwargs['name']).json()]
             else:
-                deployments = self.scheduler().deployment.get(self.id, labels=labels).json()['items']  # noqa
+                deployments = self.scheduler.deployment.get(self.id, labels=labels).json()['items']  # noqa
                 if not deployments:
                     deployments = []
             data = []
@@ -588,7 +563,7 @@ def list_events(self, ref_kind, ref_name, *args, **kwargs):
                 "regarding.name": ref_name
             }
             kwargs["fields"] = fields
-            events = self.scheduler().events.get(self.id, **kwargs).json()['items']  # noqa
+            events = self.scheduler.events.get(self.id, **kwargs).json()['items']  # noqa
             data = []
             for e in events:
                 item = {
@@ -622,16 +597,16 @@ def autoscale(self, proc_type, autoscale):
 
         try:
             # get the target for autoscaler, in this case Deployment
-            self.scheduler().hpa.get(self.id, name)
+            self.scheduler.hpa.get(self.id, name)
             if autoscale is None:
-                self.scheduler().hpa.delete(self.id, name)
+                self.scheduler.hpa.delete(self.id, name)
             else:
-                self.scheduler().hpa.update(
+                self.scheduler.hpa.update(
                     self.id, name, proc_type, target, **autoscale
                 )
         except KubeHTTPException as e:
             if e.response.status_code == 404:
-                self.scheduler().hpa.create(
+                self.scheduler.hpa.create(
                     self.id, name, proc_type, target, **autoscale
                 )
             else:
@@ -649,16 +624,16 @@ def image_pull_secret(self, namespace, ptype, registry, image):
         elif create:
             data = {'.dockerconfigjson': docker_config}
             try:
-                self.scheduler().secret.get(namespace, name)
+                self.scheduler.secret.get(namespace, name)
             except KubeHTTPException:
-                self.scheduler().secret.create(
+                self.scheduler.secret.create(
                     namespace,
                     name,
                     data,
                     secret_type='kubernetes.io/dockerconfigjson'
                 )
             else:
-                self.scheduler().secret.update(
+                self.scheduler.secret.update(
                     namespace,
                     name,
                     data,
@@ -674,7 +649,7 @@ def state_to_k8s(self):
             return
         ptypes = set()
         for ptype, scale in self.structure.items():
-            response = self.scheduler().deployment.get(
+            response = self.scheduler.deployment.get(
                 self.id, self._get_deployment_name(ptype),
                 ignore_exception=True)
             if response.status_code == 404 and scale > 0:
@@ -710,20 +685,20 @@ def set_application_config(self, release, ptype):
 
         secret_name = "{}-{}-{}-env".format(self.id, ptype, release.version_name)
         try:
-            self.scheduler().secret.get(self.id, secret_name)
+            self.scheduler.secret.get(self.id, secret_name)
         except KubeHTTPException:
-            self.scheduler().secret.create(self.id, secret_name, secrets_env, labels=labels)
+            self.scheduler.secret.create(self.id, secret_name, secrets_env, labels=labels)
         else:
-            self.scheduler().secret.update(self.id, secret_name, secrets_env, labels=labels)
+            self.scheduler.secret.update(self.id, secret_name, secrets_env, labels=labels)
 
-    def to_measurements(self, timestamp: float):
-        measurements = []
+    def to_usages(self, timestamp: float):
+        usage = []
         config = self.config_set.latest()
         for ptype, scale in self.structure.items():
             plan = config.limits.get(ptype)
-            measurements.append({
+            usage.append({
                 "app_id": str(self.uuid),
-                "owner": self.owner_id,
+                "workspace": self.workspace_id,
                 "name": plan,
                 "type": "limits",
                 "unit": "number",
@@ -732,8 +707,9 @@ def to_measurements(self, timestamp: float):
                     "ptype": ptype,
                 },
                 "timestamp": int(timestamp),
+                "identifier": self.uuid.hex,
             })
-        return measurements
+        return usage
 
     def __str__(self):
         return self.id
@@ -741,18 +717,6 @@ def __str__(self):
     def _get_deployment_name(self, ptype):
         return f"{self.id}-{ptype}"
 
-    def _clean_app_logs(self):
-        """Delete application logs stored by the logger component"""
-        try:
-            url = 'http://{}:{}/logs/{}'.format(settings.LOGGER_HOST,
-                                                settings.LOGGER_PORT, self.id)
-            requests.delete(url)
-        except Exception as e:
-            # Ignore errors deleting application logs.  An error here should not interfere with
-            # the overall success of deleting an application, but we should log it.
-            err = 'Error deleting existing application logs: {}'.format(e)
-            self.log(err, logging.WARNING)
-
     def _mount(self, user, volume, app_settings, structure=None):
         volumes = Volume.objects.filter(app=self)
         tasks = []
@@ -766,7 +730,7 @@ def _mount(self, user, volume, app_settings, structure=None):
                     volume for volume in volumes if scale_type in volume.path.keys()]
                 data = self._gather_app_settings(
                     release, app_settings, scale_type, replicas, volumes=scale_type_volumes)
-                deployment = self.scheduler().deployment.get(
+                deployment = self.scheduler.deployment.get(
                     self.id, self._get_deployment_name(scale_type)).json()
                 spec_annotations = deployment['spec']['template']['metadata'].get(
                     'annotations', {})
@@ -774,7 +738,7 @@ def _mount(self, user, volume, app_settings, structure=None):
                 # gather volume proc types to be deployed
                 tasks.append((
                     functools.partial(
-                        self.scheduler().deployment.patch,
+                        self.scheduler.deployment.patch,
                         namespace=self.id,
                         name=self._get_deployment_name(scale_type),
                         image=release.get_deploy_image(scale_type),
@@ -808,7 +772,7 @@ def _deploy(self, deploys, ptypes, prev_release,
                 self.set_application_config(release, scale_type)
                 tasks.append((
                     functools.partial(
-                        self.scheduler().deploy,
+                        self.scheduler.deploy,
                         namespace=self.id,
                         name=self._get_deployment_name(scale_type),
                         image=release.get_deploy_image(scale_type),
@@ -906,7 +870,7 @@ def _scale_pods(self, scale_types, release, app_settings):
             # gather all proc types to be deployed
             tasks.append((
                 functools.partial(
-                    self.scheduler().scale,
+                    self.scheduler.scale,
                     namespace=self.id,
                     name=self._get_deployment_name(scale_type),
                     image=release.get_deploy_image(scale_type),
@@ -938,7 +902,7 @@ def _set_default_config(self):
             limits[PTYPE_WEB] = config.limits.get(PTYPE_WEB, plan.id)
             limits[PTYPE_RUN] = config.limits.get(PTYPE_RUN, plan.id)
         except Config.DoesNotExist:
-            config = Config.objects.create(owner=self.owner, app=self, limits=limits)
+            config = Config.objects.create(app=self, limits=limits)
         for ptype in self.ptypes:
             limits[ptype] = config.limits.get(ptype, plan.id)
         if limits != config.limits:
@@ -951,7 +915,7 @@ def _create_default_ingress(self, target_port):
         try:
             service = self.service_set.filter(ptype=PTYPE_WEB).latest()
         except Service.DoesNotExist:
-            service = Service(owner=self.owner, app=self, ptype=PTYPE_WEB)
+            service = Service(app=self, ptype=PTYPE_WEB)
             service.add_port(DEFAULT_HTTP_PORT, "TCP", target_port)
             service.save()
         else:
@@ -963,7 +927,7 @@ def _create_default_ingress(self, target_port):
             if gateway.change_default_tls():
                 gateway.save()
         except Gateway.DoesNotExist:
-            gateway = Gateway(app=self, owner=self.owner, name=self.id)
+            gateway = Gateway(app=self, name=self.id)
             added, msg = gateway.add(DEFAULT_HTTP_PORT, "HTTP")
             if not added:
                 raise DryccException(msg)
@@ -974,7 +938,7 @@ def _create_default_ingress(self, target_port):
             if route.change_default_tls():
                 route.save()
         except Route.DoesNotExist:
-            route = Route(app=self, owner=self.owner, kind="HTTPRoute", name=self.id,
+            route = Route(app=self, kind="HTTPRoute", name=self.id,
                           rules=[{"backendRefs": [{"kind": "Service", "name": service.name,
                                                    "port": DEFAULT_HTTP_PORT, "weight": 100}]}])
             attached, msg = route.attach(gateway.name, DEFAULT_HTTP_PORT)
@@ -1013,9 +977,10 @@ def _verify_http_health(self, service, **kwargs):
         response = None
         for _ in range(10):
             try:
-                # http://docs.python-requests.org/en/master/user/advanced/#timeouts
-                response = get_session().get(url, timeout=req_timeout)
-                failed = False
+                with get_httpclient() as session:
+                    # http://docs.python-requests.org/en/master/user/advanced/#timeouts
+                    response = session.get(url, timeout=req_timeout)
+                    failed = False
             except requests.exceptions.RequestException:
                 # In case of a failure where response object is not available
                 failed = True
@@ -1064,7 +1029,7 @@ def _check_deployment_in_progress(self, deploys, force_deploy=False):
         for scale_type, kwargs in deploys.items():
             name = self._get_deployment_name(scale_type)
             # Is there an existing deployment in progress?
-            in_progress, deploy_okay = self.scheduler().deployment.in_progress(
+            in_progress, deploy_okay = self.scheduler.deployment.in_progress(
                 self.id, name, kwargs.get("deploy_timeout"), kwargs.get("deploy_batches"),
                 kwargs.get("replicas"), kwargs.get("tags")
             )
@@ -1148,13 +1113,13 @@ def _get_private_registry_config(self, ptype, image, registry=None):
             username = registry.get('username')
             password = registry.get('password')
         elif settings.REGISTRY_LOCATION == 'off-cluster':
-            secret = self.scheduler().secret.get(
-                settings.WORKFLOW_NAMESPACE, 'registry-secret').json()
-            username = secret['data']['username']
-            password = secret['data']['password']
-            hostname = secret['data']['hostname']
+            secret = self.scheduler.secret.get(
+                settings.WORKFLOW_NAMESPACE, 'controller-creds').json()
+            hostname = secret['data']['registry-host']
             if hostname == '':
                 hostname = docker_auth.INDEX_URL
+            username = secret['data']['registry-username']
+            password = secret['data']['registry-password']
             name = name + '-' + settings.REGISTRY_LOCATION
         else:
             return None, None, None
@@ -1206,9 +1171,13 @@ def _gather_app_settings(self, release, app_settings, ptype, replicas, volumes=N
             settings.KUBERNETES_DEPLOYMENTS_REVISION_HISTORY_LIMIT)
 
         # get application level pod termination grace period
-        pod_termination_grace_period_seconds = int(envs.get(
-            'KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS',
-            settings.KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS))
+        termination_grace_period_seconds = int(config.termination_grace_period.get(
+            ptype,
+            envs.get(
+                'KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS',
+                settings.KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS
+            )
+        ))
 
         # set the image pull policy that is associated with the application container
         image_pull_policy = envs.get('IMAGE_PULL_POLICY', settings.IMAGE_PULL_POLICY)
@@ -1224,8 +1193,9 @@ def _gather_app_settings(self, release, app_settings, ptype, replicas, volumes=N
         routable = True if (
             ptype == PTYPE_WEB and app_settings.routable) else False
 
-        healthcheck = config.healthcheck.get(ptype, {})
         volumes, volume_mounts = self._get_volumes_and_mounts(ptype, volumes)
+        volumes.extend(limit_plan.pod_volumes)
+        volume_mounts.extend(limit_plan.container_volume_mounts)
         return {
             'tags': config.tags.get(ptype, {}),
             'envs': envs,
@@ -1236,19 +1206,17 @@ def _gather_app_settings(self, release, app_settings, ptype, replicas, volumes=N
             'resources': {"limits": limit_plan.limits, "requests": limit_plan.requests},
             'build_type': release.build.type,
             'annotations': limit_plan.annotations,
-            'healthcheck': healthcheck,
+            'lifecycle': config.lifecycle.get(ptype, {}),
+            'healthcheck': config.healthcheck.get(ptype, {}),
             'runtime_class_name': limit_plan.runtime_class_name,
             'dns_policy': settings.DRYCC_APP_DNS_POLICY,
-            'lifecycle_post_start': config.lifecycle_post_start,
-            'lifecycle_pre_stop': config.lifecycle_pre_stop,
             'routable': routable,
             'deploy_batches': batches,
             'restart_policy': "Always",
             'deploy_timeout': deploy_timeout,
             'deployment_revision_history_limit': deployment_history,
             'release_summary': release.summary,
-            'pod_termination_grace_period_seconds': pod_termination_grace_period_seconds,
-            'pod_termination_grace_period_each': config.termination_grace_period,
+            'termination_grace_period_seconds': termination_grace_period_seconds,
             'image_pull_secret_name': image_pull_secret_name,
             'image_pull_policy': image_pull_policy,
             'volumes': volumes,
diff --git a/rootfs/api/models/appsettings.py b/rootfs/api/models/appsettings.py
index 9a9bf194d..32c1da8f7 100644
--- a/rootfs/api/models/appsettings.py
+++ b/rootfs/api/models/appsettings.py
@@ -6,6 +6,7 @@
 
 from rest_framework.exceptions import NotFound
 from django.contrib.auth import get_user_model
+from api.tasks import send_app_log
 from api.utils import dict_diff
 from api.exceptions import DryccException, AlreadyExists, UnprocessableEntity
 from .base import UuidAuditedModel
@@ -19,7 +20,6 @@ class AppSettings(UuidAuditedModel):
     Instance of Application settings used by scheduler
     """
 
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     app = models.ForeignKey('App', on_delete=models.CASCADE)
     routable = models.BooleanField(default=None)
     autodeploy = models.BooleanField(default=None)
@@ -48,6 +48,7 @@ def log(self, message, level=logging.INFO):
         as "belonging" to the application instead of the controller and will be handled
         accordingly.
         """
+        send_app_log.delay(self.app.id, message, level)
         logger.log(level, "[{}]: {}".format(self.app.id, message))
 
     def previous(self):
@@ -75,7 +76,9 @@ def _update_field(self, field, previous_settings):
         elif new is None and isinstance(self._meta.get_field(field), models.BooleanField):
             setattr(self, field, True)
         elif old != new:
-            self.summary += ["{} changed {} from {} to {}".format(self.owner, field, old, new)]
+            self.summary += [
+                "{} changed {} from {} to {}".format(self.app.workspace.name, field, old, new)
+            ]
 
     def _update_autoscale(self, previous_settings):
         data = getattr(previous_settings, 'autoscale', {}).copy()
@@ -115,7 +118,7 @@ def _update_autoscale(self, previous_settings):
             deleted = 'deleted autoscale for process type ' + deleted if deleted else ''
             changes = ', '.join(i for i in (added, changed, deleted) if i)
             if changes:
-                self.summary += ["{} {}".format(self.owner, changes)]
+                self.summary += ["{} {}".format(self.app.workspace.name, changes)]
 
     def _update_label(self, previous_settings):
         data = getattr(previous_settings, 'label', {}).copy()
@@ -147,7 +150,7 @@ def _update_label(self, previous_settings):
             if changes:
                 if self.summary:
                     self.summary += ' and '
-                self.summary += ["{} {}".format(self.owner, changes)]
+                self.summary += ["{} {}".format(self.app.workspace.name, changes)]
 
     @transaction.atomic
     def save(self, ignore_update_fields=None, *args, **kwargs):
@@ -173,7 +176,7 @@ def save(self, ignore_update_fields=None, *args, **kwargs):
 
         if not self.summary and previous_settings:
             self.delete()
-            raise AlreadyExists("{} changed nothing".format(self.owner))
+            raise AlreadyExists("{} changed nothing".format(self.app.workspace.name))
         super(AppSettings, self).save(**kwargs)
         summary = ' '.join(self.summary)
         self.log('summary of app setting changes: {}'.format(summary), logging.DEBUG)
diff --git a/rootfs/api/models/base.py b/rootfs/api/models/base.py
index 7e66d6136..f3310d3dc 100644
--- a/rootfs/api/models/base.py
+++ b/rootfs/api/models/base.py
@@ -1,17 +1,16 @@
 import uuid
+import time
 import string
 import random
-import importlib
 from datetime import timedelta
 from functools import partial
 from django.db import models
-from django.conf import settings
 from django.utils import timezone
 from django.contrib.auth.models import AbstractUser
 from django.utils.translation import gettext_lazy as _
 
 
-from api.utils import validate_json
+from api.utils import get_httpclient, validate_json, get_scheduler
 
 token_manager_oauth_schema = {
     "$schema": "http://json-schema.org/schema#",
@@ -35,9 +34,6 @@
 PTYPE_MAX_LENGTH = 63
 
 
-def get_anonymous_user_instance(user): return user(id=-1, username=settings.ANONYMOUS_USER_NAME)
-
-
 class AuditedModel(models.Model):
     """Add created and updated fields to a model."""
 
@@ -48,10 +44,27 @@ class Meta:
         """Mark :class:`AuditedModel` as abstract."""
         abstract = True
 
-    @classmethod
-    def scheduler(cls):
-        mod = importlib.import_module(settings.SCHEDULER_MODULE)
-        return mod.SchedulerClient(settings.SCHEDULER_URL, settings.K8S_API_VERIFY_TLS)
+    def app_log(self, app_id, msg):
+        with get_httpclient() as session:
+            session.post(
+                "",
+                json={
+                    "timestamp": int(time.time()),
+                    "log": msg,
+                    "kubernetes": {
+                        "namespace": app_id,
+                        "container_name": "drycc-controller",
+                    }
+                }
+            )
+
+    @property
+    def scheduler(self):
+        annotations = {}
+        if hasattr(self, 'app'):
+            annotations["drycc.cc/app_id"] = str(self.app.id)
+            annotations["drycc.cc/workspace_id"] = str(self.app.workspace_id)
+        return get_scheduler(metadata={"annotations": annotations})
 
 
 class UuidAuditedModel(AuditedModel):
@@ -102,7 +115,7 @@ def generate_key(cls):
         ) + ''.join(random.choices(string.ascii_letters, k=96))
 
     def refresh_token(self):
-        from api.backend import DryccOIDC
+        from api.apps_extra.social_core.backends import DryccOIDC
         drycc_open_connect = DryccOIDC()
         self.oauth = drycc_open_connect.refresh_token(self.oauth['refresh_token'])
         self.save()
diff --git a/rootfs/api/models/blocklist.py b/rootfs/api/models/blocklist.py
index 5f5f6eac3..e51a2b613 100644
--- a/rootfs/api/models/blocklist.py
+++ b/rootfs/api/models/blocklist.py
@@ -1,18 +1,22 @@
+import logging
+import functools
 from django.db import models
-from django.contrib.auth import get_user_model
+from scheduler import KubeHTTPException
+from api.exceptions import ServiceUnavailable
+from api.utils import apply_tasks
 from .app import App
 from .base import UuidAuditedModel
 
-User = get_user_model()
+
+logger = logging.getLogger(__name__)
 
 
 class Blocklist(UuidAuditedModel):
     """
-    You can block apps or users.
-    If a user is blocked, all apps owned by the user will be stopped.
-    The apps managed by the user will not be affected.
+    You can block apps or workspaces.
+    If a workspace is blocked, all apps under that workspace will be stopped.
     """
-    type_choices = [(1, "app", ), (2, "user")]
+    type_choices = [(1, "app", ), (2, "workspace")]
     id = models.CharField(max_length=128, db_index=True)
     type = models.PositiveIntegerField(choices=type_choices)
     remark = models.TextField(blank=True, null=True, default="Blocked for unknown reason")
@@ -20,8 +24,7 @@ class Blocklist(UuidAuditedModel):
     @property
     def related_apps(self):
         if self.type == 2:
-            user = User.objects.get(id=self.id)
-            return App.objects.filter(owner=user)
+            return App.objects.filter(workspace_id=self.id)
         else:
             return App.objects.filter(id=self.id)
 
@@ -35,9 +38,233 @@ def get_type(cls, name: str):
     @classmethod
     def get_blocklist(cls, app: App):
         return cls.objects.filter(
-            models.Q(id=app.id, type=1) | models.Q(id=app.owner_id, type=2)
+            models.Q(id=app.id, type=1) | models.Q(id=app.workspace_id, type=2)
         ).first()
 
     class Meta:
         ordering = ['-created']
         unique_together = (("id", "type"),)
+
+    def related_resource_deployments(self, app: App):
+        "get resource deployments"
+        try:
+            deployments = self.scheduler.deployment.get(app.id, labels={}).json()['items']  # noqa
+            if not deployments:
+                deployments = []
+            data = []
+            for d in deployments:
+                item = {
+                    'name': d['metadata']['name'],
+                    'replicas': d['spec'].get("replicas", 0),
+                }
+                data.append(item)
+            data.sort(key=lambda x: x['name'])
+            return data
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = f'(list resource deployments): {e}'
+            logger.info(err)
+            raise ServiceUnavailable(err) from e
+
+    def related_resource_statefulsets(self, app: App):
+        "get resource statefulsets"
+        try:
+            statefulsets = self.scheduler.statefulset.get(app.id, labels={}).json()['items']  # noqa
+            if not statefulsets:
+                statefulsets = []
+            data = []
+            for s in statefulsets:
+                item = {
+                    'name': s['metadata']['name'],
+                    'replicas': s['spec'].get("replicas", 0),
+                }
+                data.append(item)
+            data.sort(key=lambda x: x['name'])
+            return data
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = f'(list resource statefulsets): {e}'
+            logger.info(err)
+            raise ServiceUnavailable(err) from e
+
+    def related_resource_daemonsets(self, app: App):
+        "get resource daemonsets"
+        try:
+            daemonsets = self.scheduler.daemonset.get(app.id, labels={}).json()['items']  # noqa
+            if not daemonsets:
+                daemonsets = []
+            data = []
+            for d in daemonsets:
+                item = {
+                    'name': d['metadata']['name'],
+                    'affinity': d['spec']['template']['spec'].get('affinity', {}),
+                }
+                data.append(item)
+            data.sort(key=lambda x: x['name'])
+            return data
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = f'(list resource daemonsets): {e}'
+            logger.info(err)
+            raise ServiceUnavailable(err) from e
+
+    def suspended_state(self, app: App):
+        """
+        Get deployments/statefulsets/daemonsets with labels app.kubernetes.io/managed-by=Helm
+        Store in the suspended_state field of the app model
+        Format:
+        {"deployments": [{"name":"sample1", "replicas": 1}],
+        "statefulsets": [{"name":"sample1", "replicas": 1}],
+        "daemonsets": [{"name":"sample1", "affinity": {}}]}
+        """
+        suspended_state = {}
+        suspended_state['deployments'] = self.related_resource_deployments(app)
+        suspended_state['statefulsets'] = self.related_resource_statefulsets(app)
+        suspended_state['daemonsets'] = self.related_resource_daemonsets(app)
+        return suspended_state
+
+    def scale_resource_deployments(self, app: App, deployment_name: str, replicas=0):
+        """scale deployments"""
+        try:
+            deployment = self.scheduler.deployment.get(app.id, deployment_name).json()
+            self.scheduler.scales.update(app.id, deployment_name, replicas, deployment)
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = f'(scale resource deployments): {e}'
+            logger.info(err)
+            raise ServiceUnavailable(err) from e
+
+    def scale_resource_statefulsets(self, app: App, statefulset_name: str, replicas=0):
+        """scale statefulsets"""
+        try:
+            self.scheduler.statefulset.get(app.id, statefulset_name).json()
+            manifest = {
+                'spec': {
+                    'persistentVolumeClaimRetentionPolicy': {
+                        'whenScaled': 'Retain'
+                    },
+                    'replicas': replicas
+                }
+            }
+            self.scheduler.statefulset.patch(app.id, statefulset_name, manifest)
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = f'(scale resource statefulsets): {e}'
+            logger.info(err)
+            raise ServiceUnavailable(err) from e
+
+    def scale_resource_daemonsets(self, app: App, daemonset_name: str, manifest: dict):
+        """set affinity"""
+        try:
+            self.scheduler.daemonset.get(app.id, daemonset_name).json()
+            self.scheduler.daemonset.patch(app.id, daemonset_name, manifest)
+        except KubeHTTPException:
+            pass
+        except Exception as e:
+            err = f'(scale resource daemonsets): {e}'
+            logger.info(err)
+            raise ServiceUnavailable(err) from e
+
+    def scale_resources(self, app: App, suspended_state: dict, scale_type="block"):
+        "scale resources tasks"
+        tasks = []
+        deployments = suspended_state.get('deployments', [])
+        if not deployments:
+            deployments = []
+        for d in deployments:
+            if scale_type == "unblock":
+                replicas = d['replicas']
+            else:
+                replicas = 0
+            tasks.append((
+                functools.partial(
+                    self.scale_resource_deployments,
+                    app=app,
+                    deployment_name=d['name'],
+                    replicas=replicas
+                ),
+                lambda future, name=d["name"]: app.log(
+                    f'{scale_type} scale deployment {name} callback: {future.result()}',
+                )
+            ))
+
+        statefulsets = suspended_state.get('statefulsets', [])
+        if not statefulsets:
+            statefulsets = []
+        for s in statefulsets:
+            if scale_type == "unblock":
+                replicas = s['replicas']
+            else:
+                replicas = 0
+            tasks.append((
+                functools.partial(
+                    self.scale_resource_statefulsets,
+                    app=app,
+                    statefulset_name=s['name'],
+                    replicas=replicas
+                ),
+                lambda future, name=s["name"]: app.log(
+                    f'{scale_type} scale statefulset {name} callback: {future.result()}',
+                )
+            ))
+
+        daemonsets = suspended_state.get('daemonsets', [])
+        if not daemonsets:
+            daemonsets = []
+        for d in daemonsets:
+            if scale_type == "unblock":
+                manifest = {
+                    "spec": {
+                        "template": {
+                            "spec": {
+                                "affinity": d['affinity']
+                            }
+                        }
+                    }
+                }
+            else:
+                manifest = {
+                    "spec": {
+                        "template": {
+                            "spec": {
+                                "affinity": {
+                                    "nodeAffinity": {
+                                        "requiredDuringSchedulingIgnoredDuringExecution": {
+                                            "nodeSelectorTerms": [{
+                                                "matchExpressions": [{
+                                                    "key": "kubernetes.io/hostname",
+                                                    "operator": "In",
+                                                    "values": [
+                                                        "nohostname"
+                                                    ]
+                                                }]
+                                            }]
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            tasks.append((
+                functools.partial(
+                    self.scale_resource_daemonsets,
+                    app=app,
+                    daemonset_name=d['name'],
+                    manifest=manifest
+                ),
+                lambda future, name=d["name"]: app.log(
+                    f'{scale_type} scale daemonset {name} callback: {future.result()}',
+                )
+            ))
+        try:
+            apply_tasks(tasks)
+        except Exception as e:
+            err = f'({scale_type} scale resources): {e}'
+            logger.info(err)
+            raise ServiceUnavailable(err) from e
diff --git a/rootfs/api/models/build.py b/rootfs/api/models/build.py
index 5b2060ee8..96bee571b 100644
--- a/rootfs/api/models/build.py
+++ b/rootfs/api/models/build.py
@@ -1,4 +1,6 @@
 import logging
+from copy import deepcopy
+from collections import defaultdict
 from django.db import models
 from django.contrib.auth import get_user_model
 from api.exceptions import DryccException
@@ -15,7 +17,6 @@ class Build(UuidAuditedModel):
     Instance of a software build used by runtime nodes
     """
 
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     app = models.ForeignKey('App', on_delete=models.CASCADE)
     image = models.TextField()
     stack = models.CharField(max_length=32)
@@ -63,6 +64,25 @@ def source_based(self):
     def version(self):
         return 'git-{}'.format(self.sha) if self.source_based else 'latest'
 
+    def merge(self, config):
+        if self.dryccfile and any({"values", "values_refs"} & set(config.diff(None).keys())):
+            dryccfile = deepcopy(self.dryccfile)
+            ptype_env, group_env = defaultdict(dict), defaultdict(dict)
+            for value in config.values:
+                if "ptype" in value:
+                    ptype_env[value["ptype"]][value["name"]] = value["value"]
+                if "group" in value:
+                    group_env[value["group"]][value["name"]] = value["value"]
+            dryccfile['config'] = dict(group_env)
+            for pipeline in dryccfile['pipeline'].values():
+                pipeline['env'] = ptype_env[pipeline['ptype']]
+                pipeline['config'] = config.values_refs.get(pipeline['ptype'], [])
+            return Build.objects.create(
+                app=self.app, image=self.image, stack=self.stack, sha=self.sha,
+                procfile=self.procfile, dryccfile=dryccfile, dockerfile=self.dockerfile,
+            )
+        return self
+
     def get_image(self, ptype, default_image=None):
         pipeline = self.get_pipeline(ptype)
         if pipeline and 'build' in pipeline:
@@ -104,7 +124,7 @@ def create_release(self, user, *args, **kwargs):
                 if new_release.summary:
                     new_release.summary += " "
                 new_release.summary += "{} deployed {} which failed".format(
-                    self.owner, str(self.uuid)[:7])
+                    user, str(self.uuid)[:7])
                 # Get the exception that has occured
                 new_release.exception = "error: {}".format(str(e))
                 # avoid overwriting other fields
@@ -120,31 +140,22 @@ def _get_or_create_config(self):
         """
         dryccfile to config
         """
-        config_values, config_values_ref, changed_fields = [], {}, set()
+        if not self.dryccfile:
+            return self.app.release_set.filter(failed=False).latest().config
+        # create config from dryccfile
+        values, values_ref = [], {}
         for group, envs in self.dryccfile.get('config', {}).items():
             for key, value in envs.items():
-                config_values.append({"name": key, "group": group, "value": value})
-            changed_fields.update(["values", "values_refs"])
-
-        replace_values_ptypes = set()
+                values.append({"name": key, "group": group, "value": value})
         for pipeline in self.dryccfile.get('pipeline', {}).values():
             if 'env' in pipeline or 'config' in pipeline:
                 for key, value in pipeline.get('env', {}).items():
-                    config_values.append({"name": key, "ptype": pipeline['ptype'], "value": value})
+                    values.append({"name": key, "ptype": pipeline['ptype'], "value": value})
                 for config_ref in pipeline.get('config', []):
-                    if pipeline['ptype'] not in config_values_ref:
-                        config_values_ref[pipeline['ptype']] = [config_ref]
+                    if pipeline['ptype'] not in values_ref:
+                        values_ref[pipeline['ptype']] = [config_ref]
                     else:
-                        config_values_ref[pipeline['ptype']].append(config_ref)
-                replace_values_ptypes.add(pipeline['ptype'])
-                changed_fields.update(["values", "values_refs"])
-
-        old_config = self.app.release_set.filter(failed=False).latest().config
-        if not changed_fields:
-            return old_config
-        config = Config(
-            owner=self.owner, app=self.app, values=config_values, values_refs=config_values_ref)
-        config.merge_field("values", old_config, replace_values_ptypes)
-        config.merge_field("values_refs", old_config, replace_values_ptypes)
-        config.save(ignore_update_fields=changed_fields)
+                        values_ref[pipeline['ptype']].append(config_ref)
+        config = Config(app=self.app, values=values, values_refs=values_ref)
+        config.save(ignore_update_fields=["values", "values_refs"])
         return config
diff --git a/rootfs/api/models/certificate.py b/rootfs/api/models/certificate.py
index b1f9cd5b1..15778f0aa 100644
--- a/rootfs/api/models/certificate.py
+++ b/rootfs/api/models/certificate.py
@@ -1,10 +1,8 @@
 import logging
-from OpenSSL import crypto
-from pyasn1.codec.der import decoder as der_decoder
-from pyasn1.type import univ, constraint
-from ndg.httpsclient.ssl_peer_verification import SUBJ_ALT_NAME_SUPPORT
-from ndg.httpsclient.subj_alt_name import SubjectAltName as BaseSubjectAltName
-from datetime import datetime
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.x509.oid import ExtensionOID, NameOID
 from pytz import utc
 
 from django.shortcuts import get_object_or_404
@@ -22,59 +20,57 @@
 logger = logging.getLogger(__name__)
 
 
-# Note: This is a slightly bug-fixed version of same from ndg-httpsclient.
-class SubjectAltName(BaseSubjectAltName):
-    '''ASN.1 implementation for subjectAltNames support'''
+def get_subj_alt_name(peer_cert):
+    try:
+        san_extension = peer_cert.extensions.get_extension_for_oid(
+            ExtensionOID.SUBJECT_ALTERNATIVE_NAME
+        )
+    except x509.ExtensionNotFound:
+        return []
+
+    return san_extension.value.get_values_for_type(x509.DNSName)
+
 
-    # There is no limit to how many SAN certificates a certificate may have,
-    #   however this needs to have some limit so we'll set an arbitrarily high
-    #   limit.
-    sizeSpec = univ.SequenceOf.sizeSpec + \
-        constraint.ValueSizeConstraint(1, 1024)
+def public_keys_match(cert_public_key, private_key):
+    def _public_key_der(key):
+        """Serialize any public key to DER SubjectPublicKeyInfo bytes."""
+        return key.public_bytes(
+            serialization.Encoding.DER,
+            serialization.PublicFormat.SubjectPublicKeyInfo,
+        )
 
+    return _public_key_der(cert_public_key) == _public_key_der(
+        private_key.public_key()
+    )
 
-# Note: This is a slightly bug-fixed version of same from ndg-httpsclient.
-def get_subj_alt_name(peer_cert):
-    # Search through extensions
-    dns_name = []
-    if not SUBJ_ALT_NAME_SUPPORT:
-        return dns_name
-
-    general_names = SubjectAltName()
-    for i in range(peer_cert.get_extension_count()):
-        ext = peer_cert.get_extension(i)
-        ext_name = ext.get_short_name()
-        if ext_name != b'subjectAltName':
-            continue
-
-        # PyOpenSSL returns extension data in ASN.1 encoded form
-        ext_dat = ext.get_data()
-        decoded_dat = der_decoder.decode(ext_dat,
-                                         asn1Spec=general_names)
-
-        for name in decoded_dat:
-            if not isinstance(name, SubjectAltName):
-                continue
-            for entry in range(len(name)):
-                component = name.getComponentByPosition(entry)
-                if component.getName() != 'dNSName':
-                    continue
-                dns_name.append(str(component.getComponent()))
-
-    return dns_name
+
+def get_certificate_not_valid_after(certificate):
+    if hasattr(certificate, 'not_valid_after_utc'):
+        return certificate.not_valid_after_utc
+
+    return certificate.not_valid_after.replace(tzinfo=utc)
+
+
+def get_certificate_not_valid_before(certificate):
+    if hasattr(certificate, 'not_valid_before_utc'):
+        return certificate.not_valid_before_utc
+
+    return certificate.not_valid_before.replace(tzinfo=utc)
 
 
 def validate_certificate(value):
     try:
-        return crypto.load_certificate(crypto.FILETYPE_PEM, value)
-    except crypto.Error as e:
+        certificate_bytes = value.encode('utf-8') if isinstance(value, str) else value
+        return x509.load_pem_x509_certificate(certificate_bytes)
+    except (TypeError, ValueError) as e:
         raise ValidationError('Could not load certificate: {}'.format(e))
 
 
 def validate_private_key(value):
     try:
-        return crypto.load_privatekey(crypto.FILETYPE_PEM, value)
-    except crypto.Error as e:
+        private_key_bytes = value.encode('utf-8') if isinstance(value, str) else value
+        return serialization.load_pem_private_key(private_key_bytes, password=None)
+    except (TypeError, ValueError) as e:
         raise ValidationError('Could not load private key: {}'.format(e))
 
 
@@ -87,12 +83,8 @@ def validate_cert_pair(certificate, private_key):
         # The certificate and key should already have been validated
         raise SuspiciousOperation(e)
 
-    if pkey.type() == crypto.TYPE_RSA:
-        # Compare modulus n, to the factors p and q
-        priv_numbers = pkey.to_cryptography_key().private_numbers()
-        pub_modulus = cert.get_pubkey().to_cryptography_key().public_numbers().n
-        if pub_modulus != (priv_numbers.p * priv_numbers.q):
-            raise ValidationError('Certificate and private key do not match!')
+    if not public_keys_match(cert.public_key(), pkey):
+        raise ValidationError('Certificate and private key do not match!')
 
     # Return tuple if everything went ok
     return (cert, pkey)
@@ -102,7 +94,6 @@ class Certificate(AuditedModel):
     """
     Public and private key pair used to secure application traffic at the router.
     """
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     app = models.ForeignKey('App', on_delete=models.CASCADE)
     name = models.CharField(max_length=253, validators=[validate_label])
     # there is no upper limit on the size of an x.509 certificate
@@ -144,34 +135,26 @@ def save(self, *args, **kwargs):
         certificate, _ = validate_cert_pair(self.certificate, self.key)
 
         if not self.common_name:
-            self.common_name = certificate.get_subject().CN
+            common_names = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
+            self.common_name = common_names[0].value if common_names else None
 
         # Grab expire date of the certificate
         if not self.expires:
-            # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509.get_notAfter
-            # Convert bytes to string
-            timestamp = certificate.get_notAfter().decode(encoding='UTF-8')
-            # convert openssl's expiry date format to Django's DateTimeField format
-            self.expires = datetime.strptime(timestamp, '%Y%m%d%H%M%SZ').replace(tzinfo=utc)
+            self.expires = get_certificate_not_valid_after(certificate)
 
         # Grab the start date of the certificate
         if not self.starts:
-            # https://pyopenssl.readthedocs.org/en/latest/api/crypto.html#OpenSSL.crypto.X509.get_notBefore
-            # Convert bytes to string
-            timestamp = certificate.get_notBefore().decode(encoding='UTF-8')
-            # convert openssl's starts date format to Django's DateTimeField format
-            self.starts = datetime.strptime(timestamp, '%Y%m%d%H%M%SZ').replace(tzinfo=utc)
+            self.starts = get_certificate_not_valid_before(certificate)
 
         # process issuers - separate each key/value with a slash
-        issuer = certificate.get_issuer().get_components()
-        self.issuer = '/' + '/'.join('%s=%s' % (key.decode(encoding='UTF-8'), value.decode(encoding='UTF-8')) for key, value in issuer)  # noqa
+        self.issuer = certificate.issuer.rfc4514_string()
 
         # process subject - separate each key/value with a slash
-        subject = certificate.get_subject().get_components()
-        self.subject = '/' + '/'.join('%s=%s' % (key.decode(encoding='UTF-8'), value.decode(encoding='UTF-8')) for key, value in subject)  # noqa
+        self.subject = certificate.subject.rfc4514_string()
 
         # public fingerprint of certificate
-        self.fingerprint = certificate.digest('sha256').decode()
+        fingerprint = certificate.fingerprint(hashes.SHA256()).hex().upper()
+        self.fingerprint = ':'.join(fingerprint[i:i + 2] for i in range(0, len(fingerprint), 2))
 
         # SubjectAltName from the certificate - return a list
         self.san = get_subj_alt_name(certificate)
@@ -207,14 +190,14 @@ def attach_in_kubernetes(self, domain):
                 'tls.key': self.key
             }
 
-            secret = self.scheduler().secret.get(namespace, self.certname).json()['data']
+            secret = self.scheduler.secret.get(namespace, self.certname).json()['data']
         except KubeException:
-            self.scheduler().secret.create(namespace, self.certname, data)
+            self.scheduler.secret.create(namespace, self.certname, data)
         else:
             # update cert secret to the TLS Ingress format if required
             if secret != data:
                 try:
-                    self.scheduler().secret.update(namespace, self.certname, data)
+                    self.scheduler.secret.update(namespace, self.certname, data)
                 except KubeException as e:
                     msg = 'There was a problem updating the certificate secret ' \
                           '{} for {}'.format(self.certname, namespace)
@@ -232,8 +215,8 @@ def detach(self, *args, **kwargs):
         if len(self.domains) == 0:
             try:
                 # We raise an exception when a secret doesn't exist
-                self.scheduler().secret.get(namespace, self.certname)
-                self.scheduler().secret.delete(namespace, self.certname)
+                self.scheduler.secret.get(namespace, self.certname)
+                self.scheduler.secret.delete(namespace, self.certname)
             except KubeException as e:
                 raise ServiceUnavailable(
                     "Could not delete certificate secret {} for application {}".format(
diff --git a/rootfs/api/models/config.py b/rootfs/api/models/config.py
index 4c7742f31..51bc0cf9f 100644
--- a/rootfs/api/models/config.py
+++ b/rootfs/api/models/config.py
@@ -16,19 +16,17 @@ class Config(UuidAuditedModel):
     Set of configuration values applied as environment variables
     during runtime execution of the Application.
     """
-    ptype_fields = ("lifecycle_post_start", "lifecycle_pre_stop", "tags", "limits",
-                    "values_refs", "healthcheck", "termination_grace_period", "registry")
+    ptype_fields = ("lifecycle", "tags", "limits", "values_refs", "healthcheck",
+                    "termination_grace_period", "registry")
     allof_fields = ("values", ) + ptype_fields
 
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     app = models.ForeignKey('App', on_delete=models.CASCADE)
     values = models.JSONField(default=list, blank=True)
     values_refs = models.JSONField(default=dict, blank=True)
-    lifecycle_post_start = models.JSONField(default=dict, blank=True)
-    lifecycle_pre_stop = models.JSONField(default=dict, blank=True)
     tags = models.JSONField(default=dict, blank=True)
     limits = models.JSONField(default=dict, blank=True)
     registry = models.JSONField(default=dict, blank=True)
+    lifecycle = models.JSONField(default=dict, blank=True)
     healthcheck = models.JSONField(default=dict, blank=True)
     termination_grace_period = models.JSONField(default=dict, blank=True)
 
@@ -139,12 +137,12 @@ def save(self, ignore_update_fields=None, *args, **kwargs):
             self._update_tags(previous_config={'tags': {}})
         return super(Config, self).save(*args, **kwargs)
 
-    def merge_field(self, field, old_config, replace_ptypes=[]):
+    def merge_field(self, field, old_config, *args, **kwargs):
         getattr(
             self,
             "_update_%s" % field,
             partial(self._update_field, field)
-        )(old_config, replace_ptypes)
+        )(old_config, *args, **kwargs)
 
     def _update_field(self, field, previous_config, replace_ptypes=[]):
         data = {
@@ -164,10 +162,10 @@ def _update_field(self, field, previous_config, replace_ptypes=[]):
                 data[key] = self._merge_data(field, data.get(key, {}), value)
         setattr(self, field, data)
 
-    def _update_values(self, previous_config, replace_ptypes=[]):
+    def _update_values(self, previous_config, replace_ptypes=[], replace_groups=[]):
         data = [
             item for item in getattr(previous_config, 'values', []).copy()
-            if item.get('ptype') not in replace_ptypes
+            if item.get('ptype') not in replace_ptypes and item.get('group') not in replace_groups
         ]
         new_data = getattr(self, 'values', []).copy()
         for new_item in new_data:
@@ -236,7 +234,7 @@ def _update_tags(self, previous_config, replace_ptypes=[]):
                         '{} does not exist under {}'.format(ptype, 'tags'))
                 data.pop(ptype)
             else:
-                if not self.scheduler().node.get(labels=values).json()['items']:
+                if not self.scheduler.node.get(labels=values).json()['items']:
                     labels = ['{}={}'.format(key, value) for key, value in values.items()]
                     message = 'No nodes matched the provided labels: {}'.format(', '.join(labels))
                     # Find out if there are any other tags around
diff --git a/rootfs/api/models/domain.py b/rootfs/api/models/domain.py
index bb5e8db71..a1caff610 100644
--- a/rootfs/api/models/domain.py
+++ b/rootfs/api/models/domain.py
@@ -7,7 +7,6 @@
 
 
 class Domain(AuditedModel):
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     app = models.ForeignKey('App', on_delete=models.CASCADE)
     domain = models.TextField(
         blank=False, null=False, unique=True,
diff --git a/rootfs/api/models/gateway.py b/rootfs/api/models/gateway.py
index 6aa15cccb..c4e68c323 100644
--- a/rootfs/api/models/gateway.py
+++ b/rootfs/api/models/gateway.py
@@ -1,18 +1,19 @@
 import logging
+import hashlib
 import threading
 from django.db import models
 from django.conf import settings
-from django.contrib.auth import get_user_model
 from django.db.models import Q
 from django.http import Http404
 
+from api.tasks import send_app_log
+from api.utils import validate_label
 from api.exceptions import ServiceUnavailable
 from scheduler import KubeException
 
 from .base import AuditedModel, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT
 
 
-User = get_user_model()
 logger = logging.getLogger(__name__)
 
 TLS_PROTOCOLS = ("HTTPS", "TLS")
@@ -21,8 +22,7 @@
 
 class Gateway(AuditedModel):
     app = models.ForeignKey('App', on_delete=models.CASCADE)
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
-    name = models.CharField(max_length=63, db_index=True)
+    name = models.CharField(max_length=63, db_index=True, validators=[validate_label])
     ports = models.JSONField(default=list)
 
     def log(self, message, level=logging.INFO):
@@ -34,6 +34,7 @@ def log(self, message, level=logging.INFO):
         as "belonging" to the application instead of the controller and will be handled
         accordingly.
         """
+        send_app_log.delay(self.app.id, message, level)
         logger.log(level, "[{}]: {}".format(self.app.id, message))
 
     def add(self, port, protocol):
@@ -55,7 +56,7 @@ def remove(self, port, protocol):
 
     @property
     def addresses(self):
-        data = self.scheduler().gateways.get(self.app.id, self.name, ignore_exception=True)
+        data = self.scheduler.gateways.get(self.app.id, self.name, ignore_exception=True)
         if data.status_code != 200:
             return []
         addresses = data.json()["status"].get("addresses", [])
@@ -93,22 +94,26 @@ def listeners(self):
         return listeners
 
     def refresh_to_k8s(self):
-        kwargs = {"listeners": self.listeners, "gateway_class": settings.GATEWAY_CLASS}
+        kwargs = {"listeners": self.listeners, "gateway_class": settings.DRYCC_APP_GATEWAY_CLASS}
         if self.app.tls_set.latest().certs_auto_enabled:
             kwargs["annotations"] = {"cert-manager.io/issuer": self.app.id}
         try:
             try:
-                data = self.scheduler().gateways.get(self.app.id, self.name).json()
+                data = self.scheduler.gateways.get(self.app.id, self.name).json()
                 if len(kwargs["listeners"]) > 0:
                     kwargs["version"] = data["metadata"]["resourceVersion"]
-                    self.scheduler().gateways.patch(self.app.id, self.name, **kwargs)
+                    response = self.scheduler.gateways.patch(self.app.id, self.name, **kwargs)
+                    if response.status_code == 409:
+                        raise ServiceUnavailable(f'Kubernetes gateway could not be patched: {response.status_code} {response.reason}, please retry')  # noqa 
                 else:
                     logger.debug("delete k8s resource when listeners are empty")
-                    self.scheduler().gateways.delete(
+                    self.scheduler.gateways.delete(
                         self.app.id, self.name, ignore_exception=True)
             except KubeException:
                 if len(kwargs["listeners"]) > 0:
-                    self.scheduler().gateways.create(self.app.id, self.name, **kwargs)
+                    if "version" in kwargs:
+                        kwargs.pop("version")
+                    self.scheduler.gateways.create(self.app.id, self.name, **kwargs)
                 else:
                     logger.debug("skip creating k8s resource when listeners are empty")
         except KubeException as e:
@@ -131,7 +136,7 @@ def save(self, *args, **kwargs):
 
     def delete(self, *args, **kwargs):
         try:
-            self.scheduler().gateways.delete(self.app.id, self.name, ignore_exception=False)
+            self.scheduler.gateways.delete(self.app.id, self.name, ignore_exception=False)
         except KubeException:
             self.log(
                 'Kubernetes gateway cannot be deleted: {}'.format(self.name),
@@ -139,6 +144,21 @@ def delete(self, *args, **kwargs):
             )
         return super().delete(*args, **kwargs)
 
+    def to_usages(self, timestamp: float):
+        return [{
+            "app_id": str(self.app_id),
+            "workspace": self.app.workspace_id,
+            "name": settings.DRYCC_APP_GATEWAY_CLASS,
+            "type": "gateway",
+            "unit": "number",
+            "usage": 1,
+            "kwargs": {
+                "name": self.name,
+            },
+            "timestamp": int(timestamp),
+            "identifier": hashlib.md5(self.name.encode("utf-8")).hexdigest(),
+        }]
+
     def _check_port(self, port, protocol):
         for item in self.ports:
             if item["port"] == port:
@@ -173,7 +193,6 @@ class Route(AuditedModel):
     }
 
     app = models.ForeignKey('App', on_delete=models.CASCADE)
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     kind = models.CharField(max_length=15, choices=[
         (key, '/'.join(value)) for key, value in PROTOCOLS_CHOICES.items()])
     name = models.CharField(max_length=63, db_index=True)
@@ -254,6 +273,7 @@ def log(self, message, level=logging.INFO):
         as "belonging" to the application instead of the controller and will be handled
         accordingly.
         """
+        send_app_log.delay(self.app.id, message, level)
         logger.log(level, "[{}]: {}".format(self.app.id, message))
 
     def refresh_to_k8s(self):
@@ -265,13 +285,13 @@ def refresh_to_k8s(self):
                 self._https_enforced_to_k8s(http_parent_refs)
             elif self.kind == "HTTPRoute":
                 parent_refs.extend(http_parent_refs)
-                self.scheduler().httproute.delete(self.app.id, self._https_redirect_name)
+                self.scheduler.httproute.delete(self.app.id, self._https_redirect_name)
             else:
                 parent_refs.extend(http_parent_refs)
             self._refresh_to_k8s(self.rules, parent_refs)
         else:
-            self.scheduler().httproute.delete(self.app.id, self.name)
-            self.scheduler().httproute.delete(self.app.id, self._https_redirect_name)
+            self.scheduler.httproute.delete(self.app.id, self.name)
+            self.scheduler.httproute.delete(self.app.id, self._https_redirect_name)
 
     def change_default_tls(self):
         if self.app.id != self.name:
@@ -316,7 +336,7 @@ def save(self, *args, **kwargs):
 
     def delete(self, *args, **kwargs):
         try:
-            k8s_route = getattr(self.scheduler(), self.kind.lower())
+            k8s_route = getattr(self.scheduler, self.kind.lower())
             k8s_route.delete(self.app.id, self.name, ignore_exception=False)
         except KubeException:
             self.log(
@@ -357,7 +377,7 @@ def _refresh_to_k8s(self, rules, parent_refs):
             "parent_refs": parent_refs,
         }
         try:
-            k8s_route = getattr(self.scheduler(), self.kind.lower())
+            k8s_route = getattr(self.scheduler, self.kind.lower())
             try:
                 data = k8s_route.get(self.app.id, self.name).json()
                 manifest.update({"version": data["metadata"]["resourceVersion"]})
@@ -383,13 +403,13 @@ def _https_enforced_to_k8s(self, parent_refs):
         }
         try:
             try:
-                data = self.scheduler().httproute.get(
+                data = self.scheduler.httproute.get(
                     self.app.id, self._https_redirect_name).json()
                 manifest.update({"version": data["metadata"]["resourceVersion"]})
-                self.scheduler().httproute.patch(
+                self.scheduler.httproute.patch(
                     self.app.id, self._https_redirect_name, **manifest)
             except KubeException:
-                self.scheduler().httproute.create(
+                self.scheduler.httproute.create(
                     self.app.id, self._https_redirect_name, **manifest)
         except KubeException as e:
             raise ServiceUnavailable(
diff --git a/rootfs/api/models/key.py b/rootfs/api/models/key.py
index b94867e59..08e94cdf8 100644
--- a/rootfs/api/models/key.py
+++ b/rootfs/api/models/key.py
@@ -33,6 +33,9 @@ class Meta:
         verbose_name = 'SSH Key'
         unique_together = (('owner', 'fingerprint'))
         ordering = ['public']
+        indexes = [
+            models.Index(fields=['fingerprint']),
+        ]
 
     def __str__(self):
         return "{}...{}".format(self.public[:18], self.public[-31:])
diff --git a/rootfs/api/models/limit.py b/rootfs/api/models/limit.py
index 14119d5ac..a72eda1a4 100644
--- a/rootfs/api/models/limit.py
+++ b/rootfs/api/models/limit.py
@@ -117,11 +117,16 @@ class LimitPlan(AuditedModel):
     node_selector = models.JSONField(default=dict)
     runtime_class_name = models.CharField(max_length=63, default="")
     pod_security_context = models.JSONField(default=dict)
+    pod_volumes = models.JSONField(default=list)
     container_security_context = models.JSONField(default=dict)
+    container_volume_mounts = models.JSONField(default=list)
 
     class Meta:
         get_latest_by = 'created'
         ordering = ['priority']
+        indexes = [
+            models.Index(fields=['spec', 'cpu', 'memory']),
+        ]
 
     def __str__(self):
         return self.name
diff --git a/rootfs/api/models/release.py b/rootfs/api/models/release.py
index fb07a810e..8a9f8c2f8 100644
--- a/rootfs/api/models/release.py
+++ b/rootfs/api/models/release.py
@@ -1,13 +1,12 @@
 import logging
 
-from datetime import datetime
-from django.utils import timezone
+from datetime import datetime, timezone
 from django.conf import settings
 from django.db import models
 from django.db.models import Q
 from django.contrib.auth import get_user_model
 from django.db.models import F, Func, Value, JSONField
-from api.tasks import run_pipeline
+from api.tasks import run_pipeline, send_app_log
 from api.exceptions import DryccException, AlreadyExists
 from scheduler import KubeHTTPException
 from scheduler.resources.pod import DEFAULT_CONTAINER_PORT
@@ -33,7 +32,6 @@ class Release(UuidAuditedModel):
         ("crashed", "Release pipeline runtime crashed"),
         ("succeed", "Release pipeline runtime succeed"),
     )
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     app = models.ForeignKey('App', on_delete=models.CASCADE)
     state = models.TextField(choices=STATE_CHOICES, default=STATE_CHOICES[0][0])
     version = models.PositiveIntegerField()
@@ -50,6 +48,9 @@ class Meta:
         get_latest_by = 'created'
         ordering = ['-created']
         unique_together = (('app', 'version'),)
+        indexes = [
+            models.Index(fields=['app', 'failed', 'state']),
+        ]
 
     def __str__(self):
         return "{0}-{1}".format(self.app.id, self.version_name)
@@ -130,6 +131,7 @@ def log(self, message, level=logging.INFO):
         as "belonging" to the application instead of the controller and will be handled
         accordingly.
         """
+        send_app_log.delay(self.app.id, message, level)
         logger.log(level, "[{}]: {}".format(self.app.id, message))
 
     def new(self, user, config, build, summary=None):
@@ -143,8 +145,7 @@ def new(self, user, config, build, summary=None):
         new_version = self.app.release_set.latest().version + 1
         # create new release and auto-increment version
         return Release.objects.create(
-            owner=user, app=self.app, config=config,
-            build=build, version=new_version, summary=summary
+            app=self.app, config=config, build=build, version=new_version, summary=summary
         )
 
     def get_port(self, ptype):
@@ -263,7 +264,7 @@ def rollback(self, user, ptypes=None, version=None):
                 if new_release.summary:
                     new_release.summary += " "
                 new_release.summary += "{} performed roll back to a release that failed".format(
-                    self.owner)
+                    user)
                 # Get the exception that has occured
                 new_release.exception = "error: {}".format(str(e))
                 # avoid overwriting other fields
@@ -288,7 +289,7 @@ def clean(self, ptypes=None):
             labels['type__in'] = ptypes
         # Cleanup controllers
         replica_sets_removal = []
-        replica_sets = self.scheduler().rs.get(self.app.id, labels=labels).json()['items']
+        replica_sets = self.scheduler.rs.get(self.app.id, labels=labels).json()['items']
         if not replica_sets:
             replica_sets = []
         for replica_set in replica_sets:
@@ -320,11 +321,11 @@ def _cleanup_stray_pods(self, namespace, ptypes, latest_version):
         labels = {'heritage': 'drycc'}
         if ptypes is not None:
             labels['type__in'] = ptypes
-        pods = self.scheduler().pod.get(namespace, labels=labels).json()['items']
+        pods = self.scheduler.pod.get(namespace, labels=labels).json()['items']
         if not pods:
             pods = []
         for pod in pods:
-            if self.scheduler().pod.deleted(pod):
+            if self.scheduler.pod.deleted(pod):
                 continue
 
             current_version = int(pod['metadata']['labels']['version'][1:])
@@ -333,7 +334,7 @@ def _cleanup_stray_pods(self, namespace, ptypes, latest_version):
                 continue
 
             try:
-                self.scheduler().pod.delete(namespace, pod['metadata']['name'])
+                self.scheduler.pod.delete(namespace, pod['metadata']['name'])
             except KubeHTTPException as e:
                 # Sometimes k8s will manage to remove the pod from under us
                 if e.response.status_code == 404:
@@ -352,7 +353,7 @@ def _cleanup_deployment_secrets_and_configs(self, namespace, ptypes=None):
         # Find all ReplicaSets
         version_names = [self.version_name, ]
         labels = {'heritage': 'drycc', 'app': namespace}
-        replicasets = self.scheduler().rs.get(namespace, labels=labels).json()['items']
+        replicasets = self.scheduler.rs.get(namespace, labels=labels).json()['items']
         if not replicasets:
             replicasets = []
         for replicaset in replicasets:
@@ -378,11 +379,11 @@ def _cleanup_deployment_secrets_and_configs(self, namespace, ptypes=None):
         if ptypes is not None:
             labels['type__in'] = ptypes
         self.log('Cleaning up orphaned env var secrets for application {}'.format(namespace), level=logging.DEBUG)  # noqa
-        secrets = self.scheduler().secret.get(namespace, labels=labels).json()['items']
+        secrets = self.scheduler.secret.get(namespace, labels=labels).json()['items']
         if not secrets:
             secrets = []
         for secret in secrets:
-            self.scheduler().secret.delete(namespace, secret['metadata']['name'])
+            self.scheduler.secret.delete(namespace, secret['metadata']['name'])
 
     def _delete_release_in_scheduler(self, namespace, ptypes, version_name):
         """
@@ -398,7 +399,7 @@ def _delete_release_in_scheduler(self, namespace, ptypes, version_name):
         }
         if ptypes is not None:
             labels['type__in'] = ptypes
-        replica_sets = self.scheduler().rs.get(namespace, labels=labels).json()['items']
+        replica_sets = self.scheduler.rs.get(namespace, labels=labels).json()['items']
         if not replica_sets:
             replica_sets = []
         for replica_set in replica_sets:
@@ -409,8 +410,8 @@ def _delete_release_in_scheduler(self, namespace, ptypes, version_name):
             # Deployment takes care of this in the API, RS does not
             # Have the RS scale down pods and delete itself
             try:
-                self.scheduler().rs.scale(namespace, replica_set['metadata']['name'], 0, timeout)
-                self.scheduler().rs.delete(namespace, replica_set['metadata']['name'])
+                self.scheduler.rs.scale(namespace, replica_set['metadata']['name'], 0, timeout)
+                self.scheduler.rs.delete(namespace, replica_set['metadata']['name'])
             except KubeHTTPException as e:
                 if e.response.status_code != 404:
                     raise
@@ -424,12 +425,14 @@ def save(self, *args, **kwargs):  # noqa
             old_config = prev_release.config if prev_release else None
             # if the build changed, log it and who pushed it
             if self.version == 1:
-                self.summary += "{} created initial release".format(self.app.owner)
+                self.summary += "{} created initial release".format(self.app.workspace.name)
             elif self.build != old_build:
                 if self.build.sha:
-                    self.summary += "{} deployed {}".format(self.build.owner, self.build.sha[:7])
+                    self.summary += "{} deployed {}".format(
+                        self.app.workspace.name, self.build.sha[:7])
                 else:
-                    self.summary += "{} deployed {}".format(self.build.owner, self.build.image)
+                    self.summary += "{} deployed {}".format(
+                        self.app.workspace.name, self.build.image)
             elif self.config != old_config:
                 for field, diff in self.config.diff(old_config).items():
                     diff_list = []
@@ -437,11 +440,13 @@ def save(self, *args, **kwargs):  # noqa
                         diff_list.append(f'{diff_type} {field} {", ".join(values)}')
                     if diff_list:
                         changes = ', '.join(diff_list)
-                        self.summary += "{} {}".format(self.config.owner, changes)
+                        self.summary += "{} {}".format(self.app.workspace.name, changes)
             if not self.summary:
                 if self.version == 1:
-                    self.summary = "{} created the initial release".format(self.owner)
+                    self.summary = "{} created the initial release".format(self.app.workspace.name)
                 else:
                     # There were no changes to this release
-                    raise AlreadyExists("{} changed nothing - release stopped".format(self.owner))
+                    raise AlreadyExists(
+                        "{} changed nothing - release stopped".format(self.app.workspace.name)
+                    )
         super(Release, self).save(*args, **kwargs)
diff --git a/rootfs/api/models/resource.py b/rootfs/api/models/resource.py
index 972b2ff56..63ef0b8c2 100644
--- a/rootfs/api/models/resource.py
+++ b/rootfs/api/models/resource.py
@@ -3,7 +3,8 @@
 from django.db import models, transaction
 from django.contrib.auth import get_user_model
 from api.exceptions import DryccException, AlreadyExists, ServiceUnavailable
-from api.utils import validate_label
+from api.tasks import send_app_log
+from api.utils import validate_label, get_scheduler
 from scheduler import KubeException
 from .base import UuidAuditedModel
 
@@ -12,7 +13,6 @@
 
 
 class Resource(UuidAuditedModel):
-    owner = models.ForeignKey(User, 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)
@@ -39,8 +39,8 @@ def save(self, *args, **kwargs):
 
     @classmethod
     def services(cls):
-        services = []
-        for serviceclass in cls.scheduler().svcat.get_serviceclasses().json()["items"]:
+        services, scheduler = [], get_scheduler()
+        for serviceclass in scheduler.svcat.get_serviceclasses().json()["items"]:
             services.append({
                 "id": serviceclass["spec"]["externalID"],
                 "name": serviceclass["spec"]["externalName"],
@@ -51,14 +51,14 @@ def services(cls):
 
     @classmethod
     def plans(cls, serviceclass_name):
-        serviceclass_id = None
+        serviceclass_id, scheduler = None, get_scheduler()
         for service in cls.services():
             if service["name"] == serviceclass_name:
                 serviceclass_id = service["id"]
                 break
         plans = []
         if serviceclass_id is not None:
-            for serviceplan in cls.scheduler().svcat.get_serviceplans().json()["items"]:
+            for serviceplan in scheduler.svcat.get_serviceplans().json()["items"]:
                 if serviceplan["spec"]["clusterServiceClassRef"]["name"] == serviceclass_id:
                     plans.append({
                         "id": serviceplan["spec"]["externalID"],
@@ -74,7 +74,7 @@ def plans(cls, serviceclass_name):
 
     def attach(self, *args, **kwargs):
         try:
-            self.scheduler().svcat.get_instance(self.app.id, self.name)
+            self.scheduler.svcat.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)
@@ -87,7 +87,7 @@ def attach(self, *args, **kwargs):
                     "instance_plan": ":".join(instance[1:]),
                     "parameters": self.options,
                 }
-                self.scheduler().svcat.create_instance(
+                self.scheduler.svcat.create_instance(
                     self.app.id, self.name, **kwargs
                 )
             except KubeException as e:
@@ -108,10 +108,10 @@ def delete(self, *args, **kwargs):
 
     def detach(self, *args, **kwargs):
         try:
-            resp = self.scheduler().svcat.get_instance(
+            resp = self.scheduler.svcat.get_instance(
                 self.app.id, self.name, ignore_exception=True)
             if resp.status_code != 404:
-                self.scheduler().svcat.delete_instance(self.app.id, self.name)
+                self.scheduler.svcat.delete_instance(self.app.id, self.name)
         except KubeException as e:
             raise ServiceUnavailable("Could not delete resource {} for application {}".format(self.name, self.app_id)) from e  # noqa
 
@@ -124,6 +124,7 @@ def log(self, message, level=logging.INFO):
         as "belonging" to the application instead of the controller and will be handled
         accordingly.
         """
+        send_app_log.delay(self.app.id, message, level)
         logger.log(level, "[{}]: {}".format(self.app.id, message))
 
     def bind(self, *args, **kwargs):
@@ -134,14 +135,14 @@ def bind(self, *args, **kwargs):
         self.binding = "Binding"
         self.save()
         try:
-            self.scheduler().svcat.get_binding(self.app.id, self.name)
+            self.scheduler.svcat.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().svcat.create_binding(
+                self.scheduler.svcat.create_binding(
                     self.app.id, self.name, **kwargs)
             except KubeException as e:
                 msg = 'There was a problem binding the resource ' \
@@ -153,8 +154,8 @@ def unbind(self, *args, **kwargs):
             raise DryccException("the resource instance is not binding")
         try:
             # We raise an exception when a resource doesn't exist
-            self.scheduler().svcat.get_binding(self.app.id, self.name)
-            self.scheduler().svcat.delete_binding(self.app.id, self.name)
+            self.scheduler.svcat.get_binding(self.app.id, self.name)
+            self.scheduler.svcat.delete_binding(self.app.id, self.name)
             self.binding = None
             self.data = {}
             self.save()
@@ -163,7 +164,7 @@ def unbind(self, *args, **kwargs):
 
     def attach_update(self, *args, **kwargs):
         try:
-            data = self.scheduler().svcat.get_instance(
+            data = self.scheduler.svcat.get_instance(
                 self.app.id, self.name).json()
         except KubeException as e:
             logger.debug(e)
@@ -177,7 +178,7 @@ def attach_update(self, *args, **kwargs):
                 "parameters": self.options,
                 "external_id": data["spec"]["externalID"]
             }
-            self.scheduler().svcat.patch_instance(
+            self.scheduler.svcat.patch_instance(
                 self.app.id, self.name, version, **kwargs
             )
         except KubeException as e:
@@ -188,7 +189,7 @@ def attach_update(self, *args, **kwargs):
     @property
     def message(self):
         try:
-            resp = self.scheduler().svcat.get_instance(
+            resp = self.scheduler.svcat.get_instance(
                 self.app.id, self.name)
             if resp.status_code != 200:
                 message = ""
@@ -205,10 +206,10 @@ def retrieve(self, *args, **kwargs):
             self.save()
         return self.status == self.binding == "Ready"
 
-    def to_measurements(self, timestamp: float):
+    def to_usages(self, timestamp: float):
         return [{
             "app_id": str(self.app_id),
-            "owner": self.owner_id,
+            "workspace": self.app.workspace_id,
             "name": self.plan,
             "type": "resource",
             "unit": "number",
@@ -216,13 +217,14 @@ def to_measurements(self, timestamp: float):
             "kwargs": {
                 "name": self.name,
             },
-            "timestamp": int(timestamp)
+            "timestamp": int(timestamp),
+            "identifier": self.uuid.hex,
         }]
 
     def _retrieve_status(self):
         changed = False
         try:
-            response = self.scheduler().svcat.get_instance(
+            response = self.scheduler.svcat.get_instance(
                 self.app.id, self.name).json()
             status = response.get('status', {}).get('lastConditionState')
             options = response.get('spec', {}).get('parameters', {})
@@ -240,14 +242,14 @@ def _retrieve_binding(self):
         changed = False
         try:
             # We raise an exception when a resource doesn't exist
-            response = self.scheduler().svcat.get_binding(self.app.id, self.name).json()
+            response = self.scheduler.svcat.get_binding(self.app.id, self.name).json()
             binding = response.get('status', {}).get('lastConditionState')
             secret_name = response.get('spec', {}).get('secretName')
             if self.binding != binding:
                 self.binding = binding
                 changed = True
             if secret_name:
-                response = self.scheduler().secret.get(self.app.id, secret_name).json()
+                response = self.scheduler.secret.get(self.app.id, secret_name).json()
                 data = response.get('data', {})
                 if self.data != data:
                     self.data = data
diff --git a/rootfs/api/models/service.py b/rootfs/api/models/service.py
index d2958e8df..6b1c2711f 100644
--- a/rootfs/api/models/service.py
+++ b/rootfs/api/models/service.py
@@ -1,8 +1,8 @@
 import logging
 from functools import partial
 from django.db import models
-from django.conf import settings
 from django.contrib.auth import get_user_model
+from api.tasks import send_app_log
 from api.utils import validate_json
 from api.exceptions import ServiceUnavailable
 from scheduler import KubeException
@@ -29,7 +29,6 @@
 
 
 class Service(AuditedModel):
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     app = models.ForeignKey('App', on_delete=models.CASCADE)
     ports = models.JSONField(
         default=list, validators=[partial(validate_json, schema=service_ports_schema)])
@@ -53,9 +52,7 @@ def name(self):
 
     @property
     def domain(self):
-        return "{}.{}.svc.{}".format(
-            self.name, self.namespace, settings.KUBERNETES_CLUSTER_DOMAIN
-        )
+        return "{}.{}.svc".format(self.name, self.namespace)
 
     @property
     def namespace(self):
@@ -134,20 +131,21 @@ def log(self, message, level=logging.INFO):
         as "belonging" to the application instead of the controller and will be handled
         accordingly.
         """
+        send_app_log.delay(self.app.id, message, level)
         logger.log(level, "[{}]: {}".format(self.app.id, message))
 
     def refresh_k8s_svc(self):
         self.log('creating service: {}'.format(self.name), level=logging.DEBUG)
         try:
             try:
-                data = self.scheduler().svc.get(self.namespace, self.name).json()
-                self.scheduler().svc.patch(self.namespace, self.name, **{
+                data = self.scheduler.svc.get(self.namespace, self.name).json()
+                self.scheduler.svc.patch(self.namespace, self.name, **{
                     "ports": self.ports,
                     "version": data["metadata"]["resourceVersion"],
                     "ptype": self.ptype,
                 })
             except KubeException:
-                self.scheduler().svc.create(self.namespace, self.name, **{
+                self.scheduler.svc.create(self.namespace, self.name, **{
                     "ports": self.ports,
                     "ptype": self.ptype,
                 })
@@ -156,4 +154,4 @@ def refresh_k8s_svc(self):
 
     def _delete_k8s_svc(self, svc_name):
         self.log('deleting Service: {}'.format(svc_name), level=logging.DEBUG)
-        self.scheduler().svc.delete(self.namespace, svc_name, ignore_exception=True)
+        self.scheduler.svc.delete(self.namespace, svc_name, ignore_exception=True)
diff --git a/rootfs/api/models/tls.py b/rootfs/api/models/tls.py
index 828269f69..b6a5b9262 100644
--- a/rootfs/api/models/tls.py
+++ b/rootfs/api/models/tls.py
@@ -3,6 +3,7 @@
 from django.db import models
 from django.db import transaction
 from django.contrib.auth import get_user_model
+from api.tasks import send_app_log
 from api.exceptions import AlreadyExists
 from api.exceptions import ServiceUnavailable
 from scheduler import KubeException
@@ -23,7 +24,6 @@ def default_issuer():
 
 
 class TLS(UuidAuditedModel):
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     app = models.ForeignKey('App', on_delete=models.CASCADE)
     issuer = models.JSONField(default=default_issuer)
     https_enforced = models.BooleanField(null=True)
@@ -38,6 +38,7 @@ def log(self, message, level=logging.INFO):
         as "belonging" to the application instead of the controller and will be handled
         accordingly.
         """
+        send_app_log.delay(self.app.id, message, level)
         logger.log(level, "[{}]: {}".format(self.app.id, message))
 
     @property
@@ -54,16 +55,16 @@ def to_result(name, kind, condition):
 
         results = []
         name = namespace = self.app.id
-        response = self.scheduler().issuer.get(namespace, name, ignore_exception=True)
+        response = self.scheduler.issuer.get(namespace, name, ignore_exception=True)
         if response.status_code == 200:
             for condition in response.json()["status"]["conditions"]:
                 results.append(to_result(name, "Issuer", condition))
         name = f"{self.app.id}-auto-tls"
-        response = self.scheduler().certificate.get(namespace, name, ignore_exception=True)
+        response = self.scheduler.certificate.get(namespace, name, ignore_exception=True)
         if response.status_code == 200:
             for condition in response.json()["status"]["conditions"]:
                 results.append(to_result(name, "Certificate", condition))
-        response = self.scheduler().certificaterequest.get(namespace, ignore_exception=True)
+        response = self.scheduler.certificaterequest.get(namespace, ignore_exception=True)
         if response.status_code == 200:
             for item in response.json()["items"]:
                 for condition in item["status"]["conditions"]:
@@ -86,12 +87,12 @@ def refresh_issuer_to_k8s(self):
                 for gateway in self.app.gateway_set.all()
             ]
             try:
-                version = self.scheduler().issuer.get(
+                version = self.scheduler.issuer.get(
                     namespace, name, ignore_exception=False).json()["metadata"]["resourceVersion"]
                 data.update({"version": version})
-                self.scheduler().issuer.put(namespace, name, **data)
+                self.scheduler.issuer.put(namespace, name, **data)
             except KubeException:
-                self.scheduler().issuer.create(namespace, name, **data)
+                self.scheduler.issuer.create(namespace, name, **data)
         except KubeException as e:
             raise ServiceUnavailable('Kubernetes issuer could not be created') from e
 
@@ -100,19 +101,19 @@ def refresh_certificate_to_k8s(self):
         if self.certs_auto_enabled:
             hosts = [domain.domain for domain in self.app.domain_set.all()]
             if len(hosts) > 0:
-                response = self.scheduler().certificate.get(namespace, name)
+                response = self.scheduler.certificate.get(namespace, name)
                 if response.status_code == 200:
                     data = response.json()
                     version = data["metadata"]["resourceVersion"]
-                    self.scheduler().certificate.put(namespace, name, hosts, version)
+                    self.scheduler.certificate.put(namespace, name, hosts, version)
                 else:
                     self.log(
                         "certificate {} does not exist".format(namespace), level=logging.INFO)
-                    self.scheduler().certificate.create(namespace, name, hosts)
+                    self.scheduler.certificate.create(namespace, name, hosts)
             else:
                 self.log("skip creating certificate, no domain name set", logging.WARNING)
         else:
-            self.scheduler().certificate.delete(namespace, name, ignore_exception=True)
+            self.scheduler.certificate.delete(namespace, name, ignore_exception=True)
 
     @transaction.atomic
     def save(self, *args, **kwargs):
@@ -132,12 +133,12 @@ def _check_previous_tls_settings(self):
             if self.https_enforced is not None:
                 if previous_tls_settings.https_enforced == self.https_enforced:
                     raise AlreadyExists(
-                        "{} changed nothing".format(self.owner))
+                        "{} changed nothing".format(self.app.workspace.name))
                 self.certs_auto_enabled = previous_tls_settings.certs_auto_enabled
             elif self.certs_auto_enabled is not None:
                 if previous_tls_settings.certs_auto_enabled == self.certs_auto_enabled:
                     raise AlreadyExists(
-                        "{} changed nothing".format(self.owner))
+                        "{} changed nothing".format(self.app.workspace.name))
                 self.https_enforced = previous_tls_settings.https_enforced
             previous_tls_settings.delete()
         except TLS.DoesNotExist:
@@ -147,13 +148,13 @@ def _refresh_secret_to_k8s(self):
         secret_name = f"{self.app.id}-acme-external-account-binding-secret"
         try:
             try:
-                data = self.scheduler().secret.get(self.app.id, secret_name).json()
-                self.scheduler().secret.patch(self.app.id, secret_name, {
+                data = self.scheduler.secret.get(self.app.id, secret_name).json()
+                self.scheduler.secret.patch(self.app.id, secret_name, {
                     "secret": self.issuer["key_secret"],
                     "version": data["metadata"]["resourceVersion"],
                 })
             except KubeException:
-                self.scheduler().secret.create(self.app.id, secret_name, {
+                self.scheduler.secret.create(self.app.id, secret_name, {
                     "secret": self.issuer["key_secret"],
                 })
         except KubeException as e:
diff --git a/rootfs/api/models/volume.py b/rootfs/api/models/volume.py
index 6821fb037..01a41b54c 100644
--- a/rootfs/api/models/volume.py
+++ b/rootfs/api/models/volume.py
@@ -1,12 +1,13 @@
 import logging
-import string
 import copy
 import json
 import uuid
 import hashlib
 from django.db import models, transaction
 from django.conf import settings
+from django.template import Template, Context
 from django.contrib.auth import get_user_model
+from api.tasks import send_app_log
 from api.utils import unit_to_bytes, validate_label
 from api.exceptions import DryccException, ServiceUnavailable
 from scheduler import KubeException
@@ -22,7 +23,6 @@ class Volume(UuidAuditedModel):
         ("nfs", "network file system"),
         ("oss", "object storage service file"),
     )
-    owner = models.ForeignKey(User, on_delete=models.PROTECT)
     app = models.ForeignKey('App', on_delete=models.CASCADE)
     name = models.CharField(max_length=63, validators=[validate_label])
     size = models.CharField(default='0G', max_length=128)
@@ -43,14 +43,8 @@ def pv_name(self):
             hexdigest[20:]
         ]))
 
-    @property
-    def secret_name(self):
-        return self.pv_name
-
     @transaction.atomic
     def save(self, *args, **kwargs):
-        if self.type not in settings.DRYCC_VOLUME_CLAIM_TEMPLATE:
-            raise DryccException(f'Volume type {self.type} is not supported.')
         # Attach volume, updates k8s
         self.save_to_k8s()
         # check path
@@ -74,12 +68,13 @@ def log(self, message, level=logging.INFO):
         as "belonging" to the application instead of the controller and will be handled
         accordingly.
         """
+        send_app_log.delay(self.app.id, message, level)
         logger.log(level, "[{}]: {}".format(self.app.id, message))
 
-    def to_measurements(self, timestamp: float):
+    def to_usages(self, timestamp: float):
         return [{
             "app_id": str(self.app_id),
-            "owner": self.owner_id,
+            "workspace": self.app.workspace_id,
             "name": self.type,
             "type": "volume",
             "unit": "bytes",
@@ -110,15 +105,11 @@ def check_path(self, path=None):
             raise DryccException(msg)
 
     def save_to_k8s(self):
-        if self.type in settings.DRYCC_SECRET_TEMPLATE:
-            self._save_secret()
         if self.type in settings.DRYCC_VOLUME_TEMPLATE:
             self._save_pv()
         self._save_pvc()
 
     def delete_from_k8s(self):
-        if self.type in settings.DRYCC_SECRET_TEMPLATE:
-            self._delete_secret()
         if self.type in settings.DRYCC_VOLUME_TEMPLATE:
             self._delete_pv()
         self._delete_pvc()
@@ -136,24 +127,26 @@ def _format_size(size):
     def _save_pv(self):
         kwds = copy.deepcopy(self.parameters.get(self.type, {}))
         kwds.update({
+            "type": self.type,
             "volume_claim_name": self.name,
             "namespace": self.app.id,
-            "secret_name": self.secret_name,
             "volume_handle": "%s" % uuid.uuid4(),
         })
-        t = string.Template(json.dumps(settings.DRYCC_VOLUME_TEMPLATE.get(self.type)))
-        kwargs = json.loads(t.safe_substitute(**kwds))
+        json_str = Template(settings.DRYCC_VOLUME_TEMPLATE).render(Context(kwds)).strip()
+        if not json_str:
+            return
+        kwargs = json.loads(json_str)
         try:
-            self.scheduler().pv.get(self.pv_name)
+            self.scheduler.pv.get(self.pv_name)
             msg = "Volume {} already exists".format(self.pv_name)
             self.log(msg, logging.INFO)
             if "csi" in kwargs["spec"]:
                 del kwargs["spec"]["csi"]  # Fix: 422 unprocessable Entity
-            self.scheduler().pv.patch(self.pv_name, **kwargs)
+            self.scheduler.pv.patch(self.pv_name, **kwargs)
         except KubeException as e:
             logger.info(e)
             try:
-                self.scheduler().pv.create(self.pv_name, **kwargs)
+                self.scheduler.pv.create(self.pv_name, **kwargs)
             except KubeException as e:
                 msg = 'There was a problem creating the volume ' \
                       '{} for {}'.format(self.pv_name, self.app_id)
@@ -162,29 +155,32 @@ def _save_pv(self):
     def _delete_pv(self):
         try:
             # We raise an exception when a volume doesn't exist
-            self.scheduler().pv.get(self.pv_name)
-            self.scheduler().pv.delete(self.pv_name)
+            self.scheduler.pv.get(self.pv_name)
+            self.scheduler.pv.delete(self.pv_name)
         except KubeException as e:
             logger.exception(e)
 
     def _save_pvc(self):
-        kwds = copy.deepcopy(self.parameters)
+        kwds = copy.deepcopy(self.parameters.get(self.type, {}))
         kwds.update({
+            "type": self.type,
             "size": self._format_size(self.size),
             "volume_name": self.pv_name,
             "storage_class": settings.DRYCC_APP_STORAGE_CLASS,
         })
-        t = string.Template(json.dumps(settings.DRYCC_VOLUME_CLAIM_TEMPLATE.get(self.type)))
-        kwargs = json.loads(t.safe_substitute(**kwds))
+        json_str = Template(settings.DRYCC_VOLUME_CLAIM_TEMPLATE).render(Context(kwds)).strip()
+        if not json_str:
+            raise DryccException('Volume claim template render error, context: {}'.format(kwds))
+        kwargs = json.loads(json_str)
         try:
-            self.scheduler().pvc.get(self.app.id, self.name)
+            self.scheduler.pvc.get(self.app.id, self.name)
             msg = "Volume claim {} already exists in this namespace".format(self.name)
             self.log(msg, logging.INFO)
-            self.scheduler().pvc.patch(self.app.id, self.name, **kwargs)
+            self.scheduler.pvc.patch(self.app.id, self.name, **kwargs)
         except KubeException as e:
             logger.info(e)
             try:
-                self.scheduler().pvc.create(self.app.id, self.name, **kwargs)
+                self.scheduler.pvc.create(self.app.id, self.name, **kwargs)
             except KubeException as e:
                 msg = 'There was a problem creating the volume claim ' \
                       '{} for {}'.format(self.name, self.app_id)
@@ -193,34 +189,8 @@ def _save_pvc(self):
     def _delete_pvc(self):
         try:
             # We raise an exception when a volume doesn't exist
-            self.scheduler().pvc.get(self.app.id, self.name)
-            self.scheduler().pvc.delete(self.app.id, self.name)
-        except KubeException as e:
-            logger.exception(e)
-
-    def _save_secret(self):
-        kwds = copy.deepcopy(self.parameters.get(self.type, {}))
-        t = string.Template(json.dumps(settings.DRYCC_SECRET_TEMPLATE.get(self.type)))
-        kwargs = json.loads(t.safe_substitute(**kwds))
-        try:
-            self.scheduler().secret.get(self.app.id, self.secret_name)
-            msg = "Secret {} already exists".format(self.secret_name)
-            self.log(msg, logging.INFO)
-            self.scheduler().secret.patch(self.app.id, self.secret_name, **kwargs)
-        except KubeException as e:
-            logger.info(e)
-            try:
-                self.scheduler().secret.create(self.app.id, self.secret_name, **kwargs)
-            except KubeException as e:
-                msg = 'There was a problem creating the volume ' \
-                      '{} for {}'.format(self.secret_name, self.app_id)
-                raise ServiceUnavailable(msg) from e
-
-    def _delete_secret(self):
-        try:
-            # We raise an exception when a volume doesn't exist
-            self.scheduler().secret.get(self.app.id, self.secret_name)
-            self.scheduler().secret.delete(self.app.id, self.secret_name)
+            self.scheduler.pvc.get(self.app.id, self.name)
+            self.scheduler.pvc.delete(self.app.id, self.name)
         except KubeException as e:
             logger.exception(e)
 
diff --git a/rootfs/api/models/workspace.py b/rootfs/api/models/workspace.py
new file mode 100644
index 000000000..7e5b6a689
--- /dev/null
+++ b/rootfs/api/models/workspace.py
@@ -0,0 +1,108 @@
+import re
+import logging
+from django.db import models
+from django.core.mail import send_mail
+from django.utils.translation import gettext_lazy as _
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+from django.template.loader import render_to_string
+
+from rest_framework.exceptions import ValidationError
+from api.utils import validate_reserved_names, get_local_host
+
+User = get_user_model()
+logger = logging.getLogger(__name__)
+
+
+def validate_workspace_name(value):
+    """
+    Check that the value follows the kubernetes name constraints
+    """
+    match = re.match(r'^[0-9a-z]{5,}$', value)
+    if not match:
+        raise ValidationError("App name must start with an alphabetic character, cannot end with a"
+                              + " hyphen and can only contain a-z (lowercase), 0-9 and hyphens.")
+    validate_reserved_names(value)
+
+
+class Workspace(models.Model):
+    name = models.SlugField(
+        _("workspace name"),
+        max_length=150,
+        unique=True,
+        validators=[validate_workspace_name],
+    )
+    email = models.EmailField(_("email address"))
+    created = models.DateTimeField(auto_now_add=True)
+    updated = models.DateTimeField(auto_now=True)
+
+    def has_member(self, user, role=None):
+        kwargs = {'user': user, 'workspace': self}
+        if role:
+            kwargs['role'] = role
+        return WorkspaceMember.objects.filter(**kwargs).exists()
+
+    def __str__(self):
+        return self.name
+
+
+class WorkspaceMember(models.Model):
+    role_choices = [
+        ('admin', 'Admin'),
+        ('member', 'Member'),
+        ('viewer', 'Viewer'),
+    ]
+
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+    role = models.CharField(max_length=50, choices=role_choices)
+    alerts = models.BooleanField(default=True)
+    created = models.DateTimeField(auto_now_add=True)
+    updated = models.DateTimeField(auto_now=True)
+    workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE)
+
+    def __str__(self):
+        return f"{self.user.username} - {self.workspace.name} ({self.role})"
+
+    class Meta:
+        unique_together = ('user', 'workspace')
+
+
+class WorkspaceInvitation(models.Model):
+    email = models.EmailField(_("email address"))
+    token = models.CharField(max_length=128, unique=True)
+    inviter = models.ForeignKey(User, on_delete=models.CASCADE)
+    created = models.DateTimeField(auto_now_add=True)
+    accepted = models.BooleanField(_("accepted"), default=False)
+    workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE)
+
+    class Meta:
+        unique_together = ('email', 'workspace')
+
+    def accept(self):
+        if not self.accepted:
+            self.accepted = True
+            self.save()
+        user = User.objects.filter(email=self.email).first()
+        if not user:
+            return
+        WorkspaceMember.objects.get_or_create(
+            user=user, workspace=self.workspace, defaults={'role': 'member'})
+
+    def send_email(self, request):
+        cache_key = f"invitation:email:{self.email}"
+        cache.add(cache_key, 0, timeout=settings.DRYCC_INVITATION_EMAIL_TIMEOUT)
+        count = cache.incr(cache_key)
+        if count > settings.DRYCC_INVITATION_EMAIL_LIMIT:
+            raise ValidationError("Too many invitation emails, please try again later")
+        domain = get_local_host(request)
+        mail_subject = f'We Invite You to Join the {self.workspace.name} Workspace.'
+        message = render_to_string(
+            'workspace/workspace_invitation.html',
+            {'domain': domain, 'invitation': self}
+        )
+        send_mail(mail_subject, message, None, [self.email], fail_silently=True)
+
+    def __str__(self):
+        return f"Invitation for {self.email} to join {self.workspace.name}"
diff --git a/rootfs/api/monitor.py b/rootfs/api/monitor.py
index 2221d9e61..df8a1f79e 100644
--- a/rootfs/api/monitor.py
+++ b/rootfs/api/monitor.py
@@ -1,47 +1,13 @@
 import time
 import aiohttp
 from typing import Iterator, AsyncGenerator
-from urllib.parse import urljoin
 from django.conf import settings
+from django.template import Template, Context
 
 
-query_last_metrics_promql_tpl = """
-last_over_time({__name__=~"%s",namespace="%s"}[%s])
-"""
-
-query_loadbalancer_promql_tpl = """
-kube_service_status_load_balancer_ingress{namespace=~"%s"}
-"""
-
-query_network_receive_flow_promql_tpl = """
-increase(container_network_receive_bytes_total{namespace=~"%s"}[%s])
-"""
-
-query_network_transmit_flow_promql_tpl = """
-increase(container_network_transmit_bytes_total{namespace=~"%s"}[%s])
-"""
-
-query_cpu_usage_promql_tpl = """
-sum (rate (container_cpu_usage_seconds_total{pod=~"^%s-.*$",namespace="%s"}[%s]))
-by (pod)
-"""
-
-
-query_memory_usage_promql_tpl = """
-sum (avg_over_time (container_memory_working_set_bytes{pod=~"^%s-.*$",namespace="%s"}[%s]))
-by (pod)
-"""
-
-
-query_network_receive_usage_promql_tpl = """
-sum (rate (container_network_receive_bytes_total{pod=~"^%s-.*$",namespace="%s"}[%s]))
-by (pod)
-"""
-
-query_network_transmit_usage_promql_tpl = """
-sum (rate (container_network_transmit_bytes_total{pod=~"^%s-.*$",namespace="%s"}[%s]))
-by (pod)
-"""
+query_last_metrics_promql_tpl = Template("""
+last_over_time({__name__=~"{{metrics}}",namespace="{{namespace}}"}[{{duration}}])
+""")
 
 
 async def query_prom(url, params) -> list[tuple[dict[str, str], int]]:
@@ -58,11 +24,12 @@ async def query_prom(url, params) -> list[tuple[dict[str, str], int]]:
 async def last_metrics(namespace) -> AsyncGenerator[Iterator, str]:
     if not settings.DRYCC_METRICS_CONFIG:
         return
-    url = urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query")
-    promql = query_last_metrics_promql_tpl % (
-      '|'.join(settings.DRYCC_METRICS_CONFIG.keys()),
-      namespace,
-      settings.DRYCC_METRICS_INTERVAL)
+    url = f"{settings.DRYCC_VICTORIAMETRICS_URL}/api/v1/query"
+    promql = query_last_metrics_promql_tpl.render(Context({
+        "metrics": '|'.join(settings.DRYCC_METRICS_CONFIG.keys()),
+        "namespace": namespace,
+        "duration": settings.DRYCC_METRICS_INTERVAL
+    }))
     for item in await query_prom(url, {"query": promql, "start": int(time.time() - 60)}):
         yield '%s{%s} %s\n' % (
             item['metric']['__name__'],
@@ -74,58 +41,22 @@ async def last_metrics(namespace) -> AsyncGenerator[Iterator, str]:
         )
 
 
-async def query_loadbalancer(namespaces: Iterator[str], start: int, stop: int
+async def query_volume_usage(namespaces: Iterator[str], start: int, stop: int
                              ) -> list[tuple[dict[str, str], int]]:
-    url = urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query")
-    promql = query_loadbalancer_promql_tpl % "|".join(namespaces)
+    if not settings.DRYCC_VICTORIAMETRICS_URL:
+        return []
+    url = f"{settings.DRYCC_VICTORIAMETRICS_URL}/api/v1/query"
+    promql = Template(settings.DRYCC_VOLUME_USAGE_TEMPLATE).render(Context({
+        "namespaces": "|".join(namespaces)
+    }))
     return await query_prom(url, {"query": promql, "start": start, "end": stop})
 
 
-async def query_network_receive_flow(namespaces: Iterator[str], start: int, stop: int
-                                     ) -> list[tuple[dict[str, str], int]]:
-    url = urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query")
-    promql = query_network_receive_flow_promql_tpl % ("|".join(namespaces), f"{stop-start}s")
+async def query_network_usage(namespaces: Iterator[str], start: int, stop: int
+                              ) -> list[tuple[dict[str, str], int]]:
+    url = f"{settings.DRYCC_VICTORIAMETRICS_URL}/api/v1/query"
+    promql = Template(settings.DRYCC_NETWORK_USAGE_TEMPLATE).render(Context({
+        "namespaces": "|".join(namespaces),
+        "duration": f"{stop-start}s"
+    }))
     return await query_prom(url, {"query": promql, "start": start, "end": stop})
-
-
-async def query_network_transmit_flow(namespaces: Iterator[str], start: int, stop: int
-                                      ) -> list[tuple[dict[str, str], int]]:
-    url = urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query")
-    promql = query_network_transmit_flow_promql_tpl % ("|".join(namespaces), f"{stop-start}s")
-    return await query_prom(url, {"query": promql, "start": start, "end": stop})
-
-
-async def query_cpu_usage(namespace: str, ptype: str, every: str,
-                          start: int, stop: int, step: int,
-                          ) -> list[tuple[dict[str, str], int]]:
-    url = urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query_range")
-    pod_prefix = "%s-%s" % (namespace, ptype)
-    promql = query_cpu_usage_promql_tpl % (pod_prefix, namespace, every)
-    return await query_prom(url, {"query": promql, "start": start, "end": stop, "step": step})
-
-
-async def query_memory_usage(namespace: str, ptype: str, every: str,
-                             start: int, stop: int, step: int,
-                             ) -> list[tuple[dict[str, str], int]]:
-    url = urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query_range")
-    pod_prefix = "%s-%s" % (namespace, ptype)
-    promql = query_memory_usage_promql_tpl % (pod_prefix, namespace, every)
-    return await query_prom(url, {"query": promql, "start": start, "end": stop, "step": step})
-
-
-async def query_network_receive_usage(namespace: str, ptype: str, every: str,
-                                      start: int, stop: int, step: int,
-                                      ) -> list[tuple[dict[str, str], int]]:
-    url = urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query_range")
-    pod_prefix = "%s-%s" % (namespace, ptype)
-    promql = query_network_receive_usage_promql_tpl % (pod_prefix, namespace, every)
-    return await query_prom(url, {"query": promql, "start": start, "end": stop, "step": step})
-
-
-async def query_network_transmit_usage(namespace: str, ptype: str, every: str,
-                                       start: int, stop: int, step: int,
-                                       ) -> list[tuple[dict[str, str], int]]:
-    url = urljoin(settings.DRYCC_PROMETHEUS_URL, "/api/v1/query_range")
-    pod_prefix = "%s-%s" % (namespace, ptype)
-    promql = query_network_transmit_usage_promql_tpl % (pod_prefix, namespace, every)
-    return await query_prom(url, {"query": promql, "start": start, "end": stop, "step": step})
diff --git a/rootfs/api/permissions.py b/rootfs/api/permissions.py
index 4f19a7213..4bd7f90d1 100644
--- a/rootfs/api/permissions.py
+++ b/rootfs/api/permissions.py
@@ -1,10 +1,9 @@
 import base64
 from rest_framework import permissions
 from django.conf import settings
-from django.contrib.auth.models import AnonymousUser
 from api import manager
-from api.models import app
 from api.models import blocklist
+from api.models.workspace import Workspace, WorkspaceMember
 
 
 def get_app_status(app):
@@ -12,42 +11,12 @@ def get_app_status(app):
     if block:
         return False, block.remark
     if settings.WORKFLOW_MANAGER_URL:
-        status = manager.User().get_status(app.owner.pk)
+        status = manager.WorkspaceAPI().get_status(app.workspace_id)
         if not status["is_active"]:
             return False, status["message"]
     return True, None
 
 
-def has_app_permission(user, obj, method):
-    obj = getattr(obj, 'app', obj)
-    has_permission, message = False, f"{obj} object does not exist or does not have permission."
-    if user.is_superuser:
-        has_permission, message = True, None
-    elif getattr(obj, "owner", None) == user:
-        has_permission, message = True, None
-    elif user.is_staff:
-        has_permission, message = True, None
-    else:
-        permission = app.app_permission_registry.get(method)
-        if permission and user.has_perm(permission.codename, obj):
-            has_permission, message = True, None
-    if has_permission and isinstance(obj, app.App):
-        return get_app_status(obj)
-    return has_permission, message
-
-
-class IsAnonymous(permissions.BasePermission):
-    """
-    View permission to allow anonymous users.
-    """
-
-    def has_permission(self, request, view):
-        """
-        Return `True` if permission is granted, `False` otherwise.
-        """
-        return type(request.user) is AnonymousUser
-
-
 class IsOwner(permissions.BasePermission):
     """
     Object-level permission to allow only owners of an object to access it.
@@ -61,70 +30,45 @@ def has_object_permission(self, request, view, obj):
             return False
 
 
-class IsOwnerOrAdmin(permissions.BasePermission):
+class IsAppUser(permissions.BasePermission):
     """
-    Object-level permission to allow only owners of an object or administrators to access it.
-    Assumes the model instance has an `owner` attribute.
+    Object-level permission to allow only users who are owners
+    or collaborators of an app to access it.
     """
-    def has_object_permission(self, request, view, obj):
-        if request.user.is_superuser:
-            return True
-        if hasattr(obj, 'owner'):
-            return obj.owner == request.user
-        else:
-            return False
-
 
-class IsObjectUser(permissions.BasePermission):
-    """
-    Object-level permission to allow owners or collaborators to access
-    an app-related model.
-    """
     def has_object_permission(self, request, view, obj):
-        return has_app_permission(request.user, obj, request.method)[0]
-
-
-class IsAdmin(permissions.BasePermission):
-    """
-    View permission to allow only admins.
-    """
-
-    def has_permission(self, request, view):
-        """
-        Return `True` if permission is granted, `False` otherwise.
-        """
-        return request.user.is_superuser
-
-
-class IsAdminOrSafeMethod(permissions.BasePermission):
-    """
-    View permission to allow only admins to use unsafe methods
-    including POST, PUT, DELETE.
-
-    This allows
-    """
-
-    def has_permission(self, request, view):
-        """
-        Return `True` if permission is granted, `False` otherwise.
-        """
-        return request.method in permissions.SAFE_METHODS or request.user.is_superuser
+        if request.user.is_superuser or request.user.is_staff:
+            return True
+        elif getattr(obj, "user", None) == request.user:
+            return True
+        elif isinstance(obj, Workspace) or hasattr(obj, 'workspace'):
+            workspace = obj if isinstance(obj, Workspace) else obj.workspace
+            if request.method in ["GET", "HEAD", "OPTIONS"]:
+                allowed_roles = ["viewer", "member", "admin"]
+            elif request.method in ["POST", "PUT", "PATCH"]:
+                allowed_roles = ["member", "admin"]
+            else:
+                allowed_roles = ["admin"]
+            return WorkspaceMember.objects.filter(
+                workspace=workspace, user=request.user, role__in=allowed_roles,
+            ).exists()
+        return False
 
 
-class HasBuilderAuth(permissions.BasePermission):
+class IsServiceToken(permissions.BasePermission):
     """
-    View permission to allow builder to perform actions
-    with a special HTTP header
+    The service token is used for internal communication between Drycc components,
+    such as the builder and Quickwit.
     """
 
     def has_permission(self, request, view):
         """
         Return `True` if permission is granted, `False` otherwise.
         """
-        auth_header = request.META.get('HTTP_X_DRYCC_BUILDER_AUTH')
+        auth_header = request.META.get('HTTP_X_DRYCC_SERVICE_KEY')
         if not auth_header:
             return False
-        return auth_header == settings.BUILDER_KEY
+        return auth_header == settings.SERVICE_KEY
 
 
 class IsWorkflowManager(permissions.BasePermission):
diff --git a/rootfs/api/pipeline.py b/rootfs/api/pipeline.py
deleted file mode 100644
index 0426ccb71..000000000
--- a/rootfs/api/pipeline.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from api.serializers import UserSerializer
-
-
-def update_or_create(backend, user, response, *args, **kwargs):
-    user, created = UserSerializer.update_or_create(response)
-
-    if not created:
-        return {'is_new': False}
-
-    return {
-        'is_new': True,
-        'user': user
-    }
-
-
-def load_extra_data(backend, details, response, uid, user, *args, **kwargs):
-    social = kwargs.get('social') or \
-             backend.strategy.storage.user.get_social_auth(backend.name, uid)
-    if social:
-        extra_data = backend.extra_data(user, uid, response, details,
-                                        *args, **kwargs)
-        social.set_extra_data(extra_data)
diff --git a/rootfs/api/routing.py b/rootfs/api/routing.py
index b793db1cc..6806b2fa0 100644
--- a/rootfs/api/routing.py
+++ b/rootfs/api/routing.py
@@ -3,14 +3,17 @@
 
 from . import consumers
 
-websocket_urlpatterns = [
+http_urlpatterns = [
     re_path(
-        r'^v2/apps/(?P<id>([\w-]*))/logs/?$',
-        consumers.AppLogsConsumer.as_asgi()),
+        r'^v2/apps/(?P<id>([\w-]*))/volumes/(?P<name>([\w-]*))/filer/(?P<path>.*)$',
+        consumers.FilerProxyConsumer.as_asgi()),
+]
+
+websocket_urlpatterns = [
     re_path(
-        r'^v2/apps/(?P<id>([\w-]*))/pods/(?P<pod_id>([\w-]*))/logs/?$',
+        r'^v2/apps/(?P<id>([\w-]*))/pods/(?P<name>([\w-]*))/logs/?$',
         consumers.AppPodLogsConsumer.as_asgi()),
     re_path(
-        r'^v2/apps/(?P<id>([\w-]*))/pods/(?P<pod_id>([\w-]*))/exec/?$',
+        r'^v2/apps/(?P<id>([\w-]*))/pods/(?P<name>([\w-]*))/exec/?$',
         consumers.AppPodExecConsumer.as_asgi()),
 ]
diff --git a/rootfs/api/serializers/__init__.py b/rootfs/api/serializers/__init__.py
index 6aebc2b65..146736d93 100644
--- a/rootfs/api/serializers/__init__.py
+++ b/rootfs/api/serializers/__init__.py
@@ -15,7 +15,7 @@
 
 
 from api import models
-from api.utils import validate_json
+from api.utils import validate_json, validate_reserved_names
 from api.exceptions import DryccException
 from api.models.volume import Volume
 from api.models.base import PTYPE_MIN_LENGTH, PTYPE_MAX_LENGTH
@@ -78,6 +78,7 @@ def validate_ptype(value):
         raise serializers.ValidationError(
             "The length of ptype must be between {} and {}".format(
                 PTYPE_MIN_LENGTH, PTYPE_MAX_LENGTH))
+    validate_reserved_names(value)
     return value
 
 
@@ -91,6 +92,7 @@ def validate_name(value):
         raise serializers.ValidationError(
             "The length of name must be between {} and {}".format(
                 PTYPE_MIN_LENGTH, PTYPE_MAX_LENGTH))
+    validate_reserved_names(value)
     return value
 
 
@@ -146,9 +148,9 @@ class AuthSerializer(serializers.Serializer):
 class UserSerializer(serializers.ModelSerializer):
     class Meta:
         model = User
-        fields = ['email', 'username', 'password', 'first_name', 'last_name', 'is_superuser',
-                  'is_staff', 'groups', 'user_permissions', 'last_login', 'date_joined',
-                  'is_active']
+        fields = ['id', 'email', 'username', 'password', 'first_name', 'last_name',
+                  'is_superuser', 'is_staff', 'groups', 'user_permissions', 'last_login',
+                  'date_joined', 'is_active']
         read_only_fields = ['id', 'is_superuser', 'is_staff', 'groups',
                             'user_permissions', 'last_login', 'date_joined', 'is_active']
         extra_kwargs = {'password': {'write_only': True}}
@@ -199,27 +201,29 @@ class Meta:
 class AppSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.app.App` model."""
 
-    owner = serializers.ReadOnlyField(source='owner.username')
+    workspace = serializers.SlugRelatedField(
+        slug_field='name',
+        queryset=models.workspace.Workspace.objects.all(),
+    )
     structure = serializers.JSONField(required=False)
 
     class Meta:
         """Metadata options for a :class:`AppSerializer`."""
         model = models.app.App
-        fields = ['uuid', 'id', 'owner', 'structure', 'created', 'updated']
+        fields = ['uuid', 'id', 'workspace', 'structure', 'created', 'updated']
 
 
 class BuildSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.build.Build` model."""
 
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
     procfile = serializers.JSONField(required=False)
     dryccfile = serializers.JSONField(required=False)
 
     class Meta:
         """Metadata options for a :class:`BuildSerializer`."""
         model = models.build.Build
-        fields = ['owner', 'app', 'image', 'stack', 'sha', 'procfile', 'dryccfile',
+        fields = ['app', 'image', 'stack', 'sha', 'procfile', 'dryccfile',
                   'dockerfile', 'created', 'updated', 'uuid']
 
     @staticmethod
@@ -275,11 +279,9 @@ class ConfigSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.config.Config` model."""
 
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
     values = JSONFieldSerializer(required=False, binary=True)
     limits = JSONFieldSerializer(required=False, binary=True)
-    lifecycle_post_start = JSONFieldSerializer(required=False, binary=True)
-    lifecycle_pre_stop = JSONFieldSerializer(required=False, binary=True)
+    lifecycle = JSONFieldSerializer(convert_to_str=False, required=False, binary=True)
     tags = JSONFieldSerializer(required=False, binary=True)
     registry = JSONFieldSerializer(required=False, binary=True)
     healthcheck = JSONFieldSerializer(convert_to_str=False, required=False, binary=True)
@@ -418,7 +420,6 @@ class ReleaseSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.release.Release` model."""
 
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
 
     class Meta:
         """Metadata options for a :class:`ReleaseSerializer`."""
@@ -441,12 +442,11 @@ class DomainSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.domain.Domain` model."""
 
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
 
     class Meta:
         """Metadata options for a :class:`DomainSerializer`."""
         model = models.domain.Domain
-        fields = ['owner', 'created', 'updated', 'app', 'domain', 'ptype']
+        fields = ['created', 'updated', 'app', 'domain', 'ptype']
         read_only_fields = ['uuid']
 
     @staticmethod
@@ -503,7 +503,6 @@ class ServiceSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.service.Service` model."""
 
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
     port = serializers.IntegerField(required=True)
     protocol = serializers.CharField(required=True)
     target_port = serializers.IntegerField(default=DEFAULT_CONTAINER_PORT)
@@ -512,7 +511,7 @@ class ServiceSerializer(serializers.ModelSerializer):
     class Meta:
         """Metadata options for a :class:`ServiceSerializer`."""
         model = models.service.Service
-        fields = ['owner', 'created', 'updated', 'app', 'ptype']
+        fields = ['created', 'updated', 'app', 'ptype']
         read_only_fields = ['uuid']
 
     @staticmethod
@@ -535,7 +534,6 @@ class CertificateSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.certificate.Certificate` model."""
 
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
     domains = serializers.ReadOnlyField()
     san = serializers.ListField(
         child=serializers.CharField(allow_blank=True, allow_null=True, required=False),
@@ -591,7 +589,6 @@ class AppSettingsSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.appsettings.AppSettings` model."""
 
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
     autoscale = JSONFieldSerializer(convert_to_str=False, required=False, binary=True)
     label = JSONFieldSerializer(convert_to_str=False, required=False, binary=True)
 
@@ -614,7 +611,6 @@ class TLSSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.tls.TLS` model."""
 
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
     events = serializers.ReadOnlyField()
 
     class Meta:
@@ -627,7 +623,6 @@ class VolumeSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.volume.Volume` model."""
 
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
     path = JSONFieldSerializer(required=False, binary=True)
     parameters = serializers.JSONField(required=False)
 
@@ -686,7 +681,6 @@ def validate_parameters(data):
 class ResourceSerializer(serializers.ModelSerializer):
     """Serialize a :class:`~api.models.resource.Resource` model."""
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.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)
     data = JSONFieldSerializer(required=False, binary=True)
@@ -742,7 +736,6 @@ def validate(self, attrs):
 
 class GatewaySerializer(serializers.Serializer):
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
     name = serializers.CharField(max_length=63, required=True)
     listeners = serializers.JSONField(required=False)
     addresses = serializers.JSONField(read_only=True)
@@ -766,7 +759,6 @@ def validate_protocol(value):
 
 class RouteSerializer(serializers.ModelSerializer):
     app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
-    owner = serializers.ReadOnlyField(source='owner.username')
     kind = serializers.CharField(max_length=15, required=True)
     name = serializers.CharField(max_length=63, required=True)
     rules = serializers.JSONField(required=False)
@@ -792,3 +784,43 @@ def validate_rules(self, value):
         else:
             schema = rules.SCHEMA
         return validate_json(value, schema, serializers.ValidationError)
+
+
+class WorkspaceSerializer(serializers.ModelSerializer):
+    """Serialize Workspace model."""
+
+    class Meta:
+        model = models.workspace.Workspace
+        fields = '__all__'
+        read_only_fields = ['id', 'created', 'updated']
+        extra_kwargs = {
+            'name': {'validators': [models.workspace.validate_workspace_name]},
+        }
+
+    def update(self, instance, validated_data):
+        # Workspace name cannot be modified; the name field is read-only after creation.
+        validated_data.pop('name', None)
+        return super().update(instance, validated_data)
+
+
+class WorkspaceMemberSerializer(serializers.ModelSerializer):
+    """Serialize WorkspaceMember model."""
+    user = serializers.ReadOnlyField(source='user.username')
+    email = serializers.ReadOnlyField(source='user.email')
+    workspace = serializers.ReadOnlyField(source='workspace.name')
+
+    class Meta:
+        model = models.workspace.WorkspaceMember
+        fields = '__all__'
+        read_only_fields = ['id', 'created', 'updated']
+
+
+class WorkspaceInvitationSerializer(serializers.ModelSerializer):
+    """Serialize WorkspaceInvitation model."""
+    inviter = serializers.ReadOnlyField(source='inviter.username')
+    workspace = serializers.ReadOnlyField(source='workspace.name')
+
+    class Meta:
+        model = models.workspace.WorkspaceInvitation
+        fields = '__all__'
+        read_only_fields = ['id', 'token', 'created']
diff --git a/rootfs/api/serializers/schemas/healthcheck.py b/rootfs/api/serializers/schemas/healthcheck.py
index 8c6aa2d07..c4cc604ca 100644
--- a/rootfs/api/serializers/schemas/healthcheck.py
+++ b/rootfs/api/serializers/schemas/healthcheck.py
@@ -3,8 +3,6 @@
 
     "type": "object",
     "properties": {
-        # Exec specifies the action to take.
-        # More info: http://kubernetes.io/docs/api-reference/v1/definitions/#_v1_execaction
         "exec": {
             "type": "object",
             "properties": {
@@ -16,8 +14,14 @@
             },
             "required": ["command"]
         },
-        # HTTPGet specifies the http request to perform.
-        # More info: http://kubernetes.io/docs/api-reference/v1/definitions/#_v1_httpgetaction
+        "grpc": {
+            "type": "object",
+            "properties": {
+                "port": {"type": "integer"},
+                "service": {"type": "string"},
+            },
+            "required": ["port"]
+        },
         "httpGet": {
             "type": "object",
             "properties": {
@@ -39,8 +43,6 @@
             },
             "required": ["port"]
         },
-        # TCPSocket specifies an action involving a TCP port.
-        # More info: http://kubernetes.io/docs/api-reference/v1/definitions/#_v1_tcpsocketaction
         "tcpSocket": {
             "type": "object",
             "properties": {
@@ -48,19 +50,16 @@
             },
             "required": ["port"]
         },
-        # Number of seconds after the container has started before liveness probes are initiated.
-        # More info: http://releases.k8s.io/HEAD/docs/user-guide/pod-states.md#container-probes
         "initialDelaySeconds": {"type": "integer"},
-        # Number of seconds after which the probe times out.
-        # More info: http://releases.k8s.io/HEAD/docs/user-guide/pod-states.md#container-probes
         "timeoutSeconds": {"type": "integer"},
-        # How often (in seconds) to perform the probe.
         "periodSeconds": {"type": "integer"},
-        # Minimum consecutive successes for the probe to be considered successful
-        # after having failed.
         "successThreshold": {"type": "integer"},
-        # Minimum consecutive failures for the probe to be considered
-        # failed after having succeeded.
         "failureThreshold": {"type": "integer"},
-    }
+    },
+    "oneOf": [
+        {"required": ["exec"]},
+        {"required": ["grpc"]},
+        {"required": ["httpGet"]},
+        {"required": ["tcpSocket"]},
+    ]
 }
diff --git a/rootfs/api/settings/celery.py b/rootfs/api/settings/celery.py
index 9485d238a..4a9183d94 100644
--- a/rootfs/api/settings/celery.py
+++ b/rootfs/api/settings/celery.py
@@ -34,7 +34,7 @@ class Config(object):
 app.conf.update(
     timezone=os.environ.get('TZ', 'UTC'),
     task_routes={
-        'api.tasks.send_measurements': {
+        'api.tasks.send_usage': {
             'queue': 'controller.low',
             'exchange': 'controller.priority', 'routing_key': 'controller.priority.low',
         },
@@ -54,9 +54,9 @@ class Config(object):
             'queue': 'controller.high',
             'exchange': 'controller.priority', 'routing_key': 'controller.priority.high',
         },
-        'api.tasks.downstream_model_owner': {
-            'queue': 'controller.high',
-            'exchange': 'controller.priority', 'routing_key': 'controller.priority.high',
+        'api.tasks.send_app_log': {
+            'queue': 'controller.middle',
+            'exchange': 'controller.priority', 'routing_key': 'controller.priority.middle',
         },
     },
     task_queues=(
diff --git a/rootfs/api/settings/production.py b/rootfs/api/settings/production.py
index a05c59142..7f2d43b88 100644
--- a/rootfs/api/settings/production.py
+++ b/rootfs/api/settings/production.py
@@ -4,10 +4,16 @@
 import sys
 import uuid
 import json
+import random
+import string
 import os.path
-import tempfile
 import dj_database_url
 
+
+def randstr(k):
+    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=k))
+
+
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 sys.path.insert(0, os.path.join(BASE_DIR, 'apps_extra'))
 
@@ -66,6 +72,7 @@
         'BACKEND': 'django.template.backends.django.DjangoTemplates',
         'DIRS': [
             # insert your TEMPLATE_DIRS here
+            os.path.join(BASE_DIR, "templates"),
         ],
         'APP_DIRS': True,
         'OPTIONS': {
@@ -91,7 +98,7 @@
     'django.middleware.common.CommonMiddleware',
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
-    'api.middleware.APIVersionMiddleware',
+    'api.middleware.DjangoAPIVersionMiddleware',
 ]
 
 ROOT_URLCONF = 'drycc.urls'
@@ -107,7 +114,6 @@
     'django.contrib.sessions',
     # Third-party apps
     'corsheaders',
-    'guardian',
     'gunicorn',
     'rest_framework',
     'social_django',
@@ -120,10 +126,8 @@
 
 AUTHENTICATION_BACKENDS = (
     "django.contrib.auth.backends.ModelBackend",
-    "guardian.backends.ObjectPermissionBackend",
+    "api.apps_extra.social_core.backends.DryccOIDC",
 )
-GUARDIAN_GET_INIT_ANONYMOUS_USER = 'api.models.base.get_anonymous_user_instance'
-ANONYMOUS_USER_NAME = os.environ.get('ANONYMOUS_USER_NAME', 'AnonymousUser')
 LOGIN_URL = '/v2/auth/login/'
 
 # Security settings
@@ -172,7 +176,15 @@
     'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
     'PAGE_SIZE': 100,
     'TEST_REQUEST_DEFAULT_FORMAT': 'json',
-    'EXCEPTION_HANDLER': 'api.exceptions.custom_exception_handler'
+    'EXCEPTION_HANDLER': 'api.exceptions.custom_exception_handler',
+    'DEFAULT_THROTTLE_CLASSES': [
+        'rest_framework.throttling.AnonRateThrottle',
+        'rest_framework.throttling.UserRateThrottle'
+    ],
+    'DEFAULT_THROTTLE_RATES': {
+        'anon': os.environ.get('DRYCC_CONTROLLER_THROTTLE_RATES_ANON', '30/min'),
+        'user': os.environ.get('DRYCC_CONTROLLER_THROTTLE_RATES_USER', '60/min'),
+    }
 }
 
 # URLs that end with slashes are ugly
@@ -243,34 +255,32 @@
 }
 TEST_RUNNER = 'api.tests.SilentDjangoTestSuiteRunner'
 
-# default drycc settings
-LOG_LINES = 100
-TEMPDIR = tempfile.mkdtemp(prefix='drycc')
+# drycc controller runner(api, metric, mutate)
+RUNNER = os.environ.get('DRYCC_CONTROLLER_RUNNER', 'api')
 
 # names which apps cannot reserve for routing
-DRYCC_RESERVED_NAMES = os.environ.get('RESERVED_NAMES', '').replace(' ', '').split(',')
+RESERVED_NAME_PATTERNS_PATH = os.environ.get(
+    'RESERVED_NAME_PATTERNS_PATH', '/etc/controller/reserved-name-patterns.txt')
+if os.path.exists(RESERVED_NAME_PATTERNS_PATH):
+    with open(RESERVED_NAME_PATTERNS_PATH) as f:
+        RESERVED_NAME_PATTERNS = [line.strip() for line in f if line.strip()]
+else:
+    RESERVED_NAME_PATTERNS = [r"^drycc(?:-[\w-]+)?$", r"^kube(?:-[\w-]+)?$", r"^default$"]
 
 # the k8s namespace in which the controller and workflow were installed.
 WORKFLOW_NAMESPACE = os.environ.get('WORKFLOW_NAMESPACE', 'drycc')
 
-# kubernetes cluster domain
-KUBERNETES_CLUSTER_DOMAIN = os.environ.get("KUBERNETES_CLUSTER_DOMAIN", "cluster.local")
 # default scheduler settings
 SCHEDULER_MODULE = 'scheduler'
 SCHEDULER_URL = "https://{}:{}".format(
-    # accessing the k8s api server by IP address rather than hostname avoids
-    # intermittent DNS errors
-    os.environ.get(
-        'KUBERNETES_SERVICE_HOST',
-        'kubernetes.default.svc.{}'.format(KUBERNETES_CLUSTER_DOMAIN)
-    ),
-    os.environ.get('KUBERNETES_SERVICE_PORT', '443')
+    os.environ.get('KUBERNETES_SERVICE_HOST', 'kubernetes.default'),
+    os.environ.get('KUBERNETES_SERVICE_PORT', '443'),
 )
 
 K8S_API_VERIFY_TLS = os.environ.get('K8S_API_VERIFY_TLS', 'true').lower() == "true"
 
-# drycc prometheus url
-DRYCC_PROMETHEUS_URL = os.environ.get('DRYCC_PROMETHEUS_URL', '')
+# drycc victoriametrics url
+DRYCC_VICTORIAMETRICS_URL = os.environ.get('DRYCC_VICTORIAMETRICS_URL', '')
 
 # drycc metrics config file
 DRYCC_METRICS_CONFIG = {}
@@ -282,21 +292,13 @@
 DRYCC_METRICS_EXPIRY = int(os.environ.get('DRYCC_METRICS_EXPIRY', '20'))
 DRYCC_METRICS_INTERVAL = os.environ.get('DRYCC_METRICS_INTERVAL', '5m')
 
-# drycc secret template
-DRYCC_SECRET_TEMPLATE = {}
-DRYCC_SECRET_TEMPLATE_PATH = os.environ.get(
-    'DRYCC_SECRET_TEMPLATE_PATH', '/etc/controller/secret-template.json')
-if os.path.exists(DRYCC_SECRET_TEMPLATE_PATH):
-    with open(DRYCC_SECRET_TEMPLATE_PATH) as fd:
-        DRYCC_SECRET_TEMPLATE = json.load(fd)
-
 # drycc volume template
 DRYCC_VOLUME_TEMPLATE = {}
 DRYCC_VOLUME_TEMPLATE_PATH = os.environ.get(
     'DRYCC_VOLUME_TEMPLATE_PATH', '/etc/controller/volume-template.json')
 if os.path.exists(DRYCC_VOLUME_TEMPLATE_PATH):
     with open(DRYCC_VOLUME_TEMPLATE_PATH) as fd:
-        DRYCC_VOLUME_TEMPLATE = json.load(fd)
+        DRYCC_VOLUME_TEMPLATE = fd.read()
 
 # drycc volume claim template
 DRYCC_VOLUME_CLAIM_TEMPLATE = {}
@@ -304,23 +306,37 @@
     'DRYCC_VOLUME_CLAIM_TEMPLATE_PATH', '/etc/controller/volume-claim-template.json')
 if os.path.exists(DRYCC_VOLUME_CLAIM_TEMPLATE_PATH):
     with open(DRYCC_VOLUME_CLAIM_TEMPLATE_PATH) as fd:
-        DRYCC_VOLUME_CLAIM_TEMPLATE = json.load(fd)
-
-# security keys and auth tokens
-random_secret = 'CHANGEME_sapm$s%upvsw5l_zuy_&29rkywd^78ff(qi*#@&*^'
-SECRET_KEY = os.environ.get('DRYCC_SECRET_KEY', random_secret)
-BUILDER_KEY = os.environ.get('DRYCC_BUILDER_KEY', random_secret)
-
-# Drycc admission mutate key
-MUTATE_KEY_PATH = os.environ.get('DRYCC_MUTATE_KEY_PATH', '/etc/controller/mutate/cert/key')
-if os.path.exists(MUTATE_KEY_PATH):
-    with open(MUTATE_KEY_PATH) as f:
-        MUTATE_KEY = f.read()
+        DRYCC_VOLUME_CLAIM_TEMPLATE = fd.read()
+
+# drycc volume usage template
+DRYCC_VOLUME_USAGE_TEMPLATE = ""
+DRYCC_VOLUME_USAGE_TEMPLATE_PATH = os.environ.get(
+    'DRYCC_VOLUME_USAGE_TEMPLATE_PATH', '/etc/controller/volume-usage-template.promql')
+if os.path.exists(DRYCC_VOLUME_USAGE_TEMPLATE_PATH):
+    with open(DRYCC_VOLUME_USAGE_TEMPLATE_PATH) as fd:
+        DRYCC_VOLUME_USAGE_TEMPLATE = fd.read()
+
+# drycc network usage template
+DRYCC_NETWORK_USAGE_TEMPLATE = ""
+DRYCC_NETWORK_USAGE_TEMPLATE_PATH = os.environ.get(
+    'DRYCC_NETWORK_USAGE_TEMPLATE_PATH', '/etc/controller/network-usage-template.promql')
+if os.path.exists(DRYCC_NETWORK_USAGE_TEMPLATE_PATH):
+    with open(DRYCC_NETWORK_USAGE_TEMPLATE_PATH) as fd:
+        DRYCC_NETWORK_USAGE_TEMPLATE = fd.read()
+
+# Django secret key
+SECRET_KEY = os.environ.get('DRYCC_SECRET_KEY', randstr(64))
+
+# Drycc service key
+SERVICE_KEY = os.environ.get('DRYCC_SERVICE_KEY', randstr(64))
+
+# Drycc cert key
+CERT_KEY_PATH = os.environ.get('DRYCC_CERT_KEY_PATH', '/etc/controller/cert/key')
+if os.path.exists(CERT_KEY_PATH):
+    with open(CERT_KEY_PATH) as f:
+        CERT_KEY = f.read()
 else:
-    MUTATE_KEY = None
-
-# gateway class name
-GATEWAY_CLASS = os.environ.get('DRYCC_GATEWAY_CLASS', '')
+    CERT_KEY = None
 
 IMAGE_PULL_POLICY = os.environ.get('IMAGE_PULL_POLICY', "IfNotPresent")
 
@@ -332,7 +348,6 @@
 DRYCC_FILER_IMAGE = os.environ.get('DRYCC_FILER_IMAGE', 'registry.drycc.cc/drycc/filer:canary')
 DRYCC_FILER_IMAGE_PULL_POLICY = os.environ.get('DRYCC_FILER_IMAGE_PULL_POLICY', 'IfNotPresent')
 DRYCC_FILER_DURATION = int(os.environ.get('DRYCC_FILER_DURATION', '3600'))
-DRYCC_FILER_WAITTIME = int(os.environ.get('DRYCC_FILER_WAITTIME', '1200'))
 
 # Define a global default on how many pods to bring up and then
 # take down sequentially during a deploy
@@ -357,6 +372,8 @@
 
 DRYCC_DEPLOY_HOOK_SECRET_KEY = os.environ.get('DRYCC_DEPLOY_HOOK_SECRET_KEY', None)
 
+DRYCC_APP_GATEWAY_CLASS = os.environ.get('DRYCC_APP_GATEWAY_CLASS', "")
+
 DRYCC_APP_STORAGE_CLASS = os.environ.get('DRYCC_APP_STORAGE_CLASS', "")
 
 DRYCC_APP_DNS_POLICY = os.environ.get('DRYCC_APP_DNS_POLICY', "")
@@ -382,10 +399,6 @@
 REGISTRY_LOCATION = os.environ.get('DRYCC_REGISTRY_LOCATION', 'on-cluster')
 REGISTRY_SECRET_PREFIX = os.environ.get('DRYCC_REGISTRY_SECRET_PREFIX', 'private-registry')
 
-# logger settings
-LOGGER_HOST = os.environ.get('DRYCC_LOGGER_SERVICE_HOST', '127.0.0.1')
-LOGGER_PORT = os.environ.get('DRYCC_LOGGER_SERVICE_PORT_HTTP', 80)
-
 DRYCC_DATABASE_URL = os.environ.get('DRYCC_DATABASE_URL', 'postgres://postgres:@:5432/drycc')
 DATABASES = {
     'default': dj_database_url.config(default=DRYCC_DATABASE_URL)
@@ -410,47 +423,29 @@
 # Oauth settings
 
 DRYCC_PASSPORT_URL = os.environ.get('DRYCC_PASSPORT_URL', 'https://127.0.0.1:8000')
+DRYCC_REGISTER_URL = os.environ.get(
+    'DRYCC_REGISTER_URL',
+    f'{DRYCC_PASSPORT_URL}/user/registration'
+)
 
 LOGIN_REDIRECT_URL = os.environ.get(
     'LOGIN_REDIRECT_URL',
     f'{DRYCC_PASSPORT_URL}/user/login/done/',
 )
 
+# social auth settings
 SOCIAL_AUTH_DRYCC_KEY = os.environ.get(
     "DRYCC_PASSPORT_KEY",
     os.environ.get("SOCIAL_AUTH_DRYCC_KEY"),
 )
-
 SOCIAL_AUTH_DRYCC_SECRET = os.environ.get(
     'DRYCC_PASSPORT_SECRET',
     os.environ.get("SOCIAL_AUTH_DRYCC_SECRET"),
 )
-
-SOCIAL_AUTH_DRYCC_AUTHORIZATION_URL = os.environ.get(
-    'SOCIAL_AUTH_DRYCC_AUTHORIZATION_URL',
-    f'{DRYCC_PASSPORT_URL}/oauth/authorize/',
-)
-SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL = os.environ.get(
-    'SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL',
-    f'{DRYCC_PASSPORT_URL}/oauth/token/'
-)
-SOCIAL_AUTH_DRYCC_ACCESS_API_URL = os.environ.get(
-    'SOCIAL_AUTH_DRYCC_ACCESS_API_URL',
-    f'{DRYCC_PASSPORT_URL}'
-)
-SOCIAL_AUTH_DRYCC_USERINFO_URL = os.environ.get(
-    'SOCIAL_AUTH_DRYCC_USERINFO_URL',
-    f'{DRYCC_PASSPORT_URL}/oauth/userinfo/'
-)
-SOCIAL_AUTH_DRYCC_JWKS_URI = os.environ.get(
-    'SOCIAL_AUTH_DRYCC_JWKS_URI',
-    f'{DRYCC_PASSPORT_URL}/oauth/.well-known/jwks.json'
-)
 SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT = os.environ.get(
     'SOCIAL_AUTH_DRYCC_OIDC_ENDPOINT',
     f'{DRYCC_PASSPORT_URL}/oauth'
 )
-
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
 SOCIAL_AUTH_PIPELINE = (
     'social_core.pipeline.social_auth.social_details',
@@ -458,25 +453,30 @@
     'social_core.pipeline.social_auth.social_user',
     'social_core.pipeline.user.get_username',
     'social_core.pipeline.social_auth.associate_by_email',
-    'api.pipeline.update_or_create',
+    'api.apps_extra.social_core.pipelines.update_or_create',
     'social_core.pipeline.social_auth.associate_user',
     'social_core.pipeline.social_auth.load_extra_data',
     'social_core.pipeline.user.user_details',
 )
-AUTHENTICATION_BACKENDS = ("api.backend.DryccOIDC", ) + AUTHENTICATION_BACKENDS
 DRYCC_CACHE_USER_TIME = int(os.environ.get('DRYCC_CACHE_USER_TIME', 30 * 60))
 
+# Rate limit for invitation emails: max LIMIT emails per WINDOW seconds per recipient address
+DRYCC_INVITATION_EMAIL_LIMIT = int(os.environ.get('DRYCC_INVITATION_EMAIL_LIMIT', 5))
+DRYCC_INVITATION_EMAIL_TIMEOUT = int(os.environ.get('DRYCC_INVITATION_EMAIL_TIMEOUT', 3600))
+
 # Cache Valkey Configuration
 CACHES = {
     "default": {
-        "BACKEND": "django_redis.cache.RedisCache",
+        "BACKEND": "django.core.cache.backends.redis.RedisCache",
         "LOCATION": os.environ.get('DRYCC_VALKEY_URL', 'redis://:@127.0.0.1:6379'),
-        "OPTIONS": {
-            "CLIENT_CLASS": "django_redis.client.DefaultClient",
-        }
     }
 }
 
+# Quickwit Configuration
+QUICKWIT_INDEXER_URL = os.environ.get('QUICKWIT_INDEXER_URL', None)
+QUICKWIT_SEARCHER_URL = os.environ.get('QUICKWIT_SEARCHER_URL', None)
+QUICKWIT_LOG_INDEX_PREFIX = os.environ.get('QUICKWIT_LOG_INDEX_PREFIX', None)
+
 # Workflow-manager Configuration Options
 WORKFLOW_MANAGER_URL = os.environ.get('WORKFLOW_MANAGER_URL', None)
 WORKFLOW_MANAGER_ACCESS_KEY = os.environ.get('WORKFLOW_MANAGER_ACCESS_KEY', None)
diff --git a/rootfs/api/settings/testing.py b/rootfs/api/settings/testing.py
index 2ed583b93..25abe6502 100644
--- a/rootfs/api/settings/testing.py
+++ b/rootfs/api/settings/testing.py
@@ -1,11 +1,20 @@
 import random
 import string
 import os
+import multiprocessing
 from django.core import signals
 from api.settings.celery import app
-from api.settings.production import DATABASES
+from api.settings.production import DATABASES, REST_FRAMEWORK
 from api.settings.production import *  # noqa
 
+
+# Disable DRF throttling for unit tests
+del REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']
+del REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES']
+# Fix Django test error.
+# https://github.com/django/django/blob/main/django/test/runner.py#L455
+# This code was removed in Django > 5.2, so it is safe to remove this when we upgrade.
+multiprocessing.set_start_method('fork')
 # Monkey patch celery
 app.conf.update(task_always_eager=True)
 signals.request_started.send = lambda sender, **named: []
@@ -61,33 +70,66 @@ def __getitem__(self, item):
 WORKFLOW_MANAGER_ACCESS_KEY = "1234567890"
 WORKFLOW_MANAGER_SECRET_KEY = "1234567890"
 
-DRYCC_VOLUME_CLAIM_TEMPLATE = {
-    "csi": {
-        "spec": {
-            "accessModes": [
-                "ReadWriteMany"
-            ],
-            "resources": {
-                "requests": {
-                    "storage": "$size"
-                }
-            },
-            "storageClassName": "$storage_class",
-            "volumeMode": "Filesystem"
-        }
+DRYCC_VOLUME_CLAIM_TEMPLATE = """
+{% if type == 'csi' %}
+{
+  "metadata": {
+    "annotations": {
+      "billing.drycc.cc/type": "usage"
+    }
+  },
+  "spec": {
+    "accessModes": [
+      "ReadWriteMany"
+    ],
+    "storageClassName": "{{storage_class}}",
+    "resources": {
+      "requests": {
+        "storage": "{{size}}"
+      }
     },
-    "nfs": {
-        "spec": {
-            "accessModes": [
-                "ReadWriteMany"
-            ],
-            "resources": {
-                "requests": {
-                    "storage": "$size"
-                }
-            },
-            "storageClassName": "",
-            "volumeName": "$volume_name"
-        }
+    "volumeMode": "Filesystem"
+  }
+}
+{% elif type == 'nfs' %}
+{
+  "metadata": {
+    "annotations": {
+      "billing.drycc.cc/type": "basic"
     }
+  },
+  "spec": {
+    "accessModes": [
+      "ReadWriteMany"
+    ],
+    "storageClassName": "",
+    "resources": {
+      "requests": {
+        "storage": "{{size}}"
+      }
+    },
+    "volumeName": "{{volume_name}}"
+  }
+}
+{% elif type == 'nfs' %}
+{
+  "metadata": {
+    "annotations": {
+      "billing.drycc.cc/type": "basic"
+    }
+  },
+  "spec": {
+    "accessModes": [
+      "ReadWriteMany"
+    ],
+    "storageClassName": "",
+    "resources": {
+      "requests": {
+        "storage": "{{size}}"
+      }
+    },
+    "volumeName": "{{volume_name}}"
+  }
 }
+{% endif %}
+"""
diff --git a/rootfs/api/signals.py b/rootfs/api/signals.py
index a5fc10f40..3b7e3831b 100644
--- a/rootfs/api/signals.py
+++ b/rootfs/api/signals.py
@@ -5,6 +5,7 @@
 """
 import time
 import hashlib
+
 import hmac
 import logging
 import urllib.parse
@@ -13,8 +14,8 @@
 from django.db.models.signals import post_delete, post_save
 from django.dispatch import receiver
 from django.contrib.auth import get_user_model
-from api.utils import get_session
-from api.tasks import send_measurements
+from api.utils import get_httpclient
+from api.tasks import send_usage
 from api.models.app import App
 from api.models.service import Service
 from api.models.gateway import Gateway
@@ -27,6 +28,7 @@
 from api.models.tls import TLS
 from api.models.volume import Volume
 from api.models.resource import Resource
+from api.models.workspace import WorkspaceInvitation
 
 
 User = get_user_model()
@@ -93,7 +95,7 @@ def _hook_release_created(**kwargs):
                 'release': release.version_name,
                 'release_summary': release.summary,
                 'sha': '',
-                'user': release.owner,
+                'user': release.app.workspace.name,
             }
             if release.build is not None:
                 params['sha'] = release.build.sha
@@ -111,7 +113,8 @@ def _hook_release_created(**kwargs):
                 ).hexdigest()
 
             try:
-                get_session().post(url, headers=headers)
+                with get_httpclient() as session:
+                    session.post(url, headers=headers)
                 # just notify with the base URL, disregard the added URL query
                 release.log('Sent deploy hook to {}'.format(deploy_hook))
             except requests.RequestException as e:
@@ -142,8 +145,8 @@ def app_changed_handle(sender, instance: App, created=False, update_fields=None,
     if settings.WORKFLOW_MANAGER_URL and not created and (
             update_fields is not None and "structure" in update_fields):
         timestamp = time.time()
-        send_measurements.apply_async(
-            args=[instance.to_measurements(timestamp), ],
+        send_usage.apply_async(
+            args=[instance.to_usages(timestamp), ],
         )
 
 
@@ -168,6 +171,13 @@ def tls_changed_handle(sender, instance: TLS, created=False, update_fields=None,
                 route.refresh_to_k8s()
 
 
+@receiver(post_save, sender=User)
+def user_changed_handle(sender, instance: User, created=False, update_fields=None, **kwargs):
+    if created or (update_fields is not None and "email" in update_fields):
+        for invitation in WorkspaceInvitation.objects.filter(email=instance.email, accepted=True):
+            invitation.accept()
+
+
 @receiver(post_save, sender=Gateway)
 def gateway_changed_handle(
         sender, instance: Gateway, created=False, update_fields=None, **kwargs):
@@ -221,8 +231,8 @@ def config_changed_handle(sender, instance: Config, created=False, update_fields
     if settings.WORKFLOW_MANAGER_URL and (
             created or (update_fields is not None and "limits" in update_fields)):
         timestamp = time.time()
-        send_measurements.apply_async(
-            args=[instance.app.to_measurements(timestamp), ],
+        send_usage.apply_async(
+            args=[instance.app.to_usages(timestamp), ],
         )
 
 
@@ -231,8 +241,8 @@ def volume_changed_handle(sender, instance: Volume, created=False, update_fields
     # measure volumes to workflow manager
     if settings.WORKFLOW_MANAGER_URL and created:
         timestamp = time.time()
-        send_measurements.apply_async(
-            args=[instance.to_measurements(timestamp), ],
+        send_usage.apply_async(
+            args=[instance.to_usages(timestamp), ],
         )
 
 
@@ -246,6 +256,6 @@ def resource_changed_handle(
                 "plan" in update_fields
             ))):
         timestamp = time.time()
-        send_measurements.apply_async(
-            args=[instance.to_measurements(timestamp), ],
+        send_usage.apply_async(
+            args=[instance.to_usages(timestamp), ],
         )
diff --git a/rootfs/api/tasks.py b/rootfs/api/tasks.py
index 73bf0154c..75242e79a 100644
--- a/rootfs/api/tasks.py
+++ b/rootfs/api/tasks.py
@@ -5,7 +5,7 @@
 from django.core import signals
 from celery import shared_task
 
-from api import manager, models
+from api import utils, manager
 from api.exceptions import ServiceUnavailable
 logger = logging.getLogger(__name__)
 
@@ -15,14 +15,26 @@
     retry_backoff=8,
     retry_jitter=True,
     retry_backoff_max=3600,
-    retry_kwargs={'max_retries': None}
+    retry_kwargs={'max_retries': 32}
 )
-def send_measurements(measurements: List[Dict[str, str]]):
+def send_usage(usage: List[Dict[str, str]]):
     task_id = uuid.uuid4().hex
     signals.request_started.send(sender=task_id)
     try:
-        measurement = manager.Measurement()
-        measurement.post(measurements)
+        manager.UsageAPI().post(usage)
+    except Exception as e:
+        signals.got_request_exception.send(sender=task_id)
+        raise e
+    else:
+        signals.request_finished.send(sender=task_id)
+
+
+@shared_task(retry_kwargs={'max_retries': 3})
+def send_app_log(app_id, msg, level=logging.INFO):
+    task_id = uuid.uuid4().hex
+    signals.request_started.send(sender=task_id)
+    try:
+        utils.send_app_log(app_id, msg, level)
     except Exception as e:
         signals.got_request_exception.send(sender=task_id)
         raise e
@@ -32,7 +44,7 @@ def send_measurements(measurements: List[Dict[str, str]]):
 
 @shared_task(
     autoretry_for=(ServiceUnavailable, ),
-    retry_kwargs={'max_retries': None}
+    retry_kwargs={'max_retries': 3}
 )
 def scale_app(app, user, structure):
     task_id = uuid.uuid4().hex
@@ -48,7 +60,7 @@ def scale_app(app, user, structure):
 
 @shared_task(
     autoretry_for=(ServiceUnavailable, ),
-    retry_kwargs={'max_retries': None}
+    retry_kwargs={'max_retries': 3}
 )
 def run_pipeline(release, *args, **kwargs):
     task_id = uuid.uuid4().hex
@@ -127,20 +139,14 @@ def mount_app(app, user, volume, path):
 
 
 @shared_task(
+    autoretry_for=(ServiceUnavailable, ),
     retry_kwargs={'max_retries': 3}
 )
-def downstream_model_owner(app, old_owner, new_owner):
+def scale_resources(blocklist, app, suspended_state, scale_type):
     task_id = uuid.uuid4().hex
     signals.request_started.send(sender=task_id)
     try:
-        for downstream_model in [
-                models.appsettings.AppSettings, models.build.Build, models.config.Config,
-                models.domain.Domain, models.release.Release, models.resource.Resource,
-                models.tls.TLS, models.service.Service, models.volume.Volume,
-                models.gateway.Gateway, models.gateway.Route]:
-            downstream_model.objects.filter(owner=old_owner, app=app).update(owner=new_owner)
-        app.owner = new_owner
-        app.save()
+        blocklist.scale_resources(app, suspended_state, scale_type)
     except Exception as e:
         signals.got_request_exception.send(sender=task_id)
         raise e
diff --git a/rootfs/api/templates/workspace/workspace_invitation.html b/rootfs/api/templates/workspace/workspace_invitation.html
new file mode 100644
index 000000000..fd680435b
--- /dev/null
+++ b/rootfs/api/templates/workspace/workspace_invitation.html
@@ -0,0 +1,7 @@
+{% autoescape off %}
+Hi there,
+
+Please click on the link below to confirm joining {{ invitation.workspace.name }} workspace:
+
+{{ domain }}{% url 'workspace_invitation_detail' name=invitation.workspace.name uid=invitation.token %}
+{% endautoescape %}
\ No newline at end of file
diff --git a/rootfs/api/templates/workspace/workspace_invitation_accept.html b/rootfs/api/templates/workspace/workspace_invitation_accept.html
new file mode 100644
index 000000000..94c5c1893
--- /dev/null
+++ b/rootfs/api/templates/workspace/workspace_invitation_accept.html
@@ -0,0 +1,107 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <title>Workspace Invitation</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <style>
+        :root {
+            --vscode-foreground: #CCCCCC;
+            --vscode-editor-background: #1F1F1F;
+            --vscode-textLink-foreground: #4daafc;
+        }
+        @media (prefers-color-scheme: light) {
+            :root {
+                --vscode-foreground: #3B3B3B;
+                --vscode-editor-background: #FFFFFF;
+                --vscode-textLink-foreground: #005FB8;
+            }
+        }
+        html { height: 100%; }
+        body {
+            box-sizing: border-box;
+            min-height: 100%;
+            margin: 0;
+            padding: 15px 30px;
+            display: flex;
+            flex-direction: column;
+            color: var(--vscode-foreground);
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", system-ui, "Ubuntu", "Droid Sans", sans-serif;
+            background-color: var(--vscode-editor-background);
+        }
+        a { color: var(--vscode-textLink-foreground); text-decoration: none; }
+        a:hover, a:focus { text-decoration: underline; }
+        .container {
+            height: 100vh;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            text-align: center;
+        }
+        .title {
+            font-size: 28px;
+            font-weight: 300;
+            margin: 0 0 12px 0;
+            color: var(--vscode-foreground);
+        }
+        .subtitle {
+            font-size: 18px;
+            font-weight: 300;
+            color: var(--vscode-foreground);
+            opacity: 0.7;
+            margin: 0 0 36px 0;
+        }
+        .detail {
+            font-size: 14px;
+            color: var(--vscode-foreground);
+            opacity: 0.7;
+            margin: 0 0 8px 0;
+        }
+        .icon-container {
+            margin-bottom: 24px;
+        }
+        @keyframes rise-and-glow {
+            0% { opacity: 0; transform: translateY(15px) scale(0.95); }
+            50% { opacity: 0.8; transform: translateY(-2px) scale(1.02); }
+            70% { opacity: 1; transform: translateY(1px) scale(0.99); }
+            100% { opacity: 1; transform: translateY(0) scale(1); }
+        }
+        .drycc-icon {
+            width: 128px;
+            height: 128px;
+            animation: rise-and-glow 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
+        }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <div class="content">
+            <div class="icon-container">
+                <svg class="drycc-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+                    <g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+                        <circle stroke="#FF2F57" fill="#FF2F57" cx="3" cy="3" r="2"/>
+                        <circle stroke="#FF2F57" fill="#FF2F57" cx="3" cy="13" r="2"/>
+                        <circle stroke="#FF2F57" fill="#FF2F57" cx="13" cy="13" r="2"/>
+                        <circle stroke="#FF2F57" fill="#FF2F57" cx="13" cy="3" r="2"/>
+                        <rect stroke="#3FB1E5" fill="#3FB1E5" x="6.5" y="6.5" width="3" height="3"/>
+                        <polygon stroke="#38BB4C" fill="#38BB4C" stroke-linejoin="round" points="8 11 10 15 6 15"/>
+                        <polygon stroke="#38BB4C" fill="#38BB4C" stroke-linejoin="round" transform="translate(3,8) rotate(90) translate(-3,-8)" points="3 6 5 10 1 10"/>
+                        <polygon stroke="#38BB4C" fill="#38BB4C" stroke-linejoin="round" transform="translate(8,3) rotate(180) translate(-8,-3)" points="8 1 10 5 6 5"/>
+                        <polygon stroke="#38BB4C" fill="#38BB4C" stroke-linejoin="round" transform="translate(13,8) rotate(270) translate(-13,-8)" points="13 6 15 10 11 10"/>
+                    </g>
+                </svg>
+            </div>
+            {% if user_exists %}
+            <h1 class="title">Invitation Accepted</h1>
+            <p class="subtitle">You have been added to workspace <strong>{{ workspace_name }}</strong>.</p>
+            <p class="detail">Use the Drycc CLI to log in: <code>drycc login</code></p>
+            {% else %}
+            <h1 class="title">You're Invited to <strong>{{ workspace_name }}</strong></h1>
+            <p class="subtitle">You need an account to join this workspace.</p>
+            <p class="detail">First, <a href="{{ register_url }}">sign up on Drycc Passport</a>.</p>
+            <p class="detail">Then use the Drycc CLI to log in: <code>drycc login</code></p>
+            {% endif %}
+        </div>
+    </div>
+</body>
+</html>
diff --git a/rootfs/api/tests/__init__.py b/rootfs/api/tests/__init__.py
index 23ecade9e..052cfe63c 100644
--- a/rootfs/api/tests/__init__.py
+++ b/rootfs/api/tests/__init__.py
@@ -6,9 +6,13 @@
 from os.path import dirname, realpath
 
 from django.test.runner import DiscoverRunner
+from django.contrib.auth import get_user_model
 from rest_framework.test import APITestCase, APITransactionTestCase
 
 from api.models.base import Token
+from api.models.workspace import Workspace, WorkspaceMember
+
+User = get_user_model()
 
 
 # Mock out router requests and add in some jitter
@@ -46,20 +50,59 @@ def fake_responses(request, context):
 class SilentDjangoTestSuiteRunner(DiscoverRunner):
     """Prevents api log messages from cluttering the console during tests."""
 
-    def run_tests(self, test_labels, extra_tests=None, **kwargs):
+    def run_tests(self, test_labels, **kwargs):
         """Run tests with all but critical log messages disabled."""
         # hide any log messages less than critical
         logging.disable(logging.ERROR)
         return super(SilentDjangoTestSuiteRunner, self).run_tests(
-            test_labels, extra_tests, **kwargs)
+            test_labels, **kwargs)
 
 
 class DryccBaseTestCase(unittest.TestCase):
 
-    def create_app(self, name=None):
-        body = {}
+    def _get_authenticated_user(self):
+        credentials = getattr(getattr(self, 'client', None), '_credentials', {})
+        auth = credentials.get('HTTP_AUTHORIZATION', '')
+        if auth.startswith('Token '):
+            token_key = auth.split(' ', 1)[1]
+            token = Token.objects.filter(key=token_key).select_related('owner').first()
+            if token is not None:
+                return token.owner
+        return getattr(self, 'user', None)
+
+    def _default_workspace_name(self):
+        user = self._get_authenticated_user()
+        if user and user.username:
+            base = ''.join(ch for ch in user.username.lower() if ch.isalnum())
+            if len(base) >= 5:
+                return base
+        return 'autotest'
+
+    def _ensure_workspace_admin(self, workspace_name):
+        user = self._get_authenticated_user()
+        if user is None:
+            user = User.objects.filter(username='autotest').first()
+        if user is None:
+            raise AssertionError('No test user available for workspace membership')
+
+        workspace, _ = Workspace.objects.get_or_create(
+            name=workspace_name,
+            defaults={'email': user.email or f'{workspace_name}@example.com'},
+        )
+        WorkspaceMember.objects.update_or_create(
+            workspace=workspace,
+            user=user,
+            defaults={'role': 'admin'},
+        )
+        return workspace.name
+
+    def create_app(self, name=None, workspace=None):
+        workspace_name = workspace or self._default_workspace_name()
+        workspace_name = self._ensure_workspace_admin(workspace_name)
+
+        body = {'workspace': workspace_name}
         if name:
-            body = {'id': name}
+            body['id'] = name
 
         response = self.client.post('/v2/apps', body)
         self.assertEqual(response.status_code, 201, response.data)
diff --git a/rootfs/api/tests/test_app.py b/rootfs/api/tests/test_app.py
index 77ffa6aed..162c8ae19 100644
--- a/rootfs/api/tests/test_app.py
+++ b/rootfs/api/tests/test_app.py
@@ -15,9 +15,9 @@
 from django.core.cache import cache
 from django.test.utils import override_settings
 
-from api.models.app import App, app_permission_registry
+from api.models.app import App
 from api.models.base import PTYPE_WEB
-from api.models.config import Config
+from api.models.workspace import Workspace, WorkspaceMember
 from scheduler import KubeException, KubeHTTPException
 
 from api.exceptions import DryccException
@@ -45,6 +45,18 @@ 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)
+        self.workspace_name = self._ensure_workspace_admin(self._default_workspace_name())
+
+        original_post = self.client.post
+
+        def _post_with_workspace(path, data=None, *args, **kwargs):
+            if path == '/v2/apps':
+                payload = {} if data is None else dict(data)
+                payload.setdefault('workspace', self.workspace_name)
+                data = payload
+            return original_post(path, data, *args, **kwargs)
+
+        self.client.post = _post_with_workspace
 
     def tearDown(self):
         # make sure every test has a clean slate for k8s mocking
@@ -66,7 +78,8 @@ def test_app(self, mock_requests):
 
         body = {'id': 'new'}
         response = self.client.patch(url, body)
-        self.assertEqual(response.status_code, 405, response.content)
+        self.assertEqual(response.status_code, 400, response.data)
+        self.assertEqual(str(response.data['detail']), 'workspace is required')
 
         response = self.client.delete(url)
         self.assertEqual(response.status_code, 204, response.data)
@@ -90,10 +103,10 @@ def test_response_data(self, mock_requests):
         body = {'id': 'app-{}'.format(random.randrange(1000, 10000))}
         response = self.client.post('/v2/apps', body)
         for key in response.data:
-            self.assertIn(key, ['uuid', 'created', 'updated', 'id', 'owner', 'structure'])
+            self.assertIn(key, ['uuid', 'created', 'updated', 'id', 'workspace', 'structure'])
         expected = {
             'id': body['id'],
-            'owner': self.user.username,
+            'workspace': self.user.username,
             'structure': {}
         }
         self.assertEqual(response.data, expected | response.data)
@@ -158,8 +171,8 @@ def test_app_errors(self, mock_requests):
 
     def test_app_reserved_names(self, mock_requests):
         """Nobody should be able to create applications with names which are reserved."""
-        reserved_names = ['foo', 'bar']
-        with self.settings(DRYCC_RESERVED_NAMES=reserved_names):
+        reserved_names = ['fooooo', 'barrrrrr']
+        with self.settings(RESERVED_NAME_PATTERNS=reserved_names):
             for name in reserved_names:
                 response = self.client.post('/v2/apps', {'id': name})
                 self.assertContains(
@@ -194,6 +207,13 @@ def test_admin_can_manage_other_apps(self, mock_requests, mock_logger):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + token)
         app_id = self.create_app()
 
+        app = App.objects.get(id=app_id)
+        WorkspaceMember.objects.get_or_create(
+            workspace=app.workspace,
+            user=self.user,
+            defaults={'role': 'admin'},
+        )
+
         # log in as admin, check to see if they have access
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
         url = '/v2/apps/{}'.format(app_id)
@@ -216,7 +236,14 @@ def test_admin_can_see_other_apps(self, mock_requests):
         user = User.objects.get(username='autotest2')
         token = self.get_or_create_token(user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + token)
-        self.create_app()
+        app_id = self.create_app()
+
+        app = App.objects.get(id=app_id)
+        WorkspaceMember.objects.get_or_create(
+            workspace=app.workspace,
+            user=self.user,
+            defaults={'role': 'viewer'},
+        )
 
         # log in as admin
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
@@ -288,7 +315,7 @@ def test_unauthorized_user_cannot_see_app(self, mock_requests):
         An unauthorized user should not be able to access an app's resources.
 
         Since an unauthorized user can't access the application, these
-        tests should return a 403, but currently return a 404. FIXME!
+        tests return 404 when app is filtered out from queryset.
         """
         app_id = self.create_app()
         unauthorized_user = User.objects.get(username='autotest2')
@@ -298,14 +325,14 @@ def test_unauthorized_user_cannot_see_app(self, mock_requests):
         url = '/v2/apps/{}/run'.format(app_id)
         body = {'command': 'foo'}
         response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 404)
 
         url = '/v2/apps/{}'.format(app_id)
         response = self.client.get(url)
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 404)
 
         response = self.client.delete(url)
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 404)
 
     def test_app_info_not_showing_wrong_app(self, mock_requests):
         self.create_app()
@@ -318,70 +345,78 @@ def test_app_transfer(self, mock_requests):
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token)
 
         collaborator = User.objects.get(username='autotest3')
+        collab_token = self.get_or_create_token(collaborator)
 
-        app = App.objects.create(owner=owner)
-
-        # pretend the owner and a collaborator added some config to the app to ensure
-        # resources owned by the owner are transferred, but not resources owned by the
-        # collaborator.
+        # create a workspace and app under owner
+        response = self.client.post('/v2/workspaces', {
+            'name': 'wstransfer1',
+            'email': 'ws-owner@example.com',
+        })
+        self.assertEqual(response.status_code, 201, response.data)
 
-        config1 = Config.objects.create(
-            owner=owner, app=app, values=[{"name": "FOO", "value": "bar", "group": "global"}])
-        config2 = Config.objects.create(
-            owner=collaborator, app=app,
-            values=[{"name": "CAR", "value": "star", "group": "global"}])
+        response = self.client.post('/v2/apps', {
+            'id': 'app-transfer01',
+            'workspace': 'wstransfer1',
+        })
+        self.assertEqual(response.status_code, 201, response.data)
 
-        # Transfer App
+        app = App.objects.get(id='app-transfer01')
+        workspace = Workspace.objects.get(name='wstransfer1')
         url = '/v2/apps/{}'.format(app.id)
-        new_owner = User.objects.get(username='autotest4')
-        new_owner_token = self.get_or_create_token(new_owner)
-        body = {'owner': new_owner.username}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 200, response.data)
 
-        # Original user can no longer access it
+        # collaborator cannot access app before joining workspace
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token)
         response = self.client.get(url)
-        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.status_code, 404, response.data)
+
+        # owner adds collaborator to workspace
+        WorkspaceMember.objects.get_or_create(
+            workspace=workspace,
+            user=collaborator,
+            defaults={'role': 'member'},
+        )
 
-        # New owner can access it
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + new_owner_token)
+        # collaborator can access app after membership
         response = self.client.get(url)
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.data['owner'], new_owner.username)
 
-        # At this point config1.owner field is still the old owner, but the value in the database
-        # was updated to the new owner when we performed the transfer. The object's updated values
-        # needs to be reloaded from the database to get an accurate idea who owns the object.
-        # https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.refresh_from_db
-        config1.refresh_from_db()
-        config2.refresh_from_db()
+        # collaborator cannot manage workspace members
+        response = self.client.patch(
+            f'/v2/workspaces/{workspace.name}/members/{owner.username}',
+            {'role': 'viewer'},
+        )
+        self.assertEqual(response.status_code, 403, response.data)
+
+    def test_transfer_api_success(self, mock_requests):
+        app_id = self.create_app(name='appxferok')
+        response = self.client.post('/v2/workspaces', {
+            'name': 'wstarget',
+            'email': 'target@example.com',
+        })
+        self.assertEqual(response.status_code, 201, response.data)
 
-        # New owner also is given ownership to all resources owned by the original user, but not
-        # resources created by other users
-        self.assertEqual(config1.owner, new_owner)
-        self.assertEqual(config2.owner, collaborator)
+        response = self.client.patch(
+            f'/v2/apps/{app_id}',
+            {'workspace': 'wstarget'},
+        )
+        self.assertEqual(response.status_code, 204, response.data)
 
-        # Collaborators can't transfer
-        body = {
-            'username': owner.username,
-            'permissions': ','.join(app_permission_registry.shortnames),
-        }
-        response = self.client.post(f'/v2/apps/{app.id}/perms/', body)
-        self.assertEqual(response.status_code, 201, response.data)
+        app = App.objects.get(id=app_id)
+        self.assertEqual(app.workspace.name, 'wstarget')
 
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token)
-        body = {'owner': self.user.username}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 403)
+    def test_transfer_api_requires_workspace(self, mock_requests):
+        app_id = self.create_app(name='appxferreqws')
+        response = self.client.patch(f'/v2/apps/{app_id}', {})
+        self.assertEqual(response.status_code, 400, response.data)
+        self.assertEqual(str(response.data['detail']), 'workspace is required')
 
-        # Admins can transfer
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        body = {'owner': self.user.username}
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 200, response.data)
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.data['owner'], self.user.username)
+    def test_transfer_api_target_workspace_not_found(self, mock_requests):
+        app_id = self.create_app(name='appxfer404')
+        response = self.client.patch(
+            f'/v2/apps/{app_id}',
+            {'workspace': 'workspace-not-exists'},
+        )
+        self.assertEqual(response.status_code, 404, response.data)
 
     def test_app_exists_in_kubernetes(self, mock_requests):
         """
@@ -452,8 +487,7 @@ def test_app_verify_application_health_success(self, mock_requests):
 
         # create app
         app_id = self.create_app()
-        hostname = 'http://{}.{}.svc.{}:80/'.format(
-            app_id, app_id, settings.KUBERNETES_CLUSTER_DOMAIN)
+        hostname = 'http://{}.{}.svc:80/'.format(app_id, app_id)
         mr = mock_requests.register_uri('GET', hostname, responses)
 
         # deploy app to get verification
@@ -488,8 +522,7 @@ def test_app_verify_application_health_failure_404(self, mock_requests):
         # create app
         app_id = self.create_app()
 
-        hostname = 'http://{}.{}.svc.{}:80/'.format(
-            app_id, app_id, settings.KUBERNETES_CLUSTER_DOMAIN)
+        hostname = 'http://{}.{}.svc:80/'.format(app_id, app_id)
         mr = mock_requests.register_uri('GET', hostname, responses)
         # deploy app to get verification
         url = "/v2/apps/{}/build".format(app_id)
@@ -512,8 +545,7 @@ def _raise_exception(request, ctx):
         # create app
         app_id = self.create_app()
         # function tries to hit router 10 times
-        hostname = 'http://{}.{}.svc.{}:80/'.format(
-            app_id, app_id, settings.KUBERNETES_CLUSTER_DOMAIN)
+        hostname = 'http://{}.{}.svc:80/'.format(app_id, app_id)
         mr = mock_requests.register_uri('GET', hostname, text=_raise_exception)
 
         # deploy app to get verification
@@ -531,7 +563,7 @@ def test_list_ordering(self, mock_requests):
         """
         Test that a list of apps is sorted by name
         """
-        for name in ['zulu', 'tango', 'alpha', 'foxtrot']:
+        for name in ['zulua', 'tango', 'alpha', 'foxtrot']:
             response = self.client.post('/v2/apps', {'id': name})
             self.assertEqual(response.status_code, 201, response.data)
 
@@ -540,7 +572,7 @@ def test_list_ordering(self, mock_requests):
         self.assertEqual(apps[0]['id'], 'alpha')
         self.assertEqual(apps[1]['id'], 'foxtrot')
         self.assertEqual(apps[2]['id'], 'tango')
-        self.assertEqual(apps[3]['id'], 'zulu')
+        self.assertEqual(apps[3]['id'], 'zulua')
 
     def test_get_private_registry_config(self, mock_requests):
         registry = {"web": {'username': 'test', 'password': 'test'}}
@@ -588,7 +620,7 @@ def test_get_private_registry_config_bad_registry_location(self, mock_requests):
         self.assertEqual(create, None)
 
     def test_build_env_vars(self, mock_requests):
-        app = App.objects.create(owner=self.user)
+        app = App.objects.create(workspace=Workspace.objects.get(name=self.workspace_name))
         # Make sure an exception is raised when calling without a build available
         with self.assertRaises(DryccException):
             app._build_env_vars(app.release_set.latest(), PTYPE_WEB)
@@ -625,31 +657,34 @@ def test_build_env_vars(self, mock_requests):
             })
 
     def test_gather_app_settings(self, mock_requests):
-        app = App.objects.create(owner=self.user)
+        app = App.objects.create(workspace=Workspace.objects.get(name=self.workspace_name))
         app.save()
         data = {'image': 'autotest/example', 'stack': 'container'}
         url = f"/v2/apps/{app.id}/build"
         response = self.client.post(url, data)
         self.assertEqual(response.status_code, 201, response.data)
-        Config.objects.create(
-            owner=self.user,
-            app=app,
-            values=[
+        # Set some app settings
+        url = f"/v2/apps/{app.id}/config"
+        body = {
+            'values': [
                 {"name": "DRYCC_DEPLOY_TIMEOUT", "value": "60", "group": "global"},
                 {"name": "DRYCC_DEPLOY_BATCHES", "value": "3", "group": "global"},
                 {
                     "name": "KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS",
-                    "value": "60", "group": "global"
+                    "value": "90", "group": "global"
                 },
-            ]
-        )
+            ],
+        }
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        # Gather app settings
         s = app._gather_app_settings(app.release_set.latest(),
                                      app.appsettings_set.latest(),
                                      'web',
                                      3)
-        assert isinstance(s['deploy_batches'], int)
-        assert isinstance(s['deploy_timeout'], int)
-        assert isinstance(s['pod_termination_grace_period_seconds'], int)
+        self.assertEqual(s['deploy_batches'], 3)
+        self.assertEqual(s['deploy_timeout'], 60)
+        self.assertEqual(s['termination_grace_period_seconds'], 90)
 
     def test_app_name_bad_regex(self, mock_requests):
         """
diff --git a/rootfs/api/tests/test_build.py b/rootfs/api/tests/test_build.py
index fbd7c8625..11820bf19 100644
--- a/rootfs/api/tests/test_build.py
+++ b/rootfs/api/tests/test_build.py
@@ -88,10 +88,9 @@ def test_response_data(self, mock_requests):
         response = self.client.post(url, body)
 
         for key in response.data:
-            self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'dockerfile',
+            self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'dockerfile',
                                 'dryccfile', 'image', 'stack', 'procfile', 'sha'])
         expected = {
-            'owner': self.user.username,
             'app': app_id,
             'dockerfile': '',
             'image': 'autotest/example',
@@ -510,13 +509,13 @@ def test_new_build_does_not_scale_up_automatically(self, mock_requests):
         self.assertEqual(response.status_code, 200, response.data)
         self.assertEqual(len(response.data['results']), 0)
 
-    def test_build_image_in_registry(self, mock_requests):
+    def test_build_image_in_registry_ok(self, mock_requests):
         """When the image is already in the drycc registry no pull/tag/push happens"""
         app_id = self.create_app()
 
         # post an image as a build using registry hostname
         url = f"/v2/apps/{app_id}/build"
-        image = '127.0.0.1:5000/autotest/example'
+        image = 'registry.drycc.cc:5000/autotest/example'
         body = {'image': image, 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -527,7 +526,7 @@ def test_build_image_in_registry(self, mock_requests):
 
         # post an image as a build using registry hostname + port
         url = f"/v2/apps/{app_id}/build"
-        image = '127.0.0.1:5000/autotest/example'
+        image = 'registry.drycc.cc:5000/autotest/example'
         body = {'image': image, 'stack': 'container'}
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
@@ -536,6 +535,18 @@ def test_build_image_in_registry(self, mock_requests):
         release = build.app.release_set.latest()
         self.assertEqual(release.get_deploy_image(PTYPE_WEB), image)
 
+    def test_build_image_in_registry_err(self, mock_requests):
+        """When the image is already in the drycc registry no pull/tag/push happens"""
+        app_id = self.create_app()
+
+        # post an image as a build using registry hostname
+        url = f"/v2/apps/{app_id}/build"
+        for host in ['127.0.0.1', 'localhost', 'localhost:5000', '127.0.0.1:5000']:
+            image = f'{host}/autotest/example'
+            body = {'image': image, 'stack': 'container'}
+            response = self.client.post(url, body)
+            self.assertEqual(response.status_code, 400, response.data)
+
     def test_build_image_in_registry_with_auth(self, mock_requests):
         """add authentication to the build"""
         app_id = self.create_app()
diff --git a/rootfs/api/tests/test_certificate.py b/rootfs/api/tests/test_certificate.py
index c7efd8f0f..ccb9d5446 100644
--- a/rootfs/api/tests/test_certificate.py
+++ b/rootfs/api/tests/test_certificate.py
@@ -5,6 +5,12 @@
 from api.models.app import App
 from api.models.certificate import Certificate
 from api.tests import TEST_ROOT, DryccTestCase
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+from datetime import datetime, timedelta
+from pytz import utc
 
 User = get_user_model()
 
@@ -19,7 +25,8 @@ 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)
-        self.app = App.objects.create(owner=self.user, id='test-app-use-case')
+        app_id = self.create_app(name='test-app-use-case')
+        self.app = App.objects.get(id=app_id)
         self.url = f'/v2/apps/{self.app.id}/certs'
         self.domain = 'autotest.example.com'
 
@@ -33,6 +40,34 @@ def tearDown(self):
         # make sure every test has a clean slate for k8s mocking
         cache.clear()
 
+    def _create_certificate_pem(self, common_name, san_names=None):
+        private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+        subject = issuer = x509.Name([
+            x509.NameAttribute(NameOID.COUNTRY_NAME, 'US'),
+            x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Drycc'),
+            x509.NameAttribute(NameOID.COMMON_NAME, common_name),
+        ])
+        builder = x509.CertificateBuilder().subject_name(subject).issuer_name(issuer)
+        builder = builder.public_key(private_key.public_key())
+        builder = builder.serial_number(x509.random_serial_number())
+        builder = builder.not_valid_before(datetime.now(utc) - timedelta(minutes=1))
+        builder = builder.not_valid_after(datetime.now(utc) + timedelta(days=30))
+
+        if san_names:
+            builder = builder.add_extension(
+                x509.SubjectAlternativeName([x509.DNSName(name) for name in san_names]),
+                critical=False,
+            )
+
+        certificate = builder.sign(private_key=private_key, algorithm=hashes.SHA256())
+        cert_pem = certificate.public_bytes(serialization.Encoding.PEM).decode('utf-8')
+        key_pem = private_key.private_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PrivateFormat.TraditionalOpenSSL,
+            encryption_algorithm=serialization.NoEncryption(),
+        ).decode('utf-8')
+        return cert_pem, key_pem, certificate
+
     def test_create_certificate_with_domain(self):
         """Tests creating a certificate."""
         response = self.client.post(
@@ -146,7 +181,6 @@ def test_delete_certificate(self):
         Certificate.objects.create(
             name='random-test-cert',
             app=self.app,
-            owner=self.user,
             common_name='autotest.example.com',
             certificate=self.cert,
             key=self.key
@@ -156,7 +190,7 @@ def test_delete_certificate(self):
         self.assertEqual(response.status_code, 204, response.data)
 
     def test_create_invalid_cert(self):
-        """Upload a cert that can't be loaded by pyopenssl"""
+        """Upload a cert that can't be parsed."""
         response = self.client.post(
             self.url,
             {
@@ -166,15 +200,14 @@ def test_create_invalid_cert(self):
             }
         )
         self.assertEqual(response.status_code, 400, response.data)
-        # match partial since right now the rest is pyopenssl errors
+        # Match partial since parser details may vary.
         self.assertIn('Could not load certificate', response.data['certificate'][0])
 
     def test_load_invalid_cert(self):
-        """Inject a cert that can't be loaded by pyopenssl"""
+        """Inject a cert that can't be parsed."""
 
         with self.assertRaises(SuspiciousOperation):
             Certificate.objects.create(
-                owner=self.user,
                 app=self.app,
                 name='random-test-cert',
                 certificate='i am bad data',
@@ -182,7 +215,7 @@ def test_load_invalid_cert(self):
             )
 
     def test_create_invalid_key(self):
-        """Upload a private key that can't be loaded by pyopenssl"""
+        """Upload a private key that can't be parsed."""
         response = self.client.post(
             self.url,
             {
@@ -192,20 +225,71 @@ def test_create_invalid_key(self):
             }
         )
         self.assertEqual(response.status_code, 400, response.data)
-        # match partial since right now the rest is pyopenssl errors
+        # Match partial since parser details may vary.
         self.assertIn('Could not load private key', response.data['key'][0])
 
     def test_load_invalid_key(self):
-        """Inject a private key that can't be loaded by pyopenssl"""
+        """Inject a private key that can't be parsed."""
 
         with self.assertRaises(SuspiciousOperation):
             Certificate.objects.create(
-                owner=self.user,
+                app=self.app,
                 name='random-test-cert',
                 certificate=self.cert,
                 key='I am Groot.'
             )
 
+    def test_create_certificate_with_san(self):
+        """Certificates with SAN should expose DNS names."""
+        cert, key, _ = self._create_certificate_pem(
+            'autotest.example.com',
+            san_names=['autotest.example.com', 'www.autotest.example.com'],
+        )
+
+        response = self.client.post(
+            self.url,
+            {
+                'name': 'san-test-cert',
+                'certificate': cert,
+                'key': key,
+            }
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual(
+            response.data['san'],
+            ['autotest.example.com', 'www.autotest.example.com'],
+        )
+
+    def test_create_certificate_with_mismatched_key(self):
+        """Certificate and private key must match."""
+        cert, _, _ = self._create_certificate_pem('autotest.example.com')
+        _, other_key, _ = self._create_certificate_pem('other.example.com')
+
+        response = self.client.post(
+            self.url,
+            {
+                'name': 'mismatch-test-cert',
+                'certificate': cert,
+                'key': other_key,
+            }
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        self.assertIn('Certificate and private key do not match!', response.data[0])
+
+    def test_certificate_persists_subject_and_issuer(self):
+        """Persisted certificate metadata should reflect x509 subject and issuer."""
+        cert, key, parsed = self._create_certificate_pem('autotest.example.com')
+
+        certificate = Certificate.objects.create(
+            name='metadata-test-cert',
+            app=self.app,
+            certificate=cert,
+            key=key,
+        )
+
+        self.assertEqual(certificate.subject, parsed.subject.rfc4514_string())
+        self.assertEqual(certificate.issuer, parsed.issuer.rfc4514_string())
+
     def test_certs_fetch_limit(self):
         """
         When a user retrieves a certificate, make sure limits work
diff --git a/rootfs/api/tests/test_certificate_use_case_1.py b/rootfs/api/tests/test_certificate_use_case_1.py
index ea6d5a64a..604a9f4b4 100644
--- a/rootfs/api/tests/test_certificate_use_case_1.py
+++ b/rootfs/api/tests/test_certificate_use_case_1.py
@@ -24,10 +24,11 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.app = App.objects.create(owner=self.user, id='test-app-use-case-1')
+        app_id = self.create_app(name='test-app-use-case-1')
+        self.app = App.objects.get(id=app_id)
         self.url = f'/v2/apps/{self.app.id}/certs'
         self.domain = Domain.objects.create(
-            owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB)
+            app=self.app, domain='foo.com', ptype=PTYPE_WEB)
         self.name = 'foo-com'  # certificate name
 
         with open('{}/certs/{}.key'.format(TEST_ROOT, self.domain)) as f:
@@ -147,7 +148,6 @@ def test_certficate_denied_requests(self):
     def test_delete_certificate(self):
         """Destroying a certificate should generate a 204 response"""
         Certificate.objects.create(
-            owner=self.user,
             app=self.app,
             name=self.name,
             certificate=self.cert,
diff --git a/rootfs/api/tests/test_certificate_use_case_2.py b/rootfs/api/tests/test_certificate_use_case_2.py
index 7f808f890..c74845291 100644
--- a/rootfs/api/tests/test_certificate_use_case_2.py
+++ b/rootfs/api/tests/test_certificate_use_case_2.py
@@ -24,13 +24,14 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.app = App.objects.create(owner=self.user, id='test-app-use-case-2')
+        app_id = self.create_app(name='test-app-use-case-2')
+        self.app = App.objects.get(id=app_id)
         self.url = f'/v2/apps/{self.app.id}/certs'
         self.domains = {
             'foo.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB),
+                app=self.app, domain='foo.com', ptype=PTYPE_WEB),
             'bar.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='bar.com', ptype=PTYPE_WEB),
+                app=self.app, domain='bar.com', ptype=PTYPE_WEB),
         }
 
         # only foo.com has a cert
@@ -133,7 +134,6 @@ def test_delete_certificate(self):
             Certificate.objects.create(
                 name=certificate['name'],
                 app=self.app,
-                owner=self.user,
                 common_name=domain,
                 certificate=certificate['cert'],
                 key=certificate['key']
diff --git a/rootfs/api/tests/test_certificate_use_case_3.py b/rootfs/api/tests/test_certificate_use_case_3.py
index 9d17a2f97..7eedfc859 100644
--- a/rootfs/api/tests/test_certificate_use_case_3.py
+++ b/rootfs/api/tests/test_certificate_use_case_3.py
@@ -24,13 +24,14 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.app = App.objects.create(owner=self.user, id='test-app-use-case-3')
+        app_id = self.create_app(name='test-app-use-case-3')
+        self.app = App.objects.get(id=app_id)
         self.url = f'/v2/apps/{self.app.id}/certs'
         self.domains = {
             'foo.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB),
+                app=self.app, domain='foo.com', ptype=PTYPE_WEB),
             'bar.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='bar.com', ptype=PTYPE_WEB),
+                app=self.app, domain='bar.com', ptype=PTYPE_WEB),
         }
 
         self.certificates = {}
@@ -135,7 +136,6 @@ def test_delete_certificate(self):
             Certificate.objects.create(
                 name=certificate['name'],
                 app=self.app,
-                owner=self.user,
                 common_name=domain,
                 certificate=certificate['cert'],
                 key=certificate['key']
diff --git a/rootfs/api/tests/test_certificate_use_case_4.py b/rootfs/api/tests/test_certificate_use_case_4.py
index f12b7634b..573cfa571 100644
--- a/rootfs/api/tests/test_certificate_use_case_4.py
+++ b/rootfs/api/tests/test_certificate_use_case_4.py
@@ -24,16 +24,17 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.app = App.objects.create(owner=self.user, id='test-app-use-case-3')
+        app_id = self.create_app(name='test-app-use-case-3')
+        self.app = App.objects.get(id=app_id)
         self.url = f'/v2/apps/{self.app.id}/certs'
         self.domains = {
             '*.foo.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='*.foo.com',
+                app=self.app, domain='*.foo.com',
                 ptype=PTYPE_WEB),
             'foo.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB),
+                app=self.app, domain='foo.com', ptype=PTYPE_WEB),
             'bar.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='bar.com', ptype=PTYPE_WEB),
+                app=self.app, domain='bar.com', ptype=PTYPE_WEB),
         }
 
         self.certificates = {}
@@ -218,7 +219,6 @@ def test_delete_certificate(self):
         for domain, certificate in self.certificates.items():
             Certificate.objects.create(
                 name=certificate['name'],
-                owner=self.user,
                 app=self.app,
                 common_name=domain,
                 certificate=certificate['cert'],
diff --git a/rootfs/api/tests/test_certificate_use_case_5.py b/rootfs/api/tests/test_certificate_use_case_5.py
index 34c331e3c..1a060421b 100644
--- a/rootfs/api/tests/test_certificate_use_case_5.py
+++ b/rootfs/api/tests/test_certificate_use_case_5.py
@@ -24,17 +24,18 @@ def setUp(self):
         self.token = self.get_or_create_token(self.user)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.app = App.objects.create(owner=self.user, id='test-app-use-case-5')
+        app_id = self.create_app(name='test-app-use-case-5')
+        self.app = App.objects.get(id=app_id)
         self.url = f'/v2/apps/{self.app.id}/certs'
         # Done out of scope as it gets the same cert as the wildcard
         Domain.objects.create(
-            owner=self.user, app=self.app, domain='foo.com', ptype=PTYPE_WEB)
+            app=self.app, domain='foo.com', ptype=PTYPE_WEB)
         self.domains = {
             '*.foo.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='*.foo.com',
+                app=self.app, domain='*.foo.com',
                 ptype=PTYPE_WEB),
             'bar.com': Domain.objects.create(
-                owner=self.user, app=self.app, domain='bar.com', ptype=PTYPE_WEB),
+                app=self.app, domain='bar.com', ptype=PTYPE_WEB),
         }
 
         self.certificates = {}
@@ -164,7 +165,6 @@ def test_delete_certificate(self):
             # Create certificate
             Certificate.objects.create(
                 name=certificate['name'],
-                owner=self.user,
                 app=self.app,
                 common_name=domain,
                 certificate=certificate['cert'],
diff --git a/rootfs/api/tests/test_config.py b/rootfs/api/tests/test_config.py
index 6fe1308a8..c9579df42 100644
--- a/rootfs/api/tests/test_config.py
+++ b/rootfs/api/tests/test_config.py
@@ -35,11 +35,8 @@ 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]
+        app_id = self.create_app()
+        self.app = App.objects.get(id=app_id)
 
     def tearDown(self):
         # make sure every test has a clean slate for k8s mocking
@@ -141,6 +138,109 @@ def test_config(self, mock_requests):
         self.assertEqual(response.status_code, 405, response.data)
         return config5
 
+    def test_config_group_merge_false(self, mock_requests):
+        """
+        Test that config can be created with merge='false' parameter
+        which replaces the entire config instead of merging
+        """
+        app_id = self.create_app()
+
+        # set initial config values
+        url = f"/v2/apps/{app_id}/config"
+        value1 = {"name": "DEBUG", "value": "true", "group": "global"}
+        value2 = {"name": "DATABASE_URL", "value": "postgres://localhost/db", "group": "global"}
+        body = {'values': [value1, value2]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual(len(response.data['values']), 2)
+
+        # replace entire config with merge='false'
+        value3 = {"name": "NEW_CONFIG", "value": "replacement_value", "group": "global"}
+        body = {'values': [value1, value2, value3]}
+        response = self.client.post(f"{url}?merge=false", body)
+        self.assertEqual(response.status_code, 201, response.data)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['values']), 3)
+
+        # remove value3
+        body = {'values': [value1, value2]}
+        response = self.client.post(f"{url}?merge=false", body)
+        self.assertEqual(response.status_code, 201, response.data)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['values']), 2)
+
+    def test_config_ptype_merge_false(self, mock_requests):
+        """
+        Test that config can be created with merge='false' parameter
+        which replaces the entire config instead of merging
+        """
+        app_id = self.create_app()
+
+        # set initial config values
+        url = f"/v2/apps/{app_id}/config"
+        value1 = {"name": "DEBUG", "value": "true", "ptype": "web"}
+        value2 = {"name": "DATABASE_URL", "value": "postgres://localhost/db", "ptype": "web"}
+        value3 = {"name": "NEW_CONFIG", "value": "replacement_value", "ptype": "web"}
+        body = {'values': [value1, value2, value3]}
+        response = self.client.post(f"{url}?merge=false", body)
+        self.assertEqual(response.status_code, 201, response.data)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['values']), 3)
+
+        # remove value3
+        body = {'values': [value1, value2]}
+        response = self.client.post(f"{url}?merge=false", body)
+        self.assertEqual(response.status_code, 201, response.data)
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['values']), 2)
+
+    def test_config_merge_false_with_empty_values(self, mock_requests):
+        """
+        Test that config can be created with merge='false' and empty values
+        """
+        app_id = self.create_app()
+
+        # set initial config values
+        url = f"/v2/apps/{app_id}/config"
+        value1 = {"name": "DEBUG", "value": "true", "group": "global"}
+        body = {'values': [value1]}
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual(len(response.data['values']), 1)
+
+        # replace with empty config using merge='false'
+        body = {'values': []}
+        response = self.client.post(f"{url}?merge=false", body)
+        self.assertEqual(response.status_code, 409, response.data)
+
+        # verify config is empty
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['values']), 1)
+
+    def test_config_merge_false_case_insensitive(self, mock_requests):
+        """
+        Test that merge parameter is case insensitive
+        """
+        app_id = self.create_app()
+
+        url = f"/v2/apps/{app_id}/config"
+        value1 = {"name": "DEBUG", "value": "true", "group": "global"}
+        body = {'values': [value1]}
+
+        # test with uppercase FALSE
+        response = self.client.post(f"{url}?merge=FALSE", body)
+        self.assertEqual(response.status_code, 201, response.data)
+
+        # verify config was replaced
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['values']), 1)
+
     def test_registry_set(self, mock_requests):
         app_id = self.create_app()
         # set an initial config value
@@ -263,12 +363,10 @@ def test_response_data(self, mock_requests):
         body = {'values': [value1]}
         response = self.client.post(url, body)
         for key in response.data:
-            self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'values',
+            self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'values',
                                 'values_refs', 'limits', 'tags', 'registry', 'healthcheck',
-                                'lifecycle_post_start', 'lifecycle_pre_stop',
-                                'termination_grace_period'])
+                                'lifecycle', 'termination_grace_period'])
         expected = {
-            'owner': self.user.username,
             'app': app_id,
             'values': [value1],
             'limits': {
@@ -295,12 +393,10 @@ def test_response_data_types_converted(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 201, response.data)
         for key in response.data:
-            self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'values', 'limits',
+            self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'values', 'limits',
                                 'values_refs', 'tags', 'registry', 'healthcheck',
-                                'lifecycle_post_start', 'lifecycle_pre_stop',
-                                'termination_grace_period'])
+                                'lifecycle', 'termination_grace_period'])
         expected = {
-            'owner': self.user.username,
             'app': app_id,
             'values': [value1],
             'limits': {
@@ -412,7 +508,7 @@ def test_config_deploy_failure(self, mock_requests):
             app = App.objects.get(id=app_id)
             release = app.release_set.latest()
             self.assertEqual(release.failed, True)
-            self.assertEqual(release.conditions[0]['exception'], "Boom!")
+            self.assertEqual(release.conditions[0]['exception'], "Exception: Boom!")
 
     def test_invalid_config_keys(self, mock_requests):
         """Test that invalid config keys are rejected.
@@ -462,14 +558,6 @@ def test_admin_can_create_config_on_other_apps(self, mock_requests):
         self.assertEqual([value1], response.data['values'])
         return response
 
-    def test_config_owner_is_requesting_user(self, mock_requests):
-        """
-        Ensure that setting the config value is owned by the requesting user
-        See https://github.com/drycc/drycc/issues/2650
-        """
-        response = self.test_admin_can_create_config_on_other_apps()
-        self.assertEqual(response.data['owner'], self.user.username)
-
     def test_unauthorized_user_cannot_modify_config(self, mock_requests):
         """
         An unauthorized user should not be able to modify other config.
@@ -524,7 +612,8 @@ def test_config_failures(self, mock_requests):
             self.assertEqual(response.status_code, 201)
             self.assertEqual(app.release_set.latest().version, 4)
             self.assertEqual(app.release_set.latest().failed, True)
-            self.assertEqual(app.release_set.latest().conditions[0]['exception'], "Boom!")
+            self.assertEqual(
+                app.release_set.latest().conditions[0]['exception'], "Exception: Boom!")
             self.assertEqual(app.release_set.filter(failed=False).latest().version, 3)
 
         # create a build to see that the new release is created with the last successful config
@@ -583,9 +672,7 @@ def test_unset_limits_error(self, mock_requests):
         self.assertEqual(response.status_code, 201)
         # dockerfile + procfile worflow
         app = App.objects.get(id=app_id)
-        user = User.objects.get(username='autotest')
         build = Build.objects.create(
-            owner=user,
             app=app,
             image="qwerty",
             procfile={
@@ -598,7 +685,6 @@ def test_unset_limits_error(self, mock_requests):
         # create an initial release
         release = Release.objects.create(
             version=3,
-            owner=user,
             app=app,
             config=app.config_set.latest(),
             build=build
@@ -632,7 +718,7 @@ def test_unset_limits_error(self, mock_requests):
     def call_command(self, *args, **kwargs):
         out = StringIO()
         call_command(
-            "measure_apps",
+            "upload_app_usage",
             *args,
             stdout=out,
             stderr=StringIO(),
@@ -662,9 +748,7 @@ def test_set_config_limits_run(self, *args, **kwargs):
         app_id = self.create_app()
         # dockerfile + procfile worflow
         app = App.objects.get(id=app_id)
-        user = User.objects.get(username='autotest')
         build = Build.objects.create(
-            owner=user,
             app=app,
             image="qwerty",
             procfile={
@@ -677,7 +761,6 @@ def test_set_config_limits_run(self, *args, **kwargs):
         # create an initial release
         release = Release.objects.create(
             version=3,
-            owner=user,
             app=app,
             config=app.config_set.latest(),
             build=build
@@ -897,9 +980,9 @@ def test_config_from_dryccfile(self, mock_requests):
         self.assertEqual(
             release.config.envs("web"),
             {
-                'GROUP': 'g1', 'DEBUG': 'tr', 'TEST1': 'g1', 'TEST2': 'tr', 'PENV1': 'web',
-                'PENV2': 'web', "WEBSITE": "www.drycc.cc",
-            }
+                'GROUP': 'g1', 'DEBUG': 'tr', 'TEST1': 'g1',
+                'TEST2': 'tr', 'PENV1': 'web', 'PENV2': 'web'
+            },
         )
 
         # test use old config and healthcheck
@@ -914,8 +997,8 @@ def test_config_from_dryccfile(self, mock_requests):
             self.assertEqual(response.status_code, 201, response.data)
         release = app.release_set.latest()
         self.assertEqual(release.failed, False)
-        self.assertEqual(release.config.values, release.previous().config.values)
-        self.assertEqual(release.config.values_refs, release.previous().config.values_refs)
+        self.assertEqual(release.config.values, [])
+        self.assertEqual(release.config.values_refs, {})
         # set empty
         new_build_body = copy.deepcopy(build_body)
         new_build_body['dryccfile']['config'] = {}
@@ -928,5 +1011,83 @@ def test_config_from_dryccfile(self, mock_requests):
             self.assertEqual(response.status_code, 201, response.data)
         release = app.release_set.latest()
         self.assertEqual(release.failed, False)
-        self.assertEqual(release.config.envs("web"), {"WEBSITE": "www.drycc.cc"})
+        self.assertEqual(release.config.envs("web"), {})
         self.assertEqual(release.config.values_refs, {})
+
+        # test config modification creates new build with merged dryccfile
+        # First, create a build with dryccfile
+        build_body_with_dryccfile = copy.deepcopy(build_body)
+        build_body_with_dryccfile['dryccfile']['config'] = {
+            "mygroup1": {
+                "GROUP": "g1",
+                "DEBUG": "tr",
+            }
+        }
+        build_body_with_dryccfile['dryccfile']['pipeline']['web.yaml']['env'] = {
+            "PENV1": "web"
+        }
+        build_body_with_dryccfile['dryccfile']['pipeline']['web.yaml']['config'] = [
+            "mygroup1"
+        ]
+        with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
+            mock_kube.return_value = ['up', 'down']
+            url = f"/v2/apps/{app_id}/build"
+            response = self.client.post(url, build_body_with_dryccfile)
+            self.assertEqual(response.status_code, 201, response.data)
+
+        # Get the latest release and build
+        release = app.release_set.latest()
+        initial_build = release.build
+        self.assertIsNotNone(initial_build.dryccfile)
+
+        # Now modify config - this should trigger build.merge() and create a new build
+        config_values = [
+            {"name": "NEW_CONFIG", "value": "new_value", "group": "mygroup1"},
+            {"name": "PENV2", "value": "web2", "ptype": "web"}
+        ]
+        body = {'values': config_values}
+        url = f"/v2/apps/{app_id}/config"
+        response = self.client.post(url, body)
+        self.assertEqual(response.status_code, 201, response.data)
+
+        # Check that a new release was created
+        new_release = app.release_set.latest()
+        self.assertNotEqual(release.uuid, new_release.uuid)
+
+        # Check that a new build was created with merged dryccfile
+        new_build = new_release.build
+        self.assertIsNotNone(new_build)
+        self.assertNotEqual(initial_build.uuid, new_build.uuid)
+
+        # Verify dryccfile was merged with config values
+        self.assertIn('dryccfile', new_build.__dict__)
+        dryccfile = new_build.dryccfile
+
+        # Check config groups were merged
+        self.assertIn('config', dryccfile)
+        self.assertIn('mygroup1', dryccfile['config'])
+        self.assertEqual(dryccfile['config']['mygroup1']['GROUP'], 'g1')
+        self.assertEqual(dryccfile['config']['mygroup1']['DEBUG'], 'tr')
+        self.assertEqual(dryccfile['config']['mygroup1']['NEW_CONFIG'], 'new_value')
+
+        # Check ptype envs were merged
+        self.assertIn('pipeline', dryccfile)
+        self.assertIn('web.yaml', dryccfile['pipeline'])
+        self.assertIn('env', dryccfile['pipeline']['web.yaml'])
+        self.assertEqual(dryccfile['pipeline']['web.yaml']['env']['PENV1'], 'web')
+        self.assertEqual(dryccfile['pipeline']['web.yaml']['env']['PENV2'], 'web2')
+
+        # Check config refs
+        self.assertIn('config', dryccfile['pipeline']['web.yaml'])
+        self.assertEqual(dryccfile['pipeline']['web.yaml']['config'], ['mygroup1'])
+
+        # Verify config values are correct
+        config = new_release.config
+        expected_envs = {
+            'GROUP': 'g1',
+            'DEBUG': 'tr',
+            'NEW_CONFIG': 'new_value',
+            'PENV1': 'web',
+            'PENV2': 'web2'
+        }
+        self.assertEqual(config.envs("web"), expected_envs)
diff --git a/rootfs/api/tests/test_consumers.py b/rootfs/api/tests/test_consumers.py
new file mode 100644
index 000000000..15d84fe34
--- /dev/null
+++ b/rootfs/api/tests/test_consumers.py
@@ -0,0 +1,931 @@
+"""
+Unit tests for the Drycc api consumers.
+
+Run the tests with "./manage.py test api.tests.test_consumers"
+"""
+import json
+import asyncio
+import aiohttp
+from unittest import mock
+from unittest.mock import MagicMock, patch
+
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+from channels.exceptions import DenyConnection
+
+from api.models.app import App
+from api.models.volume import Volume
+from api.consumers import (
+    AppPermChecker,
+    BaseAppConsumer,
+    BaseK8sConsumer,
+    AppPodLogsConsumer,
+    AppPodExecConsumer,
+    FilerProxyConsumer
+)
+from api.tests import adapter, DryccTransactionTestCase
+import requests_mock
+
+User = get_user_model()
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class ConsumerTestCase(DryccTransactionTestCase):
+    """Base test case for consumer tests"""
+
+    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)
+        self.app_id = self.create_app()
+        self.app = App.objects.get(id=self.app_id)
+
+    def tearDown(self):
+        # make sure every test has a clean slate for k8s mocking
+        cache.clear()
+
+
+class AppPermCheckerTest(ConsumerTestCase):
+    """Tests for AppPermChecker"""
+
+    def test_has_perm_with_logged_in_user(self):
+        """Test permission check with valid user and app"""
+        checker = AppPermChecker({
+            'user': self.user,
+            'url_route': {'kwargs': {'id': self.app_id}}
+        })
+
+        # Mock the async method
+        async def test_async():
+            result = await checker.has_perm()
+            self.assertTrue(result[0])  # Should have permission
+            # When permission is granted, message is None
+
+        asyncio.run(test_async())
+
+    def test_has_perm_with_anonymous_user(self):
+        """Test permission check with anonymous user"""
+        checker = AppPermChecker({
+            'user': None,
+            'url_route': {'kwargs': {'id': self.app_id}}
+        })
+
+        async def test_async():
+            result = await checker.has_perm()
+            self.assertFalse(result[0])  # Should not have permission
+            self.assertEqual(result[1], "user not login")
+
+        asyncio.run(test_async())
+
+    def test_has_perm_with_nonexistent_app(self):
+        """Test permission check with non-existent app"""
+        checker = AppPermChecker({
+            'user': self.user,
+            'url_route': {'kwargs': {'id': 'nonexistent-app'}}
+        })
+
+        async def test_async():
+            result = await checker.has_perm()
+            self.assertFalse(result[0])  # Should not have permission
+            self.assertEqual(result[1], "app not exists")
+
+        asyncio.run(test_async())
+
+    def test_has_perm_with_cache(self):
+        """Test permission check with cached permissions"""
+        checker = AppPermChecker({
+            'user': self.user,
+            'url_route': {'kwargs': {'id': self.app_id}}
+        })
+
+        # Set cache
+        cache_key = f"permission:user:{self.user.id}:app:{self.app_id}"
+        cache.set(cache_key, (True, "cached permission"))
+
+        async def test_async():
+            result = await checker.has_perm()
+            self.assertTrue(result[0])
+            self.assertEqual(result[1], "cached permission")
+
+        asyncio.run(test_async())
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class BaseAppConsumerTest(ConsumerTestCase):
+    """Tests for BaseAppConsumer"""
+
+    def test_connect_with_permission(self, mock_requests):
+        """Test WebSocket connection with valid permissions"""
+        async def test_async():
+            # Mock the has_perm method to return True
+            async def mock_has_perm():
+                return (True, "permission granted")
+
+            with patch.object(AppPermChecker, 'has_perm', side_effect=mock_has_perm):
+                with patch.object(BaseAppConsumer, 'accept') as mock_accept:
+                    consumer = BaseAppConsumer()
+                    consumer.scope = {
+                        'user': self.user,
+                        'url_route': {'kwargs': {'id': self.app_id}}
+                    }
+
+                    await consumer.connect()
+                    mock_accept.assert_called_once()
+
+        asyncio.run(test_async())
+
+    def test_connect_without_permission(self, mock_requests):
+        """Test WebSocket connection without valid permissions"""
+        async def test_async():
+            # Mock the has_perm method to return False
+            async def mock_has_perm():
+                return (False, "permission denied")
+
+            with patch.object(AppPermChecker, 'has_perm', side_effect=mock_has_perm):
+                consumer = BaseAppConsumer()
+                consumer.scope = {
+                    'user': self.user,
+                    'url_route': {'kwargs': {'id': self.app_id}}
+                }
+
+                with self.assertRaises(DenyConnection):
+                    await consumer.connect()
+
+        asyncio.run(test_async())
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class BaseK8sConsumerTest(ConsumerTestCase):
+    """Tests for BaseK8sConsumer"""
+
+    def test_kubernetes_property(self, mock_requests):
+        """Test kubernetes property initialization"""
+        consumer = BaseK8sConsumer()
+
+        # Mock the token file reading and Kubernetes API client
+        with patch('builtins.open', mock.mock_open(read_data='mock-token')):
+            with patch('api.consumers.Configuration') as mock_config:
+                with patch('api.consumers.core_v1_api.CoreV1Api') as mock_api:
+                    mock_config_instance = MagicMock()
+                    mock_config.return_value = mock_config_instance
+
+                    consumer.kubernetes
+
+                    # Verify configuration was called
+                    mock_config.assert_called_once()
+                    mock_api.assert_called_once()
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class AppPodLogsConsumerTest(ConsumerTestCase):
+    """Tests for AppPodLogsConsumer"""
+
+    def test_connect(self, mock_requests):
+        """Test WebSocket connection for pod logs"""
+        async def test_async():
+            with patch.object(BaseAppConsumer, 'connect') as mock_super_connect:
+                consumer = AppPodLogsConsumer()
+                consumer.scope = {
+                    'user': self.user,
+                    'url_route': {'kwargs': {'name': 'test-pod', 'id': self.app_id}}
+                }
+
+                await consumer.connect()
+
+                mock_super_connect.assert_called_once()
+                self.assertFalse(consumer.running)
+                self.assertIsNone(consumer.response)
+                self.assertTrue(consumer.conneted)
+                self.assertEqual(consumer.buffer, b'')
+                self.assertEqual(consumer.pod_name, 'test-pod')
+
+        asyncio.run(test_async())
+
+    def test_receive_log_request(self, mock_requests):
+        """Test receiving log request message"""
+        async def test_async():
+            consumer = AppPodLogsConsumer()
+            consumer.scope = {
+                'user': self.user,
+                'url_route': {'kwargs': {'name': 'test-pod', 'id': self.app_id}}
+            }
+            consumer.running = False
+            consumer.id = self.app_id
+            consumer.pod_name = 'test-pod'
+
+            # Mock kubernetes client
+            mock_k8s = MagicMock()
+            mock_response = MagicMock()
+            mock_k8s.read_namespaced_pod_log.return_value = mock_response
+
+            with patch.object(type(consumer), 'kubernetes', new_callable=lambda: mock_k8s):
+                # Create a proper async function instead of using AsyncMock
+                async def mock_sync_to_async_func(*args, **kwargs):
+                    return mock_response
+
+                with patch('api.consumers.sync_to_async') as mock_sync_to_async:
+                    mock_sync_to_async.return_value = mock_sync_to_async_func
+                    with patch.object(consumer, 'send'):
+                        with patch.object(consumer, 'close'):
+
+                            test_data = {
+                                "lines": 100,
+                                "follow": False,
+                                "previous": False,
+                                "container": "web"
+                            }
+
+                            await consumer.receive(text_data=json.dumps(test_data))
+
+                            self.assertTrue(consumer.running)
+
+        asyncio.run(test_async())
+
+    def test_disconnect(self, mock_requests):
+        """Test WebSocket disconnection"""
+        async def test_async():
+            consumer = AppPodLogsConsumer()
+            consumer.conneted = True
+            consumer.response = MagicMock()
+            consumer.response.connection.sock = MagicMock()
+
+            with patch('asyncio.get_event_loop') as mock_loop:
+                mock_event_loop = MagicMock()
+                mock_loop.return_value = mock_event_loop
+                with patch('api.consumers.sync_to_async') as mock_sync_to_async:
+                    # Create a proper async function instead of AsyncMock
+                    async def mock_sync_close():
+                        pass
+                    mock_sync_to_async.return_value = mock_sync_close
+
+                    await consumer.disconnect(1000)
+
+                    self.assertFalse(consumer.conneted)
+
+        asyncio.run(test_async())
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class AppPodExecConsumerTest(ConsumerTestCase):
+    """Tests for AppPodExecConsumer"""
+
+    def test_connect(self, mock_requests):
+        """Test WebSocket connection for pod exec"""
+        async def test_async():
+            with patch.object(BaseAppConsumer, 'connect') as mock_super_connect:
+                consumer = AppPodExecConsumer()
+                consumer.scope = {
+                    'user': self.user,
+                    'url_route': {'kwargs': {'name': 'test-pod', 'id': self.app_id}}
+                }
+                await consumer.connect()
+                mock_super_connect.assert_called_once()
+                self.assertIsNone(consumer.stream)
+                self.assertTrue(consumer.conneted)
+                self.assertEqual(consumer.pod_name, 'test-pod')
+
+        asyncio.run(test_async())
+
+    def test_send_stdout(self, mock_requests):
+        """Test sending stdout data"""
+        async def test_async():
+            consumer = AppPodExecConsumer()
+
+            with patch.object(consumer.__class__.__bases__[0], 'send') as mock_super_send:
+                # Test sending bytes data
+                await consumer.send(b'test output', channel=1)  # STDOUT_CHANNEL
+                mock_super_send.assert_called_with(bytes_data=b'\x01test output')
+
+                # Test sending string data
+                await consumer.send('test string', channel=1)
+                mock_super_send.assert_called_with(text_data='\x01test string')
+
+        asyncio.run(test_async())
+
+    def test_send_none_data(self, mock_requests):
+        """Test sending None data"""
+        async def test_async():
+            consumer = AppPodExecConsumer()
+
+            with patch.object(consumer.__class__.__bases__[0], 'send') as mock_super_send:
+                await consumer.send(None)
+                mock_super_send.assert_not_called()
+
+        asyncio.run(test_async())
+
+    def test_receive_exec_request(self, mock_requests):
+        """Test receiving exec request"""
+        async def test_async():
+            consumer = AppPodExecConsumer()
+            consumer.scope = {
+                'user': self.user,
+                'url_route': {'kwargs': {'name': 'test-pod', 'id': self.app_id}}
+            }
+            consumer.stream = None
+            consumer.pod_name = 'test-pod'
+            consumer.id = self.app_id
+
+            exec_request = {
+                "command": ["/bin/bash"],
+                "stdin": True,
+                "tty": True
+            }
+
+            # Mock the kubernetes property
+            mock_k8s = MagicMock()
+            mock_k8s.connect_get_namespaced_pod_exec = MagicMock()
+
+            with patch.object(type(consumer), 'kubernetes', new_callable=lambda: mock_k8s):
+                with patch('api.consumers.sync_to_async') as mock_sync_to_async:
+                    with patch('api.consumers.stream'):
+                        mock_stream_obj = MagicMock()
+                        # Create a proper async function instead of AsyncMock
+
+                        async def mock_sync_to_async_func(*args, **kwargs):
+                            return mock_stream_obj
+                        mock_sync_to_async.return_value = mock_sync_to_async_func
+
+                        # Mock the task method to avoid creating unawaited coroutine
+                        async def mock_task():
+                            pass
+                        with patch.object(
+                            consumer, 'task', side_effect=mock_task
+                        ) as mock_task_method:
+                            await consumer.receive(text_data=json.dumps(exec_request))
+                            self.assertEqual(consumer.stream, mock_stream_obj)
+                            mock_task_method.assert_called_once()
+
+        asyncio.run(test_async())
+
+    def test_receive_command_data(self, mock_requests):
+        """Test receiving command input data"""
+        async def test_async():
+            consumer = AppPodExecConsumer()
+            mock_stream = MagicMock()
+            consumer.stream = mock_stream
+
+            with patch('api.consumers.sync_to_async') as mock_sync_to_async:
+                # Create a proper async function instead of AsyncMock
+                async def mock_write_stdin(channel, data):
+                    pass
+                mock_sync_to_async.return_value = mock_write_stdin
+
+                # Test text data
+                await consumer.receive(text_data='\x00test input')
+                mock_sync_to_async.assert_called()
+
+        asyncio.run(test_async())
+
+    def test_receive_invalid_operation(self, mock_requests):
+        """Test receiving data when stream is None and no valid text data"""
+        async def test_async():
+            consumer = AppPodExecConsumer()
+            consumer.stream = None
+
+            with self.assertRaises(ValueError) as context:
+                await consumer.receive(bytes_data=b'invalid')
+
+            self.assertEqual(str(context.exception), "This operation is not supported!")
+
+        asyncio.run(test_async())
+
+    def test_disconnect(self, mock_requests):
+        """Test WebSocket disconnection"""
+        async def test_async():
+            consumer = AppPodExecConsumer()
+            consumer.stream = MagicMock()
+            consumer.conneted = True
+
+            with patch('api.consumers.sync_to_async') as mock_sync_to_async:
+                # Create a proper async function instead of AsyncMock
+                async def mock_close_stream():
+                    pass
+                mock_sync_to_async.return_value = mock_close_stream
+
+                await consumer.disconnect(1000)
+
+                self.assertFalse(consumer.conneted)
+
+        asyncio.run(test_async())
+
+
+@requests_mock.Mocker(real_http=True, adapter=adapter)
+class FilerProxyConsumerTest(ConsumerTestCase):
+    """Tests for FilerProxyConsumer"""
+
+    def setUp(self):
+        super().setUp()
+        # Create a test volume using Volume.objects.create to avoid k8s calls
+        # We'll patch the save method to prevent k8s interactions
+        with patch.object(Volume, 'save_to_k8s'), patch.object(Volume, 'delete_from_k8s'):
+            self.test_volume = Volume.objects.create(
+                app=self.app,
+                name='test-volume',
+                size='1G',
+                path={'web': '/data'},
+                type='csi',
+                parameters={'csi': {'driver': 'test-driver'}}
+            )
+
+    def test_handle_without_permission(self, mock_requests):
+        """Test handling request without permission"""
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.scope = {
+                'user': self.user,
+                'path': f'/v2/apps/{self.app_id}/volumes/test-volume/filer/_/ping',
+                'url_route': {
+                    'kwargs': {'path': '_/ping', 'name': 'test-volume', 'id': self.app_id}
+                },
+                'method': 'GET',
+                'headers': []
+            }
+            # Set the id attribute that FilerProxyConsumer expects
+            consumer.id = self.app_id
+
+            async def mock_has_perm():
+                return (False, "permission denied")
+
+            async def mock_bind_func():
+                return {'endpoint': '', 'username': 'test', 'password': 'test'}
+
+            async def mock_send_response(*args, **kwargs):
+                pass
+
+            with patch.object(AppPermChecker, 'has_perm', side_effect=mock_has_perm):
+                with patch.object(
+                    consumer, 'send_response', side_effect=mock_send_response
+                ) as mock_send_response_obj:
+                    with patch('api.filer.FilerClient') as mock_filer_client:
+                        mock_client = MagicMock()
+                        mock_client.bind = mock_bind_func
+                        mock_filer_client.return_value = mock_client
+                        await consumer.handle(b'')
+                        mock_send_response_obj.assert_awaited_once_with(403, b'permission denied')
+
+        asyncio.run(test_async())
+
+    def test_handle_ping_endpoint(self, mock_requests):
+        """Test handling ping endpoint"""
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.scope = {
+                'user': self.user,
+                'path': f'/v2/apps/{self.app_id}/volumes/test-volume/filer/_/ping',
+                'url_route': {
+                    'kwargs': {'path': '_/ping', 'name': 'test-volume', 'id': self.app_id}
+                },
+                'method': 'GET',
+                'headers': []
+            }
+            # Set the id attribute that FilerProxyConsumer expects
+            consumer.id = self.app_id
+
+            async def mock_has_perm():
+                return (True, "permission granted")
+
+            async def mock_send_response(*args, **kwargs):
+                pass
+
+            with patch.object(AppPermChecker, 'has_perm', side_effect=mock_has_perm):
+                with patch.object(
+                    consumer, 'send_response', side_effect=mock_send_response
+                ) as mock_send_response_obj:
+                    # Create a proper async function instead of AsyncMock
+                    async def mock_bind_func():
+                        return {'username': 'test', 'password': 'test'}
+
+                    with patch('api.filer.FilerClient') as mock_filer_client:
+                        mock_client = MagicMock()
+                        mock_client.info = mock_client.bind = mock_bind_func
+                        mock_filer_client.return_value = mock_client
+
+                        await consumer.handle(b'')
+
+                        mock_send_response_obj.assert_called_once_with(200, b"pong")
+
+        asyncio.run(test_async())
+
+    def test_handle_bind_endpoint(self, mock_requests):
+        """Test handling bind endpoint"""
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.scope = {
+                'user': self.user,
+                'path': f'/v2/apps/{self.app_id}/volumes/test-volume/filer/',
+                'url_route': {
+                    'kwargs': {'path': '_/bind', 'name': 'test-volume', 'id': self.app_id}
+                },
+                'method': 'GET',
+                'headers': []
+            }
+            # Set the id attribute that FilerProxyConsumer expects
+            consumer.id = self.app_id
+
+            filer_data = {
+                'username': 'test-username',
+                'password': 'test-password'
+            }
+
+            async def mock_has_perm():
+                return (True, "permission granted")
+
+            async def mock_send_response(*args, **kwargs):
+                pass
+
+            with patch.object(AppPermChecker, 'has_perm', side_effect=mock_has_perm):
+                with patch.object(
+                    consumer, 'send_response', side_effect=mock_send_response
+                ) as mock_send_response_obj:
+                    # Create a proper async function instead of AsyncMock
+                    async def mock_bind_func():
+                        return filer_data
+
+                    with patch('api.filer.FilerClient') as mock_filer_client:
+                        mock_client = MagicMock()
+                        mock_client.bind = mock_bind_func
+                        mock_filer_client.return_value = mock_client
+
+                        await consumer.handle(b'')
+
+                        expected_response = json.dumps({
+                            "username": "test-username",
+                            "password": "test-password"
+                        }).encode('utf-8')
+
+                        mock_send_response_obj.assert_called_once_with(200, expected_response)
+
+        asyncio.run(test_async())
+
+    def test_handle_proxy_request(self, mock_requests):
+        """Test handling proxy request to filer"""
+        path = 'webdav/files/test.txt'
+
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.scope = {
+                'user': self.user,
+                'path': f'/v2/apps/{self.app_id}/volumes/test-volume/filer/{path}',
+                'url_route': {
+                    'kwargs': {'path': path, 'name': 'test-volume', 'id': self.app_id}
+                },
+                'method': 'GET',
+                'headers': [(b'host', b'example.com'), (b'user-agent', b'test-agent')],
+                'query_string': 'param=value'
+            }
+            # Set the id attribute that FilerProxyConsumer expects
+            consumer.id = self.app_id
+
+            filer_data = {
+                'endpoint': 'http://filer.example.com',
+                'username': 'test-username',
+                'password': 'test-password'
+            }
+
+            async def mock_has_perm():
+                return (True, "permission granted")
+
+            with patch.object(AppPermChecker, 'has_perm', side_effect=mock_has_perm):
+                with patch.object(consumer, '_handle_proxy_request') as mock_handle_proxy:
+                    # Create a proper async function instead of AsyncMock
+                    async def mock_bind_func():
+                        return filer_data
+
+                    with patch('api.filer.FilerClient') as mock_filer_client:
+                        mock_client = MagicMock()
+                        mock_client.info = mock_client.bind = mock_bind_func
+                        mock_filer_client.return_value = mock_client
+
+                        await consumer.handle(b'test body')
+
+                        expected_url = "http://filer.example.com{}?param=value".format(
+                            consumer.scope['path']
+                        )
+                        expected_headers = {'user-agent': 'test-agent'}
+
+                        mock_handle_proxy.assert_called_once_with(
+                            expected_url,
+                            'GET',
+                            expected_headers,
+                            b'test body'
+                        )
+
+        asyncio.run(test_async())
+
+    def test_forward_response(self, mock_requests):
+        """Test forwarding response from filer"""
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.config = MagicMock()
+            consumer.config.chunk_size = 1024
+
+            # Mock aiohttp response
+            mock_response = MagicMock()
+            mock_response.status = 200
+            mock_response.headers = {'content-type': 'application/json', 'content-length': '100'}
+
+            # Create an async iterator for iter_chunked
+            class AsyncIterator:
+                def __init__(self, items):
+                    self.items = iter(items)
+
+                def __aiter__(self):
+                    return self
+
+                async def __anext__(self):
+                    try:
+                        return next(self.items)
+                    except StopIteration:
+                        raise StopAsyncIteration
+
+            # Mock the iter_chunked method to return our async iterator
+            mock_response.content.iter_chunked = MagicMock(
+                return_value=AsyncIterator([b'chunk1', b'chunk2', b''])
+            )
+
+            # Mock async methods properly
+            async def mock_send_response(*args, **kwargs):
+                pass
+
+            async def mock_send_body(*args, **kwargs):
+                pass
+
+            async def mock_send_headers(*args, **kwargs):
+                pass
+
+            with patch.object(
+                consumer, 'send_body', side_effect=mock_send_body
+            ) as mock_send_body_obj:
+                with patch.object(
+                    consumer, 'send_headers', side_effect=mock_send_headers
+                ) as mock_send_headers_obj:
+                    await consumer._forward_response(mock_response)
+                    # Check that response headers were sent
+                    expected_headers = [
+                        [b'content-type', b'application/json'],
+                        [b'content-length', b'100']
+                    ]
+                    mock_send_headers_obj.assert_called_once_with(
+                        status=200,
+                        headers=expected_headers
+                    )
+                    # Check that body chunks were sent
+                    self.assertEqual(mock_send_body_obj.call_count, 3)
+
+        asyncio.run(test_async())
+
+    def test_handle_proxy_request_connection_error(self, mock_requests):
+        """Test handling connection error during proxy request"""
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.config = MagicMock()
+            consumer.config.timeout = aiohttp.ClientTimeout(total=30)
+
+            # Mock the entire session context manager flow
+            mock_session_instance = MagicMock()
+
+            async def mock_aenter(self):
+                return mock_session_instance
+
+            async def mock_aexit(self, *args):
+                return None
+            mock_session_instance.__aenter__ = mock_aenter
+            mock_session_instance.__aexit__ = mock_aexit
+
+            # Mock the request context manager to raise an exception
+            mock_request_cm = MagicMock()
+
+            async def mock_request_aenter(self):
+                raise aiohttp.ClientError("Connection failed")
+
+            async def mock_request_aexit(self, *args):
+                return None
+            mock_request_cm.__aenter__ = mock_request_aenter
+            mock_request_cm.__aexit__ = mock_request_aexit
+            mock_session_instance.request = MagicMock(return_value=mock_request_cm)
+
+            async def mock_send_response(*args, **kwargs):
+                pass
+
+            with patch('aiohttp.ClientSession', return_value=mock_session_instance):
+                with patch.object(
+                    consumer, 'send_response', side_effect=mock_send_response
+                ):
+                    await consumer._handle_proxy_request(
+                        'http://example.com/test',
+                        'GET',
+                        {},
+                        b'proxy service unavailable: Connection failed'
+                    )
+
+        asyncio.run(test_async())
+
+    def test_handle_proxy_request_timeout(self, mock_requests):
+        """Test handling timeout during proxy request"""
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.config = MagicMock()
+            consumer.config.timeout = aiohttp.ClientTimeout(total=30)
+
+            # Mock the entire session context manager flow
+            mock_session_instance = MagicMock()
+
+            async def mock_aenter(self):
+                return mock_session_instance
+
+            async def mock_aexit(self, *args):
+                return None
+            mock_session_instance.__aenter__ = mock_aenter
+            mock_session_instance.__aexit__ = mock_aexit
+
+            # Mock the request context manager to raise a timeout
+            mock_request_cm = MagicMock()
+
+            async def mock_request_aenter(self):
+                raise asyncio.TimeoutError()
+
+            async def mock_request_aexit(self, *args):
+                return None
+            mock_request_cm.__aenter__ = mock_request_aenter
+            mock_request_cm.__aexit__ = mock_request_aexit
+            mock_session_instance.request = MagicMock(return_value=mock_request_cm)
+
+            with patch('aiohttp.ClientSession', return_value=mock_session_instance):
+                with patch.object(consumer, 'send_response') as mock_send_response:
+                    await consumer._handle_proxy_request(
+                        'http://example.com/test',
+                        'GET',
+                        {},
+                        b''
+                    )
+
+                    mock_send_response.assert_called_once_with(
+                        504,
+                        b'proxy request to backend filer timeout'
+                    )
+
+        asyncio.run(test_async())
+
+    def test_skip_request_headers(self, mock_requests):
+        """Test that certain headers are skipped in proxy requests"""
+        consumer = FilerProxyConsumer()
+
+        # Verify that SKIP_REQUEST_HEADERS contains expected headers
+        expected_skip_headers = {
+            'host', 'connection', 'keep-alive', 'proxy-connection',
+            'te', 'trailers', 'upgrade'
+        }
+
+        self.assertEqual(consumer.SKIP_REQUEST_HEADERS, expected_skip_headers)
+
+    def test_skip_response_headers(self, mock_requests):
+        """Test that certain headers are skipped in proxy responses"""
+        consumer = FilerProxyConsumer()
+
+        # Verify that SKIP_RESPONSE_HEADERS contains expected headers
+        expected_skip_headers = {
+            'connection', 'keep-alive', 'proxy-authenticate',
+            'proxy-authorization', 'te', 'trailers',
+            'transfer-encoding', 'upgrade'
+        }
+
+        self.assertEqual(consumer.SKIP_RESPONSE_HEADERS, expected_skip_headers)
+
+    def test_get_client_with_existing_volume(self, mock_requests):
+        """Test _get_client method with existing volume"""
+        path = 'test/file.txt'
+
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.scope = {
+                'user': self.user,
+                'url_route': {
+                    'kwargs': {'path': path, 'name': 'test-volume', 'id': self.app_id}
+                }
+            }
+            consumer.id = self.app_id
+
+            with patch('api.filer.FilerClient') as mock_filer_client:
+                mock_client = MagicMock()
+                mock_filer_client.return_value = mock_client
+                # Test the _get_client method
+                client = await consumer._get_client(path)
+                # Verify that FilerClient was called with correct parameters
+                mock_filer_client.assert_called_once_with(self.app_id, self.test_volume, path)
+                self.assertEqual(client, mock_client)
+
+        asyncio.run(test_async())
+
+    def test_get_client_with_nonexistent_volume(self, mock_requests):
+        """Test _get_client method with non-existent volume"""
+        path = 'test/file.txt'
+
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.scope = {
+                'user': self.user,
+                'url_route': {
+                    'kwargs': {
+                        'path': path, 'name': 'nonexistent-volume', 'id': self.app_id,
+                    }
+                }
+            }
+            consumer.id = self.app_id
+            # Test the _get_client method
+            client = await consumer._get_client(path)
+            # Should return None when volume doesn't exist
+            self.assertIsNone(client)
+
+        asyncio.run(test_async())
+
+    def test_handle_with_volume_not_found(self, mock_requests):
+        """Test handling request when volume is not found"""
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            path = 'test/file.txt'
+            consumer.scope = {
+                'user': self.user,
+                'path': f'/v2/apps/{self.app_id}/volumes/nonexistent-volume/filer/{path}',
+                'url_route': {
+                    'kwargs': {
+                        'path': path, 'name': 'nonexistent-volume', 'id': self.app_id,
+                    }
+                },
+                'method': 'GET',
+                'headers': []
+            }
+            consumer.id = self.app_id
+
+            async def mock_has_perm():
+                return (True, "permission granted")
+
+            async def mock_send_response(*args, **kwargs):
+                pass
+
+            with patch.object(AppPermChecker, 'has_perm', side_effect=mock_has_perm):
+                with patch.object(
+                    consumer, 'send_response', side_effect=mock_send_response
+                ) as mock_send_response_obj:
+                    await consumer.handle(b'test body')
+                    mock_send_response_obj.assert_called_once_with(
+                        status=404,
+                        body=b'app or volume not found'
+                    )
+
+        asyncio.run(test_async())
+
+    def test_handle_with_existing_volume(self, mock_requests):
+        """Test handling request with existing volume"""
+        path = 'webdav/test/file.txt'
+
+        async def test_async():
+            consumer = FilerProxyConsumer()
+            consumer.scope = {
+                'user': self.user,
+                'path': f'/v2/apps/{self.app_id}/volumes/nonexistent-volume/filer/{path}',
+                'url_route': {
+                    'kwargs': {'path': path, 'name': 'test-volume', 'id': self.app_id}
+                },
+                'method': 'GET',
+                'headers': [(b'host', b'example.com'), (b'user-agent', b'test-agent')],
+                'query_string': 'param=value'
+            }
+            consumer.id = self.app_id
+
+            filer_data = {
+                'endpoint': 'http://filer.example.com',
+                'username': 'test-username',
+                'password': 'test-password'
+            }
+
+            async def mock_has_perm():
+                return (True, "permission granted")
+
+            with patch.object(AppPermChecker, 'has_perm', side_effect=mock_has_perm):
+                with patch.object(consumer, '_handle_proxy_request') as mock_handle_proxy:
+                    # Create a proper async function instead of AsyncMock
+                    async def mock_bind_func():
+                        return filer_data
+
+                    with patch('api.filer.FilerClient') as mock_filer_client:
+                        mock_client = MagicMock()
+                        mock_client.bind = mock_client.info = mock_bind_func
+                        mock_filer_client.return_value = mock_client
+
+                        await consumer.handle(b'test body')
+
+                        expected_url = "http://filer.example.com{}?param=value".format(
+                            consumer.scope['path']
+                        )
+                        expected_headers = {'user-agent': 'test-agent'}
+
+                        mock_handle_proxy.assert_called_once_with(
+                            expected_url,
+                            'GET',
+                            expected_headers,
+                            b'test body'
+                        )
+
+        asyncio.run(test_async())
diff --git a/rootfs/api/tests/test_domain.py b/rootfs/api/tests/test_domain.py
index 7b310ca8d..918eea36d 100644
--- a/rootfs/api/tests/test_domain.py
+++ b/rootfs/api/tests/test_domain.py
@@ -45,10 +45,9 @@ def test_response_data(self):
 
         for key in response.data:
             self.assertIn(
-                key, ['uuid', 'owner', 'created', 'updated', 'app', 'domain', 'ptype'])
+                key, ['uuid', 'created', 'updated', 'app', 'domain', 'ptype'])
 
         expected = {
-            'owner': self.user.username,
             'app': app_id,
             'domain': 'test-domain.example.com'
         }
diff --git a/rootfs/api/tests/test_event.py b/rootfs/api/tests/test_event.py
index cf44d61b2..2b3b0d717 100644
--- a/rootfs/api/tests/test_event.py
+++ b/rootfs/api/tests/test_event.py
@@ -25,11 +25,7 @@ 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]
+        self.app = App.objects.get(id=self.create_app())
 
     def tearDown(self):
         # make sure every test has a clean slate for k8s mocking
diff --git a/rootfs/api/tests/test_gateway.py b/rootfs/api/tests/test_gateway.py
index 1bd42d31f..dda6e3119 100644
--- a/rootfs/api/tests/test_gateway.py
+++ b/rootfs/api/tests/test_gateway.py
@@ -43,13 +43,11 @@ def create_app_with_domain_and_deploy(self):
         self.assertEqual(len(response.data["results"]), 0, response.data)
         # create a release so we can scale
         app = App.objects.get(id=app_id)
-        user = User.objects.get(username='autotest')
-        build = Build.objects.create(owner=user, app=app, image="qwerty")
+        build = Build.objects.create(app=app, image="qwerty")
 
         # create an initial release
         release = Release.objects.create(
             version=2,
-            owner=user,
             app=app,
             config=app.config_set.latest(),
             build=build
@@ -152,7 +150,6 @@ def test_add_listener(self):
                 break
         expect = {
             "app": app_id,
-            "owner": "autotest",
             "name": gateway_name,
             "listeners": [
                 {
@@ -195,7 +192,7 @@ def test_add_tls_listener(self):
         name = "tls-gateway"
         _, domain, secret_name, results = self.add_tls_listener(name, "TLS", port)
         expect = [{
-            'app': name, 'owner': 'autotest', 'name': name,
+            'app': name, 'name': name,
             'listeners': [{
                 'allowedRoutes': {'namespaces': {'from': 'All'}},
                 'name': 'tcp-443-1', 'port': 443, 'hostname': domain,
@@ -213,7 +210,6 @@ def test_add_https_listener(self):
         _, domain, secret_name, results = self.add_tls_listener(name, "HTTPS", port)
         expect = [{
             'app': name,
-            'owner': 'autotest',
             'name': name,
             'listeners': [{
                 'allowedRoutes': {
@@ -244,7 +240,7 @@ def test_add_http_listener(self):
         name = "bingo-gateway"
         _, domain, secret_name, results = self.add_tls_listener(name, "HTTP", port)
         expect = [{
-            'app': 'bingo-gateway', 'owner': 'autotest', 'name': 'bingo-gateway',
+            'app': 'bingo-gateway', 'name': 'bingo-gateway',
             'listeners': [
                 {
                     'allowedRoutes': {'namespaces': {'from': 'All'}},
@@ -280,7 +276,6 @@ def test_add_udp_listener(self):
         _, results = self.add_listener(app_id, name, "UDP", port)
         expect = [{
             'app': 'bingo-gateway',
-            'owner': 'autotest',
             'name': 'bingo-gateway',
             'listeners': [{
                 'allowedRoutes': {
@@ -376,7 +371,6 @@ def test_change_tls(self):
         self.assertEqual(response.data["count"], 1, response.data)
         expect = [{
             'app': app_id,
-            'owner': 'autotest',
             'name': app_id,
             'listeners': [
                 {
@@ -666,14 +660,14 @@ def test_app_settings_change_routable(self):
         )
         self.assertEqual(response.status_code, 201, response.data)
         app = App.objects.get(id=app_id)
-        response = app.scheduler().httproute.get(app_id, route_name, ignore_exception=True)
+        response = app.scheduler.httproute.get(app_id, route_name, ignore_exception=True)
         self.assertEqual(response.status_code, 404)
         # Set routable to false
         response = self.client.post(
             f'/v2/apps/{app_id}/settings',
             {'routable': True}
         )
-        response = app.scheduler().httproute.get(app_id, route_name, ignore_exception=True)
+        response = app.scheduler.httproute.get(app_id, route_name, ignore_exception=True)
         self.assertEqual(response.status_code, 200)
 
     def test_route_delete(self):
@@ -848,11 +842,11 @@ def test_route_parent_refs(self):
         )
         self.assertEqual(response.status_code, 201, response.data)
         route = Route.objects.get(name=route_name)
-        response = route.scheduler().httproutes.get(app_id, route_name)
+        response = route.scheduler.httproutes.get(app_id, route_name)
         self.assertEqual(response.status_code, 200, response.json())
         self.assertEqual(len(response.json()['spec']['parentRefs']), 1)
         # get gateway
-        response = route.scheduler().gateways.get(app_id, app_id)
+        response = route.scheduler.gateways.get(app_id, app_id)
         listeners = [
             listener for listener in response.json()['spec']['listeners']
             if listener['port'] == 443
diff --git a/rootfs/api/tests/test_hooks.py b/rootfs/api/tests/test_hooks.py
index 0056d4a20..970ec5b5d 100644
--- a/rootfs/api/tests/test_hooks.py
+++ b/rootfs/api/tests/test_hooks.py
@@ -8,7 +8,6 @@
 from django.core.cache import cache
 
 from api.tests import adapter, DryccTransactionTestCase
-from api.models.app import app_permission_registry
 import requests_mock
 
 User = get_user_model()
@@ -70,15 +69,6 @@ def test_key_hook(self, mock_requests):
         # Create app to use
         app_id = self.create_app()
 
-        # give user permission to app
-        body = {
-            'username': str(self.user),
-            'permissions': ','.join(app_permission_registry.shortnames),
-        }
-        url = f'/v2/apps/{app_id}/perms/'
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, response.data)
-
         # Create rsa key
         body = {'id': str(self.user), 'public': RSA_PUBKEY}
         response = self.client.post('/v2/keys', body)
@@ -110,12 +100,12 @@ def test_key_hook(self, mock_requests):
 
         # Make sure 404 is returned for a random app
         url = '/v2/hooks/keys/doesnotexist'
-        response = self.client.get(url, HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+        response = self.client.get(url, HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 404)
 
         # Test app that exists
         url = '/v2/hooks/keys/{}'.format(app_id)
-        response = self.client.get(url, HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+        response = self.client.get(url, HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertEqual(response.data, {"autotest": [
             {'key': rsa_pub, 'fingerprint': '54:6d:da:1f:91:b5:2b:6f:a2:83:90:c4:f9:73:76:f5'},
@@ -126,12 +116,12 @@ def test_key_hook(self, mock_requests):
 
         # Test against an app that exist but user does not
         url = '/v2/hooks/keys/{}/foooooo'.format(app_id)
-        response = self.client.get(url, HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+        response = self.client.get(url, HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 404)
 
         # Test against an app that exists and user that does
         url = '/v2/hooks/keys/{}/{}'.format(app_id, str(self.user))
-        response = self.client.get(url, HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+        response = self.client.get(url, HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertEqual(response.data, {"autotest": [
             {'key': rsa_pub, 'fingerprint': '54:6d:da:1f:91:b5:2b:6f:a2:83:90:c4:f9:73:76:f5'},
@@ -143,7 +133,7 @@ def test_key_hook(self, mock_requests):
 
         # Fetch a valid ssh key
         url = '/v2/hooks/key/54:6d:da:1f:91:b5:2b:6f:a2:83:90:c4:f9:73:76:f5'
-        response = self.client.get(url, HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+        response = self.client.get(url, HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertEqual(response.data, {
             "username": str(self.user),
@@ -154,12 +144,12 @@ def test_key_hook(self, mock_requests):
 
         # Fetch an non-existent base64 encoded ssh key
         url = '/v2/hooks/key/54:6d:da:1f:91:b5:2b:6f:a2:83:90:c4:f9:73:76:wooooo'
-        response = self.client.get(url, HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+        response = self.client.get(url, HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 404)
 
         # Fetch an invalid (not encoded) ssh key
         url = '/v2/hooks/key/nope'
-        response = self.client.get(url, HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+        response = self.client.get(url, HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 404)
 
     def test_build_hook(self, mock_requests):
@@ -174,9 +164,9 @@ def test_build_hook(self, mock_requests):
         self.client.credentials()
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 401, response.data)
-        # post the build with the builder auth key
+        # post the build with the service key
         response = self.client.post(url, body,
-                                    HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+                                    HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertIn('release', response.data)
         self.assertIn('version', response.data['release'])
@@ -195,9 +185,9 @@ def test_build_hook_slug_url(self, mock_requests):
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 401, response.data)
 
-        # post the build with the builder auth key
+        # post the build with the service key
         response = self.client.post(url, body,
-                                    HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+                                    HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertIn('release', response.data)
         self.assertIn('version', response.data['release'])
@@ -217,9 +207,9 @@ def test_build_hook_procfile(self, mock_requests):
                 'sha': SHA,
                 'procfile': PROCFILE}
 
-        # post the build with the builder auth key
+        # post the build with the service key
         response = self.client.post(url, body,
-                                    HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+                                    HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertIn('release', response.data)
         self.assertIn('version', response.data['release'])
@@ -258,9 +248,9 @@ def test_build_hook_dockerfile(self, mock_requests):
                 'stack': 'container',
                 'sha': SHA,
                 'dockerfile': DOCKERFILE}
-        # post the build with the builder auth key
+        # post the build with the service key
         response = self.client.post(url, body,
-                                    HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+                                    HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertIn('release', response.data)
         self.assertIn('version', response.data['release'])
@@ -298,9 +288,9 @@ def test_config_hook(self, mock_requests):
         self.client.credentials()
         response = self.client.post(url, body)
         self.assertEqual(response.status_code, 401, response.data)
-        # post with the builder auth key
+        # post with the service key
         response = self.client.post(url, body,
-                                    HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+                                    HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertIn('values', response.data)
         self.assertEqual(values, response.data['values'])
@@ -327,6 +317,6 @@ def test_admin_can_hook(self, mock_requests):
                 'dockerfile': DOCKERFILE}
         url = '/v2/hooks/build'
         response = self.client.post(url, body,
-                                    HTTP_X_DRYCC_BUILDER_AUTH=settings.BUILDER_KEY)
+                                    HTTP_X_DRYCC_SERVICE_KEY=settings.SERVICE_KEY)
         self.assertEqual(response.status_code, 200, response.data)
         self.assertEqual(response.data['release']['version'], 2)
diff --git a/rootfs/api/tests/test_perm.py b/rootfs/api/tests/test_perm.py
index 21055e2cf..96ec20e7a 100644
--- a/rootfs/api/tests/test_perm.py
+++ b/rootfs/api/tests/test_perm.py
@@ -1,319 +1,191 @@
 from django.test import tag
 from django.contrib.auth import get_user_model
+
 from api.tests import DryccTestCase
-from api.models.app import App, app_permission_registry, \
-    VIEW_APP_PERMISSION, CHANGE_APP_PERMISSION
+from api.models.workspace import WorkspaceMember
+from api.models.app import App
+
 
 User = get_user_model()
 
 
-class TestUserPerm(DryccTestCase):
+class TestWorkspacePerm(DryccTestCase):
 
-    fixtures = ['test_sharing.json']
+    fixtures = ['tests.json']
 
     @tag('auth')
     def setUp(self):
-        self.user = User.objects.get(username='autotest-1')
+        self.user = User.objects.get(username='autotest')
         self.token = self.get_or_create_token(self.user)
-        # Always have first user authed coming into tests
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
 
-        self.user2 = User.objects.get(username='autotest-2')
+        self.user2 = User.objects.get(username='autotest2')
         self.token2 = self.get_or_create_token(self.user2)
-        self.user3 = User.objects.get(username='autotest-3')
-        self.token3 = self.get_or_create_token(self.user3)
 
     @tag('auth')
-    def test_create(self):
-        # check that user 1 sees her lone app and user 2's app
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        response = self.client.get('/v2/apps')
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 2)
-        app_id = response.data['results'][0]['id']
-
-        # check that user 2 can only see his app
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.get('/v2/apps')
-        self.assertEqual(len(response.data['results']), 1)
-        # check that user 2 can't see any of the app's builds, configs,
-        # containers, limits, or releases
-        for model in ['builds', 'config', 'pods', 'releases']:
-            response = self.client.get("/v2/apps/{}/{}/".format(app_id, model))
-            msg = "Failed: status '%s', and data '%s'" % (response.status_code, response.data)
-            self.assertEqual(response.status_code, 403, msg=msg)
-            self.assertEqual(response.data['detail'],
-                             'You do not have permission to perform this action.', msg=msg)
-
-        for app in App.objects.filter(owner=self.user):
-            body = {
-                'username': self.user2.username,
-                'permissions': ','.join(app_permission_registry.shortnames),
-            }
-            self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-            response = self.client.post(f'/v2/apps/{app.id}/perms/', body)
-            self.assertEqual(response.status_code, 201, response.data)
-
-        # check that user 2 can see the app
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.get('/v2/apps')
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 2)
+    def _create_workspace_and_app(self, ws_name='testws01', app_id='testapp01'):
+        response = self.client.post('/v2/workspaces', {
+            'name': ws_name,
+            'email': 'ws@example.com',
+        })
+        self.assertEqual(response.status_code, 201, response.data)
 
-        # check that user 2 sees (empty) results now for builds, containers,
-        # and releases. (config and limit will still give 404s since we didn't
-        # push a build here.)
-        for model in ['builds', 'releases']:
-            response = self.client.get("/v2/apps/{}/{}/".format(app_id, model))
-            self.assertEqual(len(response.data['results']), 0)
-        # TODO:  check that user 2 can git push the app
+        response = self.client.post('/v2/apps', {
+            'id': app_id,
+            'workspace': ws_name,
+        })
+        self.assertEqual(response.status_code, 201, response.data)
+        return ws_name, app_id
 
     @tag('auth')
-    def test_create_errors(self):
-        # check that user 1 sees her lone app
-        response = self.client.get('/v2/apps')
-        app_id = response.data['results'][0]['id']
-
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join(app_permission_registry.shortnames),
-        }
+    def test_workspace_member_can_access_app(self):
+        ws_name, app_id = self._create_workspace_and_app()
 
-        # check that user 2 can't create a permission
+        # non-member cannot access app
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.post(f'/v2/apps/{app_id}/perms/', body)
-        self.assertEqual(response.status_code, 403)
+        response = self.client.get(f'/v2/apps/{app_id}')
+        self.assertEqual(response.status_code, 404, response.data)
 
-    @tag('auth')
-    def test_delete(self):
-        # give user 2 permission to user 1's app
-        response = self.client.get('/v2/apps')
-        app_id = response.data['results'][0]['id']
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join(
-                [VIEW_APP_PERMISSION.shortname, CHANGE_APP_PERMISSION.shortname]),
-        }
-        response = self.client.post(f'/v2/apps/{app_id}/perms/', body)
-        self.assertEqual(response.status_code, 201, response.data)
+        # add user2 into workspace, then user2 can access app
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace
+        WorkspaceMember.objects.get_or_create(
+            user=self.user2,
+            workspace=workspace,
+            defaults={'role': 'member'},
+        )
 
-        # check that user 2 can see the app as well as his own
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.get('/v2/apps')
+        response = self.client.get(f'/v2/apps/{app_id}')
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 2)
 
-        # delete permission to user 1's app
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        url = f"/v2/apps/{app_id}/perms/{self.user2.username}/"
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, 204, response)
-        self.assertIsNone(response.data)
+    @tag('auth')
+    def test_non_admin_cannot_manage_workspace_members(self):
+        ws_name, _ = self._create_workspace_and_app(ws_name='testws02', app_id='testapp02')
 
-        # check that user 2 can only see his app
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.get('/v2/apps')
-        self.assertEqual(len(response.data['results']), 1)
+        workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace
+        WorkspaceMember.objects.get_or_create(
+            user=self.user2,
+            workspace=workspace,
+            defaults={'role': 'member'},
+        )
 
-        # delete permission to user 1's app again, expecting an error
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, 204)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
+        response = self.client.patch(
+            f'/v2/workspaces/{ws_name}/members/{self.user.username}',
+            {'role': 'viewer'},
+        )
+        self.assertEqual(response.status_code, 403, response.data)
 
     @tag('auth')
-    def test_list(self):
-        # check that user 1 sees her lone app and user 2's app
-        response = self.client.get('/v2/apps')
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 2)
-        app_id = response.data['results'][0]['id']
-
-        # create a new object permission
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join(app_permission_registry.shortnames),
-        }
-        response = self.client.post(f'/v2/apps/{app_id}/perms/', body)
-        self.assertEqual(response.status_code, 201, response.data)
+    def test_admin_can_manage_workspace_members(self):
+        ws_name, _ = self._create_workspace_and_app(ws_name='testws03', app_id='testapp03')
 
-        # list perms on the app
-        response = self.client.get(f"/v2/apps/{app_id}/perms/")
-        self.assertEqual(response.data["count"], 1)
+        workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace
+        WorkspaceMember.objects.get_or_create(
+            user=self.user2,
+            workspace=workspace,
+            defaults={'role': 'member'},
+        )
 
-    @tag('auth')
-    def test_admin_can_list(self):
-        """Check that an administrator can list an app's perms"""
-        response = self.client.get('/v2/apps')
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        response = self.client.patch(
+            f'/v2/workspaces/{ws_name}/members/{self.user2.username}',
+            {'role': 'viewer'},
+        )
         self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 2)
+        self.assertEqual(response.data['role'], 'viewer')
 
     @tag('auth')
-    def test_list_errors(self):
-        response = self.client.get('/v2/apps')
-        # login as user 2, list perms on the app
+    def test_non_member_cannot_run_app(self):
+        _, app_id = self._create_workspace_and_app(ws_name='testws04', app_id='testapp04')
+
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.get(f"/v2/apps/{self.user2.id}/perms/")
-        self.assertEqual(response.status_code, 404)
+        response = self.client.post(
+            f'/v2/apps/{app_id}/run',
+            {'command': 'echo hello'},
+        )
+        self.assertEqual(response.status_code, 404, response.data)
 
     @tag('auth')
-    def test_unauthorized_user_cannot_modify_perms(self):
-        """
-        An unauthorized user should not be able to modify other apps' permissions.
-
-        Since an unauthorized user should not know about the application at all, these
-        requests should return a 404.
-        """
-        app_id = 'autotest'
-        url = '/v2/apps'
-        body = {'id': app_id}
-        response = self.client.post(url, body)
-
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join(app_permission_registry.shortnames),
-        }
+    def test_workspace_member_can_run_but_without_build_gets_business_error(self):
+        ws_name, app_id = self._create_workspace_and_app(ws_name='testws05', app_id='testapp05')
+        workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace
+        WorkspaceMember.objects.get_or_create(
+            user=self.user2,
+            workspace=workspace,
+            defaults={'role': 'member'},
+        )
+
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.post(f'/v2/apps/{app_id}/perms/', body)
-        self.assertEqual(response.status_code, 403)
+        response = self.client.post(
+            f'/v2/apps/{app_id}/run',
+            {'command': 'echo hello'},
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        self.assertEqual(
+            str(response.data['detail']),
+            'no build available, please deploy a release',
+        )
 
     @tag('auth')
-    def test_collaborator_share(self):
-        """
-        An collaborator should not be able to modify the app's permissions.
-        """
-        app_id = "autotest-1-app"
-        owner_token = self.token
-        collab = self.user2
-        collab_token = self.token2
-
-        # Share app with collaborator
-        body = {
-            'username': collab.username,
-            'permissions': ','.join(app_permission_registry.shortnames),
-        }
-        url = f'/v2/apps/{app_id}/perms/'
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token)
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201, response.data)
+    def test_non_admin_cannot_update_workspace(self):
+        ws_name, _ = self._create_workspace_and_app(ws_name='testws06', app_id='testapp06')
+        workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace
+        WorkspaceMember.objects.get_or_create(
+            user=self.user2,
+            workspace=workspace,
+            defaults={'role': 'member'},
+        )
 
-        # Collaborator can share app
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token)
-        body = {
-            'username': self.user3.username,
-            'permissions': ','.join(app_permission_registry.shortnames),
-        }
-        response = self.client.post(url, body)
-        self.assertEqual(response.status_code, 201)
-
-        # Collaborator can list
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
+        response = self.client.patch(
+            f'/v2/workspaces/{ws_name}',
+            {'email': 'new@example.com'},
+        )
+        self.assertEqual(response.status_code, 403, response.data)
 
-        # Share app with user 3 for rest of tests
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + owner_token)
-        response = self.client.post(url, body)
+    @tag('auth')
+    def test_non_admin_cannot_transfer_app(self):
+        ws_name, app_id = self._create_workspace_and_app(ws_name='testws07', app_id='testapp07')
+        response = self.client.post('/v2/workspaces', {
+            'name': 'testws08',
+            'email': 'ws2@example.com',
+        })
         self.assertEqual(response.status_code, 201, response.data)
 
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token3)
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.data['count'], 2, response.data)
-
-        # Collaborator cannot delete other collaborator
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + collab_token)
-        url += "{}/".format(self.user3.username)
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, 204)
-        url = f'/v2/apps/{app_id}/perms/'
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.data['count'], 1, response.data)
-
-        # Collaborator can delete themselves
-        url += "{}/".format(self.user2.username)
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        response = self.client.delete(url)
-        self.assertEqual(response.status_code, 204)
-        url = f'/v2/apps/{app_id}/perms/'
-        response = self.client.get(url)
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(response.data['count'], 0, response.data)
+        workspace = WorkspaceMember.objects.get(user=self.user, workspace__name=ws_name).workspace
+        WorkspaceMember.objects.get_or_create(
+            user=self.user2,
+            workspace=workspace,
+            defaults={'role': 'member'},
+        )
 
-    @tag('auth')
-    def test_each_permission(self):
-        # check that user 1 sees her lone app and user 2's app
-        response = self.client.get('/v2/apps')
-        self.assertEqual(response.status_code, 200, response.data)
-        self.assertEqual(len(response.data['results']), 2)
-        app_id = response.data['results'][0]['id']
-
-        # list no perms on the app
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.get(f"/v2/apps/{app_id}/perms/")
-        self.assertEqual(response.status_code, 403)
+        response = self.client.patch(
+            f'/v2/apps/{app_id}',
+            {'workspace': 'testws08'},
+        )
+        self.assertEqual(response.status_code, 400, response.data)
+        self.assertEqual(
+            str(response.data['detail']),
+            'you must be an admin of the current workspace',
+        )
 
-        # create a new object permission
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join([VIEW_APP_PERMISSION.shortname]),
-        }
-        response = self.client.post(f'/v2/apps/{app_id}/perms/', body)
+    @tag('auth')
+    def test_admin_can_transfer_app(self):
+        _, app_id = self._create_workspace_and_app(ws_name='testws09', app_id='testapp09')
+        response = self.client.post('/v2/workspaces', {
+            'name': 'testws10',
+            'email': 'ws10@example.com',
+        })
         self.assertEqual(response.status_code, 201, response.data)
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        response = self.client.get(f"/v2/apps/{app_id}/perms/")
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.data['count'], 1, response.data)
-
-        # test no change permission
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join([VIEW_APP_PERMISSION.shortname]),
-        }
-        response = self.client.post(f'/v2/apps/{app_id}/perms/', body)
-        self.assertEqual(response.status_code, 403, response.data)
 
-        # test add change permission
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join(
-                [VIEW_APP_PERMISSION.shortname, CHANGE_APP_PERMISSION.shortname]),
-        }
-        response = self.client.post(f'/v2/apps/{app_id}/perms/', body)
-        self.assertEqual(response.status_code, 201)
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join(
-                [VIEW_APP_PERMISSION.shortname, CHANGE_APP_PERMISSION.shortname]),
-        }
-        response = self.client.post(f'/v2/apps/{app_id}/perms/', body)
-        self.assertEqual(response.status_code, 201)
-
-        # test no delete permission
-        response = self.client.delete(f'/v2/apps/{app_id}/perms/{self.user2.username}/')
-        self.assertEqual(response.status_code, 403)
-
-        # test update permisssion
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join([VIEW_APP_PERMISSION.shortname]),
-        }
-        response = self.client.put(f'/v2/apps/{app_id}/perms/{self.user2.username}/', body)
-        self.assertEqual(response.status_code, 204)
-        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
+        response = self.client.patch(
+            f'/v2/apps/{app_id}',
+            {'workspace': 'testws10'},
+        )
+        self.assertEqual(response.status_code, 204, response.data)
 
-        # test not update permission
-        body = {
-            'username': self.user2.username,
-            'permissions': ','.join([VIEW_APP_PERMISSION.shortname]),
-        }
-        response = self.client.put(f'/v2/apps/{app_id}/perms/{self.user2.username}/', body)
-        self.assertEqual(response.status_code, 403)
-
-        # has view permission
-        response = self.client.get(f"/v2/apps/{app_id}/perms/")
-        self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.data['count'], 1, response.data)
+        app = App.objects.get(id=app_id)
+        self.assertEqual(app.workspace.name, 'testws10')
diff --git a/rootfs/api/tests/test_pods.py b/rootfs/api/tests/test_pods.py
index 5b8a1266a..371b8208c 100644
--- a/rootfs/api/tests/test_pods.py
+++ b/rootfs/api/tests/test_pods.py
@@ -265,13 +265,11 @@ def test_container_errors(self, mock_requests):
 
         # create a release so we can scale
         app = App.objects.get(id=app_id)
-        user = User.objects.get(username='autotest')
-        build = Build.objects.create(owner=user, app=app, image="qwerty")
+        build = Build.objects.create(app=app, image="qwerty")
 
         # create an initial release
         release = Release.objects.create(
             version=2,
-            owner=user,
             app=app,
             config=app.config_set.latest(),
             build=build
@@ -474,9 +472,7 @@ def test_admin_can_manage_other_pods(self, mock_requests):
     def test_scale_without_build_should_error(self, mock_requests):
         """A user should not be able to scale processes unless a build is present."""
         app_id = 'autotest'
-        url = '/v2/apps'
-        body = {'cluster': 'autotest', 'id': app_id}
-        response = self.client.post(url, body)
+        self.create_app(name=app_id)
 
         url = f'/v2/apps/{app_id}/ptypes/scale'
         body = {'web': '1'}
@@ -489,11 +485,9 @@ def test_command_good(self, mock_requests):
         """Test the default command for each container workflow"""
         app_id = self.create_app()
         app = App.objects.get(id=app_id)
-        user = User.objects.get(username='autotest')
 
         # CNCF Buildpack app
         build = Build.objects.create(
-            owner=user,
             app=app,
             image="qwerty",
             procfile={
@@ -507,7 +501,6 @@ def test_command_good(self, mock_requests):
         # create an initial release
         release = Release.objects.create(
             version=2,
-            owner=user,
             app=app,
             config=app.config_set.latest(),
             build=build
@@ -551,7 +544,6 @@ def test_run_command_good(self, mock_requests):
 
         # dockerfile + procfile worflow
         build = Build.objects.create(
-            owner=self.user,
             app=app,
             image="qwerty",
             stack="heroku-18",
@@ -566,7 +558,6 @@ def test_run_command_good(self, mock_requests):
         # create an initial release
         release = Release.objects.create(
             version=2,
-            owner=self.user,
             app=app,
             config=app.config_set.latest(),
             build=build
@@ -613,7 +604,6 @@ def test_run_not_fail_on_debug(self, mock_requests):
 
         # dockerfile + procfile worflow
         build = Build.objects.create(
-            owner=self.user,
             app=app,
             image="qwerty",
             procfile={
@@ -627,7 +617,6 @@ def test_run_not_fail_on_debug(self, mock_requests):
         # create an initial release
         release = Release.objects.create(
             version=2,
-            owner=self.user,
             app=app,
             config=app.config_set.latest(),
             build=build
@@ -646,11 +635,9 @@ def test_scaling_does_not_add_run_proctypes_to_structure(self, mock_requests):
         """Test that app info doesn't show transient "run" proctypes."""
         app_id = self.create_app()
         app = App.objects.get(id=app_id)
-        user = User.objects.get(username='autotest')
 
         # dockerfile + procfile worflow
         build = Build.objects.create(
-            owner=user,
             app=app,
             image="qwerty",
             procfile={
@@ -664,7 +651,6 @@ def test_scaling_does_not_add_run_proctypes_to_structure(self, mock_requests):
         # create an initial release
         release = Release.objects.create(
             version=2,
-            owner=user,
             app=app,
             config=app.config_set.latest(),
             build=build
diff --git a/rootfs/api/tests/test_ptypes.py b/rootfs/api/tests/test_ptypes.py
index 64749c3fe..15a509bee 100644
--- a/rootfs/api/tests/test_ptypes.py
+++ b/rootfs/api/tests/test_ptypes.py
@@ -25,11 +25,7 @@ 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]
+        self.app = App.objects.get(id=self.create_app())
 
     def tearDown(self):
         # make sure every test has a clean slate for k8s mocking
diff --git a/rootfs/api/tests/test_release.py b/rootfs/api/tests/test_release.py
index 825b18918..88ef6d6e1 100644
--- a/rootfs/api/tests/test_release.py
+++ b/rootfs/api/tests/test_release.py
@@ -132,13 +132,14 @@ def test_conditions(self, mock_requests):
             self.assertEqual(response.status_code, 201, response.data)
             url = f'/v2/apps/{app_id}/releases/'
             response = self.client.get(url)
-            self.assertEqual(response.data['results'][0]['conditions'][0]['exception'], 'Boom!')
+            self.assertEqual(
+                response.data['results'][0]['conditions'][0]['exception'], 'Exception: Boom!')
 
     def test_get_image(self, mock_requests):
         app_id = self.create_app()
         url = f"/v2/apps/{app_id}/build"
         body = {
-            'image': '127.0.0.1:5555/autotest/example:git-fadf1231',
+            'image': 'registry.drycc.cc:5555/autotest/example:git-fadf1231',
             'stack': 'heroku-18',
             'sha': 'a'*40,
             'dryccfile': {
@@ -206,15 +207,15 @@ def test_get_image(self, mock_requests):
                         "deploy": {
                             "command": ["bash", "-c"],
                             "args": ["bundle exec puma -C config/puma.rb"],
-                            "image": "127.0.0.1:7070/myapp/web:git-123fsa1",
+                            "image": "registry.drycc.cc:7070/myapp/web:git-123fsa1",
                         },
                     },
                 },
             },
         }
-        default_image = '127.0.0.1:5555/autotest/example:git-fadf1231'
-        worker_image = "127.0.0.1:5555/autotest/example:git-fadf1231-worker"
-        worker_4_image = "127.0.0.1:7070/myapp/web:git-123fsa1"
+        default_image = 'registry.drycc.cc:5555/autotest/example:git-fadf1231'
+        worker_image = "registry.drycc.cc:5555/autotest/example:git-fadf1231-worker"
+        worker_4_image = "registry.drycc.cc:7070/myapp/web:git-123fsa1"
 
         with mock.patch('scheduler.resources.pod.Pod.watch') as mock_kube:
             mock_kube.return_value = ['up', 'down']
@@ -256,11 +257,10 @@ def test_response_data(self, mock_requests):
         url = '/v2/apps/{}/releases/v2'.format(app_id)
         response = self.client.get(url)
         for key in response.data.keys():
-            self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'build', 'config',
+            self.assertIn(key, ['uuid', 'created', 'updated', 'app', 'build', 'config',
                                 'summary', 'version', 'state', 'failed', 'conditions',
                                 'exception', 'deployed_ptypes'])
         expected = {
-            'owner': self.user.username,
             'app': app_id,
             'build': None,
             'config': uuid.UUID(config_response.data['uuid']),
@@ -614,7 +614,7 @@ def test_deploy_hooks_logged(self, mock_requests, mock_logger):
         """
         Verifies that a configured deploy hook is dumped into the logs when a release is created.
         """
-        app_id = 'foo'
+        app_id = 'foooo'
         body = {'sha': '123456', 'image': 'autotest/example', 'stack': 'heroku-18'}
 
         mr_rocks = mock_requests.post(f'http://drycc.rocks?app={app_id}&user={self.user.username}&sha=&release=v1&release_summary={self.user.username}+created+initial+release')  # noqa
@@ -734,7 +734,6 @@ def test_release_external_registry(self, mock_requests):
     def test_run(self, mock_requests):
         app_id = self.create_app()
         app = App.objects.get(id=app_id)
-        user = User.objects.get(username='autotest')
         dryccfile = {
             "pipeline": {
                 "web.yaml": {
@@ -766,10 +765,10 @@ def test_run(self, mock_requests):
             },
         }
         build = Build.objects.create(
-            owner=user, app=app, image="qwerty", procfile={},
+            app=app, image="qwerty", procfile={},
             sha='african-swallow', dockerfile={}, dryccfile=dryccfile)
         release = Release.objects.create(
-            version=2, owner=user, app=app, config=app.config_set.latest(),
+            version=2, app=app, config=app.config_set.latest(),
             build=build, state="succeed")
         runners = release.get_runners(["web"])
         self.assertEqual(len(runners), 1)
@@ -785,10 +784,10 @@ def test_run(self, mock_requests):
             'image': 'registry.drycc.cc/drycc/python-dev:latest',
         }
         build = Build.objects.create(
-            owner=user, app=app, image="qwerty", procfile={},
+            app=app, image="qwerty", procfile={},
             sha='african-swallow', dockerfile={}, dryccfile=dryccfile)
         release = Release.objects.create(
-            version=3, owner=user, app=app, config=app.config_set.latest(),
+            version=3, app=app, config=app.config_set.latest(),
             build=build, state="succeed")
         runners = release.get_runners(["web"])
         self.assertEqual(len(runners), 1)
diff --git a/rootfs/api/tests/test_resource.py b/rootfs/api/tests/test_resource.py
index 1c591d9d0..37af38ef9 100644
--- a/rootfs/api/tests/test_resource.py
+++ b/rootfs/api/tests/test_resource.py
@@ -61,11 +61,10 @@ def test_resources_create(self, mock_requests):
 
         for key in response.data:
             self.assertIn(key,
-                          ['uuid', 'owner', 'created', 'updated', 'app', 'plan',
+                          ['uuid', 'created', 'updated', 'app', 'plan',
                            'options', 'data', 'status', 'binding', 'name'])
 
         expected = {
-            'owner': self.user.username,
             'app': app_id,
             'name': 'mysql',
             'plan': 'mysql:5.6'
@@ -155,7 +154,7 @@ def call_command(self, *args, **kwargs):
         from django.core.management import call_command
         out = StringIO()
         call_command(
-            "measure_resources",
+            "upload_resource_usage",
             *args,
             stdout=out,
             stderr=StringIO(),
@@ -163,7 +162,7 @@ def call_command(self, *args, **kwargs):
         )
         return out.getvalue()
 
-    def test_measure_resources(self, *args, **kwargs):
+    def test_upload_resource_usage(self, *args, **kwargs):
         # create
         app_id = self.create_app()
         data = [
diff --git a/rootfs/api/tests/test_service.py b/rootfs/api/tests/test_service.py
index 869c2c201..85f8f4b3a 100644
--- a/rootfs/api/tests/test_service.py
+++ b/rootfs/api/tests/test_service.py
@@ -1,6 +1,5 @@
 from django.contrib.auth import get_user_model
 from django.core.cache import cache
-from django.conf import settings
 
 from api.tests import DryccTransactionTestCase
 from api.tests.test_gateway import RouteTest
@@ -50,8 +49,7 @@ def test_service_basic_ops(self):
         self.assertEqual(response.status_code, 200, response.data)
         self.assertEqual(len(response.data['services']), 1)
         expected0 = {
-            "domain": "%s-%s.%s.svc.%s" % (
-                app_id, "test", app_id, settings.KUBERNETES_CLUSTER_DOMAIN),
+            "domain": "%s-%s.%s.svc" % (app_id, "test", app_id),
             "ports": [{
                 'name': "%s-%s-%s-%s" % (app_id, "test", 'udp', 5000),
                 'port': 5000,
@@ -75,8 +73,7 @@ def test_service_basic_ops(self):
 
         # add new port
         expected1 = {
-            "domain": "%s-%s.%s.svc.%s" % (
-                app_id, "test", app_id, settings.KUBERNETES_CLUSTER_DOMAIN),
+            "domain": "%s-%s.%s.svc" % (app_id, "test", app_id),
             "ports": [
                 {
                     'name': "%s-%s-%s-%s" % (app_id, "test", 'udp', 5000),
@@ -124,8 +121,7 @@ def test_service_basic_ops(self):
         self.assertEqual(response.status_code, 200, response.data)
         self.assertEqual(len(response.data['services']), 2)
         expected2 = {
-            "domain": "%s-%s.%s.svc.%s" % (
-                app_id, "test2", app_id, settings.KUBERNETES_CLUSTER_DOMAIN),
+            "domain": "%s-%s.%s.svc" % (app_id, "test2", app_id),
             "ports": [{
                 'name': "%s-%s-%s-%s" % (app_id, "test2", 'udp', 5000),
                 'port': 5000,
diff --git a/rootfs/api/tests/test_users.py b/rootfs/api/tests/test_users.py
deleted file mode 100644
index c8daf13cc..000000000
--- a/rootfs/api/tests/test_users.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from django.contrib.auth import get_user_model
-from api.tests import DryccTestCase
-
-User = get_user_model()
-
-
-class TestUsers(DryccTestCase):
-    """ Tests users endpoint"""
-
-    fixtures = ['tests.json']
-
-    def test_super_user_can_list(self):
-        user = User.objects.get(username='autotest')
-        token = self.get_or_create_token(user)
-
-        for url in ['/v2/users', '/v2/users/']:
-            response = self.client.get(url,
-                                       HTTP_AUTHORIZATION='token {}'.format(token))
-            self.assertEqual(response.status_code, 200, response.data)
-            self.assertEqual(len(response.data['results']), 4)
-
-    def test_enable(self):
-        user = User.objects.get(username='autotest')
-        token = self.get_or_create_token(user)
-        response = self.client.patch("/v2/users/autotest2/enable/",
-                                     HTTP_AUTHORIZATION='token {}'.format(token))
-        self.assertEqual(response.status_code, 204)
-        user = User.objects.get(username='autotest2')
-        self.assertEqual(user.is_active, True)
-
-    def test_disable(self):
-        user = User.objects.get(username='autotest')
-        token = self.get_or_create_token(user)
-        response = self.client.patch("/v2/users/autotest2/disable/",
-                                     HTTP_AUTHORIZATION='token {}'.format(token))
-        self.assertEqual(response.status_code, 204)
-        user = User.objects.get(username='autotest2')
-        self.assertEqual(user.is_active, False)
-
-    def test_non_super_user_cannot_list(self):
-        user = User.objects.get(username='autotest2')
-        token = self.get_or_create_token(user)
-
-        for url in ['/v2/users', '/v2/users/']:
-            response = self.client.get(url,
-                                       HTTP_AUTHORIZATION='token {}'.format(token))
-            self.assertEqual(response.status_code, 403)
diff --git a/rootfs/api/tests/test_volume.py b/rootfs/api/tests/test_volume.py
index 3cdaaf8e7..20f908117 100644
--- a/rootfs/api/tests/test_volume.py
+++ b/rootfs/api/tests/test_volume.py
@@ -93,11 +93,10 @@ def test_volumecreate(self, mock_requests):
 
         for key in response.data:
             self.assertIn(key,
-                          ['uuid', 'owner', 'created', 'updated', 'app', 'name',
+                          ['uuid', 'created', 'updated', 'app', 'name',
                            'size', 'path', 'type', 'parameters'])
 
         expected = {
-            'owner': self.user.username,
             'app': app_id,
             'name': 'myvolume',
             'size': '500G'
@@ -144,7 +143,6 @@ def test_volume_expand(self, mock_requests):
         url = '/v2/apps/{app_id}/volumes'.format(app_id=app_id)
         response = self.client.get(url)
         expected = {
-            'owner': self.user.username,
             'app': app_id,
             'name': 'myvolume1',
             'size': '1024G'
@@ -206,7 +204,8 @@ def test_volume_mount(self, mock_requests):
             {'name': 'myvolume1', 'size': '500G'}
         ]
         for _ in data:
-            self.client.post('/v2/apps/{}/volumes'.format(app_id), data=_)
+            response = self.client.post('/v2/apps/{}/volumes'.format(app_id), data=_)
+            self.assertEqual(response.status_code, 201, response.data)
 
         self.build_deploy(app_id)
         # mount
@@ -277,7 +276,7 @@ def call_command(self, *args, **kwargs):
         from django.core.management import call_command
         out = StringIO()
         call_command(
-            "measure_volumes",
+            "upload_volume_usage",
             *args,
             stdout=out,
             stderr=StringIO(),
@@ -285,7 +284,7 @@ def call_command(self, *args, **kwargs):
         )
         return out.getvalue()
 
-    def test_measure_volumes(self, *args, **kwargs):
+    def test_upload_volume_usage(self, *args, **kwargs):
         # create
         app_id = self.create_app()
         data = [
diff --git a/rootfs/api/tests/test_workflow_manager.py b/rootfs/api/tests/test_workflow_manager.py
index 5d655f86d..c2dd93ebc 100644
--- a/rootfs/api/tests/test_workflow_manager.py
+++ b/rootfs/api/tests/test_workflow_manager.py
@@ -40,18 +40,18 @@ def tearDown(self):
 
     def test_block(self, mock_requests):
         response = self.client.post(
-            '/v2/manager/{}/{}/block/'.format("users", 7),
+            '/v2/manager/{}/{}/block/'.format("apps", self.app_id),
             data={'remark': 'Arrears blockade'},
         )
         self.assertEqual(response.status_code, 201)
 
     def test_unblock(self, mock_requests):
         response = self.client.post(
-            '/v2/manager/{}/{}/block/'.format("users", 7),
+            '/v2/manager/{}/{}/block/'.format("apps", self.app_id),
             data={'remark': 'Arrears blockade'},
         )
         self.assertEqual(response.status_code, 201)
         response = self.client.delete(
-            '/v2/manager/{}/{}/unblock/'.format("users", 7),
+            '/v2/manager/{}/{}/unblock/'.format("apps", self.app_id),
         )
         self.assertEqual(response.status_code, 204)
diff --git a/rootfs/api/tests/test_workspace.py b/rootfs/api/tests/test_workspace.py
new file mode 100644
index 000000000..39476bd52
--- /dev/null
+++ b/rootfs/api/tests/test_workspace.py
@@ -0,0 +1,196 @@
+from django.contrib.auth import get_user_model
+from django.core.cache import cache
+from unittest import mock
+
+from api.models.workspace import Workspace, WorkspaceMember, WorkspaceInvitation
+from api.tests import DryccTestCase
+
+User = get_user_model()
+
+
+class WorkspaceTest(DryccTestCase):
+    """Tests creation and management of workspaces"""
+
+    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)
+
+        self.user2 = User.objects.get(username='autotest2')
+        self.token2 = self.get_or_create_token(self.user2)
+
+    def tearDown(self):
+        cache.clear()
+
+    def test_workspace_lifecycle(self):
+        """Test workspace create, list, retrieve, update, delete"""
+        # Create
+        response = self.client.post(
+            '/v2/workspaces', {'name': 'testworkspace', 'email': 'test@example.com'}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        self.assertEqual(response.data['name'], 'testworkspace')
+
+        # Verify admin member created
+        workspace = Workspace.objects.get(name='testworkspace')
+        self.assertTrue(workspace.has_member(self.user, role='admin'))
+
+        # List
+        response = self.client.get('/v2/workspaces')
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['results']), 1)
+
+        # Retrieve
+        response = self.client.get('/v2/workspaces/testworkspace')
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(response.data['name'], 'testworkspace')
+
+        # Update (personal workspace NOT allowed)
+        response = self.client.patch(
+            f'/v2/workspaces/{self.user.username}', {'email': 'new@example.com'}
+        )
+        self.assertEqual(response.status_code, 404, response.data)
+
+        # Update
+        response = self.client.patch('/v2/workspaces/testworkspace', {'email': 'new@example.com'})
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(response.data['email'], 'new@example.com')
+
+        # Delete
+        response = self.client.delete('/v2/workspaces/testworkspace')
+        self.assertEqual(response.status_code, 204, response.data)
+
+    def test_workspace_isolation(self):
+        # User 1 creates workspace
+        self.client.post('/v2/workspaces', {'name': 'testisolated', 'email': 'test@example.com'})
+
+        # User 2 tries to access User 1's workspace
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token2)
+        response = self.client.get('/v2/workspaces/testisolated')
+        self.assertEqual(response.status_code, 404, response.data)  # Not found for user2
+
+
+class WorkspaceMemberTest(DryccTestCase):
+    fixtures = ['tests.json']
+
+    def setUp(self):
+        self.user = User.objects.get(username='autotest')
+        self.user2 = User.objects.get(username='autotest2')
+        self.token = self.get_or_create_token(self.user)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+
+        # Create a workspace
+        self.client.post('/v2/workspaces', {'name': 'testmembers', 'email': 'test@example.com'})
+        self.workspace = Workspace.objects.get(name='testmembers')
+
+    def test_member_management(self):
+        # Add member via DB (since POST /members is not supported, users join via invitations)
+        WorkspaceMember.objects.create(user=self.user2, workspace=self.workspace, role='member')
+
+        # List members
+        response = self.client.get('/v2/workspaces/testmembers/members')
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['results']), 2)
+
+        # Update member role (admin updating user2)
+        response = self.client.patch(
+            f'/v2/workspaces/testmembers/members/{self.user2.username}', {'role': 'viewer'}
+        )
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(response.data['role'], 'viewer')
+
+        # Non-admin user2 cannot update other members
+        token2 = self.get_or_create_token(self.user2)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + token2)
+        response = self.client.patch(
+            f'/v2/workspaces/testmembers/members/{self.user.username}', {'role': 'viewer'}
+        )
+        self.assertEqual(response.status_code, 403, response.data)
+
+        # Admin delete member
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        response = self.client.delete(f'/v2/workspaces/testmembers/members/{self.user2.username}')
+        self.assertEqual(response.status_code, 204, response.data)
+
+
+class WorkspaceInvitationTest(DryccTestCase):
+    fixtures = ['tests.json']
+
+    def setUp(self):
+        self.user = User.objects.get(username='autotest')
+        self.user2 = User.objects.get(username='autotest2')
+        self.token = self.get_or_create_token(self.user)
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+
+        self.client.post('/v2/workspaces', {'name': 'testinvite', 'email': 'test@example.com'})
+        self.workspace = Workspace.objects.get(name='testinvite')
+
+    @mock.patch('api.models.workspace.send_mail')
+    def test_invitation_lifecycle(self, mock_send_mail):
+        # Create invitation
+        response = self.client.post(
+            '/v2/workspaces/testinvite/invitations', {'email': self.user2.email}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        mock_send_mail.assert_called_once()
+
+        invitation = WorkspaceInvitation.objects.get(email=self.user2.email)
+
+        # List invitations
+        response = self.client.get('/v2/workspaces/testinvite/invitations')
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertEqual(len(response.data['results']), 1)
+
+        # Accept invitation via API (JSON response)
+        response = self.client.get(f'/v2/workspaces/testinvite/invitations/{invitation.token}')
+        self.assertEqual(response.status_code, 200, response.data)
+        self.assertIn('workspace_name', response.data)
+        self.assertEqual(response.data['workspace_name'], 'testinvite')
+        self.assertIn('user_exists', response.data)
+        self.assertIn('register_url', response.data)
+
+        # Verify user became a member
+        self.assertTrue(
+            WorkspaceMember.objects.filter(
+                workspace=self.workspace,
+                user=self.user2
+            ).exists()
+        )
+
+        # Test delete invitation
+        response = self.client.post(
+            '/v2/workspaces/testinvite/invitations', {'email': 'test-invite2@example.com'}
+        )
+        invitation2 = WorkspaceInvitation.objects.get(email='test-invite2@example.com')
+        response = self.client.delete(
+            f'/v2/workspaces/testinvite/invitations/{invitation2.token}'
+        )
+        self.assertEqual(response.status_code, 204, response.data)
+
+    @mock.patch('api.models.workspace.send_mail')
+    def test_invitation_accept_browser(self, mock_send_mail):
+        """Test accepting invitation via browser returns HTML"""
+        response = self.client.post(
+            '/v2/workspaces/testinvite/invitations', {'email': self.user2.email}
+        )
+        self.assertEqual(response.status_code, 201, response.data)
+        invitation = WorkspaceInvitation.objects.get(email=self.user2.email)
+
+        # Accept invitation via browser (HTML response)
+        response = self.client.get(
+            f'/v2/workspaces/testinvite/invitations/{invitation.token}',
+            HTTP_ACCEPT='text/html',
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8')
+        self.assertIn(b'testinvite', response.content)
+
+        # Verify user became a member
+        self.assertTrue(
+            WorkspaceMember.objects.filter(
+                workspace=self.workspace,
+                user=self.user2
+            ).exists()
+        )
diff --git a/rootfs/api/urls.py b/rootfs/api/urls.py
index 698722b9d..bf4baba6b 100644
--- a/rootfs/api/urls.py
+++ b/rootfs/api/urls.py
@@ -15,7 +15,29 @@
 app_urlpatterns = [
     re_path(r'^', include(router.urls)),
     re_path(r'auth/login/?$', views.AuthLoginView.as_view({"post": "login"})),
+    re_path(r'auth/token/?$', views.AuthTokenView.as_view({"post": "token"})),
     re_path(r'auth/token/(?P<key>[-_\w]+)/?$', views.AuthTokenView.as_view({"get": "token"})),
+    # Workspaces management URLs (keep workspaces prefix, no user prefix)
+    re_path(r'^workspaces/?$',
+            views.WorkspaceViewSet.as_view({'get': 'list', 'post': 'create'}),
+            name='workspace_list'),
+    re_path(r'^workspaces/(?P<name>[-_\w]+)/?$',
+            views.WorkspaceViewSet.as_view(
+                {'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'}),
+            name='workspace_detail'),
+    re_path(r'^workspaces/(?P<name>[-_\w]+)/members/?$',
+            views.WorkspaceMemberViewSet.as_view({'get': 'list'}),
+            name='workspace_member_list'),
+    re_path(r'^workspaces/(?P<name>[-_\w]+)/members/(?P<user>[-_\w]+)/?$',
+            views.WorkspaceMemberViewSet.as_view(
+                {'get': 'retrieve', 'patch': 'partial_update', 'delete': 'destroy'}),
+            name='workspace_member_detail'),
+    re_path(r'^workspaces/(?P<name>[-_\w]+)/invitations/?$',
+            views.WorkspaceInvitationViewSet.as_view({'get': 'list', 'post': 'create'}),
+            name='workspace_invitation_list'),
+    re_path(r'^workspaces/(?P<name>[-_\w]+)/invitations/(?P<uid>[-_\w]+)/?$',
+            views.WorkspaceInvitationViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'}),
+            name='workspace_invitation_detail'),
     # limits
     re_path(
         r'^limits/specs/?$',
@@ -32,7 +54,7 @@
         views.BuildViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
     re_path(
         r"^apps/(?P<id>{})/config/?$".format(settings.APP_URL_REGEX),
-        views.ConfigViewSet.as_view({'get': 'retrieve', 'post': 'create'})),
+        views.ConfigViewSet.as_view({'get': 'retrieve', 'post': 'create', 'delete': 'destroy'})),
     re_path(
         r"^apps/(?P<id>{})/releases/v(?P<version>[0-9]+)/?$".format(settings.APP_URL_REGEX),
         views.ReleaseViewSet.as_view({'get': 'retrieve'})),
@@ -48,7 +70,7 @@
     # list/delete pods
     re_path(
         r"^apps/(?P<id>{})/pods/?$".format(settings.APP_URL_REGEX),
-        views.PodViewSet.as_view({'get': 'list', 'delete': 'delete'})),
+        views.PodViewSet.as_view({'get': 'list', 'delete': 'destroy'})),
     # describe pod
     re_path(
         r"^apps/(?P<id>{})/pods/(?P<name>{})/describe/?$".format(
@@ -91,11 +113,7 @@
     re_path(
         r"^apps/(?P<id>{})/services/?$".format(settings.APP_URL_REGEX),
         views.ServiceViewSet.as_view({'post': 'create_or_update',
-                                     'get': 'list', 'delete': 'delete'})),
-    # application actions
-    re_path(
-        r"^apps/(?P<id>{})/run/?$".format(settings.APP_URL_REGEX),
-        views.AppViewSet.as_view({'post': 'run'})),
+                                     'get': 'list', 'delete': 'destroy'})),
     # application settings
     re_path(
         r"^apps/(?P<id>{})/settings/?$".format(settings.APP_URL_REGEX),
@@ -117,15 +135,6 @@
         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>{})/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>{})/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'})),
     re_path(
@@ -151,17 +160,18 @@
     re_path(
         r'^apps/(?P<id>{})/certs/(?P<name>{})/?$'.format(
             settings.APP_URL_REGEX, settings.NAME_REGEX),
-        views.CertificateViewSet.as_view({
-            'get': 'retrieve',
-            'delete': 'destroy'
-        })),
+        views.CertificateViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'})),
     re_path(
         r'^apps/(?P<id>{})/certs/?$'.format(settings.APP_URL_REGEX),
         views.CertificateViewSet.as_view({'get': 'list', 'post': 'create'})),
+    # application actions
+    re_path(
+        r"^apps/(?P<id>{})/run/?$".format(settings.APP_URL_REGEX),
+        views.AppViewSet.as_view({'post': 'run'})),
     # apps base endpoint
     re_path(
         r"^apps/(?P<id>{})/?$".format(settings.APP_URL_REGEX),
-        views.AppViewSet.as_view({'get': 'retrieve', 'post': 'update', 'delete': 'destroy'})),
+        views.AppViewSet.as_view({'get': 'retrieve', 'patch': 'transfer', 'delete': 'destroy'})),
     re_path(
         r'^apps/?$',
         views.AppViewSet.as_view({'get': 'list', 'post': 'create'})),
@@ -191,18 +201,18 @@
     # authn / authz
     re_path(
         r'^auth/whoami/?$',
-        views.UserManagementViewSet.as_view({'get': 'list'})),
+        views.UserManagementViewSet.as_view({'get': 'whoami'})),
     # gateways
     re_path(
         r"^apps/(?P<id>{})/gateways/?$".format(settings.APP_URL_REGEX),
         views.GatewayViewSet.as_view(
-            {'post': 'create_or_update', 'get': 'list', 'delete': 'delete'})),
+            {'post': 'create_or_update', 'get': 'list', 'delete': 'destroy'})),
     # routes
     re_path(
         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'})),
+            {'post': 'create', 'get': 'list', 'delete': 'destroy'})),
     re_path(
         r"^apps/(?P<id>{})/routes/(?P<name>{})/attach/?$".format(
             settings.APP_URL_REGEX, settings.NAME_REGEX),
@@ -215,14 +225,6 @@
         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'})),
-    re_path(
-        r'^users/(?P<username>[\w.@+-]+)/enable/?$',
-        views.UserView.as_view({'patch': 'enable'})),
-    re_path(
-        r'^users/(?P<username>[\w.@+-]+)/disable/?$',
-        views.UserView.as_view({'patch': 'disable'})),
     re_path(
         r'^apps/(?P<id>{})/metrics/?$'.format(settings.APP_URL_REGEX),
         views.MetricView.as_view({'get': 'metric'})),
@@ -236,20 +238,22 @@
     re_path(
         r'^manager/(?P<type>[\w.@+-]+)s/(?P<id>{})/unblock/?$'.format(settings.APP_URL_REGEX),
         views.WorkflowManagerViewset.as_view({'delete': 'unblock'})),
-    # user perms
+    # quickwit
     re_path(
-        r"^apps/(?P<id>{})/perms/?$".format(settings.APP_URL_REGEX),
-        views.AppPermViewSet.as_view({'get': 'list', 'post': 'create'})),
+        r'^quickwit/(?P<username>[\w.@+-]+)/(?P<path>.+)/?$', views.QuickwitProxyView.as_view()),
+    # prometheus
     re_path(
-        r"^apps/(?P<id>{})/perms/(?P<username>[\w.@+-]+)/?$".format(settings.APP_URL_REGEX),
-        views.AppPermViewSet.as_view({'put': 'update', 'delete': 'destroy'})),
+        r'^prometheus/(?P<username>[\w.@+-]+)/(?P<path>.+)/?$',
+        views.PrometheusProxyView.as_view()),
     # tokens
     re_path(r'^tokens/?$', views.TokenViewSet.as_view({'get': 'list'})),
-    re_path(r"^tokens/(?P<pk>[-_\w]+)/?$", views.TokenViewSet.as_view({'delete': 'destroy'})),
-    # social login is placed at the end of the URL match
-    re_path(r'^login/(?P<backend>[^/]+){0}$'.format(extra), views.auth, name='begin'),
-    re_path(r'^complete/(?P<backend>[^/]+){0}$'.format(extra), views.complete, name='complete'),
-    re_path('', include('social_django.urls', namespace='social')),
+    re_path(
+        r"^tokens/(?P<pk>[-_\w]+)/?$",
+        views.TokenViewSet.as_view({'get': 'retrieve', 'delete': 'destroy'})),
+]
+
+metric_urlpatterns = [
+    re_path(r'^metrics/?$', views.MetricsProxyView.as_view()),
 ]
 
 mutate_urlpatterns = [
@@ -259,8 +263,16 @@
     ),
 ]
 
-# If there is a mutating admission mutate configuration, use mutate url
-if settings.MUTATE_KEY:
+# social login is placed at the end of the URL match
+social_urlpatterns = [
+    re_path(r'^login/(?P<backend>[^/]+){0}$'.format(extra), views.auth, name='begin'),
+    re_path(r'^complete/(?P<backend>[^/]+){0}$'.format(extra), views.complete, name='complete'),
+    re_path('', include('social_django.urls', namespace='social')),
+]
+
+if settings.RUNNER == 'metric':
+    urlpatterns = metric_urlpatterns
+elif settings.RUNNER == 'mutate':
     urlpatterns = mutate_urlpatterns
 else:
-    urlpatterns = app_urlpatterns
+    urlpatterns = app_urlpatterns + social_urlpatterns
diff --git a/rootfs/api/utils.py b/rootfs/api/utils.py
index 444fcee8a..d2aa1d519 100644
--- a/rootfs/api/utils.py
+++ b/rootfs/api/utils.py
@@ -15,11 +15,14 @@
 import pkgutil
 import inspect
 import requests
+import importlib
 import jsonschema
 from copy import deepcopy
+from urllib.parse import urljoin
 from django.db import models
 from django.conf import settings
 from django.core.cache import cache
+from django.utils import timezone
 from asgiref.sync import sync_to_async
 from requests_toolbelt import user_agent
 from api import __version__ as drycc_version
@@ -29,22 +32,24 @@
 logger = logging.getLogger(__name__)
 
 
-session = None
+def get_httpclient():
+    session = requests.Session()
+    session.headers = {
+        # https://toolbelt.readthedocs.org/en/latest/user-agent.html#user-agent-constructor
+        'User-Agent': user_agent('Drycc Controller', drycc_version),
+    }
+    # `mount` a custom adapter that retries failed connections for HTTP and HTTPS requests.
+    # http://docs.python-requests.org/en/latest/api/#requests.adapters.HTTPAdapter
+    session.mount('http://', requests.adapters.HTTPAdapter(max_retries=10))
+    session.mount('https://', requests.adapters.HTTPAdapter(max_retries=10))
+    return session
 
 
-def get_session():
-    global session
-    if session is None:
-        session = requests.Session()
-        session.headers = {
-            # https://toolbelt.readthedocs.org/en/latest/user-agent.html#user-agent-constructor
-            'User-Agent': user_agent('Drycc Controller', drycc_version),
-        }
-        # `mount` a custom adapter that retries failed connections for HTTP and HTTPS requests.
-        # http://docs.python-requests.org/en/latest/api/#requests.adapters.HTTPAdapter
-        session.mount('http://', requests.adapters.HTTPAdapter(max_retries=10))
-        session.mount('https://', requests.adapters.HTTPAdapter(max_retries=10))
-    return session
+def get_scheduler(metadata=None):
+    mod = importlib.import_module(settings.SCHEDULER_MODULE)
+    metadata = metadata if metadata is not None else {}
+    metadata = dict_merge(metadata, {"annotations": {"app.kubernetes.io/managed-by": "drycc"}})
+    return mod.SchedulerClient(settings.SCHEDULER_URL, settings.K8S_API_VERIFY_TLS, metadata)
 
 
 def import_all_models():
@@ -67,6 +72,14 @@ def validate_label(value):
     match = re.match(r'^[a-z0-9-]+$', value)
     if not match:
         raise ValidationError("Can only contain a-z (lowercase), 0-9 and hyphens")
+    validate_reserved_names(value)
+
+
+def validate_reserved_names(value):
+    """A value cannot use some reserved names."""
+    for reserved_name_pattern in settings.RESERVED_NAME_PATTERNS:
+        if re.match(reserved_name_pattern, value):
+            raise ValidationError('{} is a reserved name.'.format(value))
 
 
 def random_string(num):
@@ -79,6 +92,30 @@ def generate_app_name():
     return "{}-{}".format(random_string(6), random_string(8))
 
 
+def send_app_log(app_id, msg, level=logging.INFO):
+    if not settings.QUICKWIT_INDEXER_URL:
+        return
+    pod_ip = os.environ.get("POD_IP", "unknown")
+    pod_name = os.environ.get("POD_NAME", "unknown")
+    namespace = os.environ.get("WORKFLOW_NAMESPACE", "unknown")
+    docker_id = hashlib.sha256(f"{namespace}:{pod_name}:{pod_ip}".encode("utf-8")).hexdigest()
+    data = {
+        "kubernetes": {
+            "container_name": "drycc-controller",
+            "docker_id": docker_id,
+            "namespace_name": app_id,
+            "pod_name": pod_name,
+        },
+        "log": f"{logging.getLevelName(level)}\t{app_id}\t{msg}",
+        "offset": int(time.time()),
+        "stream": "stdout",
+        "timestamp": timezone.now().isoformat(),
+    }
+    index = f"{settings.QUICKWIT_LOG_INDEX_PREFIX}{app_id}"
+    with get_httpclient() as session:
+        session.post(urljoin(settings.QUICKWIT_INDEXER_URL, f"/api/v1/{index}/ingest"), json=data)
+
+
 def dict_diff(dict1, dict2):
     """
     Returns the added, changed, and deleted items in dict1 compared with dict2.
@@ -203,6 +240,11 @@ def validate_json(value, schema, raise_exception=jsonschema.ValidationError):
     return value
 
 
+def get_local_host(request):
+    uri = request.build_absolute_uri()
+    return uri[0:uri.find(request.path)]
+
+
 class CacheLock(object):
 
     def __init__(self, key):
diff --git a/rootfs/api/views.py b/rootfs/api/views.py
index 734489102..131889d5a 100644
--- a/rootfs/api/views.py
+++ b/rootfs/api/views.py
@@ -5,45 +5,54 @@
 import uuid
 import logging
 import json
+import ssl
+import time
+import zlib
+import random
+import secrets
+import aiohttp
 import requests
-from asgiref.sync import async_to_sync
+import warnings
+
+from urllib.parse import urljoin
+from django.db import transaction
 from django.db.models import Q
 from django.core.cache import cache
 from django.http import Http404, HttpResponse
 from django.conf import settings
 from django.contrib.auth import get_user_model
-from django.shortcuts import get_object_or_404, redirect
-from guardian.shortcuts import assign_perm, get_objects_for_user, \
-    get_users_with_perms, get_user_perms, remove_perm
+from django.shortcuts import get_object_or_404, redirect, render
 from django.views.generic import View
 from django.utils.decorators import method_decorator
 from django.views.decorators.cache import cache_page
 from django.views.decorators.vary import vary_on_headers
-from rest_framework import renderers, status, filters
+from rest_framework import status, filters
 from rest_framework.permissions import IsAuthenticated, AllowAny
 from rest_framework.response import Response
-from rest_framework.viewsets import GenericViewSet
-from rest_framework.exceptions import PermissionDenied
+from rest_framework.viewsets import GenericViewSet, ModelViewSet
+from rest_framework.exceptions import ValidationError
+from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
 
-from api import monitor, models, permissions, serializers, viewsets, authentication
-from api.tasks import scale_app, restart_app, mount_app, downstream_model_owner, \
-    delete_pod
+from api import monitor, models, permissions, serializers, viewsets, authentication, __version__
+from api.tasks import scale_app, restart_app, mount_app, delete_pod, scale_resources
 from api.exceptions import AlreadyExists, ServiceUnavailable, DryccException
 
 from django.views.decorators.cache import never_cache
 from django.contrib.auth import REDIRECT_FIELD_NAME
 from django.views.decorators.csrf import csrf_exempt
-from django.http.response import FileResponse, StreamingHttpResponse
+from django.http.response import JsonResponse, StreamingHttpResponse
+from channels.db import database_sync_to_async
 from social_django.utils import psa
 from social_django.views import _do_login
 from social_core.utils import setting_name
-from api import admissions, utils, filer
-from api.backend import OauthCacheManager
+from api import admissions, utils
+from api.apps_extra.social_core.backends import OauthCacheManager
 from api.apps_extra.social_core.actions import do_auth, do_complete
-from api.files.parsers import FilerUploadParser
+
 
 User = get_user_model()
 logger = logging.getLogger(__name__)
+is_loopback = re.compile(r'^(localhost|127\.0\.0\.1)(:\d+)?/').match
 oauth_cache_manager = OauthCacheManager()
 NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social'
 
@@ -112,12 +121,15 @@ def _create_browser_response(self, key):
         return redirect(f"{uri[0:uri.find(self.request.path)]}/v2/login/drycc/?key={key}")
 
     def _create_interactive_response(self, username, password, key):
+        # Get token endpoint from OIDC discovery
+        token_url = oauth_cache_manager.drycc_oauth.access_token_url()
+        client_id, client_secret = oauth_cache_manager.drycc_oauth.get_key_and_secret()
         response = requests.post(
-            settings.SOCIAL_AUTH_DRYCC_ACCESS_TOKEN_URL,
+            token_url,
             data={
                 'grant_type': 'password',
-                'client_id': settings.SOCIAL_AUTH_DRYCC_KEY,
-                'client_secret': settings.SOCIAL_AUTH_DRYCC_SECRET,
+                'client_id': client_id,
+                'client_secret': client_secret,
                 'username': username,
                 'password': password,
             },
@@ -135,27 +147,28 @@ class AuthTokenView(GenericViewSet):
     permission_classes = (AllowAny, )
 
     def token(self, request, *args, **kwargs):
-        oauth = oauth_cache_manager.get_token(self.kwargs['key'])
-        if oauth:
+        if 'key' in self.kwargs:
+            oauth = oauth_cache_manager.get_token(self.kwargs['key'])
+        else:
+            try:
+                oauth = json.loads(request.body.decode("utf8"))
+            except json.decoder.JSONDecodeError:
+                return HttpResponse(status=400)
+        if oauth and 'access_token' in oauth:
             user = oauth_cache_manager.get_user(oauth['access_token'])
             alias = request.query_params.get('alias', '')
             token = models.base.Token(owner=user, alias=alias, oauth=oauth)
             token.save()
-            return HttpResponse(json.dumps({"token": token.key, "username": user.username}))
+            return HttpResponse(json.dumps(
+                {"uuid": str(token.uuid), "token": token.key, "username": user.username}))
         return HttpResponse(status=404)
 
 
 class UserManagementViewSet(GenericViewSet):
     serializer_class = serializers.UserSerializer
 
-    def get_queryset(self):
-        return User.objects.filter(pk=self.request.user.pk)
-
-    def get_object(self):
-        return self.get_queryset()[0]
-
-    def list(self, request, **kwargs):
-        user = self.get_object()
+    def whoami(self, request, **kwargs):
+        user = get_object_or_404(User, pk=self.request.user.pk)
         serializer = self.get_serializer(user, many=False)
         return Response(serializer.data)
 
@@ -166,13 +179,17 @@ class WorkflowManagerViewset(GenericViewSet):
 
     def block(self, request,  **kwargs):
         try:
-            blocklist, _ = models.blocklist.Blocklist.objects.get_or_create(
+            blocklist, created = models.blocklist.Blocklist.objects.get_or_create(
                 id=kwargs['id'],
                 type=models.blocklist.Blocklist.get_type(kwargs["type"]),
                 defaults={"remark": request.data.get("remark")}
             )
             for app in blocklist.related_apps:
-                scale_app.delay(app, app.owner, {key: 0 for key in app.structure.keys()})
+                if created:
+                    app.suspended_state = blocklist.suspended_state(app)
+                    app.save()
+                # scale to 0
+                scale_resources.delay(blocklist, app, app.suspended_state, "block")
             return HttpResponse(status=201)
         except ValueError as e:
             logger.info(e)
@@ -180,10 +197,17 @@ def block(self, request,  **kwargs):
 
     def unblock(self, request,  **kwargs):
         try:
-            models.blocklist.Blocklist.objects.filter(
+            blocklists = models.blocklist.Blocklist.objects.filter(
                 id=kwargs['id'],
                 type=models.blocklist.Blocklist.get_type(kwargs["type"])
-            ).delete()
+            )
+            for blocklist in blocklists:
+                for app in blocklist.related_apps:
+                    # scale to up
+                    scale_resources.delay(blocklist, app, app.suspended_state, "unblock")
+                    app.suspended_state = {}
+                    app.save()
+                blocklist.delete()
             return HttpResponse(status=204)
         except ValueError as e:
             logger.info(e)
@@ -203,7 +227,7 @@ class AdmissionWebhookViewSet(GenericViewSet):
     def handle(self, request,  **kwargs):
         key = kwargs['key']
         data = json.loads(request.body.decode("utf8"))["request"]
-        if settings.MUTATE_KEY == key:
+        if settings.CERT_KEY == key:
             allowed = True
             for admission_class in self.admission_classes:
                 admission = admission_class()
@@ -222,34 +246,246 @@ def handle(self, request,  **kwargs):
         })
 
 
-class TokenViewSet(viewsets.OwnerViewSet):
+class WorkspaceViewSet(ModelViewSet):
+    """
+    ViewSet for Workspace model.
+    """
+    lookup_field = 'name'
+    serializer_class = serializers.WorkspaceSerializer
+    permission_classes = [IsAuthenticated]
+
+    def get_queryset(self):
+        return models.workspace.Workspace.objects.filter(
+            workspacemember__user=self.request.user
+        ).distinct()
+
+    def perform_create(self, serializer):
+        workspace = serializer.save()
+        models.workspace.WorkspaceMember.objects.create(
+            user=self.request.user, workspace=workspace, role='admin'
+        )
+
+    def get_object(self):
+        """Override to get workspace by name instead of pk"""
+        return get_object_or_404(self.get_queryset(), name=self.kwargs['name'])
+
+    def partial_update(self, request, *args, **kwargs):
+        """Only admins can update workspaces"""
+        workspace = self.get_object()
+        if not workspace.has_member(request.user, role='admin'):
+            return Response(
+                {"detail": "Only workspace admins can update workspaces"},
+                status=status.HTTP_403_FORBIDDEN
+            )
+        return super().partial_update(request, *args, **kwargs)
+
+    def destroy(self, request, *args, **kwargs):
+        """Only admins can delete workspaces"""
+        workspace = self.get_object()
+        if not workspace.has_member(request.user, role='admin'):
+            return Response(
+                {"detail": "Only workspace admins can delete workspaces"},
+                status=status.HTTP_403_FORBIDDEN
+            )
+        if models.workspace.WorkspaceMember.objects.filter(workspace=workspace).count() > 1:
+            return Response(
+                {"detail": "Cannot delete workspace with more than one member"},
+                status=status.HTTP_403_FORBIDDEN
+            )
+        return super().destroy(request, *args, **kwargs)
+
+
+class WorkspaceMemberViewSet(ModelViewSet):
+    """
+    ViewSet for WorkspaceMember model.
+    """
+    serializer_class = serializers.WorkspaceMemberSerializer
+    permission_classes = [IsAuthenticated]
+
+    def get_queryset(self):
+        workspace = get_object_or_404(models.workspace.Workspace, name=self.kwargs['name'])
+        # Check if user has access to this workspace
+        if workspace.has_member(self.request.user):
+            return models.workspace.WorkspaceMember.objects.filter(workspace=workspace)
+        return models.workspace.WorkspaceMember.objects.none()
+
+    def get_object(self):
+        """Override to get member by username and workspace name"""
+        workspace = get_object_or_404(models.workspace.Workspace, name=self.kwargs['name'])
+        return get_object_or_404(
+            models.workspace.WorkspaceMember,
+            workspace=workspace, user__username=self.kwargs['user']
+        )
+
+    def partial_update(self, request, *args, **kwargs):
+        """Update a member. Admins can update any member (role and alerts).
+        Non-admins can only update their own alerts field."""
+        member = self.get_object()
+        is_admin = member.workspace.has_member(request.user, role='admin')
+
+        # Check if workspace has only one member
+        member_count = models.workspace.WorkspaceMember.objects.filter(
+            workspace=member.workspace
+        ).count()
+        is_only_member = member_count == 1
+
+        # Only member cannot modify role
+        if is_only_member and 'role' in request.data:
+            return Response(
+                {"detail": "Cannot modify role: workspace only has one member"},
+                status=status.HTTP_403_FORBIDDEN
+            )
+
+        # Non-admin users restrictions
+        if not is_admin:
+            # Cannot update other members
+            if request.user != member.user:
+                return Response(
+                    {"detail": "Only workspace admins can update other members"},
+                    status=status.HTTP_403_FORBIDDEN
+                )
+            # Cannot modify own role
+            if 'role' in request.data:
+                return Response(
+                    {"detail": "Cannot modify your own role"},
+                    status=status.HTTP_403_FORBIDDEN
+                )
+
+        return super().partial_update(request, *args, **kwargs)
+
+    def destroy(self, request, *args, **kwargs):
+        """Delete a member. Admins can delete any member.
+        Non-admins can only delete themselves (leave workspace)."""
+        member = self.get_object()
+        is_admin = member.workspace.has_member(request.user, role='admin')
+
+        # Check if workspace has only one member
+        member_count = models.workspace.WorkspaceMember.objects.filter(
+            workspace=member.workspace
+        ).count()
+        is_only_member = member_count == 1
+
+        # Only member cannot delete self
+        if is_only_member and request.user == member.user:
+            return Response(
+                {"detail": "Cannot delete: workspace only has one member"},
+                status=status.HTTP_403_FORBIDDEN
+            )
+
+        # Non-admin can delete self
+        if request.user == member.user:
+            return super().destroy(request, *args, **kwargs)
+
+        # Admin can delete any member
+        if is_admin:
+            return super().destroy(request, *args, **kwargs)
+
+        # Other cases forbidden
+        return Response(
+            {"detail": "Only workspace admins can remove other members"},
+            status=status.HTTP_403_FORBIDDEN
+        )
 
+
+class WorkspaceInvitationViewSet(ModelViewSet):
+    """
+    ViewSet for WorkspaceInvitation model.
+    """
+    serializer_class = serializers.WorkspaceInvitationSerializer
+
+    def get_permissions(self):
+        """
+        Allow anyone to accept an invitation.
+        Only authenticated users can create or list invitations.
+        """
+        if self.action == 'retrieve':
+            return [AllowAny()]
+        return super().get_permissions()
+
+    def get_renderers(self):
+        if self.action == 'retrieve':
+            return [JSONRenderer(), TemplateHTMLRenderer()]
+        return super().get_renderers()
+
+    def get_queryset(self):
+        workspace = get_object_or_404(models.workspace.Workspace, name=self.kwargs['name'])
+        if workspace.has_member(self.request.user):
+            return models.workspace.WorkspaceInvitation.objects.filter(
+                workspace=workspace, accepted=False)
+        return models.workspace.WorkspaceInvitation.objects.none()
+
+    def get_object(self):
+        """Override to get invitation by uid and workspace name"""
+        return get_object_or_404(
+            models.workspace.WorkspaceInvitation,
+            workspace=get_object_or_404(models.workspace.Workspace, name=self.kwargs['name']),
+            token=self.kwargs['uid'],
+            accepted=False,
+        )
+
+    def retrieve(self, request, *args, **kwargs):
+        instance = self.get_object()
+        instance.accept()
+        user_exists = User.objects.filter(email=instance.email).exists()
+        data = {
+            'workspace_name': instance.workspace.name,
+            'user_exists': user_exists,
+            'register_url': settings.DRYCC_REGISTER_URL,
+        }
+        if isinstance(request.accepted_renderer, TemplateHTMLRenderer):
+            return render(request, 'workspace/workspace_invitation_accept.html', data)
+        return Response(data)
+
+    def perform_create(self, serializer):
+        workspace = get_object_or_404(models.workspace.Workspace, name=self.kwargs['name'])
+        if not workspace.has_member(self.request.user, role='admin'):
+            raise ValidationError("Only workspace admins can create invitations")
+        email = serializer.validated_data['email']
+        user = User.objects.filter(email=email).first()
+        if user and workspace.has_member(user):
+            raise ValidationError("User is already a member of the workspace")
+        invitation = models.workspace.WorkspaceInvitation.objects.filter(
+            email=email, workspace=workspace, accepted=False
+        ).first()
+        if not invitation:
+            models.workspace.WorkspaceInvitation.objects.filter(
+                email=email, workspace=workspace, accepted=True
+            ).delete()
+            invitation = serializer.save(
+                token=secrets.token_hex(64), inviter=self.request.user, workspace=workspace)
+        if settings.EMAIL_HOST:
+            invitation.send_email(self.request)
+        else:
+            invitation.accept()
+
+    def destroy(self, request, *args, **kwargs):
+        """Only admins can revoke invitations"""
+        invitation = self.get_object()
+        if not invitation.workspace.has_member(request.user, role='admin'):
+            return Response(
+                {"detail": "Only workspace admins can revoke invitations"},
+                status=status.HTTP_403_FORBIDDEN
+            )
+        return super().destroy(request, *args, **kwargs)
+
+
+class TokenViewSet(viewsets.OwnerViewSet):
+    """
+    A viewset for interacting with Token objects.
+    """
     serializer_class = serializers.TokenSerializer
-    permission_classes = [IsAuthenticated, permissions.IsOwner]
 
     def get_queryset(self):
         return models.base.Token.objects.filter(owner=self.request.user)
 
     def destroy(self, *args, **kwargs):
         key = self.get_object().key
-        response = super(TokenViewSet, self).destroy(self, *args, **kwargs)
+        response = super().destroy(self, *args, **kwargs)
         cache.delete(key)
         return response
 
 
-class BaseDryccViewSet(viewsets.OwnerViewSet):
-    """
-    A generic ViewSet for objects related to Drycc.
-
-    To use it, at minimum you'll need to provide the `serializer_class` attribute and
-    the `model` attribute shortcut.
-    """
-    lookup_field = 'id'
-    permission_classes = [IsAuthenticated, permissions.IsObjectUser]
-    renderer_classes = [renderers.JSONRenderer]
-
-
-class AppResourceViewSet(BaseDryccViewSet):
+class AppFilterViewSet(viewsets.BaseAppViewSet):
     """A viewset for objects which are attached to an application."""
 
     def get_app(self):
@@ -266,10 +502,10 @@ def get_object(self, **kwargs):
 
     def create(self, request, **kwargs):
         request.data['app'] = self.get_app()
-        return super(AppResourceViewSet, self).create(request, **kwargs)
+        return super().create(request, **kwargs)
 
 
-class ReleasableViewSet(AppResourceViewSet):
+class ReleasableViewSet(AppFilterViewSet):
     """A viewset for application resources which affect the release cycle."""
 
     def get_object(self):
@@ -283,34 +519,13 @@ def get_object(self):
         return getattr(release, self.model.__name__.lower())
 
 
-class AppViewSet(BaseDryccViewSet):
+class AppViewSet(viewsets.BaseAppViewSet):
     """A viewset for interacting with App objects."""
     model = models.app.App
     filter_backends = [filters.SearchFilter]
     search_fields = ['^id', ]
     serializer_class = serializers.AppSerializer
 
-    def get_queryset(self, *args, **kwargs):
-        return self.model.objects.all(*args, **kwargs)
-
-    def list(self, request, *args, **kwargs):
-        """
-        HACK: Instead of filtering by the queryset, we limit the queryset to list only the apps
-        which are owned by the user as well as any apps they have been given permission to
-        interact with.
-        """
-        queryset = super(AppViewSet, self).get_queryset(**kwargs) | \
-            get_objects_for_user(
-                self.request.user, f'api.{models.app.VIEW_APP_PERMISSION.codename}')
-        instance = self.filter_queryset(queryset)
-        page = self.paginate_queryset(instance)
-        if page is not None:
-            serializer = self.get_serializer(page, many=True)
-            return self.get_paginated_response(serializer.data)
-
-        serializer = self.get_serializer(instance, many=True)
-        return Response(serializer.data)
-
     def run(self, request, **kwargs):
         app = self.get_object()
         ptype = request.data.get('ptype', 'run')
@@ -332,18 +547,25 @@ def run(self, request, **kwargs):
                 timeout=timeout, expires=expires)
         return Response(status=status.HTTP_204_NO_CONTENT)
 
-    def update(self, request, **kwargs):
+    def perform_create(self, serializer):
+        workspace = serializer.validated_data['workspace']
+        self.check_object_permissions(self.request, workspace)
+        serializer.save()
+
+    def transfer(self, request, **kwargs):
         app = self.get_object()
-        old_owner = app.owner
-
-        if request.data.get('owner'):
-            if self.request.user != app.owner and not self.request.user.is_superuser:
-                return Response(status=status.HTTP_403_FORBIDDEN)
-            new_owner = get_object_or_404(User, username=request.data['owner'])
-            # ensure all downstream objects that are owned by this user and are part of this app
-            # is also updated
-            downstream_model_owner.delay(app, old_owner, new_owner)
-        return Response(status=status.HTTP_200_OK)
+        if not app.workspace.has_member(request.user, role='admin'):
+            raise DryccException("you must be an admin of the current workspace")
+        workspace = request.data.get('workspace', '')
+        if not workspace:
+            raise DryccException("workspace is required")
+        app.workspace = get_object_or_404(models.workspace.Workspace, name=workspace)
+        app.save()
+        return Response(status=status.HTTP_204_NO_CONTENT)
+
+    @transaction.atomic
+    def destroy(self, request, *args, **kwargs):
+        return super().destroy(request, *args, **kwargs)
 
 
 class BuildViewSet(ReleasableViewSet):
@@ -351,12 +573,16 @@ class BuildViewSet(ReleasableViewSet):
     model = models.build.Build
     serializer_class = serializers.BuildSerializer
 
-    def post_save(self, build):
+    def perform_create(self, serializer):
+        build = serializer.save()
+        for ptype in build.ptypes:
+            image = build.get_image(ptype)
+            if is_loopback(image):
+                raise DryccException("image must not use the loopback address")
         build.create_release(self.request.user)
-        super(BuildViewSet, self).post_save(build)
 
 
-class LimitSpecViewSet(BaseDryccViewSet):
+class LimitSpecViewSet(ModelViewSet):
     """A viewset for interacting with Limit objects."""
     model = models.limit.LimitSpec
     serializer_class = serializers.LimitSpecSerializer
@@ -370,7 +596,7 @@ def get_queryset(self, **kwargs):
         return self.model.objects.filter(q)
 
 
-class LimitPlanViewSet(BaseDryccViewSet):
+class LimitPlanViewSet(ModelViewSet):
     """A viewset for interacting with Limit objects."""
     model = models.limit.LimitPlan
     serializer_class = serializers.LimitPlanSerializer
@@ -397,30 +623,52 @@ class ConfigViewSet(ReleasableViewSet):
     model = models.config.Config
     serializer_class = serializers.ConfigSerializer
 
-    def delete(self, request, **kwargs):
-        values_refs = self.get_serializer().validate_values_refs(request.data.get('values_refs'))
+    def create(self, request, **kwargs):
+        if self.request.query_params.get('merge', 'true').lower() == 'true':
+            return super().create(request, **kwargs)
+        values = self.get_serializer().validate_values(request.data.get('values'))
+        config = self.model(app=self.get_app(), values=values)
+        old_config = config.previous()
+        if old_config and old_config.values:
+            replace_ptypes = {v['ptype'] for v in config.values if 'ptype' in v}
+            replace_groups = {v['group'] for v in config.values if 'group' in v}
+            config.merge_field("values", old_config, replace_ptypes, replace_groups)
+        config.save(ignore_update_fields=["values"])
+        self.post_save(config)
+        data = self.get_serializer(config).data
+        headers = self.get_success_headers(data)
+        return Response(data, status=status.HTTP_201_CREATED, headers=headers)
+
+    def perform_create(self, serializer):
+        config = serializer.save()
+        self.post_save(config)
+
+    def destroy(self, request, **kwargs):
+        values_refs = self.get_serializer().validate_values_refs(
+            request.data.get('values_refs', {}))
         if not values_refs or not values_refs.values():
             raise DryccException("ptype or groups is required")
 
-        config = self.model(app=self.get_app(), owner=self.request.user, values_refs={})
-        old_values_refs = config.previous().values_refs.copy()
-        for ptype, groups in values_refs.items():
-            for group in old_values_refs.get(ptype, []):
-                if group not in groups:
+        config = self.model(app=self.get_app(), values_refs={})
+        previous = config.previous()
+        old_values_refs = previous.values_refs.copy() if previous else {}
+        for ptype, old_groups in old_values_refs.items():
+            groups_to_delete = values_refs.get(ptype, [])
+            for group in old_groups:
+                if group not in groups_to_delete:
                     if ptype not in config.values_refs:
                         config.values_refs[ptype] = [group]
                     elif group not in config.values_refs[ptype]:
                         config.values_refs[ptype].append(group)
         config.save(ignore_update_fields=["values_refs"])
-
         self.post_save(config)
         return Response(status=status.HTTP_200_OK)
 
     def post_save(self, config):
         latest_release = models.release.Release.latest(self.get_app())
         try:
-            release = latest_release.new(
-                self.request.user, config=config, build=latest_release.build)
+            build = latest_release.build.merge(config) if latest_release.build else None
+            release = latest_release.new(self.request.user, config=config, build=build)
             if release.build and config.app.appsettings_set.latest().autodeploy:
                 release.deploy(release.ptypes, False)
         except BaseException as e:
@@ -430,7 +678,7 @@ def post_save(self, config):
             raise DryccException(str(e)) from e
 
 
-class PodViewSet(AppResourceViewSet):
+class PodViewSet(AppFilterViewSet):
     model = models.app.App
     serializer_class = serializers.PodSerializer
 
@@ -450,7 +698,7 @@ def describe(self, *args, **kwargs):
         pagination = {'results': data, 'count': len(data)}
         return Response(pagination, status=status.HTTP_200_OK)
 
-    def delete(self, request, **kwargs):
+    def destroy(self, request, **kwargs):
         pod_names = request.data.get("pod_ids")
         pod_names = pod_names.split(",")
         for pod_name in set(pod_names):
@@ -458,7 +706,7 @@ def delete(self, request, **kwargs):
         return Response(status=status.HTTP_200_OK)
 
 
-class PtypeViewSet(AppResourceViewSet):
+class PtypeViewSet(AppFilterViewSet):
     model = models.app.App
     serializer_class = serializers.PtypeSerializer
 
@@ -506,7 +754,7 @@ def scale(self, request, **kwargs):
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
-class EventViewSet(AppResourceViewSet):
+class EventViewSet(AppFilterViewSet):
     model = models.app.App
     serializer_class = serializers.EventSerializer
 
@@ -525,12 +773,12 @@ def list(self, request, **kwargs):
         return Response(pagination, status=status.HTTP_200_OK)
 
 
-class AppSettingsViewSet(AppResourceViewSet):
+class AppSettingsViewSet(AppFilterViewSet):
     model = models.appsettings.AppSettings
     serializer_class = serializers.AppSettingsSerializer
 
 
-class DomainViewSet(AppResourceViewSet):
+class DomainViewSet(AppFilterViewSet):
     """A viewset for interacting with Domain objects."""
     model = models.domain.Domain
     serializer_class = serializers.DomainSerializer
@@ -555,7 +803,7 @@ def destroy(self, request, **kwargs):
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
-class ServiceViewSet(AppResourceViewSet):
+class ServiceViewSet(AppFilterViewSet):
     """A viewset for interacting with Service objects."""
     model = models.service.Service
     serializer_class = serializers.ServiceSerializer
@@ -579,13 +827,13 @@ def create_or_update(self, request, **kwargs):
                     return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": "port is occupied"})  # noqa
             http_status = status.HTTP_204_NO_CONTENT
         else:
-            service = self.model(owner=app.owner, app=app, ptype=ptype)
+            service = self.model(app=app, ptype=ptype)
             http_status = status.HTTP_201_CREATED
         service.add_port(port, protocol, target_port)
         service.save()
         return Response(status=http_status)
 
-    def delete(self, request, **kwargs):
+    def destroy(self, request, **kwargs):
         port = self.get_serializer().validate_port(request.data.get('port'))
         protocol = self.get_serializer().validate_protocol(request.data.get('protocol'))
         ptype = self.get_serializer().validate_ptype(
@@ -599,7 +847,7 @@ def delete(self, request, **kwargs):
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
-class CertificateViewSet(AppResourceViewSet):
+class CertificateViewSet(AppFilterViewSet):
     """A viewset for interacting with Certificate objects."""
     model = models.certificate.Certificate
     serializer_class = serializers.CertificateSerializer
@@ -630,14 +878,14 @@ def detach(self, request, *args, **kwargs):
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
-class KeyViewSet(BaseDryccViewSet):
+class KeyViewSet(viewsets.OwnerViewSet):
     """A viewset for interacting with Key objects."""
+    lookup_field = 'id'
     model = models.key.Key
-    permission_classes = [IsAuthenticated, permissions.IsOwner]
     serializer_class = serializers.KeySerializer
 
 
-class ReleaseViewSet(AppResourceViewSet):
+class ReleaseViewSet(AppFilterViewSet):
     """A viewset for interacting with Release objects."""
     model = models.release.Release
     serializer_class = serializers.ReleaseSerializer
@@ -649,7 +897,7 @@ def get_object(self, **kwargs):
 
     def get_queryset(self, **kwargs):
         ptypes = self.request.query_params.get('ptypes', '').strip()
-        queryset = super(ReleaseViewSet, self).get_queryset(**kwargs)
+        queryset = super().get_queryset(**kwargs)
         if ptypes:
             queryset = queryset.filter(Q(
                 deployed_ptypes__contains=[
@@ -688,7 +936,7 @@ def rollback(self, request, **kwargs):
         return Response(response, status=status.HTTP_201_CREATED)
 
 
-class TLSViewSet(AppResourceViewSet):
+class TLSViewSet(AppFilterViewSet):
     model = models.tls.TLS
     serializer_class = serializers.TLSSerializer
 
@@ -697,8 +945,8 @@ def events(self, request, **kwargs):
         return Response({'results': results, 'count': len(results)})
 
 
-class BaseHookViewSet(BaseDryccViewSet):
-    permission_classes = [permissions.HasBuilderAuth]
+class BaseHookViewSet(viewsets.BaseAppViewSet):
+    permission_classes = [permissions.IsServiceToken]
 
 
 class KeyHookViewSet(BaseHookViewSet):
@@ -709,31 +957,22 @@ class KeyHookViewSet(BaseHookViewSet):
     def public_key(self, request, *args, **kwargs):
         fingerprint = kwargs['fingerprint'].strip()
         key = get_object_or_404(models.key.Key, fingerprint=fingerprint)
-
-        queryset = models.app.App.objects.all() | \
-            get_objects_for_user(
-                self.request.user, f'api.{models.app.VIEW_APP_PERMISSION.codename}')
-        items = self.filter_queryset(queryset)
-
-        apps = []
-        for item in items:
-            apps.append(item.id)
-
+        queryset = models.app.App.objects.filter(
+            workspace__workspacemember__user=self.request.user
+        ).distinct()
         data = {
             'username': key.owner.username,
-            'apps': apps
+            'apps': [item.id for item in self.filter_queryset(queryset)],
         }
 
         return Response(data, status=status.HTTP_200_OK)
 
     def app(self, request, *args, **kwargs):
         app = get_object_or_404(models.app.App, id=kwargs['id'])
-        usernames = [u.id for u in get_users_with_perms(app)
-                     if u.has_perm(f"api.{models.app.VIEW_APP_PERMISSION.codename}", app)]
-
+        usernames = app.workspace.workspacemember_set.values_list('user__username', flat=True)
         data = {}
         result = models.key.Key.objects \
-                       .filter(owner__in=usernames) \
+                       .filter(owner__username__in=usernames) \
                        .values('owner__username', 'public', 'fingerprint') \
                        .order_by('created')
         for info in result:
@@ -752,9 +991,8 @@ def users(self, request, *args, **kwargs):
         app = get_object_or_404(models.app.App, id=kwargs['id'])
         request.user = get_object_or_404(User, username=kwargs['username'])
         # check the user is authorized for this app
-        has_permission, message = permissions.has_app_permission(request.user, app, request.method)
-        if not has_permission:
-            return Response(message, status=status.HTTP_403_FORBIDDEN)
+        if not permissions.IsAppUser().has_object_permission(request, self, app):
+            return Response(status=status.HTTP_403_FORBIDDEN)
 
         data = {request.user.username: []}
         keys = models.key.Key.objects \
@@ -782,12 +1020,11 @@ def create(self, request, *args, **kwargs):
         app = get_object_or_404(models.app.App, id=request.data['receive_repo'])
         self.user = request.user = get_object_or_404(User, username=request.data['receive_user'])
         # check the user is authorized for this app
-        has_permission, message = permissions.has_app_permission(self.user, app, request.method)
-        if not has_permission:
-            return Response(message, status=status.HTTP_403_FORBIDDEN)
+        if not permissions.IsAppUser().has_object_permission(request, self, app):
+            return Response(status=status.HTTP_403_FORBIDDEN)
         request.data['app'] = app
         request.data['owner'] = self.user
-        super(BuildHookViewSet, self).create(request, *args, **kwargs)
+        super().create(request, *args, **kwargs)
         # return the application databag
         response = {
             'release': {
@@ -796,7 +1033,8 @@ def create(self, request, *args, **kwargs):
         }
         return Response(response, status=status.HTTP_200_OK)
 
-    def post_save(self, build):
+    def perform_create(self, serializer):
+        build = serializer.save()
         build.create_release(self.user)
 
 
@@ -809,91 +1047,14 @@ def create(self, request, *args, **kwargs):
         app = get_object_or_404(models.app.App, id=request.data['receive_repo'])
         request.user = get_object_or_404(User, username=request.data['receive_user'])
         # check the user is authorized for this app
-        has_permission, message = permissions.has_app_permission(request.user, app, request.method)
-        if not has_permission:
-            return Response(message, status=status.HTTP_403_FORBIDDEN)
+        if not permissions.IsAppUser().has_object_permission(request, self, app):
+            return Response(status=status.HTTP_403_FORBIDDEN)
         config = models.release.Release.latest(app).config
         serializer = self.get_serializer(config)
         return Response(serializer.data, status=status.HTTP_200_OK)
 
 
-class AppPermViewSet(AppResourceViewSet):
-    """RESTful views for sharing apps with collaborators."""
-
-    def get_app(self, request):
-        app = get_object_or_404(models.app.App, id=self.kwargs['id'])
-        if not permissions.IsOwnerOrAdmin().has_object_permission(request, self, app):
-            raise PermissionDenied()
-        return app
-
-    def list(self, request, **kwargs):
-        app = self.get_app(request)
-        results = [
-            {
-                "app": app.id,
-                "username": user.username,
-                "permissions": [
-                    models.app.app_permission_registry.get(codename).shortname
-                    for codename in get_user_perms(user, app)
-                ],
-            }
-            for user in get_users_with_perms(app)
-        ]
-        # fake out pagination for now
-        pagination = {'results': results, 'count': len(results)}
-        return Response(data=pagination)
-
-    def create(self, request, **kwargs):
-        app = self.get_app(request)
-        username = request.data.get('username')
-        shortnames = set([perm for perm in request.data.get("permissions", "").split(",") if perm])
-        all_shortnames = models.app.app_permission_registry.shortnames
-        if not shortnames or not shortnames.issubset(all_shortnames):
-            msg = "The permissions field is required and has a value range of: {}".format(
-                ",".join(all_shortnames)
-            )
-            return Response(status=status.HTTP_400_BAD_REQUEST, data=msg)
-        user = get_object_or_404(User, username=username)
-        for shortname in shortnames:
-            permission = models.app.app_permission_registry.get(shortname)
-            if permission:
-                assign_perm(permission.codename, user, app)
-        app.log("User {} was granted access to {}".format(user, app))
-        return Response(status=status.HTTP_201_CREATED)
-
-    def update(self, request, **kwargs):
-        app = self.get_app(request)
-        user = get_object_or_404(User, username=kwargs['username'])
-        shortnames = set([perm for perm in request.data.get("permissions", "").split(",") if perm])
-        all_shortnames = models.app.app_permission_registry.shortnames
-        if not shortnames or not shortnames.issubset(all_shortnames):
-            msg = "The permissions field is required and has a value range of: {}".format(
-                ",".join(all_shortnames)
-            )
-            return Response(status=status.HTTP_400_BAD_REQUEST, data=msg)
-        for shortname in shortnames.symmetric_difference([
-                models.app.app_permission_registry.get(codename).shortname
-                for codename in get_user_perms(user, app)]):
-            permission = models.app.app_permission_registry.get(shortname)
-            if permission:
-                if shortname in shortnames:
-                    assign_perm(permission.codename, user, app)
-                else:
-                    remove_perm(permission.codename, user, app)
-        app.log("User {} was revoked access to {}".format(user, app))
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-    def destroy(self, request, **kwargs):
-        app = self.get_app(request)
-        username = kwargs['username']
-        user = get_object_or_404(User, username=username)
-        for codename in get_user_perms(user, app):
-            remove_perm(codename, user, app)
-        app.log("User {} was revoked access to {}".format(user, app))
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-
-class AppVolumesViewSet(AppResourceViewSet):
+class AppVolumesViewSet(AppFilterViewSet):
     """RESTful views for volumes apps with collaborators."""
     model = models.volume.Volume
     serializer_class = serializers.VolumeSerializer
@@ -944,52 +1105,7 @@ def path(self, request, *args, **kwargs):
         return Response(serializer.data)
 
 
-class AppFilerClientViewSet(AppResourceViewSet):
-    """RESTful views for volumes apps with collaborators."""
-    model = models.volume.Volume
-    parser_classes = [FilerUploadParser]
-
-    def get_client(self):
-        volume = get_object_or_404(
-            models.volume.Volume, app=self.get_app(), name=self.kwargs['name'])
-        return filer.FilerClient(volume.app.id, volume, volume.app.scheduler())
-
-    def list(self, request, **kwargs):
-        path = request.query_params.get('path', '')
-        client = self.get_client()
-        response = client.get(path, params={"action": "list"})
-        if response.status_code == 200:
-            results = response.json()
-            # fake out pagination for now
-            pagination = {'results': results, 'count': len(results)}
-            return Response(data=pagination)
-        return Response(status=response.status_code, data=response.text)
-
-    def retrieve(self, request, **kwargs):
-        path = self.kwargs.get('path', '')
-        client = self.get_client()
-        chunk_size = 1024 * 1024 * 2
-        response = client.get(path, stream=True, params={"action": "get"})
-        return FileResponse(
-            status=response.status_code,
-            headers=response.headers,
-            streaming_content=utils.iter_to_aiter(response.iter_content(chunk_size=chunk_size)),
-        )
-
-    def create(self, request, **kwargs):
-        client = self.get_client()
-        file = request.data['file']
-        response = client.post(file.filepath, files=request.FILES)
-        return Response(data=response.content, status=response.status_code)
-
-    def destroy(self, request, **kwargs):
-        path = self.kwargs.get('path', '')
-        client = self.get_client()
-        response = client.delete(path)
-        return Response(data=response.content, status=response.status_code)
-
-
-class AppResourcesViewSet(AppResourceViewSet):
+class AppResourcesViewSet(AppFilterViewSet):
     """RESTful views for resources apps with collaborators."""
     model = models.resource.Resource
     serializer_class = serializers.ResourceSerializer
@@ -1012,7 +1128,7 @@ def plans(self, request, *args, **kwargs):
         ))
 
 
-class AppSingleResourceViewSet(AppResourceViewSet):
+class AppSingleResourceViewSet(AppFilterViewSet):
     """RESTful views for resource apps with collaborators."""
     model = models.resource.Resource
     serializer_class = serializers.ResourceSerializer
@@ -1025,7 +1141,7 @@ def get_object(self):
     def retrieve(self, request, *args, **kwargs):
         resource = self.get_object()
         resource.retrieve(request)
-        response =  super(AppSingleResourceViewSet, self).retrieve(request, *args, **kwargs)  # noqa
+        response =  super().retrieve(request, *args, **kwargs)  # noqa
         response.data["message"] = resource.message
         return response
 
@@ -1044,7 +1160,7 @@ def update(self, request, *args, **kwargs):
         return Response(resource.data)
 
 
-class AppResourceBindingViewSet(AppResourceViewSet):
+class AppResourceBindingViewSet(AppFilterViewSet):
     model = models.resource.Resource
     serializer_class = serializers.ResourceSerializer
 
@@ -1071,7 +1187,7 @@ def binding(self, request, *args, **kwargs):
             return Response("unknown action", status=status.HTTP_404_NOT_FOUND)
 
 
-class GatewayViewSet(AppResourceViewSet):
+class GatewayViewSet(AppFilterViewSet):
     """A viewset for interacting with Gateway objects."""
     model = models.gateway.Gateway
     filter_backends = [filters.SearchFilter]
@@ -1085,14 +1201,14 @@ def create_or_update(self, request, *args, **kwargs):
         protocol = self.get_serializer().validate_protocol(request.data['protocol'])
         gateway = app.gateway_set.filter(name=name).first()
         if not gateway:
-            gateway = self.model(app=app, owner=app.owner, name=name)
+            gateway = self.model(app=app, name=name)
         added, msg = gateway.add(port, protocol)
         if not added:
             return Response(status=status.HTTP_400_BAD_REQUEST, data=msg)
         gateway.save()
         return Response(status=status.HTTP_201_CREATED)
 
-    def delete(self, request, **kwargs):
+    def destroy(self, request, **kwargs):
         app = self.get_app()
         port = self.get_serializer().validate_port(request.data.get('port'))
         protocol = self.get_serializer().validate_protocol(request.data['protocol'])
@@ -1107,7 +1223,7 @@ def delete(self, request, **kwargs):
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
-class RouteViewSet(AppResourceViewSet):
+class RouteViewSet(AppFilterViewSet):
     """A viewset for interacting with Route objects."""
     model = models.gateway.Route
     filter_backends = [filters.SearchFilter]
@@ -1155,40 +1271,14 @@ def detach(self, request, *args, **kwargs):
         route.save()
         return Response(status=status.HTTP_204_NO_CONTENT)
 
-    def delete(self, request, *args, **kwargs):
+    def destroy(self, request, *args, **kwargs):
         app = self.get_app()
         route = get_object_or_404(self.model, app=app, name=kwargs['name'])
         route.delete()
         return Response(status=status.HTTP_204_NO_CONTENT)
 
 
-class UserView(BaseDryccViewSet):
-    """A Viewset for interacting with User objects."""
-    model = User
-    serializer_class = serializers.UserSerializer
-    permission_classes = [permissions.IsAdmin]
-
-    def get_queryset(self):
-        return self.model.objects.exclude(username='AnonymousUser')
-
-    def enable(self, request, **kwargs):
-        if request.user.username == kwargs['username']:
-            return Response(status=status.HTTP_423_LOCKED)
-        user = get_object_or_404(self.model, username=kwargs['username'])
-        user.is_active = True
-        user.save(update_fields=['is_active', ])
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-    def disable(self, request, **kwargs):
-        if request.user.username == kwargs['username']:
-            return Response(status=status.HTTP_423_LOCKED)
-        user = get_object_or_404(self.model, username=kwargs['username'])
-        user.is_active = False
-        user.save(update_fields=['is_active', ])
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-
-class MetricView(BaseDryccViewSet):
+class MetricView(viewsets.BaseAppViewSet):
     """Getting monitoring indicators from monitor database"""
 
     def _get_app(self):
@@ -1196,44 +1286,243 @@ def _get_app(self):
         self.check_object_permissions(self.request, app)
         return app
 
-    def _get_usage(self, func):
-        def wrap(app_id, ptype, every, start, stop, step):
-            result = {}
-            for item in async_to_sync(func)(app_id, ptype, every, start, stop, step):
-                result[item['metric']['pod']] = item['values']
-            return result
-        return wrap
-
-    @method_decorator(cache_page(settings.DRYCC_METRICS_EXPIRY))
-    @method_decorator(vary_on_headers("Authorization"))
-    def status(self, request, **kwargs):
-        app_id = self._get_app().id
-        data = serializers.MetricSerializer(data=self.request.query_params)
-        if not data.is_valid():
-            return Response(data.errors, status=status.HTTP_422_UNPROCESSABLE_ENTITY)
-        every, start, stop, step = data.validated_data["every"], data.validated_data[
-            'start'], data.validated_data['stop'], data.validated_data['step']
-        params = {
-            "app_id": app_id, "ptype": kwargs['ptype'], "every": every,
-            "start": start, "stop": stop, "step": step,
-        }
-        return Response({
-            "id": app_id,
-            "ptype": kwargs['ptype'],
-            "usage": {
-                "cpus": self._get_usage(monitor.query_cpu_usage)(**params),
-                "memory": self._get_usage(monitor.query_memory_usage)(**params),
-                "networks": {
-                    "receive": self._get_usage(monitor.query_network_receive_usage)(**params),
-                    "transmit": self._get_usage(monitor.query_network_transmit_usage)(**params),
-                }
-            }
-        })
-
     @method_decorator(cache_page(settings.DRYCC_METRICS_EXPIRY))
     @method_decorator(vary_on_headers("Authorization"))
     def metric(self, request, **kwargs):
+        warnings.warn(
+            'this interface will be removed in the next version.', PendingDeprecationWarning)
         app_id = self._get_app().id
         return StreamingHttpResponse(
             streaming_content=monitor.last_metrics(app_id)
         )
+
+
+class MetricsProxyView(View):
+    cache = {}
+    match_meta = staticmethod(
+        re.compile(r'^(?:# (?:HELP|TYPE) )([a-zA-Z_][a-zA-Z0-9_:.-]*)').match)
+    match_data = staticmethod(
+        re.compile(r'^([a-zA-Z_][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(\S+)').match)
+    default_cache_value = (None, -1)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if settings.K8S_API_VERIFY_TLS:
+            ssl_context = ssl.create_default_context(
+                cafile='/var/run/secrets/kubernetes.io/serviceaccount/ca.crt')
+        else:
+            ssl_context = ssl.create_default_context()
+        self.connector = aiohttp.TCPConnector(ssl_context=ssl_context)
+
+    async def sample(self, name, labels_str, value):
+        if not labels_str or name not in settings.DRYCC_METRICS_CONFIG:
+            return None
+        fields = set(settings.DRYCC_METRICS_CONFIG[name])
+        labels = {}
+        for pair in labels_str.strip(" {}").split(','):
+            if '=' not in pair:
+                continue
+            k, v = pair.split('=', 1)
+            if k in fields:
+                labels[k] = v.strip(' "')
+        app_id = labels.get("namespace", labels.get(settings.DRYCC_METRICS_CONFIG[name][0]))
+        if not app_id:
+            return None
+        workspace_id, timeout = self.cache.get(app_id, self.default_cache_value)
+        if workspace_id is None or time.time() > timeout:
+            if app := await models.app.App.objects.filter(id=app_id).afirst():
+                workspace_id = app.workspace_id
+            else:
+                workspace_id = None
+            self.cache[app_id] = (workspace_id, time.time() + random.randint(600, 1200))
+        if workspace_id is None:
+            return None
+        project_id = zlib.crc32(app_id.encode("utf-8"))
+        labels.update({'vm_account_id': workspace_id, 'vm_project_id': project_id})
+        return "%s{%s} %s\n" % (name, ",".join([f'{k}="{v}"' for k, v in labels.items()]), value)
+
+    async def get(self, request):
+        params = dict(request.GET)
+        if not set(["host", "port"]).issubset(params.keys()):
+            return HttpResponse(
+                "Error: Required parameter 'host' or 'port' is missing or empty", status=400)
+        host, port = params.pop('host')[0], params.pop('port')[0]
+        scheme, path = params.pop('scheme', ['http'])[0], params.pop('path', ['/metrics'])[0]
+        url = urljoin(f"{scheme}://{host}:{port}", path)
+        headers = {"Authorization": request.META.get("HTTP_AUTHORIZATION", "")}
+
+        async def stream_response():
+            async with aiohttp.ClientSession(connector=self.connector) as session:
+                async with session.get(url, params=params, headers=headers) as resp:
+                    async for line_bytes in resp.content:
+                        line = line_bytes.decode('utf-8', errors='ignore').strip(' \n')
+                        if line.startswith('#') and (match := self.match_meta(line)):
+                            if match.group(1) in settings.DRYCC_METRICS_CONFIG:
+                                yield f"{line}\n"
+                            continue
+                        match = self.match_data(line)
+                        if not match:
+                            continue
+                        name, labels_str, value = match.groups()
+                        sample = await self.sample(name, labels_str, value)
+                        if not sample:
+                            continue
+                        yield sample
+        content_type = f"text/plain; version={__version__}"
+        return StreamingHttpResponse(stream_response(), content_type=content_type)
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class BaseUserProxyView(View):
+    timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_read=15)
+    permission = permissions.IsServiceToken()
+    authentication = authentication.DryccAuthentication()
+    authentication.ignore_authentication_failed = True
+
+    async def authenticate(self, request, username):
+        """
+        Authenticate the user based on the provided request and username.
+        Returns the user ID on success; returns None and an error message on failure.
+        """
+        if self.permission.has_permission(request, None):
+            if username != "drycc":
+                return None, {'error': 'Access denied', "status_code": 403}
+            return 0, None
+        auth = await database_sync_to_async(self.authentication.authenticate)(request)
+        if not auth or len(auth) != 2:
+            return None, {'error': 'Unauthorized', "status_code": 401}
+        if auth[0].username != username:
+            return None, {'error': 'Access denied', "status_code": 403}
+        return auth[0].id, None
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class QuickwitProxyView(BaseUserProxyView):
+    index_url_match = re.compile(r"^indexes/?$").match
+    search_url_match = re.compile(r"^(?P<index>[a-zA-Z*][\w.*-，]{0,})/search/?$").match
+    msearch_url_match = re.compile(r"^_elastic/_msearch/?$").match
+    field_caps_url_match = re.compile(
+        r"_elastic/(?P<index>[a-zA-Z*][\w.*-，]{0,})/_field_caps/?$").match
+
+    async def proxy(self, request, username, path):
+        user_id, message = await self.authenticate(request, username)
+        if user_id is None and message is not None:
+            return JsonResponse(message, status=message["status_code"])
+        kwargs = {"request": request, "username": username}
+        if self.index_url_match(path):
+            func, kwargs["index"] = self.index, request.GET.get("index_id_patterns", "*")
+        elif match := self.search_url_match(path):
+            func, kwargs["index"] = self.query, match.group("index")
+        elif self.msearch_url_match(path):
+            func = self.msearch
+        elif match := self.field_caps_url_match(path):
+            func, kwargs["index"] = self.field_caps, match.group("index")
+        else:
+            return JsonResponse({'error': 'Not Found'}, status=404)
+        return await func(**kwargs)
+
+    async def index(self, request, username, index):
+        base_url = settings.QUICKWIT_SEARCHER_URL
+        index = await self.get_app_indexes(username, index)
+        url, params = urljoin(base_url, "/api/v1/indexes"), dict(request.GET)
+        params["index_id_patterns"] = index
+        try:
+            async with aiohttp.ClientSession() as session:
+                async with session.get(url, params=params, timeout=self.timeout) as response:
+                    data, status = await response.json(), response.status
+        except aiohttp.ClientError as e:
+            data, status = {'error': f'quickwit connection failed: {str(e)}'}, 502
+        return JsonResponse(data, status=status, safe=False)
+
+    async def query(self, request, username, index):
+        base_url = settings.QUICKWIT_SEARCHER_URL
+        index = await self.get_app_indexes(username, index)
+        url, params = urljoin(base_url, f"/api/v1/{index}/search"), dict(request.GET)
+        try:
+            async with aiohttp.ClientSession() as session:
+                async with session.get(url, params=params, timeout=self.timeout) as response:
+                    data, status = await response.json(), response.status
+        except aiohttp.ClientError as e:
+            data, status = {'error': f'quickwit connection failed: {str(e)}'}, 502
+        return JsonResponse(data, status=status)
+
+    async def msearch(self, request, username):
+        base_url = settings.QUICKWIT_SEARCHER_URL
+        json_lines = request.body.decode('utf-8').strip().split('\n')
+        for i, json_line in enumerate(json_lines):
+            if i % 2 == 0:
+                request_header = json.loads(json_line)
+                request_header['index'] = ",".join(
+                    [await self.get_app_indexes(username, i) for i in request_header['index']]
+                ).split(",")
+                json_lines[i] = json.dumps(request_header)
+        url, params = urljoin(
+            base_url, "/api/v1/_elastic/_msearch"), dict(request.GET)
+        try:
+            async with aiohttp.ClientSession() as session:
+                async with session.post(
+                    url, data="\n".join(json_lines), params=params, timeout=self.timeout
+                ) as response:
+                    data, status = await response.json(), response.status
+        except aiohttp.ClientError as e:
+            data, status = {'error': f'quickwit connection failed: {str(e)}'}, 502
+        return JsonResponse(data, status=status)
+
+    async def field_caps(self, request, username, index):
+        base_url = settings.QUICKWIT_SEARCHER_URL
+        index = await self.get_app_indexes(username, index)
+        url, params = urljoin(
+            base_url, f"/api/v1/_elastic/{index}/_field_caps"), dict(request.GET)
+        try:
+            async with aiohttp.ClientSession() as session:
+                async with session.get(url, params=params, timeout=self.timeout) as response:
+                    data, status = await response.json(), response.status
+        except aiohttp.ClientError as e:
+            data, status = {'error': f'quickwit connection failed: {str(e)}'}, 502
+        return JsonResponse(data, status=status)
+
+    async def get_app_indexes(self, username, index):
+        if username == "drycc":
+            return index
+        if "," in index:
+            match = re.compile("|".join([f"^{i}$" for i in index.split(",")])).match
+        else:
+            match = re.compile(index).match
+        app_indexes, log_index_prefix = [], settings.QUICKWIT_LOG_INDEX_PREFIX
+        if hasattr(self, "app_ids"):
+            app_ids = self.app_ids
+        else:
+            app_ids = [
+                app.id async for app in models.app.App.objects.filter(owner__username=username)]
+            setattr(self, "app_ids", app_ids)
+        for app_id in app_ids:
+            app_index = f"{log_index_prefix}{app_id}"
+            if match(app_index):
+                app_indexes.append(app_index)
+        return ",".join(app_indexes)
+
+    get = post = proxy
+
+
+@method_decorator(csrf_exempt, name='dispatch')
+class PrometheusProxyView(BaseUserProxyView):
+    async def proxy(self, request, username, path):
+        user_id, message = await self.authenticate(request, username)
+        if user_id is None and message is not None:
+            return JsonResponse(message, status=message["status_code"])
+        data = dict(request.GET) if request.method == "GET" else dict(request.POST)
+        data['extra_filters[]'] = '{vm_account_id="%s"}' % user_id
+        try:
+            async with aiohttp.ClientSession() as session:
+                async with session.post(
+                    f"{settings.DRYCC_VICTORIAMETRICS_URL.rstrip("/")}/{path}",
+                    data=data,
+                    headers={"Content-Type": "application/x-www-form-urlencoded"},
+                    timeout=self.timeout
+                ) as response:
+                    data, status = await response.json(), response.status
+        except aiohttp.ClientError as e:
+            data, status = {'error': f'victoriametrics connection failed: {str(e)}'}, 502
+        return JsonResponse(data, status=status)
+
+    get = post = proxy
diff --git a/rootfs/api/viewsets.py b/rootfs/api/viewsets.py
index 4e972fdd5..0871f5ba7 100644
--- a/rootfs/api/viewsets.py
+++ b/rootfs/api/viewsets.py
@@ -1,4 +1,5 @@
-from rest_framework import viewsets
+from django.core.exceptions import ImproperlyConfigured
+from rest_framework import viewsets, renderers
 from rest_framework.permissions import IsAuthenticated
 
 from api import permissions
@@ -17,12 +18,25 @@ def get_queryset(self):
         return self.model.objects.filter(owner=self.request.user)
 
     def perform_create(self, serializer):
-        obj = serializer.save(owner=self.request.user)
-        self.post_save(obj)
-
-    def post_save(self, obj):
-        """
-        A post_save hook for performing actions after the object has been pushed to the database.
-        Leave it up to child classes to implement.
-        """
-        pass
+        serializer.save(owner=self.request.user)
+
+
+class BaseAppViewSet(viewsets.ModelViewSet):
+    """
+    A ViewSet for the Workspace model, which filters workspaces by membership and role.
+    """
+    lookup_field = 'id'
+    permission_classes = [IsAuthenticated, permissions.IsAppUser]
+    renderer_classes = [renderers.JSONRenderer]
+
+    def get_queryset(self):
+        # Prefer direct workspace relation, then support app->workspace chain.
+        if hasattr(self.model, 'workspace'):
+            return self.model.objects.filter(
+                workspace__workspacemember__user=self.request.user).distinct()
+        elif hasattr(self.model, 'app'):
+            return self.model.objects.filter(
+                app__workspace__workspacemember__user=self.request.user).distinct()
+        raise ImproperlyConfigured(
+            f"{self.__class__.__name__} requires a model with a 'workspace' or 'app' field."
+        )
diff --git a/rootfs/bin/test-unit b/rootfs/bin/test-unit
index 45c343d88..218a94568 100755
--- a/rootfs/bin/test-unit
+++ b/rootfs/bin/test-unit
@@ -15,4 +15,4 @@ function start_valkey() {
 gosu postgres pg_ctl -D "$PGDATA" start && ln -s /tmp/.s.PGSQL.5432 /var/run/postgresql/.s.PGSQL.5432
 start_valkey
 python3 manage.py check
-coverage run --source=api,scheduler manage.py test --settings=api.settings.testing --noinput api scheduler.tests --exclude-tag=auth
+coverage run --source=api,scheduler manage.py test --settings=api.settings.testing --noinput api scheduler.tests --exclude-tag=auth --parallel="${TEST_PARALLEL:-8}"
diff --git a/rootfs/dev_requirements.txt b/rootfs/dev_requirements.txt
index 9f542ed42..1f133f1de 100644
--- a/rootfs/dev_requirements.txt
+++ b/rootfs/dev_requirements.txt
@@ -1,10 +1,13 @@
 # test module
 # test
 # Run "make test-unit" for the % of code exercised during tests
-coverage==7.8.0
+coverage==7.10.6
 
 # Run "make test-style" to check python syntax and style
-flake8==7.2.0
+flake8==7.3.0
 
 # mock out python-requests, mostly k8s
 requests-mock==1.12.1
+
+# tracebacks
+tblib==3.1.0
diff --git a/rootfs/drycc/gunicorn/config.py b/rootfs/drycc/gunicorn/config.py
index 7395214e8..9a980075c 100644
--- a/rootfs/drycc/gunicorn/config.py
+++ b/rootfs/drycc/gunicorn/config.py
@@ -4,20 +4,18 @@
 import faulthandler
 faulthandler.enable()
 
-bind = '0.0.0.0:8000'
-
-# If there is a mutating admission mutate configuration, start mutate
-MUTATE_KEY_PATH = os.environ.get(
-    'DRYCC_MUTATE_KEY_PATH', '/etc/controller/mutate/cert/key')
-MUTATE_TLS_KEY_PATH = os.environ.get(
-    'DRYCC_MUTATE_TLS_KEY_PATH', '/etc/controller/mutate/cert/tls.key')
-MUTATE_TLS_CRT_PATH = os.environ.get(
-    'DRYCC_MUTATE_TLS_CRT_PATH', '/etc/controller/mutate/cert/tls.crt')
-if exists(MUTATE_KEY_PATH) and exists(MUTATE_TLS_KEY_PATH) and exists(MUTATE_TLS_CRT_PATH):
+# If a certificate exists, set the certificate and bind to port 8443.
+CERT_KEY_PATH = os.environ.get(
+    'DRYCC_CERT_KEY_PATH', '/etc/controller/cert/key')
+CERT_TLS_KEY_PATH = os.environ.get(
+    'DRYCC_CERT_TLS_KEY_PATH', '/etc/controller/cert/tls.key')
+CERT_TLS_CRT_PATH = os.environ.get(
+    'DRYCC_CERT_TLS_CRT_PATH', '/etc/controller/cert/tls.crt')
+if exists(CERT_KEY_PATH) and exists(CERT_TLS_KEY_PATH) and exists(CERT_TLS_CRT_PATH):
     bind = '0.0.0.0:8443'
-    keyfile = MUTATE_TLS_KEY_PATH
-    certfile = MUTATE_TLS_CRT_PATH
-    reload_extra_files = [MUTATE_KEY_PATH, MUTATE_TLS_KEY_PATH, MUTATE_TLS_CRT_PATH]
+    keyfile = CERT_TLS_KEY_PATH
+    certfile = CERT_TLS_CRT_PATH
+    reload_extra_files = [CERT_KEY_PATH, CERT_TLS_KEY_PATH, CERT_TLS_CRT_PATH]
 else:
     bind = '0.0.0.0:8000'
 
diff --git a/rootfs/requirements.txt b/rootfs/requirements.txt
index b5c07f8ca..0c80ff269 100644
--- a/rootfs/requirements.txt
+++ b/rootfs/requirements.txt
@@ -1,30 +1,25 @@
 # Drycc controller requirements
-inotify==0.2.10
+inotify==0.2.12
 backoff==2.2.1
-django==4.2.20
-channels==4.2.2
-aiohttp==3.11.16
-django-cors-headers==4.7.0
-django-guardian==2.4.0
-djangorestframework==3.16.0
+django==5.2.9
+channels==4.3.1
+aiohttp==3.13.3
+django-cors-headers==4.9.0
+djangorestframework==3.16.1
 docker==7.1.0
 gunicorn==23.0.0
-uvicorn[standard]==0.34.0
-asgiref==3.8.1
-idna==3.10
-jsonschema==4.23.0
+uvicorn[standard]==0.38.0
+asgiref==3.10.0
+idna==3.11
+jsonschema==4.25.1
 morph==0.1.5
-packaging==24.2
-pyasn1==0.6.1
-psycopg[binary]==3.2.6
-pyOpenSSL==25.0.0
-ndg-httpsclient==0.5.1
+packaging==25.0
+psycopg[binary]==3.2.12
 pytz==2025.2
-requests==2.32.3
+requests==2.33.0
 requests-toolbelt==1.0.0
-celery==5.5.0
-hiredis==3.1.0
-django_redis==5.4.0
-dj-database-url==2.3.0
-social-auth-app-django==5.4.3
-kubernetes==32.0.1
+celery[redis]==5.5.3
+hiredis==3.3.0
+dj-database-url==3.0.1
+social-auth-app-django==5.6.0
+kubernetes==34.1.0
diff --git a/rootfs/scheduler/__init__.py b/rootfs/scheduler/__init__.py
index 82aa8dadf..223364a42 100644
--- a/rootfs/scheduler/__init__.py
+++ b/rootfs/scheduler/__init__.py
@@ -8,7 +8,7 @@
 import re
 from urllib.parse import urljoin
 
-from api import __version__ as drycc_version
+from api import utils, __version__ as drycc_version
 from scheduler.exceptions import KubeException, KubeHTTPException
 
 
@@ -41,10 +41,11 @@ class KubeHTTPClient(object):
     DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
     resource_mapping = OrderedDict()
 
-    def __init__(self, url, k8s_api_verify_tls=True):
+    def __init__(self, url, k8s_api_verify_tls=True, metadata=None):
         self.url = url
         self.k8s_api_verify_tls = k8s_api_verify_tls
         self.session = get_k8s_session(self.k8s_api_verify_tls)
+        self.metadata = metadata
 
         # map the various k8s Resources to an internal property
         from scheduler.resources import Resource  # lazy load
@@ -140,7 +141,7 @@ def query_params(labels=None, fields=None, resource_version=None, pretty=False):
         return query
 
     @staticmethod
-    def log(namespace, message, level='INFO'):
+    def log(namespace, message, level=logging.INFO):
         """Logs a message in the context of this application.
 
         This prefixes log messages with a namespace "tag".
@@ -148,8 +149,8 @@ def log(namespace, message, level='INFO'):
         sort like releasing or scaling, will be considered as "belonging" to the application
         instead of the controller and will be handled accordingly.
         """
-        lvl = getattr(logging, level.upper()) if hasattr(logging, level.upper()) else logging.INFO
-        logger.log(lvl, "[{}]: {}".format(namespace, message))
+        utils.send_app_log(namespace, message, level)
+        logger.log(level, "[{}]: {}".format(namespace, message))
 
     def http_head(self, path, **kwargs):
         """
@@ -190,6 +191,8 @@ def http_post(self, path, data=None, json=None, **kwargs):
         """
         try:
             url = urljoin(self.url, path)
+            if self.metadata is not None and "metadata" in data:
+                data["metadata"] = utils.dict_merge(data["metadata"], self.metadata)
             response = self.session.post(url, data=data, json=json, **kwargs)
         except requests.exceptions.ConnectionError as err:
             # reraise as KubeException, but log stacktrace.
@@ -207,6 +210,8 @@ def http_put(self, path, data=None, **kwargs):
         """
         try:
             url = urljoin(self.url, path)
+            if self.metadata is not None and "metadata" in data:
+                data["metadata"] = utils.dict_merge(data["metadata"], self.metadata)
             response = self.session.put(url, data=data, **kwargs)
         except requests.exceptions.ConnectionError as err:
             # reraise as KubeException, but log stacktrace.
@@ -229,6 +234,8 @@ def http_patch(self, path, data=None, **kwargs):
             # application/merge-patch+json,
             # application/apply-patch+yaml
             # self.session.headers["Content-Type"] = "application/json-patch+json"
+            if self.metadata is not None and "metadata" in data:
+                data["metadata"] = utils.dict_merge(data["metadata"], self.metadata)
             response = self.session.patch(url, data=data, **kwargs)
         except requests.exceptions.ConnectionError as err:
             # reraise as KubeException, but log stacktrace.
diff --git a/rootfs/scheduler/mock.py b/rootfs/scheduler/mock.py
index 12e28a685..7d016475e 100644
--- a/rootfs/scheduler/mock.py
+++ b/rootfs/scheduler/mock.py
@@ -18,6 +18,8 @@
 from django.conf import settings
 from django.core.cache import cache
 
+from api import utils
+
 import logging
 logger = logging.getLogger(__name__)
 
@@ -84,6 +86,7 @@ def _acquire(self):
     'persistentvolumeclaims', 'serviceinstances', 'servicebindings',
     'limitranges', 'gateways', 'httproutes', 'tcproutes', 'udproutes',
     'issuers', 'certificates', 'certificaterequests', 'jobs', 'clusterserviceclasses',
+    'statefulsets', 'daemonsets',
 ]
 
 
@@ -902,7 +905,7 @@ def patch(request, context):  # noqa: C901
     data = request.json()
 
     # merge new data into old but keep labels separate in case they changed
-    if 'labels' in data['metadata']:
+    if 'labels' in data.get('metadata', {}):
         labels = data['metadata'].pop('labels')
         item['metadata'].update(data['metadata'])
         data['metadata'] = item['metadata']
@@ -935,6 +938,10 @@ def patch(request, context):  # noqa: C901
     elif resource_type in ['gateways']:
         data['status'] = item['status']
         cache.set(url, data, None)
+    elif resource_type in ['daemonsets', 'statefulsets']:
+        # item data merge-patch
+        data = utils.dict_merge(item, data)
+        cache.set(url, data, None)
     else:
         # Update the individual resource
         change_cache(url, resource_type, data)
@@ -1071,8 +1078,8 @@ def session():
 
 
 class MockSchedulerClient(KubeHTTPClient):
-    def __init__(self, url, k8s_api_verify_tls=True):
-        super().__init__(url)
+    def __init__(self, url, k8s_api_verify_tls=True, metadata=None):
+        super().__init__(url, k8s_api_verify_tls, metadata)
 
         # set version data
         cache.set('version', {'major': '1', 'minor': '3'}, None)
@@ -1093,20 +1100,27 @@ def __init__(self, url, k8s_api_verify_tls=True):
             self.secret.create('drycc', 'objectstorage-keyfile', secrets)
 
         try:
-            self.secret.get('drycc', 'registry-secret')
+            self.secret.get('drycc', 'controller-creds')
         except KubeHTTPException:
             secrets = {
-                'username': 'test',
-                'password': 'test',
-                'hostname': ''
+                'registry-host': '',
+                'registry-username': 'test',
+                'registry-password': 'test',
             }
-            self.secret.create('drycc', 'registry-secret', secrets)
+            self.secret.create('drycc', 'controller-creds', secrets)
 
         try:
             self.ns.get('duplicate')
         except KubeHTTPException:
             self.ns.create('duplicate')
-
+        # node
+        self._setup_node()
+        # daemonset
+        self._setup_daemonset()
+        # statefulset
+        self._setup_statefulset()
+
+    def _setup_node(self):
         try:
             self.node.get('172.17.8.100')
         except KubeHTTPException:
@@ -1183,12 +1197,146 @@ def __init__(self, url, k8s_api_verify_tls=True):
                     }
                 }
             }
-
             name = 'api_v1_nodes_172.17.8.100'
             cache.set(name, data, None)
             cache.set('api_v1_nodes', [name], None)
-        except Exception as e:
-            logger.critical(e)
+
+    def _setup_daemonset(self):
+        data = {
+            "apiVersion": "apps/v1",
+            "kind": "DaemonSet",
+            "metadata": {
+                "name": "drycc",
+                "namespace": "drycc",
+                "labels": {
+                    "app.kubernetes.io/managed-by": "Helm",
+                    "app.kubernetes.io/name": "drycc"
+                }
+            },
+            "spec": {
+                "selector": {
+                    "matchLabels": {
+                        "app.kubernetes.io/name": "drycc"
+                    }
+                },
+                "template": {
+                    "metadata": {
+                        "labels": {
+                            "app.kubernetes.io/managed-by": "Helm",
+                            "app.kubernetes.io/name": "drycc"
+                        }
+                    },
+                    "spec": {
+                        "affinity": {
+                            "nodeAffinity": {
+                                "requiredDuringSchedulingIgnoredDuringExecution": {
+                                    "nodeSelectorTerms": [
+                                        {
+                                            "matchExpressions": [{
+                                                "key": "kubernetes.io/hostname",
+                                                "operator": "In",
+                                                "values": [
+                                                    "server-01",
+                                                    "server-02",
+                                                    "server-03"
+                                                ]}
+                                            ]
+                                        }
+                                    ]
+                                }
+                            }
+                        },
+                        "containers": [{
+                            "image": "registry.drycc.cc/drycc/drycc",
+                            "imagePullPolicy": "IfNotPresent",
+                            "name": "drycc"
+                        }]
+                    }
+                }
+            },
+            "status": {
+                "currentNumberScheduled": 3,
+                "desiredNumberScheduled": 3,
+                "numberAvailable": 3,
+                "numberMisscheduled": 0,
+                "numberReady": 3,
+                "observedGeneration": 2,
+                "updatedNumberScheduled": 3
+            }
+        }
+        name = 'apis_apps_v1_namespaces_drycc_daemonsets_drycc'
+        cache.set(name, data, None)
+        cache.set('apis_apps_v1_namespaces_drycc_daemonsets', [name], None)
+
+    def _setup_statefulset(self):
+        data = {
+            "apiVersion": "apps/v1",
+            "kind": "StatefulSet",
+            "metadata": {
+                "labels": {
+                    "app.kubernetes.io/managed-by": "Helm",
+                    "app.kubernetes.io/name": "drycc"
+                },
+                "name": "drycc",
+                "namespace": "drycc"
+            },
+            "spec": {
+                "persistentVolumeClaimRetentionPolicy": {
+                    "whenDeleted": "Delete",
+                    "whenScaled": "Retain"
+                },
+                "selector": {
+                    "matchLabels": {
+                        "app.kubernetes.io/name": "drycc"
+                    }
+                },
+                "template": {
+                    "metadata": {
+                        "labels": {
+                            "app.kubernetes.io/managed-by": "Helm",
+                            "app.kubernetes.io/name": "drycc"
+                        }
+                    },
+                    "spec": {
+                            "containers": [{
+                                "image": "registry.drycc.cc/drycc/drycc",
+                                "imagePullPolicy": "IfNotPresent",
+                                "name": "drycc"
+                            }]
+                        }
+                    },
+                "volumeClaimTemplates": [{
+                    "apiVersion": "v1",
+                    "kind": "PersistentVolumeClaim",
+                    "metadata": {
+                        "name": "work-data"
+                    },
+                    "spec": {
+                        "accessModes": [
+                            "ReadWriteOnce"
+                        ],
+                        "resources": {
+                            "requests": {
+                                "storage": "1Gi"
+                            }
+                        },
+                        "volumeMode": "Filesystem"
+                    }
+                }]
+            },
+            "status": {
+                "availableReplicas": 2,
+                "collisionCount": 0,
+                "currentReplicas": 2,
+                "observedGeneration": 4,
+                "readyReplicas": 2,
+                "replicas": 2,
+                "updatedReplicas": 2
+            }
+        }
+        name = 'apis_apps_v1_namespaces_drycc_statefulsets_drycc'
+        cache.set(name, data, None)
+        cache.set('apis_apps_v1_namespaces_drycc_statefulsets', [name], None)
 
 
 scheduler.session = session()
diff --git a/rootfs/scheduler/resources/daemonset.py b/rootfs/scheduler/resources/daemonset.py
new file mode 100644
index 000000000..9d7dfbd33
--- /dev/null
+++ b/rootfs/scheduler/resources/daemonset.py
@@ -0,0 +1,45 @@
+import json
+import logging
+from scheduler.resources import Resource
+from scheduler.exceptions import KubeHTTPException
+
+
+class Daemonset(Resource):
+    api_prefix = 'apis'
+    api_version = 'apps/v1'
+
+    def get(self, namespace, name=None, ignore_exception=False, **kwargs):
+        """
+        Fetch a single Daemonsets or a list
+        """
+        url = '/namespaces/{}/daemonsets'
+        args = [namespace]
+        if name is not None:
+            args.append(name)
+            url += '/{}'
+            message = 'get Daemonset "{}" in Namespace "{}"'
+        else:
+            message = 'get Daemonsets 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
+
+    def patch(self, namespace, name, manifest, ignore_exception=False, **kwargs):
+        url = self.api("/namespaces/{}/daemonsets/{}", namespace, name)
+        response = self.http_patch(
+            url,
+            json=manifest,
+            headers={"Content-Type": "application/merge-patch+json"}
+        )
+
+        if self.unhealthy(response.status_code):
+            self.log(
+                namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)
+            if not ignore_exception:
+                raise KubeHTTPException(response, 'patch Daemonset "{}"', name)
+        return response
diff --git a/rootfs/scheduler/resources/deployment.py b/rootfs/scheduler/resources/deployment.py
index 1f9eeef49..59b0dca8e 100644
--- a/rootfs/scheduler/resources/deployment.py
+++ b/rootfs/scheduler/resources/deployment.py
@@ -1,6 +1,7 @@
-from datetime import datetime, timedelta, timezone
 import json
 import time
+import logging
+from datetime import datetime, timedelta, timezone
 from scheduler.resources import Resource
 from scheduler.exceptions import KubeException, KubeHTTPException
 
@@ -106,6 +107,7 @@ def manifest(self, namespace, name, image, command, args, spec_annotations, **kw
 
         # set the old deployment spec annotations on this deployment
         manifest['spec']['template']['metadata']['annotations'] = spec_annotations
+        manifest['spec']['template']['spec']['automountServiceAccountToken'] = False
         if annotations:
             manifest['spec']['template']['metadata']['annotations'].update(annotations)
         if "resource_version" in kwargs:
@@ -121,7 +123,8 @@ def create(self, namespace, name, image, command, args,
         url = self.api("/namespaces/{}/deployments", namespace)
         response = self.http_post(url, json=manifest)
         if self.unhealthy(response.status_code):
-            self.log(namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG')
+            self.log(
+                namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)
             if not ignore_exception:
                 raise KubeHTTPException(
                     response,
@@ -140,7 +143,8 @@ def update(self, namespace, name, image, command, args,
         url = self.api("/namespaces/{}/deployments/{}", namespace, name)
         response = self.http_put(url, json=manifest)
         if self.unhealthy(response.status_code):
-            self.log(namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG')
+            self.log(
+                namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)
             if not ignore_exception:
                 raise KubeHTTPException(response, 'update Deployment "{}"', name)
         else:
@@ -161,7 +165,8 @@ def patch(self, namespace, name, image, command, args,
         )
 
         if self.unhealthy(response.status_code):
-            self.log(namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG')
+            self.log(
+                namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)
             if not ignore_exception:
                 raise KubeHTTPException(response, 'patch Deployment "{}"', name)
         else:
@@ -245,17 +250,17 @@ def in_progress(self, namespace, name, timeout, batches, replicas, tags):
         Returns 2 booleans, first one is for if the Deployment is in progress or not, second
         one is or if a rollback action is advised while leaving the rollback up to the caller
         """
-        self.log(namespace, 'Checking if Deployment {} is in progress'.format(name), level='DEBUG')  # noqa
+        self.log(namespace, 'Checking if Deployment {} is in progress'.format(name), level=logging.DEBUG)  # noqa
         try:
             ready, _ = self.are_replicas_ready(namespace, name)
             if ready:
                 # nothing more to do - False since it is not in progress
-                self.log(namespace, 'All replicas for Deployment {} are ready'.format(name), level='DEBUG')  # noqa
+                self.log(namespace, 'All replicas for Deployment {} are ready'.format(name), level=logging.DEBUG)  # noqa
                 return False, False
         except KubeHTTPException as e:
             # Deployment doesn't exist
             if e.response.status_code == 404:
-                self.log(namespace, 'Deployment {} does not exist yet'.format(name), level='DEBUG')  # noqa
+                self.log(namespace, 'Deployment {} does not exist yet'.format(name), level=logging.DEBUG)  # noqa
                 return False, False
 
         # get deployment information
@@ -280,7 +285,7 @@ def in_progress(self, namespace, name, timeout, batches, replicas, tags):
                 namespace,
                 'Deployment {} had stalled due an error and will be rolled back. {}'.format(
                     name, str(e)),
-                level='DEBUG'
+                level=logging.DEBUG
             )
             return False, True
 
@@ -301,7 +306,7 @@ def in_progress(self, namespace, name, timeout, batches, replicas, tags):
                 namespace,
                 'Deploy operation for Deployment {} in has expired. '
                 'Rolling back to last good known release'.format(name),
-                level='DEBUG',
+                level=logging.DEBUG,
             )
             return False, True
 
@@ -348,7 +353,7 @@ def wait_until_updated(self, namespace, name):
         More information is also available at:
         https://github.com/kubernetes/kubernetes/blob/master/docs/devel/api-conventions.md#metadata
         """
-        self.log(namespace, "waiting for Deployment {} to get a newer generation (30s timeout)".format(name), 'DEBUG')  # noqa
+        self.log(namespace, "waiting for Deployment {} to get a newer generation (30s timeout)".format(name), logging.DEBUG)  # noqa
         for _ in range(30):
             try:
                 deploy = self.deployment.get(namespace, name).json()
@@ -356,7 +361,7 @@ def wait_until_updated(self, namespace, name):
                     'observedGeneration' in deploy['status'] and
                     deploy['status']['observedGeneration'] >= deploy['metadata']['generation']
                 ):
-                    self.log(namespace, "A newer generation was found for Deployment {}".format(name), 'DEBUG')  # noqa
+                    self.log(namespace, "A newer generation was found for Deployment {}".format(name), logging.DEBUG)  # noqa
                     break
 
                 time.sleep(1)
diff --git a/rootfs/scheduler/resources/gateway.py b/rootfs/scheduler/resources/gateway.py
index 931bd8c86..a51ff538a 100644
--- a/rootfs/scheduler/resources/gateway.py
+++ b/rootfs/scheduler/resources/gateway.py
@@ -59,8 +59,10 @@ def patch(self, namespace, name, ignore_exception=False, **kwargs):
             json=data,
             headers={"Content-Type": "application/merge-patch+json"}
         )
+        if response.status_code == 409:
+            return response
         if not ignore_exception and self.unhealthy(response.status_code):
-            raise KubeHTTPException(response, "put gateway {}".format(namespace))
+            raise KubeHTTPException(response, "patch gateway {}".format(namespace))
         return response
 
     def delete(self, namespace, name, ignore_exception=True):
@@ -128,7 +130,7 @@ def patch(self, namespace, name, ignore_exception=False, **kwargs):
             headers={"Content-Type": "application/merge-patch+json"}
         )
         if not ignore_exception and self.unhealthy(response.status_code):
-            raise KubeHTTPException(response, "put {} {}".format(self.kind.lower(), namespace))
+            raise KubeHTTPException(response, "patch {} {}".format(self.kind.lower(), namespace))
         return response
 
     def delete(self, namespace, name, ignore_exception=True):
diff --git a/rootfs/scheduler/resources/hpa.py b/rootfs/scheduler/resources/hpa.py
index f45d810f8..6523a0adf 100644
--- a/rootfs/scheduler/resources/hpa.py
+++ b/rootfs/scheduler/resources/hpa.py
@@ -1,4 +1,5 @@
 import json
+import logging
 from packaging.version import parse
 
 from scheduler.resources import Resource
@@ -93,7 +94,7 @@ def create(self, namespace, name, app_type, target, **kwargs):
         url = self.api("/namespaces/{}/horizontalpodautoscalers", namespace)
         response = self.http_post(url, json=manifest)
         if self.unhealthy(response.status_code):
-            self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG')  # noqa
+            self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)  # noqa
             raise KubeHTTPException(
                 response,
                 'create HorizontalPodAutoscaler "{}" in Namespace "{}"', name, namespace
@@ -111,7 +112,7 @@ def update(self, namespace, name, app_type, target, **kwargs):
         url = self.api("/namespaces/{}/horizontalpodautoscalers/{}", namespace, name)
         response = self.http_put(url, json=manifest)
         if self.unhealthy(response.status_code):
-            self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG')  # noqa
+            self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)  # noqa
             raise KubeHTTPException(response, 'update HorizontalPodAutoscaler "{}"', name)
 
         # optionally wait for HPA if requested
diff --git a/rootfs/scheduler/resources/pod.py b/rootfs/scheduler/resources/pod.py
index 13af05245..94de5cd80 100644
--- a/rootfs/scheduler/resources/pod.py
+++ b/rootfs/scheduler/resources/pod.py
@@ -1,4 +1,3 @@
-from collections import defaultdict
 from datetime import datetime, timedelta, timezone
 import operator
 import os
@@ -169,7 +168,7 @@ def manifest(self, namespace, name, image, **kwargs):
         spec['nodeSelector'].update(kwargs.get('tags', {}))
 
         # How long until a pod is forcefully terminated. 30 is kubernetes default
-        spec['terminationGracePeriodSeconds'] = self._get_termination_grace_period(kwargs)  # noqa
+        spec['terminationGracePeriodSeconds'] = kwargs.get('termination_grace_period_seconds', 30)
         # set pod volumes
         spec['volumes'] = kwargs.get('volumes', [])
         # create the base container
@@ -236,8 +235,8 @@ def _set_container(self, namespace, container_name, data, **kwargs):
         self._set_container_default_env(data)
         # list sorted by dict key name
         data['env'].sort(key=operator.itemgetter('name'))
+        data['lifecycle'] = kwargs.get('lifecycle', {})
         self._set_health_checks(data, env, **kwargs)
-        self._set_lifecycle_hooks(data, env, **kwargs)
 
     def _set_container_default_env(self, data):
         # set fields env
@@ -258,15 +257,6 @@ def _set_container_default_env(self, data):
             data["env"].append({"name": "DRYCC_DEBUG", "value": "1"})
         return data
 
-    @staticmethod
-    def _get_termination_grace_period(kwargs):
-        """ return termination grace period """
-        app_type = kwargs.get("app_type")
-        timeout_global = kwargs.get('pod_termination_grace_period_seconds', 30)
-        timeout_local = kwargs.get("pod_termination_grace_period_each", {}).get(app_type)
-
-        return timeout_global if timeout_local is None else int(timeout_local)
-
     @staticmethod
     def _get_memory(size):
         """ Format memory limit value """
@@ -295,38 +285,6 @@ def _set_health_checks(self, container, env, **kwargs):
             container.update(
                 self._default_container_readiness_probe(env.get('PORT', DEFAULT_CONTAINER_PORT)))
 
-    @staticmethod
-    def _set_lifecycle_hooks(container, env, **kwargs):
-        app_type = kwargs.get("app_type")
-        lifecycle_post_start = kwargs.get('lifecycle_post_start', {})
-        lifecycle_post_start = lifecycle_post_start.get(app_type)
-        lifecycle_pre_stop = kwargs.get('lifecycle_pre_stop', {})
-        lifecycle_pre_stop = lifecycle_pre_stop.get(app_type)
-        if lifecycle_post_start or lifecycle_pre_stop:
-            lifecycle = defaultdict(dict)
-
-            if lifecycle_post_start:
-                lifecycle["postStart"] = {
-                        'exec': {
-                            "command": [
-                                "/bin/bash",
-                                "-c",
-                                "{0}".format(lifecycle_post_start)
-                            ]
-                        }
-                }
-            if lifecycle_pre_stop:
-                lifecycle["preStop"] = {
-                        'exec': {
-                            "command": [
-                                "/bin/bash",
-                                "-c",
-                                "{0}".format(lifecycle_pre_stop)
-                            ]
-                        }
-                }
-            container["lifecycle"] = dict(lifecycle)
-
     @staticmethod
     def _default_container_readiness_probe(port, delay=5, timeout=5, period_seconds=5,
                                            success_threshold=1, failure_threshold=1):
diff --git a/rootfs/scheduler/resources/pv.py b/rootfs/scheduler/resources/pv.py
index afb8d8b10..56f6a9e27 100644
--- a/rootfs/scheduler/resources/pv.py
+++ b/rootfs/scheduler/resources/pv.py
@@ -1,4 +1,6 @@
 import json
+import logging
+from api import utils
 from scheduler.resources import Resource
 from scheduler.exceptions import KubeHTTPException
 
@@ -18,7 +20,7 @@ def manifest(self, name, version=None, **kwargs):
                 'labels': labels
             },
         }
-        data.update(kwargs)
+        data = utils.dict_merge(data, kwargs)
         if version:
             data["metadata"]["resourceVersion"] = version
         return data
@@ -57,7 +59,7 @@ def patch(self, name, **kwargs):
         data = self.manifest(name, **kwargs)
         response = self.http_patch(url, json=data, headers={"Content-Type": "application/merge-patch+json"})  # noqa
         if self.unhealthy(response.status_code):
-            self.log('template used: {}'.format(json.dumps(data, indent=4)), 'DEBUG')
+            self.log('template used: {}'.format(json.dumps(data, indent=4)), logging.DEBUG)
             raise KubeHTTPException(response, 'update persistentvolume "{}"', name)
         return response
 
diff --git a/rootfs/scheduler/resources/pvc.py b/rootfs/scheduler/resources/pvc.py
index 5ce7df202..8582f2016 100644
--- a/rootfs/scheduler/resources/pvc.py
+++ b/rootfs/scheduler/resources/pvc.py
@@ -1,4 +1,6 @@
 import json
+import logging
+from api import utils
 from scheduler.resources import Resource
 from scheduler.exceptions import KubeHTTPException
 
@@ -19,7 +21,7 @@ def manifest(self, namespace, name, version=None, **kwargs):
                 'labels': labels
             }
         }
-        data.update(kwargs)
+        data = utils.dict_merge(data, kwargs)
         if version:
             data["metadata"]["resourceVersion"] = version
         return data
@@ -59,7 +61,7 @@ def patch(self, namespace, name, **kwargs):
         data = self.manifest(namespace, name, **kwargs)
         response = self.http_patch(url, json=data, headers={"Content-Type": "application/merge-patch+json"})  # noqa
         if self.unhealthy(response.status_code):
-            self.log(namespace, 'template used: {}'.format(json.dumps(data, indent=4)), 'DEBUG')  # noqa
+            self.log(namespace, 'template used: {}'.format(json.dumps(data, indent=4)), logging.DEBUG)  # noqa
             raise KubeHTTPException(response, 'update persistentvolumeclaims "{}"', name)
         return response
 
diff --git a/rootfs/scheduler/resources/rc.py b/rootfs/scheduler/resources/rc.py
index cfdf9f12f..f5d1fce56 100644
--- a/rootfs/scheduler/resources/rc.py
+++ b/rootfs/scheduler/resources/rc.py
@@ -1,5 +1,6 @@
 import json
 import time
+import logging
 from scheduler.exceptions import KubeHTTPException
 from scheduler.resources import Resource
 
@@ -56,7 +57,8 @@ def create(self, namespace, name, image, command, args, **kwargs):
         url = self.api("/namespaces/{}/replicationcontrollers", namespace)
         resp = self.http_post(url, json=manifest)
         if self.unhealthy(resp.status_code):
-            self.log(namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG')
+            self.log(
+                namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)
             raise KubeHTTPException(
                 resp,
                 'create ReplicationController "{}" in Namespace "{}"', name, namespace
@@ -114,7 +116,7 @@ def wait_until_updated(self, namespace, name):
         More information is also available at:
         https://github.com/kubernetes/kubernetes/blob/master/docs/devel/api-conventions.md#metadata
         """
-        self.log(namespace, "waiting for ReplicationController {} to get a newer generation (30s timeout)".format(name), 'DEBUG')  # noqa
+        self.log(namespace, "waiting for ReplicationController {} to get a newer generation (30s timeout)".format(name), logging.DEBUG)  # noqa
         for _ in range(30):
             try:
                 rc = self.get(namespace, name).json()
@@ -122,7 +124,7 @@ def wait_until_updated(self, namespace, name):
                     "observedGeneration" in rc["status"] and
                     rc["status"]["observedGeneration"] >= rc["metadata"]["generation"]
                 ):
-                    self.log(namespace, "ReplicationController {} got a newer generation (30s timeout)".format(name), 'DEBUG')  # noqa
+                    self.log(namespace, "ReplicationController {} got a newer generation (30s timeout)".format(name), logging.DEBUG)  # noqa
                     break
 
                 time.sleep(1)
diff --git a/rootfs/scheduler/resources/rs.py b/rootfs/scheduler/resources/rs.py
index bde61d188..289c26949 100644
--- a/rootfs/scheduler/resources/rs.py
+++ b/rootfs/scheduler/resources/rs.py
@@ -1,4 +1,5 @@
 import time
+import logging
 from scheduler.exceptions import KubeHTTPException
 from scheduler.resources import Resource
 
@@ -69,7 +70,7 @@ def wait_until_updated(self, namespace, name):
         More information is also available at:
         https://github.com/kubernetes/kubernetes/blob/master/docs/devel/api-conventions.md#metadata
         """
-        self.log(namespace, "waiting for ReplicationController {} to get a newer generation (30s timeout)".format(name), 'DEBUG')  # noqa
+        self.log(namespace, "waiting for ReplicationController {} to get a newer generation (30s timeout)".format(name), logging.DEBUG)  # noqa
         for _ in range(30):
             try:
                 rs = self.get(namespace, name).json()
@@ -77,7 +78,7 @@ def wait_until_updated(self, namespace, name):
                     "observedGeneration" in rs["status"] and
                     rs["status"]["observedGeneration"] >= rs["metadata"]["generation"]
                 ):
-                    self.log(namespace, "ReplicationController {} got a newer generation (30s timeout)".format(name), 'DEBUG')  # noqa
+                    self.log(namespace, "ReplicationController {} got a newer generation (30s timeout)".format(name), logging.DEBUG)  # noqa
                     break
 
                 time.sleep(1)
diff --git a/rootfs/scheduler/resources/scale.py b/rootfs/scheduler/resources/scale.py
index 43f72eb4c..fd5babcec 100644
--- a/rootfs/scheduler/resources/scale.py
+++ b/rootfs/scheduler/resources/scale.py
@@ -1,4 +1,5 @@
 import json
+import logging
 from scheduler.resources import Resource
 from scheduler.exceptions import KubeHTTPException
 
@@ -32,7 +33,7 @@ def update(self, namespace, name, replicas, target):
         url = self.api("/namespaces/{}/{}/{}/scale", namespace, resource_type, name)
         response = self.http_put(url, json=manifest)
         if self.unhealthy(response.status_code):
-            self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), 'DEBUG')  # noqa
+            self.log(namespace, 'template used: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)  # noqa
             raise KubeHTTPException(
                 response,
                 'scale {} "{}" in Namespace "{}"', target['kind'], name, namespace
diff --git a/rootfs/scheduler/resources/statefulset.py b/rootfs/scheduler/resources/statefulset.py
new file mode 100644
index 000000000..e7b0cff8d
--- /dev/null
+++ b/rootfs/scheduler/resources/statefulset.py
@@ -0,0 +1,45 @@
+import json
+import logging
+from scheduler.resources import Resource
+from scheduler.exceptions import KubeHTTPException
+
+
+class Statefulset(Resource):
+    api_prefix = 'apis'
+    api_version = 'apps/v1'
+
+    def get(self, namespace, name=None, ignore_exception=False, **kwargs):
+        """
+        Fetch a single Statefulsets or a list
+        """
+        url = '/namespaces/{}/statefulsets'
+        args = [namespace]
+        if name is not None:
+            args.append(name)
+            url += '/{}'
+            message = 'get Statefulset "{}" in Namespace "{}"'
+        else:
+            message = 'get Statefulsets 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
+
+    def patch(self, namespace, name, manifest, ignore_exception=False, **kwargs):
+        url = self.api("/namespaces/{}/statefulsets/{}", namespace, name)
+        response = self.http_patch(
+            url,
+            json=manifest,
+            headers={"Content-Type": "application/merge-patch+json"}
+        )
+
+        if self.unhealthy(response.status_code):
+            self.log(
+                namespace, 'template: {}'.format(json.dumps(manifest, indent=4)), logging.DEBUG)
+            if not ignore_exception:
+                raise KubeHTTPException(response, 'patch Statefulset "{}"', name)
+        return response
diff --git a/rootfs/scheduler/tests/__init__.py b/rootfs/scheduler/tests/__init__.py
index 4e131397d..831c7c0f9 100644
--- a/rootfs/scheduler/tests/__init__.py
+++ b/rootfs/scheduler/tests/__init__.py
@@ -3,7 +3,7 @@
 from django.conf import settings
 
 from scheduler import mock
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 class TestCase(DjangoTestCase):
diff --git a/rootfs/scheduler/tests/test_daemonsets.py b/rootfs/scheduler/tests/test_daemonsets.py
new file mode 100644
index 000000000..f4c8ab985
--- /dev/null
+++ b/rootfs/scheduler/tests/test_daemonsets.py
@@ -0,0 +1,72 @@
+"""
+Unit tests for the Drycc scheduler module.
+
+Run the tests with "./manage.py test scheduler"
+"""
+from scheduler.tests import TestCase
+from scheduler import KubeHTTPException
+
+
+class DaemonsetsTest(TestCase):
+    """Tests scheduler daemonset calls"""
+
+    def test_get_daemonsets(self):
+        response = self.scheduler.daemonset.get('drycc')
+        data = response.json()
+        self.assertEqual(response.status_code, 200, data)
+        self.assertIn('items', data)
+        self.assertEqual(1, len(data['items']))
+        # simple verify of data
+        self.assertEqual(data['items'][0]['metadata']['name'], 'drycc')
+
+    def test_get_daemonset(self):
+        with self.assertRaises(
+            KubeHTTPException,
+            msg='failed to get Daemonset doesnotexist in Namespace drycc: 404 Not Found'  # noqa
+        ):
+            self.scheduler.daemonset.get('drycc', 'doesnotexist')
+        namespace = 'drycc'
+        name = 'drycc'
+        response = self.scheduler.daemonset.get(namespace, name)
+        data = response.json()
+        self.assertEqual(response.status_code, 200, data)
+        self.assertEqual(data['apiVersion'], 'apps/v1')
+        self.assertEqual(data['kind'], 'DaemonSet')
+        self.assertEqual(data['metadata']['name'], name)
+
+    def test_patch_daemonset(self):
+        namespace = 'drycc'
+        name = 'drycc'
+        manifest_affinity = {
+            "nodeAffinity": {
+                "requiredDuringSchedulingIgnoredDuringExecution": {
+                    "nodeSelectorTerms": [{
+                        "matchExpressions": [{
+                            "key": "kubernetes.io/hostname",
+                            "operator": "In",
+                            "values": [
+                                "nohostname"
+                            ]
+                        }]
+                    }]
+                }
+            }
+        }
+        response = self.scheduler.daemonset.patch(
+            namespace,
+            name,
+            manifest={
+                "spec": {
+                    "template": {
+                        "spec": {
+                            "affinity": manifest_affinity
+                        }
+                    }
+                }
+            },
+        )
+        affinity = response.json()["spec"]["template"]['spec']['affinity']
+        self.assertEqual(
+            affinity,
+            manifest_affinity
+        )
diff --git a/rootfs/scheduler/tests/test_deployments.py b/rootfs/scheduler/tests/test_deployments.py
index c465f9835..636e1ff64 100644
--- a/rootfs/scheduler/tests/test_deployments.py
+++ b/rootfs/scheduler/tests/test_deployments.py
@@ -8,7 +8,7 @@
 from packaging.version import parse, Version, InvalidVersion
 from scheduler import KubeHTTPException, KubeException
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 class DeploymentsTest(TestCase):
@@ -24,7 +24,7 @@ def create(self, namespace=None, name=generate_random_name(), **kwargs):
             'app_type': kwargs.get('app_type', 'web'),
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 4),
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
@@ -45,7 +45,7 @@ def update(self, namespace=None, name=generate_random_name(), **kwargs):
             'app_type': kwargs.get('app_type', 'web'),
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 4),
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
@@ -67,7 +67,7 @@ def scale(self, namespace=None, name=generate_random_name(), **kwargs):
             'app_type': kwargs.get('app_type', 'web'),
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 4),
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
diff --git a/rootfs/scheduler/tests/test_hpa.py b/rootfs/scheduler/tests/test_hpa.py
index c5a7aa673..832d98e46 100644
--- a/rootfs/scheduler/tests/test_hpa.py
+++ b/rootfs/scheduler/tests/test_hpa.py
@@ -5,7 +5,7 @@
 """
 from scheduler import KubeHTTPException, KubeException
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 class HorizontalPodAutoscalersTest(TestCase):
@@ -23,7 +23,7 @@ def create(self, namespace=None, name=generate_random_name(), **kwargs):
             'app_type': kwargs.get('app_type', 'web'),
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 1),
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
@@ -72,7 +72,7 @@ def update_deployment(self, namespace=None, name=generate_random_name(), **kwarg
             'app_type': kwargs.get('app_type', 'web'),
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 4),
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
diff --git a/rootfs/scheduler/tests/test_hpa_12_lower.py b/rootfs/scheduler/tests/test_hpa_12_lower.py
index 66ac790ce..5cbca133e 100644
--- a/rootfs/scheduler/tests/test_hpa_12_lower.py
+++ b/rootfs/scheduler/tests/test_hpa_12_lower.py
@@ -8,7 +8,7 @@
 
 from scheduler import KubeHTTPException, KubeException
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 @mock.patch('scheduler.KubeHTTPClient.version', lambda *args: Version('1.2'))
@@ -27,7 +27,7 @@ def create(self, namespace=None, name=generate_random_name(), **kwargs):
             'app_type': kwargs.get('app_type', 'web'),
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 1),
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
@@ -76,7 +76,7 @@ def update_deployment(self, namespace=None, name=generate_random_name(), **kwarg
             'app_type': kwargs.get('app_type', 'web'),
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 4),
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
diff --git a/rootfs/scheduler/tests/test_job.py b/rootfs/scheduler/tests/test_job.py
index 93b3cf10f..b1a25f5cb 100644
--- a/rootfs/scheduler/tests/test_job.py
+++ b/rootfs/scheduler/tests/test_job.py
@@ -4,7 +4,7 @@
 Run the tests with './manage.py test scheduler'
 """
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 class JobTest(TestCase):
diff --git a/rootfs/scheduler/tests/test_pods.py b/rootfs/scheduler/tests/test_pods.py
index 133f04669..b1373d6dd 100644
--- a/rootfs/scheduler/tests/test_pods.py
+++ b/rootfs/scheduler/tests/test_pods.py
@@ -8,7 +8,7 @@
 from datetime import datetime, timedelta, timezone
 from scheduler import KubeHTTPException
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 from scheduler.states import PodState
 
 
@@ -25,7 +25,7 @@ def create(self, namespace=None, name=generate_random_name(), **kwargs):
             'app_type': kwargs.get('app_type', 'web'),
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 4),
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
diff --git a/rootfs/scheduler/tests/test_pvc.py b/rootfs/scheduler/tests/test_pvc.py
index 64a0ce81d..438b90c15 100644
--- a/rootfs/scheduler/tests/test_pvc.py
+++ b/rootfs/scheduler/tests/test_pvc.py
@@ -5,7 +5,7 @@
 """
 from scheduler import KubeHTTPException
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 class PVCTest(TestCase):
diff --git a/rootfs/scheduler/tests/test_rc.py b/rootfs/scheduler/tests/test_rc.py
index 02127a418..871f903b5 100644
--- a/rootfs/scheduler/tests/test_rc.py
+++ b/rootfs/scheduler/tests/test_rc.py
@@ -5,7 +5,7 @@
 """
 from scheduler import KubeHTTPException
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 class ReplicationControllersTest(TestCase):
@@ -21,7 +21,7 @@ def create(self, namespace=None, name=generate_random_name(), **kwargs):
             'app_type': kwargs.get('app_type', 'web'),
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 4),
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
@@ -43,7 +43,7 @@ def scale_rc(self, namespace=None, name=generate_random_name(), **kwargs):
             'version': kwargs.get('version', 'v99'),
             'replicas': kwargs.get('replicas', 4),
             'deploy_timeout': 120,
-            'pod_termination_grace_period_seconds': 2,
+            'termination_grace_period_seconds': 2,
             'image': 'quay.io/fake/image',
             'command': 'sh',
             'args': 'start',
diff --git a/rootfs/scheduler/tests/test_secrets.py b/rootfs/scheduler/tests/test_secrets.py
index 668c0adc1..8e000420a 100644
--- a/rootfs/scheduler/tests/test_secrets.py
+++ b/rootfs/scheduler/tests/test_secrets.py
@@ -5,7 +5,7 @@
 """
 from scheduler import KubeHTTPException, KubeException
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 class SecretsTest(TestCase):
diff --git a/rootfs/scheduler/tests/test_services.py b/rootfs/scheduler/tests/test_services.py
index f9a739fbd..dd9ffe101 100644
--- a/rootfs/scheduler/tests/test_services.py
+++ b/rootfs/scheduler/tests/test_services.py
@@ -5,7 +5,7 @@
 """
 from scheduler import KubeHTTPException
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 class ServicesTest(TestCase):
diff --git a/rootfs/scheduler/tests/test_statefulsets.py b/rootfs/scheduler/tests/test_statefulsets.py
new file mode 100644
index 000000000..292b017a0
--- /dev/null
+++ b/rootfs/scheduler/tests/test_statefulsets.py
@@ -0,0 +1,57 @@
+"""
+Unit tests for the Drycc scheduler module.
+
+Run the tests with "./manage.py test scheduler"
+"""
+from scheduler.tests import TestCase
+from scheduler import KubeHTTPException
+
+
+class StatefulsetsTest(TestCase):
+    """Tests scheduler statefulset calls"""
+
+    def test_get_statefulsets(self):
+        response = self.scheduler.statefulset.get('drycc')
+        data = response.json()
+        self.assertEqual(response.status_code, 200, data)
+        self.assertIn('items', data)
+        self.assertEqual(1, len(data['items']))
+        # simple verify of data
+        self.assertEqual(data['items'][0]['metadata']['name'], 'drycc')
+
+    def test_get_statefulset(self):
+        with self.assertRaises(
+            KubeHTTPException,
+            msg='failed to get Statefulset doesnotexist in Namespace drycc: 404 Not Found'  # noqa
+        ):
+            self.scheduler.statefulset.get('drycc', 'doesnotexist')
+        namespace = 'drycc'
+        name = 'drycc'
+        response = self.scheduler.statefulset.get(namespace, name)
+        data = response.json()
+        self.assertEqual(response.status_code, 200, data)
+        self.assertEqual(data['apiVersion'], 'apps/v1')
+        self.assertEqual(data['kind'], 'StatefulSet')
+        self.assertEqual(data['metadata']['name'], name)
+
+    def test_patch_statefulset(self):
+        namespace = 'drycc'
+        name = 'drycc'
+        manifest = {
+            'spec': {
+                'persistentVolumeClaimRetentionPolicy': {
+                    'whenScaled': 'Retain'
+                },
+                'replicas': 0
+            }
+        }
+        response = self.scheduler.statefulset.patch(
+            namespace,
+            name,
+            manifest=manifest,
+        )
+        when_scaled = response.json()["spec"]["persistentVolumeClaimRetentionPolicy"]['whenScaled']
+        self.assertEqual(
+            when_scaled,
+            'Retain'
+        )
diff --git a/rootfs/scheduler/tests/test_svcat.py b/rootfs/scheduler/tests/test_svcat.py
index 917fd357a..d61cdcd0f 100644
--- a/rootfs/scheduler/tests/test_svcat.py
+++ b/rootfs/scheduler/tests/test_svcat.py
@@ -5,7 +5,7 @@
 """
 from scheduler import KubeHTTPException
 from scheduler.tests import TestCase
-from scheduler.utils import generate_random_name
+from scheduler.tests.utils import generate_random_name
 
 
 class ServiceCatalogTest(TestCase):
diff --git a/rootfs/scheduler/tests/test_utils.py b/rootfs/scheduler/tests/test_utils.py
index 4b53eb013..cc965e7f9 100644
--- a/rootfs/scheduler/tests/test_utils.py
+++ b/rootfs/scheduler/tests/test_utils.py
@@ -1,5 +1,5 @@
 import unittest
-from scheduler import utils
+from api import utils
 
 
 class TestUtils(unittest.TestCase):
diff --git a/rootfs/scheduler/utils.py b/rootfs/scheduler/tests/utils.py
similarity index 80%
rename from rootfs/scheduler/utils.py
rename to rootfs/scheduler/tests/utils.py
index ed0f273f3..90cf6fdf8 100644
--- a/rootfs/scheduler/utils.py
+++ b/rootfs/scheduler/tests/utils.py
@@ -1,4 +1,3 @@
-from copy import deepcopy
 import random
 
 
@@ -60,34 +59,3 @@ def generate_random_name():
     ]
     return "{}-{}".format(
         random.choice(adjectives), random.choice(nouns))
-
-
-def dict_merge(origin, merge):
-    """
-    Recursively merges dict's. not just simple a["key"] = b["key"], if
-    both a and b have a key who's value is a dict then dict_merge is called
-    on both values and the result stored in the returned dictionary.
-    Also handles merging lists if they occur within the dict
-    """
-    if not isinstance(merge, dict):
-        return merge
-
-    result = deepcopy(origin)
-    for key, value in merge.items():
-        if key in result and isinstance(result[key], dict):
-            result[key] = dict_merge(result[key], value)
-        else:
-            if isinstance(value, list):
-                if key not in result:
-                    result[key] = value
-                else:
-                    # merge lists without leaving potential duplicates
-                    # result[key] = list(set(result[key] + value))  # changes the order as well
-                    for item in value:
-                        if item in result[key]:
-                            continue
-
-                        result[key].append(item)
-            else:
-                result[key] = deepcopy(value)
-    return result
